diff --git a/apps/kimi-code/src/cli/v2/run-v2-print.ts b/apps/kimi-code/src/cli/v2/run-v2-print.ts index 72ed2e9443f..d11d3259263 100644 --- a/apps/kimi-code/src/cli/v2/run-v2-print.ts +++ b/apps/kimi-code/src/cli/v2/run-v2-print.ts @@ -78,7 +78,7 @@ import type { ThinkingDelta, ToolCallDelta, } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import type { HookResult } from '@moonshot-ai/agent-core-v2/features/externalHooks/agent/agentExternalHooksService'; import type { ToolCallStarted, diff --git a/apps/kimi-inspect/src/components/ModelCatalogView.tsx b/apps/kimi-inspect/src/components/ModelCatalogView.tsx index e0d1f2dd103..3b395e0db25 100644 --- a/apps/kimi-inspect/src/components/ModelCatalogView.tsx +++ b/apps/kimi-inspect/src/components/ModelCatalogView.tsx @@ -19,15 +19,15 @@ import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { ISessionManager } from '@moonshot-ai/agent-core-v2/app/sessionManager/sessionManager'; -import type { InspectionSource } from '@moonshot-ai/agent-core-v2/kosong/contract/inspection'; -import type { TokenUsage } from '@moonshot-ai/agent-core-v2/kosong/contract/usage'; +import type { TokenUsage } from '@moonshot-ai/agent-core-v2/human/llm/usage'; +import type { InspectionSource } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/inspection'; import { IModelCatalog, type ModelCatalogItem, type ModelPingResult, type ProviderCatalogItem, -} from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; -import { IModelService } from '@moonshot-ai/agent-core-v2/kosong/model/model'; +} from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; +import { IModelService } from '@moonshot-ai/agent-core-v2/llm-adapter/model/model'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; diff --git a/apps/kimi-inspect/src/components/Sidebar.tsx b/apps/kimi-inspect/src/components/Sidebar.tsx index b46acbcd4d3..be5123bf18f 100644 --- a/apps/kimi-inspect/src/components/Sidebar.tsx +++ b/apps/kimi-inspect/src/components/Sidebar.tsx @@ -20,7 +20,7 @@ import { IWorkspaceService, type Workspace, } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; -import { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; +import { IModelCatalog } from '@moonshot-ai/agent-core-v2/llm-adapter/model/catalog'; import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMemo, useState } from 'react'; diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index c2c298dc12c..b01ff283519 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -28,7 +28,7 @@ import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/tool import { IAuthSummaryService } from '@moonshot-ai/agent-core-v2/app/auth/auth'; import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; -import { IProviderService } from '@moonshot-ai/agent-core-v2/kosong/provider/provider'; +import { IProviderService } from '@moonshot-ai/agent-core-v2/llm-adapter/provider/provider'; import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; import { ISessionInitService } from '@moonshot-ai/agent-core-v2/features/sessionInit/sessionInit'; diff --git a/apps/kimi-inspect/tsconfig.json b/apps/kimi-inspect/tsconfig.json index 53d7b02ab44..d9490032a81 100644 --- a/apps/kimi-inspect/tsconfig.json +++ b/apps/kimi-inspect/tsconfig.json @@ -17,5 +17,6 @@ "vite", "vite.config.ts", "../../packages/agent-core-v2/src" - ] + ], + "exclude": ["../../packages/agent-core-v2/src/human"] } diff --git a/apps/vis/server/src/lib/agent-record-types.ts b/apps/vis/server/src/lib/agent-record-types.ts index 0cce49b3933..75788f23e35 100644 --- a/apps/vis/server/src/lib/agent-record-types.ts +++ b/apps/vis/server/src/lib/agent-record-types.ts @@ -74,7 +74,7 @@ import type { } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextEvents'; import type { TurnCancel, TurnEnded, TurnPrompt, TurnSteer } from '@moonshot-ai/agent-core-v2/agent/loop/turnOps'; import type { TurnStepInterrupted } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; -import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/stepRetry/stepRetryService'; +import type { TurnStepRetrying } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; import type { UsageRecord } from '@moonshot-ai/agent-core-v2/agent/usage/usageOps'; import type { ConfigUpdate, diff --git a/apps/vis/server/src/lib/context-projector.ts b/apps/vis/server/src/lib/context-projector.ts index 8ba926295d8..af7b01216be 100644 --- a/apps/vis/server/src/lib/context-projector.ts +++ b/apps/vis/server/src/lib/context-projector.ts @@ -637,7 +637,7 @@ const MICRO_MIN_CONTENT_TOKENS = 100; /** Replicates the engine's per-char token weighting exactly, over the same * `text` + `think` parts its gate counts. The engine - * (`packages/agent-core-v2/src/kosong/contract/tokens.ts`) sums per-part + * (`packages/agent-core-v2/src/llm-adapter/contract/tokens.ts`) sums per-part * estimates, each * `estimateTokens(s) = Math.ceil(asciiCount / 4) + nonAsciiCount` (ASCII ~4 * chars/token, every non-ASCII/CJK code point a full token); other part types diff --git a/apps/vis/server/test/lib/context-projector.test.ts b/apps/vis/server/test/lib/context-projector.test.ts index e5832b96b36..19ce3646bb5 100644 --- a/apps/vis/server/test/lib/context-projector.test.ts +++ b/apps/vis/server/test/lib/context-projector.test.ts @@ -1,6 +1,6 @@ // apps/vis/server/test/lib/context-projector.test.ts import { describe, it, expect, afterEach } from 'vitest'; -import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/kosong/contract/tokens'; +import { estimateTokensForMessages } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/tokens'; import { buildSessionFixture } from '../fixtures/build'; import { projectContext } from '../../src/lib/context-projector'; import { readAgentWire } from '../../src/lib/wire-reader'; diff --git a/flake.nix b/flake.nix index 9ff9de15623..2453a8e384a 100644 --- a/flake.nix +++ b/flake.nix @@ -160,7 +160,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-Fi5tYo53mOO6HV2melJ2OP2xtmOTP9mEnyOA6L70IY8="; + hash = "sha256-y7bnf/mLJ6oDYF6Pcf7Tws5kAzFD7Tkl8AQBYyksaRE="; }; nativeBuildInputs = [ diff --git a/packages/acp-server/test/_helpers/scriptedProvider.ts b/packages/acp-server/test/_helpers/scriptedProvider.ts index cb25e061034..f77d6e8fd5a 100644 --- a/packages/acp-server/test/_helpers/scriptedProvider.ts +++ b/packages/acp-server/test/_helpers/scriptedProvider.ts @@ -19,16 +19,19 @@ */ import { - type FinishReason, IProtocolAdapterRegistry, type IProtocolAdapterRegistry as IProtocolAdapterRegistryType, type Message, + type Model, ProtocolAdapterRegistry, type ProtocolAdapterConfig, type StreamedMessagePart, type TokenUsage, type Tool, } from '@moonshot-ai/agent-core-v2'; +import type { FinishReason } from '@moonshot-ai/agent-core-v2/human/llm/finish-reason'; +import { fromLlmMessage } from '@moonshot-ai/agent-core-v2/llm-adapter/contract/message'; +import type { LlmRequester } from '@moonshot-ai/agent-core-v2/human/llm/requester/requester'; interface ScriptedResponse { readonly parts: readonly StreamedMessagePart[]; @@ -137,11 +140,47 @@ export function createScriptedProvider(): ScriptedProvider { // Single shared provider so every ModelImpl in the process (main agent, // sub-agents) draws from the same FIFO queue. const provider = new ScriptedChatProvider(queue, calls); - // Identity/capability resolution delegates to the real registry (the + const requester: LlmRequester = { + async generate(config, content, control) { + control.onEvent?.({ type: 'llm.sent' }); + try { + const stream = await provider.generate( + config.systemPrompt ?? '', + [...(config.tools ?? [])], + content.messages.map(fromLlmMessage), + { signal: control.signal }, + ); + for await (const part of stream) { + control.onEvent?.({ type: 'llm.delta', part }); + control.signal.throwIfAborted(); + } + control.onEvent?.({ type: 'llm.usage', usage: stream.usage ?? ZERO_USAGE }); + control.onEvent?.({ + type: 'llm.finish', + finish: { + finishReason: stream.finishReason, + rawFinishReason: stream.rawFinishReason, + }, + }); + if (stream.id !== null) { + control.onEvent?.({ type: 'llm.message-id', messageId: stream.id }); + } + control.onEvent?.({ type: 'llm.done' }); + } catch (error) { + control.onEvent?.({ + type: 'llm.failed.remote', + error: { + kind: 'unknown', + message: error instanceof Error ? error.message : String(error), + }, + }); + } + }, + }; + // Identity/capability/model resolution delegates to the real registry (the // interface grew `resolveAdapterIdentity` / `resolveProviderBaseId` / - // `resolveCapability` / `explainCapability` — delegating keeps the stub - // truthful and immune to further growth); only `createChatProvider` is - // scripted. + // `resolveCapability` / `explainCapability` / `resolve` — delegating keeps the + // stub truthful and immune to further growth); only the requester is scripted. const real = new ProtocolAdapterRegistry(); const registry: IProtocolAdapterRegistryType = { _serviceBrand: undefined, @@ -150,6 +189,7 @@ export function createScriptedProvider(): ScriptedProvider { resolveProviderBaseId: real.resolveProviderBaseId.bind(real), resolveCapability: real.resolveCapability.bind(real), explainCapability: real.explainCapability.bind(real), + resolve: (model: Model) => ({ ...real.resolve(model), requester }), // `createChatProvider` is called by `ModelImpl` (a package-internal method // not on the public interface); present at runtime, cast for the type gap. createChatProvider: (_input: ProtocolAdapterConfig) => provider, diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index d0972cf6eac..d563370a810 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -27,7 +27,7 @@ // references become '(circular)', and class instances collapse to a '(ClassName)' // marker — the wire shape of an entry is the JSON projection of the type here. // -// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 83 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 9 keys · Agent: 81 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -102,8 +102,6 @@ // runtime.binding src/agent/runtimeBinding/runtimeBindingService.ts // runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts // shellCommand.tasks src/agent/shellCommand/shellCommandService.ts -// stepRetry.failedAttempts src/agent/stepRetry/stepRetryService.ts -// stepRetry.lastFailedDriverId src/agent/stepRetry/stepRetryService.ts // swarm src/features/swarm/swarmOps.ts // task src/agent/task/taskOps.ts // task.activeTaskReminderPending src/agent/task/taskService.ts @@ -1044,30 +1042,30 @@ export interface AgentStateSnapshot { 'agentsMdReminder.seeded': boolean; // src/agent/contextMemory/contextOps.ts // replayable · durable · undoable — folds: ContextAppendMessage, ContextAppendLoopEvent, ContextClear, ContextApplyCompaction - 'contextMemory': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/kosong/contract/message.ts */ { - readonly role: /* Role — packages/agent-core-v2/src/kosong/contract/message.ts */ 'user' | 'assistant' | 'system' | 'tool'; + 'contextMemory': (/* ContextMessage — packages/agent-core-v2/src/agent/contextMemory/types.ts */ /* Message — packages/agent-core-v2/src/llm-adapter/contract/message.ts */ { + readonly role: /* Role — packages/agent-core-v2/src/human/llm/message.ts */ 'user' | 'assistant' | 'system' | 'tool'; readonly name?: string; - readonly content: (/* ContentPart — packages/agent-core-v2/src/kosong/contract/message.ts */ /* TextPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + readonly content: (/* ContentPart — packages/agent-core-v2/src/human/llm/message.ts */ /* TextPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'text'; text: string; - } | /* ThinkPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + } | /* ThinkPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'think'; think: string; encrypted?: string; - } | /* ImageURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + } | /* ImageURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'image_url'; imageUrl: { url: string; id?: string; name?: string; }; - } | /* AudioURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + } | /* AudioURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'audio_url'; audioUrl: { url: string; id?: string; }; - } | /* VideoURLPart — packages/agent-core-v2/src/kosong/contract/message.ts */ { + } | /* VideoURLPart — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'video_url'; videoUrl: { url: string; @@ -1075,7 +1073,7 @@ export interface AgentStateSnapshot { name?: string; }; })[]; - readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/kosong/contract/message.ts */ { + readonly toolCalls: /* ToolCall — packages/agent-core-v2/src/human/llm/message.ts */ { type: 'function'; id: string; name: string; @@ -1085,7 +1083,7 @@ export interface AgentStateSnapshot { }[]; readonly toolCallId?: string; readonly partial?: boolean; - readonly tools?: readonly /* Tool — packages/agent-core-v2/src/kosong/contract/tool.ts */ { + readonly tools?: readonly /* ToolDescription — packages/agent-core-v2/src/human/llm/message.ts */ { name: string; description: string; parameters: Record; @@ -1208,7 +1206,7 @@ export interface AgentStateSnapshot { 'llmRequester.turnConfigs': Map; // src/agent/media/mediaResolverService.ts - 'media.resolved': Map; - // src/agent/stepRetry/stepRetryService.ts - 'stepRetry.failedAttempts': number; - 'stepRetry.lastFailedDriverId': string | undefined; // src/agent/task/taskOps.ts // replayable · durable — folds: TaskStarted, TaskTerminated 'task': /* TaskModelState — packages/agent-core-v2/src/agent/task/taskOps.ts */ Map; - _streamIndex?: number | string; - }[]; + content: ContentPart[]; + toolCalls: ToolCall[]; toolCallId?: string; partial?: boolean; - tools?: { - name: string; - description: string; - parameters: Record; - deferred?: true; - }[]; + tools?: ToolDescription[]; id?: string; providerMessageId?: string; origin?: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry' | undefined; @@ -384,8 +370,7 @@ interface LlmRequestPayload { provider: string; model: string; modelAlias?: string; - /** ThinkingEffort */ - thinkingEffort?: 'off' | 'on' | (string & {}); + thinkingEffort?: ThinkingEffort; thinkingKeep?: string; temperature?: number; topP?: number; @@ -524,8 +509,7 @@ interface ProfileBindPayload { agentId: string; modelAlias?: string; profileName?: string; - /** ThinkingEffort */ - thinkingEffort: 'off' | 'on' | (string & {}); + thinkingEffort: ThinkingEffort; systemPrompt: string; /** EnvironmentDisclosureSnapshot */ environmentDisclosure?: { @@ -878,7 +862,7 @@ interface TurnStepInterruptedPayload { /** * states: (none) - * owner: src/agent/stepRetry/stepRetryService.ts + * owner: src/agent/loop/turnEvents.ts */ interface TurnStepRetryingPayload { _name: 'turn.step.retrying'; @@ -903,13 +887,7 @@ interface UsageRecordPayload { _name: 'usage.record'; agentId: string; model: string; - /** TokenUsage */ - usage: { - inputOther: number; - output: number; - inputCacheRead: number; - inputCacheCreation: number; - }; + usage: TokenUsage; /** UsageRecordScope */ usageScope?: 'session' | 'turn'; } diff --git a/packages/agent-core-v2/package.json b/packages/agent-core-v2/package.json index 00555b5e09d..9a298abaf94 100644 --- a/packages/agent-core-v2/package.json +++ b/packages/agent-core-v2/package.json @@ -27,7 +27,8 @@ ], "type": "module", "imports": { - "#/*": "./src/*.ts" + "#/*": "./src/*.ts", + "#human/*": "./src/human/*.ts" }, "exports": { ".": { @@ -77,6 +78,7 @@ "openai": "^6.34.0", "pathe": "^2.0.3", "picomatch": "^4.0.4", + "radashi": "^12.9.1", "retry": "0.13.1", "semver": "^7.7.4", "smol-toml": "^1.6.1", diff --git a/packages/agent-core-v2/scripts/check-import-boundaries.mjs b/packages/agent-core-v2/scripts/check-import-boundaries.mjs index 9d8a5aa9e43..a2569d5791d 100644 --- a/packages/agent-core-v2/scripts/check-import-boundaries.mjs +++ b/packages/agent-core-v2/scripts/check-import-boundaries.mjs @@ -8,75 +8,68 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = resolve(__dirname, '..'); export const SRC_ROOT = join(PKG_ROOT, 'src'); const TEST_ROOT = join(PKG_ROOT, 'test'); +const HUMAN_ROOT = join(SRC_ROOT, 'human'); +const ADAPTER_ROOT = join(SRC_ROOT, 'llm-adapter'); +const LOOP_MACHINE_ADAPTER_ROOT = join(SRC_ROOT, 'agent/loop/machine'); const V1_PACKAGE = '@moonshot-ai/agent-core'; const SELF_PACKAGE_PREFIX = '@moonshot-ai/agent-core-v2/'; - -const SCOPE_DIRS = new Set(['app', 'workspace', 'session', 'agent', 'persistence', 'os', 'kosong']); - -const TWO_LEVEL_SCOPES = new Set(['persistence', 'os', 'kosong']); - -const KOSONG_LAYER = new Map([ - ['contract', 0], - ['protocol', 1], - ['provider', 2], - ['model', 2], +const KOSONG_PATH_RE = /(?:^|\/)kosong(?:\/|$)/; + +const HUMAN_VOCABULARY = new Set([ + 'llm/message', + 'llm/usage', + 'llm/capability', + 'llm/thinking', + 'llm/finish-reason', + 'llm/response-format', + 'llm/media/upload', + 'llm/requester/requester', + 'llm-kimi/trait', ]); -const KOSONG_BASE_ONLY_SUBDOMAINS = new Set(['contract', 'protocol', 'provider', 'model']); - -const KOSONG_ALLOWED_VOCABULARY = new Set(['app/scopes']); - -const KOSONG_BANNED_SDK_PACKAGES = ['@anthropic-ai/sdk', '@google/genai', 'openai']; - -function kosongInfoOf(absPath) { - const rel = relative(SRC_ROOT, absPath); - if (rel.startsWith('..') || rel === '') return undefined; - const segments = rel.split(/[\\/]/); - if (segments[0] !== 'kosong') return undefined; - const sub = segments[1]; - const last = segments[segments.length - 1] ?? ''; - return { - sub: sub === undefined || sub.endsWith('.ts') ? undefined : sub, - inBases: sub === 'provider' && segments[2] === 'bases', - isContrib: last.endsWith('.contrib.ts'), - isIndex: last === 'index.ts', - }; -} +const V2_ONLY_FIRST_SEGMENTS = new Set([ + 'llm-adapter', + 'app', + 'workspace', + 'features', + 'state', + 'wire', + 'persistence', + 'os', + 'mcpCore', + 'errors', + 'debug', + 'program', + 'runtime', + '_base', +]); -function isKosongBasesBannedTarget(targetAbs) { - const rel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); - const stripped = rel.endsWith('.ts') ? rel.slice(0, -'.ts'.length) : rel; - if (stripped.endsWith('.contrib')) return true; - return ( - /(^|\/)kosong\/provider\/providerDefinition$/.test(stripped) || - /(^|\/)kosong\/provider\/protocolAdapterRegistry$/.test(stripped) || - /(^|\/)kosong\/protocol\/protocolBase$/.test(stripped) - ); +function isInside(root, absPath) { + const rel = relative(root, absPath); + return rel !== '' && !rel.startsWith('..'); } -function domainFromRel(rel) { - const segments = rel.split(/[\\/]/); - if (TWO_LEVEL_SCOPES.has(segments[0])) { - return segments[1] ? `${segments[0]}/${segments[1]}` : segments[0]; +function humanSubpathOf(specifier) { + if (specifier.startsWith('#human/')) return specifier.slice('#human/'.length); + if (specifier.startsWith(`${SELF_PACKAGE_PREFIX}human/`)) { + return specifier.slice(`${SELF_PACKAGE_PREFIX}human/`.length); } - if (SCOPE_DIRS.has(segments[0])) { - if (segments.length === 2 && segments[1]?.endsWith('.ts')) return segments[0]; - if (segments[0] === 'agent' && segments[1] === 'task') return 'agentTask'; - if (segments[0] === 'agent' && segments[1] === 'plugin') return 'agentPlugin'; - return segments[1]; - } - return segments[0]; + return undefined; } -function targetDomainOf(targetAbs) { - const rel = relative(SRC_ROOT, targetAbs); - if (rel.startsWith('..') || rel === '') return undefined; - return domainFromRel(rel); +function stripTs(path) { + return path.endsWith('.ts') ? path.slice(0, -'.ts'.length) : path; } function resolveIntraV2(specifier, fromFile) { + if (specifier.startsWith('#human/')) { + return join(HUMAN_ROOT, specifier.slice('#human/'.length)); + } if (specifier.startsWith('#/')) { + if (isInside(HUMAN_ROOT, fromFile)) { + return join(HUMAN_ROOT, specifier.slice(2)); + } return join(SRC_ROOT, specifier.slice(2)); } if (specifier.startsWith(SELF_PACKAGE_PREFIX)) { @@ -94,6 +87,8 @@ const IMPORT_RE = export function checkSource(source, absFile) { const violations = []; const inSrc = !relative(SRC_ROOT, absFile).startsWith('..'); + const inHuman = isInside(HUMAN_ROOT, absFile); + const inAdapter = isInside(ADAPTER_ROOT, absFile) || isInside(LOOP_MACHINE_ADAPTER_ROOT, absFile); let match; IMPORT_RE.lastIndex = 0; @@ -111,78 +106,47 @@ export function checkSource(source, absFile) { continue; } - if (!inSrc) continue; - const targetAbs = resolveIntraV2(specifier, absFile); - const sourceKosong = kosongInfoOf(absFile); - if (sourceKosong === undefined) continue; - - if (targetAbs === undefined) { - if (sourceKosong.sub === 'contract') { - violations.push({ - file: absFile, - line, - message: `kosong/contract must not import external package '${specifier}' — the L0 wire contract is pure (no SDK, no I/O, no third-party dependencies)`, - }); - } else if ( - sourceKosong.sub === 'protocol' && - KOSONG_BANNED_SDK_PACKAGES.some( - (pkg) => specifier === pkg || specifier.startsWith(`${pkg}/`), - ) - ) { - violations.push({ - file: absFile, - line, - message: `kosong/protocol must not import wire SDK '${specifier}' — L1 trait interfaces are SDK-free`, - }); - } + if (KOSONG_PATH_RE.test(specifier)) { + violations.push({ + file: absFile, + line, + message: `the kosong kernel is deleted (${specifier}) — request/provider code lives in #human/llm, the v2 compatibility boundary is #/llm-adapter`, + }); continue; } - const targetKosong = kosongInfoOf(targetAbs); - if (targetKosong !== undefined) { - const sourceKosongLayer = KOSONG_LAYER.get(sourceKosong.sub); - const targetKosongLayer = KOSONG_LAYER.get(targetKosong.sub); - if (sourceKosongLayer !== undefined && targetKosongLayer !== undefined) { - if (targetKosongLayer > sourceKosongLayer) { - violations.push({ - file: absFile, - line, - message: `kosong layer violation: 'kosong/${sourceKosong.sub}' (L${sourceKosongLayer}) imports 'kosong/${targetKosong.sub}' (L${targetKosongLayer}) via '${specifier}' — kosong layers are contract(L0) ← protocol(L1) ← provider/model(L2)`, - }); - } else if (sourceKosong.sub === 'provider' && targetKosong.sub === 'model') { + if (!inSrc) continue; + + if (inHuman) { + if (specifier.startsWith('#/')) { + const first = specifier.slice(2).split('/')[0]; + if (first !== undefined && V2_ONLY_FIRST_SEGMENTS.has(first)) { violations.push({ file: absFile, line, - message: `kosong peer violation: 'kosong/provider' must not import 'kosong/model' via '${specifier}' — the peer dependency runs model → provider only`, + message: `human must not import outside its kernel ('${specifier}') — human is the pure LLM/agent kernel: it never imports llm-adapter or v2 domains`, }); + continue; } } - if ( - sourceKosong.inBases && - !sourceKosong.isContrib && - !sourceKosong.isIndex && - isKosongBasesBannedTarget(targetAbs) - ) { + const targetAbs = resolveIntraV2(specifier, absFile); + if (targetAbs !== undefined && !isInside(HUMAN_ROOT, targetAbs)) { violations.push({ file: absFile, line, - message: `kosong bases boundary: base implementation files under 'kosong/provider/bases' must not import registries (protocolBase/protocolAdapterRegistry), providerDefinition, or contrib modules (via '${specifier}') — registration lives in *.contrib.ts and the directory index.ts`, + message: `human must not import outside its kernel ('${specifier}') — human is the pure LLM/agent kernel: it never imports llm-adapter or v2 domains`, }); } continue; } - if (KOSONG_BASE_ONLY_SUBDOMAINS.has(sourceKosong.sub)) { - const targetDomain = targetDomainOf(targetAbs); - const targetRel = relative(SRC_ROOT, targetAbs).split(/[\\/]/).join('/'); - const targetStripped = targetRel.endsWith('.ts') ? targetRel.slice(0, -'.ts'.length) : targetRel; - if (targetDomain !== '_base' && !KOSONG_ALLOWED_VOCABULARY.has(targetStripped)) { - violations.push({ - file: absFile, - line, - message: `'kosong/${sourceKosong.sub}' must not import domain '${targetDomain ?? specifier}' via '${specifier}' — kosong is a pure abstraction layer: only _base utilities are allowed outside the kosong subtree (persistence/OAuth/discovery live in app/kosongConfig)`, - }); - } + const humanSub = humanSubpathOf(specifier); + if (humanSub !== undefined && !inAdapter && !HUMAN_VOCABULARY.has(stripTs(humanSub))) { + violations.push({ + file: absFile, + line, + message: `only llm-adapter and agent/loop/machine may import the human implementation ('${specifier}') — v2 code outside those adapter layers is limited to the vocabulary modules (${[...HUMAN_VOCABULARY].join(', ')})`, + }); } } diff --git a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts index bee6e04b212..85f49a8ed8d 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts @@ -12,9 +12,9 @@ import { TurnStepStarted, TurnStepCompleted, TurnStepInterrupted, + TurnStepRetrying, } from '#/agent/loop/turnEvents'; import { TurnEnded, turnKey } from '#/agent/loop/turnOps'; -import { TurnStepRetrying } from '#/agent/stepRetry/stepRetryService'; import { ToolCallStarted, ToolResultEvent } from '#/agent/toolExecutor/toolExecutorEvents'; import { PermissionApprovalRequested, diff --git a/packages/agent-core-v2/src/agent/blob/agentBlobService.ts b/packages/agent-core-v2/src/agent/blob/agentBlobService.ts index 94bfd6a7b11..4d1da5a13e8 100644 --- a/packages/agent-core-v2/src/agent/blob/agentBlobService.ts +++ b/packages/agent-core-v2/src/agent/blob/agentBlobService.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { createDecorator } from "#/_base/di/instantiation"; diff --git a/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts index 2ad64a8a2b5..01c34ba2359 100644 --- a/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts +++ b/packages/agent-core-v2/src/agent/blob/agentBlobServiceImpl.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts index 11a6a9f73c3..ad2bcdcc883 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/compactionHandoff.ts @@ -1,5 +1,5 @@ -import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/kosong/contract/tokens'; -import type { ContentPart } from '#/kosong/contract/message'; +import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; +import type { ContentPart } from '#human/llm/message'; import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import summaryPrefixTemplate from './compaction-summary-prefix.md?raw'; import type { ContextMessage, PromptOrigin } from './types'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 80186c277f8..9cdfcf3318c 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; import { ErrorCodes, Error2 } from '#/errors'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { defineState } from '#/state/state'; import type { PartsTransformer } from '#/wire/record'; import type { WireRecord } from '#/wire/record'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 75df69068b8..b5e348a8559 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,4 +1,4 @@ -import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; +import { type ContentPart, type ToolCall } from '#human/llm/message'; import type { WireRecord } from '#/wire/record'; import { diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index cf68b8c9f28..2201637879a 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -1,8 +1,9 @@ import { isDraft, original } from 'immer'; -import type { FinishReason } from '#/kosong/contract/provider'; -import { createToolMessage, type ContentPart, type ToolCall } from '#/kosong/contract/message'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { FinishReason } from '#human/llm/finish-reason'; +import { createToolMessage } from '#/llm-adapter/contract/message'; +import type { ContentPart, ToolCall } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; import type { ContextMessage } from './types'; import { isVacuousContentPart } from './vacuousContent'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts b/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts index 425d4c4faa3..a36d6e7d99f 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/openToolExchange.ts @@ -1,4 +1,4 @@ -import { createToolMessage } from '#/kosong/contract/message'; +import { createToolMessage } from '#/llm-adapter/contract/message'; import type { ContextMessage } from './types'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts index 26e4969f1cc..c7bad66c0d3 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/toolResultRender.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; const TOOL_ERROR_STATUS = 'ERROR: Tool execution failed.'; const TOOL_EMPTY_STATUS = 'Tool output is empty.'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index a925dbc0894..a1817b7cbad 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -1,4 +1,5 @@ -import type { ContentPart, Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { AgentTaskStatus } from '#/agent/task/task'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts index e0e1f9e46ab..d0bcf4729c1 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/vacuousContent.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; export function isVacuousContentPart(part: ContentPart): boolean { switch (part.type) { diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts index 46e1bbacdc4..e2a5e386dfe 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjector.ts @@ -1,5 +1,5 @@ import { createDecorator } from '#/_base/di/instantiation'; -import type { Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; import type { ContextMessage } from '#/agent/contextMemory/types'; diff --git a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts index 82bab0a2e7f..4414905e901 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/contextProjectorService.ts @@ -4,7 +4,7 @@ import { ILogService } from '#/_base/log/log'; import { defineState } from '#/state/state'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentStateService } from '#/agent/state/agentState'; -import type { Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IAgentContextProjectorService, diff --git a/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts b/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts index 741465ce9b1..164294be494 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/mediaProjection.ts @@ -1,6 +1,7 @@ import { createHash } from 'node:crypto'; -import type { ContentPart, Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { MediaStripSnapshot } from './contextProjector'; diff --git a/packages/agent-core-v2/src/agent/contextProjector/projection.ts b/packages/agent-core-v2/src/agent/contextProjector/projection.ts index c4cd5f726c4..8e837f82d98 100644 --- a/packages/agent-core-v2/src/agent/contextProjector/projection.ts +++ b/packages/agent-core-v2/src/agent/contextProjector/projection.ts @@ -2,7 +2,8 @@ import { ErrorCodes, Error2 } from '#/errors'; import { renderToolResultForModel } from '#/agent/contextMemory/toolResultRender'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; -import type { ContentPart, Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; export type ProjectionAnomaly = | { readonly kind: 'tool_result_reordered'; readonly toolCallId: string } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index b858f7dad30..b6db96e1ae5 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -3,13 +3,13 @@ import { Service } from "#/_base/di/service"; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; -import { estimateTokensForMessage } from "#/kosong/contract/tokens"; +import { estimateTokensForMessage } from "#/llm-adapter/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { IAgentLoopService, type LoopErrorContext } from '#/agent/loop/loop'; import { TurnStarted } from '#/agent/loop/turnEvents'; @@ -38,10 +38,10 @@ import { APIEmptyResponseError, APIStatusError, isRetryableGenerateError, -} from '#/kosong/contract/errors'; -import { createUserMessage, type Message } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import { inputTotal, type TokenUsage } from '#/kosong/contract/usage'; +} from '#/llm-adapter/contract/errors'; +import { createUserMessage, type Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import { inputTotal, type TokenUsage } from '#human/llm/usage'; import { IEventBus } from '#/app/event/eventBus'; import type { CompactionFailedEvent, CompactionFinishedEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -497,9 +497,8 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } private retryFailedDriver(context: LoopErrorContext): boolean { - const driver = context.failedDriver; - if (driver === undefined || context.currentStep?.signal.aborted === true) return false; - context.retry(driver, { at: 'head' }); + if (context.signal.aborted) return false; + context.retry(); return true; } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts index fd0ccdfbef1..e588799f3f3 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/strategy.ts @@ -1,7 +1,7 @@ -import type { Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; import type { ProfileModelContext } from '#/agent/profile/profile'; import type { CompactionSource } from './types'; -import { estimateTokensForMessage } from '#/kosong/contract/tokens'; +import { estimateTokensForMessage } from '#/llm-adapter/contract/tokens'; export interface CompactionConfig { triggerRatio: number; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index 0f60cc00944..45771b2c042 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { AgentEvent2 } from '#/app/event/event2'; -import type { ThinkingEffort } from '#/kosong/contract/provider'; +import type { ThinkingEffort } from '#human/llm/thinking'; import { defineState } from '#/state/state'; export interface LlmRequestToolSchema { diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts index dceb42ac37b..ac414f36975 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequester.ts @@ -1,10 +1,11 @@ import { createDecorator } from '#/_base/di/instantiation'; -import type { FinishReason, ThinkingEffort } from '#/kosong/contract/provider'; -import type { Message, StreamedMessagePart } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; -import type { ModelRequestTiming } from '#/kosong/model/modelRequester'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { StreamedMessagePart, ToolDescription as Tool } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; import type { LogContext } from '#/_base/log/log'; export type AgentLLMRequestLogFields = Readonly; diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 2c4df9f1fb2..673c32574f8 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -24,26 +24,26 @@ import { isImageFormatError, isRecoverableRequestStructureError, isRetryableGenerateError, -} from '#/kosong/contract/errors'; -import { isToolCall, type Message, type StreamedMessagePart } from '#/kosong/contract/message'; -import { type ThinkingEffort } from '#/kosong/contract/provider'; -import { type Tool } from '#/kosong/contract/tool'; -import { emptyUsage, inputTotal, type TokenUsage } from '#/kosong/contract/usage'; +} from '#/llm-adapter/contract/errors'; +import type { Message } from '#/llm-adapter/contract/message'; +import { type ThinkingEffort } from '#human/llm/thinking'; +import { isToolCall, type StreamedMessagePart, type ToolDescription as Tool } from '#human/llm/message'; +import { emptyUsage, inputTotal, type TokenUsage } from '#human/llm/usage'; import { ILogService, type LogContext } from '#/_base/log/log'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; import { effectiveMaxCompletionTokens, type ModelRequestEvent, type ModelRequestParams, type ModelRequester, type ModelRequestTiming, -} from '#/kosong/model/modelRequester'; -import type { ModelOverrides } from '#/kosong/model/model.types'; -import { IModelService } from '#/kosong/model/model'; -import { completionBudgetParams, resolveCompletionBudget } from '#/kosong/model/completionBudget'; -import { resolveThinkingKeep, type ThinkingConfig } from '#/kosong/model/thinking'; +} from '#/llm-adapter/model/model-requester'; +import type { ModelOverrides } from '#/llm-adapter/model/model.types'; +import { IModelService } from '#/llm-adapter/model/model'; +import { completionBudgetParams, resolveCompletionBudget } from '#/llm-adapter/model/completion-budget'; +import { resolveThinkingKeep, type ThinkingConfig } from '#/llm-adapter/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; -import type { Protocol } from '#/kosong/protocol/protocol'; +import type { Protocol } from '#/llm-adapter/protocol/protocol'; import type { ApiErrorEvent, LlmRequestProjectionFallbackEvent, @@ -63,7 +63,7 @@ import { type AgentLLMRequestTask, type PreparedTurnRequestConfig, } from './llmRequester'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import { ToolCallIdNormalizer, type ToolCallIdResponseNormalizer, diff --git a/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts b/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts index e8eb0e3ff88..a63ae570924 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/toolCallIdNormalizer.ts @@ -1,4 +1,5 @@ -import type { Message, ToolCall } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolCall } from '#human/llm/message'; export class ToolCallIdNormalizer { private readonly seen = new Set(); diff --git a/packages/agent-core-v2/src/agent/loop/handoffStep.ts b/packages/agent-core-v2/src/agent/loop/handoffStep.ts deleted file mode 100644 index eb27197dc21..00000000000 --- a/packages/agent-core-v2/src/agent/loop/handoffStep.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ContinuationStepRequest } from './stepRequest'; - -export const HANDOFF_STEP_KIND = 'handoff'; - -export interface HandoffStepObserver { - onMaterialize(): void; - onAbort(): void; -} - -export class HandoffStepRequest extends ContinuationStepRequest { - constructor(private readonly observer: HandoffStepObserver) { - super({ kind: HANDOFF_STEP_KIND }); - } - - override onWillMaterialize(): void { - this.observer.onMaterialize(); - } - - override abort(): boolean { - const aborted = super.abort(); - if (aborted) this.observer.onAbort(); - return aborted; - } -} diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 733686675d0..b86bdf7ade9 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -1,11 +1,11 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; import { Error2, isError2, type Error2Options } from '#/_base/errors/errors'; -import type { FinishReason } from '#/kosong/contract/provider'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { TokenUsage } from '#human/llm/usage'; import type { Hooks } from '#/hooks'; import { LoopErrors } from './errors'; -import type { StepRequest } from './stepRequest'; export type LoopErrorCode = (typeof LoopErrors.codes)[keyof typeof LoopErrors.codes]; @@ -43,14 +43,12 @@ export interface AfterStepContext extends BeforeStepContext { } export interface LoopErrorContext { - readonly currentStep?: Step; readonly turnId: number; readonly step?: number; readonly stepId?: string; readonly signal: AbortSignal; readonly error: unknown; - readonly failedDriver?: StepRequest; - retry(request: StepRequest, options?: StepEnqueueOptions): Step; + retry(): void; } export interface LoopErrorHandler { @@ -64,12 +62,6 @@ export interface LoopErrorHandlerRegistrationOptions { readonly after?: string; } -export interface LoopRunOptions { - readonly turnId: number; - readonly signal?: AbortSignal; - readonly onStarted?: (step: number) => void; -} - export type LoopRunResult = | { readonly type: 'completed'; @@ -90,22 +82,6 @@ export type LoopRunResult = export type TurnResult = LoopRunResult; -export type StepState = 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; - -export type StepResult = - | { readonly type: 'completed' } - | { readonly type: 'failed'; readonly error: unknown } - | { readonly type: 'cancelled'; readonly reason: unknown }; - -export interface Step { - readonly id: string; - readonly turnId: number; - readonly state: StepState; - readonly signal: AbortSignal; - readonly result: Promise; - cancel(reason?: unknown): boolean; -} - export interface Turn { readonly id: number; readonly state?: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'; @@ -115,16 +91,6 @@ export interface Turn { cancel(reason?: unknown): boolean; } -export interface StepAssignment { - readonly turn: Turn; - readonly step: Step; -} - -export interface EnqueueReceipt { - readonly assigned: Promise; - abort(reason?: unknown): boolean; -} - export interface AgentLoopStatus { readonly state: 'idle' | 'running'; readonly activeTurnId?: number; @@ -133,23 +99,41 @@ export interface AgentLoopStatus { readonly activeTraceId?: string; } -export interface StepEnqueueOptions { - readonly at?: 'head' | 'tail'; +export interface LoopPromptSubmit { + readonly message: ContextMessage; + readonly origin?: PromptOrigin; + readonly promptId?: string; + readonly onMaterialize?: () => void; +} + +export interface LoopNotify { + readonly message?: ContextMessage; + readonly turnScoped?: boolean; + readonly bypassMaxSteps?: boolean; + readonly onConsume?: () => void; + readonly onDrop?: () => void; +} + +export interface LoopNotifyHandle { + readonly dropped: boolean; + drop(): void; } export interface IAgentLoopService { readonly _serviceBrand: undefined; - enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt; + submit(prompt: LoopPromptSubmit): { readonly turn: Turn }; - run(options: LoopRunOptions): Promise; + steer(prompt: LoopPromptSubmit): Turn | undefined; - status(): AgentLoopStatus; + notify(note?: LoopNotify): LoopNotifyHandle; cancel(turnId?: number, reason?: unknown): boolean; cancelFromUser(turnId?: number): void; + status(): AgentLoopStatus; + tryAcquireQuiescence(): IDisposable | undefined; settled(): Promise; diff --git a/packages/agent-core-v2/src/agent/loop/loopContinuation.ts b/packages/agent-core-v2/src/agent/loop/loopContinuation.ts deleted file mode 100644 index 90a9803f0de..00000000000 --- a/packages/agent-core-v2/src/agent/loop/loopContinuation.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createDecorator } from '#/_base/di/instantiation'; - -export interface IAgentLoopContinuationService { - readonly _serviceBrand: undefined; -} - -export const IAgentLoopContinuationService = createDecorator( - 'agentLoopContinuationService', -); diff --git a/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts b/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts deleted file mode 100644 index bd0e5aea80a..00000000000 --- a/packages/agent-core-v2/src/agent/loop/loopContinuationService.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; - -import { IAgentLoopContinuationService } from './loopContinuation'; -import { IAgentLoopService } from './loop'; -import { ContinuationStepRequest } from './stepRequest'; - -export class AgentLoopContinuationService - extends Service - implements IAgentLoopContinuationService -{ - declare readonly _serviceBrand: undefined; - - constructor(@IAgentLoopService loop: IAgentLoopService) { - super(); - this._register( - loop.hooks.onDidFinishStep.register('loop-continuation', async (ctx, next) => { - await next(); - if (ctx.stopTurn || ctx.finishReason !== 'tool_calls') return; - loop.enqueue(new ContinuationStepRequest()); - }), - ); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentLoopContinuationService, - AgentLoopContinuationService, - ScopeActivation.OnScopeCreated, - 'loop', -); diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 09caa223074..46b1758cf37 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -9,19 +9,26 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; import { abortError, isAbortError, isUserCancellation, userCancellationReason } from '#/_base/utils/abort'; import { toErrorMessage } from '#/_base/errors/errorMessage'; -import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import { retryErrorFields } from '#/_base/utils/retry'; +import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { abortedToolOutput } from '#/agent/toolExecutor/toolExecutorService'; +import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IConfigService } from '#/app/config/config'; import { AgentErrorEvent } from '#/agent/mcp/mcpEvents'; -import { type FinishReason } from '#/kosong/contract/provider'; -import { mergeInPlace, type ContentPart, type StreamedMessagePart } from '#/kosong/contract/message'; -import { type TokenUsage } from '#/kosong/contract/usage'; +import { type FinishReason } from '#human/llm/finish-reason'; +import { UNKNOWN_CAPABILITY } from '#human/llm/capability'; +import { mergeInPlace } from '#/llm-adapter/contract/message'; +import type { ContentPart, UserMessage } from '#human/llm/message'; +import { emptyUsage, type TokenUsage } from '#human/llm/usage'; import { BugIndicatingError, ErrorCodes, Error2, isError2, toKimiErrorPayload } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import type { @@ -31,6 +38,7 @@ import type { } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IEventDispatcher } from '#/state/eventDispatcher'; +import { IWireService } from '#/wire/wire'; import { LOOP_CONTROL_SECTION, type LoopControl } from './configSection'; import { createMaxStepsExceededError, @@ -38,24 +46,17 @@ import { isMaxStepsExceededError, type AfterStepContext, type AgentLoopStatus, - type EnqueueReceipt, + type LoopError, type LoopErrorContext, type LoopErrorHandler, type LoopErrorHandlerRegistrationOptions, - type LoopRunOptions, + type LoopNotify, + type LoopNotifyHandle, + type LoopPromptSubmit, type LoopRunResult, - type Step, - type StepEnqueueOptions, - type StepResult, type Turn, type TurnResult, } from './loop'; -import { - type StepRequest, - type TurnSeed, -} from './stepRequest'; -import { StepRequestQueue, type StepRequestBatch } from './stepRequestQueue'; -import { HANDOFF_STEP_KIND } from './handoffStep'; import { AssistantDelta, isDisplayablePromptOrigin, @@ -66,10 +67,19 @@ import { TurnStarted, TurnStepCompleted, TurnStepInterrupted, + TurnStepRetrying, TurnStepStarted, type TurnInterruptReason, } from './turnEvents'; import { TurnCancel, TurnEnded, turnKey, TurnPrompt } from './turnOps'; +import { + createMachineEngine, + EMPTY_MACHINE_PROMPT, + historyFromContext, + type MachineEngine, + type MachineEngineEvent, + type MachineTurnOutcome, +} from './machine'; export type LoopInterruptReason = 'aborted' | 'max_steps' | 'error'; @@ -85,6 +95,12 @@ export const loopDisposingKey = defineState('loop.disposing', () => fal const MAX_STEP_SIGNAL_LISTENERS = 64; +const MACHINE_LOOP_MODEL = { + provider: 'agent-loop', + model: 'agent-loop', + capability: UNKNOWN_CAPABILITY, +}; + export class AgentLoopService extends Disposable implements IAgentLoopService { declare readonly _serviceBrand: undefined; @@ -93,25 +109,31 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onDidFinishStep: new OrderedHookSlot(), }; - private readonly standaloneStepQueue = new StepRequestQueue(); - private readonly pendingAssignments = new Map>>(); private readonly errorHandlers: LoopErrorHandler[] = []; - private readonly pendingTurns: TurnJob[] = []; - private readonly heldAdmissions: HeldAdmission[] = []; - private activeTurnJob: TurnJob | undefined; + private readonly reservations: TurnReservation[] = []; + private readonly nudges: Nudge[] = []; + private nudgeCursor = 0; + private active: ActiveTurn | undefined; + private machineTurnUnbound = false; + private machineTurnSuppressed = false; + private unboundDrained: TurnReservation | undefined; + private readonly pendingMachineQueueIds = new Set(); private readonly settleWaiters: Array<() => void> = []; private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; + private engine: MachineEngine | undefined; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, + @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IConfigService private readonly config: IConfigService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentStateService private readonly states: IAgentStateService, + @IWireService private readonly wire: IWireService, ) { super(); this.states.contributeState(turnKey); @@ -144,83 +166,158 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.states.set(loopDisposingKey, value); } + private machineEngine(): MachineEngine { + if (this.engine === undefined) { + this.engine = createMachineEngine({ + model: MACHINE_LOOP_MODEL, + llmRequester: this.llmRequester, + toolExecutor: this.toolExecutor, + toolInfos: this.toolRegistry.list(), + maxAttemptsPerStep: this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep, + trace: () => this.activeRequestTrace, + toolTurnId: () => this.active?.id, + source: () => + this.active === undefined + ? undefined + : { + type: 'turn', + turnId: this.active.id, + step: this.active.gatedSteps, + }, + gate: (signal) => this.gate(signal), + onTrace: (trace) => { + this.activeRequestTrace = trace; + }, + onEvent: (event) => this.projectMachineEvent(event), + onToolResult: (toolCallId, result) => this.appendMachineToolResult(toolCallId, result), + }); + } + return this.engine; + } + override dispose(): void { if (this.disposing) return; this.disposing = true; const reason = abortError('Agent loop disposed'); - for (const job of this.pendingTurns.slice()) this.cancel(job.turn.id, reason); - this.activeTurnJob?.turn.cancel(reason); - for (const request of this.standaloneStepQueue.drain()) { - request.abort(); - this.rejectAssignment(request, reason); - } - for (const { request } of this.heldAdmissions.splice(0)) { - request.abort(); - this.rejectAssignment(request, reason); + for (const reservation of this.reservations.splice(0)) { + this.settleReservationCancelled(reservation, reason); } + this.pendingMachineQueueIds.clear(); + this.active?.turn.cancel(reason); + this.engine?.stop(); this.maybeSettle(); super.dispose(); } - enqueue(request: StepRequest, options?: StepEnqueueOptions): EnqueueReceipt { + submit(prompt: LoopPromptSubmit): { readonly turn: Turn } { if (this.disposing) throw abortError('Agent loop disposed'); - const assignment = createControlledPromise(); - void assignment.catch(() => undefined); - this.pendingAssignments.set(request, assignment); + const reservation = this.createReservation(prompt); + this.reservations.push(reservation); + if (this.quiescenceDepth === 0) { + this.launchReservation(reservation); + } + return { turn: reservation.turn }; + } - if (this.quiescenceDepth > 0) { - this.heldAdmissions.push({ request, options }); - } else { - this.admit(request, options); + steer(prompt: LoopPromptSubmit): Turn | undefined { + if (this.disposing) throw abortError('Agent loop disposed'); + const active = this.active; + if (active === undefined) return undefined; + const message = normalizePromptMessage(prompt); + const id = prompt.promptId ?? randomUUID(); + this.nudges.push({ + contextMessage: message, + bypassMaxSteps: false, + turnScoped: false, + onConsume: prompt.onMaterialize, + onDrop: undefined, + }); + this.machineEngine().submit({ id, message: machineUserMessage(message) }); + this.machineEngine().steer(id); + return active.turn; + } + + notify(note: LoopNotify = {}): LoopNotifyHandle { + if (this.disposing) throw abortError('Agent loop disposed'); + const nudge: Nudge = { + contextMessage: note.message, + bypassMaxSteps: note.bypassMaxSteps ?? false, + turnScoped: note.turnScoped ?? true, + onConsume: note.onConsume, + onDrop: note.onDrop, + }; + this.nudges.push(nudge); + if (this.quiescenceDepth === 0) { + nudge.sentToMachine = true; + this.machineEngine().notify(machineUserMessage(note.message)); } return { - assigned: assignment, - abort: (reason) => this.abortRequest(request, reason), + get dropped() { + return nudge.dropped === true; + }, + drop: () => { + if (nudge.dropped === true || nudge.consumed === true) return; + nudge.dropped = true; + nudge.onDrop?.(); + this.maybeSettle(); + }, }; } - private admit(request: StepRequest, options?: StepEnqueueOptions): void { - const active = this.activeTurnJob; - switch (request.admission) { - case 'newTurn': - this.createAndQueueTurn(request); - break; - case 'activeOrNewTurn': - if (active === undefined) this.createAndQueueTurn(request); - else this.assignStep(active, request, options); - break; - case 'activeOrNextTurn': - if (active === undefined) this.standaloneStepQueue.enqueue(request, options?.at ?? 'tail'); - else this.assignStep(active, request, options); - break; - case 'activeTurnOnly': - if (active === undefined) { - const error = new BugIndicatingError(`Step request "${request.kind}" requires an active turn`); - this.rejectAssignment(request, error); - throw error; - } - this.assignStep(active, request, options); - break; - } + private createReservation(prompt: LoopPromptSubmit): TurnReservation { + const id = this.reserveTurnId(); + const controller = new AbortController(); + const ready = createControlledPromise(); + const result = createControlledPromise(); + void ready.catch(() => undefined); + const message = normalizePromptMessage(prompt); + const turn: MutableTurn = { + id, + state: 'queued', + signal: controller.signal, + ready, + result, + cancel: (reason) => this.cancel(id, reason), + }; + return { + id, + machineQueueId: prompt.promptId ?? `turn-${String(id)}`, + message, + origin: message.origin ?? { kind: 'user' }, + promptId: prompt.promptId, + onMaterialize: prompt.onMaterialize, + cancelled: false, + controller, + ready, + result, + turn, + }; } - private createAndQueueTurn(request: StepRequest): void { - const seed = request.turnSeed; - if (seed === undefined) { - const error = new BugIndicatingError(`Step request "${request.kind}" cannot start a turn without turnSeed`); - this.rejectAssignment(request, error); - throw error; - } - const job = this.createPendingTurn(request, seed); - this.pendingTurns.push(job); - this.pumpTurns(); + private launchReservation(reservation: TurnReservation): void { + if (reservation.cancelled || reservation.launched) return; + reservation.launched = true; + this.pendingMachineQueueIds.add(reservation.machineQueueId); + this.machineEngine().submit({ + id: reservation.machineQueueId, + message: machineUserMessage(reservation.message), + }); + } + + private reserveTurnId(): number { + const modelNextId = this.states.get(turnKey).nextTurnId; + const id = Math.max(modelNextId, this.nextReservedTurnId ?? modelNextId); + this.nextReservedTurnId = id + 1; + return id; } status(): AgentLoopStatus { return { - state: this.activeTurnJob === undefined ? 'idle' : 'running', - activeTurnId: this.activeTurnJob?.turn.id, - pendingTurnIds: this.pendingTurns.map((job) => job.turn.id), + state: this.active === undefined ? 'idle' : 'running', + activeTurnId: this.active?.id, + pendingTurnIds: this.reservations + .filter((reservation) => !reservation.cancelled) + .map((reservation) => reservation.id), hasPendingRequests: this.hasPendingRequests(), activeTraceId: this.activeRequestTrace?.traceId, }; @@ -249,8 +346,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (this.disposing) throw abortError('Agent loop disposed'); if ( this.quiescenceDepth > 0 || - this.activeTurnJob !== undefined || - this.hasPendingRequests() + this.active !== undefined || + this.hasPendingRequests() || + this.machineTurnUnbound ) { return undefined; } @@ -262,40 +360,44 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (this.quiescenceDepth === 0) return; this.quiescenceDepth -= 1; if (this.quiescenceDepth > 0 || this.disposing) return; - this.pumpTurns(); - for (const admission of this.heldAdmissions.splice(0)) { - if (admission.request.aborted) continue; - try { - this.admit(admission.request, admission.options); - } catch (error) { - admission.request.abort(); - this.rejectAssignment(admission.request, error); + for (const reservation of this.reservations) { + if (!reservation.cancelled) this.launchReservation(reservation); + } + for (const nudge of this.nudges.slice(this.nudgeCursor)) { + if (!nudge.dropped && !nudge.sentToMachine) { + nudge.sentToMachine = true; + this.machineEngine().notify(machineUserMessage(nudge.contextMessage)); } } - this.pumpTurns(); + this.maybeSettle(); } private cancelActiveTurn(turnId: number | undefined, cancellation: unknown): boolean { - const job = this.activeTurnJob; - if (job === undefined || (turnId !== undefined && job.turn.id !== turnId)) return false; - if (job.controller.signal.aborted) return true; + const active = this.active; + if (active === undefined || (turnId !== undefined && active.id !== turnId)) return false; + if (active.controller.signal.aborted) { + this.machineEngine().abort(); + return true; + } void this.dispatcher.dispatch( new TurnCancel({ agentId: this.scopeContext.agentId, - turnId: job.turn.id, + turnId: active.id, target: 'active', reason: cancelReasonFor(cancellation), }), ); - job.controller.abort(cancellation); + active.controller.abort(cancellation); + this.machineEngine().abort(); return true; } private cancelQueuedTurn(turnId: number, cancellation: unknown): boolean { - const index = this.pendingTurns.findIndex((job) => job.turn.id === turnId); + const index = this.reservations.findIndex((entry) => entry.id === turnId); if (index < 0) return false; - const [job] = this.pendingTurns.splice(index, 1); - if (job === undefined || job.turn.state !== 'queued') return false; + const reservation = this.reservations[index]!; + if (reservation.cancelled) return false; + reservation.cancelled = true; void this.dispatcher.dispatch( new TurnCancel({ agentId: this.scopeContext.agentId, @@ -304,29 +406,36 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { reason: cancelReasonFor(cancellation), }), ); - for (const step of job.steps.values()) step.cancel(cancellation); - job.controller.abort(cancellation); - job.turn.state = 'cancelled'; - job.ready.reject(cancellation instanceof Error ? cancellation : abortError('Turn cancelled')); - job.result.resolve({ type: 'cancelled', steps: 0, reason: cancellation }); - this.maybeSettle(); + if (!reservation.launched) { + this.reservations.splice(index, 1); + } + this.settleReservationCancelled(reservation, cancellation); return true; } + private settleReservationCancelled(reservation: TurnReservation, cancellation: unknown): void { + reservation.cancelled = true; + reservation.controller.abort(cancellation); + reservation.turn.state = 'cancelled'; + reservation.ready.reject( + cancellation instanceof Error ? cancellation : abortError('Turn cancelled'), + ); + reservation.result.resolve({ type: 'cancelled', steps: 0, reason: cancellation }); + this.maybeSettle(); + } + hasPendingRequests(): boolean { return ( - this.activeTurnJob?.queue.hasPendingRequests() === true || - this.standaloneStepQueue.hasPendingRequests() || - this.pendingTurns.length > 0 || - this.heldAdmissions.some(({ request }) => !request.aborted) + this.reservations.some((reservation) => !reservation.cancelled) || + this.nudges.slice(this.nudgeCursor).some((nudge) => !nudge.dropped) ); } settled(): Promise { if ( - this.activeTurnJob === undefined && - this.pendingTurns.length === 0 && - this.heldAdmissions.length === 0 + this.active === undefined && + !this.hasPendingRequests() && + !this.machineTurnUnbound ) { return Promise.resolve(); } @@ -337,272 +446,15 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private maybeSettle(): void { if ( - this.activeTurnJob !== undefined || - this.pendingTurns.length > 0 || - this.heldAdmissions.length > 0 + this.active !== undefined || + this.machineTurnUnbound || + this.hasPendingRequests() ) return; if (this.settleWaiters.length === 0) return; const waiters = this.settleWaiters.splice(0); for (const resolve of waiters) resolve(); } - private createPendingTurn(request: StepRequest, seed: TurnSeed): TurnJob { - const id = this.reserveTurnId(); - const controller = new AbortController(); - const ready = createControlledPromise(); - const result = createControlledPromise(); - const queue = new StepRequestQueue(); - const steps = new Map(); - void ready.catch(() => undefined); - const turn: MutableTurn = { - id, - state: 'queued', - signal: controller.signal, - ready, - result, - cancel: (reason) => this.cancel(id, reason), - }; - const job = { request, seed, controller, ready, result, queue, steps, turn }; - this.assignStep(job, request); - this.moveStandaloneStepsTo(job); - return job; - } - - private reserveTurnId(): number { - const modelNextId = this.states.get(turnKey).nextTurnId; - const id = Math.max(modelNextId, this.nextReservedTurnId ?? modelNextId); - this.nextReservedTurnId = id + 1; - return id; - } - - private moveStandaloneStepsTo(job: TurnJob): void { - for (const pending of this.standaloneStepQueue.drain()) { - if (!pending.aborted) this.assignStep(job, pending); - } - } - - private assignStep(job: TurnJob, request: StepRequest, options?: StepEnqueueOptions): Step { - const step = this.enqueueStep(job, request, options); - const assignment = this.pendingAssignments.get(request); - assignment?.resolve({ turn: job.turn, step }); - this.pendingAssignments.delete(request); - return step; - } - - private rejectAssignment(request: StepRequest, reason: unknown): void { - const assignment = this.pendingAssignments.get(request); - assignment?.reject(reason instanceof Error ? reason : abortError('Step request aborted')); - this.pendingAssignments.delete(request); - } - - private abortRequest(request: StepRequest, reason?: unknown): boolean { - const heldIndex = this.heldAdmissions.findIndex((entry) => entry.request === request); - if (heldIndex >= 0) { - this.heldAdmissions.splice(heldIndex, 1); - if (!request.abort()) return false; - this.rejectAssignment(request, reason ?? userCancellationReason()); - this.maybeSettle(); - return true; - } - for (const job of [this.activeTurnJob, ...this.pendingTurns]) { - if (job === undefined) continue; - if (job.turn.state === 'queued' && job.request === request) { - return this.cancel(job.turn.id, reason); - } - const step = job.steps.get(request.id); - if (step !== undefined) return step.cancel(reason); - } - if (!request.abort()) return false; - this.rejectAssignment(request, reason ?? userCancellationReason()); - return true; - } - - private enqueueStep(job: TurnJob, request: StepRequest, options?: StepEnqueueOptions): Step { - const existing = job.steps.get(request.id); - if (existing !== undefined && existing.state !== 'cancelled') { - job.queue.enqueue(request, options?.at ?? 'tail'); - existing.state = 'queued'; - return existing; - } - const controller = new AbortController(); - const result = createControlledPromise(); - const step: MutableStep = { - id: request.id, - turnId: job.turn.id, - state: 'queued', - signal: controller.signal, - result, - controller, - resultControl: result, - cancel: (reason) => this.cancelStep(job, step, request, reason), - }; - job.steps.set(step.id, step); - job.queue.enqueue(request, options?.at ?? 'tail'); - return step; - } - - private cancelStep(job: TurnJob, step: MutableStep, request: StepRequest, reason?: unknown): boolean { - if (step.state === 'completed' || step.state === 'failed' || step.state === 'cancelled') return false; - const cancellation = reason ?? userCancellationReason(); - step.state = 'cancelled'; - request.abort(); - step.controller?.abort(cancellation); - step.resultControl?.resolve({ type: 'cancelled', reason: cancellation }); - return true; - } - - private pumpTurns(): void { - if (this.disposing || this.quiescenceDepth > 0 || this.activeTurnJob !== undefined) return; - const job = this.pendingTurns.shift(); - if (job === undefined) { - this.maybeSettle(); - return; - } - this.startTurn(job); - } - - private startTurn(job: TurnJob): void { - const origin = job.seed.origin; - void this.dispatcher.dispatch( - new TurnPrompt({ - agentId: this.scopeContext.agentId, - input: job.seed.input, - origin, - promptId: job.seed.promptId, - }), - ); - job.turn.state = 'running'; - this.activeTurnJob = job; - void this.dispatcher.dispatch( - new TurnStarted({ - agentId: this.scopeContext.agentId, - turnId: job.turn.id, - promptId: job.seed.promptId, - origin, - prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined, - promptAttachments: turnPromptAttachments(job.seed.input, origin), - }), - ); - void this.runTurn(job.turn, job.ready).then(job.result.resolve, job.result.reject); - } - - private async runTurn( - turn: Turn, - ready: ReturnType>, - ): Promise { - const startedAt = Date.now(); - this.telemetry.setContext({ turn_id: turn.id }); - const { mode, provider_type, protocol } = this.telemetry.getContext(); - let thinkingEffort: string | undefined; - let result: TurnResult | undefined; - try { - thinkingEffort = this.llmRequester.prepareTurnConfig(turn.id)?.thinkingEffort; - this.telemetry.setContext({ thinking_effort: thinkingEffort }); - const started: TurnStartedTelemetryEvent = { - turn_id: turn.id, - mode: mode ?? 'agent', - provider_type, - protocol, - }; - this.telemetry.track2('turn_started', started); - result = await this.run({ - turnId: turn.id, - signal: turn.signal, - onStarted: () => ready.resolve(), - }); - return result; - } catch (error) { - result = this.resultFromTurnError(turn, error); - return result; - } finally { - this.settleTurnReady(ready, result); - this.releaseActiveTurn(turn, result); - const traceId = - result?.type === 'completed' - ? this.lastRequestTraceId - : this.activeRequestTrace?.traceId; - if (result !== undefined) { - const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; - const interruptReason = - result.type === 'completed' ? undefined : interruptReasonFor(result); - const durationMs = Date.now() - startedAt; - void this.dispatcher.dispatch( - new TurnEnded({ - agentId: this.scopeContext.agentId, - turnId: turn.id, - reason: result.type, - error, - durationMs, - interruptReason, - stopReason: result.type === 'completed' ? result.stopReason : undefined, - }), - ); - if (error !== undefined) { - void this.dispatcher.dispatch( - new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), - ); - } - if (interruptReason !== undefined) { - const interrupted: TurnInterruptedEvent = { - turn_id: turn.id, - at_step: result.steps, - mode: mode ?? 'agent', - interrupt_reason: interruptReason, - provider_type, - protocol, - trace_id: traceId, - }; - this.telemetry.track2('turn_interrupted', interrupted); - } - } - const ended: TurnEndedTelemetryEvent = { - turn_id: turn.id, - reason: result?.type ?? 'failed', - duration_ms: Date.now() - startedAt, - mode: mode ?? 'agent', - provider_type, - protocol, - trace_id: traceId, - }; - this.telemetry.track2('turn_ended', ended); - this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); - this.activeRequestTrace = undefined; - this.lastRequestTraceId = undefined; - this.pumpTurns(); - } - } - - private resultFromTurnError(turn: Turn, error: unknown): TurnResult { - const signal = turn.signal; - if (!signal?.aborted) return { type: 'failed', error, steps: 0 }; - return { type: 'cancelled', steps: 0, reason: signal.reason ?? error }; - } - - private settleTurnReady( - ready: ReturnType>, - result: TurnResult | undefined, - ): void { - if (result?.type === 'failed') { - ready.reject(result.error); - } else if (result?.type === 'cancelled') { - ready.reject(result.reason instanceof Error ? result.reason : abortError('Turn cancelled')); - } else { - ready.reject(new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step')); - } - } - - private releaseActiveTurn(turn: Turn, result: TurnResult | undefined): void { - (turn as MutableTurn).state = result?.type ?? 'failed'; - const job = this.activeTurnJob?.turn === turn ? this.activeTurnJob : undefined; - if (job === undefined) return; - const reason = result?.type === 'cancelled' ? result.reason : abortError('Turn ended'); - for (const step of job.steps.values()) { - if (step.state === 'queued' || step.state === 'running') step.cancel(reason); - } - this.activeTurnJob = undefined; - this.maybeSettle(); - } - registerLoopErrorHandler( handler: LoopErrorHandler, options: LoopErrorHandlerRegistrationOptions = {}, @@ -634,452 +486,716 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return true; } - async run(options: LoopRunOptions): Promise { - const runtime = this.createLoopRuntime(options); - try { - while (true) { - try { - const begun = this.beginLoopStep(runtime); - if ('result' in begun) return begun.result; - runtime.current = begun.step; - const result = await this.executeLoopStep( - runtime.turnId, - begun.step.signal, - runtime.turnSignal, - begun.step.number, - runtime.job !== undefined && begun.step.number === 1, - begun.step.uuid, - options.onStarted, - ); - const completed = this.completeLoopStep(runtime, result); - if (completed !== undefined) return completed; - } catch (error) { - const disposition = await this.handleLoopStepError(runtime, error); - if (disposition.type === 'return') return disposition.result; - } - } - } finally { - runtime.queue.abortTurnScoped(); - } - } - - private createLoopRuntime(options: LoopRunOptions): LoopRuntime { - const job = this.activeTurnJob?.turn.id === options.turnId ? this.activeTurnJob : undefined; - return { - turnId: options.turnId, - turnSignal: options.signal ?? new AbortController().signal, - job, - queue: job?.queue ?? this.standaloneStepQueue, - steps: 0, - lastStopReason: undefined, - forcedStopReason: undefined, - current: undefined, - }; - } + private async gate(machineSignal: AbortSignal): Promise { - private completedResult(runtime: LoopRuntime): LoopRunResult { - const truncated = runtime.lastStopReason === 'truncated'; - if (runtime.forcedStopReason === undefined) { - return { type: 'completed', steps: runtime.steps, truncated }; - } - return { - type: 'completed', - steps: runtime.steps, - truncated, - stopReason: runtime.forcedStopReason, - }; - } - private beginLoopStep(runtime: LoopRuntime): BeginStepResult { - runtime.current = undefined; - runtime.turnSignal.throwIfAborted(); - if (!runtime.queue.hasPendingRequests()) { - return { result: this.completedResult(runtime) }; + const active = this.active; + if (active !== undefined) await active.afterChain; + if (this.machineTurnUnbound && !this.bindMachineTurn()) { + return { type: 'fail' }; } + const turn = this.active; + if (turn === undefined) return { type: 'fail' }; + if (turn.controller.signal.aborted || machineSignal.aborted) return { type: 'fail' }; + if (turn.stopRequested) return { type: 'fail' }; + if (turn.failedStep !== undefined) return { type: 'fail' }; + const consumed = this.mirrorConsumedNudges(turn); + if (turn.toolStopRequested && consumed.live === 0) return { type: 'fail' }; const maxSteps = this.config.get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; if ( maxSteps !== undefined && maxSteps > 0 && - runtime.steps >= maxSteps && - runtime.queue.peekDriverKind() !== HANDOFF_STEP_KIND + turn.steps >= maxSteps && + !consumed.bypass ) { - throw createMaxStepsExceededError(maxSteps); + turn.maxStepsError = createMaxStepsExceededError(maxSteps); + return { type: 'fail' }; } - const batch = runtime.queue.takeNextBatch()!; - const mutableStep = runtime.job?.steps.get(batch.driver.id); - if (mutableStep !== undefined) { - mutableStep.state = 'running'; - mutableStep.controller = new AbortController(); - mutableStep.signal = mutableStep.controller.signal; - } - const step: StepRuntime = { - number: ++runtime.steps, + turn.steps += 1; + turn.gatedSteps = turn.steps; + const step: MachineStepState = { + number: turn.steps, uuid: randomUUID(), - batch, - mutableStep, - signal: mutableStep?.controller === undefined - ? runtime.turnSignal - : AbortSignal.any([runtime.turnSignal, mutableStep.controller.signal]), + signal: turn.controller.signal, + contentAppended: false, + entry: undefined, + usage: undefined, + timing: undefined, + providerFinishReason: undefined, + rawFinishReason: undefined, + messageId: undefined, + pendingToolIds: new Set(), + toolCallUuids: new Map(), + resolvedToolIds: new Set(), + toolStopTurn: false, }; - EventEmitter.setMaxListeners(MAX_STEP_SIGNAL_LISTENERS, step.signal); - this.materializeBatch(batch); - return { step }; - } + turn.current = step; + turn.interruptStep = step.number; + this.activeRequestTrace = undefined; + this.telemetry.setContext({ trace_id: undefined }); + EventEmitter.setMaxListeners(MAX_STEP_SIGNAL_LISTENERS, turn.controller.signal); + try { - private completeLoopStep( - runtime: LoopRuntime, - result: StepExecutionResult, - ): LoopRunResult | undefined { - const current = runtime.current!; - if (current.mutableStep !== undefined) { - current.mutableStep.state = 'completed'; - current.mutableStep.resultControl?.resolve({ type: 'completed' }); - } - runtime.current = undefined; - runtime.lastStopReason = result.stopReason; - if (result.stopTurnReason !== undefined && runtime.forcedStopReason === undefined) { - runtime.forcedStopReason = result.stopTurnReason; - } - if (result.stopReason === 'filtered') { - throw new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { - name: 'ProviderFilteredError', - details: { finishReason: 'filtered' }, + await this.hooks.onWillBeginStep.run({ + turnId: turn.id, + step: step.number, + firstStepOfTurn: step.number === 1, + signal: step.signal, }); + + } catch (error) { + + return this.failMachineGate(turn, step, error); + } + if (step.signal.aborted) { + return this.failMachineGate(turn, step, step.signal.reason ?? abortError('Step aborted')); } - if (!result.hookStopTurn) return undefined; - return this.completedResult(runtime); + return { type: 'proceed', signal: step.signal, step: step.number }; } - private async handleLoopStepError( - runtime: LoopRuntime, + private failMachineGate( + turn: ActiveTurn, + step: MachineStepState, error: unknown, - ): Promise { - const cancellation = this.handleLoopCancellation(runtime, error); - if (cancellation !== undefined) return cancellation; - const recovery = await this.tryRecoverLoopError(runtime, error); - return recovery ?? this.failLoopStep(runtime, error); + ): MachineGateDecision { + if (turn.controller.signal.aborted || isAbortError(error) || step.signal.aborted) { + turn.abortReason = turn.controller.signal.aborted ? turn.controller.signal.reason : error; + return { type: 'fail' }; + } + turn.failedStep = { + number: step.number, + uuid: step.uuid, + error, + }; + return { type: 'fail' }; } - private handleLoopCancellation( - runtime: LoopRuntime, - error: unknown, - ): LoopErrorDisposition | undefined { - const step = runtime.current?.mutableStep; - if (!isAbortError(error) && !runtime.turnSignal.aborted && step?.signal.aborted !== true) return undefined; - const reason = runtime.turnSignal.reason ?? step?.signal.reason ?? error; - this.emitStepInterrupted( - runtime.turnId, - runtime.current?.number, - 'aborted', - isUserCancellation(reason) ? undefined : toErrorMessage(reason), + private bindMachineTurn(): boolean { + this.machineTurnUnbound = false; + if (this.active !== undefined) return true; + const drained = this.unboundDrained; + this.unboundDrained = undefined; + if (drained !== undefined) { + const index = this.reservations.indexOf(drained); + if (index >= 0) this.reservations.splice(index, 1); + if (drained.cancelled) { + this.machineTurnSuppressed = true; + return false; + } + this.beginActiveTurn(drained.turn, drained.controller, drained); + drained.onMaterialize?.(); + this.materializeMessage(drained.message); + return true; + } + const seeded = this.nudges.slice(this.nudgeCursor).find( + (nudge) => !nudge.dropped && nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0, ); - if (!runtime.turnSignal.aborted && step?.state === 'cancelled') { - runtime.current = undefined; - return { type: 'continue' }; + if (seeded === undefined) { + this.machineTurnSuppressed = true; + return false; } - return { type: 'return', result: { type: 'cancelled', reason, steps: runtime.steps } }; + const message = seeded.contextMessage as ContextMessage; + const id = this.reserveTurnId(); + const controller = new AbortController(); + const ready = createControlledPromise(); + const result = createControlledPromise(); + void ready.catch(() => undefined); + const turn: MutableTurn = { + id, + state: 'queued', + signal: controller.signal, + ready, + result, + cancel: (reason) => this.cancel(id, reason), + }; + const origin = message.origin ?? { kind: 'user' }; + this.beginActiveTurn(turn, controller, { + id, + machineQueueId: `turn-${String(id)}`, + message, + origin, + promptId: message.id, + onMaterialize: undefined, + cancelled: false, + controller, + ready, + result, + turn, + }); + return true; } - private async tryRecoverLoopError( - runtime: LoopRuntime, - error: unknown, - ): Promise { - const current = runtime.current; - const context: LoopErrorContext = { - currentStep: current?.mutableStep, - turnId: runtime.turnId, - step: current?.number, - stepId: current?.uuid, - signal: runtime.turnSignal, - error, - failedDriver: current?.batch.driver, - retry: (request, options) => { - if (runtime.job !== undefined) return this.enqueueStep(runtime.job, request, options); - runtime.queue.enqueue(request, options?.at ?? 'tail'); - return current?.mutableStep ?? { - id: request.id, - turnId: runtime.turnId, - state: 'queued', - signal: runtime.turnSignal, - result: Promise.resolve({ type: 'completed' }), - cancel: () => request.abort(), - }; - }, + private beginActiveTurn( + turn: MutableTurn, + controller: AbortController, + reservation: TurnReservation, + ): void { + + const id = reservation.id; + const active: ActiveTurn = { + id, + reservation, + controller, + turn, + startedAt: Date.now(), + steps: 0, + gatedSteps: 0, + nudgeCursor: this.nudgeCursor, + current: undefined, + interruptStep: undefined, + failedStep: undefined, + stopRequested: false, + toolStopRequested: false, + forcedStopReason: undefined, + lastStopReason: undefined, + filtered: false, + maxStepsError: undefined, + abortReason: undefined, + retryRequested: false, + afterChain: Promise.resolve(), + partials: [], + forceContentPartBoundary: false, + readyResolved: false, + mode: undefined, + providerType: undefined, + protocol: undefined, }; - const handler = this.errorHandlers.find((entry) => entry.match(context)); - if (handler === undefined) return undefined; - try { - if (await handler.handle(context)) { - runtime.current = undefined; - return { type: 'continue' }; - } - return undefined; - } catch (handlerError) { - return this.handleLoopCancellation(runtime, handlerError) ?? this.failLoopStep(runtime, handlerError); - } + this.active = active; + active.mode = this.telemetry.getContext().mode; + const { provider_type, protocol } = this.telemetry.getContext(); + active.providerType = provider_type; + active.protocol = protocol; + this.telemetry.setContext({ turn_id: id }); + const thinkingEffort = this.llmRequester.prepareTurnConfig(id)?.thinkingEffort; + this.telemetry.setContext({ thinking_effort: thinkingEffort }); + void this.dispatcher.dispatch( + new TurnPrompt({ + agentId: this.scopeContext.agentId, + input: reservation.message.content, + origin: reservation.origin, + promptId: reservation.promptId, + }), + ); + turn.state = 'running'; + void this.dispatcher.dispatch( + new TurnStarted({ + agentId: this.scopeContext.agentId, + turnId: id, + promptId: reservation.promptId, + origin: reservation.origin, + prompt: isDisplayablePromptOrigin(reservation.origin) + ? turnPromptText(reservation.message.content, reservation.origin) + : undefined, + promptAttachments: turnPromptAttachments(reservation.message.content, reservation.origin), + }), + ); + const started: TurnStartedTelemetryEvent = { + turn_id: id, + mode: active.mode ?? 'agent', + provider_type, + protocol, + }; + this.telemetry.track2('turn_started', started); } - private failLoopStep(runtime: LoopRuntime, error: unknown): LoopErrorDisposition { - const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; - const interruptedError = - isError2(error) && error.code === ErrorCodes.INTERNAL && error.cause !== undefined ? error.cause : error; - this.emitStepInterrupted(runtime.turnId, runtime.current?.number, reason, toErrorMessage(interruptedError)); - return { type: 'return', result: { type: 'failed', error, steps: runtime.steps } }; + private materializeMessage(message: ContextMessage): void { + if (message.content.length === 0) return; + this.context.append(message); } - private materializeBatch(batch: StepRequestBatch): void { - this.materializeRequest(batch.driver); - for (const request of batch.merged) { - this.materializeRequest(request); + private mirrorConsumedNudges(turn: ActiveTurn): { readonly live: number; readonly bypass: boolean } { + const engine = this.engine; + if (engine === undefined) return { live: 0, bypass: false }; + const notificationCount = engine.snapshot().notificationCount; + let consumed = this.nudges.length - this.nudgeCursor - notificationCount; + let live = 0; + let bypass = false; + while (consumed > 0 && this.nudgeCursor < this.nudges.length) { + const nudge = this.nudges[this.nudgeCursor]!; + this.nudgeCursor += 1; + consumed -= 1; + if (nudge.dropped) continue; + live += 1; + bypass = bypass || nudge.bypassMaxSteps; + nudge.consumed = true; + if (nudge.contextMessage !== undefined && nudge.contextMessage.content.length > 0) { + this.materializeMessage(nudge.contextMessage); + } + nudge.onConsume?.(); } + turn.nudgeCursor = this.nudgeCursor; + return { live, bypass }; } - private materializeRequest(request: StepRequest): void { - if (request.state !== 'pending') return; - request.onWillMaterialize(); - const messages = request.resolveContextMessages(); - if (messages.length > 0) { - this.context.append(...messages); + private reconcileDrainedQueueEntry(): void { + const engine = this.engine; + if (engine === undefined) return; + const queueIds = engine.snapshot().queueIds; + const drainedIds: string[] = []; + for (const id of this.pendingMachineQueueIds) { + if (!queueIds.includes(id)) drainedIds.push(id); + } + for (const id of drainedIds) { + this.pendingMachineQueueIds.delete(id); + const reservation = this.reservations.find((entry) => entry.machineQueueId === id); + if (reservation === undefined) continue; + if (this.active === undefined) { + this.unboundDrained = reservation; + } else { + this.pendingMachineQueueIds.add(reservation.machineQueueId); + this.machineEngine().submit({ + id: reservation.machineQueueId, + message: machineUserMessage(reservation.message), + }); + } } - request.markMaterialized(); } - private async executeLoopStep( - turnId: number, - signal: AbortSignal, - turnSignal: AbortSignal, - currentStep: number, - firstStepOfTurn: boolean, - stepUuid: string, - onStarted: ((step: number) => void) | undefined, - ): Promise { - this.activeRequestTrace = undefined; - this.telemetry.setContext({ trace_id: undefined }); - await this.hooks.onWillBeginStep.run({ turnId, step: currentStep, firstStepOfTurn, signal }); - const markStepStarted = this.beginStep(turnId, signal, currentStep, stepUuid, onStarted); - let stepEndAppended = false; - try { - const streamParts = this.createStreamPartHandler(turnId, markStepStarted); - const request = this.llmRequester.start( - { source: { type: 'turn', turnId, step: currentStep } }, - streamParts.handle, - signal, - ); - this.activeRequestTrace = request.trace; - let response: AgentLLMRequestFinish; - try { - response = await request.result; - } catch (error) { - this.appendInterruptedStreamContent(turnId, currentStep, stepUuid, streamParts); - throw error; + private projectMachineEvent(event: MachineEngineEvent): void { + switch (event.type) { + case 'turnStarted': { + this.reconcileDrainedQueueEntry(); + this.machineTurnUnbound = true; + this.machineTurnSuppressed = false; + return; } - this.lastRequestTraceId = request.trace.traceId; - this.appendResponseContent(turnId, currentStep, stepUuid, response); - const { finishReason, stopTurnReason } = await this.executeStepTools( - turnId, - signal, - currentStep, - stepUuid, - response, - request.trace, - ); - this.finishStep(turnId, signal, currentStep, stepUuid, response, finishReason, markStepStarted); - stepEndAppended = true; - const hookStopTurn = await this.runAfterStep( - turnId, - signal, - currentStep, - firstStepOfTurn, - response.usage, - finishReason, - ); - return { stopReason: finishReason, hookStopTurn, stopTurnReason }; - } catch (error) { - if (!stepEndAppended) { + case 'turnSettled': { + const outcome = event; + const active = this.active; + if (this.machineTurnSuppressed) { + this.machineTurnSuppressed = false; + this.maybeSettle(); + return; + } + if (active === undefined) return; + active.afterChain = active.afterChain.then(() => this.evaluateSettle(active, outcome)); + return; + } + case 'stepStarted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + if (!turn.readyResolved) { + turn.readyResolved = true; + turn.reservation.ready.resolve(); + } + void this.dispatcher.dispatch( + new TurnStepStarted({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step.number, + stepId: step.uuid, + }), + ); this.context.appendLoopEvent({ - type: 'step.end', - uuid: stepUuid, - turnId: String(turnId), - step: currentStep, - finishReason: - isAbortError(error) || signal.aborted || turnSignal.aborted ? 'interrupted' : 'error', + type: 'step.begin', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, }); + turn.partials = []; + turn.forceContentPartBoundary = false; + return; + } + case 'delta': { + const turn = this.active; + if (turn === undefined) return; + const delta = event.delta; + switch (delta.kind) { + case 'assistant': + this.accumulateMachinePart(turn, { type: 'text', text: delta.delta }); + void this.dispatcher.dispatch( + new AssistantDelta({ agentId: this.scopeContext.agentId, turnId: turn.id, delta: delta.delta }), + ); + return; + case 'thinking': + this.accumulateMachinePart(turn, { type: 'think', think: delta.delta }); + void this.dispatcher.dispatch( + new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId: turn.id, delta: delta.delta }), + ); + return; + case 'toolCall': + if (delta.started === true) turn.forceContentPartBoundary = true; + void this.dispatcher.dispatch( + new ToolCallDelta({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + toolCallId: delta.toolCallId, + name: delta.name, + argumentsPart: delta.argumentsPart, + }), + ); + return; + } + return; + } + case 'stepCompleted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + step.entry = event.entry; + step.usage = event.usage; + step.timing = event.timing; + step.providerFinishReason = event.finish?.finishReason ?? undefined; + step.rawFinishReason = event.finish?.rawFinishReason ?? undefined; + step.messageId = event.messageId; + for (const part of event.entry.message.content) { + this.context.appendLoopEvent({ + type: 'content.part', + uuid: randomUUID(), + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + part, + }); + } + step.contentAppended = true; + this.lastRequestTraceId = this.activeRequestTrace?.traceId; + const toolCalls = event.entry.message.toolCalls; + if (toolCalls.length === 0) { + const finishReason = step.providerFinishReason ?? 'completed'; + this.endOrInterruptMachineStep(turn, step, finishReason === 'tool_calls' ? 'other' : finishReason); + } else { + step.pendingToolIds = new Set(toolCalls.map((call) => call.id)); + } + return; } - throw error; + case 'toolStarted': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + const callUuid = randomUUID(); + step.toolCallUuids.set(event.toolCallId, callUuid); + const extras = step.entry?.message.toolCalls.find((call) => call.id === event.toolCallId)?.extras; + this.context.appendLoopEvent({ + type: 'tool.call', + uuid: callUuid, + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + toolCallId: event.toolCallId, + name: event.name, + args: event.args, + extras, + }); + return; + } + case 'toolDone': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + step.pendingToolIds.delete(event.toolCallId); + if (this.isCannedUnknownToolResult(step, event.toolCallId, event.result)) { + turn.afterChain = turn.afterChain.then(async () => { + await this.executeUnknownToolCall(turn, step, event.toolCallId); + if (turn.current === step && step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + }); + return; + } + if (step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + return; + } + case 'toolFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + const message = event.error instanceof Error ? event.error.message : String(event.error); + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: step.toolCallUuids.get(event.toolCallId) ?? randomUUID(), + toolCallId: event.toolCallId, + result: { output: message, isError: true }, + }); + step.resolvedToolIds.add(event.toolCallId); + step.pendingToolIds.delete(event.toolCallId); + if (step.pendingToolIds.size === 0) { + this.endOrInterruptMachineStep(turn, step, step.toolStopTurn ? 'completed' : 'tool_calls'); + } + return; + } + case 'toolBatchFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + if (step.signal.aborted) return; + this.closeFailedMachineStep(turn, step, 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error: event.error, + }; + turn.current = undefined; + this.machineEngine().abort(); + return; + } + case 'retrying': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined) return; + if (step !== undefined) { + this.closeFailedMachineStep(turn, step, 'error'); + } + const fields = + event.rawError !== undefined + ? retryErrorFields(event.rawError) + : { + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }; + void this.dispatcher.dispatch( + new TurnStepRetrying({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step?.number ?? turn.gatedSteps, + stepId: step?.uuid, + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: fields.errorName, + errorMessage: fields.errorMessage, + statusCode: fields.statusCode, + }), + ); + turn.current = undefined; + return; + } + case 'stepFailed': { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; + this.closeFailedMachineStep(turn, step, step.signal.aborted ? 'interrupted' : 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error: event.rawError ?? event.error, + }; + turn.current = undefined; + return; + } + default: + return; } } - private beginStep( - turnId: number, - signal: AbortSignal, - currentStep: number, - stepUuid: string, - onStarted: ((step: number) => void) | undefined, - ): () => void { - signal.throwIfAborted(); - void this.dispatcher.dispatch( - new TurnStepStarted({ - agentId: this.scopeContext.agentId, - turnId, - step: currentStep, - stepId: stepUuid, - }), + private isCannedUnknownToolResult( + step: MachineStepState, + toolCallId: string, + result: { readonly content: readonly ContentPart[]; readonly isError?: boolean }, + ): boolean { + if (step.toolCallUuids.has(toolCallId)) return false; + if (result.isError !== true || result.content.length !== 1) return false; + const part = result.content[0]; + const call = step.entry?.message.toolCalls.find((entry) => entry.id === toolCallId); + return ( + part !== undefined && + part.type === 'text' && + call !== undefined && + part.text === `unknown tool: ${call.name}` ); + } + + private async executeUnknownToolCall( + turn: ActiveTurn, + step: MachineStepState, + toolCallId: string, + ): Promise { + const call = step.entry?.message.toolCalls.find((entry) => entry.id === toolCallId); + if (call === undefined) return; + try { + for await (const result of this.toolExecutor.execute([call], { + signal: turn.controller.signal, + turnId: turn.id, + trace: this.activeRequestTrace, + onToolCall: (payload) => { + const callUuid = randomUUID(); + step.toolCallUuids.set(payload.toolCallId, callUuid); + const extras = step.entry?.message.toolCalls.find( + (entry) => entry.id === payload.toolCallId, + )?.extras; + this.context.appendLoopEvent({ + type: 'tool.call', + uuid: callUuid, + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, + toolCallId: payload.toolCallId, + name: payload.name, + args: payload.args, + extras, + }); + }, + })) { + if (result.toolCallId === toolCallId) { + this.appendMachineToolResult(toolCallId, result.result); + } + } + } catch (error) { + if (this.active !== turn || turn.current !== step || step.signal.aborted) return; + this.closeFailedMachineStep(turn, step, 'error'); + turn.failedStep ??= { + number: step.number, + uuid: step.uuid, + error, + }; + turn.current = undefined; + this.machineEngine().abort(); + } + } + + private accumulateMachinePart(turn: ActiveTurn, part: ContentPart): void { + const last = turn.partials.at(-1); + if (!turn.forceContentPartBoundary && last !== undefined && mergeInPlace(last, part)) return; + turn.forceContentPartBoundary = false; + turn.partials.push({ ...part }); + } + + private appendMachineToolResult( + toolCallId: string, + result: { + readonly output: string | ContentPart[]; + readonly isError?: boolean; + readonly note?: string; + readonly stopTurn?: boolean; + readonly stopTurnReason?: string; + }, + ): void { + const turn = this.active; + const step = turn?.current; + if (turn === undefined || step === undefined) return; this.context.appendLoopEvent({ - type: 'step.begin', - uuid: stepUuid, - turnId: String(turnId), - step: currentStep, + type: 'tool.result', + parentUuid: step.toolCallUuids.get(toolCallId) ?? randomUUID(), + toolCallId, + result: { output: result.output, isError: result.isError, note: result.note }, }); - let stepStarted = false; - return () => { - if (stepStarted) return; - stepStarted = true; - onStarted?.(currentStep); - }; + step.resolvedToolIds.add(toolCallId); + if (result.stopTurn === true) { + step.toolStopTurn = true; + turn.toolStopRequested = true; + turn.forcedStopReason ??= result.stopTurnReason; + } } - private appendResponseContent( - turnId: number, - currentStep: number, - stepUuid: string, - response: AgentLLMRequestFinish, - ): void { - for (const part of response.message.content) { + private drainMachinePartials(turn: ActiveTurn, step: MachineStepState): void { + for (const part of turn.partials.splice(0).filter((entry) => !isVacuousContentPart(entry))) { this.context.appendLoopEvent({ type: 'content.part', uuid: randomUUID(), - turnId: String(turnId), - step: currentStep, - stepUuid, + turnId: String(turn.id), + step: step.number, + stepUuid: step.uuid, part, }); } } - private appendInterruptedStreamContent( - turnId: number, - currentStep: number, - stepUuid: string, - streamParts: StreamPartCollector, + private closeFailedMachineStep( + turn: ActiveTurn, + step: MachineStepState, + finishReason: 'error' | 'interrupted', ): void { - for (const part of streamParts.drainInterruptedContent()) { - this.context.appendLoopEvent({ - type: 'content.part', - uuid: randomUUID(), - turnId: String(turnId), - step: currentStep, - stepUuid, - part, - }); - } + if (!step.contentAppended) this.drainMachinePartials(turn, step); + this.context.appendLoopEvent({ + type: 'step.end', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason, + }); } - private async executeStepTools( - turnId: number, - signal: AbortSignal, - currentStep: number, - stepUuid: string, - response: AgentLLMRequestFinish, - trace: LLMRequestTrace, - ): Promise { - let finishReason = response.providerFinishReason ?? 'completed'; - if (response.message.toolCalls.length === 0) { - return { finishReason: finishReason === 'tool_calls' ? 'other' : finishReason }; - } - const toolCallUuids = new Map(); - let stopTurn = false; - let stopTurnReason: string | undefined; - for await (const toolResult of this.toolExecutor.execute(response.message.toolCalls, { - signal, - turnId, - trace, - onToolCall: ({ toolCallId, name, args }) => { - const callUuid = randomUUID(); - toolCallUuids.set(toolCallId, callUuid); - const extras = response.message.toolCalls.find((t) => t.id === toolCallId)?.extras; - this.context.appendLoopEvent({ - type: 'tool.call', - uuid: callUuid, - turnId: String(turnId), - step: currentStep, - stepUuid, - toolCallId, - name, - args, - extras, - }); - }, - })) { - const { result } = toolResult; + private endOrInterruptMachineStep( + turn: ActiveTurn, + step: MachineStepState, + finishReason: FinishReason, + ): void { + if (turn.controller.signal.aborted) { this.context.appendLoopEvent({ - type: 'tool.result', - parentUuid: toolCallUuids.get(toolResult.toolCallId) ?? randomUUID(), - toolCallId: toolResult.toolCallId, - result: { output: result.output, isError: result.isError, note: result.note }, + type: 'step.end', + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason: 'interrupted', }); - if (result.stopTurn === true) { - stopTurn = true; - stopTurnReason ??= result.stopTurnReason; - } + turn.current = undefined; + return; } - finishReason = stopTurn ? 'completed' : 'tool_calls'; - return { finishReason, stopTurnReason }; + this.endMachineStep(turn, step, finishReason); } - private finishStep( - turnId: number, - signal: AbortSignal, - currentStep: number, - stepUuid: string, - response: AgentLLMRequestFinish, - finishReason: FinishReason, - markStepStarted: () => void, + private endMachineStep(turn: ActiveTurn, step: MachineStepState, finishReason: FinishReason): void { + const normalized = normalizeFinishReason(finishReason); + const usage = step.usage ?? emptyUsage(); + turn.lastStopReason = finishReason; + turn.current = undefined; + const firstStepOfTurn = step.number === 1; + turn.afterChain = turn.afterChain.then(async () => { + this.finishMachineStepProjection(turn, step, normalized, usage); + await this.runMachineAfterStep(turn, step, firstStepOfTurn, usage, finishReason); + }); + } + + private finishMachineStepProjection( + turn: ActiveTurn, + step: MachineStepState, + normalized: string, + usage: TokenUsage, ): void { - signal.throwIfAborted(); - markStepStarted(); - const timing = response.timing; - const stepFinishReason = normalizeFinishReason(finishReason); this.context.appendLoopEvent({ type: 'step.end', - uuid: stepUuid, - turnId: String(turnId), - step: currentStep, - finishReason: stepFinishReason, - usage: response.usage, - llmFirstTokenLatencyMs: timing?.firstTokenLatencyMs, - llmStreamDurationMs: timing?.streamDurationMs, - llmRequestBuildMs: timing?.requestBuildMs, - llmServerFirstTokenMs: timing?.serverFirstTokenMs, - llmServerDecodeMs: timing?.serverDecodeMs, - llmClientConsumeMs: timing?.clientConsumeMs, - messageId: response.providerMessageId, - providerFinishReason: response.providerFinishReason, - rawFinishReason: response.rawFinishReason, + uuid: step.uuid, + turnId: String(turn.id), + step: step.number, + finishReason: normalized, + usage, + llmFirstTokenLatencyMs: step.timing?.firstTokenLatencyMs, + llmStreamDurationMs: step.timing?.streamDurationMs, + llmRequestBuildMs: step.timing?.requestBuildMs, + llmServerFirstTokenMs: step.timing?.serverFirstTokenMs, + llmServerDecodeMs: step.timing?.serverDecodeMs, + llmClientConsumeMs: step.timing?.clientConsumeMs, + messageId: step.messageId, + providerFinishReason: step.providerFinishReason, + rawFinishReason: step.rawFinishReason, }); - this.emitStepCompleted( - turnId, - currentStep, - stepUuid, - response.usage, - stepFinishReason, - response, + void this.dispatcher.dispatch( + new TurnStepCompleted({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + step: step.number, + stepId: step.uuid, + usage, + finishReason: normalized, + llmFirstTokenLatencyMs: step.timing?.firstTokenLatencyMs, + llmStreamDurationMs: step.timing?.streamDurationMs, + llmRequestBuildMs: step.timing?.requestBuildMs, + llmServerFirstTokenMs: step.timing?.serverFirstTokenMs, + llmServerDecodeMs: step.timing?.serverDecodeMs, + llmClientConsumeMs: step.timing?.clientConsumeMs, + providerFinishReason: step.providerFinishReason, + rawFinishReason: step.rawFinishReason, + }), ); } - private async runAfterStep( - turnId: number, - signal: AbortSignal, - currentStep: number, + private async runMachineAfterStep( + turn: ActiveTurn, + step: MachineStepState, firstStepOfTurn: boolean, usage: TokenUsage, finishReason: FinishReason, - ): Promise { + ): Promise { const context: AfterStepContext = { - turnId, - step: currentStep, + turnId: turn.id, + step: step.number, firstStepOfTurn, - signal, + signal: step.signal, usage, finishReason, stopTurn: false, @@ -1087,37 +1203,248 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { try { await this.hooks.onDidFinishStep.run(context); } catch (error) { - if (isAbortError(error) || signal.aborted) throw error; + if (isAbortError(error) || step.signal.aborted) { + turn.abortReason = turn.controller.signal.aborted + ? turn.controller.signal.reason + : error; + return; + } } - return context.stopTurn; + turn.interruptStep = undefined; + if (context.stopTurn) turn.stopRequested = true; + if (finishReason === 'filtered') turn.filtered = true; } - private emitStepCompleted( - turnId: number, - step: number, - stepId: string, - usage: TokenUsage, - finishReason: string, - response: AgentLLMRequestFinish, - ): void { + private async evaluateSettle( + turn: ActiveTurn, + outcome: { readonly outcome: MachineTurnOutcome; readonly error?: unknown }, + ): Promise { + if (this.active !== turn) return; + if ( + turn.failedStep !== undefined && + turn.abortReason === undefined && + !turn.controller.signal.aborted + ) { + await this.recoverOrFailMachineRun(turn); + return; + } + if (turn.abortReason !== undefined || turn.controller.signal.aborted || outcome.outcome === 'aborted') { + const reason = + turn.abortReason ?? + (turn.controller.signal.aborted ? turn.controller.signal.reason : undefined) ?? + abortError('Turn aborted'); + this.interruptMachineRunForCancel(turn, reason); + await this.endTurn(turn, { type: 'cancelled', steps: turn.steps, reason }); + return; + } + if (turn.filtered) { + await this.endTurn(turn, { + type: 'failed', + steps: turn.steps, + error: new Error2(ErrorCodes.PROVIDER_FILTERED, 'Provider safety policy blocked the response.', { + name: 'ProviderFilteredError', + details: { finishReason: 'filtered' }, + }), + }); + return; + } + if (turn.maxStepsError !== undefined) { + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: turn.maxStepsError }); + return; + } + if (turn.stopRequested) { + await this.endTurn(turn, this.machineCompletedResult(turn)); + return; + } + if (this.hasLiveNudge()) { + return; + } + if (turn.toolStopRequested) { + await this.endTurn(turn, this.machineCompletedResult(turn)); + return; + } + if (outcome.outcome === 'failed') { + const error = outcome.error ?? new Error('Turn failed'); + this.emitStepInterrupted(turn.id, turn.interruptStep, 'error', toErrorMessage(error)); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error }); + return; + } + await this.endTurn(turn, this.machineCompletedResult(turn)); + } + + private hasLiveNudge(): boolean { + return this.nudges.slice(this.nudgeCursor).some((nudge) => !nudge.dropped); + } + + private async recoverOrFailMachineRun(turn: ActiveTurn): Promise { + const failure = turn.failedStep!; + turn.failedStep = undefined; + const context: LoopErrorContext = { + turnId: turn.id, + step: failure.number, + stepId: failure.uuid, + signal: turn.controller.signal, + error: failure.error, + retry: () => { + turn.retryRequested = true; + }, + }; + const handler = this.errorHandlers.find((entry) => entry.match(context)); + if (handler !== undefined) { + try { + if (await handler.handle(context)) { + turn.interruptStep = undefined; + if (turn.retryRequested) { + turn.retryRequested = false; + this.machineEngine().resetHistory(historyFromContext(this.context.get()), turn.id - 1); + this.machineEngine().notify(EMPTY_MACHINE_PROMPT); + } + return; + } + } catch (handlerError) { + if (isAbortError(handlerError) || turn.controller.signal.aborted) { + const reason = turn.controller.signal.aborted ? turn.controller.signal.reason : handlerError; + this.interruptMachineRunForCancel(turn, reason); + await this.endTurn(turn, { type: 'cancelled', steps: turn.steps, reason }); + return; + } + this.emitStepInterrupted(turn.id, failure.number, 'error', toErrorMessage(handlerError)); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: handlerError }); + return; + } + } + this.failMachineStep(turn, failure.number, failure.error); + await this.endTurn(turn, { type: 'failed', steps: turn.steps, error: failure.error }); + } + + private failMachineStep(turn: ActiveTurn, step: number | undefined, error: unknown): void { + const reason: LoopInterruptReason = isMaxStepsExceededError(error) ? 'max_steps' : 'error'; + const interruptedError = + isError2(error) && error.code === ErrorCodes.INTERNAL && error.cause !== undefined ? error.cause : error; + this.emitStepInterrupted(turn.id, step, reason, toErrorMessage(interruptedError)); + } + + private backfillAbortedToolResults(step: MachineStepState, reason: unknown): void { + for (const toolCallId of step.pendingToolIds) { + if (step.resolvedToolIds.has(toolCallId)) continue; + const name = + step.entry?.message.toolCalls.find((call) => call.id === toolCallId)?.name ?? toolCallId; + this.context.appendLoopEvent({ + type: 'tool.result', + parentUuid: step.toolCallUuids.get(toolCallId) ?? randomUUID(), + toolCallId, + result: { output: abortedToolOutput(name, reason), isError: true }, + }); + step.resolvedToolIds.add(toolCallId); + } + } + + private interruptMachineRunForCancel(turn: ActiveTurn, reason: unknown): void { + const current = turn.current; + if (current !== undefined) { + this.backfillAbortedToolResults(current, reason); + if (!current.contentAppended) this.drainMachinePartials(turn, current); + this.context.appendLoopEvent({ + type: 'step.end', + uuid: current.uuid, + turnId: String(turn.id), + step: current.number, + finishReason: 'interrupted', + }); + turn.current = undefined; + } + if (turn.interruptStep !== undefined) { + this.emitStepInterrupted( + turn.id, + turn.interruptStep, + 'aborted', + isUserCancellation(reason) ? undefined : toErrorMessage(reason), + ); + turn.interruptStep = undefined; + } + } + + private machineCompletedResult(turn: ActiveTurn): LoopRunResult { + const truncated = turn.lastStopReason === 'truncated'; + return { + type: 'completed', + steps: turn.steps, + truncated, + stopReason: turn.forcedStopReason, + }; + } + + private async endTurn(turn: ActiveTurn, result: TurnResult): Promise { + if (this.active !== turn) return; + this.active = undefined; + await this.wire.drainPersisted().catch(() => undefined); + for (const nudge of this.nudges.slice(this.nudgeCursor)) { + if (nudge.turnScoped && !nudge.dropped) { + nudge.dropped = true; + nudge.onDrop?.(); + } + } + turn.turn.state = result.type; + const reservation = turn.reservation; + if (!turn.readyResolved) { + if (result.type === 'failed') { + reservation.ready.reject(result.error); + } else if (result.type === 'cancelled') { + reservation.ready.reject( + result.reason instanceof Error ? result.reason : abortError('Turn cancelled'), + ); + } else { + reservation.ready.reject(new Error2(ErrorCodes.INTERNAL, 'Turn ended before first step')); + } + } + const durationMs = Date.now() - turn.startedAt; + const traceId = + result.type === 'completed' ? this.lastRequestTraceId : this.activeRequestTrace?.traceId; + const error = result.type === 'failed' ? toKimiErrorPayload(result.error) : undefined; + const interruptReason = result.type === 'completed' ? undefined : interruptReasonFor(result); void this.dispatcher.dispatch( - new TurnStepCompleted({ + new TurnEnded({ agentId: this.scopeContext.agentId, - turnId, - step, - stepId, - usage, - finishReason, - llmFirstTokenLatencyMs: response.timing?.firstTokenLatencyMs, - llmStreamDurationMs: response.timing?.streamDurationMs, - llmRequestBuildMs: response.timing?.requestBuildMs, - llmServerFirstTokenMs: response.timing?.serverFirstTokenMs, - llmServerDecodeMs: response.timing?.serverDecodeMs, - llmClientConsumeMs: response.timing?.clientConsumeMs, - providerFinishReason: response.providerFinishReason, - rawFinishReason: response.rawFinishReason, + turnId: turn.id, + reason: result.type, + error, + durationMs, + interruptReason, + stopReason: result.type === 'completed' ? result.stopReason : undefined, }), ); + if (error !== undefined) { + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), + ); + } + if (interruptReason !== undefined) { + const interrupted: TurnInterruptedEvent = { + turn_id: turn.id, + at_step: result.steps, + mode: turn.mode ?? 'agent', + interrupt_reason: interruptReason, + provider_type: turn.providerType, + protocol: turn.protocol, + trace_id: traceId, + }; + this.telemetry.track2('turn_interrupted', interrupted); + } + const ended: TurnEndedTelemetryEvent = { + turn_id: turn.id, + reason: result.type, + duration_ms: durationMs, + mode: turn.mode ?? 'agent', + provider_type: turn.providerType, + protocol: turn.protocol, + trace_id: traceId, + }; + this.telemetry.track2('turn_ended', ended); + this.telemetry.setContext({ turn_id: undefined, trace_id: undefined, thinking_effort: undefined }); + this.activeRequestTrace = undefined; + this.lastRequestTraceId = undefined; + reservation.result.resolve(result); + this.maybeSettle(); } private emitStepInterrupted( @@ -1137,85 +1464,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { }), ); } - - private createStreamPartHandler( - turnId: number, - onResponseEvent: () => void, - ): StreamPartCollector { - const callsByIndex = new Map(); - const partialContent: ContentPart[] = []; - let forceContentPartBoundary = false; - const accumulate = (part: ContentPart): void => { - const last = partialContent.at(-1); - if (!forceContentPartBoundary && last !== undefined && mergeInPlace(last, part)) return; - forceContentPartBoundary = false; - partialContent.push({ ...part }); - }; - - return { - handle: (part) => { - switch (part.type) { - case 'text': - onResponseEvent(); - accumulate(part); - void this.dispatcher.dispatch( - new AssistantDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.text }), - ); - return; - case 'think': - onResponseEvent(); - accumulate(part); - void this.dispatcher.dispatch( - new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.think }), - ); - return; - case 'image_url': - case 'audio_url': - case 'video_url': - return; - case 'function': { - onResponseEvent(); - forceContentPartBoundary = true; - callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); - void this.dispatcher.dispatch( - new ToolCallDelta({ - agentId: this.scopeContext.agentId, - turnId, - toolCallId: part.id, - name: part.name, - argumentsPart: part.arguments ?? undefined, - }), - ); - return; - } - case 'tool_call_part': { - if (part.argumentsPart === null) return; - const toolCall = callsByIndex.get(part.index); - if (toolCall === undefined) return; - onResponseEvent(); - void this.dispatcher.dispatch( - new ToolCallDelta({ - agentId: this.scopeContext.agentId, - turnId, - toolCallId: toolCall.id, - name: toolCall.name, - argumentsPart: part.argumentsPart, - }), - ); - return; - } - default: { - const _exhaustive: never = part; - return _exhaustive; - } - } - }, - drainInterruptedContent: () => - partialContent.splice(0).filter((part) => !isVacuousContentPart(part)), - }; - } } +type MachineGateDecision = + | { readonly type: 'proceed'; readonly signal: AbortSignal; readonly step: number } + | { readonly type: 'fail' }; + function normalizeFinishReason(reason: FinishReason): string { if (reason === 'tool_calls') return 'tool_use'; if (reason === 'completed') return 'end_turn'; @@ -1223,57 +1477,97 @@ function normalizeFinishReason(reason: FinishReason): string { return reason; } +function normalizePromptMessage(prompt: LoopPromptSubmit): ContextMessage { + return prompt.message; +} + +function machineUserMessage(message: ContextMessage | undefined): UserMessage { + if (message === undefined) return EMPTY_MACHINE_PROMPT; + return { role: 'user', content: [...message.content] }; +} + type MutableTurn = { -readonly [K in keyof Turn]: Turn[K]; }; -type MutableStep = { - -readonly [K in keyof Step]: Step[K]; -} & { - controller?: AbortController; - resultControl?: ReturnType>; -}; - -interface TurnJob { - readonly request: StepRequest; - readonly seed: TurnSeed; +interface TurnReservation { + readonly id: number; + readonly machineQueueId: string; + readonly message: ContextMessage; + readonly origin: PromptOrigin; + readonly promptId?: string; + readonly onMaterialize?: () => void; + cancelled: boolean; + launched?: boolean; readonly controller: AbortController; readonly ready: ReturnType>; readonly result: ReturnType>; - readonly queue: StepRequestQueue; - readonly steps: Map; readonly turn: MutableTurn; } -interface HeldAdmission { - readonly request: StepRequest; - readonly options?: StepEnqueueOptions; +interface Nudge { + readonly contextMessage?: ContextMessage; + readonly bypassMaxSteps: boolean; + readonly turnScoped: boolean; + readonly onConsume?: () => void; + readonly onDrop?: () => void; + dropped?: boolean; + consumed?: boolean; + sentToMachine?: boolean; } -interface LoopRuntime { - readonly turnId: number; - readonly turnSignal: AbortSignal; - readonly job: TurnJob | undefined; - readonly queue: StepRequestQueue; - steps: number; - lastStopReason: FinishReason | undefined; - forcedStopReason: string | undefined; - current: StepRuntime | undefined; -} +type MachineStepEntry = Extract['entry']; -interface StepRuntime { +interface MachineStepState { readonly number: number; readonly uuid: string; - readonly batch: StepRequestBatch; - readonly mutableStep: MutableStep | undefined; readonly signal: AbortSignal; + contentAppended: boolean; + entry: MachineStepEntry | undefined; + usage: TokenUsage | undefined; + timing: ModelRequestTiming | undefined; + providerFinishReason: FinishReason | undefined; + rawFinishReason: string | undefined; + messageId: string | undefined; + pendingToolIds: Set; + toolCallUuids: Map; + resolvedToolIds: Set; + toolStopTurn: boolean; } -type BeginStepResult = { readonly step: StepRuntime } | { readonly result: LoopRunResult }; +interface MachineFailedStep { + readonly number: number; + readonly uuid: string; + readonly error: unknown; +} -interface StreamPartCollector { - readonly handle: (part: StreamedMessagePart) => void; - drainInterruptedContent(): ContentPart[]; +interface ActiveTurn { + readonly id: number; + readonly reservation: TurnReservation; + readonly controller: AbortController; + readonly turn: MutableTurn; + readonly startedAt: number; + steps: number; + gatedSteps: number; + nudgeCursor: number; + current: MachineStepState | undefined; + interruptStep: number | undefined; + failedStep: MachineFailedStep | undefined; + stopRequested: boolean; + toolStopRequested: boolean; + forcedStopReason: string | undefined; + lastStopReason: FinishReason | undefined; + filtered: boolean; + maxStepsError: LoopError | undefined; + abortReason: unknown; + retryRequested: boolean; + afterChain: Promise; + partials: ContentPart[]; + forceContentPartBoundary: boolean; + readyResolved: boolean; + mode: 'agent' | 'plan' | undefined; + providerType: string | undefined; + protocol: string | undefined; } function cancelReasonFor(cancellation: unknown): 'user_cancelled' | 'aborted' { @@ -1293,21 +1587,6 @@ function interruptReasonFor( return 'error'; } -type StepExecutionResult = { - readonly stopReason: FinishReason; - readonly hookStopTurn: boolean; - readonly stopTurnReason?: string; -}; - -type StepToolsOutcome = { - readonly finishReason: FinishReason; - readonly stopTurnReason?: string; -}; - -type LoopErrorDisposition = - | { readonly type: 'continue' } - | { readonly type: 'return'; readonly result: LoopRunResult }; - registerScopedService( LifecycleScope.Agent, IAgentLoopService, diff --git a/packages/agent-core-v2/src/agent/loop/machine/engine.ts b/packages/agent-core-v2/src/agent/loop/machine/engine.ts new file mode 100644 index 00000000000..3df60ec3577 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/engine.ts @@ -0,0 +1,393 @@ +import type { IAgentLLMRequesterService, AgentLLMRequestFinish, AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; +import type { ToolInfo, ToolResult as AgentToolResult, ToolUpdate as AgentToolUpdate } from '#/tool/toolContract'; +import { createAgentMachine } from '#human/agent/machine'; +import { createTurnMachine, type AssistantEntry, type HistoryMessage } from '#human/agent/turn'; +import type { LlmErrorMessage } from '#human/llm/errors'; +import type { FinishInfo } from '#human/llm/finish-reason'; +import type { StreamedMessagePart, UserMessage } from '#human/llm/message'; +import type { LlmModel } from '#human/llm/model'; +import { createLlmMachine } from '#human/llm/requester/machine'; +import type { LlmRecovery, LlmRecoveryRecord } from '#human/llm/requester/recovery'; +import type { ToolResult as MachineToolResult, ToolUpdate } from '#human/tool/executor'; +import type { TokenUsage } from '#human/llm/usage'; +import { createActor, type Subscription } from '#human/xstate2'; + +import { createMachineRequester, type MachineRequesterGateDecision } from './requester'; +import { createMachineTools, type ToolResultExtras } from './tools'; + +export type MachineEngineDelta = + | { readonly kind: 'assistant'; readonly delta: string } + | { readonly kind: 'thinking'; readonly delta: string } + | { + readonly kind: 'toolCall'; + readonly toolCallId: string; + readonly name: string; + readonly argumentsPart?: string; + readonly started?: boolean; + }; + +export type MachineTurnOutcome = 'done' | 'failed' | 'aborted'; + +export type MachineEngineEvent = + | { readonly type: 'turnStarted'; readonly machineTurnId: number } + | { + readonly type: 'turnSettled'; + readonly outcome: MachineTurnOutcome; + readonly error?: unknown; + readonly produced: readonly HistoryMessage[]; + } + | { readonly type: 'stepStarted'; readonly step: number; readonly recovery?: LlmRecoveryRecord } + | { + readonly type: 'stepCompleted'; + readonly step: number; + readonly entry: AssistantEntry; + readonly usage: TokenUsage; + readonly finish?: FinishInfo; + readonly messageId?: string; + readonly model?: string; + readonly timing?: ModelRequestTiming; + readonly traceId?: string; + } + | { readonly type: 'stepFailed'; readonly step: number; readonly error: LlmErrorMessage; readonly rawError?: unknown } + | { readonly type: 'delta'; readonly delta: MachineEngineDelta } + | { + readonly type: 'retrying'; + readonly step: number; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; + readonly rawError?: unknown; + } + | { + readonly type: 'recovering'; + readonly step: number; + readonly strategy: string; + readonly action: string; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; + } + | { + readonly type: 'toolStarted'; + readonly toolCallId: string; + readonly name: string; + readonly args: unknown; + } + | { readonly type: 'toolUpdate'; readonly toolCallId: string; readonly update: ToolUpdate } + | { readonly type: 'toolAsync'; readonly toolCallId: string; readonly text: string } + | { readonly type: 'toolDone'; readonly toolCallId: string; readonly result: MachineToolResult } + | { readonly type: 'toolFailed'; readonly toolCallId: string; readonly error: unknown } + | { readonly type: 'toolAborted'; readonly toolCallId: string } + | { readonly type: 'toolBatchFailed'; readonly error: unknown } + | { readonly type: 'remindersConsumed'; readonly reminders: HistoryMessage[] } + | { readonly type: 'aborting' }; + +export interface CreateMachineEngineOptions { + readonly model: LlmModel; + readonly systemPrompt?: string; + readonly llmRequester: IAgentLLMRequesterService; + readonly toolExecutor: IAgentToolExecutorService; + readonly toolInfos: readonly ToolInfo[]; + readonly maxAttemptsPerStep?: number; + readonly recovery?: LlmRecovery; + readonly abortTimeoutMs?: number; + readonly trace?: () => LLMRequestTrace | undefined; + readonly source?: () => AgentLLMRequestSource | undefined; + readonly toolTurnId?: () => number | undefined; + readonly gate?: (signal: AbortSignal) => Promise; + readonly onTrace?: (trace: LLMRequestTrace) => void; + readonly onEvent?: (event: MachineEngineEvent) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; +} + +export interface MachineEngineSnapshot { + readonly running: boolean; + readonly aborting: boolean; + readonly waitingForBackground: boolean; + readonly queueLength: number; + readonly queueIds: readonly (string | undefined)[]; + readonly notificationCount: number; + readonly reminderCount: number; + readonly backgroundCount: number; +} + +export interface MachineEngine { + submit(input: { readonly id?: string; readonly message: UserMessage }): void; + steer(id: string): void; + notify(message: UserMessage): void; + remind(key: string, message: UserMessage): void; + abort(): void; + resetHistory(history: readonly HistoryMessage[], turnId: number): void; + stop(): void; + snapshot(): MachineEngineSnapshot; + lastFinish(): AgentLLMRequestFinish | undefined; + readonly toolExtras: ReadonlyMap; + handleToolProgress(toolCallId: string, update: AgentToolUpdate): void; +} + +interface MachineSnapshotLike { + readonly value: unknown; + readonly context: { + readonly queue: readonly { readonly id?: string }[]; + readonly notifications: readonly unknown[]; + readonly reminders: readonly unknown[]; + readonly background: Record; + }; +} + +function createDeltaSplitter(): (part: StreamedMessagePart) => MachineEngineDelta | undefined { + const callsByIndex = new Map(); + return (part) => { + switch (part.type) { + case 'text': + return { kind: 'assistant', delta: part.text }; + case 'think': + return { kind: 'thinking', delta: part.think }; + case 'image_url': + case 'audio_url': + case 'video_url': + return undefined; + case 'function': { + callsByIndex.set(part._streamIndex, { id: part.id, name: part.name }); + return { + kind: 'toolCall', + toolCallId: part.id, + name: part.name, + argumentsPart: part.arguments ?? undefined, + started: true, + }; + } + case 'tool_call_part': { + if (part.argumentsPart === null) return undefined; + const call = callsByIndex.get(part.index); + if (call === undefined) return undefined; + return { + kind: 'toolCall', + toolCallId: call.id, + name: call.name, + argumentsPart: part.argumentsPart, + }; + } + } + }; +} + +export function createMachineEngine(options: CreateMachineEngineOptions): MachineEngine { + let currentStep = 0; + let split = createDeltaSplitter(); + let pendingFailure: { step: number; error: LlmErrorMessage } | undefined; + + const publish = (event: MachineEngineEvent): void => { + options.onEvent?.(event); + }; + const requester = createMachineRequester(options.llmRequester, { + source: options.source, + gate: options.gate, + onTrace: options.onTrace, + }); + const tools = createMachineTools({ + toolExecutor: options.toolExecutor, + toolInfos: options.toolInfos, + turnId: () => options.toolTurnId?.() ?? 0, + trace: options.trace, + onToolCall: (payload) => { + publish({ + type: 'toolStarted', + toolCallId: payload.toolCallId, + name: payload.name, + args: payload.args, + }); + }, + onToolResult: options.onToolResult, + onBatchError: (error) => { + publish({ type: 'toolBatchFailed', error }); + }, + }); + const actor = createActor( + createAgentMachine({ + tools: tools.tools, + turnActor: createTurnMachine( + createLlmMachine({ + requester: requester.requester, + retry: { maxAttemptsPerStep: options.maxAttemptsPerStep }, + recovery: options.recovery, + }), + ), + abortTimeoutMs: options.abortTimeoutMs, + }), + { input: { request: { model: options.model, systemPrompt: options.systemPrompt } } }, + ); + const subscriptions: Subscription[] = [ + actor.on('turn.start', (event) => { + currentStep = 0; + split = createDeltaSplitter(); + pendingFailure = undefined; + publish({ type: 'turnStarted', machineTurnId: event.turnId }); + }), + actor.on('llm.sent', (event) => { + currentStep += 1; + split = createDeltaSplitter(); + tools.beginBatch(); + publish({ type: 'stepStarted', step: currentStep, recovery: event.recovery }); + }), + actor.on('llm.delta', (event) => { + const delta = split(event.part); + if (delta !== undefined) publish({ type: 'delta', delta }); + }), + actor.on('llm.retrying', (event) => { + pendingFailure = undefined; + publish({ + type: 'retrying', + step: currentStep, + failedAttempt: event.failedAttempt, + nextAttempt: event.nextAttempt, + maxAttempts: event.maxAttempts, + delayMs: event.delayMs, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + rawError: requester.lastError(), + }); + }), + actor.on('llm.recovering', (event) => { + pendingFailure = undefined; + publish({ + type: 'recovering', + step: currentStep, + strategy: event.strategy, + action: event.action, + errorName: event.errorName, + errorMessage: event.errorMessage, + statusCode: event.statusCode, + }); + }), + actor.on('llm.done', (event) => { + pendingFailure = undefined; + tools.beginBatch(event.entry.message.toolCalls); + const finish = requester.lastFinish(); + const meta = event.entry.meta; + publish({ + type: 'stepCompleted', + step: currentStep, + entry: event.entry, + usage: finish?.usage ?? meta.usage, + finish: + finish !== undefined + ? { + finishReason: finish.providerFinishReason ?? null, + rawFinishReason: finish.rawFinishReason ?? null, + } + : meta.finish, + messageId: finish?.providerMessageId ?? meta.messageId, + model: finish?.model ?? meta.model?.model, + timing: finish?.timing, + traceId: finish?.traceId, + }); + }), + actor.on('llm.failed.syntax', (event) => { + pendingFailure = { step: currentStep, error: event.error }; + }), + actor.on('llm.failed.remote', (event) => { + pendingFailure = { step: currentStep, error: event.error }; + }), + actor.on('tool.update', (event) => { + publish({ type: 'toolUpdate', toolCallId: event.toolCallId, update: event.update }); + }), + actor.on('tool.async', (event) => { + publish({ type: 'toolAsync', toolCallId: event.toolCallId, text: event.text }); + }), + actor.on('tool.done', (event) => { + publish({ type: 'toolDone', toolCallId: event.toolCallId, result: event.result }); + }), + actor.on('tool.failed', (event) => { + publish({ type: 'toolFailed', toolCallId: event.toolCallId, error: event.error }); + }), + actor.on('tool.aborted', (event) => { + publish({ type: 'toolAborted', toolCallId: event.toolCallId }); + }), + actor.on('turn.remindersConsumed', (event) => { + publish({ type: 'remindersConsumed', reminders: event.reminders }); + }), + actor.on('turn.aborting', () => { + publish({ type: 'aborting' }); + }), + actor.on('turn.done', (event) => { + publish({ type: 'turnSettled', outcome: 'done', produced: event.messages }); + }), + actor.on('turn.failed', (event) => { + const failure = pendingFailure; + if (failure !== undefined) { + publish({ + type: 'stepFailed', + step: failure.step, + error: failure.error, + rawError: requester.lastError(), + }); + } + publish({ + type: 'turnSettled', + outcome: 'failed', + error: event.error, + produced: event.messages, + }); + }), + actor.on('turn.aborted', (event) => { + publish({ type: 'turnSettled', outcome: 'aborted', produced: event.messages }); + }), + ]; + actor.start(); + + return { + submit: (input) => { + actor.send({ type: 'input.submit', id: input.id, message: input.message }); + }, + steer: (id) => { + actor.send({ type: 'input.steer', id }); + }, + notify: (message) => { + actor.send({ type: 'input.notify', message }); + }, + remind: (key, message) => { + actor.send({ type: 'input.reminder', key, message }); + }, + abort: () => { + actor.send({ type: 'input.abort' }); + }, + resetHistory: (history, turnId) => { + actor.send({ type: 'context.reset', history, turnId }); + }, + stop: () => { + for (const subscription of subscriptions) subscription.unsubscribe(); + actor.stop(); + }, + snapshot: () => { + const snapshot = actor.getSnapshot() as unknown as MachineSnapshotLike; + const value = snapshot.value; + return { + running: value === 'running' || (typeof value === 'object' && value !== null && 'running' in value), + aborting: typeof value === 'object' && value !== null && 'running' in value && + (value as { running?: unknown }).running === 'aborting', + waitingForBackground: + typeof value === 'object' && value !== null && 'idle' in value && + (value as { idle?: unknown }).idle === 'waiting', + queueLength: snapshot.context.queue.length, + queueIds: snapshot.context.queue.map((entry) => entry.id), + notificationCount: snapshot.context.notifications.length, + reminderCount: snapshot.context.reminders.length, + backgroundCount: Object.keys(snapshot.context.background).length, + }; + }, + lastFinish: () => requester.lastFinish(), + toolExtras: tools.extras, + handleToolProgress: (toolCallId, update) => { + tools.handleProgress(toolCallId, update); + }, + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/history.ts b/packages/agent-core-v2/src/agent/loop/machine/history.ts new file mode 100644 index 00000000000..8fb5653991b --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/history.ts @@ -0,0 +1,25 @@ +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { toLlmMessage } from '#/llm-adapter/contract/message'; +import type { HistoryMessage } from '#human/agent/turn'; +import type { UserMessage } from '#human/llm/message'; +import { emptyUsage } from '#human/llm/usage'; + +export const EMPTY_MACHINE_PROMPT: UserMessage = { role: 'user', content: [] }; + +export function historyEntryFromContext(message: ContextMessage): HistoryMessage { + const converted = toLlmMessage(message); + switch (converted.role) { + case 'system': + return { message: converted, meta: {} }; + case 'user': + return { message: converted, meta: {} }; + case 'assistant': + return { message: converted, meta: { usage: emptyUsage() } }; + case 'tool': + return { message: converted, meta: {} }; + } +} + +export function historyFromContext(messages: readonly ContextMessage[]): HistoryMessage[] { + return messages.map(historyEntryFromContext); +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/index.ts b/packages/agent-core-v2/src/agent/loop/machine/index.ts new file mode 100644 index 00000000000..5b587b78e84 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/index.ts @@ -0,0 +1,4 @@ +export * from './engine'; +export * from './history'; +export * from './requester'; +export * from './tools'; diff --git a/packages/agent-core-v2/src/agent/loop/machine/requester.ts b/packages/agent-core-v2/src/agent/loop/machine/requester.ts new file mode 100644 index 00000000000..3f60b90922d --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/requester.ts @@ -0,0 +1,118 @@ +import type { + AgentLLMRequestFinish, + AgentLLMRequestSource, + IAgentLLMRequesterService, +} from '#/agent/llmRequester/llmRequester'; +import { unwrapErrorCause } from '#/errors'; +import { llmMessageFromError } from '#/llm-adapter/contract/errors'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { toLlmErrorMessage, type LlmRemoteErrorMessage } from '#human/llm/errors'; +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequestControl, + LlmRequester, +} from '#human/llm/requester/requester'; + +export type MachineRequesterGateDecision = + | { readonly type: 'proceed'; readonly signal?: AbortSignal; readonly step?: number } + | { readonly type: 'fail' }; + +export interface MachineRequesterOptions { + readonly source?: () => AgentLLMRequestSource | undefined; + readonly gate?: (signal: AbortSignal) => Promise; + readonly onTrace?: (trace: LLMRequestTrace) => void; +} + +export interface MachineRequester { + readonly requester: LlmRequester; + lastFinish(): AgentLLMRequestFinish | undefined; + lastError(): unknown; +} + +const GATE_FAILURE: LlmRemoteErrorMessage = { + kind: 'abort', + message: 'The agent loop gate stopped the step.', +}; + +function toRemoteErrorMessage(error: unknown, signal: AbortSignal): LlmRemoteErrorMessage { + const raw = unwrapErrorCause(error); + const known = + llmMessageFromError(raw) ?? (raw === error ? undefined : llmMessageFromError(error)); + if (known !== undefined) return known; + if (signal.aborted) { + return { kind: 'abort', message: 'The operation was aborted.' }; + } + return toLlmErrorMessage(raw); +} + +export function createMachineRequester( + service: IAgentLLMRequesterService, + options?: MachineRequesterOptions, +): MachineRequester { + let lastFinish: AgentLLMRequestFinish | undefined; + let lastError: unknown; + const generate = async ( + _config: LlmRequestConfig, + _content: LlmRequestContent, + control: LlmRequestControl, + ): Promise => { + const decision = + options?.gate !== undefined + ? await options.gate(control.signal) + : ({ type: 'proceed' } as const); + if (decision.type === 'fail') { + control.onEvent?.({ type: 'llm.failed.remote', error: GATE_FAILURE }); + return; + } + if (control.signal.aborted) { + control.onEvent?.({ type: 'llm.failed.remote', error: GATE_FAILURE }); + return; + } + const signal = + decision.signal !== undefined + ? AbortSignal.any([control.signal, decision.signal]) + : control.signal; + lastError = undefined; + control.onEvent?.({ type: 'llm.sent' }); + const baseSource = options?.source?.(); + const source: AgentLLMRequestSource | undefined = + baseSource?.type === 'turn' && decision.step !== undefined + ? { ...baseSource, step: decision.step } + : baseSource; + const task = service.start( + { source }, + (part) => control.onEvent?.({ type: 'llm.delta', part }), + signal, + ); + options?.onTrace?.(task.trace); + try { + const finish = await task.result; + lastFinish = finish; + control.onEvent?.({ type: 'llm.usage', usage: finish.usage }); + control.onEvent?.({ + type: 'llm.finish', + finish: { + finishReason: finish.providerFinishReason ?? null, + rawFinishReason: finish.rawFinishReason ?? null, + }, + }); + if (finish.providerMessageId !== undefined) { + control.onEvent?.({ type: 'llm.message-id', messageId: finish.providerMessageId }); + } + control.onEvent?.({ type: 'llm.done' }); + } catch (error) { + lastFinish = undefined; + lastError = error; + control.onEvent?.({ + type: 'llm.failed.remote', + error: toRemoteErrorMessage(error, signal), + }); + } + }; + return { + requester: { generate }, + lastFinish: () => lastFinish, + lastError: () => lastError, + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/machine/tools.ts b/packages/agent-core-v2/src/agent/loop/machine/tools.ts new file mode 100644 index 00000000000..42042810228 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/machine/tools.ts @@ -0,0 +1,231 @@ +import type { + IAgentToolExecutorService, + ToolCallStartedPayload, + ToolExecutionResult, +} from '#/agent/toolExecutor/toolExecutor'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; +import { toErrorMessage } from '#/_base/errors/errorMessage'; +import type { + ToolDelivery, + ToolInfo, + ToolResult as AgentToolResult, + ToolUpdate as AgentToolUpdate, +} from '#/tool/toolContract'; +import type { ContentPart, ToolCall } from '#human/llm/message'; +import type { ToolExecuteInput, ToolResult, ToolUpdate } from '#human/tool/executor'; +import type { ToolDefinition } from '#human/tool/tool'; + +const EMPTY_TOOL_PARAMETERS: Record = { + type: 'object', + properties: {}, +}; + +export interface ToolResultExtras { + readonly stopTurn?: boolean; + readonly stopTurnReason?: string; + readonly note?: string; + readonly delivery?: ToolDelivery; + readonly stopBatchAfterThis?: boolean; + readonly output?: string | ContentPart[]; + readonly isError?: boolean; +} + +export interface CreateMachineToolsOptions { + readonly toolExecutor: IAgentToolExecutorService; + readonly toolInfos: readonly ToolInfo[]; + readonly turnId: () => number; + readonly trace?: () => LLMRequestTrace | undefined; + readonly onToolCall?: (payload: ToolCallStartedPayload) => void; + readonly onToolResult?: (toolCallId: string, result: AgentToolResult) => void; + readonly onBatchError?: (error: unknown) => void; +} + +export interface MachineTools { + readonly tools: ToolDefinition[]; + readonly extras: ReadonlyMap; + beginBatch(expectedCalls?: readonly ToolCall[]): void; + handleProgress(toolCallId: string, update: AgentToolUpdate): void; +} + +interface PendingEntry { + readonly input: ToolExecuteInput; + readonly resolve: (result: ToolResult) => void; + readonly removeAbortListener: () => void; +} + +function toContentParts(output: string | ContentPart[]): ContentPart[] { + return typeof output === 'string' ? [{ type: 'text', text: output }] : output; +} + +export function createMachineTools(options: CreateMachineToolsOptions): MachineTools { + const extras = new Map(); + const progressHandlers = new Map void) | undefined>(); + const knownNames = new Set(options.toolInfos.map((info) => info.name)); + const pending = new Map(); + let expectedIds: readonly string[] | undefined; + let batchInFlight = false; + + const settleEntry = (entry: PendingEntry, result: ToolResult): void => { + entry.removeAbortListener(); + progressHandlers.delete(entry.input.toolCall.id); + entry.resolve(result); + }; + + const settleAborted = (entry: PendingEntry): void => { + settleEntry(entry, { + content: [{ type: 'text', text: `Tool "${entry.input.toolCall.name}" aborted before execution.` }], + isError: true, + }); + }; + + const runBatch = async (entries: readonly PendingEntry[]): Promise => { + batchInFlight = true; + const inFlight = new Map(); + for (const entry of entries) inFlight.set(entry.input.toolCall.id, entry); + const signal = AbortSignal.any(entries.map((entry) => entry.input.signal)); + const calls = entries.map((entry) => entry.input.toolCall); + const settleRemaining = (error?: unknown): void => { + for (const entry of inFlight.values()) { + settleEntry(entry, { + content: [ + { + type: 'text', + text: + error === undefined + ? `Tool "${entry.input.toolCall.name}" produced no result.` + : `Tool "${entry.input.toolCall.name}" failed: ${toErrorMessage(error)}`, + }, + ], + isError: true, + }); + } + inFlight.clear(); + }; + try { + const stream = options.toolExecutor.execute(calls, { + signal, + turnId: options.turnId(), + trace: options.trace?.(), + onToolCall: options.onToolCall, + }); + for await (const result of stream) { + const entry = inFlight.get(result.toolCallId); + if (entry === undefined) continue; + inFlight.delete(result.toolCallId); + try { + applyResult(entry, result); + } catch (error) { + settleEntry(entry, { + content: [ + { + type: 'text', + text: `Tool "${entry.input.toolCall.name}" failed: ${toErrorMessage(error)}`, + }, + ], + isError: true, + }); + } + } + settleRemaining(); + } catch (error) { + try { + options.onBatchError?.(error); + } finally { + settleRemaining(error); + } + } finally { + batchInFlight = false; + } + }; + + const applyResult = (entry: PendingEntry, matched: ToolExecutionResult): void => { + const id = entry.input.toolCall.id; + const { result } = matched; + options.onToolResult?.(id, result); + extras.set(id, { + stopTurn: result.stopTurn, + stopTurnReason: result.stopTurnReason, + note: result.note, + delivery: result.delivery, + stopBatchAfterThis: result.stopBatchAfterThis, + output: result.output, + isError: result.isError, + }); + settleEntry(entry, { + content: toContentParts(result.output), + isError: result.isError === true ? true : undefined, + }); + }; + + const flushIfReady = (): void => { + if (expectedIds === undefined || batchInFlight) return; + if (!expectedIds.every((id) => pending.has(id))) return; + const entries: PendingEntry[] = []; + for (const id of expectedIds) { + const entry = pending.get(id)!; + pending.delete(id); + entries.push(entry); + } + if (entries.length === 0) return; + void runBatch(entries); + }; + + const execute = (input: ToolExecuteInput): Promise => { + progressHandlers.set(input.toolCall.id, input.onUpdate); + if (expectedIds === undefined || batchInFlight) { + return new Promise((resolve) => { + const entry: PendingEntry = { input, resolve, removeAbortListener: () => {} }; + void runBatch([entry]); + }); + } + return new Promise((resolve) => { + const onAbort = (): void => { + if (!pending.delete(input.toolCall.id)) return; + const stale = [...pending.values()]; + pending.clear(); + settleAborted({ input, resolve, removeAbortListener: () => {} }); + for (const entry of stale) settleAborted(entry); + }; + input.signal.addEventListener('abort', onAbort, { once: true }); + pending.set(input.toolCall.id, { + input, + resolve, + removeAbortListener: () => { + input.signal.removeEventListener('abort', onAbort); + }, + }); + flushIfReady(); + }); + }; + + return { + tools: options.toolInfos.map((info) => ({ + name: info.name, + description: info.description, + parameters: info.parameters ?? EMPTY_TOOL_PARAMETERS, + deferred: info.disclosure === 'deferred' ? true : undefined, + execute, + })), + extras, + beginBatch: (expectedCalls) => { + if (expectedCalls === undefined) { + expectedIds = undefined; + const stale = [...pending.values()]; + pending.clear(); + for (const entry of stale) settleAborted(entry); + return; + } + expectedIds = expectedCalls.filter((call) => knownNames.has(call.name)).map((call) => call.id); + flushIfReady(); + }, + handleProgress: (toolCallId, update) => { + const onUpdate = progressHandlers.get(toolCallId); + if (onUpdate === undefined) return; + onUpdate({ + key: update.customKind ?? update.kind, + text: update.text ?? '', + percent: update.percent, + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/agent/loop/stepRequest.ts b/packages/agent-core-v2/src/agent/loop/stepRequest.ts deleted file mode 100644 index 6cbd9295539..00000000000 --- a/packages/agent-core-v2/src/agent/loop/stepRequest.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { randomUUID } from 'node:crypto'; - -import type { ContentPart } from '#/kosong/contract/message'; -import { USER_PROMPT_ORIGIN, type ContextMessage, type PromptOrigin } from '#/agent/contextMemory/types'; - -export type StepRequestState = 'pending' | 'materialized' | 'aborted'; - -export type StepRequestAdmission = - | 'newTurn' - | 'activeOrNewTurn' - | 'activeOrNextTurn' - | 'activeTurnOnly'; - -export interface TurnSeed { - readonly input: readonly ContentPart[]; - readonly origin: PromptOrigin; - readonly promptId?: string; -} - -export interface StepRequestOptions { - readonly mergeable?: boolean; - readonly turnScoped?: boolean; - readonly admission?: StepRequestAdmission; -} - -export abstract class StepRequest { - readonly id: string = randomUUID(); - abstract readonly kind: string; - readonly mergeable: boolean; - readonly turnScoped: boolean; - readonly admission: StepRequestAdmission; - - private _state: StepRequestState = 'pending'; - - constructor(options: StepRequestOptions = {}) { - this.mergeable = options.mergeable ?? false; - this.turnScoped = options.turnScoped ?? true; - this.admission = options.admission ?? 'activeOrNextTurn'; - } - - get turnSeed(): TurnSeed | undefined { - return undefined; - } - - get state(): StepRequestState { - return this._state; - } - - get aborted(): boolean { - return this._state === 'aborted'; - } - - abort(): boolean { - if (this._state !== 'pending') return false; - this._state = 'aborted'; - this.onSettled(); - return true; - } - - onWillMaterialize(): void {} - - abstract resolveContextMessages(): readonly ContextMessage[]; - - markMaterialized(): void { - if (this._state !== 'pending') return; - this._state = 'materialized'; - this.onSettled(); - } - - protected onSettled(): void {} -} - -export interface MessageStepRequestOptions extends StepRequestOptions { - readonly kind?: string; -} - -export class MessageStepRequest extends StepRequest { - readonly kind: string; - - constructor( - private readonly message: ContextMessage, - options: MessageStepRequestOptions = {}, - ) { - super(options); - this.kind = options.kind ?? 'message'; - } - - override get turnSeed(): TurnSeed { - return { input: this.message.content, origin: this.message.origin ?? USER_PROMPT_ORIGIN }; - } - - resolveContextMessages(): readonly ContextMessage[] { - return [this.message]; - } -} - -export class ContinuationStepRequest extends StepRequest { - readonly kind: string; - - constructor(options: MessageStepRequestOptions = {}) { - super(options); - this.kind = options.kind ?? 'continuation'; - } - - resolveContextMessages(): readonly ContextMessage[] { - return []; - } -} diff --git a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts b/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts deleted file mode 100644 index 42721ddb8e1..00000000000 --- a/packages/agent-core-v2/src/agent/loop/stepRequestQueue.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { StepRequest } from './stepRequest'; - -export interface StepRequestBatch { - readonly driver: StepRequest; - readonly merged: readonly StepRequest[]; -} - -export class StepRequestQueue { - private readonly items: StepRequest[] = []; - - enqueue(request: StepRequest, at: 'head' | 'tail' = 'tail'): void { - if (at === 'head') { - this.items.unshift(request); - } else { - this.items.push(request); - } - } - - hasPendingRequests(): boolean { - return this.items.some((item) => !item.aborted); - } - - peekDriverKind(): string | undefined { - this.discardAborted(); - const driver = this.items.find((item) => !item.mergeable) ?? this.items[0]; - return driver?.kind; - } - - takeNextBatch(): StepRequestBatch | undefined { - this.discardAborted(); - if (this.items.length === 0) return undefined; - - let driverIndex = this.items.findIndex((item) => !item.mergeable); - if (driverIndex < 0) driverIndex = 0; - const driver = this.items[driverIndex]!; - - const merged: StepRequest[] = []; - const rest: StepRequest[] = []; - this.items.forEach((item, index) => { - if (index === driverIndex) return; - (item.mergeable ? merged : rest).push(item); - }); - this.items.length = 0; - this.items.push(...rest); - return { driver, merged }; - } - - drain(): StepRequest[] { - return this.items.splice(0); - } - - abortTurnScoped(): void { - for (const item of this.items) { - if (item.turnScoped) item.abort(); - } - this.discardAborted(); - } - - private discardAborted(): void { - for (let index = this.items.length - 1; index >= 0; index -= 1) { - if (this.items[index]!.aborted) this.items.splice(index, 1); - } - } -} diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index e77f0d9b7a6..c686852d4a6 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -4,9 +4,9 @@ import { z } from 'zod'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; import { AgentEvent2, registerEvent2Class } from '#/app/event/event2'; -import type { FinishReason } from '#/kosong/contract/provider'; -import type { ContentPart, TextPart } from '#/kosong/contract/message'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { ContentPart, TextPart } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; export type TurnEndReason = 'completed' | 'cancelled' | 'failed' | 'blocked'; @@ -159,6 +159,42 @@ export class TurnStepInterrupted extends AgentEvent2 } export interface TurnStepInterrupted extends TurnStepInterruptedPayload {} +export interface TurnStepRetryingPayload { + readonly agentId: string; + readonly turnId: number; + readonly step: number; + readonly stepId?: string; + readonly failedAttempt: number; + readonly nextAttempt: number; + readonly maxAttempts: number; + readonly delayMs: number; + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +const turnStepRetryingSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + step: z.number(), + stepId: z.string().optional(), + failedAttempt: z.number(), + nextAttempt: z.number(), + maxAttempts: z.number(), + delayMs: z.number(), + errorName: z.string(), + errorMessage: z.string(), + statusCode: z.number().optional(), +}); + +export class TurnStepRetrying extends AgentEvent2 { + static override readonly type = 'turn.step.retrying'; + static override readonly durable = true; + static override readonly observable = true; + static override readonly schema = turnStepRetryingSchema; +} +export interface TurnStepRetrying extends TurnStepRetryingPayload {} + export interface AssistantDeltaPayload { readonly agentId: string; readonly turnId: number; @@ -198,3 +234,4 @@ export class ToolCallDelta extends AgentEvent2 { export interface ToolCallDelta extends ToolCallDeltaPayload {} registerEvent2Class(TurnStepInterrupted); +registerEvent2Class(TurnStepRetrying); diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index 5f511599058..6d97cbdd2c1 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -11,7 +11,7 @@ import { import { isUndoAnchorOrigin } from '#/agent/contextMemory/conversationTime'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import { AgentEvent2, type SerializedEvent2 } from '#/app/event/event2'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { defineState } from '#/state/state'; import type { TurnInterruptReason } from './turnEvents'; diff --git a/packages/agent-core-v2/src/agent/mcp/mcp.ts b/packages/agent-core-v2/src/agent/mcp/mcp.ts index 0e47b09725d..013adc5deb0 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcp.ts @@ -1,4 +1,4 @@ -import type { Tool as KosongTool } from '#/kosong/contract/tool'; +import type { ToolDescription as KosongTool } from '#human/llm/message'; import { createDecorator } from "#/_base/di/instantiation"; import { type IDisposable } from "#/_base/di/lifecycle"; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index bded747b103..7ea4a8e21fa 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; -import type { Tool as KosongTool } from '#/kosong/contract/tool'; +import type { ToolDescription as KosongTool } from '#human/llm/message'; import { type IDisposable } from "#/_base/di/lifecycle"; import { Service } from "#/_base/di/service"; diff --git a/packages/agent-core-v2/src/agent/mcp/output.ts b/packages/agent-core-v2/src/agent/mcp/output.ts index 6fab8566293..b4fb9c9cef4 100644 --- a/packages/agent-core-v2/src/agent/mcp/output.ts +++ b/packages/agent-core-v2/src/agent/mcp/output.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import type { ExecutableToolResult } from '#/tool/toolContract'; diff --git a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts index a5e22046950..93dc04bdd1b 100644 --- a/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts +++ b/packages/agent-core-v2/src/agent/mcp/tools/mcp.ts @@ -1,4 +1,4 @@ -import type { Tool as KosongTool } from '#/kosong/contract/tool'; +import type { ToolDescription as KosongTool } from '#human/llm/message'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; import { Error2, ErrorCodes, toErrorMessage } from '#/errors'; import { isAbortError } from '#/_base/utils/abort'; diff --git a/packages/agent-core-v2/src/agent/media/image-compress.ts b/packages/agent-core-v2/src/agent/media/image-compress.ts index 4d751ed1020..a85b995b93e 100644 --- a/packages/agent-core-v2/src/agent/media/image-compress.ts +++ b/packages/agent-core-v2/src/agent/media/image-compress.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { ImageCompressEvent, ImageCropEvent } from '#/app/telemetry/events'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; diff --git a/packages/agent-core-v2/src/agent/media/mediaRef.ts b/packages/agent-core-v2/src/agent/media/mediaRef.ts index c3460988cf2..d1ea3e2fd02 100644 --- a/packages/agent-core-v2/src/agent/media/mediaRef.ts +++ b/packages/agent-core-v2/src/agent/media/mediaRef.ts @@ -1,6 +1,6 @@ import { join } from 'node:path'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; export type MediaKind = 'image' | 'video' | 'audio' | 'file'; diff --git a/packages/agent-core-v2/src/agent/media/mediaResolver.ts b/packages/agent-core-v2/src/agent/media/mediaResolver.ts index b459db931d3..25a7b69ea5e 100644 --- a/packages/agent-core-v2/src/agent/media/mediaResolver.ts +++ b/packages/agent-core-v2/src/agent/media/mediaResolver.ts @@ -1,6 +1,6 @@ import { createDecorator } from '#/_base/di/instantiation'; -import type { Message } from '#/kosong/contract/message'; -import type { ModelRequester } from '#/kosong/model/modelRequester'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; export interface IAgentMediaResolverService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts index 43ab9289400..ff51e8c17eb 100644 --- a/packages/agent-core-v2/src/agent/media/mediaResolverService.ts +++ b/packages/agent-core-v2/src/agent/media/mediaResolverService.ts @@ -6,8 +6,9 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { IFileService } from '#/app/file/fileService'; import { LifecycleScope } from '#/app/scopes'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ContentPart, Message } from '#/kosong/contract/message'; -import type { ModelRequester } from '#/kosong/model/modelRequester'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart } from '#human/llm/message'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; import { IBlobStore } from '#/persistence/interface/blobStore'; import { detectFileType, MEDIA_SNIFF_BYTES } from './file-type'; diff --git a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts index fb8e4fa2bac..0aa5eb6c2e8 100644 --- a/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts +++ b/packages/agent-core-v2/src/agent/media/mediaToolsRegistrar.ts @@ -7,8 +7,8 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { type ModelRequester } from '#/kosong/model/modelRequester'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { type ModelRequester } from '#/llm-adapter/model/model-requester'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import { ISessionSkillCatalog } from '#/features/skill/session/skillCatalog'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; diff --git a/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts b/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts index 03971728e6c..cfdda724764 100644 --- a/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts +++ b/packages/agent-core-v2/src/agent/media/promptMediaIntake.ts @@ -1,6 +1,6 @@ import type { IFileService } from '#/app/file/fileService'; import { abortable } from '#/_base/utils/abort'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { daemonFileRefFromPart } from './mediaRef'; import { ISessionMediaStore } from './sessionMediaStore'; diff --git a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts index 976568794c8..a998ff279cd 100644 --- a/packages/agent-core-v2/src/agent/media/registerMediaTools.ts +++ b/packages/agent-core-v2/src/agent/media/registerMediaTools.ts @@ -1,5 +1,5 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ModelRequester } from '#/kosong/model/modelRequester'; +import type { ModelCapability } from '#human/llm/capability'; +import type { ModelRequester } from '#/llm-adapter/model/model-requester'; import type { VideoUploadEvent } from '#/app/telemetry/events'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; diff --git a/packages/agent-core-v2/src/agent/media/videoUpload.ts b/packages/agent-core-v2/src/agent/media/videoUpload.ts index dfda405921f..38b4314183f 100644 --- a/packages/agent-core-v2/src/agent/media/videoUpload.ts +++ b/packages/agent-core-v2/src/agent/media/videoUpload.ts @@ -1,7 +1,7 @@ -import { VideoUploadUnsupportedError } from '#/kosong/contract/errors'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import type { Protocol } from '#/kosong/protocol/protocol'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; +import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; +import type { VideoURLPart } from '#human/llm/message'; +import type { Protocol } from '#/llm-adapter/protocol/protocol'; +import { ProtocolErrors } from '#/llm-adapter/protocol/errors'; export function isVideoUploadAuthError(error: unknown): boolean { if (typeof error !== 'object' || error === null) return false; diff --git a/packages/agent-core-v2/src/agent/profile/profile.ts b/packages/agent-core-v2/src/agent/profile/profile.ts index 1fdbbc24f6e..27c0246670d 100644 --- a/packages/agent-core-v2/src/agent/profile/profile.ts +++ b/packages/agent-core-v2/src/agent/profile/profile.ts @@ -3,9 +3,9 @@ import type { AgentProfileContext, EnvironmentDisclosureSnapshot, } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ThinkingEffort } from '#/kosong/contract/provider'; -import type { ModelRequestParams } from '#/kosong/model/modelRequester'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { ModelRequestParams } from '#/llm-adapter/model/model-requester'; import { createDecorator } from "#/_base/di/instantiation"; import type { ErrorCode } from '#/errors'; diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index a03f90c3377..ef224628346 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { EnvironmentDisclosureSnapshot } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { AgentEvent2 } from '#/app/event/event2'; -import type { ThinkingEffort } from '#/kosong/contract/provider'; +import type { ThinkingEffort } from '#human/llm/thinking'; import { defineState } from '#/state/state'; import { ProfileError, ProfileErrors } from './profile'; diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 8ca3eddfb74..f47de072582 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -2,12 +2,12 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; -import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/kosong/contract/capability'; -import { type SamplingOptions, type ThinkingEffort } from '#/kosong/contract/provider'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { type ModelOverrides } from '#/kosong/model/model.types'; -import { type ModelRequestParams } from '#/kosong/model/modelRequester'; -import { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm-adapter/contract/capability'; +import { type ThinkingEffort } from '#human/llm/thinking'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { type ModelOverrides } from '#/llm-adapter/model/model.types'; +import { type ModelRequestParams, type SamplingOptions } from '#/llm-adapter/model/model-requester'; +import { IProtocolAdapterRegistry } from '#/llm-adapter/protocol/protocol'; import { drivesThinkingThroughTraits, modelSupportsThinkingEffort, @@ -17,7 +17,7 @@ import { resolveThinkingKeep, requiresStrictThinkingValidation, type ThinkingConfig, -} from '#/kosong/model/thinking'; +} from '#/llm-adapter/model/thinking'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import { DEFAULT_AGENT_PROFILE_NAME } from '#/app/agentProfileCatalog/agentProfileCatalog'; import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAgentProfileLoader'; diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index fc17432448d..ff6fb115184 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -2,7 +2,7 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { IDisposable } from '#/_base/di/lifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Turn, TurnResult } from '#/agent/loop/loop'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { Hooks } from '#/hooks'; export interface PromptSubmitContext { diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 78f31415b27..2dc135668e7 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { matchSingleMediaPathTag } from '#/agent/media/mediaRef'; import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 90a2c421932..ce4383a28c6 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -5,7 +5,7 @@ import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; -import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; +import { extractImageCompressionCaptions, gateImageFormatParts } from '#/agent/media/image-compress'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { newMessageId } from '#/agent/contextMemory/messageId'; @@ -20,7 +20,7 @@ import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IFileService } from '#/app/file/fileService'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { IEventService } from '#/app/event/event'; import { AgentEvent2 } from '#/app/event/event2'; import { ErrorCodes, Error2, isError2 } from '#/errors'; @@ -49,7 +49,6 @@ import { type SteerPayload, } from './prompt'; import { promptMetadataTextFromContentParts } from './promptMetadataText'; -import { PromptStepRequest, RetryStepRequest, SteerStepRequest } from './promptStepRequests'; import { PromptAccepted, promptAdmissionKey } from './promptOps'; import { daemonFileRefFromPart } from '#/agent/media/mediaRef'; import { materializePromptDaemonRefs } from '#/agent/media/promptMediaIntake'; @@ -412,18 +411,24 @@ export class AgentPromptService implements IAgentPromptService { removed.push({ item, index }); this.pending.splice(index, 1); } - const request = new SteerStepRequest(rerouted, captions, this.reminder, (materialized) => { - void this.dispatcher.dispatch( - new TurnSteer({ - agentId: this.scopeContext.agentId, - input: materialized.content, - origin: materialized.origin ?? USER_PROMPT_ORIGIN, - }), - ); - }, () => {}); + const ownerPromptId = rerouted.id ?? newMessageId(); + const message = { ...rerouted, id: ownerPromptId, content: gateImageFormatParts(rerouted.content) }; let turn: Turn | undefined; try { - turn = (await this.loop.enqueue(request).assigned).turn; + turn = this.loop.steer({ + message, + promptId: ownerPromptId, + onMaterialize: () => { + void this.dispatcher.dispatch( + new TurnSteer({ + agentId: this.scopeContext.agentId, + input: message.content, + origin: message.origin ?? USER_PROMPT_ORIGIN, + }), + ); + this.notifyCaptions(captions, ownerPromptId); + }, + }); } catch { turn = undefined; } finally { @@ -461,19 +466,30 @@ export class AgentPromptService implements IAgentPromptService { async inject(message: ContextMessage): Promise { const { message: rerouted, captions } = this.extractCompressionCaptions(message); await this.materializeDaemonRefs(rerouted); - const request = new SteerStepRequest(rerouted, captions, this.reminder, (materialized) => { - void this.dispatcher.dispatch( - new TurnSteer({ - agentId: this.scopeContext.agentId, - input: materialized.content, - origin: materialized.origin ?? USER_PROMPT_ORIGIN, - }), - ); - }, () => {}, 'activeOrNewTurn'); - return (await this.loop.enqueue(request).assigned).turn; + const ownerPromptId = rerouted.id ?? newMessageId(); + const gated = { ...rerouted, id: ownerPromptId, content: gateImageFormatParts(rerouted.content) }; + const request = { + message: gated, + promptId: ownerPromptId, + onMaterialize: () => { + void this.dispatcher.dispatch( + new TurnSteer({ + agentId: this.scopeContext.agentId, + input: gated.content, + origin: gated.origin ?? USER_PROMPT_ORIGIN, + }), + ); + this.notifyCaptions(captions, ownerPromptId); + }, + }; + return this.loop.steer(request) ?? this.loop.submit(request).turn; } - async retry(): Promise { return (await this.loop.enqueue(new RetryStepRequest()).assigned).turn; } + async retry(): Promise { + return this.loop.submit({ + message: { role: 'user', content: [], toolCalls: [], origin: { kind: 'retry' } }, + }).turn; + } clear(): void { for (const item of this.pending.slice()) this.abort(item.id); @@ -494,7 +510,13 @@ export class AgentPromptService implements IAgentPromptService { item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; } - const turn = (await this.loop.enqueue(new PromptStepRequest(message, captions, this.reminder)).assigned).turn; + const turn = this.loop.submit({ + message: { ...message, content: gateImageFormatParts(message.content) }, + promptId: item.id, + onMaterialize: () => { + this.notifyCaptions(captions, item.id); + }, + }).turn; if (turn === undefined) { this.pending.unshift(item); return; } item.state = 'running'; item.launchedDeferred.resolve(turn); this.active = Object.assign(item, { turn }); this.publishStarted(item); @@ -558,6 +580,14 @@ export class AgentPromptService implements IAgentPromptService { } if (message.content.length > 0) this.context.append({ ...message, id: ownerPromptId }); } + private notifyCaptions(captions: readonly string[], ownerPromptId: string): void { + for (const caption of captions) { + this.reminder.notify(caption, { + variant: 'image_compression', + ownerPromptId, + }); + } + } private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { const delivery = ctx.result.delivery; if (delivery === undefined) return; const { delivery: _delivery, ...rest } = ctx.result; ctx.result = rest as ExecutableToolResult; diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts deleted file mode 100644 index e7751009994..00000000000 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ /dev/null @@ -1,106 +0,0 @@ -import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types'; -import { newMessageId } from '#/agent/contextMemory/messageId'; -import { StepRequest, type StepRequestOptions, type TurnSeed } from '#/agent/loop/stepRequest'; -import { gateImageFormatParts } from '#/agent/media/image-compress'; -import type { IAgentReminderService } from '#/features/reminder/reminderService'; - -abstract class UserMessageStepRequest extends StepRequest { - protected readonly message: ContextMessage; - private readonly ownerPromptId: string; - - constructor( - message: ContextMessage, - private readonly captions: readonly string[], - private readonly reminders: IAgentReminderService, - options?: StepRequestOptions, - ) { - super(options); - this.ownerPromptId = message.id ?? newMessageId(); - this.message = { - ...message, - id: this.ownerPromptId, - content: gateImageFormatParts(message.content), - }; - } - - override get turnSeed(): TurnSeed { - return { input: this.message.content, origin: this.message.origin ?? USER_PROMPT_ORIGIN }; - } - - override onWillMaterialize(): void { - for (const caption of this.captions) { - this.reminders.notify(caption, { - variant: 'image_compression', - ownerPromptId: this.ownerPromptId, - }); - } - } - - resolveContextMessages(): readonly ContextMessage[] { - return this.message.content.length > 0 ? [this.message] : []; - } -} - -export class PromptStepRequest extends UserMessageStepRequest { - readonly kind = 'prompt'; - - constructor( - message: ContextMessage, - captions: readonly string[], - reminders: IAgentReminderService, - ) { - super(message, captions, reminders, { admission: 'newTurn' }); - } - - override get turnSeed(): TurnSeed { - return { - input: this.message.content, - origin: this.message.origin ?? USER_PROMPT_ORIGIN, - promptId: this.message.id, - }; - } -} - -export class SteerStepRequest extends UserMessageStepRequest { - readonly kind = 'steer'; - - constructor( - message: ContextMessage, - captions: readonly string[], - reminders: IAgentReminderService, - private readonly recordSteer: (message: ContextMessage) => void, - private readonly forgetSteer: (request: SteerStepRequest) => void, - admission: 'activeTurnOnly' | 'activeOrNewTurn' = 'activeTurnOnly', - ) { - super(message, captions, reminders, { - mergeable: true, - turnScoped: false, - admission, - }); - } - - override onWillMaterialize(): void { - this.recordSteer(this.message); - super.onWillMaterialize(); - } - - protected override onSettled(): void { - this.forgetSteer(this); - } -} - -export class RetryStepRequest extends StepRequest { - readonly kind = 'retry'; - - constructor() { - super({ admission: 'newTurn' }); - } - - override get turnSeed(): TurnSeed { - return { input: [], origin: { kind: 'retry' } }; - } - - resolveContextMessages(): readonly ContextMessage[] { - return []; - } -} diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetry.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetry.ts deleted file mode 100644 index 753efef3c75..00000000000 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetry.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { createDecorator } from '#/_base/di/instantiation'; - -export interface IAgentStepRetryService { - readonly _serviceBrand: undefined; -} - -export const IAgentStepRetryService = createDecorator( - 'agentStepRetryService', -); diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts deleted file mode 100644 index bf55aa99e7f..00000000000 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ /dev/null @@ -1,180 +0,0 @@ -/* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ -import { z } from 'zod'; - -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { defineState } from '#/state/state'; -import { - DEFAULT_MAX_RETRY_ATTEMPTS, - readRetryAfterMs, - retryBackoffDelays, - retryErrorFields, - sleepForRetry, -} from '#/_base/utils/retry'; -import { isRetryableGenerateError } from '#/kosong/contract/errors'; -import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; -import { AgentEvent2, registerEvent2Class } from '#/app/event/event2'; -import { unwrapErrorCause } from '#/errors'; -import { - IAgentLoopService, - type LoopErrorContext, -} from '#/agent/loop/loop'; -import { LOOP_CONTROL_SECTION, type LoopControl } from '#/agent/loop/configSection'; -import { TurnStarted } from '#/agent/loop/turnEvents'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IEventDispatcher } from '#/state/eventDispatcher'; - -import { IAgentStepRetryService } from './stepRetry'; - -export interface TurnStepRetryingPayload { - readonly agentId: string; - readonly turnId: number; - readonly step: number; - readonly stepId?: string; - readonly failedAttempt: number; - readonly nextAttempt: number; - readonly maxAttempts: number; - readonly delayMs: number; - readonly errorName: string; - readonly errorMessage: string; - readonly statusCode?: number; -} - -const turnStepRetryingSchema = z.object({ - agentId: z.string(), - turnId: z.number(), - step: z.number(), - stepId: z.string().optional(), - failedAttempt: z.number(), - nextAttempt: z.number(), - maxAttempts: z.number(), - delayMs: z.number(), - errorName: z.string(), - errorMessage: z.string(), - statusCode: z.number().optional(), -}); - -export class TurnStepRetrying extends AgentEvent2 { - static override readonly type = 'turn.step.retrying'; - static override readonly durable = true; - static override readonly observable = true; - static override readonly schema = turnStepRetryingSchema; -} -export interface TurnStepRetrying extends TurnStepRetryingPayload {} - -export const stepRetryLastFailedDriverIdKey = defineState( - 'stepRetry.lastFailedDriverId', - () => undefined as string | undefined, -); -export const stepRetryFailedAttemptsKey = defineState( - 'stepRetry.failedAttempts', - () => 0, -); - -export class AgentStepRetryService extends Disposable implements IAgentStepRetryService { - declare readonly _serviceBrand: undefined; - - constructor( - @IAgentLoopService private readonly loopService: IAgentLoopService, - @IConfigService private readonly config: IConfigService, - @IEventBus private readonly eventBus: IEventBus, - @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, - @IAgentStateService private readonly states: IAgentStateService, - ) { - super(); - this.states.contributeState(stepRetryLastFailedDriverIdKey); - this.states.contributeState(stepRetryFailedAttemptsKey); - this._register( - this.loopService.registerLoopErrorHandler({ - id: 'step-retry', - match: (context) => isRetryableGenerateError(unwrapErrorCause(context.error)), - handle: (context) => this.recover(context), - }), - ); - this._register( - this.loopService.hooks.onDidFinishStep.register('step-retry', async (_ctx, next) => { - this.resetAttempts(); - await next(); - }), - ); - this._register(this.eventBus.subscribe(TurnStarted, () => this.resetAttempts())); - } - - private get lastFailedDriverId(): string | undefined { - return this.states.get(stepRetryLastFailedDriverIdKey); - } - - private set lastFailedDriverId(value: string | undefined) { - this.states.set(stepRetryLastFailedDriverIdKey, value); - } - - private get failedAttempts(): number { - return this.states.get(stepRetryFailedAttemptsKey); - } - - private set failedAttempts(value: number) { - this.states.set(stepRetryFailedAttemptsKey, value); - } - - private resetAttempts(): void { - this.lastFailedDriverId = undefined; - this.failedAttempts = 0; - } - - private async recover(context: LoopErrorContext): Promise { - const driver = context.failedDriver; - if (driver === undefined || context.step === undefined) return false; - - if (this.lastFailedDriverId !== driver.id) { - this.lastFailedDriverId = driver.id; - this.failedAttempts = 0; - } - this.failedAttempts += 1; - - const maxAttempts = Math.max( - this.config.get(LOOP_CONTROL_SECTION)?.maxAttemptsPerStep ?? - DEFAULT_MAX_RETRY_ATTEMPTS, - 1, - ); - if (this.failedAttempts >= maxAttempts) { - this.resetAttempts(); - return false; - } - - const error = unwrapErrorCause(context.error); - const delayMs = - readRetryAfterMs(error) ?? retryBackoffDelays(maxAttempts)[this.failedAttempts - 1] ?? 0; - void this.dispatcher.dispatch( - new TurnStepRetrying({ - agentId: this.scopeContext.agentId, - turnId: context.turnId, - step: context.step, - stepId: context.stepId, - failedAttempt: this.failedAttempts, - nextAttempt: this.failedAttempts + 1, - maxAttempts, - delayMs, - ...retryErrorFields(error), - }), - ); - await sleepForRetry(delayMs, context.signal); - - if (context.currentStep?.signal.aborted === true) return false; - context.retry(driver, { at: 'head' }); - return true; - } -} - -registerEvent2Class(TurnStepRetrying); - -registerScopedService( - LifecycleScope.Agent, - IAgentStepRetryService, - AgentStepRetryService, - ScopeActivation.OnScopeCreated, - 'stepRetry', -); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index c036ba8c07d..5bea340a513 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -3,7 +3,7 @@ import { join } from 'pathe'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { Disposable } from '#/_base/di/lifecycle'; import { ILogService } from '#/_base/log/log'; @@ -24,10 +24,9 @@ import { import '#/agent/contextMemory/conversationTime'; import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import { IEventDispatcher } from '#/state/eventDispatcher'; -import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; +import type { TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentReminderService } from '#/features/reminder/reminderService'; -import { IAgentLoopService } from '#/agent/loop/loop'; -import { MessageStepRequest } from '#/agent/loop/stepRequest'; +import { IAgentLoopService, type LoopNotifyHandle } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { ITaskService, type ITaskHandle, TERMINAL_TASK_STATES } from '#/app/task/task'; @@ -185,24 +184,6 @@ function coerceTimeoutSettlement( return settlement; } -export class TaskNotificationStepRequest extends MessageStepRequest { - constructor( - message: ContextMessage, - private readonly onWillDeliver?: () => void, - ) { - super(message, { - kind: 'task_notification', - mergeable: true, - turnScoped: false, - admission: 'activeOrNewTurn', - }); - } - - override onWillMaterialize(): void { - this.onWillDeliver?.(); - } -} - export const taskGhostsKey = defineState>( 'task.ghosts', () => new Map(), @@ -225,7 +206,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private readonly tasks = new Map(); private readonly buildingNotificationKeys = new Set(); - private readonly pendingNotificationRequests = new Map(); + private readonly pendingNotificationRequests = new Map(); private readonly persistence: AgentTaskPersistence; private notificationRestoreQueue: Promise = Promise.resolve(); @@ -519,7 +500,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private async reconcileNotificationDeliveryAfterUndo(): Promise { const restoredKeys = new Set(this.states.get(taskNotificationDeliveryKey)); for (const [key, request] of this.pendingNotificationRequests) { - if (request.aborted) this.clearPendingNotification(key, request); + if (request.dropped) this.clearPendingNotification(key, request); } this.deliveredNotificationKeys.clear(); for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); @@ -626,7 +607,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { notificationId: taskNotificationId(taskId, status), }; const key = notificationKey(origin); - this.pendingNotificationRequests.get(key)?.abort(); + this.pendingNotificationRequests.get(key)?.drop(); this.markDeliveredNotification(origin); keys.push(key); } @@ -1106,30 +1087,21 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { if (context === undefined) return; const key = notificationKey(context.origin); if (this.deliveredNotificationKeys.has(key)) return; - const request = new TaskNotificationStepRequest( - { + const handle = this.loop.notify({ + message: { role: 'user', content: [...context.content], toolCalls: [], origin: context.origin, }, - () => this.fireNotificationHook(context.notification), - ); - this.pendingNotificationRequests.set(key, request); - try { - const receipt = this.loop.enqueue(request); - void receipt.assigned - .then(({ step }) => step.result) - .then( - () => { - if (request.aborted) this.clearPendingNotification(key, request); - }, - () => this.clearPendingNotification(key, request), - ); - } catch (error) { - this.clearPendingNotification(key, request); - throw error; - } + turnScoped: false, + onConsume: () => { + this.pendingNotificationRequests.delete(key); + this.fireNotificationHook(context.notification); + }, + onDrop: () => this.clearPendingNotification(key, handle), + }); + this.pendingNotificationRequests.set(key, handle); } private restoreAgentTaskNotifications(): Promise { @@ -1330,7 +1302,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.deliveredNotificationKeys.add(key); } - private clearPendingNotification(key: string, request: TaskNotificationStepRequest): void { + private clearPendingNotification(key: string, request: LoopNotifyHandle): void { if (this.pendingNotificationRequests.get(key) !== request) return; this.pendingNotificationRequests.delete(key); if (!this.deliveredNotificationKeys.has(key) && !this.hasDeliveredNotification(key)) { diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts index 05659efaf0b..6eae3ee0de8 100644 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts @@ -1,5 +1,5 @@ -import type { Message } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; export type TokenCountingStrategy = 'measured+estimated' | 'measured' | 'estimated'; diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts index 865548a8139..82783bad1ad 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupe.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ExecutableToolErrorResult, ExecutableToolSuccessResult } from '#/tool/toolContract'; diff --git a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts index 5605ca6761f..4acf2a3b24a 100644 --- a/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts +++ b/packages/agent-core-v2/src/agent/toolDedupe/toolDedupeService.ts @@ -12,16 +12,15 @@ import type { ToolCallTurnRepeatEvent, } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import { parseToolCallArguments } from '#/tool/tool-args-parse'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { HandoffStepRequest } from '#/agent/loop/handoffStep'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { TurnEnded } from '#/agent/loop/turnOps'; import { wrapSystemReminder } from '#/features/reminder/systemReminder'; import { IAgentToolExecutorService, type ToolCallDupType } from '#/agent/toolExecutor/toolExecutor'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { IAgentToolDedupeService, REPEAT_BREAKER_STOP_REASON, @@ -389,16 +388,15 @@ export class AgentToolDedupeService extends Service implements IAgentToolDedupeS } if (phase !== 'idle' || !this.forceStoppedInStep) return; this.handoffPhase = 'pending'; - this.loop.enqueue( - new HandoffStepRequest({ - onMaterialize: () => { - this.handoffPhase = 'active'; - }, - onAbort: () => { - this.handoffPhase = 'done'; - }, - }), - ); + this.loop.notify({ + bypassMaxSteps: true, + onConsume: () => { + this.handoffPhase = 'active'; + }, + onDrop: () => { + this.handoffPhase = 'done'; + }, + }); } private recordTurnRepeat( diff --git a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts index 32fa99dc20f..0ccc3e02373 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/beforeToolExecuteEvent.ts @@ -1,7 +1,7 @@ import { Emitter } from '#/_base/event'; import { BugIndicatingError } from '#/errors'; -import type { ToolCall } from '#/kosong/contract/message'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { ToolCall } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import type { ExecutableTool, ExecutableToolResult, diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts index f4bd72b2ecf..fe8e869e9a1 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutor.ts @@ -7,9 +7,9 @@ import type { ToolDidExecuteContext, WillExecuteToolEvent, } from '#/agent/toolExecutor/toolHooks'; -import type { ToolCall } from '#/kosong/contract/message'; +import type { ToolCall } from '#human/llm/message'; import type { OrderedHookSlot } from '#/hooks'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import type { ToolSource } from '#/tool/toolContract'; export interface ToolCallStartedPayload { diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 9f144d187f6..d17746a1c42 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -3,7 +3,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event } from '#/_base/event'; import { defineState } from '#/state/state'; -import type { ContentPart, ToolCall } from '#/kosong/contract/message'; +import type { ContentPart, ToolCall } from '#human/llm/message'; import type { ToolInputDisplay } from '@moonshot-ai/protocol'; import { @@ -401,7 +401,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { if (options.signal.aborted) { return settleError( call.args, - abortedToolOutput(call.toolName, options.signal), + abortedToolOutput(call.toolName, options.signal.reason), 'aborted', displayFields, ); @@ -513,7 +513,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { result: makeErrorToolResult( call, call.args, - abortedToolOutput(call.toolName, signal), + abortedToolOutput(call.toolName, signal.reason), ).result, outcome: 'aborted', }; @@ -536,7 +536,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { } catch (error) { const aborted = isAbortError(error) || signal.aborted; const output = aborted - ? abortedToolOutput(call.toolName, signal) + ? abortedToolOutput(call.toolName, signal.reason) : `Tool "${call.toolName}" failed: ${errorMessage(error)}`; return { result: makeErrorToolResult(call, call.args, output).result, @@ -933,8 +933,8 @@ function isMediaContentPart(part: ContentPart): boolean { return part.type === 'image_url' || part.type === 'audio_url' || part.type === 'video_url'; } -function abortedToolOutput(toolName: string, signal: AbortSignal): string { - if (isUserCancellation(signal.reason)) { +export function abortedToolOutput(toolName: string, reason: unknown): string { + if (isUserCancellation(reason)) { return `The user manually interrupted "${toolName}" (and anything else running at the same time). This was a deliberate user action, not a system error, timeout, or capacity limit. Do not retry automatically or guess at the cause — wait for the user's next instruction.`; } return `Tool "${toolName}" was aborted`; @@ -952,7 +952,7 @@ async function raceWithAbortGrace( const armTimer = (): void => { graceTimer = setTimeout(() => { resolve({ - output: abortedToolOutput(toolName, signal), + output: abortedToolOutput(toolName, signal.reason), isError: true, } as unknown as Result); }, ABORT_GRACE_MS); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index c14472af0fa..113ede36e57 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -1,6 +1,6 @@ import type { IWaitUntil } from '#/_base/event'; -import type { ToolCall } from '#/kosong/contract/message'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { ToolCall } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import type { ExecutableTool, diff --git a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts index fec11d1fd30..6080b211bcc 100644 --- a/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts +++ b/packages/agent-core-v2/src/agent/toolResultTruncation/toolResultTruncationService.ts @@ -9,7 +9,7 @@ import { } from '#/tool/toolContract'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { AGENT_WIRE_RECORD_KEY } from '#/wire/record'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { basename, join, normalize } from 'pathe'; import { diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts index 36d6458a75b..1902ea8f0a4 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelect.ts @@ -1,6 +1,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import type { Tool } from '#/kosong/contract/tool'; +import type { ToolDescription as Tool } from '#human/llm/message'; import type { ToolInfo } from '#/tool/toolContract'; export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 208e3cefdd5..6a389808314 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -4,7 +4,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { defineState } from '#/state/state'; import { IEventBus } from '#/app/event/eventBus'; import { IFlagService } from '#/app/flag/flag'; -import type { Tool } from '#/kosong/contract/tool'; +import type { ToolDescription as Tool } from '#human/llm/message'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; import type { ContextMessage } from '#/agent/contextMemory/types'; diff --git a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts index 886a6b0c01e..3f2dc6605e4 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/subagent-task.ts @@ -1,4 +1,4 @@ -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import type { SubagentModelSource } from '#/session/subagent/configSection'; import { isAbortError } from '#/_base/utils/abort'; diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts index 9df67d0e875..a3bdda43889 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/read-media-file.ts @@ -1,7 +1,7 @@ import { z } from 'zod'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import type { VideoUploadInput as ProviderVideoUploadInput } from '#/kosong/contract/provider'; +import type { VideoURLPart } from '#human/llm/message'; +import type { VideoUploadInput as ProviderVideoUploadInput } from '#human/llm/media/upload'; export const MAX_MEDIA_MEGABYTES = 100; export const MAX_MEDIA_BYTES = MAX_MEDIA_MEGABYTES * 1024 * 1024; diff --git a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts index 30c95fca4a5..82c3bfcb69e 100644 --- a/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts +++ b/packages/agent-core-v2/src/agent/tools/read-media-file/readMediaFileTool.ts @@ -1,6 +1,6 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ContentPart } from '#/kosong/contract/message'; -import { VideoUploadUnsupportedError } from '#/kosong/contract/errors'; +import type { ModelCapability } from '#human/llm/capability'; +import type { ContentPart } from '#human/llm/message'; +import { VideoUploadUnsupportedError } from '#/llm-adapter/contract/errors'; import { inlineVideoPart, isVideoUploadAuthError } from '#/agent/media/videoUpload'; import type { ITelemetryService } from '#/app/telemetry/telemetry'; diff --git a/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts b/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts index 9065defcd61..ff2cafb7015 100644 --- a/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts +++ b/packages/agent-core-v2/src/agent/usage/cacheProbeService.ts @@ -3,8 +3,8 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { LifecycleScope } from '#/app/scopes'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { inputTotal } from '#/kosong/contract/usage'; -import { IModelCatalog } from '#/kosong/model/catalog'; +import { inputTotal } from '#human/llm/usage'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IAgentCacheProbeService } from './cacheProbe'; diff --git a/packages/agent-core-v2/src/agent/usage/usage.ts b/packages/agent-core-v2/src/agent/usage/usage.ts index f47e96abdc9..c40e8ed54fc 100644 --- a/packages/agent-core-v2/src/agent/usage/usage.ts +++ b/packages/agent-core-v2/src/agent/usage/usage.ts @@ -1,6 +1,6 @@ import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import { type ErrorCode } from '#/errors'; import { Error2 } from '#/_base/errors/errors'; diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index d44ca844a1e..7b8b1316642 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { AgentEvent2 } from '#/app/event/event2'; -import { type TokenUsage } from '#/kosong/contract/usage'; +import { type TokenUsage } from '#human/llm/usage'; export type UsageRecordScope = 'session' | 'turn'; diff --git a/packages/agent-core-v2/src/app/auth/auth.ts b/packages/agent-core-v2/src/app/auth/auth.ts index 19e6dedb2ae..7b75a8a3e52 100644 --- a/packages/agent-core-v2/src/app/auth/auth.ts +++ b/packages/agent-core-v2/src/app/auth/auth.ts @@ -11,7 +11,7 @@ import type { import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { Error2 } from '#/_base/errors/errors'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; import { AuthErrors } from './errors'; import type { diff --git a/packages/agent-core-v2/src/app/auth/authService.ts b/packages/agent-core-v2/src/app/auth/authService.ts index b23176ef0e4..820a7279b40 100644 --- a/packages/agent-core-v2/src/app/auth/authService.ts +++ b/packages/agent-core-v2/src/app/auth/authService.ts @@ -47,8 +47,8 @@ import { resolveModelForReady, providerNameFromFlatModel, type ModelReadyFailureReason, -} from '#/kosong/model/modelAuth'; -import { IModelService, type ModelRecord } from '#/kosong/model/model'; +} from '#/llm-adapter/model/model-auth'; +import { IModelService, type ModelRecord } from '#/llm-adapter/model/model'; import { DEFAULT_MODEL_SECTION, MODELS_SECTION, @@ -61,8 +61,8 @@ import { type OAuthRef, type ProviderConfig, type ProvidersChangedEvent, -} from '#/kosong/provider/provider'; -import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; +} from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { diff --git a/packages/agent-core-v2/src/app/auth/configSection.ts b/packages/agent-core-v2/src/app/auth/configSection.ts index 9c035298a31..0ac2dd6b834 100644 --- a/packages/agent-core-v2/src/app/auth/configSection.ts +++ b/packages/agent-core-v2/src/app/auth/configSection.ts @@ -19,7 +19,7 @@ import { transformPlainObject, } from '#/app/config/toml'; import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; export const SERVICES_SECTION = 'services'; diff --git a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts index 39960ac163c..de9e9817571 100644 --- a/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts +++ b/packages/agent-core-v2/src/app/auth/webSearch/webSearchService.ts @@ -9,8 +9,8 @@ import { IOAuthService } from '#/app/auth/auth'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IProviderService, type ProviderConfig } from '#/kosong/provider/provider'; -import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; +import { IProviderService, type ProviderConfig } from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; import { SERVICES_SECTION, type ServicesConfig } from '../configSection'; import { MoonshotWebSearchProvider } from './providers/moonshot-web-search'; diff --git a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts index e61886ed5d3..0f16472277f 100644 --- a/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts +++ b/packages/agent-core-v2/src/app/authLegacy/authLegacyService.ts @@ -10,9 +10,9 @@ import { MODELS_SECTION, PROVIDERS_SECTION, } from '#/app/kosongConfig/configSection'; -import { resolveModelForReady } from '#/kosong/model/modelAuth'; -import type { ModelRecord } from '#/kosong/model/model'; -import type { ProviderConfig } from '#/kosong/provider/provider'; +import { resolveModelForReady } from '#/llm-adapter/model/model-auth'; +import type { ModelRecord } from '#/llm-adapter/model/model'; +import type { ProviderConfig } from '#/llm-adapter/provider/provider'; import { IAuthLegacyService } from './authLegacy'; diff --git a/packages/agent-core-v2/src/app/capability/capabilityService.ts b/packages/agent-core-v2/src/app/capability/capabilityService.ts index c89afba6084..fcf6117d1c5 100644 --- a/packages/agent-core-v2/src/app/capability/capabilityService.ts +++ b/packages/agent-core-v2/src/app/capability/capabilityService.ts @@ -10,7 +10,7 @@ import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IPluginService } from '#/app/plugin/plugin'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import { IHostProcessService } from '#/os/interface/hostProcess'; import { ICapabilityService } from './capability'; diff --git a/packages/agent-core-v2/src/app/config/errors.ts b/packages/agent-core-v2/src/app/config/errors.ts index 63c01cb813a..1af2a67f7b1 100644 --- a/packages/agent-core-v2/src/app/config/errors.ts +++ b/packages/agent-core-v2/src/app/config/errors.ts @@ -1,5 +1,5 @@ import { registerErrorDomain, type ErrorDomain } from '#/_base/errors/codes'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; export const ConfigErrors = { codes: { diff --git a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts index b7870ca0aef..992e9b3bc67 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/configSection.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/configSection.ts @@ -15,10 +15,10 @@ import { transformPlainObject, } from '#/app/config/toml'; import { type AssertExact, type Equal } from '#/_base/utils/typeEquality'; -import type { ModelOverride, ModelRecord, ModelsSection } from '#/kosong/model/model'; -import type { ThinkingConfig } from '#/kosong/model/thinking'; -import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/kosong/provider/provider'; -import { ProtocolSchema } from '#/kosong/protocol/protocol'; +import type { ModelOverride, ModelRecord, ModelsSection } from '#/llm-adapter/model/model'; +import type { ThinkingConfig } from '#/llm-adapter/model/thinking'; +import type { OAuthRef, ProviderConfig, ProvidersSection } from '#/llm-adapter/provider/provider'; +import { ProtocolSchema } from '#/llm-adapter/protocol/protocol'; export const PROVIDERS_SECTION = 'providers'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts index e04413b9bba..b7ad0673099 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/discoveryService.ts @@ -13,15 +13,15 @@ import { AuthErrors } from '#/app/auth/errors'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; import { IEventService } from '#/app/event/event'; -import { ModelCatalogErrors } from '#/kosong/model/errors'; -import { type ModelRecord } from '#/kosong/model/model'; +import { ModelCatalogErrors } from '#/llm-adapter/model/errors'; +import { type ModelRecord } from '#/llm-adapter/model/model'; import { IProviderService, type ModelSource, type OAuthRef, type ProviderConfig, -} from '#/kosong/provider/provider'; -import { getProviderDefinition } from '#/kosong/provider/providerDefinition'; +} from '#/llm-adapter/provider/provider'; +import { getProviderDefinition } from '#/llm-adapter/provider/provider-definition'; import { DEFAULT_MODEL_SECTION, diff --git a/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts index 79afbb21b62..d0c4e631177 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/envOverlay.ts @@ -3,8 +3,8 @@ import { Error2 } from '#/_base/errors/errors'; import type { ConfigEffectiveOverlay } from '#/app/config/config'; import { registerConfigOverlay } from '#/app/config/configOverlayContributions'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import { resolveProviderEndpoint } from '#/kosong/provider/providerDefinition'; +import { CONFIG_INVALID_ERROR_CODE } from '#/llm-adapter/contract/errors'; +import { resolveProviderEndpoint } from '#/llm-adapter/provider/provider-definition'; import { ENV_MODEL_PROVIDER_KEY } from './configSection'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts index 52c9312569c..4525e708f44 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/hostRequestHeadersAdapter.ts @@ -2,7 +2,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; +import { IHostRequestHeaders } from '#/llm-adapter/model/host-request-headers'; export class HostRequestHeadersAdapter implements IHostRequestHeaders { readonly headers: Readonly>; diff --git a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts index 774f1755fac..e2fc9726184 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/kosongConfigService.ts @@ -7,8 +7,8 @@ import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; import { type ConfigSectionChangedEvent, IConfigService } from '#/app/config/config'; import { describeUnknownError } from '#/app/config/configPure'; import { deepEqual } from '#/app/config/sectionDiff'; -import { IModelService, type ModelsSection } from '#/kosong/model/model'; -import { IProviderService, type ProvidersSection } from '#/kosong/provider/provider'; +import { IModelService, type ModelsSection } from '#/llm-adapter/model/model'; +import { IProviderService, type ProvidersSection } from '#/llm-adapter/provider/provider'; import { IKosongConfigService } from './kosongConfig'; import { diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts index 8d15f95be1a..f2097a34424 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDev.ts @@ -1,7 +1,7 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderType } from '#/kosong/provider/provider'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ProviderType } from '#/llm-adapter/provider/provider'; -import { wireHasProtocolThinkingDisable } from '#/kosong/model/thinking'; +import { wireHasProtocolThinkingDisable } from '#/llm-adapter/model/thinking'; export interface ModelsDevModelEntry { readonly id?: string; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts index 621c36755e1..43528456bf1 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImport.ts @@ -1,5 +1,5 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ProviderCatalogItem } from '#/kosong/model/catalog'; +import type { ProviderCatalogItem } from '#/llm-adapter/model/catalog'; export interface ModelsDevModelItem { readonly id: string; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts index 22681feb21a..c3c2eceb619 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevImportService.ts @@ -11,9 +11,9 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IConfigService } from '#/app/config/config'; -import { IModelCatalog } from '#/kosong/model/catalog'; -import { type ModelsSection } from '#/kosong/model/model'; -import { type ProviderConfig, type ProvidersSection } from '#/kosong/provider/provider'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { type ModelsSection } from '#/llm-adapter/model/model'; +import { type ProviderConfig, type ProvidersSection } from '#/llm-adapter/provider/provider'; import { modelsDevProviderModels, resolveModelsDevImport } from './modelsDev'; import { DEFAULT_MODEL_SECTION, MODELS_SECTION, PROVIDERS_SECTION } from './configSection'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts index 3b385ab113c..1d56256de74 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/modelsDevUpstream.ts @@ -1,7 +1,7 @@ import { CoreErrors } from '#/_base/errors/codes'; import { BugIndicatingError, Error2 } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ModelRecord } from '#/kosong/model/model'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import type { ModelRecord } from '#/llm-adapter/model/model'; import { BUILT_IN_MODELS_DEV_JSON } from './builtInModelsDev'; import { ModelsDevImportErrors } from './errors'; diff --git a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts index 1cf5c8ea4d9..4d870c1a4da 100644 --- a/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts +++ b/packages/agent-core-v2/src/app/kosongConfig/oauthTokenAdapter.ts @@ -5,9 +5,9 @@ import { Error2 } from '#/_base/errors/errors'; import { IOAuthService } from '#/app/auth/auth'; import { AuthErrors } from '#/app/auth/errors'; -import { nonEmpty } from '#/kosong/model/modelAuth'; -import { IModelOAuthTokens } from '#/kosong/model/modelOAuth'; -import type { OAuthRef } from '#/kosong/provider/provider'; +import { nonEmpty } from '#/llm-adapter/model/model-auth'; +import { IModelOAuthTokens } from '#/llm-adapter/model/model-oauth'; +import type { OAuthRef } from '#/llm-adapter/provider/provider'; export class ModelOAuthTokenAdapter implements IModelOAuthTokens { declare readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/app/plugin/pluginService.ts b/packages/agent-core-v2/src/app/plugin/pluginService.ts index 4469bfcb8c8..58fde0cbc4c 100644 --- a/packages/agent-core-v2/src/app/plugin/pluginService.ts +++ b/packages/agent-core-v2/src/app/plugin/pluginService.ts @@ -9,7 +9,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; import type { SkillRoot } from '#/features/skill/catalog/types'; import { BugIndicatingError, Error2, PluginErrors } from '#/errors'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import type { McpServerConfig } from '#/mcpCore/config-schema'; import { PluginManager } from './manager'; diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index 2b559a8f744..d643d1a260c 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -24,8 +24,8 @@ import { getLiveSessionById, resumeSessionById, } from '#/app/sessionManager/sessionLookup'; -import { IModelCatalog } from '#/kosong/model/catalog'; -import { IModelService } from '#/kosong/model/model'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; +import { IModelService } from '#/llm-adapter/model/model'; import { ErrorCodes, Error2 } from '#/errors'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; diff --git a/packages/agent-core-v2/src/app/web/webService.ts b/packages/agent-core-v2/src/app/web/webService.ts index 4d56392707d..350dcfbc905 100644 --- a/packages/agent-core-v2/src/app/web/webService.ts +++ b/packages/agent-core-v2/src/app/web/webService.ts @@ -10,8 +10,8 @@ import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { IProviderService } from '#/kosong/provider/provider'; -import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; +import { IProviderService } from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; import { LocalFetchURLProvider } from './providers/local-fetch-url'; import { MoonshotFetchURLProvider } from './providers/moonshot-fetch-url'; diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index 54018cf97e3..5da52b4095e 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -2,7 +2,7 @@ import { CoreErrors } from '#/_base/errors/codes'; import { AgentLifecycleErrors } from '#/session/agentLifecycle/errors'; import { AuthErrors } from '#/app/auth/errors'; import { TaskErrors } from '#/agent/task/errors'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; +import { ProtocolErrors } from '#/llm-adapter/protocol/errors'; import { ConfigErrors } from '#/app/config/errors'; import { CapabilityErrors } from '#/app/capability/errors'; import { CronErrors } from '#/features/cron/errors'; @@ -14,7 +14,7 @@ import { FullCompactionErrors } from '#/agent/fullCompaction/errors'; import { GoalErrors } from '#/features/goal/errors'; import { LoopErrors } from '#/agent/loop/errors'; import { McpErrors } from '#/mcpCore/errors'; -import { ModelCatalogErrors } from '#/kosong/model/errors'; +import { ModelCatalogErrors } from '#/llm-adapter/model/errors'; import { OsFsErrors } from '#/os/interface/hostFsErrors'; import { OsProcessErrors } from '#/os/interface/hostProcess'; import { PluginErrors } from '#/app/plugin/errors'; @@ -40,7 +40,7 @@ export * from '#/_base/errors/unexpectedError'; export { AgentLifecycleErrors } from '#/session/agentLifecycle/errors'; export { AuthErrors } from '#/app/auth/errors'; export { TaskErrors } from '#/agent/task/errors'; -export { ProtocolErrors } from '#/kosong/protocol/errors'; +export { ProtocolErrors } from '#/llm-adapter/protocol/errors'; export { ConfigErrors } from '#/app/config/errors'; export { CapabilityErrors } from '#/app/capability/errors'; export { CronErrors } from '#/features/cron/errors'; @@ -51,7 +51,7 @@ export { FullCompactionErrors } from '#/agent/fullCompaction/errors'; export { GoalErrors } from '#/features/goal/errors'; export { LoopErrors } from '#/agent/loop/errors'; export { McpErrors } from '#/mcpCore/errors'; -export { ModelCatalogErrors } from '#/kosong/model/errors'; +export { ModelCatalogErrors } from '#/llm-adapter/model/errors'; export { OsFsErrors } from '#/os/interface/hostFsErrors'; export { OsProcessErrors } from '#/os/interface/hostProcess'; export { PluginErrors } from '#/app/plugin/errors'; diff --git a/packages/agent-core-v2/src/features/cron/cronService.ts b/packages/agent-core-v2/src/features/cron/cronService.ts index c7cf42ddfde..8094bdabae3 100644 --- a/packages/agent-core-v2/src/features/cron/cronService.ts +++ b/packages/agent-core-v2/src/features/cron/cronService.ts @@ -23,7 +23,7 @@ import { jitteredNextCronRunMs, oneShotJitteredNextCronRunMs } from '#/features/ import type { CronDeletedEvent, CronScheduledEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { BugIndicatingError } from '#/errors'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { IEventDispatcher } from '#/state/eventDispatcher'; diff --git a/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts b/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts index 81d25799160..4d9592cb1fc 100644 --- a/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts +++ b/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts @@ -14,7 +14,6 @@ import { } from '#/agent/fullCompaction/fullCompaction'; import type { CompactionResult } from '#/agent/fullCompaction/types'; import { IAgentLoopService, type AfterStepContext } from '#/agent/loop/loop'; -import { ContinuationStepRequest } from '#/agent/loop/stepRequest'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded } from '#/agent/loop/turnOps'; import { @@ -252,13 +251,7 @@ export class AgentExternalHooksService extends Service implements IAgentExternal toolCalls: [], origin: { kind: 'system_trigger', name: 'stop_hook' }, }); - loop.enqueue( - new ContinuationStepRequest({ - kind: 'stop_hook', - mergeable: true, - admission: 'activeOrNextTurn', - }), - ); + loop.notify(); return; } }), diff --git a/packages/agent-core-v2/src/features/externalHooks/internal/types.ts b/packages/agent-core-v2/src/features/externalHooks/internal/types.ts index 1cab03130b1..b83753181d7 100644 --- a/packages/agent-core-v2/src/features/externalHooks/internal/types.ts +++ b/packages/agent-core-v2/src/features/externalHooks/internal/types.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; export const HOOK_EVENT_TYPES = [ 'PreToolUse', diff --git a/packages/agent-core-v2/src/features/externalHooks/session/sessionExternalHooksService.ts b/packages/agent-core-v2/src/features/externalHooks/session/sessionExternalHooksService.ts index 9b6aa2251b0..cdb13a80dc5 100644 --- a/packages/agent-core-v2/src/features/externalHooks/session/sessionExternalHooksService.ts +++ b/packages/agent-core-v2/src/features/externalHooks/session/sessionExternalHooksService.ts @@ -1,7 +1,7 @@ import { Service } from '#/_base/di/service'; import { IntervalTimer } from '#/_base/utils/timer'; import { ISessionManager } from '#/app/sessionManager/sessionManager'; -import { IModelService } from '#/kosong/model/model'; +import { IModelService } from '#/llm-adapter/model/model'; import { ISessionAgentProfileCatalog, } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; diff --git a/packages/agent-core-v2/src/features/goal/goalService.ts b/packages/agent-core-v2/src/features/goal/goalService.ts index fd6cd26c581..b94c8d7aefd 100644 --- a/packages/agent-core-v2/src/features/goal/goalService.ts +++ b/packages/agent-core-v2/src/features/goal/goalService.ts @@ -21,9 +21,8 @@ import { IAgentLoopService, type AfterStepContext, type BeforeStepContext, - type EnqueueReceipt, + type Turn, } from '#/agent/loop/loop'; -import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded } from '#/agent/loop/turnOps'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; @@ -177,7 +176,7 @@ export interface GoalRuntimeState { } interface PendingContinuation { - readonly receipt: EnqueueReceipt; + readonly turn: Turn; readonly goalId: string; turnId?: number; } @@ -600,7 +599,7 @@ function enqueueGoalOutcomeContinuation(context: GoalOperationContext, ctx: Afte context.effects.goalOutcomeContinuationTurns.add(ctx.turnId); const maxSteps = context.runtime.get(IConfigService).get(LOOP_CONTROL_SECTION)?.maxStepsPerTurn; if (!hasStepBudgetRemaining(maxSteps, ctx.step)) return; - context.runtime.get(IAgentLoopService).enqueue(new ContinuationStepRequest()); + context.runtime.get(IAgentLoopService).notify(); } async function handleTurnEnded(context: GoalOperationContext, @@ -723,25 +722,17 @@ function launchContinuationTurn(context: GoalOperationContext, goalId: string, s toolCalls: [], origin: GOAL_CONTINUATION_ORIGIN, }; - const request = new MessageStepRequest(message, { - kind: 'goal_continuation', - admission: 'newTurn', - }); - const receipt = context.runtime.get(IAgentLoopService).enqueue(request); - const pending: PendingContinuation = { receipt, goalId }; + const { turn } = context.runtime.get(IAgentLoopService).submit({ message }); + const pending: PendingContinuation = { turn, goalId }; context.effects.pendingContinuation = pending; - void receipt.assigned - .then(({ turn }) => { - pending.turnId = turn.id; - if (!context.effects.goalDrivenTurns.has(turn.id)) { - context.effects.pendingContinuationGoals.set(turn.id, pending.goalId); - } - return turn.result; - }) - .finally(() => { - if (pending.turnId !== undefined) context.effects.pendingContinuationGoals.delete(pending.turnId); - if (context.effects.pendingContinuation === pending) context.effects.pendingContinuation = undefined; - }); + pending.turnId = turn.id; + if (!context.effects.goalDrivenTurns.has(turn.id)) { + context.effects.pendingContinuationGoals.set(turn.id, pending.goalId); + } + void turn.result.finally(() => { + if (pending.turnId !== undefined) context.effects.pendingContinuationGoals.delete(pending.turnId); + if (context.effects.pendingContinuation === pending) context.effects.pendingContinuation = undefined; + }); } function canLaunchContinuation(context: GoalOperationContext): boolean { @@ -775,8 +766,8 @@ function cancelPendingContinuation(context: GoalOperationContext, if (preserveLiveContinuation && pending?.turnId === context.effects.liveTurnId) return; context.effects.pendingContinuation = undefined; const cancellation = reason ?? abortError('Goal continuation cancelled'); - const aborted = pending?.receipt.abort(cancellation); - if (pending !== undefined && !aborted && pending.turnId !== undefined) { + const cancelled = pending?.turn.cancel(cancellation) ?? false; + if (pending !== undefined && !cancelled && pending.turnId !== undefined) { context.runtime.get(IAgentLoopService).cancel(pending.turnId, cancellation); } } diff --git a/packages/agent-core-v2/src/features/reminder/types.ts b/packages/agent-core-v2/src/features/reminder/types.ts index 0bb66679f1d..1835dd3f161 100644 --- a/packages/agent-core-v2/src/features/reminder/types.ts +++ b/packages/agent-core-v2/src/features/reminder/types.ts @@ -1,7 +1,6 @@ import type { IDisposable } from '#/_base/di/lifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import type { ContentPart } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; +import type { ContentPart, ToolDescription as Tool } from '#human/llm/message'; export interface ContextInjectionContext { readonly injectedPositions: readonly number[]; diff --git a/packages/agent-core-v2/src/features/skill/skill.ts b/packages/agent-core-v2/src/features/skill/skill.ts index 8c7e12ffc60..9255e20dad9 100644 --- a/packages/agent-core-v2/src/features/skill/skill.ts +++ b/packages/agent-core-v2/src/features/skill/skill.ts @@ -1,4 +1,4 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import type { PromptFileAttachment } from '#/agent/contextMemory/types'; export interface SkillActivationInput { diff --git a/packages/agent-core-v2/src/features/skill/skillService.ts b/packages/agent-core-v2/src/features/skill/skillService.ts index 9048fccfc34..49cad63c479 100644 --- a/packages/agent-core-v2/src/features/skill/skillService.ts +++ b/packages/agent-core-v2/src/features/skill/skillService.ts @@ -13,7 +13,7 @@ import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IEventService } from '#/app/event/event'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2 } from '#/errors'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; diff --git a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts index 75443eb24f5..6c6214a5a96 100644 --- a/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts +++ b/packages/agent-core-v2/src/features/swarm/session/agentRunBatch.ts @@ -1,5 +1,5 @@ -import { isProviderRateLimitError } from '#/kosong/contract/errors'; -import { type TokenUsage } from '#/kosong/contract/usage'; +import { isProviderRateLimitError } from '#/llm-adapter/contract/errors'; +import { type TokenUsage } from '#human/llm/usage'; import * as retry from 'retry'; import { isUserCancellation } from '#/_base/utils/abort'; diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts index 6ad754ad9a8..839bb65ef32 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarm.ts @@ -1,4 +1,4 @@ -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { SubagentSpawnPlan } from '#/session/subagent/spawn'; diff --git a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts index 9bc8b30d317..d99092f6d37 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -1,5 +1,5 @@ /* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace -- Event2 class+payload-interface declaration merging is the sanctioned event-declaration idiom. */ -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import { Error2, ErrorCodes } from '#/errors'; import { linkAbortSignal } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts index 7ab16708351..72e6221084a 100644 --- a/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts +++ b/packages/agent-core-v2/src/features/tower/tools/spawn/spawnTool.ts @@ -23,7 +23,7 @@ import { IAgentTowerService, TOWER_WORKER_PROFILE } from '#/features/tower/tower import { ITowerRateLimitService } from '#/features/tower/towerRateLimit'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; import { toInputJsonSchema } from '#/tool/input-schema'; import { type ExecutableToolContext, diff --git a/packages/agent-core-v2/src/human/agent/context-usage.ts b/packages/agent-core-v2/src/human/agent/context-usage.ts new file mode 100644 index 00000000000..69b445ffc96 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/context-usage.ts @@ -0,0 +1,85 @@ +import type { Message, ToolDescription } from '#/llm/message'; +import type { TokenUsage } from '#/llm/usage'; + +import type { AssistantEntry, HistoryMessage } from './turn'; + +const MEDIA_TOKEN_ESTIMATE = 2000; + +export interface ContextUsagePrefix { + systemPrompt?: string; + tools?: readonly ToolDescription[]; +} + +export function calculateContextTokens(usage: TokenUsage): number { + return usage.inputOther + usage.inputCacheRead + usage.inputCacheCreation + usage.output; +} + +export function estimateTextTokens(text: string): number { + let asciiCount = 0; + let nonAsciiCount = 0; + for (const char of text) { + if ((char.codePointAt(0) as number) <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + } + return Math.ceil(asciiCount / 4) + nonAsciiCount; +} + +export function estimateMessageTokens(message: Message): number { + let total = estimateTextTokens(message.role); + for (const part of message.content) { + switch (part.type) { + case 'text': + total += estimateTextTokens(part.text); + break; + case 'think': + total += estimateTextTokens(part.think); + break; + case 'image_url': + case 'audio_url': + case 'video_url': + total += MEDIA_TOKEN_ESTIMATE; + break; + } + } + if (message.role === 'assistant') { + for (const call of message.toolCalls) { + total += estimateTextTokens(call.name); + total += estimateTextTokens(call.arguments ?? ''); + } + } + return total; +} + +export function estimateUsedContextTokens( + history: readonly HistoryMessage[], + prefix?: ContextUsagePrefix, +): number { + let lastUsageIndex = -1; + let usageTokens = 0; + for (let i = history.length - 1; i >= 0; i--) { + const entry = history[i] as HistoryMessage; + if (entry.message.role !== 'assistant') continue; + const tokens = calculateContextTokens((entry as AssistantEntry).meta.usage); + if (tokens > 0) { + lastUsageIndex = i; + usageTokens = tokens; + break; + } + } + let tokens = usageTokens; + for (let i = lastUsageIndex + 1; i < history.length; i++) { + tokens += estimateMessageTokens((history[i] as HistoryMessage).message); + } + if (lastUsageIndex === -1 && prefix !== undefined) { + if (prefix.systemPrompt !== undefined) { + tokens += estimateTextTokens(prefix.systemPrompt); + } + if (prefix.tools !== undefined && prefix.tools.length > 0) { + tokens += estimateTextTokens(JSON.stringify(prefix.tools)); + } + } + return tokens; +} diff --git a/packages/agent-core-v2/src/human/agent/errors.ts b/packages/agent-core-v2/src/human/agent/errors.ts new file mode 100644 index 00000000000..bad84a7db70 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/errors.ts @@ -0,0 +1,30 @@ +export const LOOP_MAX_STEPS_EXCEEDED_ERROR_CODE = 'loop.max_steps_exceeded'; + +export type TurnInterruptReason = 'max_steps' | 'error'; + +export class MaxStepsExceededError extends Error { + readonly code = LOOP_MAX_STEPS_EXCEEDED_ERROR_CODE; + readonly details: { maxSteps: number }; + + constructor(maxSteps: number, message?: string) { + super( + message ?? + `Turn exceeded maxSteps=${maxSteps}. If max_steps_per_turn is too small, raise it in config.toml (loop_control.max_steps_per_turn), or run "/update-config" to update it, then "/reload".`, + ); + this.name = 'MaxStepsExceededError'; + this.details = { maxSteps }; + } +} + +export function isMaxStepsExceededError(error: unknown): error is MaxStepsExceededError { + if (error instanceof MaxStepsExceededError) return true; + return ( + typeof error === 'object' && + error !== null && + (error as { code?: unknown }).code === LOOP_MAX_STEPS_EXCEEDED_ERROR_CODE + ); +} + +export function interruptReasonOf(error: unknown): TurnInterruptReason { + return isMaxStepsExceededError(error) ? 'max_steps' : 'error'; +} diff --git a/packages/agent-core-v2/src/human/agent/machine.ts b/packages/agent-core-v2/src/human/agent/machine.ts new file mode 100644 index 00000000000..468cdf4f49e --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/machine.ts @@ -0,0 +1,542 @@ +import { assign, emit, enqueueActions, sendTo, setup } from '#/xstate2'; + +import { + createUserMessage, + type SystemMessage, + type ToolCall, + type UserMessage, +} from '#/llm/message'; +import type { LlmRequestConfig } from '#/llm/requester/requester'; +import type { ToolExecutor, ToolResult } from '#/tool/executor'; +import { createToolMachine, type ToolEvent, type ToolOutput } from '#/tool/machine'; +import type { ToolDefinition } from '#/tool/tool'; + +import { createWaitForTasks, type ToolActorRef } from './wait-for'; +import { interruptReasonOf, type TurnInterruptReason } from './errors'; +import { createSystemEntry, createUserEntry } from './turn'; +import type { + createTurnMachine, + HistoryMessage, + TurnLlmEvent, + TurnOutput, + UserEntry, +} from './turn'; + +export interface AgentInput { + request: LlmRequestConfig; + history?: readonly HistoryMessage[]; + turnId?: number; + branchId?: string; +} + +export type AgentEvent = + | TurnLlmEvent + | ToolEvent + | { type: 'input.submit'; id?: string; message: UserMessage } + | { type: 'input.notify'; message: UserMessage } + | { type: 'input.reminder'; key: string; message: UserMessage | SystemMessage } + | { type: 'input.steer'; id: string } + | { type: 'input.abort' } + | { type: 'turn.spawnTools'; toolCalls: ToolCall[] } + | { type: 'turn.drain' } + | { type: 'turn.remindersConsumed'; reminders: HistoryMessage[] } + | { type: 'context.reset'; history: readonly HistoryMessage[]; turnId: number; branchId?: string }; + +export type AgentEmitted = + | TurnLlmEvent + | ToolEvent + | { type: 'turn.start'; turnId: number; branchId: string } + | { type: 'turn.aborting' } + | { type: 'turn.remindersConsumed'; reminders: HistoryMessage[] } + | { type: 'turn.done'; messages: HistoryMessage[]; branchId: string } + | { + type: 'turn.failed'; + error: unknown; + messages: HistoryMessage[]; + interruptReason: TurnInterruptReason; + branchId: string; + } + | { type: 'turn.aborted'; messages: HistoryMessage[]; branchId: string } + | { type: 'context.reset'; branchId: string }; + +interface ToolEntry { + toolCall: ToolCall; + ref: ToolActorRef; +} + +export interface QueuedPrompt { + id?: string; + message: UserMessage; +} + +export interface AgentMachineContext { + input: AgentInput; + messages: HistoryMessage[]; + turnTools: Record; + background: Record; + notifications: UserEntry[]; + reminders: HistoryMessage[]; + queue: QueuedPrompt[]; + turnId: number; + branchId: string; +} + +function completionNotification(toolCall: ToolCall, output: ToolOutput): UserEntry { + if (output.type === 'failed') { + const text = output.error instanceof Error ? output.error.message : String(output.error); + return createUserEntry( + createUserMessage(`[async tool failed] ${toolCall.name} (tool_call_id=${toolCall.id})\n${text}`), + { source: 'async-tool' }, + ); + } + if (output.type === 'aborted') { + return createUserEntry( + createUserMessage(`[async tool aborted] ${toolCall.name} (tool_call_id=${toolCall.id})`), + { source: 'async-tool' }, + ); + } + return createUserEntry( + { + role: 'user', + content: [ + { + type: 'text', + text: `[async tool completed] ${toolCall.name} (tool_call_id=${toolCall.id})`, + }, + ...output.result.content, + ], + }, + { source: 'async-tool' }, + ); +} + +function completionPatch( + context: AgentMachineContext, + event: { toolCallId: string } & ({ result: ToolResult } | { error: unknown }), +): { notifications?: UserEntry[]; background?: AgentMachineContext['background'] } { + const entry = context.background[event.toolCallId]; + if (entry === undefined) { + return {}; + } + const output: ToolOutput = + 'result' in event + ? { type: 'succeeded', result: event.result } + : { type: 'failed', error: event.error }; + const background = { ...context.background }; + delete background[event.toolCallId]; + return { + notifications: [...context.notifications, completionNotification(entry.toolCall, output)], + background, + }; +} + +function turnOutputPatch( + context: AgentMachineContext, + output: TurnOutput, +): Pick { + return { + messages: [...context.messages, ...output.produced], + }; +} + +function turnOutcomeEvent(context: AgentMachineContext, output: TurnOutput): AgentEmitted { + if (output.type === 'failed') { + return { + type: 'turn.failed', + error: output.error, + messages: context.messages, + interruptReason: interruptReasonOf(output.error), + branchId: context.branchId, + }; + } + if (output.type === 'aborted') { + return { type: 'turn.aborted', messages: context.messages, branchId: context.branchId }; + } + return { type: 'turn.done', messages: context.messages, branchId: context.branchId }; +} + +function hasPendingWork(context: AgentMachineContext): boolean { + return context.notifications.length > 0 || context.queue.length > 0; +} + +function hasBackgroundWork(context: AgentMachineContext): boolean { + return Object.keys(context.background).length > 0; +} + +function drainPendingPatch( + context: AgentMachineContext, +): Pick { + const [head, ...rest] = context.queue; + return { + messages: [ + ...context.messages, + ...context.notifications, + ...(head === undefined ? [] : [createUserEntry(head.message, { source: 'input' })]), + ], + notifications: [], + queue: rest, + }; +} + +function steerPatch( + context: AgentMachineContext, + id: string, +): Partial> { + const index = context.queue.findIndex((entry) => entry.id === id); + if (index === -1) { + return {}; + } + const entry = context.queue[index] as QueuedPrompt; + return { + queue: context.queue.filter((_, i) => i !== index), + notifications: [...context.notifications, createUserEntry(entry.message, { source: 'input' })], + }; +} + +export interface CreateAgentMachineOptions { + tools?: readonly ToolDefinition[]; + turnActor: ReturnType; + abortTimeoutMs?: number; + maxStepsPerTurn?: number; +} + +function dispatchTools(tools: readonly ToolDefinition[]): ToolExecutor { + const byName = new Map(); + for (const tool of tools) { + if (byName.has(tool.name)) { + throw new Error(`duplicate tool name: '${tool.name}'`); + } + byName.set(tool.name, tool); + } + return { + async execute(input) { + const tool = byName.get(input.toolCall.name); + if (tool === undefined) { + return { + content: [{ type: 'text', text: `unknown tool: ${input.toolCall.name}` }], + isError: true, + }; + } + return tool.execute(input); + }, + }; +} + +export function createAgentMachine({ + tools, + turnActor, + abortTimeoutMs, + maxStepsPerTurn, +}: CreateAgentMachineOptions) { + const executor = dispatchTools(tools ?? []); + return setup({ + types: { + input: {} as AgentInput, + context: {} as AgentMachineContext, + events: {} as AgentEvent, + emitted: {} as AgentEmitted, + }, + actors: { + turnActor, + toolActor: createToolMachine(executor), + }, + actions: { + forwardToParent: ({ self, event }) => { + self._parent?.send(event); + }, + spawnTurnTools: assign(({ context, spawn, self, event }) => { + if (event.type !== 'turn.spawnTools') { + return {}; + } + const waitForTasks = createWaitForTasks(self); + const turnTools = { ...context.turnTools }; + for (const toolCall of event.toolCalls) { + turnTools[toolCall.id] = { + toolCall, + ref: spawn('toolActor', { + id: toolCall.id, + input: { toolCall, waitForTasks }, + }), + }; + } + return { turnTools }; + }), + abortSpawnedTools: enqueueActions(({ context, event, enqueue }) => { + if (event.type !== 'turn.spawnTools') { + return; + } + for (const toolCall of event.toolCalls) { + const entry = context.turnTools[toolCall.id]; + if (entry !== undefined) { + enqueue.sendTo(entry.ref, { type: 'tool.abort' as const }); + } + } + }), + abortTurn: sendTo('turn', { type: 'turn.abort' as const }), + abortTurnTools: enqueueActions(({ context, enqueue }) => { + for (const entry of Object.values(context.turnTools)) { + enqueue.sendTo(entry.ref, { type: 'tool.abort' as const }); + } + }), + stopTurnTools: enqueueActions(({ context, enqueue }) => { + for (const toolCallId of Object.keys(context.turnTools)) { + enqueue.stopChild(toolCallId); + } + }), + }, + delays: { + abortTimeout: abortTimeoutMs ?? 10_000, + }, + }).createMachine({ + id: 'agent', + initial: 'idle', + context: ({ input }) => ({ + input, + messages: [...(input.history ?? [])], + turnTools: {}, + background: {}, + notifications: [], + reminders: [], + queue: [], + turnId: input.turnId ?? 0, + branchId: input.branchId ?? 'main', + }), + on: { + 'input.submit': { + actions: assign({ + queue: ({ context, event }) => [ + ...context.queue, + { id: event.id, message: event.message }, + ], + }), + }, + 'input.notify': { + actions: assign({ + notifications: ({ context, event }) => [ + ...context.notifications, + createUserEntry(event.message, { source: 'notify' }), + ], + }), + }, + 'input.reminder': { + actions: assign({ + reminders: ({ context, event }) => [ + ...context.reminders.filter((entry) => entry.meta.key !== event.key), + event.message.role === 'system' + ? createSystemEntry(event.message, { source: 'reminder', key: event.key }) + : createUserEntry(event.message, { source: 'reminder', key: event.key }), + ], + }), + }, + 'input.steer': { + actions: assign(({ context, event }) => steerPatch(context, event.id)), + }, + 'tool.update': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'tool.done': { + guard: ({ context, event }) => context.background[event.toolCallId] !== undefined, + actions: [ + assign(({ context, event }) => completionPatch(context, event)), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.failed': { + guard: ({ context, event }) => context.background[event.toolCallId] !== undefined, + actions: [ + assign(({ context, event }) => completionPatch(context, event)), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + }, + states: { + idle: { + initial: 'ready', + always: { + guard: ({ context }) => hasPendingWork(context), + target: 'running', + actions: assign(({ context }) => drainPendingPatch(context)), + }, + on: { + 'context.reset': { + actions: [ + assign(({ context, event }) => ({ + messages: [...event.history], + turnId: event.turnId, + branchId: event.branchId ?? context.branchId, + })), + emit(({ context }) => ({ type: 'context.reset' as const, branchId: context.branchId })), + 'forwardToParent', + ], + }, + }, + states: { + ready: { + always: { + guard: ({ context }) => hasBackgroundWork(context), + target: 'waiting', + }, + }, + waiting: {}, + }, + }, + running: { + invoke: { + id: 'turn', + src: 'turnActor', + input: ({ context }) => ({ + request: { ...context.input.request, tools: tools?.filter((tool) => tool.deferred !== true) }, + history: context.messages, + maxSteps: maxStepsPerTurn, + }), + onDone: { + target: '#agent.idle', + actions: [ + assign(({ context, event }) => turnOutputPatch(context, event.output)), + emit(({ context, event }) => turnOutcomeEvent(context, event.output)), + ], + }, + onError: { + target: '#agent.idle', + actions: emit(({ context, event }) => ({ + type: 'turn.failed' as const, + error: event.error, + messages: context.messages, + interruptReason: interruptReasonOf(event.error), + branchId: context.branchId, + })), + }, + }, + entry: [ + assign({ turnId: ({ context }) => context.turnId + 1 }), + emit(({ context }) => ({ type: 'turn.start' as const, turnId: context.turnId, branchId: context.branchId })), + ], + exit: assign({ turnTools: {} }), + initial: 'active', + on: { + 'turn.drain': { + actions: [ + sendTo('turn', ({ context }) => ({ + type: 'turn.notifications' as const, + messages: [...context.notifications, ...context.reminders], + })), + assign({ notifications: [], reminders: [] }), + ], + }, + 'tool.async': { + guard: ({ context, event }) => context.turnTools[event.toolCallId] !== undefined, + actions: [ + assign(({ context, event }) => { + const entry = context.turnTools[event.toolCallId] as ToolEntry; + const turnTools = { ...context.turnTools }; + delete turnTools[event.toolCallId]; + return { + turnTools, + background: { ...context.background, [event.toolCallId]: entry }, + }; + }), + sendTo('turn', ({ event }) => event), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.done': { + guard: ({ context, event }) => + context.background[event.toolCallId] === undefined && + context.turnTools[event.toolCallId] !== undefined, + actions: [ + sendTo('turn', ({ event }) => event), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.failed': { + guard: ({ context, event }) => + context.background[event.toolCallId] === undefined && + context.turnTools[event.toolCallId] !== undefined, + actions: [ + sendTo('turn', ({ event }) => event), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.aborted': { + guard: ({ context, event }) => + context.background[event.toolCallId] === undefined && + context.turnTools[event.toolCallId] !== undefined, + actions: [ + sendTo('turn', ({ event }) => event), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'llm.sent': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.delta': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.headers': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.done': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.failed.syntax': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.failed.remote': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.retrying': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.usage': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.finish': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'llm.message-id': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'turn.remindersConsumed': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + }, + states: { + active: { + on: { + 'turn.spawnTools': { + actions: 'spawnTurnTools', + }, + 'input.abort': { + target: 'aborting', + actions: [ + 'abortTurn', + 'abortTurnTools', + emit({ type: 'turn.aborting' as const }), + ], + }, + }, + }, + aborting: { + after: { + abortTimeout: { actions: ['abortTurn', 'stopTurnTools'] }, + }, + on: { + 'turn.spawnTools': { + actions: ['spawnTurnTools', 'abortSpawnedTools'], + }, + 'input.abort': { + actions: ['abortTurn', 'stopTurnTools'], + }, + }, + }, + }, + }, + }, + }); +} diff --git a/packages/agent-core-v2/src/human/agent/replay.ts b/packages/agent-core-v2/src/human/agent/replay.ts new file mode 100644 index 00000000000..e0c0955ef56 --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/replay.ts @@ -0,0 +1,40 @@ +import type { HistoryMessage } from '#/agent/turn'; +import type { Tree } from '#/store/tree'; + +export type TurnOutcome = 'done' | 'failed' | 'aborted'; + +export type TurnEntryData = + | { phase: 'start'; turnId: number } + | { phase: 'end'; turnId?: number; outcome: TurnOutcome }; + +export interface StateEntryData { + name: string; + value: unknown; +} + +export interface LoadedAgentState { + messages: HistoryMessage[]; + turnId: number; + states: Record; +} + +export async function loadAgentState(tree: Tree, branch: string): Promise { + const loadedBranch = tree.openBranch(branch); + const entries = [...loadedBranch.walk()].toReversed(); + const messages: HistoryMessage[] = []; + let turnId = 0; + const states: Record = {}; + for (const entry of entries) { + const data = await tree.resolve(entry); + if (entry.type === 'message') { + messages.push(data as HistoryMessage); + } else if (entry.type === 'turn') { + const turn = data as TurnEntryData; + if (turn.phase === 'start') turnId = Math.max(turnId, turn.turnId); + } else if (entry.type === 'state') { + const state = data as StateEntryData; + states[state.name] = state.value; + } + } + return { messages, turnId, states }; +} diff --git a/packages/agent-core-v2/src/human/agent/turn.ts b/packages/agent-core-v2/src/human/agent/turn.ts new file mode 100644 index 00000000000..2d515c97aad --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/turn.ts @@ -0,0 +1,570 @@ +import { assign, setup } from '#/xstate2'; + +import type { FinishInfo } from '#/llm/finish-reason'; +import { + createMessageAccumulator, + createToolMessage, + salvageInterruptedMessage, + type AssistantMessage, + type Message, + type StreamedMessagePart, + type SystemMessage, + type ToolCall, + type ToolMessage, + type UserMessage, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { createLlmMachine, LlmEvent } from '#/llm/requester/machine'; +import type { LlmRequestConfig } from '#/llm/requester/requester'; +import { emptyUsage, type TokenUsage } from '#/llm/usage'; +import type { ToolResult } from '#/tool/executor'; +import type { ToolOutput } from '#/tool/machine'; + +import { MaxStepsExceededError } from './errors'; +import { estimateUsedContextTokens } from './context-usage'; + +export interface EntryMeta { + source?: string; + key?: string; +} + +export type SystemMeta = EntryMeta; + +export type UserMeta = EntryMeta; + +export type ToolMeta = EntryMeta; + +export interface AssistantMeta extends EntryMeta { + model?: { provider: string; model: string }; + usage: TokenUsage; + headers?: Record; + finish?: FinishInfo; + messageId?: string; +} + +export type AssistantMetaInput = Omit & { usage?: TokenUsage }; + +export interface HistoryEntry { + message: T; + meta: F; +} + +export type SystemEntry = HistoryEntry; + +export type UserEntry = HistoryEntry; + +export type ToolEntry = HistoryEntry; + +export type AssistantEntry = HistoryEntry; + +export type HistoryMessage = SystemEntry | UserEntry | AssistantEntry | ToolEntry; + +export function createUserEntry(message: UserMessage, meta: UserMeta = {}): UserEntry { + return { message, meta }; +} + +export function createSystemEntry(message: SystemMessage, meta: SystemMeta = {}): SystemEntry { + return { message, meta }; +} + +export function createToolEntry(message: ToolMessage, meta: ToolMeta = {}): ToolEntry { + return { message, meta }; +} + +export function createAssistantEntry( + message: AssistantMessage, + meta: AssistantMeta, +): AssistantEntry { + return { message, meta }; +} + +export function toInputMessages(history: readonly HistoryMessage[]): Message[] { + return history.map((entry) => entry.message); +} + +export interface HistoryAccumulator { + push(part: StreamedMessagePart): void; + pushUsage(usage: Partial): void; + pushHeaders(headers: Record): void; + pushFinish(finish: FinishInfo): void; + pushMessageId(messageId: string): void; + finish(meta?: AssistantMetaInput): AssistantEntry; +} + +export function createHistoryAccumulator(meta?: AssistantMetaInput): HistoryAccumulator { + const inner = createMessageAccumulator(); + let usage: TokenUsage | undefined; + let headers: Record | undefined; + let finish: FinishInfo | undefined; + let messageId: string | undefined; + return { + push: (part) => inner.push(part), + pushUsage: (value) => { + usage = { + inputOther: value.inputOther ?? usage?.inputOther ?? 0, + output: value.output ?? usage?.output ?? 0, + inputCacheRead: value.inputCacheRead ?? usage?.inputCacheRead ?? 0, + inputCacheCreation: value.inputCacheCreation ?? usage?.inputCacheCreation ?? 0, + raw: value.raw !== undefined ? { ...usage?.raw, ...value.raw } : usage?.raw, + }; + }, + pushHeaders: (value) => { + headers = value; + }, + pushFinish: (value) => { + finish = value; + }, + pushMessageId: (value) => { + messageId = value; + }, + finish: (extra = {}) => + createAssistantEntry(inner.finish(), { + ...meta, + ...extra, + usage: usage ?? extra.usage ?? meta?.usage ?? emptyUsage(), + headers, + finish, + messageId, + }), + }; +} + +function modelMeta(model: LlmModel): AssistantMetaInput { + return { model: { provider: model.provider, model: model.model } }; +} + +export interface TurnInput { + request: LlmRequestConfig; + history: readonly HistoryMessage[]; + maxSteps?: number; +} + +export type TurnToolEvent = + | { type: 'tool.async'; toolCallId: string; text: string } + | { type: 'tool.done'; toolCallId: string; result: ToolResult } + | { type: 'tool.failed'; toolCallId: string; error: unknown } + | { type: 'tool.aborted'; toolCallId: string }; + +export type TurnEvent = + | LlmEvent + | TurnToolEvent + | { type: 'turn.notifications'; messages: HistoryMessage[] } + | { type: 'turn.abort' }; + +export type TurnLlmEvent = + | Exclude + | { type: 'llm.done'; entry: AssistantEntry }; + +export type TurnSignal = + | { type: 'turn.spawnTools'; toolCalls: ToolCall[] } + | { type: 'turn.drain' } + | { type: 'turn.remindersConsumed'; reminders: HistoryMessage[] }; + +export type TurnOutput = + | { type: 'done'; produced: HistoryMessage[] } + | { type: 'failed'; error: unknown; produced: HistoryMessage[] } + | { type: 'aborted'; produced: HistoryMessage[] }; + +export interface TurnMachineContext { + input: TurnInput; + produced: HistoryMessage[]; + accumulator: HistoryAccumulator; + pendingToolCalls: ToolCall[]; + outcomes: Record; + steps: number; + outcome?: 'done' | 'failed' | 'aborted'; + error?: unknown; +} + +function toolOutcomeEntry(toolCall: ToolCall, output: ToolOutput): ToolEntry { + if (output.type === 'failed') { + const text = output.error instanceof Error ? output.error.message : String(output.error); + return createToolEntry(createToolMessage(toolCall.id, text), { source: 'tool' }); + } + if (output.type === 'aborted') { + return createToolEntry(createToolMessage(toolCall.id, 'aborted'), { source: 'tool' }); + } + return createToolEntry(createToolMessage(toolCall.id, output.result.content), { + source: 'tool', + }); +} + +function asyncAckOutcome(toolCall: ToolCall, text: string): ToolOutput { + return { + type: 'succeeded', + result: { + content: [ + { type: 'text', text: text === '' ? `async running: ${toolCall.name}` : text }, + ], + }, + }; +} + +function collectToolOutcomes( + context: TurnMachineContext, +): Pick { + return { + produced: [ + ...context.produced, + ...context.pendingToolCalls.map((toolCall) => + toolOutcomeEntry(toolCall, context.outcomes[toolCall.id] as ToolOutput), + ), + ], + pendingToolCalls: [], + outcomes: {}, + }; +} + +function abortOutcomes(context: TurnMachineContext): Record { + const outcomes = { ...context.outcomes }; + for (const toolCall of context.pendingToolCalls) { + if (outcomes[toolCall.id] === undefined) { + outcomes[toolCall.id] = { type: 'aborted' }; + } + } + return outcomes; +} + +function maxStepsExceeded(context: TurnMachineContext): boolean { + const maxSteps = context.input.maxSteps; + return maxSteps !== undefined && maxSteps > 0 && context.steps >= maxSteps; +} + +export function createTurnMachine(llmActor: ReturnType) { + return setup({ + types: { + input: {} as TurnInput, + context: {} as TurnMachineContext, + events: {} as TurnEvent, + output: {} as TurnOutput, + }, + actors: { + llmActor, + }, + actions: { + forwardToParent: ({ self, event }) => { + self._parent?.send(event); + }, + signalParent: ({ self }, params: TurnSignal) => { + self._parent?.send(params); + }, + signalRemindersConsumed: ({ self, event }) => { + if (event.type !== 'turn.notifications') return; + const reminders = event.messages.filter((entry) => entry.meta.source === 'reminder'); + if (reminders.length === 0) return; + self._parent?.send({ type: 'turn.remindersConsumed', reminders }); + }, + sendToParent: ({ self }, params: TurnLlmEvent) => { + self._parent?.send(params); + }, + }, + }).createMachine({ + id: 'turn', + initial: 'thinking', + context: ({ input }) => ({ + input, + produced: [], + accumulator: createHistoryAccumulator(modelMeta(input.request.model)), + pendingToolCalls: [], + outcomes: {}, + steps: 0, + }), + states: { + thinking: { + entry: assign({ + steps: ({ context }) => context.steps + 1, + accumulator: ({ context }) => createHistoryAccumulator(modelMeta(context.input.request.model)), + }), + invoke: { + src: 'llmActor', + input: ({ context }) => { + const entries = [...context.input.history, ...context.produced]; + return { + config: context.input.request, + content: { + messages: toInputMessages(entries), + usedContextTokens: estimateUsedContextTokens(entries, { + systemPrompt: context.input.request.systemPrompt, + tools: context.input.request.tools, + }), + }, + }; + }, + onError: { + target: 'failed', + actions: assign({ + outcome: 'failed' as const, + error: ({ event }) => event.error, + }), + }, + }, + on: { + 'llm.sent': { + actions: ['forwardToParent'], + }, + 'llm.headers': { + actions: [ + 'forwardToParent', + ({ context, event }) => { + context.accumulator.pushHeaders(event.headers); + }, + ], + }, + 'llm.delta': { + actions: [ + 'forwardToParent', + ({ context, event }) => { + context.accumulator.push(event.part); + }, + ], + }, + 'llm.retrying': { + actions: [ + 'forwardToParent', + assign({ + accumulator: ({ context }) => + createHistoryAccumulator(modelMeta(context.input.request.model)), + }), + ], + }, + 'llm.recovering': { + actions: [ + 'forwardToParent', + assign({ + accumulator: ({ context }) => + createHistoryAccumulator(modelMeta(context.input.request.model)), + }), + ], + }, + 'llm.usage': { + actions: [ + 'forwardToParent', + ({ context, event }) => { + context.accumulator.pushUsage(event.usage); + }, + ], + }, + 'llm.finish': { + actions: [ + 'forwardToParent', + ({ context, event }) => { + context.accumulator.pushFinish(event.finish); + }, + ], + }, + 'llm.message-id': { + actions: [ + 'forwardToParent', + ({ context, event }) => { + context.accumulator.pushMessageId(event.messageId); + }, + ], + }, + 'llm.done': [ + { + guard: ({ context }) => context.accumulator.finish().message.toolCalls.length > 0, + target: 'acting', + actions: [ + { + type: 'sendToParent', + params: ({ context }) => ({ + type: 'llm.done' as const, + entry: context.accumulator.finish({ source: 'llm' }), + }), + }, + assign(({ context }) => { + const entry = context.accumulator.finish({ source: 'llm' }); + return { + produced: [...context.produced, entry], + pendingToolCalls: [...entry.message.toolCalls], + }; + }), + ], + }, + { + target: 'done', + actions: [ + { + type: 'sendToParent', + params: ({ context }) => ({ + type: 'llm.done' as const, + entry: context.accumulator.finish({ source: 'llm' }), + }), + }, + assign({ + produced: ({ context }) => [ + ...context.produced, + context.accumulator.finish({ source: 'llm' }), + ], + }), + ], + }, + ], + 'llm.failed.syntax': { + target: 'failed', + actions: [ + 'forwardToParent', + assign({ + outcome: 'failed' as const, + error: ({ event }) => event.error, + }), + ], + }, + 'llm.failed.remote': { + target: 'failed', + actions: [ + 'forwardToParent', + assign({ + outcome: 'failed' as const, + error: ({ event }) => event.error, + }), + ], + }, + 'turn.abort': { + target: 'aborted', + actions: assign(({ context }) => { + const partial = context.accumulator.finish({ source: 'salvaged' }); + const salvaged = salvageInterruptedMessage(partial.message); + return { + outcome: 'aborted' as const, + produced: + salvaged === null + ? context.produced + : [...context.produced, { message: salvaged, meta: partial.meta }], + }; + }), + }, + }, + }, + acting: { + entry: { + type: 'signalParent', + params: ({ context }) => ({ + type: 'turn.spawnTools' as const, + toolCalls: context.pendingToolCalls, + }), + }, + always: [ + { + guard: ({ context }) => + context.outcome === 'aborted' && + context.pendingToolCalls.every( + (toolCall) => context.outcomes[toolCall.id] !== undefined, + ), + target: 'aborted', + actions: assign(({ context }) => collectToolOutcomes(context)), + }, + { + guard: ({ context }) => + context.pendingToolCalls.every( + (toolCall) => context.outcomes[toolCall.id] !== undefined, + ), + target: 'draining', + actions: assign(({ context }) => collectToolOutcomes(context)), + }, + ], + on: { + 'tool.async': { + guard: ({ context, event }) => context.outcomes[event.toolCallId] === undefined, + actions: assign(({ context, event }) => { + const toolCall = context.pendingToolCalls.find( + (call) => call.id === event.toolCallId, + ); + if (toolCall === undefined) { + return {}; + } + return { + outcomes: { + ...context.outcomes, + [event.toolCallId]: asyncAckOutcome(toolCall, event.text), + }, + }; + }), + }, + 'tool.done': { + actions: assign({ + outcomes: ({ context, event }) => ({ + ...context.outcomes, + [event.toolCallId]: { type: 'succeeded', result: event.result }, + }), + }), + }, + 'tool.failed': { + actions: assign({ + outcomes: ({ context, event }) => ({ + ...context.outcomes, + [event.toolCallId]: { type: 'failed', error: event.error }, + }), + }), + }, + 'tool.aborted': { + actions: assign({ + outcomes: ({ context, event }) => ({ + ...context.outcomes, + [event.toolCallId]: { type: 'aborted' }, + }), + }), + }, + 'turn.abort': [ + { + guard: ({ context }) => context.outcome === 'aborted', + target: 'aborted', + actions: assign(({ context }) => + collectToolOutcomes({ ...context, outcomes: abortOutcomes(context) }), + ), + }, + { + actions: assign({ outcome: 'aborted' as const }), + }, + ], + }, + }, + draining: { + entry: { + type: 'signalParent', + params: { type: 'turn.drain' }, + }, + on: { + 'turn.notifications': [ + { + guard: ({ context, event }) => + event.messages.length === 0 && maxStepsExceeded(context), + target: 'failed', + actions: assign(({ context }) => ({ + outcome: 'failed' as const, + error: new MaxStepsExceededError(context.input.maxSteps as number), + })), + }, + { + target: 'thinking', + actions: [ + assign(({ context, event }) => ({ + produced: [...context.produced, ...event.messages], + steps: event.messages.length > 0 ? 0 : context.steps, + })), + 'signalRemindersConsumed', + ], + }, + ], + 'turn.abort': { + target: 'aborted', + actions: assign({ outcome: 'aborted' as const }), + }, + }, + }, + done: { type: 'final' }, + failed: { type: 'final' }, + aborted: { type: 'final' }, + }, + output: ({ context }): TurnOutput => + context.outcome === 'failed' + ? { + type: 'failed', + error: context.error, + produced: context.produced, + } + : context.outcome === 'aborted' + ? { type: 'aborted', produced: context.produced } + : { type: 'done', produced: context.produced }, + }); +} diff --git a/packages/agent-core-v2/src/human/agent/wait-for.ts b/packages/agent-core-v2/src/human/agent/wait-for.ts new file mode 100644 index 00000000000..1411e0a853b --- /dev/null +++ b/packages/agent-core-v2/src/human/agent/wait-for.ts @@ -0,0 +1,63 @@ +import { waitFor, type ActorRefFrom } from '#/xstate2'; + +import type { TaskWaitInput, TaskWaitOutcome } from '#/tool/executor'; +import { createToolMachine } from '#/tool/machine'; + +export type ToolActorRef = ActorRefFrom>; + +interface WaitForTarget { + id: string; + ref: ToolActorRef; +} + +interface AgentSnapshotSource { + getSnapshot(): { + context: { + background: Record; + }; + }; +} + +export function createWaitForTasks( + self: AgentSnapshotSource, +): (input: TaskWaitInput) => Promise { + return async ({ taskId, timeoutMs }) => { + const { background } = self.getSnapshot().context; + const targets: WaitForTarget[] = []; + const unknown: string[] = []; + const ids = taskId === undefined ? Object.keys(background) : [taskId]; + for (const id of ids) { + const ref = background[id]?.ref; + if (ref === undefined) { + unknown.push(id); + } else { + targets.push({ id, ref }); + } + } + if (targets.length === 0) { + return { completed: [], running: [], unknown, timedOut: false }; + } + const waitForAny = () => + Promise.any( + targets.map(({ ref }) => + waitFor(ref, (snapshot) => snapshot.status === 'done').catch(() => undefined), + ), + ); + let timer: ReturnType | undefined; + const timeout = new Promise<'timeout'>((resolve) => { + timer = setTimeout(() => { + resolve('timeout'); + }, timeoutMs); + }); + const timedOut = + (await Promise.race([waitForAny().then(() => 'done' as const), timeout])) === 'timeout'; + clearTimeout(timer); + const completed = targets + .filter(({ ref }) => ref.getSnapshot().status === 'done') + .map(({ id }) => id); + const running = targets + .filter(({ ref }) => ref.getSnapshot().status !== 'done') + .map(({ id }) => id); + return { completed, running, unknown, timedOut }; + }; +} diff --git a/packages/agent-core-v2/src/human/index.ts b/packages/agent-core-v2/src/human/index.ts new file mode 100644 index 00000000000..6bacfaa06d6 --- /dev/null +++ b/packages/agent-core-v2/src/human/index.ts @@ -0,0 +1,83 @@ +export * from './llm/message'; +export * from './llm/model'; +export * from './llm/capability'; +export * from './models-dev/models-dev'; +export * from './llm/errors'; +export * from './llm/syntax-errors'; +export * from './llm/thinking'; +export * from './llm/response-format'; +export * from './llm/finish-reason'; +export * from './llm/usage'; +export * from './plugin'; +export * from './llm/protocol/format'; +export * from './llm/protocol/base'; +export * from './llm/protocol/trait'; +export * from './llm/protocol/rewrite'; +export * from './llm/protocol/patterns'; +export * from './llm/media'; +export * from './llm/requester/requester'; +export * from './llm/requester/empty-response'; +export * from './llm/requester/machine'; +export * from './llm/requester/recovery'; +export * from './llm/requester/retry'; +export * from './llm/requester/bases/openai/capability'; +export * from './llm/requester/bases/openai/extra-params'; +export * from './llm/requester/bases/openai/format'; +export * from './llm/requester/bases/openai/reasoning-key'; +export * from './llm/requester/bases/openai/requester'; +export * from './llm/requester/bases/openai-responses/capability'; +export * from './llm/requester/bases/openai-responses/extra-params'; +export * from './llm/requester/bases/openai-responses/format'; +export * from './llm/requester/bases/openai-responses/requester'; +export * from './llm/requester/bases/google-genai/capability'; +export * from './llm/requester/bases/google-genai/extra-params'; +export * from './llm/requester/bases/google-genai/format'; +export * from './llm/requester/bases/google-genai/requester'; +export * from './llm/requester/bases/anthropic/capability'; +export * from './llm/requester/bases/anthropic/extra-params'; +export * from './llm/requester/bases/anthropic/format'; +export * from './llm/requester/bases/anthropic/profile'; +export * from './llm/requester/bases/anthropic/requester'; +export * from './llm/requester/bases/tool-call-id'; +export * from './llm/requester/bases/tool-result-text'; +export * from './llm/provider/definition'; +export * from './llm/provider-catalog'; +export * from './llm-kimi/provider'; +export * from './llm-kimi/errors'; +export * from './llm-kimi/files'; +export * from './llm-kimi/media'; +export * from './llm-kimi/schema'; +export * from './llm-kimi/trait'; +export * from './llm/provider/providers/standard'; +export * from './kimi-oauth'; +export * from './tool/executor'; +export * from './tool/machine'; +export * from './tool/wait-for'; +export * from './tool/tool'; +export * from './media/tool'; +export * from './agent/errors'; +export * from './agent/machine'; +export * from './agent/wait-for'; +export * from './agent/turn'; +export * from './agent/context-usage'; +export * from './agent/replay'; +export * from './persist/agent'; +export * from './persist/open'; +export * from './persist/v2/migrate'; +export * from './session/machine'; +export * from './session/undo'; +export * from './persist/session'; +export * from './usage/usage'; +export * from './usage/machine'; +export * from './usage/plugin'; +export * from './timing/plugin'; +export * from './kimi/trace'; +export * from './todo/todoItem'; +export * from './todo/state'; +export * from './todo/tool'; +export * from './todo/plugin'; +export * from './tool-select/state'; +export * from './tool-select/tool'; +export * from './tool-select/plugin'; +export * from './tool-select/resolver'; +export * from './store'; diff --git a/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts b/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts new file mode 100644 index 00000000000..28a148c9b1c --- /dev/null +++ b/packages/agent-core-v2/src/human/kimi-oauth/credential-source.ts @@ -0,0 +1,79 @@ +import type { MediaVideoUploader } from '#/llm/media/upload'; +import type { LlmRemoteErrorMessage } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequestControl, + LlmRequester, +} from '#/llm/requester/requester'; + +export interface CredentialResolveOptions { + readonly force?: boolean; +} + +export interface CredentialSource { + resolve(model: LlmModel, options?: CredentialResolveOptions): Promise | LlmModel; + canRecover?(model: LlmModel, error: unknown): boolean; +} + +async function runWithCredentials( + source: CredentialSource, + model: LlmModel, + aborted: () => boolean, + run: (model: LlmModel) => Promise, +): Promise { + const resolved = await source.resolve(model); + try { + return await run(resolved); + } catch (error) { + if (aborted() || source.canRecover?.(resolved, error) !== true) { + throw error; + } + } + const refreshed = await source.resolve(model, { force: true }); + return run(refreshed); +} + +export function withAuth(inner: LlmRequester, source: CredentialSource): LlmRequester { + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const resolved = await source.resolve(config.model); + let failed: LlmRemoteErrorMessage | undefined; + await inner.generate({ ...config, model: resolved }, content, { + ...control, + onEvent: (event) => { + if (event.type === 'llm.failed.remote') { + failed = event.error; + return; + } + control.onEvent?.(event); + }, + }); + if (failed === undefined) { + return; + } + const failure: LlmRemoteErrorMessage = failed; + if (control.signal.aborted || source.canRecover?.(resolved, failure) !== true) { + control.onEvent?.({ type: 'llm.failed.remote', error: failure }); + return; + } + const refreshed = await source.resolve(config.model, { force: true }); + await inner.generate({ ...config, model: refreshed }, content, control); + }, + }; +} + +export function withAuthUpload( + inner: MediaVideoUploader, + source: CredentialSource, +): MediaVideoUploader { + return (video, options) => + runWithCredentials(source, options.model, () => options.signal?.aborted === true, (model) => + inner(video, { ...options, model }), + ); +} diff --git a/packages/agent-core-v2/src/human/kimi-oauth/index.ts b/packages/agent-core-v2/src/human/kimi-oauth/index.ts new file mode 100644 index 00000000000..e22369501ad --- /dev/null +++ b/packages/agent-core-v2/src/human/kimi-oauth/index.ts @@ -0,0 +1,2 @@ +export * from './credential-source'; +export * from './kimi-oauth'; diff --git a/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts b/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts new file mode 100644 index 00000000000..4690301462b --- /dev/null +++ b/packages/agent-core-v2/src/human/kimi-oauth/kimi-oauth.ts @@ -0,0 +1,22 @@ +import type { BearerTokenProvider } from '@moonshot-ai/kimi-code-oauth'; + +import type { CredentialSource } from './credential-source'; + +function statusOf(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const record = error as Record; + const status = record['status'] ?? record['statusCode']; + return typeof status === 'number' ? status : undefined; +} + +export function kimiOAuthCredentialSource(tokens: BearerTokenProvider): CredentialSource { + return { + resolve: async (model, options) => ({ + ...model, + apiKey: await tokens.getAccessToken({ force: options?.force === true }), + }), + canRecover: (_model, error) => statusOf(error) === 401, + }; +} diff --git a/packages/agent-core-v2/src/human/kimi/trace.ts b/packages/agent-core-v2/src/human/kimi/trace.ts new file mode 100644 index 00000000000..fb521f95f38 --- /dev/null +++ b/packages/agent-core-v2/src/human/kimi/trace.ts @@ -0,0 +1,34 @@ +import { llmStatusErrorMessage } from '#/llm/errors'; +import type { Plugin } from '#/plugin'; + +export interface TracePlugin extends Plugin { + readonly name: 'trace'; + traceId(): string | undefined; +} + +export function createTracePlugin(): TracePlugin { + let current: string | undefined; + const capture = (headers: Record | null | undefined): void => { + const value = headers?.['x-trace-id']; + if (value !== undefined && value.length > 0) { + current = value; + } + }; + return { + name: 'trace', + traceId: () => current, + connect(target) { + if (target.kind !== 'agent') return; + target.on('llm.headers', (event) => { + if (event.type === 'llm.headers') { + capture(event.headers); + } + }); + target.on('llm.failed.remote', (event) => { + if (event.type === 'llm.failed.remote') { + capture(llmStatusErrorMessage(event.error)?.headers); + } + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts b/packages/agent-core-v2/src/human/llm-kimi/errors.ts similarity index 85% rename from packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts rename to packages/agent-core-v2/src/human/llm-kimi/errors.ts index aa3dbce5f3f..49ae56426fd 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-errors.ts +++ b/packages/agent-core-v2/src/human/llm-kimi/errors.ts @@ -1,8 +1,8 @@ import { - APIProviderQuotaExhaustedError, + headersToRecord, parseRetryAfterMs, - parseTraceId, -} from '#/kosong/contract/errors'; + type LlmRemoteErrorMessage, +} from '#/llm/errors'; const KIMI_QUOTA_EXHAUSTED_ERROR_CODES = new Set(['exceeded_current_quota_error']); @@ -37,7 +37,7 @@ function collectErrorCodes(error: object): string[] { return codes; } -export function classifyKimiQuotaError(error: unknown): APIProviderQuotaExhaustedError | undefined { +export function classifyKimiQuotaError(error: unknown): LlmRemoteErrorMessage | undefined { if (typeof error !== 'object' || error === null) return undefined; const status = (error as Record)['status']; if (status !== 429) return undefined; @@ -54,10 +54,12 @@ export function classifyKimiQuotaError(error: unknown): APIProviderQuotaExhauste const requestId = readStringProp(error, 'requestID') ?? null; const headers = (error as Record)['headers']; - return new APIProviderQuotaExhaustedError( + return { + kind: 'quota_exhausted', message, + statusCode: 429, requestId, - parseRetryAfterMs(headers), - parseTraceId(headers), - ); + retryAfterMs: parseRetryAfterMs(headers), + headers: headersToRecord(headers), + }; } diff --git a/packages/agent-core-v2/src/human/llm-kimi/files.ts b/packages/agent-core-v2/src/human/llm-kimi/files.ts new file mode 100644 index 00000000000..e5e7ee36ee3 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm-kimi/files.ts @@ -0,0 +1,85 @@ +import { Blob, File } from 'node:buffer'; + +import type OpenAI from 'openai'; +import OpenAIClient from 'openai'; + +import type { VideoURLPart } from '#/llm/message'; +import type { VideoUploadInput } from '#/llm/media/upload'; + +export interface KimiUploadOptions { + signal?: AbortSignal; +} + +export interface KimiFilesOptions { + apiKey?: string; + baseUrl: string; + defaultHeaders?: Record; +} + +export class KimiFiles { + private readonly _client: OpenAI | undefined; + + constructor(options: KimiFilesOptions) { + this._client = + options.apiKey === undefined || options.apiKey.length === 0 + ? undefined + : new OpenAIClient({ + apiKey: options.apiKey, + baseURL: options.baseUrl, + defaultHeaders: options.defaultHeaders, + }); + } + + async uploadVideo( + input: VideoUploadInput, + options?: KimiUploadOptions, + ): Promise { + if (!input.mimeType.startsWith('video/')) { + throw new Error(`Expected a video mime type, got ${input.mimeType}`); + } + const filename = input.filename ?? guessFilename(input.mimeType); + const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); + const blob = new Blob([bytes], { type: input.mimeType }); + const file = new File([blob], filename, { type: input.mimeType }); + + const client = this._createClient(); + const uploaded = (await client.files.create( + { + file: file as never, + purpose: 'video' as never, + }, + options?.signal ? { signal: options.signal } : undefined, + )) as unknown as { id: string }; + + return { + type: 'video_url', + videoUrl: { + url: `ms://${uploaded.id}`, + id: uploaded.id, + }, + }; + } + + private _createClient(): OpenAI { + if (this._client === undefined) { + throw new Error('KimiFiles.uploadVideo: apiKey is required'); + } + return this._client; + } +} + +function guessFilename(mimeType: string): string { + const ext = MIME_TO_EXT[mimeType.toLowerCase()] ?? 'bin'; + return `upload.${ext}`; +} + +const MIME_TO_EXT: Record = { + 'video/mp4': 'mp4', + 'video/mpeg': 'mpeg', + 'video/quicktime': 'mov', + 'video/webm': 'webm', + 'video/x-matroska': 'mkv', + 'video/x-msvideo': 'avi', + 'video/x-flv': 'flv', + 'video/3gpp': '3gp', +}; diff --git a/packages/agent-core-v2/src/human/llm-kimi/media.ts b/packages/agent-core-v2/src/human/llm-kimi/media.ts new file mode 100644 index 00000000000..50a81278ca0 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm-kimi/media.ts @@ -0,0 +1,26 @@ +import type { ProviderMediaContribution } from '#/llm/media/upload'; +import { modelKey, type LlmModel } from '#/llm/model'; + +import { KimiFiles } from './files'; +import { KIMI_DEFAULT_BASE_URL } from './trait'; + +const filesByModel = new Map(); + +function resolveFiles(model: LlmModel): KimiFiles { + const key = modelKey(model); + let files = filesByModel.get(key); + if (files === undefined) { + files = new KimiFiles({ + apiKey: model.apiKey, + baseUrl: model.baseUrl ?? KIMI_DEFAULT_BASE_URL, + defaultHeaders: + model.defaultHeaders === undefined ? undefined : { ...model.defaultHeaders }, + }); + filesByModel.set(key, files); + } + return files; +} + +export const kimiMediaContribution: ProviderMediaContribution = { + uploadVideo: (video, { model, signal }) => resolveFiles(model).uploadVideo(video, { signal }), +}; diff --git a/packages/agent-core-v2/src/human/llm-kimi/provider.ts b/packages/agent-core-v2/src/human/llm-kimi/provider.ts new file mode 100644 index 00000000000..373059d608b --- /dev/null +++ b/packages/agent-core-v2/src/human/llm-kimi/provider.ts @@ -0,0 +1,17 @@ +import { createProvider } from '#/llm/provider/definition'; +import { anthropicBetaBase } from '#/llm/requester/bases/anthropic/requester'; +import { openAIBase } from '#/llm/requester/bases/openai/requester'; +import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester'; + +import { kimiMediaContribution } from './media'; +import { kimiAnthropicTrait, kimiOpenAITrait, kimiResponsesTrait } from './trait'; + +export const kimiProvider = createProvider({ + id: 'kimi', + protocols: { + openai: { base: openAIBase, trait: kimiOpenAITrait }, + anthropic_beta: { base: anthropicBetaBase, trait: kimiAnthropicTrait }, + openai_responses: { base: openAIResponsesBase, trait: kimiResponsesTrait }, + }, + media: kimiMediaContribution, +}); diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts b/packages/agent-core-v2/src/human/llm-kimi/schema.ts similarity index 95% rename from packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts rename to packages/agent-core-v2/src/human/llm-kimi/schema.ts index 74d1ed637fe..2bd85e388f1 100644 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-schema.ts +++ b/packages/agent-core-v2/src/human/llm-kimi/schema.ts @@ -1,5 +1,4 @@ -import { Error2 } from '#/_base/errors/errors'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; export function derefJsonSchema(schema: Record): Record { const visited = new Set(); @@ -102,10 +101,7 @@ export function normalizeKimiToolSchema(schema: Record): Record function ensureKimiPropertyTypes(schema: Record): Record { const normalized = cloneJsonValue(schema); if (!isRecord(normalized)) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'JSON Schema root must normalize to an object.', - ); + throw new SyntaxRequestFormatError('JSON Schema root must normalize to an object.'); } recurseSchema(normalized); return normalized; @@ -339,8 +335,7 @@ function inferTypeFromValues(values: unknown[]): JsonSchemaType { for (const value of values) { const valueType = inferValueType(value); if (valueType === undefined) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, + throw new SyntaxRequestFormatError( 'Cannot infer JSON Schema type from non-JSON enum or const value.', ); } @@ -350,15 +345,11 @@ function inferTypeFromValues(values: unknown[]): JsonSchemaType { if (types.length === 1) { const onlyType = types[0]; if (onlyType === undefined) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'Cannot infer JSON Schema type from an empty enum.', - ); + throw new SyntaxRequestFormatError('Cannot infer JSON Schema type from an empty enum.'); } return onlyType; } - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, + throw new SyntaxRequestFormatError( 'Mixed JSON Schema enum or const types are not supported by Kimi tool schemas.', ); } diff --git a/packages/agent-core-v2/src/human/llm-kimi/trait.ts b/packages/agent-core-v2/src/human/llm-kimi/trait.ts new file mode 100644 index 00000000000..d5e535ab1e7 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm-kimi/trait.ts @@ -0,0 +1,181 @@ +import type { ProtocolEndpoint, ProtocolTrait } from '#/llm/protocol/trait'; +import type { ContentPart, ToolDescription } from '#/llm/message'; +import { CONTEXT_MANAGEMENT_BETA } from '#/llm/requester/bases/anthropic/format'; + +import { classifyKimiQuotaError } from './errors'; +import { normalizeKimiToolSchema } from './schema'; + +export const KIMI_API_KEY_ENV = 'KIMI_API_KEY'; +export const KIMI_BASE_URL_ENV = 'KIMI_BASE_URL'; +export const KIMI_DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1'; + +const kimiEndpoint: ProtocolEndpoint = { + apiKeyEnv: KIMI_API_KEY_ENV, + baseUrlEnv: KIMI_BASE_URL_ENV, + defaultBaseUrl: KIMI_DEFAULT_BASE_URL, +}; + +export interface KimiThinkingConfig { + type?: 'enabled' | 'disabled'; + effort?: string; + keep?: unknown; + [key: string]: unknown; +} + +export interface ExtraBody { + thinking?: KimiThinkingConfig; + [key: string]: unknown; +} + +function isEffectivelyEmptyContent(parts: readonly ContentPart[]): boolean { + for (const part of parts) { + if (part.type !== 'text') { + return false; + } + if (part.text.trim() !== '') { + return false; + } + } + return true; +} + +function convertKimiTool(tool: ToolDescription): Record { + if (tool.name.startsWith('$')) { + return { + type: 'builtin_function', + function: { name: tool.name }, + }; + } + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: normalizeKimiToolSchema(tool.parameters), + }, + }; +} + +export const kimiOpenAITrait: ProtocolTrait = { + strictThinkingValidation: true, + + endpoint: () => kimiEndpoint, + + convertError: (error) => classifyKimiQuotaError(error), + + toolMessageConversion: () => 'keep_parts', + + cacheKey: (key) => ({ prompt_cache_key: key }), + + withThinking: (thinking) => { + const config: KimiThinkingConfig = + thinking.effort === 'off' + ? { type: 'disabled' } + : thinking.effort === 'on' + ? { type: 'enabled' } + : { type: 'enabled', effort: thinking.effort }; + if (thinking.keep !== undefined) { + config.keep = thinking.keep; + } + return { extra_body: { thinking: config } }; + }, + + preserveThinking: (thinking) => { + if (thinking.keep === 'all' && thinking.effort !== 'off') { + return true; + } + return undefined; + }, + + withMaxCompletionTokens: (maxCompletionTokens) => ({ + max_completion_tokens: maxCompletionTokens, + }), + + buildParams: (params) => { + const { + extra_body: extraBody, + max_tokens: maxTokens, + max_completion_tokens: maxCompletionTokens, + ...rest + } = params; + const out: Record = { ...rest }; + const resolvedMaxCompletionTokens = maxCompletionTokens ?? maxTokens; + if (resolvedMaxCompletionTokens !== undefined) { + out['max_completion_tokens'] = resolvedMaxCompletionTokens; + } + if (extraBody !== undefined && extraBody !== null) { + Object.assign(out, extraBody); + } + return out; + }, + + convertTool: (tool) => convertKimiTool(tool), + + convertMessage: (message, converted) => { + if (message.role === 'assistant' && message.toolCalls.length > 0) { + const nonThinkParts = message.content.filter((part) => part.type !== 'think'); + if (isEffectivelyEmptyContent(nonThinkParts)) { + delete converted['content']; + } + } + + if (message.role === 'system' && message.tools !== undefined && message.tools.length > 0) { + converted['tools'] = message.tools.map((tool) => convertKimiTool(tool)); + } + + const convertedToolCalls = converted['tool_calls']; + if (message.role === 'assistant' && Array.isArray(convertedToolCalls)) { + message.toolCalls.forEach((toolCall, index) => { + if (toolCall.extras === undefined) { + return; + } + const out = convertedToolCalls[index] as Record | undefined; + if (out !== undefined) { + out['extras'] = toolCall.extras; + } + }); + } + + return converted; + }, + + extractUsage: (chunk) => { + const topLevel = chunk['usage']; + if (topLevel !== null && topLevel !== undefined && typeof topLevel === 'object') { + return topLevel as Record; + } + const choices = chunk['choices']; + if (!Array.isArray(choices) || choices.length === 0) { + return undefined; + } + const firstChoice = choices[0] as Record | undefined; + const choiceUsage = firstChoice?.['usage']; + if (choiceUsage !== null && choiceUsage !== undefined && typeof choiceUsage === 'object') { + return choiceUsage as Record; + } + return undefined; + }, +}; + +export const kimiAnthropicTrait: ProtocolTrait = { + endpoint: () => kimiEndpoint, + + convertError: (error) => classifyKimiQuotaError(error), + + withThinking: (thinking) => { + if (thinking.effort === 'off') { + return { thinking: { type: 'disabled' }, betaFeatures: [CONTEXT_MANAGEMENT_BETA] }; + } + return { + thinking: { type: 'enabled' }, + output_config: thinking.effort === 'on' ? undefined : { effort: thinking.effort }, + betaFeatures: [CONTEXT_MANAGEMENT_BETA], + }; + }, +}; + +export const kimiResponsesTrait: ProtocolTrait = { + endpoint: () => kimiEndpoint, + + convertError: (error) => classifyKimiQuotaError(error), +}; diff --git a/packages/agent-core-v2/src/kosong/contract/capability.ts b/packages/agent-core-v2/src/human/llm/capability.ts similarity index 84% rename from packages/agent-core-v2/src/kosong/contract/capability.ts rename to packages/agent-core-v2/src/human/llm/capability.ts index a8825fb60b9..546fc5aa3df 100644 --- a/packages/agent-core-v2/src/kosong/contract/capability.ts +++ b/packages/agent-core-v2/src/human/llm/capability.ts @@ -4,8 +4,6 @@ export interface ModelCapability { readonly audio_in: boolean; readonly thinking: boolean; readonly tool_use: boolean; - readonly max_context_tokens: number; - readonly max_input_tokens?: number; readonly dynamically_loaded_tools?: boolean; } @@ -19,7 +17,6 @@ export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( audio_in: false, thinking: false, tool_use: false, - max_context_tokens: 0, dynamically_loaded_tools: false, }, UNKNOWN_CAPABILITY_MARKER, @@ -38,7 +35,6 @@ export function isUnknownCapability(capability: ModelCapability): boolean { !capability.audio_in && !capability.thinking && !capability.tool_use && - capability.dynamically_loaded_tools !== true && - capability.max_context_tokens === 0 + capability.dynamically_loaded_tools !== true ); } diff --git a/packages/agent-core-v2/src/human/llm/errors.ts b/packages/agent-core-v2/src/human/llm/errors.ts new file mode 100644 index 00000000000..9f99b67c0b1 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/errors.ts @@ -0,0 +1,311 @@ +import type { FinishReason } from '#/llm/finish-reason'; + +export function sanitizeStatusErrorMessage(message: string): string { + const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); + const extracted = titleMatch?.[1]?.trim(); + const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; + return normalized.replaceAll('\r', ''); +} + +export function isAbortError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') return true; + if (error instanceof Error && error.name === 'AbortError') return true; + return ( + typeof error === 'object' && + error !== null && + (error as object).constructor?.name === 'APIUserAbortError' + ); +} + +export type LlmErrorKind = + | 'syntax' + | 'abort' + | 'connection' + | 'timeout' + | 'status' + | 'rate_limit' + | 'quota_exhausted' + | 'overloaded' + | 'context_overflow' + | 'request_too_large' + | 'request_structure' + | 'image_format' + | 'empty_response' + | 'provider' + | 'unknown'; + +export type LlmRemoteErrorKind = Exclude; + +export type LlmSyntaxErrorCode = 'request_format' | 'thinking_config' | 'internal'; + +export type LlmStatusErrorKind = + | 'status' + | 'rate_limit' + | 'quota_exhausted' + | 'overloaded' + | 'context_overflow' + | 'request_too_large' + | 'request_structure' + | 'image_format'; + +export interface LlmStatusErrorInfo { + readonly statusCode: number; + readonly requestId: string | null; + readonly retryAfterMs: number | null; + readonly headers: Record | null; +} + +export type LlmErrorMessage = + T extends LlmStatusErrorKind + ? { readonly kind: T; readonly message: string } & LlmStatusErrorInfo + : T extends 'syntax' + ? { readonly kind: T; readonly message: string; readonly code: LlmSyntaxErrorCode } + : T extends 'empty_response' + ? { + readonly kind: T; + readonly message: string; + readonly finishReason: FinishReason | null; + readonly rawFinishReason: string | null; + } + : { readonly kind: T; readonly message: string }; + +export type LlmRemoteErrorMessage = LlmErrorMessage; + +export function llmStatusErrorMessage( + error: LlmErrorMessage, +): LlmErrorMessage | null { + switch (error.kind) { + case 'status': + case 'rate_limit': + case 'quota_exhausted': + case 'overloaded': + case 'context_overflow': + case 'request_too_large': + case 'request_structure': + case 'image_format': + return error; + default: + return null; + } +} + +function abortErrorMessage(error: unknown): string { + if ( + typeof error === 'object' && + error !== null && + typeof (error as { message?: unknown }).message === 'string' + ) { + return (error as { message: string }).message; + } + return 'The operation was aborted.'; +} + +export function toLlmErrorMessage(error: unknown): LlmRemoteErrorMessage { + if (isAbortError(error)) { + return { kind: 'abort', message: abortErrorMessage(error) }; + } + return { kind: 'unknown', message: error instanceof Error ? error.message : String(error) }; +} + +const NETWORK_RE = /network|connection|connect|disconnect|terminated/i; +const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; + +export function toLlmTransportErrorMessage(message: string): LlmRemoteErrorMessage { + if (TIMEOUT_RE.test(message)) { + return { kind: 'timeout', message }; + } + if (NETWORK_RE.test(message)) { + return { kind: 'connection', message }; + } + return { kind: 'provider', message: `Error: ${message}` }; +} + +const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ + /context[ _-]?length/, + /(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/, + /maximum context/, + /exceed(?:ed|s|ing)?\s+(?:the\s+)?max(?:imum)?\s+tokens?/, + /(?:too many tokens.*(?:prompt|input|context)|(?:prompt|input|context).*too many tokens)/, + /prompt is too long.*maximum/, + /input token count.*exceeds?.*maximum number of tokens/, + /request.*exceed(?:ed|s|ing)?.*model token limit/, +] as const; + +const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; + +const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ + /request exceeds the maximum size/, + /request entity too large/, + /request_too_large/, + /exceeds? the maximum allowed number of bytes/, + /payload too large/, + /content too large/, + /request (?:body )?too large/, +] as const; + +const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ + /tool_use[\s\S]*tool_result/, + /tool_result[\s\S]*tool_use/, + /unexpected\s+`?tool_result/, + /tool_call_id[\s\S]*not found/, + /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, + /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, + /tool_call_ids? did not have response messages/, + /insufficient tool messages following/, +] as const; + +const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ + /text content blocks must be non-empty/, + /text content blocks must contain non-whitespace/, + /first message must use the .*user.* role/, + /roles must alternate/, + /multiple .*(?:user|assistant).* roles in a row/, + /tool_use[\s\S]*ids must be unique/, + /message at position \d+ with role ['"`]?[a-z]+['"`]? must not be empty/, +] as const; + +const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ + /unsupported image (?:url|format|type)/, + /does not represent a valid image/, + /could not (?:process|decode) (?:the |input )?image/, + /unable to process (?:the |input )?image/, + /failed to decode (?:the )?image/, + /invalid image(?: data| type| format)?/, +] as const; + +const MEDIA_TYPE_FIELD_PATTERN = /(?:media|mime)_?type/; + +const THINKING_EFFORT_CONFIG_DOCS_URL = + 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; + +const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ + /reasoning[_ .-]?effort/, + /thinking[_ .-]?effort/, + /output_config[\s\S]*effort/, + /unsupported[\s\S]*effort/, + /invalid[\s\S]*effort/, +] as const; + +export function appendThinkingEffortConfigHint(statusCode: number, message: string): string { + if (statusCode !== 400 && statusCode !== 422) return message; + const lowerMessage = message.toLowerCase(); + if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { + return message; + } + if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; + return `${message} + +The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; +} + +export interface LlmStatusErrorInput { + readonly statusCode: number; + readonly message: string; + readonly requestId?: string | null; + readonly retryAfterMs?: number | null; + readonly headers?: Record | null; +} + +export function toLlmStatusErrorMessage(input: LlmStatusErrorInput): LlmRemoteErrorMessage { + const info: LlmStatusErrorInfo = { + statusCode: input.statusCode, + requestId: input.requestId ?? null, + retryAfterMs: input.retryAfterMs ?? null, + headers: input.headers ?? null, + }; + const message = sanitizeStatusErrorMessage(input.message); + if (input.statusCode === 429) { + return { kind: 'rate_limit', message, ...info }; + } + if (isContextOverflowStatusError(input.statusCode, input.message)) { + return { kind: 'context_overflow', message, ...info }; + } + if (isRequestTooLargeStatusError(input.statusCode, input.message)) { + return { kind: 'request_too_large', message, ...info }; + } + if (isProviderOverloadStatusError(input.statusCode, input.message)) { + return { kind: 'overloaded', message, ...info }; + } + if (isRequestStructureStatusError(input.statusCode, input.message)) { + return { kind: 'request_structure', message, ...info }; + } + if (isImageFormatStatusError(input.statusCode, input.message)) { + return { kind: 'image_format', message, ...info }; + } + return { + kind: 'status', + message: appendThinkingEffortConfigHint(input.statusCode, message), + ...info, + }; +} + +export function parseRetryAfterMs(headers: unknown): number | null { + const raw = + headers !== null && + typeof headers === 'object' && + typeof (headers as { get?: unknown }).get === 'function' + ? (headers as { get(name: string): string | null }).get('retry-after') + : null; + if (raw === null || raw === undefined) return null; + const seconds = Number.parseInt(raw, 10); + if (!Number.isFinite(seconds) || seconds < 0) return null; + return seconds * 1000; +} + +export function headersToRecord(headers: unknown): Record | null { + if ( + headers === null || + typeof headers !== 'object' || + typeof (headers as { forEach?: unknown }).forEach !== 'function' + ) { + return null; + } + const record: Record = {}; + (headers as { forEach(callback: (value: string, key: string) => void): void }).forEach( + (value, key) => { + record[key] = value; + }, + ); + return record; +} + +export function isContextOverflowStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; + const lowerMessage = message.toLowerCase(); + return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +export function isProviderOverloadStatusError(statusCode: number, message: string): boolean { + if (statusCode === 529) return true; + if (statusCode !== 500 && statusCode !== 503) return false; + const lowerMessage = message.toLowerCase(); + return PROVIDER_OVERLOAD_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 413) return false; + const lowerMessage = message.toLowerCase(); + return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +export function isToolExchangeAdjacencyStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 400 && statusCode !== 422) return false; + const lowerMessage = message.toLowerCase(); + return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +export function isRequestStructureStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 400 && statusCode !== 422) return false; + if (isToolExchangeAdjacencyStatusError(statusCode, message)) return true; + const lowerMessage = message.toLowerCase(); + return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +} + +export function isImageFormatStatusError(statusCode: number, message: string): boolean { + if (statusCode !== 400) return false; + const lowerMessage = message.toLowerCase(); + return ( + IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)) || + (MEDIA_TYPE_FIELD_PATTERN.test(lowerMessage) && lowerMessage.includes('image')) + ); +} diff --git a/packages/agent-core-v2/src/human/llm/finish-reason.ts b/packages/agent-core-v2/src/human/llm/finish-reason.ts new file mode 100644 index 00000000000..3b4cd78aa6a --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/finish-reason.ts @@ -0,0 +1,14 @@ +export type FinishReason = + | 'completed' + | 'tool_calls' + | 'truncated' + | 'filtered' + | 'paused' + | 'other'; + +export interface FinishInfo { + readonly finishReason: FinishReason | null; + readonly rawFinishReason: string | null; +} + +export const NO_FINISH: FinishInfo = { finishReason: null, rawFinishReason: null }; diff --git a/packages/agent-core-v2/src/human/llm/media/cache.ts b/packages/agent-core-v2/src/human/llm/media/cache.ts new file mode 100644 index 00000000000..d9281b3932c --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/cache.ts @@ -0,0 +1,52 @@ +import type { VideoURLPart } from '#/llm/message'; +import type { BlobBackend } from '#/store/backend/backend'; +import { sha256Hex } from '#/store/internal/blob'; + +const CACHE_PREFIX = 'media-upload'; + +export interface MediaUploadCache { + get(ref: string, providerKey: string): Promise; + put(ref: string, providerKey: string, part: VideoURLPart): Promise; +} + +export function createMemoryMediaUploadCache(): MediaUploadCache { + const map = new Map(); + return { + get: (ref, providerKey) => Promise.resolve(map.get(`${ref}${providerKey}`)), + put: (ref, providerKey, part) => { + map.set(`${ref}${providerKey}`, part); + return Promise.resolve(); + }, + }; +} + +export function createBlobMediaUploadCache(blobs: BlobBackend): MediaUploadCache { + const key = (ref: string, providerKey: string) => + sha256Hex(`${CACHE_PREFIX}${ref}${providerKey}`); + return { + get: async (ref, providerKey) => { + const refKey = await key(ref, providerKey); + if (!(await blobs.has(refKey))) return undefined; + const raw = await blobs.read(refKey).catch(() => undefined); + if (raw === undefined) return undefined; + return parseCachedPart(raw); + }, + put: async (ref, providerKey, part) => { + const refKey = await key(ref, providerKey); + await blobs.write(refKey, JSON.stringify(part.videoUrl)).catch(() => undefined); + }, + }; +} + +function parseCachedPart(raw: string): VideoURLPart | undefined { + try { + const data = JSON.parse(raw) as { url?: unknown; id?: unknown }; + if (typeof data.url !== 'string' || data.url.length === 0) return undefined; + return { + type: 'video_url', + videoUrl: { url: data.url, id: typeof data.id === 'string' ? data.id : undefined }, + }; + } catch { + return undefined; + } +} diff --git a/packages/agent-core-v2/src/human/llm/media/degrade.ts b/packages/agent-core-v2/src/human/llm/media/degrade.ts new file mode 100644 index 00000000000..e25cf10d537 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/degrade.ts @@ -0,0 +1,99 @@ +import type { ContentPart, Message } from '#/llm/message'; +import type { LlmRecovery } from '#/llm/requester/recovery'; + +export const MEDIA_DEGRADE_KEEP_RECENT = 2; + +const MEDIA_DEGRADED_PLACEHOLDERS = { + image_url: + '[image omitted: dropped to fit the provider request size limit; re-read the file to view it]', + audio_url: + '[audio omitted: dropped to fit the provider request size limit; re-read the file to hear it]', + video_url: + '[video omitted: dropped to fit the provider request size limit; re-read the file to view it]', +} as const; + +const MEDIA_STRIPPED_PLACEHOLDERS = { + image_url: + '[image omitted for provider compatibility; re-read the file to view it or get conversion guidance]', + audio_url: '[audio omitted for provider compatibility; re-read the file to hear it]', + video_url: '[video omitted for provider compatibility; re-read the file to view it]', +} as const; + +type DegradableMediaPart = Extract< + ContentPart, + { readonly type: keyof typeof MEDIA_DEGRADED_PLACEHOLDERS } +>; + +function isDegradableMediaPart(part: ContentPart): part is DegradableMediaPart { + return part.type in MEDIA_DEGRADED_PLACEHOLDERS; +} + +function replaceMediaParts( + messages: readonly Message[], + placeholders: Record, + shouldReplace: (part: DegradableMediaPart) => boolean, +): readonly Message[] { + let changed = false; + const result = messages.map((message) => { + let messageChanged = false; + const content = message.content.map((part): ContentPart => { + if (!isDegradableMediaPart(part) || !shouldReplace(part)) return part; + changed = true; + messageChanged = true; + return { type: 'text', text: placeholders[part.type] }; + }); + return messageChanged ? { ...message, content } : message; + }); + return changed ? result : messages; +} + +export function degradeOlderMediaParts( + messages: readonly Message[], + keepRecent: number, +): readonly Message[] { + const mediaCount = messages.reduce( + (count, message) => count + message.content.filter(isDegradableMediaPart).length, + 0, + ); + let toDegrade = Math.max(0, mediaCount - keepRecent); + if (toDegrade === 0) return messages; + return replaceMediaParts(messages, MEDIA_DEGRADED_PLACEHOLDERS, () => { + if (toDegrade === 0) return false; + toDegrade -= 1; + return true; + }); +} + +export function stripMediaParts(messages: readonly Message[]): readonly Message[] { + return replaceMediaParts(messages, MEDIA_STRIPPED_PLACEHOLDERS, () => true); +} + +const MEDIA_RECOVERY_ID = 'media-degrade'; + +export function createMediaDegradeRecovery(): LlmRecovery { + return { + id: MEDIA_RECOVERY_ID, + propose: ({ error, messages, applied }) => { + const done = new Set( + applied.filter((r) => r.strategy === MEDIA_RECOVERY_ID).map((r) => r.action), + ); + if (error.kind === 'image_format') { + if (!done.has('stripped')) { + const stripped = stripMediaParts(messages); + if (stripped !== messages) return { action: 'stripped', messages: stripped }; + } + return undefined; + } + if (error.kind !== 'request_too_large') return undefined; + if (!done.has('degraded')) { + const degraded = degradeOlderMediaParts(messages, MEDIA_DEGRADE_KEEP_RECENT); + if (degraded !== messages) return { action: 'degraded', messages: degraded }; + } + if (!done.has('stripped')) { + const stripped = stripMediaParts(messages); + if (stripped !== messages) return { action: 'stripped', messages: stripped }; + } + return undefined; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/media/index.ts b/packages/agent-core-v2/src/human/llm/media/index.ts new file mode 100644 index 00000000000..0408773455d --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/index.ts @@ -0,0 +1,8 @@ +export * from './cache'; +export * from './degrade'; +export * from './mime'; +export * from './ref'; +export * from './resolver'; +export * from './source'; +export * from './store'; +export * from './upload'; diff --git a/packages/agent-core-v2/src/human/llm/media/mime.ts b/packages/agent-core-v2/src/human/llm/media/mime.ts new file mode 100644 index 00000000000..ce8faf52929 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/mime.ts @@ -0,0 +1,42 @@ +export const IMAGE_MIME_BY_EXT: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + bmp: 'image/bmp', + tif: 'image/tiff', + tiff: 'image/tiff', + webp: 'image/webp', +}; + +export const VIDEO_MIME_BY_EXT: Record = { + mp4: 'video/mp4', + mpg: 'video/mpeg', + mpeg: 'video/mpeg', + mov: 'video/quicktime', + webm: 'video/webm', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', + flv: 'video/x-flv', + '3gp': 'video/3gpp', +}; + +export type MediaKind = 'image' | 'video'; + +export function mediaKindForMime(mimeType: string): MediaKind | undefined { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('video/')) return 'video'; + return undefined; +} + +export function mediaMimeForPath(path: string): string | undefined { + const dot = path.lastIndexOf('.'); + if (dot < 0) return undefined; + const ext = path.slice(dot + 1).toLowerCase(); + return IMAGE_MIME_BY_EXT[ext] ?? VIDEO_MIME_BY_EXT[ext]; +} + +export function mediaKindForPath(path: string): MediaKind | undefined { + const mimeType = mediaMimeForPath(path); + return mimeType === undefined ? undefined : mediaKindForMime(mimeType); +} diff --git a/packages/agent-core-v2/src/human/llm/media/ref.ts b/packages/agent-core-v2/src/human/llm/media/ref.ts new file mode 100644 index 00000000000..b8361091583 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/ref.ts @@ -0,0 +1,30 @@ +import type { ContentPart } from '#/llm/message'; + +const MEDIA_REF_SCHEME = 'media://'; + +export interface MediaRef { + readonly kind: 'image' | 'video'; + readonly ref: string; +} + +export function buildMediaRefUrl(ref: string): string { + return `${MEDIA_REF_SCHEME}${ref}`; +} + +export function parseMediaRefUrl(url: string): string | undefined { + if (!url.startsWith(MEDIA_REF_SCHEME)) return undefined; + const ref = url.slice(MEDIA_REF_SCHEME.length); + return ref.length > 0 ? ref : undefined; +} + +export function mediaRefFromPart(part: ContentPart): MediaRef | undefined { + if (part.type === 'image_url') { + const ref = parseMediaRefUrl(part.imageUrl.url); + return ref === undefined ? undefined : { kind: 'image', ref }; + } + if (part.type === 'video_url') { + const ref = parseMediaRefUrl(part.videoUrl.url); + return ref === undefined ? undefined : { kind: 'video', ref }; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/media/resolver.ts b/packages/agent-core-v2/src/human/llm/media/resolver.ts new file mode 100644 index 00000000000..73974c1c61e --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/resolver.ts @@ -0,0 +1,132 @@ +import type { ModelCapability } from '#/llm/capability'; +import type { ContentPart, Message, VideoURLPart } from '#/llm/message'; +import type { Provider } from '#/llm/provider/definition'; +import type { MessageResolveContext, MessageResolver } from '#/llm/requester/machine'; + +import type { MediaUploadCache } from './cache'; +import { mediaKindForMime, mediaMimeForPath, type MediaKind } from './mime'; +import { mediaRefFromPart } from './ref'; +import type { MediaContent, MediaSource } from './source'; + +export interface MediaRefResolverDeps { + readonly providers: readonly Provider[]; + readonly source: MediaSource; + readonly cache: MediaUploadCache; +} + +const VIDEO_UNAVAILABLE_TEXT = '[video omitted: media unavailable]'; +const IMAGE_UNAVAILABLE_TEXT = '[image omitted: media unavailable]'; + +function resolveMimeType(content: MediaContent, kind: MediaKind): string | undefined { + if (content.mimeType !== undefined) { + return mediaKindForMime(content.mimeType) === kind ? content.mimeType : undefined; + } + if (content.filename === undefined) return undefined; + const mimeType = mediaMimeForPath(content.filename); + return mimeType !== undefined && mediaKindForMime(mimeType) === kind ? mimeType : undefined; +} + +function dataUrl(bytes: Uint8Array, mimeType: string): string { + return `data:${mimeType};base64,${Buffer.from(bytes).toString('base64')}`; +} + +function unavailableText(kind: 'image' | 'video'): ContentPart { + return { type: 'text', text: kind === 'video' ? VIDEO_UNAVAILABLE_TEXT : IMAGE_UNAVAILABLE_TEXT }; +} + +function isMediaUploadAuthError(error: unknown): boolean { + const statusCode = (error as { statusCode?: unknown; status?: unknown }).statusCode; + if (statusCode === 401 || statusCode === 403) return true; + const status = (error as { status?: unknown }).status; + return status === 401 || status === 403; +} + +function hasMediaRef(message: Message): boolean { + return message.content.some((part) => mediaRefFromPart(part) !== undefined); +} + +export function createMediaRefResolver(deps: MediaRefResolverDeps): MessageResolver { + const providers = new Map(deps.providers.map((provider) => [provider.id, provider])); + const imageMemo = new Map(); + + const resolveImagePart = async ( + ref: string, + capability: ModelCapability | undefined, + ): Promise => { + if (capability?.image_in !== true) return unavailableText('image'); + const memoed = imageMemo.get(ref); + if (memoed !== undefined) return memoed; + const content = await deps.source.get(ref); + const mimeType = content === undefined ? undefined : resolveMimeType(content, 'image'); + if (content === undefined || mimeType === undefined) return unavailableText('image'); + const part: ContentPart = { + type: 'image_url', + imageUrl: { url: dataUrl(content.bytes, mimeType) }, + }; + imageMemo.set(ref, part); + return part; + }; + + const resolveVideoPart = async ( + ref: string, + ctx: MessageResolveContext, + provider: Provider | undefined, + capability: ModelCapability | undefined, + ): Promise => { + if (capability?.video_in !== true) return unavailableText('video'); + const providerKey = ctx.model.provider; + const cached = await deps.cache.get(ref, providerKey); + if (cached !== undefined) return cached; + const content = await deps.source.get(ref); + const mimeType = content === undefined ? undefined : resolveMimeType(content, 'video'); + if (content === undefined || mimeType === undefined) return unavailableText('video'); + const uploader = provider?.media?.uploadVideo; + if (uploader !== undefined) { + try { + const part: VideoURLPart = await uploader( + { data: content.bytes, mimeType, filename: content.filename }, + { model: ctx.model, signal: ctx.signal }, + ); + await deps.cache.put(ref, providerKey, part); + return part; + } catch (error) { + if (ctx.signal.aborted || isMediaUploadAuthError(error)) throw error; + } + } + if (provider?.media?.inlineVideo === true) { + return { type: 'video_url', videoUrl: { url: dataUrl(content.bytes, mimeType) } }; + } + return unavailableText('video'); + }; + + return { + id: 'media-ref', + resolve: async (messages, ctx) => { + if (!messages.some(hasMediaRef)) return messages; + const provider = providers.get(ctx.model.provider); + const capability = ctx.model.capability; + const out: Message[] = []; + for (const message of messages) { + if (!hasMediaRef(message)) { + out.push(message); + continue; + } + const content: ContentPart[] = []; + for (const part of message.content) { + const ref = mediaRefFromPart(part); + if (ref === undefined) { + content.push(part); + continue; + } + content.push( + ref.kind === 'image' + ? await resolveImagePart(ref.ref, capability) + : await resolveVideoPart(ref.ref, ctx, provider, capability), + ); + } + out.push({ ...message, content }); + } + return out; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/media/source.ts b/packages/agent-core-v2/src/human/llm/media/source.ts new file mode 100644 index 00000000000..4f30d79a774 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/source.ts @@ -0,0 +1,25 @@ +export interface MediaContent { + readonly bytes: Uint8Array; + readonly mimeType?: string; + readonly filename?: string; +} + +export interface MediaSource { + get(ref: string): Promise; +} + +export interface MemoryMediaSource extends MediaSource { + set(ref: string, content: MediaContent): void; +} + +export function createMemoryMediaSource( + entries?: Readonly>, +): MemoryMediaSource { + const map = new Map(Object.entries(entries ?? {})); + return { + get: (ref) => Promise.resolve(map.get(ref)), + set: (ref, content) => { + map.set(ref, content); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/media/store.ts b/packages/agent-core-v2/src/human/llm/media/store.ts new file mode 100644 index 00000000000..ba98cadb9cc --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/store.ts @@ -0,0 +1,22 @@ +import type { MediaContent, MediaSource } from './source'; + +export interface MediaStore extends MediaSource { + put(content: MediaContent): Promise; +} + +async function sha256BytesHex(bytes: Uint8Array): Promise { + const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export function createMemoryMediaStore(): MediaStore { + const map = new Map(); + return { + get: (ref) => Promise.resolve(map.get(ref)), + put: async (content) => { + const ref = await sha256BytesHex(content.bytes); + map.set(ref, content); + return ref; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/media/upload.ts b/packages/agent-core-v2/src/human/llm/media/upload.ts new file mode 100644 index 00000000000..08f11cfcf6b --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/media/upload.ts @@ -0,0 +1,23 @@ +import type { VideoURLPart } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; + +export interface VideoUploadInput { + readonly data: Uint8Array; + readonly mimeType: string; + readonly filename?: string; +} + +export interface MediaVideoUploadOptions { + readonly model: LlmModel; + readonly signal?: AbortSignal; +} + +export type MediaVideoUploader = ( + video: VideoUploadInput, + options: MediaVideoUploadOptions, +) => Promise; + +export interface ProviderMediaContribution { + readonly inlineVideo?: boolean; + readonly uploadVideo?: MediaVideoUploader; +} diff --git a/packages/agent-core-v2/src/human/llm/message.ts b/packages/agent-core-v2/src/human/llm/message.ts new file mode 100644 index 00000000000..6c8b184b690 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/message.ts @@ -0,0 +1,253 @@ +export interface ToolDescription { + name: string; + description: string; + parameters: Record; + deferred?: true; +} + +export type Role = 'system' | 'user' | 'assistant' | 'tool'; + +export interface TextPart { + type: 'text'; + text: string; +} + +export interface ThinkPart { + type: 'think'; + think: string; + encrypted?: string; +} + +export interface ImageURLPart { + type: 'image_url'; + imageUrl: { url: string; id?: string; name?: string }; +} + +export interface AudioURLPart { + type: 'audio_url'; + audioUrl: { url: string; id?: string }; +} + +export interface VideoURLPart { + type: 'video_url'; + videoUrl: { url: string; id?: string; name?: string }; +} + +export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart; + +export interface ToolCall { + type: 'function'; + id: string; + name: string; + arguments: string | null; + extras?: Record; + _streamIndex?: number | string; +} + +export interface ToolCallPart { + type: 'tool_call_part'; + argumentsPart: string | null; + index?: number | string; +} + +export type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart; + +export interface SystemMessage { + readonly role: 'system'; + content: ContentPart[]; + readonly tools?: ToolDescription[]; +} + +export interface UserMessage { + readonly role: 'user'; + content: ContentPart[]; +} + +export interface AssistantMessage { + readonly role: 'assistant'; + content: ContentPart[]; + toolCalls: ToolCall[]; +} + +export interface ToolMessage { + readonly role: 'tool'; + content: ContentPart[]; + readonly toolCallId: string; +} + +export type Message = SystemMessage | UserMessage | AssistantMessage | ToolMessage; + +export function isContentPart(part: StreamedMessagePart): part is ContentPart { + const t = part.type; + return ( + t === 'text' || t === 'think' || t === 'image_url' || t === 'audio_url' || t === 'video_url' + ); +} + +export function isToolCall(part: StreamedMessagePart): part is ToolCall { + return part.type === 'function'; +} + +export function isToolCallPart(part: StreamedMessagePart): part is ToolCallPart { + return part.type === 'tool_call_part'; +} + +export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessagePart): boolean { + if (target.type === 'text' && source.type === 'text') { + target.text += source.text; + return true; + } + + if (target.type === 'think' && source.type === 'think') { + if (target.encrypted !== undefined) { + return false; + } + target.think += source.think; + if (source.encrypted !== undefined) { + target.encrypted = source.encrypted; + } + return true; + } + + if (target.type === 'function' && source.type === 'tool_call_part') { + if (source.argumentsPart !== null) { + target.arguments = + target.arguments === null + ? source.argumentsPart + : target.arguments + source.argumentsPart; + } + return true; + } + + return false; +} + +export function extractText(message: { readonly content: readonly ContentPart[] }, sep: string = ''): string { + return message.content + .filter((part): part is TextPart => part.type === 'text') + .map((part) => part.text) + .join(sep); +} + +export function getTextContent(message: { readonly content: readonly ContentPart[] }): string { + return extractText(message); +} + +export function createUserMessage(content: string): UserMessage { + return { + role: 'user', + content: [{ type: 'text', text: content }], + }; +} + +export function createAssistantMessage( + content: ContentPart[], + toolCalls?: ToolCall[], +): AssistantMessage { + return { + role: 'assistant', + content, + toolCalls: toolCalls ?? [], + }; +} + +export function createToolMessage(toolCallId: string, output: string | ContentPart[]): ToolMessage { + const content: ContentPart[] = + typeof output === 'string' ? [{ type: 'text', text: output }] : output; + return { + role: 'tool', + content, + toolCallId, + }; +} + +export function isVacuousContentPart(part: ContentPart): boolean { + switch (part.type) { + case 'text': + return part.text.trim().length === 0; + case 'think': + return part.encrypted === undefined && part.think.trim().length === 0; + case 'image_url': + case 'audio_url': + case 'video_url': + return false; + default: { + const exhaustive: never = part; + void exhaustive; + return false; + } + } +} + +export function salvageInterruptedMessage(message: AssistantMessage): AssistantMessage | null { + const content = message.content.filter((part) => !isVacuousContentPart(part)); + if (content.length === 0) { + return null; + } + return { role: 'assistant', content, toolCalls: [] }; +} + +export interface MessageAccumulator { + push(part: StreamedMessagePart): void; + finish(): AssistantMessage; +} + +export function createMessageAccumulator(): MessageAccumulator { + const message: AssistantMessage = { role: 'assistant', content: [], toolCalls: [] }; + const toolCallIndexMap = new Map(); + let pending: StreamedMessagePart | null = null; + const flush = () => { + if (pending === null) { + return; + } + if (isContentPart(pending)) { + message.content.push(pending); + } else if (isToolCall(pending)) { + const ordinal = message.toolCalls.length; + message.toolCalls.push({ + type: 'function', + id: pending.id, + name: pending.name, + arguments: pending.arguments, + extras: pending.extras, + }); + if (pending._streamIndex !== undefined) { + toolCallIndexMap.set(pending._streamIndex, ordinal); + } + } + pending = null; + }; + return { + push(part: StreamedMessagePart) { + if ( + isToolCallPart(part) && + part.index !== undefined && + !(pending !== null && isToolCall(pending) && pending._streamIndex === part.index) + ) { + const arrayIndex = toolCallIndexMap.get(part.index); + if (arrayIndex !== undefined) { + const target = message.toolCalls[arrayIndex]; + if (target !== undefined && part.argumentsPart !== null) { + target.arguments = + target.arguments === null + ? part.argumentsPart + : target.arguments + part.argumentsPart; + } + return; + } + } + if (pending === null) { + pending = structuredClone(part); + return; + } + if (!mergeInPlace(pending, part)) { + flush(); + pending = structuredClone(part); + } + }, + finish(): AssistantMessage { + flush(); + return message; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/model.ts b/packages/agent-core-v2/src/human/llm/model.ts new file mode 100644 index 00000000000..0b9aaa866fe --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/model.ts @@ -0,0 +1,19 @@ +import type { ModelCapability } from '#/llm/capability'; + +export interface LlmConnection { + readonly baseUrl?: string; + readonly apiKey?: string; + readonly defaultHeaders?: Record; +} + +export interface LlmModel extends LlmConnection { + readonly provider: string; + readonly model: string; + readonly capability: ModelCapability; + readonly maxContextSize?: number; + readonly maxInputSize?: number; +} + +export function modelKey(model: LlmModel): string { + return model.baseUrl === undefined ? model.model : `${model.baseUrl}#${model.model}`; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/base.ts b/packages/agent-core-v2/src/human/llm/protocol/base.ts new file mode 100644 index 00000000000..eaf4a5cbaf1 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/base.ts @@ -0,0 +1,17 @@ +import type { ModelCapability } from '#/llm/capability'; +import type { LlmRequester } from '#/llm/requester/requester'; + +import type { ProtocolTrait } from './trait'; + +export type ProtocolName = + | 'openai' + | 'openai_responses' + | 'anthropic' + | 'anthropic_beta' + | 'google-genai' + | 'google-vertex'; + +export interface ProtocolBase { + capability?(modelName: string): ModelCapability | undefined; + createRequester(trait?: ProtocolTrait): LlmRequester; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/format.ts b/packages/agent-core-v2/src/human/llm/protocol/format.ts new file mode 100644 index 00000000000..26f6bdfac0d --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/format.ts @@ -0,0 +1,42 @@ +import type { LlmRemoteErrorMessage } from '#/llm/errors'; +import type { FinishInfo } from '#/llm/finish-reason'; +import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; +import type { Message, StreamedMessagePart, ToolDescription } from '#/llm/message'; +import type { LlmRequestConfig } from '#/llm/requester/requester'; +import type { TokenUsage } from '#/llm/usage'; + +export type FormatRequestInput = LlmRequestConfig & { + readonly messages: readonly Message[]; + readonly tools: readonly ToolDescription[]; + readonly usedContextTokens?: number; + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; +}; + +export interface FormatRequestOptions { + readonly reasoningKey?: string; +} + +export interface StreamParseSink { + onDelta(part: StreamedMessagePart): void; + onFinish(finish: FinishInfo): void; + onMessageId?(messageId: string): void; + onUsage?(usage: Partial): void; + onError?(message: LlmRemoteErrorMessage): void; +} + +export type StreamParser = (chunk: TChunk, sink: StreamParseSink) => void; + +export interface StreamParserOptions { + readonly trait?: ProtocolTrait; + readonly ctx: TraitContext; +} + +export interface ProtocolFormat< + TRequest = Record, + _TResponse = unknown, + TChunk = unknown, +> { + formatRequest(input: FormatRequestInput, options?: FormatRequestOptions): TRequest; + createStreamParser(options?: StreamParserOptions): StreamParser; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/patterns.ts b/packages/agent-core-v2/src/human/llm/protocol/patterns.ts new file mode 100644 index 00000000000..7d67e4de3e2 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/patterns.ts @@ -0,0 +1,52 @@ +import type { Message, ThinkPart } from '#/llm/message'; + +import { convertToolResultToPlainText } from '../requester/bases/tool-result-text'; +import type { Pattern } from './rewrite'; + +export interface MergeUsersPolicy { + readonly isUser: (message: T) => boolean; + readonly isToolResultOnly: (message: T) => boolean; + readonly merge: (last: T, next: T) => T; +} + +export function mergeConsecutiveUsers(policy: MergeUsersPolicy): Pattern { + return { + name: 'mergeConsecutiveUsers', + rewrite(items, index) { + const first = items[index]; + if (first === undefined || !policy.isUser(first)) return null; + let acc: T = first; + let end = index + 1; + while (end < items.length) { + const next = items[end] as T; + if (!policy.isUser(next)) break; + if (!policy.isToolResultOnly(acc) && policy.isToolResultOnly(next)) break; + acc = policy.merge(acc, next); + end += 1; + } + if (end === index + 1) return null; + return { consumed: end - index, replacement: [acc] }; + }, + }; +} + +export const toolResultToPlainText: Pattern = { + name: 'toolResultToPlainText', + rewrite(items, index) { + const message = items[index]; + if (message === undefined || message.role !== 'tool') return null; + return { + consumed: 1, + replacement: [ + { + role: 'tool', + toolCallId: message.toolCallId, + content: [ + { type: 'text', text: convertToolResultToPlainText(message) }, + ...message.content.filter((part): part is ThinkPart => part.type === 'think'), + ], + }, + ], + }; + }, +}; diff --git a/packages/agent-core-v2/src/human/llm/protocol/rewrite.ts b/packages/agent-core-v2/src/human/llm/protocol/rewrite.ts new file mode 100644 index 00000000000..3b18753da35 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/rewrite.ts @@ -0,0 +1,29 @@ +export interface Rewrite { + readonly consumed: number; + readonly replacement: readonly T[]; +} + +export interface Pattern { + readonly name: string; + rewrite(items: readonly T[], index: number): Rewrite | null; +} + +export function applyPatterns(items: readonly T[], patterns: readonly Pattern[]): T[] { + let current = [...items]; + for (const pattern of patterns) { + const next: T[] = []; + let i = 0; + while (i < current.length) { + const rewrite = pattern.rewrite(current, i); + if (rewrite === null) { + next.push(current[i] as T); + i += 1; + } else { + next.push(...rewrite.replacement); + i += rewrite.consumed; + } + } + current = next; + } + return current; +} diff --git a/packages/agent-core-v2/src/human/llm/protocol/trait.ts b/packages/agent-core-v2/src/human/llm/protocol/trait.ts new file mode 100644 index 00000000000..6f4df8447ba --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/protocol/trait.ts @@ -0,0 +1,120 @@ +import type { ModelCapability } from '#/llm/capability'; +import type { LlmRemoteErrorMessage } from '#/llm/errors'; +import type { Message, ToolDescription } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { ToolCallIdPolicy, ToolMessageConversion } from '#/llm/requester/requester'; +import type { ThinkingRequestOptions } from '#/llm/thinking'; + +export interface TraitContext { + readonly model: LlmModel; +} + +export interface ProtocolEndpoint { + readonly apiKeyEnv?: string; + readonly baseUrlEnv?: string; + readonly defaultBaseUrl?: string; +} + +export interface ProtocolTrait { + readonly strictThinkingValidation?: boolean; + + endpoint?(): ProtocolEndpoint | undefined; + + defaultHeaders?(ctx: TraitContext): Record | undefined; + + convertTool?(tool: ToolDescription, ctx: TraitContext): Record | undefined; + + convertMessage?( + message: Message, + converted: Record, + ctx: TraitContext, + ): Record | null; + + mergeHistory?( + messages: readonly Record[], + ctx: TraitContext, + ): Record[] | undefined; + + buildParams?( + params: Record, + ctx: TraitContext, + ): Record | undefined; + + toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; + + toolMessageConversion?(ctx: TraitContext): ToolMessageConversion | undefined; + + convertError?(error: unknown, ctx: TraitContext): LlmRemoteErrorMessage | undefined; + + cacheKey?(key: string, ctx: TraitContext): Record | undefined; + + withThinking?( + thinking: ThinkingRequestOptions, + ctx: TraitContext, + ): Record | undefined; + + preserveThinking?( + thinking: ThinkingRequestOptions, + ctx: TraitContext, + ): boolean | undefined; + + withMaxCompletionTokens?( + maxCompletionTokens: number, + ctx: TraitContext, + ): Record | undefined; + + extractUsage?( + chunk: Record, + ctx: TraitContext, + ): Record | null | undefined; + + reasoningKey?(ctx: TraitContext): string | undefined; + + capability?(modelName: string): ModelCapability | undefined; +} + +export interface ThinkingApplication { + readonly kwargs: Record; + readonly preserveThinking: boolean; +} + +export function resolveModelConnection( + model: LlmModel, + trait: ProtocolTrait | undefined, +): LlmModel { + const declaration = trait?.endpoint?.(); + if (declaration === undefined) { + return model; + } + const read = (envName: string | undefined): string | undefined => { + if (envName === undefined) { + return undefined; + } + const value = process.env[envName]; + return value !== undefined && value.length > 0 ? value : undefined; + }; + return { + ...model, + baseUrl: model.baseUrl ?? read(declaration.baseUrlEnv) ?? declaration.defaultBaseUrl, + apiKey: model.apiKey ?? read(declaration.apiKeyEnv), + }; +} + +export type ThinkingFallback = ( + thinking: ThinkingRequestOptions, + ctx: TraitContext, +) => Record | undefined; + +export function applyThinking( + kwargs: Record, + thinking: ThinkingRequestOptions, + trait: ProtocolTrait | undefined, + ctx: TraitContext, + fallback?: ThinkingFallback, +): ThinkingApplication { + const hooked = trait?.withThinking?.(thinking, ctx) ?? fallback?.(thinking, ctx); + return { + kwargs: hooked === undefined ? kwargs : { ...kwargs, ...hooked }, + preserveThinking: trait?.preserveThinking?.(thinking, ctx) ?? false, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/provider-catalog.ts b/packages/agent-core-v2/src/human/llm/provider-catalog.ts new file mode 100644 index 00000000000..f3ba40fbe30 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/provider-catalog.ts @@ -0,0 +1,717 @@ +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm/capability'; +import type { LlmErrorMessage } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import type { ProtocolName } from '#/llm/protocol/base'; +import type { Provider } from '#/llm/provider/definition'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { assign, createActor, emit, enqueueActions, fromPromise, setup } from '#/xstate2'; + +export interface CatalogOAuthRef { + readonly storage: 'file' | 'keyring'; + readonly key: string; + readonly oauthHost?: string; +} + +export interface CatalogModelOverrides { + readonly maxContextSize?: number; + readonly maxInputSize?: number; + readonly maxOutputSize?: number; + readonly capability?: ModelCapability; + readonly displayName?: string; + readonly reasoningKey?: string; + readonly adaptiveThinking?: boolean; + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; + readonly offEffort?: string; + readonly alwaysThinking?: boolean; +} + +export interface CatalogModelDefinition extends LlmModel { + readonly displayName?: string; + readonly maxOutputSize?: number; + readonly reasoningKey?: string; + readonly supportEfforts?: readonly string[]; + readonly offEffort?: string; + readonly alwaysThinking?: boolean; + readonly protocol?: ProtocolName; + readonly defaultEffort?: string; + readonly adaptiveThinking?: boolean; + readonly betaApi?: boolean; + readonly name?: string; + readonly aliases?: readonly string[]; + readonly oauth?: CatalogOAuthRef; + readonly overrides?: CatalogModelOverrides; + readonly extras?: Readonly>; +} + +export interface CatalogProviderInfo { + readonly type?: string; + readonly apiKey?: string; + readonly baseUrl?: string; + readonly customHeaders?: Readonly>; + readonly defaultModel?: string; + readonly oauth?: CatalogOAuthRef; + readonly env?: Readonly>; + readonly modelSource?: 'static' | 'discover' | 'oauth-catalog'; + readonly source?: Readonly>; +} + +export interface CatalogProviderEntry { + readonly info?: CatalogProviderInfo; + readonly discovered: Readonly>; + readonly override: Readonly>; + readonly pingErrors?: Readonly>; +} + +export interface CatalogModel extends CatalogModelDefinition { + readonly pingError?: string; +} + +export interface CatalogSnapshot { + readonly providers: Readonly>; +} + +export interface ProviderCatalogStore { + load(): Promise; + save(snapshot: CatalogSnapshot): Promise; +} + +export type ProviderCatalogEvent = + | { + type: 'upsert'; + providerId: string; + info?: CatalogProviderInfo; + models?: readonly CatalogModelDefinition[]; + } + | { type: 'remove'; providerId: string } + | { type: 'refresh'; providers: readonly Provider[] } + | { type: 'ping'; provider: Provider; model: string }; + +export type ProviderCatalogEmitted = + | { readonly type: 'changed'; readonly providers: readonly string[] } + | { readonly type: 'refresh-failed'; readonly providerId: string; readonly error: unknown }; + +export type ProviderCatalogChanged = Extract; + +export type ProviderCatalogRefreshFailed = Extract< + ProviderCatalogEmitted, + { type: 'refresh-failed' } +>; + +interface RefreshFailure { + readonly providerId: string; + readonly error: unknown; +} + +interface PingRequest { + readonly provider: Provider; + readonly model: CatalogModelDefinition; +} + +interface PingOutcome { + readonly providerId: string; + readonly model: string; + readonly error?: string; +} + +interface ProviderCatalogContext { + readonly snapshot: CatalogSnapshot; + readonly batch: readonly Provider[]; + readonly queue: readonly Provider[]; + readonly dirty: readonly string[]; + readonly failures: readonly RefreshFailure[]; + readonly ping?: PingRequest; + readonly pingQueue: readonly PingRequest[]; +} + +interface PullResult { + readonly providerId: string; + readonly models?: readonly LlmModel[]; + readonly error?: unknown; +} + +function applyPullResults( + snapshot: CatalogSnapshot, + results: readonly PullResult[], +): CatalogSnapshot { + let providers = snapshot.providers; + for (const result of results) { + if (result.models === undefined) continue; + const entry = providers[result.providerId]; + providers = { + ...providers, + [result.providerId]: { + info: entry?.info, + override: entry?.override ?? {}, + discovered: Object.fromEntries(result.models.map((model) => [model.model, model])), + pingErrors: entry?.pingErrors, + }, + }; + } + return { providers }; +} + +function pullFailures(results: readonly PullResult[]): RefreshFailure[] { + return results + .filter((result) => result.models === undefined) + .map((result) => ({ providerId: result.providerId, error: result.error })); +} + +function mergeDirty(dirty: readonly string[], results: readonly PullResult[]): string[] { + const succeeded = results + .filter((result) => result.models !== undefined) + .map((result) => result.providerId); + return [...new Set([...dirty, ...succeeded])].toSorted(); +} + +function enqueueProviders( + batch: readonly Provider[], + queue: readonly Provider[], + incoming: readonly Provider[], +): readonly Provider[] { + const active = new Set([...batch, ...queue].map((provider) => provider.id)); + return [...queue, ...incoming.filter((provider) => !active.has(provider.id))]; +} + +function applyPingOutcome( + snapshot: CatalogSnapshot, + outcome: PingOutcome, +): { snapshot: CatalogSnapshot; changed: boolean } { + const entry = snapshot.providers[outcome.providerId]; + if (entry === undefined) return { snapshot, changed: false }; + if (entry.pingErrors?.[outcome.model] === outcome.error) return { snapshot, changed: false }; + const pingErrors = { ...entry.pingErrors }; + if (outcome.error === undefined) delete pingErrors[outcome.model]; + else pingErrors[outcome.model] = outcome.error; + return { + snapshot: { + providers: { + ...snapshot.providers, + [outcome.providerId]: { ...entry, pingErrors }, + }, + }, + changed: true, + }; +} + +function resolveCatalogModel( + entry: CatalogProviderEntry | undefined, + modelId: string, +): CatalogModelDefinition | undefined { + if (entry === undefined) return undefined; + const override = entry.override[modelId]; + if (override !== undefined) return mergeModel(override, entry.discovered[modelId]); + const discovered = entry.discovered[modelId]; + if (discovered === undefined) return undefined; + return mergeModel({ ...discovered }, undefined); +} + +function mergeEntryModels(entry: CatalogProviderEntry): CatalogModel[] { + const attach = (model: CatalogModelDefinition): CatalogModel => { + const pingError = entry.pingErrors?.[model.model]; + return pingError === undefined ? model : { ...model, pingError }; + }; + const merged = Object.values(entry.override).map((record) => + attach(mergeModel(record, entry.discovered[record.model])), + ); + const discoveredOnly = Object.values(entry.discovered) + .filter((model) => entry.override[model.model] === undefined) + .map((model) => attach(mergeModel({ ...model }, undefined))); + return [...merged, ...discoveredOnly].toSorted((a, b) => a.model.localeCompare(b.model)); +} + +function enqueuePing( + active: PingRequest | undefined, + queue: readonly PingRequest[], + request: PingRequest, +): readonly PingRequest[] { + const keyOf = (ping: PingRequest): string => `${ping.provider.id}#${ping.model.model}`; + if (active !== undefined && keyOf(active) === keyOf(request)) return queue; + if (queue.some((ping) => keyOf(ping) === keyOf(request))) return queue; + return [...queue, request]; +} + +async function runPingProbe( + provider: Provider, + model: CatalogModelDefinition, +): Promise { + let requester: LlmRequester; + try { + requester = provider.createRequester(model.protocol); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + let failure: LlmErrorMessage | undefined; + try { + await requester.generate( + { + model, + systemPrompt: 'You are a connectivity probe. Answer with the single word "pong".', + tools: [], + maxCompletionTokens: 512, + }, + { messages: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }] }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.failed.syntax' || event.type === 'llm.failed.remote') { + failure = event.error; + } + }, + }, + ); + } catch (error) { + return error instanceof Error ? error.message : String(error); + } + return failure?.message; +} + +export function createProviderCatalogMachine() { + return setup({ + types: { + context: {} as ProviderCatalogContext, + events: {} as ProviderCatalogEvent, + emitted: {} as ProviderCatalogEmitted, + input: {} as CatalogSnapshot | undefined, + }, + actors: { + pullBatch: fromPromise(async ({ input: providers }) => + Promise.all( + providers.map(async (provider): Promise => { + try { + return { providerId: provider.id, models: await provider.listModels() }; + } catch (error) { + return { providerId: provider.id, error }; + } + }), + ), + ), + pingModel: fromPromise(async ({ input }) => ({ + providerId: input.provider.id, + model: input.model.model, + error: await runPingProbe(input.provider, input.model), + })), + }, + }).createMachine({ + id: 'providerCatalog', + context: ({ input }) => ({ + snapshot: input ?? { providers: {} }, + batch: [], + queue: [], + dirty: [], + failures: [], + pingQueue: [], + }), + initial: 'idle', + on: { + upsert: { + actions: [ + assign(({ context, event }) => ({ + snapshot: { + providers: { + ...context.snapshot.providers, + [event.providerId]: { + info: event.info, + discovered: {}, + override: Object.fromEntries( + (event.models ?? []).map((model) => [model.model, model]), + ), + }, + }, + }, + })), + emit(({ event }) => ({ type: 'changed' as const, providers: [event.providerId] })), + ], + }, + remove: { + actions: [ + assign(({ context, event }) => { + const providers = { ...context.snapshot.providers }; + delete providers[event.providerId]; + return { snapshot: { providers } }; + }), + emit(({ event }) => ({ type: 'changed' as const, providers: [event.providerId] })), + ], + }, + }, + states: { + idle: { + on: { + ping: [ + { + guard: ({ context, event }) => + resolveCatalogModel(context.snapshot.providers[event.provider.id], event.model) !== + undefined, + target: 'pinging', + actions: assign(({ context, event }) => { + const model = resolveCatalogModel( + context.snapshot.providers[event.provider.id], + event.model, + ); + return model === undefined ? {} : { ping: { provider: event.provider, model } }; + }), + }, + {}, + ], + refresh: { + target: 'refreshing', + actions: assign(({ event }) => ({ + batch: event.providers, + queue: [], + dirty: [], + failures: [], + })), + }, + }, + }, + refreshing: { + invoke: { + src: 'pullBatch', + input: ({ context }) => context.batch, + onDone: [ + { + guard: ({ context }) => context.queue.length > 0, + target: 'refreshing', + reenter: true, + actions: assign(({ context, event }) => ({ + snapshot: applyPullResults(context.snapshot, event.output), + batch: context.queue, + queue: [], + dirty: mergeDirty(context.dirty, event.output), + failures: [...context.failures, ...pullFailures(event.output)], + })), + }, + { + guard: ({ context }) => context.pingQueue.length > 0, + target: 'pinging', + actions: [ + assign(({ context, event }) => ({ + snapshot: applyPullResults(context.snapshot, event.output), + ping: context.pingQueue.at(0) as PingRequest, + pingQueue: context.pingQueue.slice(1), + })), + enqueueActions(({ context, event, enqueue }) => { + const providers = mergeDirty(context.dirty, event.output); + if (providers.length > 0) { + enqueue.emit({ type: 'changed', providers }); + } + for (const failure of [...context.failures, ...pullFailures(event.output)]) { + enqueue.emit({ + type: 'refresh-failed', + providerId: failure.providerId, + error: failure.error, + }); + } + }), + ], + }, + { + target: 'idle', + actions: [ + assign(({ context, event }) => ({ + snapshot: applyPullResults(context.snapshot, event.output), + })), + enqueueActions(({ context, event, enqueue }) => { + const providers = mergeDirty(context.dirty, event.output); + if (providers.length > 0) { + enqueue.emit({ type: 'changed', providers }); + } + for (const failure of [...context.failures, ...pullFailures(event.output)]) { + enqueue.emit({ + type: 'refresh-failed', + providerId: failure.providerId, + error: failure.error, + }); + } + }), + ], + }, + ], + }, + on: { + ping: { + actions: assign(({ context, event }) => { + const model = resolveCatalogModel( + context.snapshot.providers[event.provider.id], + event.model, + ); + if (model === undefined) return {}; + return { + pingQueue: enqueuePing(context.ping, context.pingQueue, { + provider: event.provider, + model, + }), + }; + }), + }, + refresh: { + actions: assign(({ context, event }) => ({ + queue: enqueueProviders(context.batch, context.queue, event.providers), + })), + }, + }, + }, + pinging: { + invoke: { + src: 'pingModel', + input: ({ context }) => context.ping as PingRequest, + onDone: [ + { + guard: ({ context }) => context.pingQueue.length > 0, + target: 'pinging', + reenter: true, + actions: enqueueActions(({ context, event, enqueue }) => { + const outcome = applyPingOutcome(context.snapshot, event.output); + enqueue.assign({ + snapshot: outcome.snapshot, + ping: context.pingQueue.at(0) as PingRequest, + pingQueue: context.pingQueue.slice(1), + }); + if (outcome.changed) { + enqueue.emit({ + type: 'changed', + providers: [event.output.providerId], + }); + } + }), + }, + { + guard: ({ context }) => context.queue.length > 0, + target: 'refreshing', + actions: enqueueActions(({ context, event, enqueue }) => { + const outcome = applyPingOutcome(context.snapshot, event.output); + enqueue.assign({ + snapshot: outcome.snapshot, + ping: undefined, + batch: context.queue, + queue: [], + dirty: [], + failures: [], + }); + if (outcome.changed) { + enqueue.emit({ + type: 'changed', + providers: [event.output.providerId], + }); + } + }), + }, + { + target: 'idle', + actions: enqueueActions(({ context, event, enqueue }) => { + const outcome = applyPingOutcome(context.snapshot, event.output); + enqueue.assign({ snapshot: outcome.snapshot, ping: undefined }); + if (outcome.changed) { + enqueue.emit({ + type: 'changed', + providers: [event.output.providerId], + }); + } + }), + }, + ], + }, + on: { + ping: { + actions: assign(({ context, event }) => { + const model = resolveCatalogModel( + context.snapshot.providers[event.provider.id], + event.model, + ); + if (model === undefined) return {}; + return { + pingQueue: enqueuePing(context.ping, context.pingQueue, { + provider: event.provider, + model, + }), + }; + }), + }, + refresh: { + actions: assign(({ context, event }) => ({ + queue: enqueueProviders([], context.queue, event.providers), + })), + }, + }, + }, + }, + }); +} + +export interface ProviderCatalog { + providers(): readonly string[]; + providerInfo(providerId: string): CatalogProviderInfo | undefined; + models(providerId: string): readonly CatalogModel[]; + upsert(input: { + provider: Provider; + info?: CatalogProviderInfo; + models?: readonly CatalogModelDefinition[]; + }): void; + remove(providerId: string): void; + refresh(provider: Provider): void; + ping(providerId: string, model: string): void; + onChanged(listener: (event: ProviderCatalogChanged) => void): () => void; + onRefreshFailed(listener: (event: ProviderCatalogRefreshFailed) => void): () => void; + stop(): void; +} + +export function createMemoryProviderCatalogStore(): ProviderCatalogStore { + let snapshot: CatalogSnapshot | undefined; + return { + load: () => Promise.resolve(snapshot), + save: (value) => { + snapshot = value; + return Promise.resolve(); + }, + }; +} + +function mergeCapability( + discovered: ModelCapability | undefined, + override: ModelCapability | undefined, +): ModelCapability { + if (discovered === undefined) { + return override ?? UNKNOWN_CAPABILITY; + } + if (override === undefined) { + return discovered; + } + return { + image_in: discovered.image_in || override.image_in, + video_in: discovered.video_in || override.video_in, + audio_in: discovered.audio_in || override.audio_in, + thinking: discovered.thinking || override.thinking, + tool_use: discovered.tool_use || override.tool_use, + dynamically_loaded_tools: + discovered.dynamically_loaded_tools === true || override.dynamically_loaded_tools === true, + }; +} + +function clampMaxInputSize(model: CatalogModelDefinition): CatalogModelDefinition { + if ( + model.maxInputSize !== undefined && + model.maxContextSize !== undefined && + model.maxInputSize > model.maxContextSize + ) { + return { ...model, maxInputSize: model.maxContextSize }; + } + return model; +} + +function applyModelOverrides( + model: CatalogModelDefinition, + overrides: CatalogModelOverrides | undefined, +): CatalogModelDefinition { + if (overrides === undefined) return model; + const effective: CatalogModelDefinition = { ...model, ...overrides }; + if ( + overrides.supportEfforts !== undefined && + overrides.defaultEffort === undefined && + effective.defaultEffort !== undefined && + !overrides.supportEfforts.includes(effective.defaultEffort) + ) { + const { defaultEffort: _dropped, ...rest } = effective; + return clampMaxInputSize(rest); + } + return clampMaxInputSize(effective); +} + +function mergeModel( + record: CatalogModelDefinition, + discovered: LlmModel | undefined, +): CatalogModelDefinition { + const merged: CatalogModelDefinition = { + provider: record.provider, + model: record.model, + capability: mergeCapability(discovered?.capability, record.capability), + maxContextSize: record.maxContextSize ?? discovered?.maxContextSize, + maxInputSize: record.maxInputSize ?? discovered?.maxInputSize, + baseUrl: record.baseUrl ?? discovered?.baseUrl, + apiKey: record.apiKey ?? discovered?.apiKey, + defaultHeaders: record.defaultHeaders ?? discovered?.defaultHeaders, + displayName: record.displayName, + maxOutputSize: record.maxOutputSize, + reasoningKey: record.reasoningKey, + supportEfforts: record.supportEfforts, + offEffort: record.offEffort, + alwaysThinking: record.alwaysThinking, + protocol: record.protocol, + defaultEffort: record.defaultEffort, + adaptiveThinking: record.adaptiveThinking, + betaApi: record.betaApi, + name: record.name, + aliases: record.aliases, + oauth: record.oauth, + extras: record.extras, + }; + return applyModelOverrides(merged, record.overrides); +} + +export async function createProviderCatalog( + options: { + store?: ProviderCatalogStore; + snapshot?: CatalogSnapshot; + } = {}, +): Promise { + const loaded = options.snapshot ?? (await options.store?.load()); + const actor = createActor(createProviderCatalogMachine(), { input: loaded }); + actor.start(); + + if (options.store !== undefined) { + const store = options.store; + actor.on('changed', () => { + void store.save(actor.getSnapshot().context.snapshot); + }); + } + + const read = (): CatalogSnapshot => actor.getSnapshot().context.snapshot; + const live = new Map(); + + return { + providers: () => Object.keys(read().providers).toSorted(), + providerInfo: (providerId) => read().providers[providerId]?.info, + models: (providerId) => { + const entry = read().providers[providerId]; + return entry === undefined ? [] : mergeEntryModels(entry); + }, + upsert: (input) => { + live.set(input.provider.id, input.provider); + actor.send({ + type: 'upsert', + providerId: input.provider.id, + info: input.info, + models: input.models, + }); + actor.send({ type: 'refresh', providers: [input.provider] }); + }, + remove: (providerId) => { + live.delete(providerId); + actor.send({ type: 'remove', providerId }); + }, + refresh: (provider) => { + live.set(provider.id, provider); + actor.send({ type: 'refresh', providers: [provider] }); + }, + ping: (providerId, model) => { + const provider = live.get(providerId); + if (provider === undefined) return; + actor.send({ type: 'ping', provider, model }); + }, + onChanged: (listener) => { + const subscription = actor.on('changed', listener); + return () => { + subscription.unsubscribe(); + }; + }, + onRefreshFailed: (listener) => { + const subscription = actor.on('refresh-failed', listener); + return () => { + subscription.unsubscribe(); + }; + }, + stop: () => { + live.clear(); + actor.stop(); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/provider/definition.ts b/packages/agent-core-v2/src/human/llm/provider/definition.ts new file mode 100644 index 00000000000..2c384108432 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/provider/definition.ts @@ -0,0 +1,121 @@ +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm/capability'; +import type { ProviderMediaContribution } from '#/llm/media/upload'; +import type { LlmConnection, LlmModel } from '#/llm/model'; +import type { ProtocolBase, ProtocolName } from '#/llm/protocol/base'; +import type { ProtocolTrait } from '#/llm/protocol/trait'; +import type { LlmRequester } from '#/llm/requester/requester'; + +export interface ProviderProtocolDefinition { + readonly base: ProtocolBase; + readonly trait?: ProtocolTrait; +} + +export interface LlmModelSeed { + readonly model: string; + readonly capability?: ModelCapability; + readonly maxContextSize?: number; + readonly maxInputSize?: number; + readonly baseUrl?: string; +} + +export type ProviderModelSource = () => Promise; + +export interface ProviderDefinition { + readonly id: string; + readonly protocols: Readonly>>; + readonly media?: ProviderMediaContribution; + readonly models?: ProviderModelSource; +} + +export interface LlmResolveModelOptions extends LlmConnection { + readonly protocol?: ProtocolName; +} + +export interface Provider { + readonly id: string; + readonly protocols: readonly ProtocolName[]; + readonly media?: ProviderMediaContribution; + listModels(): Promise; + resolveModel(model: string, options?: LlmResolveModelOptions): LlmModel; + createRequester(protocol?: ProtocolName): LlmRequester; +} + +interface ProviderProtocolEntry { + readonly name: ProtocolName; + readonly base: ProtocolBase; + readonly trait?: ProtocolTrait; +} + +export function createProvider(definition: ProviderDefinition): Provider { + const entries: ProviderProtocolEntry[] = []; + for (const name of Object.keys(definition.protocols) as ProtocolName[]) { + const protocol = definition.protocols[name]; + if (protocol !== undefined) { + entries.push({ name, base: protocol.base, trait: protocol.trait }); + } + } + const defaultEntry = entries[0]; + if (defaultEntry === undefined) { + throw new Error(`provider '${definition.id}' declares no protocols`); + } + + const protocolFor = (name: ProtocolName | undefined): ProviderProtocolEntry => { + if (name === undefined) { + return defaultEntry; + } + const found = entries.find((entry) => entry.name === name); + if (found === undefined) { + throw new Error( + `provider '${definition.id}' has no protocol '${name}' (available: ${entries.map((entry) => entry.name).join(', ')})`, + ); + } + return found; + }; + + const detectCapability = ( + entry: ProviderProtocolEntry, + modelName: string, + ): ModelCapability => + entry.trait?.capability?.(modelName) ?? + entry.base.capability?.(modelName) ?? + UNKNOWN_CAPABILITY; + + return { + id: definition.id, + protocols: entries.map((entry) => entry.name), + media: definition.media, + listModels: async () => { + if (definition.models === undefined) { + return []; + } + const seeds = await definition.models(); + return seeds.map((seed) => ({ + provider: definition.id, + model: seed.model, + capability: + defaultEntry.trait?.capability?.(seed.model) ?? + seed.capability ?? + defaultEntry.base.capability?.(seed.model) ?? + UNKNOWN_CAPABILITY, + maxContextSize: seed.maxContextSize, + maxInputSize: seed.maxInputSize, + baseUrl: seed.baseUrl, + })); + }, + resolveModel: (model, options = {}) => { + const entry = protocolFor(options.protocol); + return { + provider: definition.id, + model, + capability: detectCapability(entry, model), + baseUrl: options.baseUrl, + apiKey: options.apiKey, + defaultHeaders: options.defaultHeaders, + }; + }, + createRequester: (protocol) => { + const entry = protocolFor(protocol); + return entry.base.createRequester(entry.trait); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts b/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts new file mode 100644 index 00000000000..3e62e206751 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/provider/providers/standard.ts @@ -0,0 +1,49 @@ +import type { ProtocolTrait } from '#/llm/protocol/trait'; +import { createProvider } from '#/llm/provider/definition'; +import { anthropicBase } from '#/llm/requester/bases/anthropic/requester'; +import { createGoogleGenAIBase, googleGenAIBase } from '#/llm/requester/bases/google-genai/requester'; +import { openAIBase } from '#/llm/requester/bases/openai/requester'; +import { openAIResponsesBase } from '#/llm/requester/bases/openai-responses/requester'; + +const openAITrait: ProtocolTrait = { + endpoint: () => ({ apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }), +}; + +export const openaiProvider = createProvider({ + id: 'openai', + protocols: { + openai: { base: openAIBase, trait: openAITrait }, + openai_responses: { base: openAIResponsesBase, trait: openAITrait }, + }, +}); + +export const anthropicProvider = createProvider({ + id: 'anthropic', + protocols: { + anthropic: { + base: anthropicBase, + trait: { + endpoint: () => ({ apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }), + }, + }, + }, +}); + +export const googleProvider = createProvider({ + id: 'google', + protocols: { + 'google-genai': { + base: googleGenAIBase, + trait: { + endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }), + }, + }, + 'google-vertex': { + base: createGoogleGenAIBase({ vertexai: true }), + trait: { + endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }), + }, + }, + }, + media: { inlineVideo: true }, +}); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/capability.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/capability.ts new file mode 100644 index 00000000000..f6346603494 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/capability.ts @@ -0,0 +1,35 @@ +const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; + +const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ + 'claude-opus-4', + 'claude-sonnet-4', + 'claude-haiku-4', + 'claude-fable', +] as const; + +const ANTHROPIC_VISION_TOOL_CAPABILITY = Object.freeze({ + image_in: true, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, +}); + +const ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY = Object.freeze({ + image_in: true, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, +}); + +export function getAnthropicModelCapability(modelName: string) { + const normalized = modelName.toLowerCase(); + if (CLAUDE_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return ANTHROPIC_VISION_TOOL_CAPABILITY; + } + if (CLAUDE_THINKING_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/extra-params.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/extra-params.ts new file mode 100644 index 00000000000..902915ceaec --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/extra-params.ts @@ -0,0 +1,6 @@ +export interface AnthropicExtraParams { + readonly temperature?: number; + readonly top_p?: number; + readonly top_k?: number; + readonly stop_sequences?: readonly string[]; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts new file mode 100644 index 00000000000..1af46e23312 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/format.ts @@ -0,0 +1,427 @@ +import Anthropic, { + APIConnectionError as RawAnthropicSDKConnectionError, + APIConnectionTimeoutError as RawAnthropicSDKConnectionTimeoutError, + APIError as RawAnthropicSDKAPIError, +} from '@anthropic-ai/sdk'; +import { assign, shake } from 'radashi'; + +import { + headersToRecord, + isAbortError, + parseRetryAfterMs, + toLlmErrorMessage, + toLlmStatusErrorMessage, + toLlmTransportErrorMessage, + type LlmRemoteErrorMessage, +} from '#/llm/errors'; +import { NO_FINISH, type FinishInfo, type FinishReason } from '#/llm/finish-reason'; +import type { FormatRequestInput, ProtocolFormat } from '#/llm/protocol/format'; +import type { ResponseFormat } from '#/llm/response-format'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; +import type { ToolDescription } from '#/llm/message'; +import { applyThinking } from '#/llm/protocol/trait'; +import { mergeConsecutiveUsers } from '#/llm/protocol/patterns'; +import { applyPatterns } from '#/llm/protocol/rewrite'; +import type { TokenUsage } from '#/llm/usage'; + +import { lowerMessage, messageContent, type AnthropicWireMessage } from './lower'; +import { audioToPlaceholder, stripUnsignedThinking } from './patterns'; +import { + encodeThinking, + INTERLEAVED_THINKING_BETA, + resolveDefaultMaxTokens, + shouldPreserveUnsignedThinking, +} from './profile'; + +export { INTERLEAVED_THINKING_BETA } from './profile'; +export const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; + +const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; + +type RawUsage = { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}; + +const CACHE_CONTROL = { type: 'ephemeral' as const }; + +const CACHEABLE_TYPES = new Set([ + 'text', + 'image', + 'document', + 'search_result', + 'tool_use', + 'tool_result', + 'server_tool_use', + 'web_search_tool_result', +]); + +export type { AnthropicWireContentBlock, AnthropicWireMessage } from './lower'; + +function injectCacheControlOnLastBlock(messages: AnthropicWireMessage[]): void { + const lastMessage = messages.at(-1); + if (lastMessage === undefined) return; + const content = messageContent(lastMessage); + const lastBlock = content.at(-1); + if (lastBlock === undefined) return; + if (CACHEABLE_TYPES.has(lastBlock.type)) { + lastBlock.cache_control = CACHE_CONTROL; + } +} + +function isToolResultOnly(message: AnthropicWireMessage): boolean { + if (message.role !== 'user') return false; + const content = messageContent(message); + if (content.length === 0) return false; + return content.every((block) => block.type === 'tool_result'); +} + +interface RawContentBlock { + type: string; + text?: string; + thinking?: string; + signature?: string; + data?: string; + id?: string; + name?: string; + input?: unknown; +} + +interface RawStreamDelta { + type?: string; + text?: string; + thinking?: string; + partial_json?: string; + signature?: string; + stop_reason?: string | null; + stop_sequence?: string | null; +} + +interface RawStreamEvent { + type: string; + index?: number; + content_block?: RawContentBlock; + delta?: RawStreamDelta; + message?: { id?: string; usage?: RawUsage }; + usage?: RawUsage; +} + +type RawResponse = { content?: RawContentBlock[]; usage?: RawUsage }; + +function normalizeStopReason(raw: string | null | undefined): FinishInfo { + if (raw === null || raw === undefined) { + return NO_FINISH; + } + const finishReason: FinishReason = (() => { + switch (raw) { + case 'end_turn': + case 'stop_sequence': + return 'completed'; + case 'max_tokens': + return 'truncated'; + case 'tool_use': + return 'tool_calls'; + case 'pause_turn': + return 'paused'; + case 'refusal': + return 'filtered'; + default: + return 'other'; + } + })(); + return { finishReason, rawFinishReason: raw }; +} + +function parseRawUsage(usage: RawUsage | undefined): Partial | undefined { + if (usage === undefined) { + return undefined; + } + const patch: Partial = { raw: usage as Record }; + if (typeof usage.input_tokens === 'number') { + patch.inputOther = usage.input_tokens; + } + if (typeof usage.output_tokens === 'number') { + patch.output = usage.output_tokens; + } + if (typeof usage.cache_read_input_tokens === 'number') { + patch.inputCacheRead = usage.cache_read_input_tokens; + } + if (typeof usage.cache_creation_input_tokens === 'number') { + patch.inputCacheCreation = usage.cache_creation_input_tokens; + } + return patch; +} + +function applyResponseFormat( + kwargs: Record, + format: ResponseFormat, +): Record { + if (format.type === 'json_object') { + throw new SyntaxRequestFormatError( + 'Anthropic requires a JSON schema for structured response output.', + ); + } + const existing = kwargs['output_config']; + const outputConfig = + existing !== undefined && existing !== null + ? { ...(existing as Record) } + : {}; + outputConfig['format'] = { type: 'json_schema', schema: format.jsonSchema.schema }; + return { ...kwargs, output_config: outputConfig }; +} + +function applyThinkingKeep(kwargs: Record, keep: string): Record { + const betaFeatures = kwargs['betaFeatures']; + const existing = kwargs['context_management'] as + | { edits?: Array<{ type: string }> } + | undefined; + return { + ...kwargs, + betaFeatures: Array.isArray(betaFeatures) + ? betaFeatures.includes(CONTEXT_MANAGEMENT_BETA) + ? betaFeatures + : [...betaFeatures, CONTEXT_MANAGEMENT_BETA] + : [CONTEXT_MANAGEMENT_BETA], + context_management: { + edits: [ + { type: CLEAR_THINKING_EDIT, keep }, + ...(existing?.edits ?? []).filter((edit) => edit.type !== CLEAR_THINKING_EDIT), + ], + }, + }; +} + +function resolveRequestKwargs(input: FormatRequestInput): Record { + const { + trait, + ctx, + thinking, + responseFormat, + maxCompletionTokens, + usedContextTokens, + maxContextTokens, + extraParams, + } = input; + let kwargs: Record = { betaFeatures: [INTERLEAVED_THINKING_BETA] }; + if (thinking !== undefined) { + kwargs = applyThinking(kwargs, thinking, trait, ctx, (t, c) => + encodeThinking(t, c.model), + ).kwargs; + } + if (responseFormat !== undefined) { + kwargs = applyResponseFormat(kwargs, responseFormat); + } + if (maxCompletionTokens !== undefined) { + let cap = maxCompletionTokens; + if ( + usedContextTokens !== undefined && + maxContextTokens !== undefined && + maxContextTokens > 0 + ) { + cap = Math.min(cap, maxContextTokens - usedContextTokens); + } + cap = Math.max(1, cap); + cap = resolveDefaultMaxTokens(ctx.model.model, cap); + const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); + if (hooked !== undefined) { + kwargs = { ...kwargs, ...hooked }; + } else { + kwargs = { ...kwargs, max_tokens: cap }; + } + } + kwargs = assign(kwargs, extraParams?.anthropic ?? {}); + if (thinking?.keep !== undefined) { + kwargs = applyThinkingKeep(kwargs, thinking.keep); + } + kwargs = shake(kwargs); + return kwargs; +} + +export interface AnthropicRequestParams { + readonly params: Anthropic.MessageCreateParamsStreaming; + readonly betas: readonly string[]; + readonly useBetaApi: boolean; +} + +export interface AnthropicFormatOptions { + readonly betaApi?: boolean; +} + +export function createAnthropicFormat( + options?: AnthropicFormatOptions, +): ProtocolFormat { + const betaApi = options?.betaApi === true; + return { + formatRequest(input) { + const { messages, systemPrompt, tools, trait, ctx, cacheKey, thinking } = input; + const kwargs = resolveRequestKwargs(input); + const normalized = applyPatterns(messages, [ + stripUnsignedThinking({ preserve: shouldPreserveUnsignedThinking(ctx.model.model) }), + audioToPlaceholder, + ]); + const converted = normalized.flatMap((message) => lowerMessage(message, { trait, ctx })); + const merged = + (trait?.mergeHistory?.(converted, ctx) as AnthropicWireMessage[] | undefined) ?? + applyPatterns(converted, [ + mergeConsecutiveUsers({ + isUser: (param) => param.role === 'user', + isToolResultOnly, + merge: (last, next) => ({ + ...last, + content: [...messageContent(last), ...messageContent(next)], + }), + }), + ]); + injectCacheControlOnLastBlock(merged); + const formattedTools: Record[] = tools.map( + (tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool), + ); + const lastTool = formattedTools.at(-1); + if (lastTool !== undefined) { + lastTool['cache_control'] = CACHE_CONTROL; + } + const { betaFeatures, ...restKwargs } = kwargs; + const betas = Array.isArray(betaFeatures) ? (betaFeatures as string[]) : []; + const useBetaApi = betaApi || thinking?.keep !== undefined; + const createParams: Record = { + model: ctx.model.model, + max_tokens: resolveDefaultMaxTokens(ctx.model.model), + metadata: cacheKey === undefined ? undefined : { user_id: cacheKey }, + ...restKwargs, + system: systemPrompt + ? [{ type: 'text', text: systemPrompt, cache_control: CACHE_CONTROL }] + : undefined, + messages: merged, + tools: formattedTools.length === 0 ? undefined : formattedTools, + betas: useBetaApi && betas.length > 0 ? betas : undefined, + stream: true, + }; + const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; + return { + params: finalParams as unknown as Anthropic.MessageCreateParamsStreaming, + betas, + useBetaApi, + }; + }, + + createStreamParser() { + return (chunk, sink) => { + if (chunk.type === 'message_start') { + const messageId = chunk.message?.id; + if (typeof messageId === 'string' && messageId.length > 0) { + sink.onMessageId?.(messageId); + } + const usage = parseRawUsage(chunk.message?.usage); + if (usage !== undefined) { + const inputUsage = { ...usage }; + delete inputUsage.output; + sink.onUsage?.(inputUsage); + } + return; + } + if (chunk.type === 'message_delta') { + const usage = parseRawUsage(chunk.usage); + if (usage !== undefined) { + sink.onUsage?.(usage); + } + const stopReason = chunk.delta?.stop_reason; + if (stopReason !== undefined && stopReason !== null) { + sink.onFinish(normalizeStopReason(stopReason)); + } + return; + } + if (chunk.type === 'content_block_start' && chunk.content_block !== undefined) { + const block = chunk.content_block; + const index = chunk.index ?? 0; + if (block.type === 'tool_use') { + sink.onDelta({ + type: 'function', + id: block.id ?? crypto.randomUUID(), + name: block.name ?? '', + arguments: '', + _streamIndex: index, + }); + return; + } + if (block.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) { + sink.onDelta({ type: 'think', think: block.thinking }); + return; + } + if (block.type === 'redacted_thinking' && typeof block.data === 'string' && block.data) { + sink.onDelta({ type: 'think', think: '', encrypted: block.data }); + return; + } + if (block.type === 'text' && typeof block.text === 'string' && block.text) { + sink.onDelta({ type: 'text', text: block.text }); + } + return; + } + if (chunk.type === 'content_block_delta' && chunk.delta !== undefined) { + const delta = chunk.delta; + const index = chunk.index ?? 0; + if (delta.type === 'text_delta' && delta.text) { + sink.onDelta({ type: 'text', text: delta.text }); + return; + } + if (delta.type === 'thinking_delta' && delta.thinking) { + sink.onDelta({ type: 'think', think: delta.thinking }); + return; + } + if (delta.type === 'input_json_delta' && delta.partial_json) { + sink.onDelta({ type: 'tool_call_part', argumentsPart: delta.partial_json, index }); + return; + } + if (delta.type === 'signature_delta' && delta.signature) { + sink.onDelta({ type: 'think', think: '', encrypted: delta.signature }); + } + return; + } + }; + }, + }; +} + +export const anthropicFormat: ProtocolFormat = + createAnthropicFormat(); + +function defaultConvertTool(tool: ToolDescription): Record { + return { + name: tool.name, + description: tool.description, + input_schema: tool.parameters, + }; +} + +export function convertAnthropicError( + error: unknown, + convertErrorHook?: (error: unknown) => LlmRemoteErrorMessage | undefined, +): LlmRemoteErrorMessage { + if (isAbortError(error)) { + return toLlmErrorMessage(error); + } + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } + if (error instanceof RawAnthropicSDKConnectionTimeoutError) { + return { kind: 'timeout', message: error.message }; + } + if (error instanceof RawAnthropicSDKConnectionError) { + return { kind: 'connection', message: error.message }; + } + if (error instanceof RawAnthropicSDKAPIError && typeof error.status === 'number') { + return toLlmStatusErrorMessage({ + statusCode: error.status, + message: error.message, + requestId: error.requestID ?? null, + retryAfterMs: parseRetryAfterMs(error.headers), + headers: headersToRecord(error.headers), + }); + } + if (error instanceof Error) { + return toLlmTransportErrorMessage(error.message); + } + return { kind: 'unknown', message: String(error) }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts new file mode 100644 index 00000000000..b3220b3c0c5 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/lower.ts @@ -0,0 +1,202 @@ +import type { Message, TextPart } from '#/llm/message'; +import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; + +export type AnthropicWireContentBlock = + | { type: 'text'; text: string; cache_control?: { type: 'ephemeral' } } + | { + type: 'image'; + source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string }; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'video'; + source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string }; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'thinking'; + thinking: string; + signature?: string; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'tool_use'; + id: string; + name: string; + input: unknown; + cache_control?: { type: 'ephemeral' }; + } + | { + type: 'tool_result'; + tool_use_id: string; + content: AnthropicWireContentBlock[]; + cache_control?: { type: 'ephemeral' }; + }; + +export type AnthropicWireMessage = { + role: 'user' | 'assistant'; + content: AnthropicWireContentBlock[]; +}; + +type AnthropicWireImageBlock = Extract; + +type AnthropicWireVideoBlock = Extract; + +const SUPPORTED_B64_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); + +const SUPPORTED_B64_VIDEO_TYPES = new Set([ + 'video/mp4', + 'video/mpeg', + 'video/quicktime', + 'video/webm', + 'video/x-matroska', + 'video/x-msvideo', + 'video/x-flv', + 'video/3gpp', +]); + +function imageUrlPartToAnthropic(url: string): AnthropicWireImageBlock { + if (url.startsWith('data:')) { + const withoutScheme = url.slice(5); + const parts = withoutScheme.split(';base64,', 2); + if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { + throw new SyntaxRequestFormatError(`Invalid data URL for image: ${url}`); + } + const mediaType = parts[0]; + const data = parts[1]; + if (!SUPPORTED_B64_MEDIA_TYPES.has(mediaType)) { + throw new SyntaxRequestFormatError( + `Unsupported media type for base64 image: ${mediaType}, url: ${url}`, + ); + } + return { + type: 'image', + source: { type: 'base64', data, media_type: mediaType }, + }; + } + return { + type: 'image', + source: { type: 'url', url }, + }; +} + +function videoUrlPartToAnthropic(url: string): AnthropicWireVideoBlock { + if (url.startsWith('data:')) { + const withoutScheme = url.slice(5); + const parts = withoutScheme.split(';base64,', 2); + if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { + throw new SyntaxRequestFormatError(`Invalid data URL for video: ${url}`); + } + const mediaType = parts[0]; + const data = parts[1]; + if (!SUPPORTED_B64_VIDEO_TYPES.has(mediaType)) { + throw new SyntaxRequestFormatError( + `Unsupported media type for base64 video: ${mediaType}, url: ${url}`, + ); + } + return { + type: 'video', + source: { type: 'base64', media_type: mediaType, data }, + }; + } + + return { + type: 'video', + source: { type: 'url', url }, + }; +} + +function parseToolArguments(args: string | null): unknown { + if (args === null || args.trim() === '') { + return {}; + } + try { + return JSON.parse(args); + } catch { + return {}; + } +} + +export function messageContent(message: AnthropicWireMessage): AnthropicWireContentBlock[] { + return Array.isArray(message.content) ? message.content : []; +} + +export interface AnthropicLowerContext { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; +} + +export function lowerMessage( + message: Message, + lower: AnthropicLowerContext, +): AnthropicWireMessage[] { + const { trait, ctx } = lower; + const content: AnthropicWireContentBlock[] = []; + if (message.role === 'system') { + const text = message.content + .filter((part): part is TextPart => part.type === 'text') + .map((part) => part.text) + .join('\n'); + content.push({ type: 'text', text: `${text}` }); + } else if (message.role === 'tool') { + const blocks: AnthropicWireContentBlock[] = []; + for (const part of message.content) { + if (part.type === 'text') { + if (part.text) { + blocks.push({ type: 'text', text: part.text }); + } + } else if (part.type === 'image_url') { + blocks.push(imageUrlPartToAnthropic(part.imageUrl.url)); + } else if (part.type === 'video_url') { + blocks.push(videoUrlPartToAnthropic(part.videoUrl.url)); + } + } + content.push({ + type: 'tool_result', + tool_use_id: message.toolCallId, + content: blocks, + }); + } else { + for (const part of message.content) { + if (part.type === 'think') { + if (part.encrypted !== undefined) { + content.push({ type: 'thinking', thinking: part.think, signature: part.encrypted }); + } else { + content.push({ type: 'thinking', thinking: part.think }); + } + } else if (part.type === 'text') { + content.push({ type: 'text', text: part.text }); + } else if (part.type === 'image_url') { + content.push(imageUrlPartToAnthropic(part.imageUrl.url)); + } else if (part.type === 'video_url') { + content.push(videoUrlPartToAnthropic(part.videoUrl.url)); + } + } + if (message.role === 'assistant') { + for (const toolCall of message.toolCalls) { + content.push({ + type: 'tool_use', + id: toolCall.id, + name: toolCall.name, + input: parseToolArguments(toolCall.arguments), + }); + } + } + } + const converted: AnthropicWireMessage = { + role: message.role === 'assistant' ? 'assistant' : 'user', + content, + }; + const hooked = + trait?.convertMessage === undefined + ? converted + : (trait.convertMessage(message, converted, ctx) as AnthropicWireMessage | null); + if (hooked === null) { + return []; + } + if (messageContent(hooked).length === 0) { + return []; + } + return [hooked]; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/patterns.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/patterns.ts new file mode 100644 index 00000000000..94a0757fccc --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/patterns.ts @@ -0,0 +1,46 @@ +import type { ContentPart, Message } from '#/llm/message'; +import type { Pattern } from '#/llm/protocol/rewrite'; + +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; + +export function stripUnsignedThinking(options: { readonly preserve: boolean }): Pattern { + return { + name: 'stripUnsignedThinking', + rewrite(items, index) { + const message = items[index]; + if (message === undefined) return null; + const content = message.content.filter((part) => { + if (part.type !== 'think') return true; + if (part.encrypted !== undefined) return true; + return options.preserve; + }); + if (content.length === message.content.length) return null; + return { consumed: 1, replacement: [{ ...message, content }] }; + }, + }; +} + +export const audioToPlaceholder: Pattern = { + name: 'audioToPlaceholder', + rewrite(items, index) { + const message = items[index]; + if (message === undefined || message.role === 'system') return null; + let changed = false; + const content: ContentPart[] = []; + for (const part of message.content) { + if (part.type === 'audio_url') { + const last = content.at(-1); + if (last === undefined || last.type !== 'text' || last.text !== OMITTED_AUDIO_PLACEHOLDER) { + content.push({ type: 'text', text: OMITTED_AUDIO_PLACEHOLDER }); + } + changed = true; + } else if (message.role === 'tool' && part.type === 'text' && part.text === '') { + changed = true; + } else { + content.push(part); + } + } + if (!changed) return null; + return { consumed: 1, replacement: [{ ...message, content }] }; + }, +}; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts similarity index 51% rename from packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts rename to packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts index 3f73e9742c5..4959ee9731a 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic-profile.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/profile.ts @@ -1,3 +1,13 @@ +import type { LlmModel } from '#/llm/model'; +import { + ThinkingConfigError, + thinkingMetadataOf, + type ThinkingEffort, + type ThinkingRequestOptions, +} from '#/llm/thinking'; + +export const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; + export type AnthropicThinkingMode = 'budget' | 'adaptive'; export interface AnthropicModelProfile { @@ -97,6 +107,54 @@ export function parseAnthropicModelVersion( return null; } +const CEILING_BY_FAMILY_VERSION: Readonly> = { + 'fable-5': 128000, + 'mythos-5': 128000, + 'opus-4-8': 128000, + 'opus-4-7': 128000, + 'opus-4-6': 128000, + 'opus-4-5': 64000, + 'opus-4-1': 32000, + 'opus-4-0': 32000, + 'opus-4': 32000, + 'sonnet-5': 128000, + 'sonnet-4-6': 128000, + 'sonnet-4-5': 64000, + 'sonnet-4-0': 64000, + 'sonnet-4': 64000, + 'haiku-4-5': 64000, + 'haiku-4': 64000, + 'opus-3-5': 8192, + 'sonnet-3-5': 8192, + 'sonnet-3-7': 8192, + 'haiku-3-5': 8192, + 'opus-3': 4096, + 'sonnet-3': 4096, + 'haiku-3': 4096, +}; + +const FALLBACK_MAX_TOKENS = 128000; + +function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { + const { family, major, minor } = version; + if (minor !== null) { + for (let candidate = minor; candidate >= 0; candidate--) { + const ceiling = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${candidate}`]; + if (ceiling !== undefined) return ceiling; + } + } + return CEILING_BY_FAMILY_VERSION[`${family}-${major}`]; +} + +export function resolveDefaultMaxTokens(model: string, override?: number): number { + const parsed = parseAnthropicModelVersion(model, true); + const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); + if (ceiling === undefined) { + return override ?? FALLBACK_MAX_TOKENS; + } + return override === undefined ? ceiling : Math.min(override, ceiling); +} + export function matchKnownAnthropicModelProfile(model: string): AnthropicModelProfile | undefined { const normalized = model.toLowerCase(); if (/mythos[-._]preview/.test(normalized)) return ALWAYS_ADAPTIVE_MAX_PROFILE; @@ -146,3 +204,93 @@ export function matchUnknownClaudeProfile(model: string): AnthropicModelProfile } const CLAUDE_FAMILY_WORD_RE = /\b(?:opus|sonnet|haiku|fable|mythos)\b/; + +export function shouldPreserveUnsignedThinking(model: string): boolean { + return ( + parseAnthropicModelVersion(model) === null && + matchKnownAnthropicModelProfile(model) === undefined + ); +} + +function requiresAdaptiveThinking(efforts: readonly string[]): boolean { + return efforts.some((effort) => effort !== 'low' && effort !== 'medium' && effort !== 'high'); +} + +export function resolveThinkingProfile(model: LlmModel): AnthropicModelProfile { + const inferred = inferAnthropicModelProfile(model.model); + const meta = thinkingMetadataOf(model); + const supportEfforts = meta?.supportEfforts; + const adaptiveThinking = meta?.adaptiveThinking; + if (adaptiveThinking === false) { + return { + ...inferred, + mode: 'budget', + efforts: supportEfforts ?? BUDGET_THINKING_EFFORTS, + supportsEffortParam: false, + }; + } + if (adaptiveThinking === true) { + return { + ...inferred, + mode: 'adaptive', + efforts: supportEfforts ?? inferred.efforts, + supportsEffortParam: true, + }; + } + if (supportEfforts === undefined) { + return inferred; + } + const adaptive = requiresAdaptiveThinking(supportEfforts); + return { + ...inferred, + mode: adaptive ? 'adaptive' : inferred.mode, + efforts: supportEfforts, + supportsEffortParam: adaptive || inferred.supportsEffortParam, + }; +} + +function budgetTokensForEffort(effort: ThinkingEffort): number | undefined { + if (effort === 'low') return 1024; + if (effort === 'medium') return 4096; + if (effort === 'on' || effort === 'high') return 32_000; + return undefined; +} + +export function encodeThinking( + thinking: ThinkingRequestOptions, + model: LlmModel, +): Record | undefined { + const profile = resolveThinkingProfile(model); + const effort = thinking.effort; + if (effort === 'off') { + if (!profile.canDisableThinking) { + throw new ThinkingConfigError( + 'thinking-cannot-disable', + `Model '${model.model}' always reasons and thinking cannot be turned off. Choose a concrete thinking effort (${profile.efforts.join(', ')}) instead of 'off'.`, + ); + } + const patch: Record = { thinking: { type: 'disabled' } }; + if (profile.mode === 'adaptive') { + patch['betaFeatures'] = []; + } + return patch; + } + if (profile.mode === 'adaptive') { + return { + thinking: { type: 'adaptive', display: 'summarized' }, + output_config: effort === 'on' ? undefined : { effort }, + betaFeatures: [], + }; + } + const budgetTokens = budgetTokensForEffort(effort); + const patch: Record = { + thinking: + budgetTokens === undefined + ? { type: 'enabled' } + : { type: 'enabled', budget_tokens: budgetTokens }, + }; + if ((profile.supportsEffortParam || budgetTokens === undefined) && effort !== 'on') { + patch['output_config'] = { effort }; + } + return patch; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts new file mode 100644 index 00000000000..1cf105112ed --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/anthropic/requester.ts @@ -0,0 +1,192 @@ +import Anthropic from '@anthropic-ai/sdk'; + +import { headersToRecord } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; +import type { ProtocolBase } from '#/llm/protocol/base'; +import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import { + mergeRequestHeaders, + type LlmClientContext, + type LlmRequestConfig, + type LlmRequestContent, + type LlmRequestControl, + type LlmRequester, + type LlmRequesterOptions, + type LlmRequestEvent, + type ToolCallIdPolicy, +} from '#/llm/requester/requester'; + +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, +} from '../tool-call-id'; +import { getAnthropicModelCapability } from './capability'; +import { + createAnthropicFormat, + type AnthropicFormatOptions, + type AnthropicRequestParams, + convertAnthropicError, +} from './format'; + +const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, +}; + +export type AnthropicBaseOptions = AnthropicFormatOptions & LlmRequesterOptions; + +function anthropicCustomHeaderEnvNames(): string[] { + const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; + if (customHeaders === undefined || customHeaders.length === 0) return []; + + const names: string[] = []; + for (const line of customHeaders.split('\n')) { + const colonIndex = line.indexOf(':'); + if (colonIndex < 0) continue; + + const name = line.slice(0, colonIndex).trim().toLowerCase(); + if (name.length > 0) names.push(name); + } + return names; +} + +function buildDefaultHeaders( + headers: Record | undefined, +): Record { + const defaultHeaders: Record = { authorization: null }; + for (const name of anthropicCustomHeaderEnvNames()) { + defaultHeaders[name] = null; + } + for (const [name, value] of Object.entries(headers ?? {})) { + defaultHeaders[name.toLowerCase()] = value; + } + return defaultHeaders; +} + +function createClient(model: LlmModel, headers: Record | undefined): Anthropic { + return new Anthropic({ + apiKey: model.apiKey ?? 'unused', + authToken: null, + baseURL: model.baseUrl ?? null, + defaultHeaders: buildDefaultHeaders(headers), + maxRetries: 0, + }); +} + +interface AnthropicTransport { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; + readonly format: ReturnType; + readonly resolveClient: (request: LlmClientContext) => Anthropic; + readonly signal: AbortSignal; + readonly onEvent?: (event: LlmRequestEvent) => void; +} + +async function internalGenerate( + request: AnthropicRequestParams, + transport: AnthropicTransport, +): Promise { + const { trait, ctx, format, resolveClient, signal, onEvent } = transport; + const client = resolveClient({ + model: ctx.model, + headers: mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + }); + onEvent?.({ type: 'llm.sent' }); + const betaHeaders = + !request.useBetaApi && request.betas.length > 0 + ? { 'anthropic-beta': request.betas.join(',') } + : undefined; + const requestOptions = { signal, headers: betaHeaders }; + const { data: stream, response } = request.useBetaApi + ? await client.beta.messages.create(request.params, requestOptions).withResponse() + : await client.messages.create(request.params, requestOptions).withResponse(); + onEvent?.({ type: 'llm.headers', headers: headersToRecord(response.headers) ?? {} }); + const parse = format.createStreamParser({ trait, ctx }); + let messageId: string | undefined; + for await (const event of stream) { + let failed = false; + parse(event, { + onDelta: (part) => onEvent?.({ type: 'llm.delta', part }), + onFinish: (finish) => onEvent?.({ type: 'llm.finish', finish }), + onMessageId: (id) => { + if (id === messageId) return; + messageId = id; + onEvent?.({ type: 'llm.message-id', messageId: id }); + }, + onUsage: (usage) => onEvent?.({ type: 'llm.usage', usage }), + onError: (message) => { + failed = true; + onEvent?.({ type: 'llm.failed.remote', error: message }); + }, + }); + if (failed) { + return; + } + } + onEvent?.({ type: 'llm.done' }); +} + +export function createAnthropicRequester( + trait?: ProtocolTrait, + options?: AnthropicBaseOptions, +): LlmRequester { + const format = createAnthropicFormat(options); + const resolveClient = + options?.clientFactory ?? + ((request: LlmClientContext) => createClient(request.model, request.headers)); + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const model = resolveModelConnection(config.model, trait); + const { systemPrompt, tools = [] } = config; + const { messages } = content; + const { signal, onEvent } = control; + const ctx: TraitContext = { model }; + let request: AnthropicRequestParams; + try { + const policy = trait?.toolCallIdPolicy?.(ctx) ?? ANTHROPIC_TOOL_CALL_ID_POLICY; + request = format.formatRequest({ + model, + messages: normalizeToolCallIdsForProvider(messages, policy), + systemPrompt, + tools, + trait, + ctx, + cacheKey: config.cacheKey, + thinking: config.thinking, + responseFormat: config.responseFormat, + maxCompletionTokens: config.maxCompletionTokens, + usedContextTokens: content.usedContextTokens, + maxContextTokens: config.maxContextTokens, + extraParams: config.extraParams, + }); + } catch (error) { + onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); + return; + } + try { + await internalGenerate(request, { trait, ctx, format, resolveClient, signal, onEvent }); + } catch (error) { + onEvent?.({ + type: 'llm.failed.remote', + error: convertAnthropicError(error, (e) => trait?.convertError?.(e, ctx)), + }); + } + }, + }; +} + +export function createAnthropicBase(options?: AnthropicBaseOptions): ProtocolBase { + return { + capability: getAnthropicModelCapability, + createRequester: (trait?: ProtocolTrait) => createAnthropicRequester(trait, options), + }; +} + +export const anthropicBase: ProtocolBase = createAnthropicBase(); + +export const anthropicBetaBase: ProtocolBase = createAnthropicBase({ betaApi: true }); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/capability.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/capability.ts new file mode 100644 index 00000000000..5db214fb9d8 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/capability.ts @@ -0,0 +1,37 @@ +const GEMINI_CATALOGUED_PREFIXES = [ + 'gemini-1.5-pro', + 'gemini-1.5-flash', + 'gemini-2.0-flash', + 'gemini-2.0-pro', + 'gemini-2.5-pro', + 'gemini-2.5-flash', +] as const; + +const GEMINI_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ + image_in: true, + video_in: true, + audio_in: true, + thinking: false, + tool_use: true, +}); + +const GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ + image_in: true, + video_in: true, + audio_in: true, + thinking: true, + tool_use: true, +}); + +export function getGoogleGenAIModelCapability(modelName: string) { + const normalized = modelName.toLowerCase(); + if (!normalized.startsWith('gemini-')) return undefined; + if (!GEMINI_CATALOGUED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { + return undefined; + } + + if (normalized.startsWith('gemini-2.5-') || normalized.includes('thinking')) { + return GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY; + } + return GEMINI_MULTIMODAL_TOOL_CAPABILITY; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/extra-params.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/extra-params.ts new file mode 100644 index 00000000000..2fbf255002b --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/extra-params.ts @@ -0,0 +1,11 @@ +export interface GoogleGenAIExtraParams { + readonly temperature?: number; + readonly topP?: number; + readonly topK?: number; + readonly candidateCount?: number; + readonly seed?: number; + readonly stopSequences?: readonly string[]; + readonly presencePenalty?: number; + readonly frequencyPenalty?: number; + readonly thinkingConfig?: { includeThoughts?: boolean; thinkingBudget?: number }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts new file mode 100644 index 00000000000..4de9a8200a5 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/format.ts @@ -0,0 +1,370 @@ +import { ApiError as RawGoogleGenAISDKApiError, type GenerateContentParameters } from '@google/genai'; +import { assign, shake } from 'radashi'; + +import { + isAbortError, + toLlmErrorMessage, + toLlmStatusErrorMessage, + type LlmRemoteErrorMessage, +} from '#/llm/errors'; +import { NO_FINISH, type FinishInfo, type FinishReason } from '#/llm/finish-reason'; +import type { FormatRequestInput, ProtocolFormat } from '#/llm/protocol/format'; +import type { + Message, + StreamedMessagePart, + ThinkPart, + ToolCall, + ToolDescription, +} from '#/llm/message'; +import type { ThinkingEffort } from '#/llm/thinking'; +import { applyThinking } from '#/llm/protocol/trait'; +import { mergeConsecutiveUsers } from '#/llm/protocol/patterns'; +import { applyPatterns } from '#/llm/protocol/rewrite'; +import type { TokenUsage } from '#/llm/usage'; + +import { buildToolNameById, lowerMessage, type GoogleContent } from './lower'; +import { sortToolRunByCallOrder } from './patterns'; + +export type { GoogleContent, GooglePart } from './lower'; + +function toolToGoogleGenAI(tool: ToolDescription): Record { + return { + functionDeclarations: [ + { + name: tool.name, + description: tool.description, + parametersJsonSchema: tool.parameters, + }, + ], + }; +} + +export function messagesToGoogleGenAIContents(messages: readonly Message[]): GoogleContent[] { + const normalized = applyPatterns(messages, [sortToolRunByCallOrder]); + const toolNameById = buildToolNameById(normalized); + const lowered = normalized.flatMap((message) => lowerMessage(message, { toolNameById })); + return applyPatterns(lowered, [ + mergeConsecutiveUsers({ + isUser: (content) => content.role === 'user', + isToolResultOnly: (content) => content.parts[0]?.functionResponse !== undefined, + merge: (last, next) => { + const lastStartsWithFunctionResponse = last.parts[0]?.functionResponse !== undefined; + const nextHasFunctionResponse = next.parts.some( + (part) => part.functionResponse !== undefined, + ); + if (lastStartsWithFunctionResponse && !nextHasFunctionResponse) { + return { ...next, parts: [...next.parts, ...last.parts] }; + } + return { ...last, parts: [...last.parts, ...next.parts] }; + }, + }), + ]); +} + +function extractChunkFinishReason(response: Record): unknown { + const candidates = response['candidates'] as unknown[] | undefined; + const first = candidates?.[0] as Record | undefined; + return first?.['finishReason'] ?? first?.['finish_reason']; +} + +function normalizeFinishReason(raw: unknown): FinishInfo { + if (raw === null || raw === undefined) { + return NO_FINISH; + } + let rawString: string; + if (typeof raw === 'string') { + rawString = raw.toUpperCase(); + } else if (typeof raw === 'number' || typeof raw === 'bigint' || typeof raw === 'boolean') { + rawString = String(raw).toUpperCase(); + } else { + return NO_FINISH; + } + if (rawString === 'FINISH_REASON_UNSPECIFIED' || rawString === '') { + return NO_FINISH; + } + const finishReason: FinishReason = (() => { + switch (rawString) { + case 'STOP': + return 'completed'; + case 'MAX_TOKENS': + return 'truncated'; + case 'SAFETY': + case 'RECITATION': + case 'BLOCKLIST': + case 'PROHIBITED_CONTENT': + case 'SPII': + case 'IMAGE_SAFETY': + return 'filtered'; + default: + return 'other'; + } + })(); + return { finishReason, rawFinishReason: rawString }; +} + +function extractChunkParts(response: Record): StreamedMessagePart[] { + const parts: StreamedMessagePart[] = []; + + const candidates = response['candidates'] as unknown[] | undefined; + for (const candidate of candidates ?? []) { + const cand = candidate as Record; + const content = cand['content'] as Record | undefined; + const contentParts = content?.['parts'] as unknown[] | undefined; + if (!contentParts) continue; + + for (const part of contentParts) { + const p = part as Record; + if (p['thought'] === true && typeof p['text'] === 'string') { + const thoughtSignature = p['thoughtSignature'] ?? p['thought_signature']; + const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; + if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { + thinkPart.encrypted = thoughtSignature; + } + parts.push(thinkPart); + } else if (p['text']) { + parts.push({ type: 'text', text: p['text'] as string }); + } else if (p['functionCall'] || p['function_call']) { + const fc = (p['functionCall'] ?? p['function_call']) as Record; + const name = fc['name'] as string; + if (!name) continue; + const id_ = (fc['id'] as string) ?? crypto.randomUUID(); + const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`; + const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; + const toolCall: ToolCall = { + type: 'function', + id: toolCallId, + name, + arguments: fc['args'] ? JSON.stringify(fc['args']) : '{}', + }; + if (typeof thoughtSigB64 === 'string' && thoughtSigB64.length > 0) { + toolCall.extras = { thought_signature_b64: thoughtSigB64 }; + } + parts.push(toolCall); + } + } + } + + return parts; +} + +function encodeThinking(model: string, effort: ThinkingEffort): Record { + if (model.includes('gemini-3')) { + switch (effort) { + case 'off': + return { includeThoughts: false, thinkingLevel: 'MINIMAL' }; + case 'low': + return { includeThoughts: true, thinkingLevel: 'LOW' }; + case 'medium': + return { includeThoughts: true, thinkingLevel: 'MEDIUM' }; + case 'high': + case 'xhigh': + case 'max': + return { includeThoughts: true, thinkingLevel: 'HIGH' }; + default: + return { includeThoughts: true }; + } + } + switch (effort) { + case 'off': + return { includeThoughts: false, thinkingBudget: 0 }; + case 'low': + return { includeThoughts: true, thinkingBudget: 1024 }; + case 'medium': + return { includeThoughts: true, thinkingBudget: 4096 }; + case 'high': + case 'xhigh': + case 'max': + return { includeThoughts: true, thinkingBudget: 32_000 }; + default: + return { includeThoughts: true }; + } +} + +function resolveRequestKwargs(input: FormatRequestInput): Record { + const { + trait, + ctx, + thinking, + responseFormat, + maxCompletionTokens, + usedContextTokens, + maxContextTokens, + extraParams, + } = input; + let kwargs: Record = {}; + if (thinking !== undefined) { + kwargs = applyThinking(kwargs, thinking, trait, ctx, (t, c) => ({ + thinkingConfig: encodeThinking(c.model.model, t.effort), + })).kwargs; + } + if (maxCompletionTokens !== undefined) { + let cap = maxCompletionTokens; + if ( + usedContextTokens !== undefined && + maxContextTokens !== undefined && + maxContextTokens > 0 + ) { + cap = Math.min(cap, maxContextTokens - usedContextTokens); + } + cap = Math.max(1, cap); + const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); + if (hooked !== undefined) { + kwargs = { ...kwargs, ...hooked }; + } else { + kwargs = { ...kwargs, maxOutputTokens: cap }; + } + } + if (responseFormat !== undefined) { + kwargs['responseMimeType'] = 'application/json'; + delete kwargs['responseSchema']; + delete kwargs['responseJsonSchema']; + if (responseFormat.type === 'json_schema') { + kwargs['responseJsonSchema'] = responseFormat.jsonSchema.schema; + } + } + kwargs = assign(kwargs, extraParams?.googleGenai ?? {}); + kwargs = shake(kwargs); + return kwargs; +} + +export interface GoogleGenAIRequestParams { + readonly params: GenerateContentParameters; + readonly headers?: Record; +} + +export const googleGenAIFormat: ProtocolFormat = { + formatRequest(input) { + const { messages, systemPrompt, tools, trait, ctx } = input; + const kwargs = resolveRequestKwargs(input); + const contents = messagesToGoogleGenAIContents(messages); + const finalContents = trait?.mergeHistory?.(contents, ctx) as GoogleContent[] | undefined; + const params: Record = { + model: ctx.model.model, + contents: finalContents ?? contents, + config: { + systemInstruction: systemPrompt ? systemPrompt : undefined, + tools: + tools.length === 0 + ? undefined + : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? toolToGoogleGenAI(tool)), + ...kwargs, + }, + }; + const finalParams = trait?.buildParams?.(params, ctx) ?? params; + return { params: finalParams as unknown as GenerateContentParameters }; + }, + + createStreamParser() { + return (chunk, sink) => { + const response = chunk as Record; + if (response === null || typeof response !== 'object') { + return; + } + const rawFinish = extractChunkFinishReason(response); + const responseId = response['responseId']; + if (typeof responseId === 'string' && responseId.length > 0) { + sink.onMessageId?.(responseId); + } + const usage = parseUsageMetadata(response); + if (usage !== undefined && rawFinish !== undefined && rawFinish !== null) { + sink.onUsage?.(usage); + } + if (rawFinish !== undefined && rawFinish !== null) { + sink.onFinish(normalizeFinishReason(rawFinish)); + } + for (const part of extractChunkParts(response)) { + sink.onDelta(part); + } + }; + }, +}; + +function parseUsageMetadata(response: Record): TokenUsage | undefined { + const usageMetadata = response['usageMetadata'] as Record | undefined; + if (usageMetadata === undefined || usageMetadata === null) { + return undefined; + } + const promptTokenCount = + typeof usageMetadata['promptTokenCount'] === 'number' + ? usageMetadata['promptTokenCount'] + : 0; + const cachedContentTokenCount = + typeof usageMetadata['cachedContentTokenCount'] === 'number' + ? usageMetadata['cachedContentTokenCount'] + : 0; + const candidatesTokenCount = + typeof usageMetadata['candidatesTokenCount'] === 'number' + ? usageMetadata['candidatesTokenCount'] + : 0; + return { + inputOther: Math.max(promptTokenCount - cachedContentTokenCount, 0), + output: candidatesTokenCount, + inputCacheRead: cachedContentTokenCount, + inputCacheCreation: 0, + raw: usageMetadata, + }; +} + +const NETWORK_RE = /network|connection|connect|disconnect|fetch failed/i; +const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; + +export function convertGoogleGenAIError( + error: unknown, + convertErrorHook?: (error: unknown) => LlmRemoteErrorMessage | undefined, +): LlmRemoteErrorMessage { + if (isAbortError(error)) { + return toLlmErrorMessage(error); + } + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } + if (error instanceof RawGoogleGenAISDKApiError) { + return toLlmStatusErrorMessage({ + statusCode: error.status, + message: error.message, + retryAfterMs: parseRetryInfoDelayMs(error.message), + }); + } + if (error instanceof Error) { + const msg = error.message; + if (TIMEOUT_RE.test(msg)) { + return { kind: 'timeout', message: msg }; + } + if (NETWORK_RE.test(msg) || (error instanceof TypeError && msg.includes('fetch'))) { + return { kind: 'connection', message: msg }; + } + const statusCode = (error as { code?: number }).code; + if (typeof statusCode === 'number') { + return toLlmStatusErrorMessage({ statusCode, message: msg }); + } + return { kind: 'provider', message: `GoogleGenAI error: ${msg}` }; + } + return { kind: 'unknown', message: `GoogleGenAI error: ${String(error)}` }; +} + +function parseRetryInfoDelayMs(message: string): number | null { + const jsonStart = message.indexOf('{'); + if (jsonStart < 0) return null; + try { + const body: unknown = JSON.parse(message.slice(jsonStart)); + if (typeof body !== 'object' || body === null) return null; + const details = (body as { error?: { details?: unknown } }).error?.details; + if (!Array.isArray(details)) return null; + for (const detail of details) { + if (typeof detail !== 'object' || detail === null) continue; + const type = (detail as { '@type'?: unknown })['@type']; + if (typeof type !== 'string' || !type.endsWith('google.rpc.RetryInfo')) continue; + const retryDelay = (detail as { retryDelay?: unknown }).retryDelay; + if (typeof retryDelay !== 'string') continue; + const match = /^(\d+(?:\.\d+)?)s$/.exec(retryDelay.trim()); + if (match?.[1] === undefined) continue; + const seconds = Number.parseFloat(match[1]); + if (!Number.isFinite(seconds) || seconds < 0) continue; + return Math.round(seconds * 1000); + } + return null; + } catch { + return null; + } +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts new file mode 100644 index 00000000000..9d9f62880e2 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/lower.ts @@ -0,0 +1,194 @@ +import type { Message, TextPart } from '#/llm/message'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; + +export type GoogleContent = { + role: 'user' | 'model'; + parts: GooglePart[]; +}; + +export type GooglePart = { + text?: string; + thought?: boolean; + thoughtSignature?: string; + inlineData?: { mimeType: string; data: string }; + fileData?: { fileUri: string; mimeType: string }; + functionCall?: { name: string; args: Record }; + functionResponse?: { + name: string; + response: Record; + parts: GooglePart[]; + }; +}; + +function toolCallIdToName(toolCallId: string, toolNameById: Map): string { + const name = toolNameById.get(toolCallId); + if (name !== undefined) return name; + const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, ''); + const match = /^(.+)_[^_]+$/.exec(withoutEntropy); + return match?.[1] ?? withoutEntropy; +} + +function convertMediaUrl( + url: string, + fallbackMimeType: string, +): + | { inlineData: { mimeType: string; data: string } } + | { fileData: { fileUri: string; mimeType: string } } { + if (url.startsWith('data:')) { + const commaIndex = url.indexOf(','); + if (commaIndex === -1) { + return { fileData: { fileUri: url, mimeType: fallbackMimeType } }; + } + const meta = url.slice(0, commaIndex); + const data = url.slice(commaIndex + 1); + const colonIndex = meta.indexOf(':'); + const semiIndex = meta.indexOf(';'); + const mimeType = + colonIndex !== -1 && semiIndex !== -1 + ? meta.slice(colonIndex + 1, semiIndex) + : fallbackMimeType; + return { inlineData: { mimeType, data } }; + } + let mimeType = fallbackMimeType; + try { + const pathname = new URL(url).pathname.toLowerCase(); + if (pathname.endsWith('.png')) mimeType = 'image/png'; + else if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) mimeType = 'image/jpeg'; + else if (pathname.endsWith('.gif')) mimeType = 'image/gif'; + else if (pathname.endsWith('.webp')) mimeType = 'image/webp'; + else if (pathname.endsWith('.mp3') || pathname.endsWith('.mpeg')) mimeType = 'audio/mpeg'; + else if (pathname.endsWith('.wav')) mimeType = 'audio/wav'; + else if (pathname.endsWith('.ogg')) mimeType = 'audio/ogg'; + } catch {} + return { fileData: { fileUri: url, mimeType } }; +} + +export function buildToolNameById(messages: readonly Message[]): Map { + const toolNameById = new Map(); + for (const message of messages) { + if (message.role !== 'assistant') continue; + for (const toolCall of message.toolCalls) { + toolNameById.set(toolCall.id, toolCall.name); + } + } + return toolNameById; +} + +export interface GoogleGenAILowerContext { + readonly toolNameById: Map; +} + +export function lowerMessage(message: Message, lower: GoogleGenAILowerContext): GoogleContent[] { + const { toolNameById } = lower; + if (message.role === 'tool') { + let textOutput = ''; + const mediaParts: GooglePart[] = []; + for (const part of message.content) { + switch (part.type) { + case 'text': + if (part.text) textOutput += part.text; + break; + case 'image_url': + mediaParts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); + break; + case 'audio_url': + mediaParts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); + break; + case 'video_url': + mediaParts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); + break; + case 'think': + break; + } + } + return [ + { + role: 'user', + parts: [ + { + functionResponse: { + name: toolCallIdToName(message.toolCallId, toolNameById), + response: { output: textOutput }, + parts: [], + }, + }, + ...mediaParts, + ], + }, + ]; + } + + if (message.role === 'system') { + const text = message.content + .filter((part): part is TextPart => part.type === 'text') + .map((part) => part.text) + .join('\n'); + if (text.length === 0) return []; + return [ + { + role: 'user', + parts: [{ text: `${text}` }], + }, + ]; + } + + const role = message.role === 'assistant' ? 'model' : 'user'; + const parts: GooglePart[] = []; + for (const part of message.content) { + switch (part.type) { + case 'text': + parts.push({ text: part.text }); + break; + case 'think': { + const thoughtPart: GooglePart = { text: part.think, thought: true }; + if (part.encrypted !== undefined && part.encrypted.length > 0) { + thoughtPart.thoughtSignature = part.encrypted; + } + parts.push(thoughtPart); + break; + } + case 'image_url': + parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); + break; + case 'audio_url': + parts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); + break; + case 'video_url': + parts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); + break; + } + } + + if (message.role === 'assistant') { + for (const toolCall of message.toolCalls) { + let args: Record = {}; + if (toolCall.arguments) { + let parsed: unknown; + try { + parsed = JSON.parse(toolCall.arguments); + } catch { + throw new SyntaxRequestFormatError('Tool call arguments must be valid JSON.'); + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new SyntaxRequestFormatError('Tool call arguments must be a JSON object.'); + } + args = parsed as Record; + } + + const functionCallPart: GooglePart = { + functionCall: { + name: toolCall.name, + args, + }, + }; + + if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) { + functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string; + } + + parts.push(functionCallPart); + } + } + + return [{ role, parts }]; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/patterns.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/patterns.ts new file mode 100644 index 00000000000..70e910be398 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/patterns.ts @@ -0,0 +1,44 @@ +import type { Message, ToolMessage } from '#/llm/message'; +import type { Pattern } from '#/llm/protocol/rewrite'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; + +export const sortToolRunByCallOrder: Pattern = { + name: 'sortToolRunByCallOrder', + rewrite(items, index) { + const message = items[index]; + if (message === undefined || message.role !== 'assistant' || message.toolCalls.length === 0) { + return null; + } + let end = index + 1; + while (end < items.length && items[end]?.role === 'tool') { + end += 1; + } + if (end === index + 1) return null; + const run = items.slice(index + 1, end) as ToolMessage[]; + const toolMsgById = new Map(); + const seenToolCallIds = new Set(); + for (const toolMsg of run) { + if (seenToolCallIds.has(toolMsg.toolCallId)) { + throw new SyntaxRequestFormatError(`Duplicate tool response for id: ${toolMsg.toolCallId}`); + } + seenToolCallIds.add(toolMsg.toolCallId); + toolMsgById.set(toolMsg.toolCallId, toolMsg); + } + const sorted: ToolMessage[] = []; + for (const toolCall of message.toolCalls) { + const msg = toolMsgById.get(toolCall.id); + if (msg === undefined) { + throw new SyntaxRequestFormatError(`Missing tool responses for ids: ${toolCall.id}`); + } + sorted.push(msg); + toolMsgById.delete(toolCall.id); + } + if (toolMsgById.size > 0) { + throw new SyntaxRequestFormatError( + `Unexpected tool responses for ids: ${JSON.stringify([...toolMsgById.keys()])}`, + ); + } + if (run.every((msg, i) => msg === sorted[i])) return null; + return { consumed: 1 + run.length, replacement: [message, ...sorted] }; + }, +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts new file mode 100644 index 00000000000..d6e70632e4b --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/google-genai/requester.ts @@ -0,0 +1,180 @@ +import { GoogleGenAI as GenAIClient, type GenerateContentParameters } from '@google/genai'; + +import type { LlmModel } from '#/llm/model'; +import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; +import type { ProtocolBase } from '#/llm/protocol/base'; +import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import { + mergeRequestHeaders, + type LlmClientContext, + type LlmRequestConfig, + type LlmRequestContent, + type LlmRequestControl, + type LlmRequester, + type LlmRequesterOptions, + type LlmRequestEvent, +} from '#/llm/requester/requester'; + +import { getGoogleGenAIModelCapability } from './capability'; +import { convertGoogleGenAIError, googleGenAIFormat, type GoogleGenAIRequestParams } from './format'; + +export interface GoogleGenAIBaseOptions extends LlmRequesterOptions { + vertexai?: boolean; +} + +function createClient( + model: LlmModel, + headers: Record | undefined, + vertexai: boolean, +): GenAIClient { + const httpOptions: { headers?: Record; baseUrl?: string } = {}; + if (headers !== undefined) { + httpOptions.headers = headers; + } + if (model.baseUrl !== undefined) { + httpOptions.baseUrl = model.baseUrl; + } + return new GenAIClient({ + apiKey: model.apiKey, + vertexai: vertexai ? true : undefined, + httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, + }); +} + +function createAbortException(): DOMException { + return new DOMException('The operation was aborted.', 'AbortError'); +} + +async function abortPromise(signal: AbortSignal): Promise { + if (signal.aborted) { + throw createAbortException(); + } + return new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => { + reject(createAbortException()); + }, + { once: true }, + ); + }); +} + +interface GoogleGenAITransport { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; + readonly resolveClient: (request: LlmClientContext) => GenAIClient; + readonly signal: AbortSignal; + readonly onEvent?: (event: LlmRequestEvent) => void; +} + +async function internalGenerate( + request: GoogleGenAIRequestParams, + transport: GoogleGenAITransport, +): Promise { + const { trait, ctx, resolveClient, signal, onEvent } = transport; + const client = resolveClient({ + model: ctx.model, + headers: mergeRequestHeaders( + mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + request.headers, + ), + }); + onEvent?.({ type: 'llm.sent' }); + const models = client.models as unknown as { + generateContentStream( + params: GenerateContentParameters, + ): Promise>>; + }; + const stream = await Promise.race([ + models.generateContentStream(request.params), + abortPromise(signal), + ]); + const parse = googleGenAIFormat.createStreamParser({ trait, ctx }); + let messageId: string | undefined; + for await (const chunk of stream) { + if (signal.aborted) { + throw createAbortException(); + } + let failed = false; + parse(chunk, { + onDelta: (part) => onEvent?.({ type: 'llm.delta', part }), + onFinish: (finish) => onEvent?.({ type: 'llm.finish', finish }), + onMessageId: (id) => { + if (id === messageId) return; + messageId = id; + onEvent?.({ type: 'llm.message-id', messageId: id }); + }, + onUsage: (usage) => onEvent?.({ type: 'llm.usage', usage }), + onError: (message) => { + failed = true; + onEvent?.({ type: 'llm.failed.remote', error: message }); + }, + }); + if (failed) { + return; + } + } + onEvent?.({ type: 'llm.done' }); +} + +export function createGoogleGenAIRequester( + trait?: ProtocolTrait, + options?: GoogleGenAIBaseOptions, +): LlmRequester { + const vertexai = options?.vertexai === true; + const resolveClient = + options?.clientFactory ?? + ((request: LlmClientContext) => createClient(request.model, request.headers, vertexai)); + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const model = resolveModelConnection(config.model, trait); + const { systemPrompt, tools = [] } = config; + const { messages } = content; + const { signal, onEvent } = control; + const ctx: TraitContext = { model }; + let request: GoogleGenAIRequestParams; + try { + request = googleGenAIFormat.formatRequest({ + model, + messages, + systemPrompt, + tools, + trait, + ctx, + cacheKey: config.cacheKey, + thinking: config.thinking, + responseFormat: config.responseFormat, + maxCompletionTokens: config.maxCompletionTokens, + usedContextTokens: content.usedContextTokens, + maxContextTokens: config.maxContextTokens, + extraParams: config.extraParams, + }); + } catch (error) { + onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); + return; + } + try { + await internalGenerate(request, { trait, ctx, resolveClient, signal, onEvent }); + } catch (error) { + onEvent?.({ + type: 'llm.failed.remote', + error: convertGoogleGenAIError(error, (e) => trait?.convertError?.(e, ctx)), + }); + } + }, + }; +} + +export function createGoogleGenAIBase(options?: GoogleGenAIBaseOptions): ProtocolBase { + return { + capability: getGoogleGenAIModelCapability, + createRequester: (trait?: ProtocolTrait) => createGoogleGenAIRequester(trait, options), + }; +} + +export const googleGenAIBase: ProtocolBase = createGoogleGenAIBase(); diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/capability.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/capability.ts new file mode 100644 index 00000000000..be2545316f3 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/capability.ts @@ -0,0 +1,18 @@ +import { + hasModelPrefix, + isOpenAIReasoningModel, + OPENAI_REASONING_CAPABILITY, + OPENAI_VISION_TOOL_CAPABILITY, + OPENAI_VISION_TOOL_PREFIXES, +} from '../openai/capability'; + +export function getOpenAIResponsesModelCapability(modelName: string) { + const normalized = modelName.toLowerCase(); + if (isOpenAIReasoningModel(normalized)) { + return OPENAI_REASONING_CAPABILITY; + } + if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { + return OPENAI_VISION_TOOL_CAPABILITY; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/extra-params.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/extra-params.ts new file mode 100644 index 00000000000..df2f900accc --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/extra-params.ts @@ -0,0 +1,13 @@ +export interface OpenAIResponsesExtraParams { + readonly temperature?: number; + readonly top_p?: number; + readonly include?: readonly string[]; + readonly metadata?: Record; + readonly parallel_tool_calls?: boolean; + readonly service_tier?: string; + readonly store?: boolean; + readonly truncation?: 'auto' | 'disabled'; + readonly user?: string; + readonly text?: { verbosity?: 'low' | 'medium' | 'high' }; + readonly reasoning?: { effort?: string; summary?: 'auto' | 'concise' | 'detailed' }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts new file mode 100644 index 00000000000..20118ba3073 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/format.ts @@ -0,0 +1,659 @@ +import type OpenAI from 'openai'; +import { assign, shake } from 'radashi'; + +import type { LlmRemoteErrorMessage } from '#/llm/errors'; +import { NO_FINISH, type FinishInfo } from '#/llm/finish-reason'; +import type { FormatRequestInput, ProtocolFormat, StreamParserOptions } from '#/llm/protocol/format'; +import type { StreamedMessagePart, ToolDescription } from '#/llm/message'; +import { applyThinking } from '#/llm/protocol/trait'; +import type { ResponseFormat } from '#/llm/response-format'; +import { encodeReasoningEffortFallback } from '#/llm/thinking'; +import type { TokenUsage } from '#/llm/usage'; + +import { isContextOverflowErrorCode, isOpenAIInsufficientQuotaCode } from '../openai/format'; +import { lowerMessage, type ResponsesInputItem } from './lower'; + +type RawObject = Record; + +function responseFormatToResponsesText(format: ResponseFormat): RawObject { + if (format.type === 'json_object') { + return { type: 'json_object' }; + } + return { + type: 'json_schema', + name: format.jsonSchema.name, + schema: format.jsonSchema.schema, + strict: format.jsonSchema.strict, + description: format.jsonSchema.description, + }; +} + +export type { ResponsesInputContentItem, ResponsesInputItem } from './lower'; + +type ResponseOutputItemView = + | { + type: 'message'; + content: RawObject[]; + } + | { + type: 'function_call'; + itemId?: string; + callId?: string; + name?: string; + arguments?: string | null; + } + | { + type: 'reasoning'; + encryptedContent?: string; + summary: RawObject[]; + } + | { + type: 'other'; + }; + +function asRawObject(value: unknown): RawObject | null { + if (value === null || typeof value !== 'object' || Array.isArray(value)) { + return null; + } + return value as RawObject; +} + +function readStringField(object: RawObject, key: string): string | undefined { + const value = object[key]; + return typeof value === 'string' ? value : undefined; +} + +function hasOwn(object: RawObject, key: string): boolean { + return Object.prototype.hasOwnProperty.call(object, key); +} + +function readNullableStringField(object: RawObject, key: string): string | null | undefined { + const value = object[key]; + if (value === null) return null; + return typeof value === 'string' ? value : undefined; +} + +function readNumberField(object: RawObject, key: string): number | undefined { + const value = object[key]; + return typeof value === 'number' ? value : undefined; +} + +function readObjectField(object: RawObject, key: string): RawObject | undefined { + return asRawObject(object[key]) ?? undefined; +} + +function readObjectArrayField(object: RawObject, key: string): RawObject[] | undefined { + const value = object[key]; + if (!Array.isArray(value)) return undefined; + return value.flatMap((item) => { + const objectItem = asRawObject(item); + return objectItem === null ? [] : [objectItem]; + }); +} + +function failResponsesDecode(context: string, detail: string): never { + throw new Error(`OpenAI Responses decode error: ${context} ${detail}`); +} + +function requireStringField(object: RawObject, key: string, context: string): string { + const value = readStringField(object, key); + if (value === undefined) { + failResponsesDecode(`${context}.${key}`, 'must be a string.'); + } + return value; +} + +function requireObjectField(object: RawObject, key: string, context: string): RawObject { + const value = readObjectField(object, key); + if (value === undefined) { + failResponsesDecode(`${context}.${key}`, 'must be an object.'); + } + return value; +} + +function readResponseOutputItem(value: unknown, context: string): ResponseOutputItemView { + const item = asRawObject(value); + if (item === null) { + failResponsesDecode(context, 'must be an object.'); + } + + const type = requireStringField(item, 'type', context); + + if (type === 'message') { + return { + type, + content: readObjectArrayField(item, 'content') ?? [], + }; + } + + if (type === 'function_call') { + return { + type, + itemId: readStringField(item, 'id'), + callId: readStringField(item, 'call_id'), + name: readStringField(item, 'name'), + arguments: readNullableStringField(item, 'arguments'), + }; + } + + if (type === 'reasoning') { + return { + type, + encryptedContent: readStringField(item, 'encrypted_content'), + summary: readObjectArrayField(item, 'summary') ?? [], + }; + } + + return { type: 'other' }; +} + +function responseStreamIndex( + itemId: string | undefined, + outputIndex: number | undefined, +): string | number | undefined { + return itemId ?? outputIndex; +} + +function formatResponseStreamIndex(streamIndex: string | number | undefined): string { + return streamIndex === undefined ? '' : String(streamIndex); +} + +function requireFunctionCallName(item: { name?: string }): string { + if (item.name === undefined) { + throw new Error('OpenAI Responses function_call item is missing a name.'); + } + return item.name; +} + +function functionCallId(callId: string | undefined): string { + return callId === undefined || callId.length === 0 ? crypto.randomUUID() : callId; +} + +function formatResponsesErrorEvent( + code: string | null, + message: string, + param: string | null, +): string { + const codeText = code ?? 'unknown'; + const paramText = param === null ? '' : ` (param: ${param})`; + return `${codeText}: ${message}${paramText}`; +} + +const EMBEDDED_STATUS_CODE_RE = /\bstatus_code\s*[:=]\s*(\d{3})\b/; + +function readEmbeddedStatusCode(message: string): number | undefined { + const match = EMBEDDED_STATUS_CODE_RE.exec(message); + return match === null ? undefined : Number(match[1]); +} + +function errorFromOpenAIResponsesEvent( + prefix: string, + code: string | null, + message: string, + param: string | null, +): LlmRemoteErrorMessage { + const formatted = formatResponsesErrorEvent(code, message, param); + const fullMessage = `${prefix}: ${formatted}`; + const statusInfo = { + requestId: null, + retryAfterMs: null, + headers: null, + }; + if (isContextOverflowErrorCode(code)) { + return { kind: 'context_overflow', message: fullMessage, statusCode: 400, ...statusInfo }; + } + if (isOpenAIInsufficientQuotaCode(code)) { + return { kind: 'quota_exhausted', message: fullMessage, statusCode: 429, ...statusInfo }; + } + if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { + return { kind: 'rate_limit', message: fullMessage, statusCode: 429, ...statusInfo }; + } + return { kind: 'provider', message: fullMessage }; +} + +function parseNestedGatewayStreamError(message: string): + | { + code: string | null; + message: string; + param: string | null; + } + | undefined { + const marker = 'received error while streaming:'; + const markerIndex = message.indexOf(marker); + if (markerIndex === -1) return undefined; + + const jsonText = message.slice(markerIndex + marker.length).trim(); + if (jsonText.length === 0) return undefined; + + let parsed: unknown; + try { + parsed = JSON.parse(jsonText); + } catch { + return undefined; + } + + const error = asRawObject(parsed); + if (error === null) return undefined; + + const nestedMessage = readStringField(error, 'message'); + if (nestedMessage === undefined) return undefined; + + return { + code: readNullableStringField(error, 'code') ?? null, + message: nestedMessage, + param: readNullableStringField(error, 'param') ?? null, + }; +} + +function malformedStreamErrorEvent(message: string): LlmRemoteErrorMessage { + const nested = parseNestedGatewayStreamError(message); + if (nested !== undefined) { + return errorFromOpenAIResponsesEvent( + 'OpenAI Responses malformed stream error', + nested.code, + nested.message, + nested.param, + ); + } + + return errorFromOpenAIResponsesEvent( + 'OpenAI Responses malformed stream error', + null, + message, + null, + ); +} + +function readResponsesFailedResponseError(response: RawObject): + | { + code: string | null; + message: string; + } + | undefined { + const error = readObjectField(response, 'error'); + if (error !== undefined) { + const code = readNullableStringField(error, 'code') ?? 'unknown'; + const message = readStringField(error, 'message') ?? 'no message'; + return { code, message }; + } + return undefined; +} + +function formatResponsesFailedResponse(response: RawObject): string { + const error = readResponsesFailedResponseError(response); + if (error !== undefined) { + return formatResponsesErrorEvent(error.code, error.message, null); + } + + const incompleteDetails = readObjectField(response, 'incomplete_details'); + const reason = + incompleteDetails === undefined ? undefined : readStringField(incompleteDetails, 'reason'); + return reason === undefined + ? 'Unknown error (no error details in response)' + : `incomplete: ${reason}`; +} + +function normalizeResponsesFinish( + status: string | undefined, + incompleteReason: string | undefined, +): FinishInfo { + if (status === 'completed') { + return { finishReason: 'completed', rawFinishReason: 'completed' }; + } + if (status === 'incomplete') { + if (incompleteReason === 'max_output_tokens') { + return { finishReason: 'truncated', rawFinishReason: 'max_output_tokens' }; + } + if (incompleteReason === 'content_filter') { + return { finishReason: 'filtered', rawFinishReason: 'content_filter' }; + } + return { finishReason: 'other', rawFinishReason: incompleteReason ?? 'incomplete' }; + } + if (status === 'failed') { + return { finishReason: 'other', rawFinishReason: 'failed' }; + } + return NO_FINISH; +} + +function defaultConvertTool(tool: ToolDescription): Record { + return { + type: 'function', + name: tool.name, + description: tool.description, + parameters: tool.parameters, + strict: false, + }; +} + +function parseResponsesUsage(usage: RawObject | null | undefined): TokenUsage | undefined { + if (usage === null || usage === undefined) { + return undefined; + } + const inputTokens = readNumberField(usage, 'input_tokens') ?? 0; + const outputTokens = readNumberField(usage, 'output_tokens') ?? 0; + const details = readObjectField(usage, 'input_tokens_details'); + const cached = details === undefined ? 0 : (readNumberField(details, 'cached_tokens') ?? 0); + return { + inputOther: inputTokens - cached, + output: outputTokens, + inputCacheRead: cached, + inputCacheCreation: 0, + raw: usage, + }; +} + +function extractEventUsage(event: RawObject): RawObject | undefined { + const type = readStringField(event, 'type'); + if (type === 'response.completed' || type === 'response.incomplete') { + const response = readObjectField(event, 'response'); + return response === undefined ? undefined : readObjectField(response, 'usage'); + } + return readObjectField(event, 'usage'); +} + +function resolveRequestKwargs(input: FormatRequestInput): Record { + const { + trait, + ctx, + cacheKey, + thinking, + responseFormat, + maxCompletionTokens, + usedContextTokens, + maxContextTokens, + extraParams, + } = input; + let kwargs: Record = {}; + if (cacheKey !== undefined) { + kwargs = trait?.cacheKey?.(cacheKey, ctx) ?? { prompt_cache_key: cacheKey }; + } + if (thinking !== undefined) { + kwargs = applyThinking(kwargs, thinking, trait, ctx, (t) => + encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), + ).kwargs; + } + if (maxCompletionTokens !== undefined) { + let cap = maxCompletionTokens; + if ( + usedContextTokens !== undefined && + maxContextTokens !== undefined && + maxContextTokens > 0 + ) { + cap = Math.min(cap, maxContextTokens - usedContextTokens); + } + cap = Math.max(1, cap); + const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); + if (hooked !== undefined) { + kwargs = { ...kwargs, ...hooked }; + } else { + kwargs = { ...kwargs, max_output_tokens: cap }; + } + } + if (responseFormat !== undefined) { + kwargs['text'] = { + ...asRawObject(kwargs['text']), + format: responseFormatToResponsesText(responseFormat), + }; + } + const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; + delete kwargs['reasoning_effort']; + if (reasoningEffort !== undefined) { + kwargs['reasoning'] = { effort: reasoningEffort, summary: 'auto' }; + kwargs['include'] = ['reasoning.encrypted_content']; + } + kwargs = assign(kwargs, extraParams?.responses ?? {}); + kwargs = shake(kwargs); + return kwargs; +} + +export interface OpenAIResponsesRequestParams { + readonly params: OpenAI.Responses.ResponseCreateParamsStreaming; + readonly headers?: Record; +} + +export const openAIResponsesFormat: ProtocolFormat = { + formatRequest(input) { + const { messages, systemPrompt, tools, trait, ctx } = input; + const kwargs = resolveRequestKwargs(input); + const inputItems = messages.flatMap((message) => + lowerMessage(message, { + modelName: ctx.model.model, + extractText: trait?.toolMessageConversion?.(ctx) === 'extract_text', + }), + ); + const finalInput = + (trait?.mergeHistory?.(inputItems, ctx) as ResponsesInputItem[] | undefined) ?? inputItems; + const createParams: Record = { + model: ctx.model.model, + instructions: systemPrompt ? systemPrompt : undefined, + input: finalInput, + tools: + tools.length === 0 + ? undefined + : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool)), + store: false, + stream: true, + ...kwargs, + }; + const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; + return { params: finalParams as unknown as OpenAI.Responses.ResponseCreateParamsStreaming }; + }, + + createStreamParser(options?: StreamParserOptions) { + const functionCallArgumentsByIndex = new Map(); + let unindexedFunctionCallArguments: string | undefined; + + const hasFunctionCallArguments = (streamIndex: number | string | undefined): boolean => + streamIndex === undefined + ? unindexedFunctionCallArguments !== undefined + : functionCallArgumentsByIndex.has(streamIndex); + + const getFunctionCallArguments = (streamIndex: number | string | undefined): string => + streamIndex === undefined + ? (unindexedFunctionCallArguments as string) + : functionCallArgumentsByIndex.get(streamIndex)!; + + const setFunctionCallArguments = ( + streamIndex: number | string | undefined, + argumentsValue: string, + ): void => { + if (streamIndex === undefined) { + unindexedFunctionCallArguments = argumentsValue; + } else { + functionCallArgumentsByIndex.set(streamIndex, argumentsValue); + } + }; + + const appendFunctionCallArguments = ( + streamIndex: number | string | undefined, + argumentsPart: string, + context: string, + ): void => { + if (!hasFunctionCallArguments(streamIndex)) { + failResponsesDecode( + context, + `received function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, + ); + } + setFunctionCallArguments(streamIndex, getFunctionCallArguments(streamIndex) + argumentsPart); + }; + + const finalArgumentsSuffix = ( + streamIndex: number | string | undefined, + finalArguments: string, + context: string, + ): StreamedMessagePart[] => { + if (!hasFunctionCallArguments(streamIndex)) { + failResponsesDecode( + context, + `received final function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, + ); + } + + const accumulatedArguments = getFunctionCallArguments(streamIndex); + if (finalArguments === accumulatedArguments) { + return []; + } + + if (!finalArguments.startsWith(accumulatedArguments)) { + throw new Error( + `OpenAI Responses final function-call arguments for stream index ${formatResponseStreamIndex( + streamIndex, + )} do not match the streamed argument deltas.`, + ); + } + + const suffix = finalArguments.slice(accumulatedArguments.length); + setFunctionCallArguments(streamIndex, finalArguments); + if (suffix.length === 0) { + return []; + } + + return [{ type: 'tool_call_part', argumentsPart: suffix, index: streamIndex }]; + }; + + return (chunk, sink) => { + const event = asRawObject(chunk); + if (event === null) { + return; + } + const hookedUsage = + options?.trait?.extractUsage !== undefined && options.ctx !== undefined + ? options.trait.extractUsage(event, options.ctx) + : undefined; + const usage = parseResponsesUsage( + hookedUsage !== undefined ? hookedUsage : extractEventUsage(event), + ); + if (usage !== undefined) { + sink.onUsage?.(usage); + } + const type = readStringField(event, 'type'); + if (type === undefined) { + if (!hasOwn(event, 'type')) { + const message = readStringField(event, 'message'); + if (message !== undefined) { + sink.onError?.(malformedStreamErrorEvent(message)); + return; + } + } + failResponsesDecode('stream event.type', 'must be a string.'); + } + + switch (type) { + case 'response.output_text.delta': + sink.onDelta({ type: 'text', text: requireStringField(event, 'delta', type) }); + return; + case 'response.output_item.added': { + const item = readResponseOutputItem(event['item'], `${type}.item`); + const outputIndex = readNumberField(event, 'output_index'); + if (item.type !== 'function_call') { + return; + } + const streamIndex = responseStreamIndex(item.itemId, outputIndex); + setFunctionCallArguments(streamIndex, item.arguments ?? ''); + sink.onDelta({ + type: 'function', + id: functionCallId(item.callId), + name: requireFunctionCallName(item), + arguments: item.arguments ?? null, + _streamIndex: streamIndex, + }); + return; + } + case 'response.output_item.done': { + const item = readResponseOutputItem(event['item'], `${type}.item`); + const outputIndex = readNumberField(event, 'output_index'); + if (item.type === 'reasoning') { + sink.onDelta({ type: 'think', think: '', encrypted: item.encryptedContent }); + return; + } + if (item.type === 'function_call' && typeof item.arguments === 'string') { + const streamIndex = responseStreamIndex(item.itemId, outputIndex); + for (const part of finalArgumentsSuffix(streamIndex, item.arguments, type)) { + sink.onDelta(part); + } + } + return; + } + case 'response.function_call_arguments.delta': { + const streamIndex = responseStreamIndex( + readStringField(event, 'item_id'), + readNumberField(event, 'output_index'), + ); + const argumentsPart = requireStringField(event, 'delta', type); + appendFunctionCallArguments(streamIndex, argumentsPart, type); + sink.onDelta({ type: 'tool_call_part', argumentsPart, index: streamIndex }); + return; + } + case 'response.function_call_arguments.done': { + const functionArguments = requireStringField(event, 'arguments', type); + const streamIndex = responseStreamIndex( + readStringField(event, 'item_id'), + readNumberField(event, 'output_index'), + ); + for (const part of finalArgumentsSuffix(streamIndex, functionArguments, type)) { + sink.onDelta(part); + } + return; + } + case 'response.reasoning_summary_part.added': + sink.onDelta({ type: 'think', think: '' }); + return; + case 'response.reasoning_summary_text.delta': + sink.onDelta({ type: 'think', think: requireStringField(event, 'delta', type) }); + return; + case 'response.completed': + case 'response.incomplete': { + const response = readObjectField(event, 'response'); + const messageId = response === undefined ? undefined : readStringField(response, 'id'); + if (messageId !== undefined) { + sink.onMessageId?.(messageId); + } + const status = response === undefined ? undefined : readStringField(response, 'status'); + const incompleteDetails = + response === undefined ? undefined : readObjectField(response, 'incomplete_details'); + const reason = + incompleteDetails === undefined + ? undefined + : readStringField(incompleteDetails, 'reason'); + sink.onFinish(normalizeResponsesFinish(status ?? type.slice('response.'.length), reason)); + return; + } + case 'error': { + const message = requireStringField(event, 'message', type); + sink.onError?.( + errorFromOpenAIResponsesEvent( + 'OpenAI Responses stream error', + readNullableStringField(event, 'code') ?? null, + message, + readNullableStringField(event, 'param') ?? null, + ), + ); + return; + } + case 'response.failed': { + const response = requireObjectField(event, 'response', type); + const error = readResponsesFailedResponseError(response); + if (error !== undefined) { + sink.onError?.( + errorFromOpenAIResponsesEvent( + 'OpenAI Responses response.failed', + error.code, + error.message, + null, + ), + ); + return; + } + sink.onError?.({ + kind: 'provider', + message: `OpenAI Responses response.failed: ${formatResponsesFailedResponse(response)}`, + }); + return; + } + default: + return; + } + }; + }, +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts new file mode 100644 index 00000000000..0ace444ad02 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/lower.ts @@ -0,0 +1,238 @@ +import type { ContentPart, Message } from '#/llm/message'; + +import { convertToolResultToPlainText } from '../tool-result-text'; + +export type ResponsesInputContentItem = + | { type: 'input_text'; text: string } + | { type: 'input_image'; detail?: string; image_url: string } + | { type: 'input_file'; file_data: string; filename: string } + | { type: 'input_file'; file_url: string } + | { type: 'output_text'; text: string; annotations: unknown[] }; + +export type ResponsesInputItem = + | { type: 'message'; role: string; content: ResponsesInputContentItem[] } + | { type: 'function_call'; call_id: string; name: string; arguments: string } + | { type: 'function_call_output'; call_id: string; output: string | ResponsesInputContentItem[] } + | { + type: 'reasoning'; + summary: { type: 'summary_text'; text: string }[]; + encrypted_content?: string; + }; + +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: unsupported audio format)'; +const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; + +function contentPartsToInputItems(parts: readonly ContentPart[]): ResponsesInputContentItem[] { + const items: ResponsesInputContentItem[] = []; + for (const part of parts) { + switch (part.type) { + case 'text': + if (part.text) { + items.push({ type: 'input_text', text: part.text }); + } + break; + case 'image_url': + items.push({ + type: 'input_image', + detail: 'auto', + image_url: part.imageUrl.url, + }); + break; + case 'audio_url': { + const mapped = mapAudioUrlToInputItem(part.audioUrl.url); + items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); + break; + } + case 'video_url': + items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); + break; + case 'think': + break; + } + } + return items; +} + +function contentPartsToOutputItems(parts: readonly ContentPart[]): ResponsesInputContentItem[] { + const items: ResponsesInputContentItem[] = []; + for (const part of parts) { + if (part.type === 'text' && part.text) { + items.push({ type: 'output_text', text: part.text, annotations: [] }); + } + } + return items; +} + +function messageContentToFunctionOutputItems( + content: readonly ContentPart[], +): ResponsesInputContentItem[] { + const items: ResponsesInputContentItem[] = []; + for (const part of content) { + switch (part.type) { + case 'text': + if (part.text) { + items.push({ type: 'input_text', text: part.text }); + } + break; + case 'image_url': + items.push({ type: 'input_image', image_url: part.imageUrl.url }); + break; + case 'audio_url': { + const mapped = mapAudioUrlToInputItem(part.audioUrl.url); + items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); + break; + } + case 'video_url': + items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); + break; + case 'think': + break; + } + } + return items; +} + +function mapAudioUrlToInputItem(url: string): ResponsesInputContentItem | null { + if (url.startsWith('data:audio/')) { + try { + const parts = url.split(',', 2); + if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) return null; + const header = parts[0]; + const b64 = parts[1]; + const subtypePart = header.split('/')[1]; + if (subtypePart === undefined) return null; + const [subtypeHead = ''] = subtypePart.split(';'); + const subtype = subtypeHead.toLowerCase(); + const ext = + subtype === 'mp3' || subtype === 'mpeg' ? 'mp3' : subtype === 'wav' ? 'wav' : null; + if (ext === null) return null; + return { type: 'input_file', file_data: b64, filename: `inline.${ext}` }; + } catch { + return null; + } + } + if (url.startsWith('http://') || url.startsWith('https://')) { + return { type: 'input_file', file_url: url }; + } + return null; +} + +const OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS = new Set([ + 'gpt-4.1', + 'gpt-4.1-mini', + 'gpt-4.1-nano', + 'gpt-5-codex', + 'o1', + 'o1-mini', + 'o1-pro', + 'o3', + 'o3-mini', + 'o3-pro', + 'o4-mini', +]); + +function usesOpenAIResponsesDeveloperRole(modelName: string): boolean { + const normalized = modelName.toLowerCase(); + if (OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS.has(normalized)) return true; + for (const cataloguedModel of OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS) { + if (normalized.startsWith(cataloguedModel + '-')) return true; + } + return false; +} + +export interface OpenAIResponsesLowerContext { + readonly modelName: string; + readonly extractText: boolean; +} + +export function lowerMessage( + message: Message, + lower: OpenAIResponsesLowerContext, +): ResponsesInputItem[] { + const { modelName, extractText } = lower; + if (message.role === 'tool') { + return [ + { + call_id: message.toolCallId, + output: extractText + ? convertToolResultToPlainText(message) + : messageContentToFunctionOutputItems(message.content), + type: 'function_call_output', + }, + ]; + } + + let role: string = message.role; + if (usesOpenAIResponsesDeveloperRole(modelName) && role === 'system') { + role = 'developer'; + } + const result: ResponsesInputItem[] = []; + + if (message.content.length > 0) { + const pendingParts: ContentPart[] = []; + + const flushPendingParts = (): void => { + if (pendingParts.length === 0) return; + if (role === 'assistant') { + result.push({ + content: contentPartsToOutputItems(pendingParts), + role, + type: 'message', + }); + } else { + result.push({ + content: contentPartsToInputItems(pendingParts), + role, + type: 'message', + }); + } + pendingParts.length = 0; + }; + + let i = 0; + const n = message.content.length; + while (i < n) { + const part = message.content[i]; + if (part === undefined) break; + if (part.type === 'think') { + flushPendingParts(); + const encryptedValue = part.encrypted; + const summaries: { type: 'summary_text'; text: string }[] = [ + { type: 'summary_text', text: part.think }, + ]; + i += 1; + while (i < n) { + const nextPart = message.content[i]; + if (nextPart === undefined) break; + if (nextPart.type !== 'think') break; + if (nextPart.encrypted !== encryptedValue) break; + summaries.push({ type: 'summary_text', text: nextPart.think }); + i += 1; + } + result.push({ + summary: summaries, + type: 'reasoning', + encrypted_content: encryptedValue, + }); + } else { + pendingParts.push(part); + i += 1; + } + } + + flushPendingParts(); + } + + if (message.role === 'assistant') { + for (const toolCall of message.toolCalls) { + result.push({ + arguments: toolCall.arguments ?? '{}', + call_id: toolCall.id, + name: toolCall.name, + type: 'function_call', + }); + } + } + + return result; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts new file mode 100644 index 00000000000..36e321eeede --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai-responses/requester.ts @@ -0,0 +1,148 @@ +import OpenAI from 'openai'; + +import { headersToRecord } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; +import type { ProtocolBase } from '#/llm/protocol/base'; +import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import { + mergeRequestHeaders, + type LlmClientContext, + type LlmRequestConfig, + type LlmRequestContent, + type LlmRequestControl, + type LlmRequester, + type LlmRequesterOptions, + type LlmRequestEvent, + type ToolCallIdPolicy, +} from '#/llm/requester/requester'; + +import { + normalizeToolCallIdsForProvider, + sanitizeOpenAIResponsesCallId, +} from '../tool-call-id'; +import { convertOpenAIError } from '../openai/format'; +import { getOpenAIResponsesModelCapability } from './capability'; +import { openAIResponsesFormat, type OpenAIResponsesRequestParams } from './format'; + +const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), + maxLength: 64, +}; + +function createClient(model: LlmModel, headers: Record | undefined): OpenAI { + return new OpenAI({ + apiKey: model.apiKey ?? 'unused', + baseURL: model.baseUrl, + defaultHeaders: headers, + maxRetries: 0, + }); +} + +interface OpenAIResponsesTransport { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; + readonly resolveClient: (request: LlmClientContext) => OpenAI; + readonly signal: AbortSignal; + readonly onEvent?: (event: LlmRequestEvent) => void; +} + +async function internalGenerate( + request: OpenAIResponsesRequestParams, + transport: OpenAIResponsesTransport, +): Promise { + const { trait, ctx, resolveClient, signal, onEvent } = transport; + const client = resolveClient({ + model: ctx.model, + headers: mergeRequestHeaders( + mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + request.headers, + ), + }); + onEvent?.({ type: 'llm.sent' }); + const { data: stream, response } = await client.responses + .create(request.params, { signal }) + .withResponse(); + onEvent?.({ type: 'llm.headers', headers: headersToRecord(response.headers) ?? {} }); + const parse = openAIResponsesFormat.createStreamParser({ trait, ctx }); + let messageId: string | undefined; + for await (const chunk of stream) { + let failed = false; + parse(chunk, { + onDelta: (part) => onEvent?.({ type: 'llm.delta', part }), + onFinish: (finish) => onEvent?.({ type: 'llm.finish', finish }), + onMessageId: (id) => { + if (id === messageId) return; + messageId = id; + onEvent?.({ type: 'llm.message-id', messageId: id }); + }, + onUsage: (usage) => onEvent?.({ type: 'llm.usage', usage }), + onError: (message) => { + failed = true; + onEvent?.({ type: 'llm.failed.remote', error: message }); + }, + }); + if (failed) { + return; + } + } + onEvent?.({ type: 'llm.done' }); +} + +export function createOpenAIResponsesRequester( + trait?: ProtocolTrait, + options?: LlmRequesterOptions, +): LlmRequester { + const resolveClient = + options?.clientFactory ?? + ((request: LlmClientContext) => createClient(request.model, request.headers)); + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const model = resolveModelConnection(config.model, trait); + const { systemPrompt, tools = [] } = config; + const { messages } = content; + const { signal, onEvent } = control; + const ctx: TraitContext = { model }; + let request: OpenAIResponsesRequestParams; + try { + const policy = trait?.toolCallIdPolicy?.(ctx) ?? OPENAI_RESPONSES_TOOL_CALL_ID_POLICY; + request = openAIResponsesFormat.formatRequest({ + model, + messages: normalizeToolCallIdsForProvider(messages, policy), + systemPrompt, + tools, + trait, + ctx, + cacheKey: config.cacheKey, + thinking: config.thinking, + responseFormat: config.responseFormat, + maxCompletionTokens: config.maxCompletionTokens, + usedContextTokens: content.usedContextTokens, + maxContextTokens: config.maxContextTokens, + extraParams: config.extraParams, + toolMessageConversion: config.toolMessageConversion, + }); + } catch (error) { + onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); + return; + } + try { + await internalGenerate(request, { trait, ctx, resolveClient, signal, onEvent }); + } catch (error) { + onEvent?.({ + type: 'llm.failed.remote', + error: convertOpenAIError(error, (e) => trait?.convertError?.(e, ctx)), + }); + } + }, + }; +} + +export const openAIResponsesBase: ProtocolBase = { + capability: getOpenAIResponsesModelCapability, + createRequester: createOpenAIResponsesRequester, +}; diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/capability.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/capability.ts new file mode 100644 index 00000000000..2ec6061ba75 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/capability.ts @@ -0,0 +1,47 @@ +export const OPENAI_REASONING_CAPABILITY = Object.freeze({ + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, +}); + +export const OPENAI_VISION_TOOL_CAPABILITY = Object.freeze({ + image_in: true, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, +}); + +export const OPENAI_TEXT_TOOL_CAPABILITY = Object.freeze({ + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, +}); + +export const OPENAI_VISION_TOOL_PREFIXES = ['gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-4.5'] as const; + +export function isOpenAIReasoningModel(normalizedModelName: string): boolean { + return /^o\d/.test(normalizedModelName); +} + +export function hasModelPrefix(modelName: string, prefixes: readonly string[]): boolean { + return prefixes.some((prefix) => modelName.startsWith(prefix)); +} + +export function getOpenAILegacyModelCapability(modelName: string) { + const normalized = modelName.toLowerCase(); + if (isOpenAIReasoningModel(normalized)) { + return OPENAI_REASONING_CAPABILITY; + } + if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { + return OPENAI_VISION_TOOL_CAPABILITY; + } + if (normalized.startsWith('gpt-3.5-turbo')) { + return OPENAI_TEXT_TOOL_CAPABILITY; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/extra-params.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/extra-params.ts new file mode 100644 index 00000000000..ca0ddb44f95 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/extra-params.ts @@ -0,0 +1,16 @@ +export interface OpenAIExtraParams { + readonly temperature?: number; + readonly top_p?: number; + readonly stop?: string | readonly string[]; + readonly n?: number; + readonly seed?: number; + readonly presence_penalty?: number; + readonly frequency_penalty?: number; + readonly logit_bias?: Record; + readonly logprobs?: boolean; + readonly top_logprobs?: number; + readonly parallel_tool_calls?: boolean; + readonly service_tier?: string; + readonly user?: string; + readonly extra_body?: Record; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts new file mode 100644 index 00000000000..4eb25413dac --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/format.ts @@ -0,0 +1,449 @@ +import OpenAI, { + APIConnectionError as RawOpenAISDKConnectionError, + APIConnectionTimeoutError as RawOpenAISDKConnectionTimeoutError, + APIError as RawOpenAISDKAPIError, + OpenAIError as RawOpenAISDKError, +} from 'openai'; +import { assign, shake } from 'radashi'; + +import { + headersToRecord, + isAbortError, + parseRetryAfterMs, + sanitizeStatusErrorMessage, + toLlmErrorMessage, + toLlmStatusErrorMessage, + toLlmTransportErrorMessage, + type LlmRemoteErrorMessage, +} from '#/llm/errors'; +import { NO_FINISH, type FinishInfo, type FinishReason } from '#/llm/finish-reason'; +import type { + FormatRequestInput, + FormatRequestOptions, + ProtocolFormat, + StreamParserOptions, +} from '#/llm/protocol/format'; +import { + type StreamedMessagePart, + type ToolDescription, +} from '#/llm/message'; +import { toolResultToPlainText } from '#/llm/protocol/patterns'; +import { applyPatterns } from '#/llm/protocol/rewrite'; +import { applyThinking } from '#/llm/protocol/trait'; +import type { ResponseFormat } from '#/llm/response-format'; +import { encodeReasoningEffortFallback } from '#/llm/thinking'; +import type { TokenUsage } from '#/llm/usage'; + +import { lowerMessage, type OpenAIWireMessage } from './lower'; +import { extractToolMedia } from './patterns'; +import { DEFAULT_REASONING_KEY, extractReasoning } from './reasoning-key'; + +function responseFormatToOpenAI(format: ResponseFormat): Record { + if (format.type === 'json_object') { + return { type: 'json_object' }; + } + return { + type: 'json_schema', + json_schema: { + name: format.jsonSchema.name, + schema: format.jsonSchema.schema, + strict: format.jsonSchema.strict, + description: format.jsonSchema.description, + }, + }; +} + +export type { OpenAIContentPart, OpenAIWireMessage, OpenAIWireToolCall } from './lower'; + +type RawUsage = { + prompt_tokens?: number; + completion_tokens?: number; + cached_tokens?: number; + prompt_tokens_details?: { cached_tokens?: number } | null; +}; + +interface RawToolCall { + id?: string; + function?: { name?: string; arguments?: string } | null; +} + +interface RawResponseMessage { + content?: string | null; + reasoning_content?: string | null; + tool_calls?: RawToolCall[]; +} + +interface RawStreamToolCallDelta { + index?: number | string; + id?: string; + function?: { name?: string; arguments?: string } | null; +} + +interface BufferedStreamToolCall { + id?: string; + arguments: string; + emitted: boolean; +} + +type RawChunk = { + id?: string; + choices?: { + delta?: { + content?: string | null; + reasoning_content?: string | null; + tool_calls?: RawStreamToolCallDelta[]; + }; + finish_reason?: string | null; + }[]; + usage?: RawUsage | null; +}; + +type RawResponse = { + choices?: { message?: RawResponseMessage }[]; + usage?: RawUsage | null; +}; + +function normalizeFinishReason(raw: string | null | undefined): FinishInfo { + if (raw === null || raw === undefined) { + return NO_FINISH; + } + const finishReason: FinishReason = (() => { + switch (raw) { + case 'stop': + return 'completed'; + case 'tool_calls': + case 'function_call': + return 'tool_calls'; + case 'length': + return 'truncated'; + case 'content_filter': + return 'filtered'; + default: + return 'other'; + } + })(); + return { finishReason, rawFinishReason: raw }; +} + +function parseRawUsage(usage: RawUsage | null | undefined): TokenUsage | undefined { + if (usage === null || usage === undefined) { + return undefined; + } + const promptTokens = usage.prompt_tokens ?? 0; + const cached = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0; + return { + inputOther: promptTokens - cached, + output: usage.completion_tokens ?? 0, + inputCacheRead: cached, + inputCacheCreation: 0, + raw: usage as Record, + }; +} + +const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; + +function usesMaxCompletionTokens(model: string): boolean { + const normalized = model.toLowerCase(); + return /^o\d(?:$|[-.])/.test(normalized) || /^gpt-5(?:$|[-.])/.test(normalized); +} + +function completionTokenKwargs( + model: string, + maxCompletionTokens: number, +): Record { + return usesMaxCompletionTokens(model) + ? { max_completion_tokens: maxCompletionTokens } + : { max_tokens: maxCompletionTokens }; +} + +interface ResolvedRequestKwargs { + kwargs: Record; + preserveThinking: boolean; +} + +function resolveRequestKwargs(input: FormatRequestInput): ResolvedRequestKwargs { + const { + messages, + trait, + ctx, + cacheKey, + thinking, + responseFormat, + maxCompletionTokens, + usedContextTokens, + maxContextTokens, + extraParams, + } = input; + let kwargs: Record = {}; + if (cacheKey !== undefined) { + kwargs = trait?.cacheKey?.(cacheKey, ctx) ?? { prompt_cache_key: cacheKey }; + } + let preserveThinking = false; + if (thinking !== undefined) { + const applied = applyThinking(kwargs, thinking, trait, ctx, (t) => + encodeReasoningEffortFallback(t, ctx.model, trait?.strictThinkingValidation === true), + ); + kwargs = applied.kwargs; + preserveThinking = applied.preserveThinking; + } + if ( + trait?.withThinking === undefined && + thinking?.effort !== 'off' && + kwargs['reasoning_effort'] === undefined && + messages.some((message) => message.content.some((part) => part.type === 'think')) + ) { + kwargs = { ...kwargs, reasoning_effort: 'medium' }; + } + if (responseFormat !== undefined) { + kwargs = { ...kwargs, response_format: responseFormatToOpenAI(responseFormat) }; + } + if (maxCompletionTokens !== undefined) { + let cap = maxCompletionTokens; + if ( + usedContextTokens !== undefined && + maxContextTokens !== undefined && + maxContextTokens > 0 + ) { + cap = Math.min(cap, maxContextTokens - usedContextTokens); + } + cap = Math.max(1, cap); + const hooked = trait?.withMaxCompletionTokens?.(cap, ctx); + if (hooked !== undefined) { + kwargs = { ...kwargs, ...hooked }; + } else { + const capped = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); + kwargs = { ...kwargs, ...completionTokenKwargs(ctx.model.model, Math.max(1, capped)) }; + } + } + kwargs = assign(kwargs, extraParams?.openai ?? {}); + kwargs = shake(kwargs); + return { kwargs, preserveThinking }; +} + +export interface OpenAIRequestParams { + readonly params: OpenAI.Chat.ChatCompletionCreateParamsStreaming; + readonly headers?: Record; +} + +export const openAIFormat: ProtocolFormat = { + formatRequest(input, options?: FormatRequestOptions) { + const { messages, systemPrompt, tools, trait, ctx } = input; + const reasoningKey = options?.reasoningKey ?? DEFAULT_REASONING_KEY; + const { kwargs, preserveThinking } = resolveRequestKwargs(input); + + const conversion = trait?.toolMessageConversion?.(ctx); + const mediaPattern = + conversion === 'extract_text' + ? toolResultToPlainText + : conversion === 'keep_parts' + ? undefined + : extractToolMedia; + const normalized = + mediaPattern === undefined ? messages : applyPatterns(messages, [mediaPattern]); + const converted: OpenAIWireMessage[] = []; + if (systemPrompt) { + converted.push({ role: 'system', content: systemPrompt }); + } + for (const message of normalized) { + converted.push(...lowerMessage(message, { trait, ctx, reasoningKey, preserveThinking })); + } + const finalMessages = + (trait?.mergeHistory?.(converted, ctx) as OpenAIWireMessage[] | undefined) ?? converted; + const createParams: Record = { + model: ctx.model.model, + messages: finalMessages, + tools: + tools.length === 0 + ? undefined + : tools.map((tool) => trait?.convertTool?.(tool, ctx) ?? defaultConvertTool(tool)), + stream: true, + stream_options: { include_usage: true }, + ...kwargs, + }; + const finalParams = trait?.buildParams?.(createParams, ctx) ?? createParams; + return { params: finalParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsStreaming }; + }, + + createStreamParser(options?: StreamParserOptions) { + const bufferedToolCalls = new Map(); + + function convertStreamToolCall(toolCall: RawStreamToolCallDelta): StreamedMessagePart[] { + if (toolCall.function === undefined || toolCall.function === null) { + return []; + } + const streamIndex = toolCall.index; + const functionName = toolCall.function.name; + const functionArguments = toolCall.function.arguments; + const hasConcreteName = typeof functionName === 'string' && functionName.length > 0; + const hasArguments = typeof functionArguments === 'string' && functionArguments.length > 0; + + if (streamIndex === undefined) { + if (hasConcreteName) { + return [ + { + type: 'function', + id: toolCall.id ?? crypto.randomUUID(), + name: functionName, + arguments: functionArguments ?? null, + }, + ]; + } + if (hasArguments) { + return [{ type: 'tool_call_part', argumentsPart: functionArguments }]; + } + return []; + } + + const buffered = bufferedToolCalls.get(streamIndex) ?? { arguments: '', emitted: false }; + if (toolCall.id !== undefined) { + buffered.id = toolCall.id; + } + if (!buffered.emitted) { + if (!hasConcreteName) { + if (hasArguments) { + buffered.arguments += functionArguments; + } + bufferedToolCalls.set(streamIndex, buffered); + return []; + } + buffered.emitted = true; + const initialArguments = + buffered.arguments.length > 0 + ? buffered.arguments + (functionArguments ?? '') + : (functionArguments ?? null); + buffered.arguments = ''; + bufferedToolCalls.set(streamIndex, buffered); + return [ + { + type: 'function', + id: buffered.id ?? toolCall.id ?? crypto.randomUUID(), + name: functionName, + arguments: initialArguments, + _streamIndex: streamIndex, + }, + ]; + } + if (!hasArguments) { + return []; + } + return [{ type: 'tool_call_part', argumentsPart: functionArguments, index: streamIndex }]; + } + + return (chunk, sink) => { + if (typeof chunk.id === 'string' && chunk.id.length > 0) { + sink.onMessageId?.(chunk.id); + } + const hooked = + options?.trait?.extractUsage !== undefined && options.ctx !== undefined + ? options.trait.extractUsage(chunk as Record, options.ctx) + : undefined; + const usage = parseRawUsage( + (hooked !== undefined ? hooked : chunk.usage) as RawUsage | null | undefined, + ); + if (usage !== undefined) { + sink.onUsage?.(usage); + } + const choice = chunk.choices?.[0]; + if (choice?.finish_reason !== undefined && choice.finish_reason !== null) { + sink.onFinish(normalizeFinishReason(choice.finish_reason)); + } + const delta = choice?.delta; + if (!delta) { + return; + } + const reasoning = extractReasoning(delta); + if (reasoning !== undefined) { + sink.onDelta({ type: 'think', think: reasoning.value }); + } + if (typeof delta.content === 'string' && delta.content.length > 0) { + sink.onDelta({ type: 'text', text: delta.content }); + } + for (const toolCall of delta.tool_calls ?? []) { + for (const part of convertStreamToolCall(toolCall)) { + sink.onDelta(part); + } + } + }; + }, +}; + +function defaultConvertTool(tool: ToolDescription): Record { + return { + type: 'function', + function: { + name: tool.name, + description: tool.description, + parameters: tool.parameters, + }, + }; +} + +export function isOpenAIInsufficientQuotaCode(code: string | null | undefined): boolean { + return code === 'insufficient_quota'; +} + +export function isContextOverflowErrorCode(code: string | null | undefined): boolean { + return code === 'context_length_exceeded'; +} + +function isOpenAIInsufficientQuotaError(error: RawOpenAISDKAPIError): boolean { + if (error.status !== 429) return false; + if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; + if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; + return error.message.toLowerCase().includes('insufficient_quota'); +} + +export function convertOpenAIError( + error: unknown, + convertErrorHook?: (error: unknown) => LlmRemoteErrorMessage | undefined, +): LlmRemoteErrorMessage { + if (isAbortError(error)) { + return toLlmErrorMessage(error); + } + const hooked = convertErrorHook?.(error); + if (hooked !== undefined) { + return hooked; + } + if (error instanceof RawOpenAISDKConnectionTimeoutError) { + return { kind: 'timeout', message: error.message }; + } + if (error instanceof RawOpenAISDKConnectionError) { + return { kind: 'connection', message: error.message }; + } + if (error instanceof RawOpenAISDKAPIError && typeof error.status === 'number') { + const requestId = error.requestID ?? null; + const retryAfterMs = parseRetryAfterMs(error.headers); + const headers = headersToRecord(error.headers); + if (isOpenAIInsufficientQuotaError(error)) { + return { + kind: 'quota_exhausted', + message: sanitizeStatusErrorMessage(error.message), + statusCode: 429, + requestId, + retryAfterMs, + headers, + }; + } + return toLlmStatusErrorMessage({ + statusCode: error.status, + message: error.message, + requestId, + retryAfterMs, + headers, + }); + } + if ( + error instanceof RawOpenAISDKAPIError && + error.constructor === RawOpenAISDKAPIError && + error.error === undefined + ) { + return toLlmTransportErrorMessage(error.message); + } + if (error instanceof RawOpenAISDKError) { + return { kind: 'provider', message: `Error: ${error.message}` }; + } + if (error instanceof Error) { + return toLlmTransportErrorMessage(error.message); + } + return { kind: 'unknown', message: String(error) }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts new file mode 100644 index 00000000000..865db526b9b --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/lower.ts @@ -0,0 +1,151 @@ +import { extractText, type ContentPart, type Message } from '#/llm/message'; +import type { ProtocolTrait, TraitContext } from '#/llm/protocol/trait'; + +import { TOOL_RESULT_MEDIA_PLACEHOLDER } from './patterns'; + +export type OpenAIContentPart = { + type: 'text' | 'image_url' | 'audio_url' | 'video_url'; + text?: string | undefined; + image_url?: { url: string; id?: string | null } | undefined; + audio_url?: { url: string; id?: string | null } | undefined; + video_url?: { url: string; id?: string | null } | undefined; +}; + +export type OpenAIWireToolCall = { + id: string; + type: 'function'; + function: { name: string; arguments: string }; +}; + +export type OpenAIWireMessage = + | { role: 'system' | 'user'; content: string | OpenAIContentPart[] } + | { + role: 'assistant'; + content: string | OpenAIContentPart[] | null; + tool_calls?: OpenAIWireToolCall[]; + } + | { role: 'tool'; tool_call_id: string; content: string | OpenAIContentPart[] }; + +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; +const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; + +function convertContentPart(part: ContentPart): OpenAIContentPart | null { + switch (part.type) { + case 'text': + return { type: 'text', text: part.text }; + case 'think': + return null; + case 'image_url': + return { + type: 'image_url', + image_url: + part.imageUrl.id === undefined + ? { url: part.imageUrl.url } + : { url: part.imageUrl.url, id: part.imageUrl.id }, + }; + case 'audio_url': + return { + type: 'audio_url', + audio_url: + part.audioUrl.id === undefined + ? { url: part.audioUrl.url } + : { url: part.audioUrl.url, id: part.audioUrl.id }, + }; + case 'video_url': + return { + type: 'video_url', + video_url: + part.videoUrl.id === undefined + ? { url: part.videoUrl.url } + : { url: part.videoUrl.url, id: part.videoUrl.id }, + }; + } +} + +function convertToolMessageMediaText(message: Message): string { + const text = extractText(message); + const lines: string[] = text.length > 0 ? [text] : []; + if (message.content.some((part) => part.type === 'audio_url')) { + lines.push(OMITTED_AUDIO_PLACEHOLDER); + } + if ( + message.content.some( + (part) => part.type === 'video_url' && part.videoUrl.url.startsWith('data:'), + ) + ) { + lines.push(OMITTED_VIDEO_PLACEHOLDER); + } + if (lines.length === 0 && message.content.some((part) => part.type === 'image_url')) { + return TOOL_RESULT_MEDIA_PLACEHOLDER; + } + return lines.join('\n'); +} + +export interface OpenAILowerContext { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; + readonly reasoningKey: string; + readonly preserveThinking: boolean; +} + +export function lowerMessage(message: Message, lower: OpenAILowerContext): OpenAIWireMessage[] { + const { trait, ctx, reasoningKey, preserveThinking } = lower; + let reasoningContent = ''; + let hasReasoningPart = false; + const nonThinkParts: ContentPart[] = []; + for (const part of message.content) { + if (part.type === 'think') { + hasReasoningPart = true; + reasoningContent += part.think; + } else { + nonThinkParts.push(part); + } + } + let content: string | OpenAIContentPart[] | undefined; + if (message.role === 'tool' && trait?.toolMessageConversion?.(ctx) !== 'keep_parts') { + content = message.content.some((part) => part.type !== 'text' && part.type !== 'think') + ? convertToolMessageMediaText(message) + : extractText(message); + } else { + const firstPart = nonThinkParts[0]; + if (nonThinkParts.length === 1 && firstPart?.type === 'text') { + content = firstPart.text; + } else if (nonThinkParts.length > 0) { + content = nonThinkParts + .map((part) => convertContentPart(part)) + .filter((part): part is OpenAIContentPart => part !== null); + } + } + let converted: OpenAIWireMessage; + if (message.role === 'assistant') { + converted = { + role: 'assistant', + content: + content !== undefined + ? content + : hasReasoningPart && message.toolCalls.length === 0 + ? '' + : null, + tool_calls: + message.toolCalls.length > 0 + ? message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + type: 'function' as const, + function: { name: toolCall.name, arguments: toolCall.arguments ?? '' }, + })) + : undefined, + }; + } else if (message.role === 'tool') { + converted = { role: 'tool', tool_call_id: message.toolCallId, content: content ?? '' }; + } else { + converted = { role: message.role, content: content ?? '' }; + } + if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { + (converted as Record)[reasoningKey] = reasoningContent; + } + const hooked = + trait?.convertMessage === undefined + ? converted + : (trait.convertMessage(message, converted, ctx) as OpenAIWireMessage | null); + return hooked === null ? [] : [hooked]; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/patterns.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/patterns.ts new file mode 100644 index 00000000000..fc9ddaacc6f --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/patterns.ts @@ -0,0 +1,55 @@ +import type { ContentPart, Message, ThinkPart, ToolMessage, UserMessage } from '#/llm/message'; +import type { Pattern } from '#/llm/protocol/rewrite'; + +export const TOOL_RESULT_MEDIA_PROMPT = 'Attached media from tool result:'; +export const TOOL_RESULT_MEDIA_PLACEHOLDER = '(see attached media)'; + +function isExtractableMedia(part: ContentPart): boolean { + if (part.type === 'image_url') return true; + return part.type === 'video_url' && !part.videoUrl.url.startsWith('data:'); +} + +export const extractToolMedia: Pattern = { + name: 'extractToolMedia', + rewrite(items, index) { + const first = items[index]; + if (first === undefined || first.role !== 'tool') return null; + let end = index; + while (end < items.length && items[end]?.role === 'tool') { + end += 1; + } + const run = items.slice(index, end) as ToolMessage[]; + const media: ContentPart[] = []; + for (const message of run) { + for (const part of message.content) { + if (isExtractableMedia(part)) { + media.push(part); + } + } + } + if (media.length === 0) return null; + const stripped = run.map((message) => { + const content = message.content.filter((part) => !isExtractableMedia(part)); + const hadImage = message.content.some((part) => part.type === 'image_url'); + const hasText = content.some((part) => part.type === 'text' && part.text.length > 0); + const hasAudio = content.some((part) => part.type === 'audio_url'); + const hasDataVideo = content.some((part) => part.type === 'video_url'); + if (!hasText && !hasAudio && !hasDataVideo && hadImage) { + return { + ...message, + content: [ + { type: 'text', text: TOOL_RESULT_MEDIA_PLACEHOLDER } as ContentPart, + ...message.content.filter((part): part is ThinkPart => part.type === 'think'), + ], + }; + } + if (content.length === message.content.length) return message; + return { ...message, content }; + }); + const mediaUser: UserMessage = { + role: 'user', + content: [{ type: 'text', text: TOOL_RESULT_MEDIA_PROMPT }, ...media], + }; + return { consumed: run.length, replacement: [...stripped, mediaUser] }; + }, +}; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/provider/bases/openai/reasoning-key.ts rename to packages/agent-core-v2/src/human/llm/requester/bases/openai/reasoning-key.ts diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts new file mode 100644 index 00000000000..72b8011ad79 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/openai/requester.ts @@ -0,0 +1,165 @@ +import OpenAI from 'openai'; + +import { headersToRecord } from '#/llm/errors'; +import { modelKey, type LlmModel } from '#/llm/model'; +import { toLlmSyntaxErrorMessage } from '#/llm/syntax-errors'; +import type { ProtocolBase } from '#/llm/protocol/base'; +import { resolveModelConnection, type ProtocolTrait, type TraitContext } from '#/llm/protocol/trait'; +import { + mergeRequestHeaders, + type LlmClientContext, + type LlmRequestConfig, + type LlmRequestContent, + type LlmRequestControl, + type LlmRequester, + type LlmRequesterOptions, + type LlmRequestEvent, + type ToolCallIdPolicy, +} from '#/llm/requester/requester'; + +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, +} from '../tool-call-id'; +import { getOpenAILegacyModelCapability } from './capability'; +import { convertOpenAIError, openAIFormat, type OpenAIRequestParams } from './format'; +import { ReasoningKeyDialect } from './reasoning-key'; + +const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, +}; + +function createClient(model: LlmModel, headers: Record | undefined): OpenAI { + return new OpenAI({ + apiKey: model.apiKey ?? 'unused', + baseURL: model.baseUrl, + defaultHeaders: headers, + maxRetries: 0, + }); +} + +interface OpenAITransport { + readonly trait: ProtocolTrait | undefined; + readonly ctx: TraitContext; + readonly dialect: ReasoningKeyDialect; + readonly resolveClient: (request: LlmClientContext) => OpenAI; + readonly signal: AbortSignal; + readonly onEvent?: (event: LlmRequestEvent) => void; +} + +async function internalGenerate( + request: OpenAIRequestParams, + transport: OpenAITransport, +): Promise { + const { trait, ctx, dialect, resolveClient, signal, onEvent } = transport; + const client = resolveClient({ + model: ctx.model, + headers: mergeRequestHeaders( + mergeRequestHeaders(trait?.defaultHeaders?.(ctx), ctx.model.defaultHeaders), + request.headers, + ), + }); + onEvent?.({ type: 'llm.sent' }); + const { data: stream, response } = await client.chat.completions + .create(request.params, { signal }) + .withResponse(); + onEvent?.({ type: 'llm.headers', headers: headersToRecord(response.headers) ?? {} }); + const parse = openAIFormat.createStreamParser({ trait, ctx }); + let messageId: string | undefined; + for await (const chunk of stream) { + dialect.observe(chunk.choices?.[0]?.delta); + let failed = false; + parse(chunk, { + onDelta: (part) => onEvent?.({ type: 'llm.delta', part }), + onFinish: (finish) => onEvent?.({ type: 'llm.finish', finish }), + onMessageId: (id) => { + if (id === messageId) return; + messageId = id; + onEvent?.({ type: 'llm.message-id', messageId: id }); + }, + onUsage: (usage) => onEvent?.({ type: 'llm.usage', usage }), + onError: (message) => { + failed = true; + onEvent?.({ type: 'llm.failed.remote', error: message }); + }, + }); + if (failed) { + return; + } + } + onEvent?.({ type: 'llm.done' }); +} + +export function createOpenAIRequester( + trait?: ProtocolTrait, + options?: LlmRequesterOptions, +): LlmRequester { + const resolveClient = + options?.clientFactory ?? + ((request: LlmClientContext) => createClient(request.model, request.headers)); + const dialects = new Map(); + const dialectFor = (ctx: TraitContext): ReasoningKeyDialect => { + const key = modelKey(ctx.model); + let dialect = dialects.get(key); + if (dialect === undefined) { + dialect = new ReasoningKeyDialect(trait?.reasoningKey?.(ctx)); + dialects.set(key, dialect); + } + return dialect; + }; + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const model = resolveModelConnection(config.model, trait); + const { systemPrompt, tools = [] } = config; + const { messages } = content; + const { signal, onEvent } = control; + const ctx: TraitContext = { model }; + let dialect: ReasoningKeyDialect; + let request: OpenAIRequestParams; + try { + dialect = dialectFor(ctx); + const policy = trait?.toolCallIdPolicy?.(ctx) ?? OPENAI_CHAT_TOOL_CALL_ID_POLICY; + request = openAIFormat.formatRequest( + { + model, + messages: normalizeToolCallIdsForProvider(messages, policy), + systemPrompt, + tools, + trait, + ctx, + cacheKey: config.cacheKey, + thinking: config.thinking, + responseFormat: config.responseFormat, + maxCompletionTokens: config.maxCompletionTokens, + usedContextTokens: content.usedContextTokens, + maxContextTokens: config.maxContextTokens, + extraParams: config.extraParams, + toolMessageConversion: config.toolMessageConversion, + }, + { reasoningKey: dialect.outboundKey() }, + ); + } catch (error) { + onEvent?.({ type: 'llm.failed.syntax', error: toLlmSyntaxErrorMessage(error) }); + return; + } + try { + await internalGenerate(request, { trait, ctx, dialect, resolveClient, signal, onEvent }); + } catch (error) { + onEvent?.({ + type: 'llm.failed.remote', + error: convertOpenAIError(error, (e) => trait?.convertError?.(e, ctx)), + }); + } + }, + }; +} + +export const openAIBase: ProtocolBase = { + capability: getOpenAILegacyModelCapability, + createRequester: createOpenAIRequester, +}; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts b/packages/agent-core-v2/src/human/llm/requester/bases/tool-call-id.ts similarity index 71% rename from packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts rename to packages/agent-core-v2/src/human/llm/requester/bases/tool-call-id.ts index d6b174bce01..8555ddcc84a 100644 --- a/packages/agent-core-v2/src/kosong/provider/bases/tool-call-id.ts +++ b/packages/agent-core-v2/src/human/llm/requester/bases/tool-call-id.ts @@ -1,6 +1,6 @@ -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { Message, ToolCall } from '#/kosong/contract/message'; -import type { ToolCallIdPolicy } from '#/kosong/contract/provider'; +import type { Message, ToolCall } from '#/llm/message'; +import type { ToolCallIdPolicy } from '#/llm/requester/requester'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; const EMPTY_TOOL_CALL_ID = 'tool_call'; const TOOL_CALL_ID_SAFE_CHARS = /[^a-zA-Z0-9_-]/g; @@ -16,43 +16,40 @@ export function sanitizeOpenAIResponsesCallId(id: string, maxLength?: number): s } export function normalizeToolCallIdsForProvider( - messages: Message[], + messages: readonly Message[], policy: ToolCallIdPolicy, ): Message[] { const rawIds = collectToolCallIds(messages); - if (rawIds.length === 0) return messages; + if (rawIds.length === 0) return [...messages]; const mappedIds = buildToolCallIdMap(rawIds, policy); let changed = false; const normalizedMessages = messages.map((message) => { - let messageChanged = false; - let toolCalls = message.toolCalls; - - if (message.toolCalls.length > 0) { - toolCalls = message.toolCalls.map((toolCall) => { + if (message.role === 'assistant') { + let messageChanged = false; + const toolCalls = message.toolCalls.map((toolCall) => { const mappedId = mappedIds.get(toolCall.id); if (mappedId === undefined || mappedId === toolCall.id) return toolCall; messageChanged = true; return { ...toolCall, id: mappedId } satisfies ToolCall; }); + if (!messageChanged) return message; + changed = true; + return { ...message, toolCalls }; } - - const toolCallId = - message.toolCallId === undefined ? undefined : mappedIds.get(message.toolCallId); - const mappedToolCallId = toolCallId ?? message.toolCallId; - if (mappedToolCallId !== message.toolCallId) { - messageChanged = true; + if (message.role === 'tool') { + const mappedToolCallId = mappedIds.get(message.toolCallId) ?? message.toolCallId; + if (mappedToolCallId === message.toolCallId) return message; + changed = true; + return { ...message, toolCallId: mappedToolCallId }; } - - if (!messageChanged) return message; - changed = true; - return { ...message, toolCalls, toolCallId: mappedToolCallId }; + return message; }); - return changed ? normalizedMessages : messages; + return changed ? normalizedMessages : [...messages]; } -function collectToolCallIds(messages: Message[]): string[] { +function collectToolCallIds(messages: readonly Message[]): string[] { const ids: string[] = []; const seen = new Set(); const append = (id: string): void => { @@ -62,10 +59,12 @@ function collectToolCallIds(messages: Message[]): string[] { }; for (const message of messages) { - for (const toolCall of message.toolCalls) { - append(toolCall.id); + if (message.role === 'assistant') { + for (const toolCall of message.toolCalls) { + append(toolCall.id); + } } - if (message.toolCallId !== undefined) { + if (message.role === 'tool') { append(message.toolCallId); } } @@ -116,7 +115,7 @@ function truncateToolCallId(base: string, maxLength: number | undefined, suffix: if (maxLength === undefined) return `${base}${suffix}`; const baseLength = maxLength - suffix.length; if (baseLength <= 0) { - throw new BugIndicatingError( + throw new SyntaxRequestFormatError( `Tool call id maxLength ${maxLength} is too small for suffix ${suffix}.`, ); } diff --git a/packages/agent-core-v2/src/human/llm/requester/bases/tool-result-text.ts b/packages/agent-core-v2/src/human/llm/requester/bases/tool-result-text.ts new file mode 100644 index 00000000000..164dab7a1ae --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/bases/tool-result-text.ts @@ -0,0 +1,23 @@ +import { extractText, type Message } from '#/llm/message'; + +const OMITTED_IMAGE_PLACEHOLDER = '(image omitted: tool result converted to plain text)'; +const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: tool result converted to plain text)'; +const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: tool result converted to plain text)'; + +export function convertToolResultToPlainText(message: Message): string { + const lines: string[] = []; + const text = extractText(message); + if (text.length > 0) { + lines.push(text); + } + if (message.content.some((part) => part.type === 'image_url')) { + lines.push(OMITTED_IMAGE_PLACEHOLDER); + } + if (message.content.some((part) => part.type === 'audio_url')) { + lines.push(OMITTED_AUDIO_PLACEHOLDER); + } + if (message.content.some((part) => part.type === 'video_url')) { + lines.push(OMITTED_VIDEO_PLACEHOLDER); + } + return lines.join('\n'); +} diff --git a/packages/agent-core-v2/src/human/llm/requester/empty-response.ts b/packages/agent-core-v2/src/human/llm/requester/empty-response.ts new file mode 100644 index 00000000000..d65f0a11dde --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/empty-response.ts @@ -0,0 +1,103 @@ +import type { LlmErrorMessage } from '#/llm/errors'; +import { NO_FINISH, type FinishInfo } from '#/llm/finish-reason'; +import { createMessageAccumulator } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; + +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequestControl, + LlmRequester, + LlmRequestEvent, +} from './requester'; + +function formatFinishReasonHint(finish: FinishInfo): string { + if (finish.finishReason === null && finish.rawFinishReason === null) return ''; + const raw = + finish.rawFinishReason === null ? '' : `, rawFinishReason=${finish.rawFinishReason}`; + const filteredHint = + finish.finishReason === 'filtered' + ? ' The provider filtered the response before visible output was emitted.' + : ''; + return ` Provider stop details: finishReason=${finish.finishReason ?? 'unknown'}${raw}.${filteredHint}`; +} + +export function createEmptyResponseError( + model: LlmModel, + finish: FinishInfo, + thinkOnly: boolean, +): LlmErrorMessage<'empty_response'> { + const detail = thinkOnly + ? 'The API returned a response containing only thinking content without any text or tool calls. This usually indicates the stream was interrupted or the output token budget was exhausted during reasoning.' + : 'The API returned an empty response (no content, no tool calls).'; + return { + kind: 'empty_response', + message: `${detail}${formatFinishReasonHint(finish)} Provider: ${model.provider}, model: ${model.model}`, + finishReason: finish.finishReason, + rawFinishReason: finish.rawFinishReason, + }; +} + +export function withEmptyResponseGuard(requester: LlmRequester): LlmRequester { + return { + async generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise { + const { onEvent } = control; + if (onEvent === undefined) { + await requester.generate(config, content, control); + return; + } + let accumulator = createMessageAccumulator(); + let finish: FinishInfo = NO_FINISH; + await requester.generate(config, content, { + ...control, + onEvent: (event: LlmRequestEvent) => { + switch (event.type) { + case 'llm.sent': + accumulator = createMessageAccumulator(); + finish = NO_FINISH; + onEvent(event); + return; + case 'llm.delta': + accumulator.push(event.part); + onEvent(event); + return; + case 'llm.finish': + finish = event.finish; + onEvent(event); + return; + case 'llm.done': { + const message = accumulator.finish(); + const hasToolCalls = message.toolCalls.length > 0; + if (message.content.length === 0 && !hasToolCalls) { + onEvent({ + type: 'llm.failed.remote', + error: createEmptyResponseError(config.model, finish, false), + }); + return; + } + const hasThink = message.content.some((part) => part.type === 'think'); + const hasText = message.content.some( + (part) => part.type === 'text' && part.text.trim().length > 0, + ); + if (hasThink && !hasText && !hasToolCalls) { + onEvent({ + type: 'llm.failed.remote', + error: createEmptyResponseError(config.model, finish, true), + }); + return; + } + onEvent(event); + return; + } + default: + onEvent(event); + } + }, + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/machine.ts b/packages/agent-core-v2/src/human/llm/requester/machine.ts new file mode 100644 index 00000000000..f5aef056851 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/machine.ts @@ -0,0 +1,326 @@ +import { assign, emit, fromCallback, setup } from '#/xstate2'; + +import type { LlmErrorMessage } from '#/llm/errors'; +import type { Message } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; + +import type { + LlmRequestConfig, + LlmRequestContent, + LlmRequester, + LlmRequestEvent, +} from './requester'; +import { withEmptyResponseGuard } from './empty-response'; +import type { + LlmRecovery, + LlmRecoveryContext, + LlmRecoveryProposal, + LlmRecoveryRecord, +} from './recovery'; +import { + readRetryAfterMs, + resolveMaxAttempts, + retryBackoffDelay, + retryErrorFields, + shouldRetry, + type LlmRetryOptions, +} from './retry'; + +export interface LlmInput { + readonly config: LlmRequestConfig; + readonly content: LlmRequestContent; +} + +export interface MessageResolveContext { + readonly model: LlmModel; + readonly signal: AbortSignal; +} + +export interface MessageResolver { + readonly id: string; + resolve( + messages: readonly Message[], + ctx: MessageResolveContext, + ): Promise; +} + +export type LlmEvent = + | Exclude + | { type: 'llm.sent'; recovery?: LlmRecoveryRecord } + | { + type: 'llm.retrying'; + failedAttempt: number; + nextAttempt: number; + maxAttempts: number; + delayMs: number; + errorName: string; + errorMessage: string; + statusCode?: number; + } + | { + type: 'llm.recovering'; + strategy: string; + action: string; + errorName: string; + errorMessage: string; + statusCode?: number; + }; + +export type LlmOutput = { type: 'succeeded' } | { type: 'failed'; error: LlmErrorMessage }; + +export interface LlmMachineContext { + input: LlmInput; + appliedRecoveries: LlmRecoveryRecord[]; + outcome?: 'succeeded' | 'failed'; + error?: LlmErrorMessage; + attempt: number; + delayMs: number; +} + +function llmRetryingEvent( + retry: LlmRetryOptions | undefined, + context: LlmMachineContext, + error: LlmErrorMessage, +): Extract { + return { + type: 'llm.retrying', + failedAttempt: context.attempt, + nextAttempt: context.attempt + 1, + maxAttempts: resolveMaxAttempts(retry), + delayMs: context.delayMs, + ...retryErrorFields(error), + }; +} + +function llmRecoveringEvent( + context: LlmMachineContext, + error: LlmErrorMessage, +): Extract { + const record = context.appliedRecoveries.at(-1) as LlmRecoveryRecord; + return { + type: 'llm.recovering', + strategy: record.strategy, + action: record.action, + ...retryErrorFields(error), + }; +} + +function proposeRecovery( + recovery: LlmRecovery | undefined, + ctx: LlmRecoveryContext, +): (LlmRecoveryProposal & LlmRecoveryRecord) | undefined { + if (recovery === undefined) return undefined; + const proposal = recovery.propose(ctx); + if (proposal === undefined || proposal.messages === ctx.messages) return undefined; + return { strategy: recovery.id, action: proposal.action, messages: proposal.messages }; +} + +function createRequestActor( + requester: LlmRequester, + messageResolvers: readonly MessageResolver[], +) { + return fromCallback(({ input, sendBack }) => { + const controller = new AbortController(); + void (async () => { + let messages = input.content.messages; + for (const resolver of messageResolvers) { + messages = await resolver.resolve(messages, { + model: input.config.model, + signal: controller.signal, + }); + } + await requester.generate( + input.config, + { ...input.content, messages }, + { signal: controller.signal, onEvent: sendBack }, + ); + })(); + return () => controller.abort(); + }); +} + +export interface CreateLlmMachineOptions { + requester: LlmRequester; + messageResolvers?: readonly MessageResolver[]; + recovery?: LlmRecovery; + retry?: LlmRetryOptions; +} + +export function createLlmMachine(options: CreateLlmMachineOptions) { + const retry = options.retry; + const recovery = options.recovery; + const requestActor = createRequestActor( + withEmptyResponseGuard(options.requester), + options.messageResolvers ?? [], + ); + return setup({ + types: { + input: {} as LlmInput, + context: {} as LlmMachineContext, + events: {} as LlmEvent, + emitted: {} as LlmEvent, + output: {} as LlmOutput, + }, + actors: { requestActor }, + actions: { + forwardToParent: ({ self, event }) => { + self._parent?.send(event); + }, + sendToParent: ({ self }, params: LlmEvent) => { + self._parent?.send(params); + }, + }, + delays: { + retryDelay: ({ context }) => context.delayMs, + }, + }).createMachine({ + id: 'llm', + initial: 'generating', + context: ({ input }) => ({ input, appliedRecoveries: [], attempt: 1, delayMs: 0 }), + states: { + generating: { + invoke: { + src: 'requestActor', + input: ({ context }) => context.input, + }, + on: { + 'llm.sent': { + actions: [ + emit(({ context }) => ({ + type: 'llm.sent' as const, + recovery: context.appliedRecoveries.at(-1), + })), + { + type: 'sendToParent', + params: ({ context }) => ({ + type: 'llm.sent' as const, + recovery: context.appliedRecoveries.at(-1), + }), + }, + ], + }, + 'llm.headers': { + actions: [ + emit(({ event }) => ({ type: 'llm.headers' as const, headers: event.headers })), + 'forwardToParent', + ], + }, + 'llm.delta': { + actions: [ + emit(({ event }) => ({ type: 'llm.delta' as const, part: event.part })), + 'forwardToParent', + ], + }, + 'llm.usage': { + actions: [ + emit(({ event }) => ({ type: 'llm.usage' as const, usage: event.usage })), + 'forwardToParent', + ], + }, + 'llm.finish': { + actions: [ + emit(({ event }) => ({ type: 'llm.finish' as const, finish: event.finish })), + 'forwardToParent', + ], + }, + 'llm.message-id': { + actions: [ + emit(({ event }) => ({ type: 'llm.message-id' as const, messageId: event.messageId })), + 'forwardToParent', + ], + }, + 'llm.done': { + target: 'succeeded', + actions: [ + assign({ outcome: 'succeeded' as const }), + emit({ type: 'llm.done' as const }), + 'forwardToParent', + ], + }, + 'llm.failed.syntax': { + target: 'failed', + actions: [ + assign({ outcome: 'failed' as const, error: ({ event }) => event.error }), + emit(({ event }) => ({ type: 'llm.failed.syntax' as const, error: event.error })), + 'forwardToParent', + ], + }, + 'llm.failed.remote': [ + { + target: 'generating', + reenter: true, + guard: ({ context, event }) => + proposeRecovery(recovery, { + error: event.error, + messages: context.input.content.messages, + applied: context.appliedRecoveries, + }) !== undefined, + actions: [ + assign(({ context, event }) => { + const proposal = proposeRecovery(recovery, { + error: event.error, + messages: context.input.content.messages, + applied: context.appliedRecoveries, + }); + if (proposal === undefined) return {}; + return { + input: { + ...context.input, + content: { ...context.input.content, messages: proposal.messages }, + }, + appliedRecoveries: [ + ...context.appliedRecoveries, + { strategy: proposal.strategy, action: proposal.action }, + ], + attempt: 1, + }; + }), + emit(({ context, event }) => llmRecoveringEvent(context, event.error)), + { + type: 'sendToParent', + params: ({ context, event }) => llmRecoveringEvent(context, event.error), + }, + ], + }, + { + target: 'retrying', + guard: ({ context, event }) => + shouldRetry(retry, context.attempt, event.error), + actions: [ + assign({ + delayMs: ({ context, event }) => + readRetryAfterMs(event.error) ?? retryBackoffDelay(context.attempt - 1), + }), + emit(({ context, event }) => llmRetryingEvent(retry, context, event.error)), + { + type: 'sendToParent', + params: ({ context, event }) => llmRetryingEvent(retry, context, event.error), + }, + ], + }, + { + target: 'failed', + actions: [ + assign({ outcome: 'failed' as const, error: ({ event }) => event.error }), + emit(({ event }) => ({ type: 'llm.failed.remote' as const, error: event.error })), + 'forwardToParent', + ], + }, + ], + }, + }, + retrying: { + entry: assign({ attempt: ({ context }) => context.attempt + 1 }), + after: { + retryDelay: 'generating', + }, + }, + succeeded: { type: 'final' }, + failed: { type: 'final' }, + }, + output: ({ context }): LlmOutput => + context.outcome === 'failed' + ? { type: 'failed', error: context.error as LlmErrorMessage } + : { type: 'succeeded' }, + }); +} diff --git a/packages/agent-core-v2/src/human/llm/requester/recovery.ts b/packages/agent-core-v2/src/human/llm/requester/recovery.ts new file mode 100644 index 00000000000..e65a19dfbd2 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/recovery.ts @@ -0,0 +1,23 @@ +import type { LlmRemoteErrorMessage } from '#/llm/errors'; +import type { Message } from '#/llm/message'; + +export interface LlmRecoveryRecord { + readonly strategy: string; + readonly action: string; +} + +export interface LlmRecoveryContext { + readonly error: LlmRemoteErrorMessage; + readonly messages: readonly Message[]; + readonly applied: readonly LlmRecoveryRecord[]; +} + +export interface LlmRecoveryProposal { + readonly action: string; + readonly messages: readonly Message[]; +} + +export interface LlmRecovery { + readonly id: string; + propose(ctx: LlmRecoveryContext): LlmRecoveryProposal | undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/requester.ts b/packages/agent-core-v2/src/human/llm/requester/requester.ts new file mode 100644 index 00000000000..06345df3059 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/requester.ts @@ -0,0 +1,95 @@ +import type { LlmErrorMessage, LlmRemoteErrorMessage } from '#/llm/errors'; +import type { FinishInfo } from '#/llm/finish-reason'; +import type { + Message, + StreamedMessagePart, + ToolDescription, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { ResponseFormat } from '#/llm/response-format'; +import type { ThinkingRequestOptions } from '#/llm/thinking'; +import type { TokenUsage } from '#/llm/usage'; + +import type { AnthropicExtraParams } from './bases/anthropic/extra-params'; +import type { GoogleGenAIExtraParams } from './bases/google-genai/extra-params'; +import type { OpenAIExtraParams } from './bases/openai/extra-params'; +import type { OpenAIResponsesExtraParams } from './bases/openai-responses/extra-params'; + +export interface ToolCallIdPolicy { + normalize: (id: string) => string; + maxLength?: number; +} + +export type LlmRequestEvent = + | { type: 'llm.sent' } + | { type: 'llm.headers'; headers: Record } + | { type: 'llm.delta'; part: StreamedMessagePart } + | { type: 'llm.usage'; usage: Partial } + | { type: 'llm.finish'; finish: FinishInfo } + | { type: 'llm.message-id'; messageId: string } + | { type: 'llm.failed.syntax'; error: LlmErrorMessage<'syntax'> } + | { type: 'llm.failed.remote'; error: LlmRemoteErrorMessage } + | { type: 'llm.done' }; + +export interface ExtraParams { + readonly openai?: OpenAIExtraParams; + readonly responses?: OpenAIResponsesExtraParams; + readonly anthropic?: AnthropicExtraParams; + readonly googleGenai?: GoogleGenAIExtraParams; +} + +export type ToolMessageConversion = 'extract_text' | 'keep_parts'; + +export interface LlmRequestConfig { + readonly model: LlmModel; + readonly systemPrompt?: string; + readonly tools?: readonly ToolDescription[]; + readonly cacheKey?: string; + readonly thinking?: ThinkingRequestOptions; + readonly responseFormat?: ResponseFormat; + readonly maxCompletionTokens?: number; + readonly maxContextTokens?: number; + readonly extraParams?: ExtraParams; + readonly toolMessageConversion?: ToolMessageConversion; +} + +export interface LlmRequestContent { + readonly messages: readonly Message[]; + readonly usedContextTokens?: number; +} + +export interface LlmRequestControl { + readonly signal: AbortSignal; + readonly onEvent?: (event: LlmRequestEvent) => void; +} + +export interface LlmRequester { + generate( + config: LlmRequestConfig, + content: LlmRequestContent, + control: LlmRequestControl, + ): Promise; +} + +export interface LlmClientContext { + readonly model: LlmModel; + readonly headers?: Record; +} + +export interface LlmRequesterOptions { + readonly clientFactory?: (request: LlmClientContext) => TClient; +} + +export function mergeRequestHeaders( + defaultHeaders: Record | undefined, + requestHeaders: Record | undefined, +): Record | undefined { + const merged: Record = {}; + if (defaultHeaders !== undefined) { + Object.assign(merged, defaultHeaders); + } + if (requestHeaders !== undefined) { + Object.assign(merged, requestHeaders); + } + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/packages/agent-core-v2/src/human/llm/requester/retry.ts b/packages/agent-core-v2/src/human/llm/requester/retry.ts new file mode 100644 index 00000000000..f7e035f91fd --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/requester/retry.ts @@ -0,0 +1,74 @@ +import { llmStatusErrorMessage, type LlmErrorMessage } from '#/llm/errors'; + +export const DEFAULT_MAX_RETRY_ATTEMPTS = 10; + +const BASE_DELAY_MS = 500; +const MAX_DELAY_MS = 32_000; +const RETRY_FACTOR = 2; +const JITTER_FACTOR = 0.25; + +const RETRYABLE_STATUS_CODES: readonly number[] = [408, 409, 429, 500, 502, 503, 504, 529]; + +export interface LlmRetryOptions { + readonly maxAttemptsPerStep?: number; + readonly infiniteRetry?: boolean; +} + +export interface LlmRetryErrorFields { + readonly errorName: string; + readonly errorMessage: string; + readonly statusCode?: number; +} + +export function resolveMaxAttempts(options: LlmRetryOptions | undefined): number { + return Math.max(options?.maxAttemptsPerStep ?? DEFAULT_MAX_RETRY_ATTEMPTS, 1); +} + +export function retryBackoffDelay(attemptIndex: number): number { + const base = Math.min(BASE_DELAY_MS * Math.pow(RETRY_FACTOR, attemptIndex), MAX_DELAY_MS); + return base + Math.random() * JITTER_FACTOR * base; +} + +export function readRetryAfterMs(error: LlmErrorMessage): number | undefined { + const retryAfterMs = llmStatusErrorMessage(error)?.retryAfterMs; + return retryAfterMs !== null && retryAfterMs !== undefined && retryAfterMs > 0 + ? retryAfterMs + : undefined; +} + +export function isRetryableError(error: LlmErrorMessage): boolean { + switch (error.kind) { + case 'syntax': + case 'abort': + case 'quota_exhausted': + case 'context_overflow': + case 'request_too_large': + case 'request_structure': + case 'image_format': + case 'unknown': + return false; + case 'empty_response': + return error.finishReason !== 'filtered'; + case 'status': + return RETRYABLE_STATUS_CODES.includes(error.statusCode); + default: + return true; + } +} + +export function shouldRetry( + options: LlmRetryOptions | undefined, + attempt: number, + error: LlmErrorMessage, +): boolean { + if (options?.infiniteRetry === true) return true; + return isRetryableError(error) && attempt < resolveMaxAttempts(options); +} + +export function retryErrorFields(error: LlmErrorMessage): LlmRetryErrorFields { + return { + errorName: error.kind, + errorMessage: error.message, + statusCode: llmStatusErrorMessage(error)?.statusCode, + }; +} diff --git a/packages/agent-core-v2/src/human/llm/response-format.ts b/packages/agent-core-v2/src/human/llm/response-format.ts new file mode 100644 index 00000000000..ba0c00bbfc9 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/response-format.ts @@ -0,0 +1,17 @@ +export type JsonSchemaObject = Record; + +export interface JsonObjectResponseFormat { + readonly type: 'json_object'; +} + +export interface JsonSchemaResponseFormat { + readonly type: 'json_schema'; + readonly jsonSchema: { + readonly name: string; + readonly schema: JsonSchemaObject; + readonly strict?: boolean; + readonly description?: string; + }; +} + +export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat; diff --git a/packages/agent-core-v2/src/human/llm/syntax-errors.ts b/packages/agent-core-v2/src/human/llm/syntax-errors.ts new file mode 100644 index 00000000000..d58a3d3e528 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/syntax-errors.ts @@ -0,0 +1,18 @@ +import type { LlmErrorMessage } from '#/llm/errors'; + +export class SyntaxRequestFormatError extends Error { + toLlmErrorMessage(): LlmErrorMessage<'syntax'> { + return { kind: 'syntax', code: 'request_format', message: this.message }; + } +} + +export function toLlmSyntaxErrorMessage(error: unknown): LlmErrorMessage<'syntax'> { + if (error instanceof SyntaxRequestFormatError) { + return error.toLlmErrorMessage(); + } + return { + kind: 'syntax', + code: 'internal', + message: error instanceof Error ? error.message : String(error), + }; +} diff --git a/packages/agent-core-v2/src/human/llm/thinking.ts b/packages/agent-core-v2/src/human/llm/thinking.ts new file mode 100644 index 00000000000..5ac6039dbb8 --- /dev/null +++ b/packages/agent-core-v2/src/human/llm/thinking.ts @@ -0,0 +1,258 @@ +import { isUnknownCapability, type ModelCapability } from '#/llm/capability'; +import type { LlmErrorMessage } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import { SyntaxRequestFormatError } from '#/llm/syntax-errors'; + +export type ThinkingEffort = 'off' | 'on' | (string & {}); + +export interface ThinkingRequestOptions { + readonly effort: ThinkingEffort; + readonly keep?: string; +} + +export interface ModelThinkingMetadata { + readonly supportEfforts?: readonly string[]; + readonly defaultEffort?: string; + readonly offEffort?: string; + readonly alwaysThinking?: boolean; + readonly adaptiveThinking?: boolean; +} + +export interface ThinkingDefaults { + readonly enabled?: boolean; + readonly effort?: string; +} + +export type ThinkingConfigErrorCode = + | 'effort-not-supported' + | 'thinking-unsupported' + | 'thinking-cannot-disable' + | 'off-needs-offeffort'; + +export class ThinkingConfigError extends SyntaxRequestFormatError { + readonly code: ThinkingConfigErrorCode; + + constructor(code: ThinkingConfigErrorCode, message: string) { + super(message); + this.name = 'ThinkingConfigError'; + this.code = code; + } + + override toLlmErrorMessage(): LlmErrorMessage<'syntax'> { + return { kind: 'syntax', code: 'thinking_config', message: this.message }; + } +} + +export type ThinkingResolution = + | { readonly ok: true; readonly encode: 'silent' } + | { readonly ok: true; readonly encode: 'effort'; readonly value: string } + | { readonly ok: false; readonly error: ThinkingConfigError }; + +export function thinkingMetadataOf(model: LlmModel): ModelThinkingMetadata | undefined { + const candidate = model as LlmModel & ModelThinkingMetadata; + const { supportEfforts, defaultEffort, offEffort, alwaysThinking, adaptiveThinking } = candidate; + if ( + supportEfforts === undefined && + defaultEffort === undefined && + offEffort === undefined && + alwaysThinking === undefined && + adaptiveThinking === undefined + ) { + return undefined; + } + return { supportEfforts, defaultEffort, offEffort, alwaysThinking, adaptiveThinking }; +} + +function capabilityThinking(capability: ModelCapability): boolean | undefined { + return isUnknownCapability(capability) ? undefined : capability.thinking; +} + +function effortList(efforts: readonly string[] | undefined): string | undefined { + return efforts !== undefined && efforts.length > 0 ? efforts.join(', ') : undefined; +} + +export function resolveThinkingEffort( + options: ThinkingRequestOptions, + model: LlmModel, + strictValidation = false, +): ThinkingResolution { + const effort = options.effort; + const meta = thinkingMetadataOf(model); + if (effort === 'on') { + return { ok: true, encode: 'silent' }; + } + if (effort === 'off') { + if (meta?.offEffort !== undefined) { + return { ok: true, encode: 'effort', value: meta.offEffort }; + } + if (meta?.alwaysThinking === true) { + const list = effortList(meta.supportEfforts); + return { + ok: false, + error: new ThinkingConfigError( + 'thinking-cannot-disable', + list === undefined + ? `Model '${model.model}' always reasons and thinking cannot be turned off. Choose a concrete thinking effort instead of 'off'.` + : `Model '${model.model}' always reasons and thinking cannot be turned off. Choose a concrete thinking effort (${list}) instead of 'off'.`, + ), + }; + } + if (meta?.supportEfforts !== undefined) { + return { + ok: false, + error: new ThinkingConfigError( + 'off-needs-offeffort', + `Model '${model.model}' reasons by default but declares no off effort, so thinking cannot be turned off. Declare offEffort (for example 'none') for this model in the model catalog configuration.`, + ), + }; + } + return { ok: true, encode: 'silent' }; + } + if ( + strictValidation && + meta?.supportEfforts !== undefined && + !meta.supportEfforts.includes(effort) + ) { + return { + ok: false, + error: new ThinkingConfigError( + 'effort-not-supported', + `Model '${model.model}' does not support thinking effort '${effort}'. Supported efforts: ${meta.supportEfforts.join(', ')}. Set the thinking effort to one of the supported values.`, + ), + }; + } + if (meta === undefined && capabilityThinking(model.capability) === false) { + return { + ok: false, + error: new ThinkingConfigError( + 'thinking-unsupported', + `Model '${model.model}' does not support thinking, but thinking effort '${effort}' was requested. Remove the thinking effort setting or choose a thinking-capable model.`, + ), + }; + } + return { ok: true, encode: 'effort', value: effort }; +} + +export function encodeReasoningEffortFallback( + thinking: ThinkingRequestOptions, + model: LlmModel, + strictValidation = false, +): Record | undefined { + const resolution = resolveThinkingEffort(thinking, model, strictValidation); + if (!resolution.ok) throw resolution.error; + return resolution.encode === 'effort' ? { reasoning_effort: resolution.value } : undefined; +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed; +} + +function middleOf(values: readonly string[]): string { + return values[Math.floor(values.length / 2)]!; +} + +function effortsFor(meta: ModelThinkingMetadata | undefined): readonly string[] { + return meta?.supportEfforts?.map(nonEmpty).filter((v): v is string => v !== undefined) ?? []; +} + +export function normalizeRequestedThinkingEffort( + requested: string | undefined, +): ThinkingEffort | undefined { + return nonEmpty(requested)?.toLowerCase() as ThinkingEffort | undefined; +} + +export function modelSupportsThinking(model: LlmModel): boolean { + const meta = thinkingMetadataOf(model); + return ( + meta?.alwaysThinking === true || + meta?.adaptiveThinking === true || + capabilityThinking(model.capability) === true + ); +} + +export function defaultThinkingEffortForModel(model: LlmModel): ThinkingEffort { + const meta = thinkingMetadataOf(model); + if (!modelSupportsThinking(model)) return 'off'; + const efforts = effortsFor(meta); + if (efforts.length > 0) { + const declared = nonEmpty(meta?.defaultEffort); + return (declared !== undefined && efforts.includes(declared) + ? declared + : middleOf(efforts)) as ThinkingEffort; + } + return 'on'; +} + +function normalizeThinkingEffortForModel( + effort: ThinkingEffort, + model: LlmModel, + strictValidation: boolean, +): ThinkingEffort { + const meta = thinkingMetadataOf(model); + if (effort === 'off' && meta?.alwaysThinking !== true) return 'off'; + const efforts = effortsFor(meta); + if (!strictValidation) { + return effort === 'on' && efforts.length > 0 + ? defaultThinkingEffortForModel(model) + : effort; + } + if (!modelSupportsThinking(model)) return 'off'; + if (efforts.length === 0) return 'on'; + if (effort === 'on' || !efforts.includes(effort)) { + return defaultThinkingEffortForModel(model); + } + return effort; +} + +export function resolveThinkingEffortForModel( + requested: string | undefined, + defaults: ThinkingDefaults | undefined, + model: LlmModel, + strictValidation = false, +): ThinkingEffort { + const configured = normalizeRequestedThinkingEffort(defaults?.effort); + const normalized = normalizeRequestedThinkingEffort(requested); + let effort: ThinkingEffort; + if (normalized !== undefined) { + effort = normalized; + } else if (defaults?.enabled === false) { + effort = 'off'; + } else { + effort = configured ?? defaultThinkingEffortForModel(model); + } + + if (effort === 'off' && thinkingMetadataOf(model)?.alwaysThinking === true) { + effort = + configured !== undefined && configured !== 'off' + ? configured + : defaultThinkingEffortForModel(model); + } + return normalizeThinkingEffortForModel(effort, model, strictValidation); +} + +const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); + +type KeepResolution = + | { readonly specified: false } + | { readonly specified: true; readonly value: string | undefined }; + +function parseKeepValue(raw: string | undefined): KeepResolution { + const trimmed = raw?.trim(); + if (trimmed === undefined || trimmed.length === 0) return { specified: false }; + if (KEEP_OFF_VALUES.has(trimmed.toLowerCase())) return { specified: true, value: undefined }; + return { specified: true, value: trimmed }; +} + +export function resolveThinkingKeep( + envKeep: string | undefined, + configKeep: string | undefined, + thinkingEffort: ThinkingEffort, +): string | undefined { + if (thinkingEffort === 'off') return undefined; + const fromEnv = parseKeepValue(envKeep); + if (fromEnv.specified) return fromEnv.value; + const fromConfig = parseKeepValue(configKeep); + if (fromConfig.specified) return fromConfig.value; + return 'all'; +} diff --git a/packages/agent-core-v2/src/kosong/contract/usage.ts b/packages/agent-core-v2/src/human/llm/usage.ts similarity index 59% rename from packages/agent-core-v2/src/kosong/contract/usage.ts rename to packages/agent-core-v2/src/human/llm/usage.ts index ad59bd6bcf9..0c89998a50a 100644 --- a/packages/agent-core-v2/src/kosong/contract/usage.ts +++ b/packages/agent-core-v2/src/human/llm/usage.ts @@ -3,6 +3,11 @@ export interface TokenUsage { output: number; inputCacheRead: number; inputCacheCreation: number; + raw?: Record; +} + +export function emptyUsage(): TokenUsage { + return { inputOther: 0, output: 0, inputCacheRead: 0, inputCacheCreation: 0 }; } export function inputTotal(usage: TokenUsage): number { @@ -13,15 +18,6 @@ export function grandTotal(usage: TokenUsage): number { return inputTotal(usage) + usage.output; } -export function emptyUsage(): TokenUsage { - return { - inputOther: 0, - output: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - }; -} - export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { return { inputOther: a.inputOther + b.inputOther, @@ -30,3 +26,15 @@ export function addUsage(a: TokenUsage, b: TokenUsage): TokenUsage { inputCacheCreation: a.inputCacheCreation + b.inputCacheCreation, }; } + +export function mergeUsagePatch( + base: TokenUsage | undefined, + patch: Partial, +): TokenUsage { + return { + inputOther: patch.inputOther ?? base?.inputOther ?? 0, + output: patch.output ?? base?.output ?? 0, + inputCacheRead: patch.inputCacheRead ?? base?.inputCacheRead ?? 0, + inputCacheCreation: patch.inputCacheCreation ?? base?.inputCacheCreation ?? 0, + }; +} diff --git a/packages/agent-core-v2/src/human/media/read-media.md b/packages/agent-core-v2/src/human/media/read-media.md new file mode 100644 index 00000000000..f7d557523a1 --- /dev/null +++ b/packages/agent-core-v2/src/human/media/read-media.md @@ -0,0 +1,9 @@ +Read an image or video file and view its content. Relative paths resolve against the working directory. Directories and text files are not supported. + +**When to use:** +- The user asks you to look at, analyze, describe, or compare images or videos +- You need to verify visual content produced by an earlier step, such as a screenshot, a generated image, or a recording + +**When NOT to use:** +- Text files of any kind, including source code and documents +- Files larger than 100MB diff --git a/packages/agent-core-v2/src/human/media/tool.ts b/packages/agent-core-v2/src/human/media/tool.ts new file mode 100644 index 00000000000..21613792e03 --- /dev/null +++ b/packages/agent-core-v2/src/human/media/tool.ts @@ -0,0 +1,99 @@ +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; + +import type { ModelCapability } from '#/llm/capability'; +import type { ContentPart } from '#/llm/message'; +import { mediaKindForPath, mediaMimeForPath } from '#/llm/media/mime'; +import { buildMediaRefUrl } from '#/llm/media/ref'; +import type { MediaStore } from '#/llm/media/store'; +import type { ToolResult } from '#/tool/executor'; +import { defineTool, type ToolDefinition } from '#/tool/tool'; + +import DESCRIPTION from './read-media.md?raw'; + +export const READ_MEDIA_FILE_TOOL_NAME = 'ReadMediaFile'; +export const MAX_MEDIA_BYTES = 100 * 1024 * 1024; + +export interface ReadMediaFileToolOptions { + readonly store: MediaStore; + readonly workspaceDir: string; + readonly capability: ModelCapability; +} + +function errorResult(output: string): ToolResult { + return { content: [{ type: 'text', text: output }], isError: true }; +} + +export function createReadMediaFileTool(options: ReadMediaFileToolOptions): ToolDefinition { + return defineTool({ + name: READ_MEDIA_FILE_TOOL_NAME, + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + path: { + type: 'string', + description: + 'Path to an image or video file. Relative paths resolve against the working directory. Directories and text files are not supported.', + }, + }, + required: ['path'], + }, + async execute({ toolCall }) { + const args = JSON.parse(toolCall.arguments ?? '{}') as { path?: unknown }; + if (typeof args.path !== 'string' || args.path.trim() === '') { + return errorResult('File path cannot be empty.'); + } + const filePath = path.resolve(options.workspaceDir, args.path); + const kind = mediaKindForPath(filePath); + if (kind === undefined) { + return errorResult(`"${args.path}" is not a supported image or video file.`); + } + if (kind === 'image' && !options.capability.image_in) { + return errorResult( + 'The current model does not support image input. Tell the user to use a model with image input capability.', + ); + } + if (kind === 'video' && !options.capability.video_in) { + return errorResult( + 'The current model does not support video input. Tell the user to use a model with video input capability.', + ); + } + let data: Buffer; + try { + data = await fs.readFile(filePath); + } catch (error) { + return errorResult( + `Failed to read ${args.path}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (data.length === 0) { + return errorResult(`"${args.path}" is empty.`); + } + if (data.length > MAX_MEDIA_BYTES) { + return errorResult( + `"${args.path}" is ${String(data.length)} bytes, which exceeds the maximum 100MB for media files.`, + ); + } + const mimeType = mediaMimeForPath(filePath) as string; + const filename = path.basename(filePath); + const ref = await options.store.put({ + bytes: new Uint8Array(data), + mimeType, + filename, + }); + const url = buildMediaRefUrl(ref); + const mediaPart: ContentPart = + kind === 'image' + ? { type: 'image_url', imageUrl: { url } } + : { type: 'video_url', videoUrl: { url } }; + return { + content: [ + { type: 'text', text: `<${kind} path="${filePath}">` }, + mediaPart, + { type: 'text', text: `` }, + ], + }; + }, + }); +} diff --git a/packages/agent-core-v2/src/human/models-dev/models-dev.ts b/packages/agent-core-v2/src/human/models-dev/models-dev.ts new file mode 100644 index 00000000000..61bcd584a43 --- /dev/null +++ b/packages/agent-core-v2/src/human/models-dev/models-dev.ts @@ -0,0 +1,332 @@ +import type { CatalogModelDefinition } from '#/llm/provider-catalog'; + +export type ModelsDevWire = + | 'anthropic' + | 'openai' + | 'openai_responses' + | 'google-genai' + | 'google-vertex' + | 'kimi'; + +export interface ModelsDevModelEntry { + readonly id?: string; + readonly name?: string; + readonly family?: string; + readonly limit?: { readonly context?: number; readonly input?: number; readonly output?: number }; + readonly tool_call?: boolean; + readonly dynamically_loaded_tools?: boolean; + readonly reasoning?: boolean; + readonly reasoning_options?: readonly ModelsDevReasoningOption[]; + readonly status?: string; + readonly provider?: ModelsDevModelProviderOverride; + readonly interleaved?: boolean | { readonly field?: string }; + readonly modalities?: { + readonly input?: readonly string[]; + readonly output?: readonly string[]; + }; +} + +export interface ModelsDevReasoningOption { + readonly type?: string; + readonly values?: unknown; +} + +export interface ModelsDevModelProviderOverride { + readonly npm?: string; + readonly api?: string; +} + +export interface ModelsDevProviderEntry { + readonly id?: string; + readonly api?: string; + readonly npm?: string; + readonly type?: string; + readonly models?: Record; +} + +export type ModelsDevImportInvalidReason = + | 'unknown-explicit-type' + | 'proprietary-sdk' + | 'empty-base-url' + | 'placeholder-base-url'; + +export type ModelsDevImportResolution = + | { + readonly kind: 'ok'; + readonly wire: ModelsDevWire; + readonly guessed: boolean; + readonly baseUrl?: string; + } + | { readonly kind: 'needs-base-url'; readonly wire: ModelsDevWire; readonly guessed: boolean } + | { readonly kind: 'invalid'; readonly reason: ModelsDevImportInvalidReason }; + +const KNOWN_WIRES = [ + 'anthropic', + 'openai', + 'openai_responses', + 'google-genai', + 'google-vertex', + 'kimi', +] as const satisfies readonly ModelsDevWire[]; + +function isModelsDevWire(value: unknown): value is ModelsDevWire { + return typeof value === 'string' && (KNOWN_WIRES as readonly string[]).includes(value); +} + +function hasEmbeddingMarker(value: string | undefined): boolean { + if (value === undefined) return false; + const lower = value.toLowerCase(); + return lower.includes('embedding') || /(?:^|[-_/])embed(?:$|[-_/])/.test(lower); +} + +function isUsableChatModel(model: ModelsDevModelEntry): boolean { + const outputModalities = model.modalities?.output; + if (outputModalities !== undefined && !outputModalities.includes('text')) return false; + if (model.status === 'deprecated' || model.status === 'alpha') return false; + return ( + !hasEmbeddingMarker(model.family) && + !hasEmbeddingMarker(model.id) && + !hasEmbeddingMarker(model.name) + ); +} + +export function resolveModelsDevImport( + entry: ModelsDevProviderEntry, + userBaseUrl?: string, +): ModelsDevImportResolution { + const wire = resolveModelsDevWire(entry); + if (wire === undefined) { + return { + kind: 'invalid', + reason: + typeof entry.type === 'string' && entry.type.length > 0 + ? 'unknown-explicit-type' + : 'proprietary-sdk', + }; + } + const guessed = inferDeclaredWire(entry) === undefined; + + if (userBaseUrl !== undefined) { + const trimmed = userBaseUrl.trim(); + if (trimmed.length === 0) return { kind: 'invalid', reason: 'empty-base-url' }; + if (trimmed.includes('${')) return { kind: 'invalid', reason: 'placeholder-base-url' }; + return { kind: 'ok', wire, guessed, baseUrl: adaptBaseUrlForWire(trimmed, wire) }; + } + + const modelsDevUrl = modelsDevBaseUrl(entry, wire); + if (modelsDevUrl !== undefined) return { kind: 'ok', wire, guessed, baseUrl: modelsDevUrl }; + if (modelsDevEndpointRequired(entry, wire)) return { kind: 'needs-base-url', wire, guessed }; + return { kind: 'ok', wire, guessed }; +} + +function resolveModelsDevWire(entry: ModelsDevProviderEntry): ModelsDevWire | undefined { + if (isModelsDevWire(entry.type)) return entry.type; + if (typeof entry.type === 'string' && entry.type.length > 0) return undefined; + const declared = inferDeclaredWire(entry); + if (declared !== undefined) return declared; + const npm = (entry.npm ?? '').toLowerCase(); + if (npm.includes('amazon-bedrock') || npm.includes('cohere')) return undefined; + return 'openai'; +} + +function inferDeclaredWire(entry: ModelsDevProviderEntry): ModelsDevWire | undefined { + if (isModelsDevWire(entry.type)) return entry.type; + const npm = (entry.npm ?? '').toLowerCase(); + const id = (entry.id ?? '').toLowerCase(); + if (npm.includes('anthropic') || id.includes('anthropic') || id.includes('claude')) { + return 'anthropic'; + } + if (id.includes('vertex')) return 'google-vertex'; + if (npm.includes('google') || id.includes('google') || id.includes('gemini')) { + return 'google-genai'; + } + if (npm.includes('openai') || id.includes('openai')) return 'openai'; + return undefined; +} + +function modelsDevBaseUrl(entry: ModelsDevProviderEntry, wire: ModelsDevWire): string | undefined { + const api = entry.api; + if (typeof api !== 'string' || api.length === 0 || api.includes('${')) return undefined; + return adaptBaseUrlForWire(api, wire); +} + +function adaptBaseUrlForWire(baseUrl: string, wire: ModelsDevWire): string { + return wire === 'anthropic' ? baseUrl.replace(/\/v1\/?$/, '') : baseUrl; +} + +function modelsDevEndpointRequired(entry: ModelsDevProviderEntry, wire: ModelsDevWire): boolean { + if (typeof entry.api === 'string' && entry.api.length > 0) return true; + const npm = (entry.npm ?? '').toLowerCase(); + if (wire === 'openai' || wire === 'openai_responses') return npm !== '@ai-sdk/openai'; + if (wire === 'anthropic') return npm !== '@ai-sdk/anthropic'; + return false; +} + +function normalizeModelsDevModel( + providerId: string, + model: ModelsDevModelEntry, +): CatalogModelDefinition | undefined { + if (typeof model.id !== 'string' || model.id.length === 0) return undefined; + const context = model.limit?.context; + if (typeof context !== 'number' || !Number.isInteger(context) || context <= 0) return undefined; + if (!isUsableChatModel(model)) return undefined; + const inputs = model.modalities?.input ?? []; + const output = model.limit?.output; + const thinking = modelsDevThinkingOptions(model.reasoning_options); + const input = model.limit?.input; + const maxInputTokens = + typeof input === 'number' && Number.isInteger(input) && input > 0 + ? Math.min(input, context) + : undefined; + return { + provider: providerId, + model: model.id, + displayName: typeof model.name === 'string' && model.name.length > 0 ? model.name : undefined, + maxContextSize: context, + maxInputSize: maxInputTokens, + maxOutputSize: typeof output === 'number' && output > 0 ? output : undefined, + reasoningKey: modelsDevReasoningKey(model.interleaved), + supportEfforts: thinking.efforts, + offEffort: thinking.offEffort, + alwaysThinking: thinking.alwaysThinking, + capability: { + image_in: inputs.includes('image'), + video_in: inputs.includes('video'), + audio_in: inputs.includes('audio'), + thinking: + Boolean(model.reasoning) || thinking.efforts !== undefined || thinking.hasToggle, + tool_use: model.tool_call ?? true, + dynamically_loaded_tools: model.dynamically_loaded_tools === true, + }, + }; +} + +function modelsDevThinkingOptions(options: ModelsDevModelEntry['reasoning_options']): { + readonly efforts: readonly string[] | undefined; + readonly offEffort: string | undefined; + readonly hasToggle: boolean; + readonly alwaysThinking: boolean | undefined; +} { + if (!Array.isArray(options)) { + return { + efforts: undefined, + offEffort: undefined, + hasToggle: false, + alwaysThinking: undefined, + }; + } + let efforts: readonly string[] | undefined; + let offEffort: string | undefined; + let hasToggle = false; + for (const option of options) { + if (option?.type === 'toggle') { + hasToggle = true; + continue; + } + if (option?.type !== 'effort' || !Array.isArray(option.values)) continue; + const hasNullTier = (option.values as unknown[]).some((value) => value === null); + const levels = (option.values as unknown[]).filter( + (value: unknown): value is string => typeof value === 'string' && value.length > 0, + ); + const off = levels.find((value) => value.toLowerCase() === 'none'); + if (off !== undefined) offEffort = off; + else if (hasNullTier) offEffort = 'none'; + const selectable = levels.filter((value) => value.toLowerCase() !== 'none'); + if (selectable.length > 0) efforts = selectable; + } + const alwaysThinking = + efforts !== undefined && offEffort === undefined && !hasToggle ? true : undefined; + return { efforts, offEffort, hasToggle, alwaysThinking }; +} + +function modelsDevReasoningKey(interleaved: ModelsDevModelEntry['interleaved']): string | undefined { + if (typeof interleaved !== 'object' || interleaved === null) return undefined; + const field = interleaved.field?.trim(); + return field !== undefined && field.length > 0 ? field : undefined; +} + +export function modelsDevProviderModels( + providerId: string, + entry: ModelsDevProviderEntry, +): CatalogModelDefinition[] { + const providerWire = resolveModelsDevWire(entry); + return Object.values(entry.models ?? {}) + .map((raw) => { + const resolved = applyModelProviderOverride( + normalizeModelsDevModel(providerId, raw), + raw, + entry, + providerWire, + ); + return resolved === undefined + ? undefined + : dropAlwaysThinkingForWire(resolved.model, resolved.wire); + }) + .filter((model): model is CatalogModelDefinition => model !== undefined); +} + +function dropAlwaysThinkingForWire( + model: CatalogModelDefinition, + wire: ModelsDevWire | undefined, +): CatalogModelDefinition { + return model.alwaysThinking === true && (wire === 'anthropic' || wire === 'kimi') + ? { ...model, alwaysThinking: undefined } + : model; +} + +function applyModelProviderOverride( + model: CatalogModelDefinition | undefined, + raw: ModelsDevModelEntry, + entry: ModelsDevProviderEntry, + providerWire: ModelsDevWire | undefined, +): { model: CatalogModelDefinition; wire: ModelsDevWire | undefined } | undefined { + if (model === undefined) return undefined; + const override = raw.provider; + if (override === undefined) return { model, wire: providerWire }; + const overrideNpm = typeof override.npm === 'string' ? override.npm.toLowerCase() : undefined; + if ( + overrideNpm !== undefined && + (overrideNpm.includes('amazon-bedrock') || overrideNpm.includes('cohere')) + ) { + return undefined; + } + const overrideWire = + overrideNpm !== undefined ? (inferOverrideWire(overrideNpm) ?? 'openai') : providerWire; + if (overrideWire === undefined) return { model, wire: providerWire }; + const rawApi = override.api; + const api = rawApi ?? entry.api; + const usableApi = + typeof api === 'string' && api.length > 0 && !api.includes('${') ? api : undefined; + + if (overrideWire === providerWire) { + if (typeof rawApi === 'string' && rawApi.includes('${')) return undefined; + if (usableApi !== undefined && usableApi !== entry.api) { + return { + model: { ...model, baseUrl: adaptBaseUrlForWire(usableApi, overrideWire) }, + wire: overrideWire, + }; + } + return { model, wire: overrideWire }; + } + + if (overrideWire === 'anthropic' && usableApi !== undefined) { + return { + model: { + ...model, + protocol: 'anthropic', + baseUrl: adaptBaseUrlForWire(usableApi, 'anthropic'), + }, + wire: 'anthropic', + }; + } + return undefined; +} + +function inferOverrideWire(npm: string): ModelsDevWire | undefined { + const normalized = npm.toLowerCase(); + if (normalized.includes('anthropic')) return 'anthropic'; + if (normalized.includes('vertex')) return 'google-vertex'; + if (normalized.includes('google')) return 'google-genai'; + if (normalized.includes('openai')) return 'openai'; + return undefined; +} diff --git a/packages/agent-core-v2/src/human/package.json b/packages/agent-core-v2/src/human/package.json new file mode 100644 index 00000000000..d8fdda99a5f --- /dev/null +++ b/packages/agent-core-v2/src/human/package.json @@ -0,0 +1,6 @@ +{ + "type": "module", + "imports": { + "#/*": "./*.ts" + } +} diff --git a/packages/agent-core-v2/src/human/persist/agent.ts b/packages/agent-core-v2/src/human/persist/agent.ts new file mode 100644 index 00000000000..7a0914ef1e3 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/agent.ts @@ -0,0 +1,134 @@ +import type { ActorRefFrom } from 'xstate'; + +import type { HistoryMessage } from '#/agent/turn'; +import type { TurnEntryData, TurnOutcome } from '#/agent/replay'; +import type { Branch } from '#/store/branch'; +import type { AppendInput } from '#/store/types'; + +import type { createAgentMachine } from '#/agent/machine'; + +type AgentActor = ActorRefFrom>; +type AgentSnapshot = ReturnType; + +export interface PersistAgentOptions { + states?: Record unknown>; + persistedMessages?: number; + onError?: (error: unknown) => void; +} + +export interface AgentPersistence { + readonly persistedMessages: number; + flush(): Promise; + dispose(): void; +} + +interface BufferedTurnEvent { + kind: 'start' | 'end'; + turnId?: number; + outcome?: TurnOutcome; + messageCount?: number; +} + +function countPersistedMessages(branch: Branch): number { + let count = 0; + for (const entry of branch.walk()) { + if (entry.type === 'message') count += 1; + } + return count; +} + +export function persistAgent(actor: AgentActor, branch: Branch, opts?: PersistAgentOptions): AgentPersistence { + const report = opts?.onError ?? ((error: unknown) => console.error(error)); + const write = (input: AppendInput): void => { + void branch.append(input).catch(report); + }; + const writeMessage = (message: HistoryMessage): void => { + void branch.append({ type: 'message', kind: 'agent', data: message }).then( + () => { + completed += 1; + }, + report, + ); + }; + + let queued = opts?.persistedMessages ?? countPersistedMessages(branch); + let completed = queued; + let currentTurnId: number | undefined; + const buffered: BufferedTurnEvent[] = []; + const stateValues = new Map(); + for (const [name, get] of Object.entries(opts?.states ?? {})) { + stateValues.set(name, JSON.stringify(get())); + } + + const subscriptions = [ + actor.on('turn.start', (event) => { + if (event.type !== 'turn.start') return; + currentTurnId = event.turnId; + buffered.push({ kind: 'start', turnId: event.turnId }); + }), + actor.on('turn.done', (event) => { + if (event.type !== 'turn.done') return; + buffered.push({ kind: 'end', outcome: 'done', messageCount: event.messages.length }); + }), + actor.on('turn.failed', (event) => { + if (event.type !== 'turn.failed') return; + buffered.push({ kind: 'end', outcome: 'failed', messageCount: event.messages.length }); + }), + actor.on('turn.aborted', (event) => { + if (event.type !== 'turn.aborted') return; + buffered.push({ kind: 'end', outcome: 'aborted', messageCount: event.messages.length }); + }), + ]; + + const flushSnapshot = (snapshot: AgentSnapshot): void => { + const context = snapshot.context; + const writeUpTo = (count: number): void => { + while (queued < count) { + writeMessage(context.messages[queued] as HistoryMessage); + queued += 1; + } + }; + for (const event of buffered) { + if (event.kind === 'end') { + writeUpTo(event.messageCount ?? queued); + const data: TurnEntryData = { + phase: 'end', + turnId: currentTurnId, + outcome: event.outcome as TurnOutcome, + }; + write({ type: 'turn', kind: 'agent', data }); + } else { + writeUpTo(context.messages.length); + const data: TurnEntryData = { phase: 'start', turnId: event.turnId as number }; + write({ type: 'turn', kind: 'agent', data }); + } + } + buffered.length = 0; + writeUpTo(context.messages.length); + for (const [name, get] of Object.entries(opts?.states ?? {})) { + const value = get(); + const json = JSON.stringify(value); + if (json !== stateValues.get(name)) { + stateValues.set(name, json); + write({ type: 'state', kind: 'agent', data: { name, value } }); + } + } + }; + + const subscription = actor.subscribe((snapshot) => { + flushSnapshot(snapshot); + }); + + return { + get persistedMessages() { + return completed; + }, + flush: () => branch.settled(), + dispose: () => { + subscription.unsubscribe(); + for (const sub of subscriptions) { + sub.unsubscribe(); + } + }, + }; +} diff --git a/packages/agent-core-v2/src/human/persist/open.ts b/packages/agent-core-v2/src/human/persist/open.ts new file mode 100644 index 00000000000..76d0ae91548 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/open.ts @@ -0,0 +1,30 @@ +import { NodeBackend } from '#/store/backend/node'; +import { TreeStore } from '#/store/store'; +import type { Tree } from '#/store/tree'; + +import { isV2SessionDir, migrateV2Session, V2_SESSION_TREE_NAME } from './v2/migrate'; + +export interface OpenSessionStoreOptions { + treeName?: string; + fsync?: boolean; +} + +export interface OpenedSessionStore { + store: TreeStore; + tree: Tree; + migrated: boolean; +} + +export async function openSessionStore( + dir: string, + opts?: OpenSessionStoreOptions, +): Promise { + let migrated = false; + if (await isV2SessionDir(dir)) { + await migrateV2Session(dir); + migrated = true; + } + const store = await TreeStore.open(new NodeBackend(dir), { fsync: opts?.fsync ?? false }); + const tree = await store.tree(opts?.treeName ?? V2_SESSION_TREE_NAME); + return { store, tree, migrated }; +} diff --git a/packages/agent-core-v2/src/human/persist/session.ts b/packages/agent-core-v2/src/human/persist/session.ts new file mode 100644 index 00000000000..9074d99ad70 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/session.ts @@ -0,0 +1,207 @@ +import type { ActorRefFrom } from '#/xstate2'; + +import { loadAgentState, type LoadedAgentState } from '#/agent/replay'; +import { persistAgent, type AgentPersistence } from '#/persist/agent'; +import type { Branch } from '#/store/branch'; +import type { Tree } from '#/store/tree'; + +import type { AgentActorRef, createSessionMachine } from '#/session/machine'; + +type SessionActor = ActorRefFrom>; + +export interface PersistSessionOptions { + states?: (agentId: string) => Record unknown>; + branches?: ReadonlyMap; + onError?: (error: unknown) => void; +} + +export interface SessionPersistence { + flush(): Promise; + dispose(): void; +} + +export interface LoadedSessionAgent extends LoadedAgentState { + agentId: string; + branch: string; +} + +export interface LoadedSessionState { + agents: LoadedSessionAgent[]; + meta?: unknown; +} + +interface AgentHandle { + persistence: AgentPersistence; + branch: Branch; +} + +export const SESSION_LOG_BRANCH = '_session'; +export const SESSION_AGENT_OPEN_ENTRY_TYPE = 'agent.open'; +export const SESSION_META_ENTRY_TYPE = 'session.meta'; +const CLOSE_ENTRY_TYPE = 'agent.close'; +const SWITCH_ENTRY_TYPE = 'agent.switch'; + +export function persistSession( + session: SessionActor, + tree: Tree, + opts?: PersistSessionOptions, +): SessionPersistence { + const report = opts?.onError ?? ((error: unknown) => console.error(error)); + const handles = new Map(); + let logBranch: Branch | undefined; + + const log = (): Branch => { + logBranch ??= tree.has(SESSION_LOG_BRANCH) + ? tree.openBranch(SESSION_LOG_BRANCH) + : tree.createBranch(SESSION_LOG_BRANCH); + return logBranch; + }; + + const attach = ( + agentId: string, + ref: AgentActorRef, + branch: Branch, + persistedMessages?: number, + ): void => { + const persistence = persistAgent(ref, branch, { + states: opts?.states?.(agentId), + onError: report, + persistedMessages, + }); + handles.set(agentId, { persistence, branch }); + }; + + const attachCreated = (agentId: string, branchId: string, ref: AgentActorRef): void => { + const mapped = opts?.branches?.get(agentId); + if (mapped !== undefined) { + attach(agentId, ref, tree.openBranch(mapped)); + return; + } + if (tree.has(branchId)) { + report(new Error(`branch '${branchId}' already exists for agent '${agentId}'`)); + return; + } + const branch = tree.createBranch(branchId); + void log() + .append({ type: SESSION_AGENT_OPEN_ENTRY_TYPE, kind: 'session', data: { agentId, branch: branch.name } }) + .catch(report); + attach(agentId, ref, branch); + }; + + const attachForked = ( + sourceId: string, + agentId: string, + branchId: string, + ref: AgentActorRef, + ): void => { + const source = handles.get(sourceId); + if (source === undefined) { + report(new Error(`cannot persist fork of unknown agent '${sourceId}'`)); + return; + } + if (tree.has(branchId)) { + report(new Error(`branch '${branchId}' already exists for agent '${agentId}'`)); + return; + } + const head = source.branch.head; + const branch = + head === null + ? tree.createBranch(branchId) + : tree.createBranch(branchId, { from: { branch: source.branch.name, seq: head } }); + void log() + .append({ type: SESSION_AGENT_OPEN_ENTRY_TYPE, kind: 'session', data: { agentId, branch: branch.name } }) + .catch(report); + attach(agentId, ref, branch, source.persistence.persistedMessages); + }; + + const attachSwitched = (agentId: string, branchId: string, reason?: string): void => { + const entry = session.getSnapshot().context.agents[agentId]; + if (entry === undefined) return; + const previous = handles.get(agentId); + const from = + previous === undefined + ? null + : { branch: previous.branch.name, seq: previous.branch.head }; + previous?.persistence.dispose(); + attach(agentId, entry.ref, tree.openBranch(branchId)); + void log() + .append({ + type: SWITCH_ENTRY_TYPE, + kind: 'session', + data: { agentId, branch: branchId, reason, from }, + }) + .catch(report); + }; + + const detach = (agentId: string): void => { + const handle = handles.get(agentId); + if (handle === undefined) return; + handles.delete(agentId); + handle.persistence.dispose(); + void log().append({ type: CLOSE_ENTRY_TYPE, kind: 'session', data: { agentId } }).catch(report); + }; + + for (const [agentId, entry] of Object.entries(session.getSnapshot().context.agents)) { + attachCreated(agentId, entry.ref.getSnapshot().context.branchId, entry.ref); + } + + const subscriptions = [ + session.on('agent.created', (event) => { + if (event.type !== 'agent.created') return; + attachCreated(event.agentId, event.branchId, event.ref); + }), + session.on('agent.forked', (event) => { + if (event.type !== 'agent.forked') return; + attachForked(event.sourceId, event.agentId, event.branchId, event.ref); + }), + session.on('agent.switched', (event) => { + if (event.type !== 'agent.switched') return; + attachSwitched(event.agentId, event.branchId, event.reason); + }), + session.on('agent.stopped', (event) => { + if (event.type !== 'agent.stopped') return; + detach(event.agentId); + }), + ]; + + return { + flush: async () => { + await Promise.all([...handles.values()].map((handle) => handle.persistence.flush())); + await logBranch?.settled(); + }, + dispose: () => { + for (const subscription of subscriptions) { + subscription.unsubscribe(); + } + for (const handle of handles.values()) { + handle.persistence.dispose(); + } + handles.clear(); + }, + }; +} + +export async function loadSessionState(tree: Tree): Promise { + const agents: LoadedSessionAgent[] = []; + if (!tree.has(SESSION_LOG_BRANCH)) return { agents }; + const open = new Map(); + let meta: unknown; + const entries = [...tree.openBranch(SESSION_LOG_BRANCH).walk()].toReversed(); + for (const entry of entries) { + const data = await tree.resolve(entry); + if (entry.type === SESSION_AGENT_OPEN_ENTRY_TYPE || entry.type === SWITCH_ENTRY_TYPE) { + const opened = data as { agentId: string; branch: string }; + open.set(opened.agentId, opened.branch); + } else if (entry.type === CLOSE_ENTRY_TYPE) { + const closed = data as { agentId: string }; + open.delete(closed.agentId); + } else if (entry.type === SESSION_META_ENTRY_TYPE) { + meta = data; + } + } + for (const [agentId, branch] of [...open.entries()].sort(([a], [b]) => a.localeCompare(b))) { + const loaded = await loadAgentState(tree, branch); + agents.push({ agentId, branch, ...loaded }); + } + return meta === undefined ? { agents } : { agents, meta }; +} diff --git a/packages/agent-core-v2/src/human/persist/v2/convert.ts b/packages/agent-core-v2/src/human/persist/v2/convert.ts new file mode 100644 index 00000000000..5f39d25ddd3 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/v2/convert.ts @@ -0,0 +1,122 @@ +import type { AssistantMeta, HistoryMessage } from '#/agent/turn'; +import type { FinishReason } from '#/llm/finish-reason'; +import type { ContentPart } from '#/llm/message'; +import { emptyUsage } from '#/llm/usage'; + +import type { V2AssistantExtra, V2ContextMessage, V2PromptOrigin } from './fold'; + +const BLOBREF_PROTOCOL = 'blobref:'; +const MISSING_MEDIA_PLACEHOLDER = '[media missing]'; + +export type V2BlobResolver = (hash: string) => Promise; + +function parseBlobRef(url: string): { mimeType: string; hash: string } | undefined { + if (!url.startsWith(BLOBREF_PROTOCOL)) return undefined; + const rest = url.slice(BLOBREF_PROTOCOL.length); + const semiIndex = rest.indexOf(';'); + if (semiIndex === -1) return undefined; + const hash = rest.slice(semiIndex + 1); + if (hash.length === 0) return undefined; + return { mimeType: rest.slice(0, semiIndex), hash }; +} + +async function resolvePart(part: ContentPart, resolveBlob: V2BlobResolver): Promise { + let updated: Record | undefined; + for (const [key, value] of Object.entries(part)) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue; + if (!('url' in value)) continue; + const url = (value as { url: unknown }).url; + if (typeof url !== 'string') continue; + const ref = parseBlobRef(url); + if (ref === undefined) continue; + const payload = await resolveBlob(ref.hash); + const resolved = payload === null ? MISSING_MEDIA_PLACEHOLDER : `data:${ref.mimeType};base64,${payload}`; + if (updated === undefined) updated = { ...part }; + updated[key] = { ...(value as object), url: resolved }; + } + return updated === undefined ? part : (updated as unknown as ContentPart); +} + +async function resolveContent( + content: readonly ContentPart[], + resolveBlob: V2BlobResolver, +): Promise { + const parts: ContentPart[] = []; + for (const part of content) { + parts.push(await resolvePart(part, resolveBlob)); + } + return parts; +} + +function mapOriginToSource(origin: V2PromptOrigin | undefined): string { + if (origin === undefined) return 'input'; + if (origin.kind === 'user') return 'input'; + if ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ) { + return 'input'; + } + return origin.kind; +} + +function mapFinishReason(reason: string | undefined): FinishReason | null { + switch (reason) { + case 'tool_use': + return 'tool_calls'; + case 'end_turn': + return 'completed'; + case 'max_tokens': + return 'truncated'; + case 'filtered': + return 'filtered'; + case 'paused': + return 'paused'; + case 'other': + return 'other'; + default: + return null; + } +} + +function buildAssistantMeta(extra: V2AssistantExtra | undefined): AssistantMeta { + const meta: AssistantMeta = { usage: extra?.usage ?? emptyUsage() }; + if (extra === undefined) return meta; + if (extra.model !== undefined) meta.model = extra.model; + if (extra.messageId !== undefined) meta.messageId = extra.messageId; + const rawFinishReason = extra.rawFinishReason ?? extra.providerFinishReason ?? null; + const finishReason = mapFinishReason(extra.finishReason); + if (finishReason !== null || rawFinishReason !== null) { + meta.finish = { finishReason, rawFinishReason }; + } + return meta; +} + +export async function convertV2Message( + message: V2ContextMessage, + extra: V2AssistantExtra | undefined, + resolveBlob: V2BlobResolver, +): Promise { + const content = await resolveContent(message.content ?? [], resolveBlob); + switch (message.role) { + case 'system': + return { message: { role: 'system', content }, meta: {} }; + case 'user': + return { + message: { role: 'user', content }, + meta: { source: mapOriginToSource(message.origin) }, + }; + case 'assistant': + return { + message: { role: 'assistant', content, toolCalls: message.toolCalls ?? [] }, + meta: buildAssistantMeta(extra), + }; + case 'tool': + return { + message: { role: 'tool', content, toolCallId: message.toolCallId ?? '' }, + meta: { source: 'tool' }, + }; + default: + return null; + } +} diff --git a/packages/agent-core-v2/src/human/persist/v2/fold.ts b/packages/agent-core-v2/src/human/persist/v2/fold.ts new file mode 100644 index 00000000000..e04251cfbb6 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/v2/fold.ts @@ -0,0 +1,644 @@ +import type { ContentPart, ToolCall } from '#/llm/message'; +import type { TokenUsage } from '#/llm/usage'; +import { readTodoItems, type TodoItem } from '#/todo/todoItem'; + +import { V2WireError, type V2WireRecord } from './wire'; + +const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.'; + +const COMPACT_USER_MESSAGE_MAX_TOKENS = 20_000; +const COMPACT_USER_MESSAGE_HEAD_TOKENS = 2_000; +const MEDIA_TOKEN_ESTIMATE = 2000; + +export interface V2PromptOrigin { + kind: string; + trigger?: string; + variant?: string; + ownerPromptId?: string; + [key: string]: unknown; +} + +export interface V2ContextMessage { + role: string; + content: ContentPart[]; + toolCalls?: ToolCall[]; + toolCallId?: string; + partial?: boolean; + id?: string; + providerMessageId?: string; + origin?: V2PromptOrigin; + isError?: boolean; + note?: string; +} + +export interface V2AssistantExtra { + usage?: TokenUsage; + finishReason?: string; + rawFinishReason?: string; + providerFinishReason?: string; + model?: { provider: string; model: string }; + messageId?: string; +} + +export interface FoldedV2Agent { + messages: V2ContextMessage[]; + nextTurnId: number; + todos: readonly TodoItem[]; + assistantExtras: Map; +} + +interface V2LoopEvent { + type: string; + uuid?: string; + stepUuid?: string; + turnId?: string; + step?: number; + part?: ContentPart; + toolCallId?: string; + name?: string; + args?: unknown; + extras?: Record; + result?: { output?: unknown; isError?: boolean; note?: string }; + finishReason?: string; + usage?: TokenUsage; + rawFinishReason?: string; + providerFinishReason?: string; + messageId?: string; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function asMessage(value: unknown): V2ContextMessage | undefined { + if (!isObject(value)) return undefined; + if (typeof value['role'] !== 'string') return undefined; + const content = value['content']; + const toolCalls = value['toolCalls']; + return { + ...(value as unknown as V2ContextMessage), + content: Array.isArray(content) ? (content as ContentPart[]) : [], + toolCalls: Array.isArray(toolCalls) ? (toolCalls as ToolCall[]) : [], + }; +} + +function isVacuousContentPart(part: ContentPart): boolean { + switch (part.type) { + case 'text': + return part.text.trim().length === 0; + case 'think': + return part.encrypted === undefined && part.think.trim().length === 0; + case 'image_url': + case 'audio_url': + case 'video_url': + return false; + default: + return false; + } +} + +function isUndoAnchorOrigin(origin: V2PromptOrigin | undefined): boolean { + if (origin === undefined || origin.kind === 'user') return true; + return ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ); +} + +function isUndoAnchor(message: V2ContextMessage): boolean { + return message.role === 'user' && isUndoAnchorOrigin(message.origin); +} + +function isPromptOwnedInjection(message: V2ContextMessage, prompt: V2ContextMessage): boolean { + const origin = message.origin; + return ( + origin?.kind === 'injection' && + origin.ownerPromptId !== undefined && + origin.ownerPromptId === prompt.id + ); +} + +function estimateTokens(text: string): number { + let asciiCount = 0; + let nonAsciiCount = 0; + for (const char of text) { + if ((char.codePointAt(0) as number) <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + } + return Math.ceil(asciiCount / 4) + nonAsciiCount; +} + +function estimateTokensForMessage(message: V2ContextMessage): number { + let total = estimateTokens(message.role); + for (const part of message.content) { + switch (part.type) { + case 'text': + total += estimateTokens(part.text); + break; + case 'think': + total += estimateTokens(part.think); + break; + case 'image_url': + case 'audio_url': + case 'video_url': + total += MEDIA_TOKEN_ESTIMATE; + break; + } + } + for (const call of message.toolCalls ?? []) { + total += estimateTokens(call.name); + total += estimateTokens(JSON.stringify(call.arguments)); + } + return total; +} + +function extractText(content: readonly ContentPart[]): string { + let text = ''; + for (const part of content) { + if (part.type === 'text') text += part.text; + } + return text; +} + +function truncateTextToTokens(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + let asciiCount = 0; + let nonAsciiCount = 0; + let end = 0; + for (const char of text) { + if ((char.codePointAt(0) as number) <= 127) { + asciiCount++; + } else { + nonAsciiCount++; + } + if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break; + end += char.length; + } + return text.slice(0, end); +} + +function truncateTextToTokensFromEnd(text: string, maxTokens: number): string { + if (maxTokens <= 0) return ''; + let asciiCount = 0; + let nonAsciiCount = 0; + let start = text.length; + for (let i = text.length - 1; i >= 0; i--) { + let isAscii = false; + const code = text.charCodeAt(i); + if (code >= 0xdc00 && code <= 0xdfff && i > 0) { + const high = text.charCodeAt(i - 1); + if (high >= 0xd800 && high <= 0xdbff) { + i--; + } + } else { + isAscii = code <= 127; + } + if (isAscii) { + asciiCount++; + } else { + nonAsciiCount++; + } + if (Math.ceil(asciiCount / 4) + nonAsciiCount > maxTokens) break; + start = i; + } + return text.slice(start); +} + +function replaceMessageText(message: V2ContextMessage, text: string): V2ContextMessage { + return { ...message, content: [{ type: 'text', text }], toolCalls: [] }; +} + +function wrapSystemReminder(content: string): string { + return `\n${content.trim()}\n`; +} + +function createCompactionSummaryMessage(text: string): V2ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }; +} + +function createCompactionElisionMessage(omittedTokens: number): V2ContextMessage { + return { + role: 'user', + content: [ + { + type: 'text', + text: wrapSystemReminder( + `Some of this conversation's user messages were omitted here during compaction: the messages above this note are the oldest user input, the messages below are the most recent, and roughly ${String(omittedTokens)} tokens in between were dropped. The omitted content is covered by the compaction summary at the end of the conversation.`, + ), + }, + ], + toolCalls: [], + origin: { kind: 'injection', variant: 'compaction_elision' }, + }; +} + +function isCompactableUserMessage(message: V2ContextMessage): boolean { + if (message.role !== 'user') return false; + if (message.origin?.kind === 'compaction_summary') return false; + return isUndoAnchorOrigin(message.origin); +} + +interface CompactionShapeInput { + summaryText: string; + legacySummaryMessage?: V2ContextMessage; + contextSummary?: string; + compactedCount: number; + legacyTail: boolean; +} + +function readCompactionShapeInput(record: V2WireRecord): CompactionShapeInput { + const summary = record['summary']; + const contextSummary = record['contextSummary']; + let summaryText: string; + let legacySummaryMessage: V2ContextMessage | undefined; + if (typeof summary === 'string') { + summaryText = summary; + } else if (typeof contextSummary === 'string') { + summaryText = contextSummary; + } else { + const message = asMessage(summary); + if (message === undefined) { + throw new V2WireError( + 'invalid-compaction-record', + 'context.apply_compaction record is missing a usable summary', + ); + } + legacySummaryMessage = message; + summaryText = extractText(message.content); + } + const compactedCount = record['compactedCount']; + const legacyCount = record['count']; + const count = + typeof compactedCount === 'number' + ? compactedCount + : typeof legacyCount === 'number' + ? legacyCount + : undefined; + if (count === undefined) { + throw new V2WireError( + 'invalid-compaction-record', + 'context.apply_compaction record is missing compactedCount', + ); + } + const legacyTailField = record['legacyTail']; + const keptUserMessageCount = record['keptUserMessageCount']; + return { + summaryText, + legacySummaryMessage, + contextSummary: typeof contextSummary === 'string' ? contextSummary : undefined, + compactedCount: count, + legacyTail: + typeof legacyTailField === 'boolean' ? legacyTailField : keptUserMessageCount === undefined, + }; +} + +function selectCompactionUserMessages( + messages: readonly V2ContextMessage[], + maxTokens: number, + headTokens: number, +): { head: V2ContextMessage[]; tail: V2ContextMessage[]; elided: boolean; omittedTokens: number } { + let totalTokens = 0; + for (const message of messages) { + totalTokens += estimateTokensForMessage(message); + } + if (totalTokens <= maxTokens) { + return { head: [], tail: [...messages], elided: false, omittedTokens: 0 }; + } + const headBudget = Math.min(Math.max(headTokens, 0), maxTokens); + const tailBudget = maxTokens - headBudget; + const tail: V2ContextMessage[] = []; + let tailRemaining = tailBudget; + let headEndExclusive = messages.length; + let tailBoundaryDroppedPrefix: V2ContextMessage | null = null; + for (let i = messages.length - 1; i >= 0 && tailRemaining > 0; i--) { + const message = messages[i] as V2ContextMessage; + const tokens = estimateTokensForMessage(message); + if (tokens <= tailRemaining) { + tail.push(message); + tailRemaining -= tokens; + headEndExclusive = i; + continue; + } + const fullText = extractText(message.content); + const keptSuffix = truncateTextToTokensFromEnd(fullText, tailRemaining); + tail.push(replaceMessageText(message, keptSuffix)); + headEndExclusive = i; + const droppedPrefix = fullText.slice(0, fullText.length - keptSuffix.length); + if (droppedPrefix.length > 0) { + tailBoundaryDroppedPrefix = replaceMessageText(message, droppedPrefix); + } + break; + } + tail.reverse(); + const headCandidates = messages.slice(0, headEndExclusive); + if (tailBoundaryDroppedPrefix !== null) { + headCandidates.push(tailBoundaryDroppedPrefix); + } + const head: V2ContextMessage[] = []; + let headRemaining = headBudget; + for (const message of headCandidates) { + if (headRemaining <= 0) break; + const tokens = estimateTokensForMessage(message); + if (tokens <= headRemaining) { + head.push(message); + headRemaining -= tokens; + continue; + } + head.push(replaceMessageText(message, truncateTextToTokens(extractText(message.content), headRemaining))); + break; + } + let keptTokens = 0; + for (const message of head) keptTokens += estimateTokensForMessage(message); + for (const message of tail) keptTokens += estimateTokensForMessage(message); + return { head, tail, elided: true, omittedTokens: Math.max(0, totalTokens - keptTokens) }; +} + +function buildCompactionMessages( + history: readonly V2ContextMessage[], + input: CompactionShapeInput, +): V2ContextMessage[] { + const contextSummary = input.contextSummary ?? input.summaryText; + if (input.legacyTail) { + return [ + input.legacySummaryMessage ?? createCompactionSummaryMessage(contextSummary), + ...history.slice(input.compactedCount), + ]; + } + const compactable = history.filter(isCompactableUserMessage); + const selection = selectCompactionUserMessages( + compactable, + COMPACT_USER_MESSAGE_MAX_TOKENS, + COMPACT_USER_MESSAGE_HEAD_TOKENS, + ); + const kept = selection.elided + ? [...selection.head, createCompactionElisionMessage(selection.omittedTokens), ...selection.tail] + : [...selection.head, ...selection.tail]; + return [...kept, createCompactionSummaryMessage(contextSummary)]; +} + +interface UndoCut { + cutIndex: number; + removedCount: number; +} + +function computeUndoCut(state: readonly V2ContextMessage[], count: number): UndoCut { + let remaining = count; + let cutIndex = -1; + let removedCount = 0; + for (let i = state.length - 1; i >= 0 && remaining > 0; i--) { + const message = state[i] as V2ContextMessage; + if (message.origin?.kind === 'injection') continue; + if (message.origin?.kind === 'compaction_summary') break; + if (isUndoAnchor(message)) { + remaining--; + removedCount++; + cutIndex = i; + while (cutIndex > 0 && isPromptOwnedInjection(state[cutIndex - 1] as V2ContextMessage, message)) { + cutIndex--; + } + } + } + return { cutIndex, removedCount }; +} + +export function foldV2WireRecords(records: readonly V2WireRecord[]): FoldedV2Agent { + const messages: V2ContextMessage[] = []; + const assistantExtras = new Map(); + let openIndex = -1; + let openStepUuid: string | undefined; + let openHasToolCalls = false; + let openVacuous = true; + let stepExtra: V2AssistantExtra | undefined; + let lastModel: { provider: string; model: string } | undefined; + const pending = new Set(); + let deferred: V2ContextMessage[] = []; + let todos: readonly TodoItem[] = []; + let nextTurnId = 0; + const cancelledTurnIds = new Set(); + + const advanceTurnClock = (target: number): void => { + for (const id of cancelledTurnIds) { + if (id < target) cancelledTurnIds.delete(id); + } + while (cancelledTurnIds.delete(target)) target += 1; + nextTurnId = target; + }; + + const resetFold = (): void => { + openIndex = -1; + openStepUuid = undefined; + openHasToolCalls = false; + openVacuous = true; + stepExtra = undefined; + pending.clear(); + deferred = []; + }; + + const flushDeferred = (): void => { + if (pending.size > 0 || deferred.length === 0) return; + messages.push(...deferred); + deferred = []; + }; + + const closePending = (): void => { + if (pending.size === 0) return; + for (const toolCallId of pending) { + messages.push({ + role: 'tool', + content: [{ type: 'text', text: TOOL_INTERRUPTED_ON_RESUME_OUTPUT }], + toolCalls: [], + toolCallId, + isError: true, + }); + } + pending.clear(); + flushDeferred(); + }; + + const settleOpen = (): void => { + if (openStepUuid === undefined) return; + closePending(); + if (openIndex !== -1) { + const open = messages[openIndex] as V2ContextMessage; + if (!openHasToolCalls && openVacuous) { + messages.splice(openIndex, 1); + } else { + delete open.partial; + const extra: V2AssistantExtra = { ...stepExtra, model: stepExtra?.model ?? lastModel }; + if ( + extra.usage !== undefined || + extra.finishReason !== undefined || + extra.model !== undefined || + extra.messageId !== undefined + ) { + assistantExtras.set(open, extra); + } + } + } + openIndex = -1; + openStepUuid = undefined; + stepExtra = undefined; + }; + + const acceptsOpenStep = (stepUuid: unknown): stepUuid is string => { + if (openStepUuid === undefined) return false; + return stepUuid === openStepUuid; + }; + + const foldLoopEvent = (event: V2LoopEvent): void => { + switch (event.type) { + case 'step.begin': { + settleOpen(); + messages.push({ role: 'assistant', content: [], toolCalls: [], partial: true }); + openIndex = messages.length - 1; + openStepUuid = event.uuid; + openHasToolCalls = false; + openVacuous = true; + return; + } + case 'step.end': { + if (event.finishReason === 'interrupted' || event.finishReason === 'error') return; + if (openStepUuid !== undefined) { + stepExtra = { + usage: event.usage, + finishReason: event.finishReason, + rawFinishReason: event.rawFinishReason, + providerFinishReason: event.providerFinishReason, + messageId: event.messageId, + }; + } + settleOpen(); + flushDeferred(); + return; + } + case 'content.part': { + if (!acceptsOpenStep(event.stepUuid)) return; + if (openIndex === -1 || event.part === undefined) return; + (messages[openIndex] as V2ContextMessage).content.push(event.part); + openVacuous = openVacuous && isVacuousContentPart(event.part); + return; + } + case 'tool.call': { + if (!acceptsOpenStep(event.stepUuid)) return; + if (openIndex === -1 || typeof event.toolCallId !== 'string') return; + const call: ToolCall = { + type: 'function', + id: event.toolCallId, + name: typeof event.name === 'string' ? event.name : '', + arguments: event.args === undefined ? null : JSON.stringify(event.args), + ...(event.extras !== undefined ? { extras: event.extras } : {}), + }; + (messages[openIndex] as V2ContextMessage).toolCalls?.push(call); + pending.add(event.toolCallId); + openHasToolCalls = true; + return; + } + case 'tool.result': { + const toolCallId = event.toolCallId; + if (typeof toolCallId !== 'string' || !pending.has(toolCallId)) return; + pending.delete(toolCallId); + const output = event.result?.output; + messages.push({ + role: 'tool', + content: + typeof output === 'string' + ? [{ type: 'text', text: output }] + : Array.isArray(output) + ? ([...output] as ContentPart[]) + : [], + toolCalls: [], + toolCallId, + isError: event.result?.isError, + note: event.result?.note, + }); + flushDeferred(); + return; + } + } + }; + + for (const record of records) { + switch (record.type) { + case 'context.append_message': { + const message = asMessage(record['message']); + if (message === undefined) continue; + if (pending.size > 0) { + deferred.push(message); + } else { + messages.push(message); + } + break; + } + case 'context.append_loop_event': { + const event = record['event']; + if (!isObject(event) || typeof event['type'] !== 'string') continue; + const loopEvent = event as unknown as V2LoopEvent; + if (loopEvent.type !== 'tool.result' && typeof loopEvent.turnId === 'string') { + const turnId = Number.parseInt(loopEvent.turnId, 10); + if (Number.isInteger(turnId) && turnId >= nextTurnId) { + advanceTurnClock(turnId + 1); + } + } + foldLoopEvent(loopEvent); + break; + } + case 'context.clear': { + messages.length = 0; + resetFold(); + break; + } + case 'context.undo': { + const count = record['count'] as number; + if (messages.length === 0) break; + const cut = computeUndoCut(messages, count); + if (cut.cutIndex < 0 || cut.removedCount < count) break; + messages.length = cut.cutIndex; + resetFold(); + break; + } + case 'context.apply_compaction': { + const input = readCompactionShapeInput(record); + const compacted = buildCompactionMessages(messages, input); + messages.length = 0; + messages.push(...compacted); + resetFold(); + break; + } + case 'turn.prompt': { + advanceTurnClock(nextTurnId + 1); + break; + } + case 'turn.cancel': { + const target = record['target']; + const turnId = record['turnId']; + if (target === undefined || typeof turnId !== 'number' || turnId < nextTurnId) break; + cancelledTurnIds.add(turnId); + advanceTurnClock(nextTurnId); + break; + } + case 'llm.request': { + lastModel = { provider: record['provider'] as string, model: record['model'] as string }; + break; + } + case 'tools.update_store': { + if (record['key'] !== 'todo') break; + todos = readTodoItems(record['value']); + break; + } + } + } + + settleOpen(); + flushDeferred(); + + return { messages, nextTurnId, todos, assistantExtras }; +} diff --git a/packages/agent-core-v2/src/human/persist/v2/migrate.ts b/packages/agent-core-v2/src/human/persist/v2/migrate.ts new file mode 100644 index 00000000000..b67cca1ceb1 --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/v2/migrate.ts @@ -0,0 +1,211 @@ +import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'; +import { basename, join } from 'node:path'; + +import { SESSION_AGENT_OPEN_ENTRY_TYPE, SESSION_LOG_BRANCH, SESSION_META_ENTRY_TYPE } from '#/persist/session'; +import { NodeBackend } from '#/store/backend/node'; +import { TreeStore } from '#/store/store'; + +import { convertV2Message, type V2BlobResolver } from './convert'; +import { foldV2WireRecords } from './fold'; +import { readV2WireRecords, type V2WireRecord } from './wire'; + +export const V2_SESSION_TREE_NAME = 'session'; + +export class V2MigrationError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'V2MigrationError'; + this.code = code; + } +} + +export interface V2MigrationResult { + treeName: string; + agents: string[]; +} + +async function pathIsDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +async function pathIsFile(path: string): Promise { + try { + return (await stat(path)).isFile(); + } catch { + return false; + } +} + +async function listV2AgentIds(dir: string): Promise { + const agentsDir = join(dir, 'agents'); + let names: string[]; + try { + names = await readdir(agentsDir); + } catch { + return []; + } + const ids: string[] = []; + for (const name of names.sort()) { + if (await pathIsFile(join(agentsDir, name, 'wire.jsonl'))) ids.push(name); + } + return ids; +} + +export async function isV2SessionDir(dir: string): Promise { + if (await pathIsDirectory(join(dir, 'trees'))) return false; + if (!(await pathIsFile(join(dir, 'state.json')))) return false; + return (await listV2AgentIds(dir)).length > 0; +} + +function toEpochMs(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Date.parse(value); + if (!Number.isNaN(parsed)) return parsed; + } + return 0; +} + +function normalizeTitle(raw: Record): { title?: string; titleKind?: string } { + const title = typeof raw['title'] === 'string' ? raw['title'] : undefined; + if (title !== undefined && raw['isCustomTitle'] === true) return { title, titleKind: 'custom' }; + if ( + title !== undefined && + (raw['titleKind'] === 'replaceable' || + raw['titleKind'] === 'generated' || + raw['titleKind'] === 'custom') + ) { + return { title, titleKind: raw['titleKind'] }; + } + if (title !== undefined && raw['isCustomTitle'] === false) return { title, titleKind: 'replaceable' }; + if (typeof raw['customTitle'] === 'string') return { title: raw['customTitle'], titleKind: 'custom' }; + return title === undefined ? {} : { title, titleKind: 'replaceable' }; +} + +function normalizeV2SessionMeta( + raw: Record, + fallbackId: string, +): Record { + const { + workDir, + titleSource: _titleSource, + isCustomTitle: _isCustomTitle, + customTitle: _customTitle, + createdAt, + updatedAt, + ...rest + } = raw; + const cwd = + typeof rest['cwd'] === 'string' + ? rest['cwd'] + : typeof workDir === 'string' && workDir.length > 0 + ? workDir + : undefined; + const { title, titleKind } = normalizeTitle(raw); + return { + ...rest, + id: typeof rest['id'] === 'string' ? rest['id'] : fallbackId, + version: 2, + cwd, + title, + titleKind, + createdAt: toEpochMs(createdAt), + updatedAt: toEpochMs(updatedAt), + archived: rest['archived'] === true, + }; +} + +export async function migrateV2Session(dir: string): Promise { + if (!(await isV2SessionDir(dir))) { + throw new V2MigrationError('not-v2-session', `${dir} is not a v2 session directory`); + } + const statePath = join(dir, 'state.json'); + let rawMeta: unknown; + try { + rawMeta = JSON.parse(await readFile(statePath, 'utf8')); + } catch (error) { + throw new V2MigrationError( + 'invalid-state-json', + `cannot read or parse ${statePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (typeof rawMeta !== 'object' || rawMeta === null || Array.isArray(rawMeta)) { + throw new V2MigrationError('invalid-state-json', `${statePath} does not contain an object`); + } + const meta = normalizeV2SessionMeta(rawMeta as Record, basename(dir)); + const agentIds = await listV2AgentIds(dir); + const tmp = join( + dir, + `.v3-migrate-${process.pid.toString(36)}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + ); + try { + const store = await TreeStore.open(new NodeBackend(tmp)); + const tree = await store.tree(V2_SESSION_TREE_NAME); + const log = tree.createBranch(SESSION_LOG_BRANCH); + for (const agentId of agentIds) { + const agentDir = join(dir, 'agents', agentId); + const records: V2WireRecord[] = []; + for await (const record of readV2WireRecords(join(agentDir, 'wire.jsonl'), { agentId })) { + records.push(record); + } + const folded = foldV2WireRecords(records); + const branch = tree.createBranch(agentId); + const resolveBlob: V2BlobResolver = async (hash) => { + try { + return (await readFile(join(agentDir, 'blobs', hash))).toString('base64'); + } catch { + return null; + } + }; + for (const message of folded.messages) { + const converted = await convertV2Message( + message, + folded.assistantExtras.get(message), + resolveBlob, + ); + if (converted === null) continue; + await branch.append({ type: 'message', kind: 'agent', data: converted }); + } + const lastTurnId = folded.nextTurnId - 1; + if (folded.todos.length > 0) { + await branch.append({ + type: 'state', + kind: 'agent', + data: { + name: 'todo', + value: { todos: folded.todos, lastWriteTurn: Math.max(0, lastTurnId) }, + }, + }); + } + if (folded.nextTurnId > 0) { + await branch.append({ + type: 'turn', + kind: 'agent', + data: { phase: 'start', turnId: lastTurnId }, + }); + } + await log.append({ + type: SESSION_AGENT_OPEN_ENTRY_TYPE, + kind: 'session', + data: { agentId, branch: agentId }, + }); + } + await log.append({ type: SESSION_META_ENTRY_TYPE, kind: 'session', data: meta }); + for (const branchName of tree.branches()) { + await tree.openBranch(branchName).settled(); + } + if (await pathIsDirectory(join(tmp, 'blobs'))) { + await rename(join(tmp, 'blobs'), join(dir, 'blobs')); + } + await rename(join(tmp, 'trees'), join(dir, 'trees')); + } finally { + await rm(tmp, { recursive: true, force: true }); + } + return { treeName: V2_SESSION_TREE_NAME, agents: agentIds }; +} diff --git a/packages/agent-core-v2/src/human/persist/v2/wire.ts b/packages/agent-core-v2/src/human/persist/v2/wire.ts new file mode 100644 index 00000000000..19015e3848d --- /dev/null +++ b/packages/agent-core-v2/src/human/persist/v2/wire.ts @@ -0,0 +1,159 @@ +import { createReadStream } from 'node:fs'; +import { createInterface } from 'node:readline'; + +export const V2_WIRE_PROTOCOL_VERSION = '1.5'; + +export interface V2WireRecord { + type: string; + time?: number; + [key: string]: unknown; +} + +export class V2WireError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'V2WireError'; + this.code = code; + } +} + +const CONSUMED_TYPES = new Set([ + 'context.append_message', + 'context.append_loop_event', + 'context.apply_compaction', + 'context.clear', + 'context.undo', + 'turn.prompt', + 'turn.cancel', + 'turn.ended', + 'llm.request', + 'tools.update_store', +]); + +const TURN_END_REASONS = new Set(['completed', 'cancelled', 'failed', 'blocked']); + +function compareWireVersions(a: string, b: string): number { + const partsA = a.split('.'); + const partsB = b.split('.'); + const maxLength = Math.max(partsA.length, partsB.length); + for (let i = 0; i < maxLength; i++) { + const diff = Number(partsA[i] ?? '0') - Number(partsB[i] ?? '0'); + if (diff !== 0) return diff; + } + return 0; +} + +function migrateV1_0ToolCall(toolCall: unknown): unknown { + if (typeof toolCall !== 'object' || toolCall === null || Array.isArray(toolCall)) return toolCall; + const record = toolCall as Record; + const fn = record['function']; + if (typeof fn !== 'object' || fn === null || Array.isArray(fn)) return toolCall; + const { function: _fn, ...rest } = record; + const fnRecord = fn as Record; + return { ...rest, name: fnRecord['name'], arguments: fnRecord['arguments'] }; +} + +function migrateV1_0Record(record: V2WireRecord): V2WireRecord { + if (record.type !== 'context.append_message') return record; + const message = record['message']; + if (typeof message !== 'object' || message === null || Array.isArray(message)) return record; + const messageRecord = message as Record; + const toolCalls = messageRecord['toolCalls']; + if (!Array.isArray(toolCalls)) return record; + return { ...record, message: { ...messageRecord, toolCalls: toolCalls.map(migrateV1_0ToolCall) } }; +} + +function isValidCompactionRecord(record: V2WireRecord): boolean { + if (typeof record['summary'] === 'string' && typeof record['compactedCount'] === 'number') { + return true; + } + if (typeof record['contextSummary'] === 'string' && typeof record['compactedCount'] === 'number') { + return true; + } + return 'summary' in record && typeof record['count'] === 'number'; +} + +function passesValidation(record: V2WireRecord): boolean { + switch (record.type) { + case 'context.undo': { + const count = record['count']; + return typeof count === 'number' && Number.isSafeInteger(count) && count > 0; + } + case 'context.apply_compaction': + return isValidCompactionRecord(record); + case 'turn.prompt': { + const promptId = record['promptId']; + return promptId === undefined || typeof promptId === 'string'; + } + case 'turn.ended': { + const turnId = record['turnId']; + const reason = record['reason']; + return ( + typeof turnId === 'number' && + typeof reason === 'string' && + TURN_END_REASONS.has(reason) + ); + } + case 'llm.request': + return typeof record['provider'] === 'string' && typeof record['model'] === 'string'; + case 'tools.update_store': + return typeof record['key'] === 'string'; + default: + return true; + } +} + +export async function* readV2WireRecords( + path: string, + opts: { agentId: string }, +): AsyncGenerator { + const lines = createInterface({ + input: createReadStream(path, { encoding: 'utf8' }), + crlfDelay: Infinity, + }); + let version: string | undefined; + let first = true; + let migrate: (record: V2WireRecord) => V2WireRecord = (record) => record; + for await (const line of lines) { + let value: unknown; + try { + value = JSON.parse(line) as unknown; + } catch { + break; + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) break; + const record = value as V2WireRecord; + if (typeof record.type !== 'string') break; + if (first) { + first = false; + if (record.type === 'metadata') { + const declared = record['protocol_version']; + version = typeof declared === 'string' ? declared : V2_WIRE_PROTOCOL_VERSION; + if (compareWireVersions(version, V2_WIRE_PROTOCOL_VERSION) > 0) { + throw new V2WireError( + 'unsupported-wire-version', + `wire protocol version ${version} is newer than supported ${V2_WIRE_PROTOCOL_VERSION}`, + ); + } + if (compareWireVersions(version, '1.1') < 0) { + migrate = migrateV1_0Record; + } + continue; + } + version = '1.4'; + } + if (record.type === 'metadata') continue; + const migrated = migrate(record); + if (!CONSUMED_TYPES.has(migrated.type)) continue; + const agentId = migrated['agentId']; + if (agentId === undefined) { + migrated['agentId'] = opts.agentId; + } else if (agentId !== opts.agentId) { + continue; + } + if (!passesValidation(migrated)) continue; + yield migrated; + } +} diff --git a/packages/agent-core-v2/src/human/plugin.ts b/packages/agent-core-v2/src/human/plugin.ts new file mode 100644 index 00000000000..c09357119fb --- /dev/null +++ b/packages/agent-core-v2/src/human/plugin.ts @@ -0,0 +1,49 @@ +import type { SystemMessage, UserMessage } from '#/llm/message'; +import type { AgentEmitted } from '#/agent/machine'; +import type { ToolDefinition } from '#/tool/tool'; + +export interface AgentPluginTarget { + kind: 'agent'; + on(type: AgentEmitted['type'], handler: (event: AgentEmitted) => void): unknown; + notify(message: UserMessage): void; + remind(key: string, message: UserMessage | SystemMessage): void; +} + +export type PluginTarget = AgentPluginTarget; + +export interface Plugin { + readonly name: string; + tools?(): readonly ToolDefinition[]; + connect?(target: PluginTarget): void; +} + +export function collectPluginTools(plugins: readonly Plugin[]): readonly ToolDefinition[] { + return plugins.flatMap((plugin) => plugin.tools?.() ?? []); +} + +export interface AgentPluginSource { + on(type: AgentEmitted['type'], handler: (event: AgentEmitted) => void): unknown; + send( + event: + | { type: 'input.notify'; message: UserMessage } + | { type: 'input.reminder'; key: string; message: UserMessage | SystemMessage }, + ): void; +} + +export function connectPlugins(actor: AgentPluginSource, plugins: readonly Plugin[]): void { + const target: AgentPluginTarget = { + kind: 'agent', + on: (type, handler) => { + actor.on(type, handler); + }, + notify: (message) => { + actor.send({ type: 'input.notify', message }); + }, + remind: (key, message) => { + actor.send({ type: 'input.reminder', key, message }); + }, + }; + for (const plugin of plugins) { + plugin.connect?.(target); + } +} diff --git a/packages/agent-core-v2/src/human/session/machine.ts b/packages/agent-core-v2/src/human/session/machine.ts new file mode 100644 index 00000000000..1b942b11fbb --- /dev/null +++ b/packages/agent-core-v2/src/human/session/machine.ts @@ -0,0 +1,272 @@ +import { assign, emit, sendTo, setup, stopChild, type ActorRefFrom } from '#/xstate2'; + +import type { LlmRequestConfig } from '#/llm/requester/requester'; +import type { createAgentMachine, AgentEvent, AgentInput } from '#/agent/machine'; +import type { HistoryMessage, TurnLlmEvent, TurnToolEvent } from '#/agent/turn'; +import type { ToolUpdate } from '#/tool/executor'; + +export interface SessionInput { + request: LlmRequestConfig; +} + +export type AgentActorRef = ActorRefFrom>; + +export interface AgentEntry { + ref: AgentActorRef; +} + +export type SessionEvent = + | TurnLlmEvent + | TurnToolEvent + | { type: 'tool.update'; toolCallId: string; update: ToolUpdate } + | { type: 'agent.create'; agentId?: string; input?: Pick } + | { type: 'agent.fork'; sourceId: string; agentId?: string } + | { + type: 'agent.switch'; + agentId: string; + input: { branchId: string; history: readonly HistoryMessage[]; turnId: number; reason?: string }; + } + | { type: 'agent.send'; agentId: string; event: AgentEvent } + | { type: 'agent.stop'; agentId: string }; + +export type SessionEmitted = + | { type: 'agent.created'; agentId: string; branchId: string; ref: AgentActorRef } + | { type: 'agent.forked'; sourceId: string; agentId: string; branchId: string; ref: AgentActorRef } + | { type: 'agent.switched'; agentId: string; branchId: string; reason?: string } + | { type: 'agent.stopped'; agentId: string } + | { type: 'agent.error'; agentId: string; error: string }; + +export interface SessionMachineContext { + input: SessionInput; + agents: Record; + anonymousCount: number; +} + +export interface CreateSessionMachineOptions { + agent: ReturnType; +} + +function nextAnonymousCount(context: SessionMachineContext): number { + let count = context.anonymousCount + 1; + while (context.agents[`agent-${count}`] !== undefined) { + count += 1; + } + return count; +} + +export function createSessionMachine({ agent }: CreateSessionMachineOptions) { + return setup({ + types: { + input: {} as SessionInput, + context: {} as SessionMachineContext, + events: {} as SessionEvent, + emitted: {} as SessionEmitted, + }, + actors: { + agentActor: agent, + }, + }).createMachine({ + id: 'session', + initial: 'active', + context: ({ input }) => ({ + input, + agents: {}, + anonymousCount: 0, + }), + on: { + 'llm.sent': {}, + 'llm.delta': {}, + 'llm.headers': {}, + 'llm.done': {}, + 'llm.failed.syntax': {}, + 'llm.failed.remote': {}, + 'llm.retrying': {}, + 'llm.usage': {}, + 'llm.finish': {}, + 'llm.message-id': {}, + 'tool.async': {}, + 'tool.update': {}, + 'tool.done': {}, + 'tool.failed': {}, + 'tool.aborted': {}, + 'context.reset': {}, + }, + states: { + active: { + on: { + 'agent.create': [ + { + guard: ({ context, event }) => + event.agentId !== undefined && context.agents[event.agentId] !== undefined, + actions: emit(({ event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId as string, + error: `duplicate agent id: '${event.agentId}'`, + })), + }, + { + actions: [ + assign(({ context, event, spawn }) => { + const anonymousCount = + event.agentId === undefined + ? nextAnonymousCount(context) + : context.anonymousCount; + const agentId = event.agentId ?? `agent-${anonymousCount}`; + const ref = spawn('agentActor', { + id: agentId, + input: { + request: event.input?.request ?? context.input.request, + history: event.input?.history, + turnId: event.input?.turnId, + branchId: event.input?.branchId ?? agentId, + }, + }); + return { + agents: { ...context.agents, [agentId]: { ref } }, + anonymousCount, + }; + }), + emit(({ context, event }) => { + const agentId = event.agentId ?? `agent-${context.anonymousCount}`; + const entry = context.agents[agentId] as AgentEntry; + return { + type: 'agent.created' as const, + agentId, + branchId: event.input?.branchId ?? agentId, + ref: entry.ref, + }; + }), + ], + }, + ], + 'agent.fork': [ + { + guard: ({ context, event }) => + context.agents[event.sourceId] === undefined || + (event.agentId !== undefined && context.agents[event.agentId] !== undefined), + actions: emit(({ context, event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId ?? event.sourceId, + error: + context.agents[event.sourceId] === undefined + ? `unknown agent: '${event.sourceId}'` + : `duplicate agent id: '${event.agentId}'`, + })), + }, + { + actions: [ + assign(({ context, event, spawn }) => { + const source = (context.agents[event.sourceId] as AgentEntry).ref.getSnapshot(); + const anonymousCount = + event.agentId === undefined + ? nextAnonymousCount(context) + : context.anonymousCount; + const agentId = event.agentId ?? `agent-${anonymousCount}`; + const ref = spawn('agentActor', { + id: agentId, + input: { + request: source.context.input.request, + history: [...source.context.messages], + turnId: source.context.turnId, + branchId: agentId, + }, + }); + return { + agents: { ...context.agents, [agentId]: { ref } }, + anonymousCount, + }; + }), + emit(({ context, event }) => { + const agentId = event.agentId ?? `agent-${context.anonymousCount}`; + const entry = context.agents[agentId] as AgentEntry; + return { + type: 'agent.forked' as const, + sourceId: event.sourceId, + agentId, + branchId: agentId, + ref: entry.ref, + }; + }), + ], + }, + ], + 'agent.switch': [ + { + guard: ({ context, event }) => context.agents[event.agentId] === undefined, + actions: emit(({ event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId, + error: `unknown agent: '${event.agentId}'`, + })), + }, + { + guard: ({ context, event }) => + !(context.agents[event.agentId] as AgentEntry).ref.getSnapshot().matches('idle'), + actions: emit(({ event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId, + error: `agent is busy: '${event.agentId}'`, + })), + }, + { + actions: [ + sendTo( + ({ context, event }) => (context.agents[event.agentId] as AgentEntry).ref, + ({ event }) => ({ + type: 'context.reset' as const, + history: event.input.history, + turnId: event.input.turnId, + branchId: event.input.branchId, + }), + ), + emit(({ event }) => ({ + type: 'agent.switched' as const, + agentId: event.agentId, + branchId: event.input.branchId, + reason: event.input.reason, + })), + ], + }, + ], + 'agent.send': [ + { + guard: ({ context, event }) => context.agents[event.agentId] === undefined, + actions: emit(({ event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId, + error: `unknown agent: '${event.agentId}'`, + })), + }, + { + actions: sendTo( + ({ context, event }) => (context.agents[event.agentId] as AgentEntry).ref, + ({ event }) => event.event, + ), + }, + ], + 'agent.stop': [ + { + guard: ({ context, event }) => context.agents[event.agentId] === undefined, + actions: emit(({ event }) => ({ + type: 'agent.error' as const, + agentId: event.agentId, + error: `unknown agent: '${event.agentId}'`, + })), + }, + { + actions: [ + stopChild(({ event }) => event.agentId), + assign(({ context, event }) => { + const agents = { ...context.agents }; + delete agents[event.agentId]; + return { agents }; + }), + emit(({ event }) => ({ type: 'agent.stopped' as const, agentId: event.agentId })), + ], + }, + ], + }, + }, + }, + }); +} diff --git a/packages/agent-core-v2/src/human/session/undo.ts b/packages/agent-core-v2/src/human/session/undo.ts new file mode 100644 index 00000000000..470e95f83de --- /dev/null +++ b/packages/agent-core-v2/src/human/session/undo.ts @@ -0,0 +1,118 @@ +import type { ActorRefFrom } from '#/xstate2'; + +import { loadAgentState, type LoadedAgentState } from '#/agent/replay'; +import type { HistoryMessage, UserMeta } from '#/agent/turn'; +import type { Branch } from '#/store/branch'; +import type { Tree } from '#/store/tree'; +import type { BranchRef, BranchHeader } from '#/store/types'; + +import type { createSessionMachine } from '#/session/machine'; + +type SessionActor = ActorRefFrom>; + +export type UndoErrorReason = 'unknown-agent' | 'invalid-count' | 'busy' | 'insufficient'; + +export class UndoError extends Error { + readonly reason: UndoErrorReason; + + constructor(reason: UndoErrorReason, message: string) { + super(message); + this.name = 'UndoError'; + this.reason = reason; + } +} + +export interface UndoResult extends LoadedAgentState { + agentId: string; + branchId: string; +} + +export function isUndoAnchor(entry: HistoryMessage): boolean { + if (entry.message.role !== 'user') return false; + const source = (entry.meta as UserMeta).source; + return source === undefined || source === 'input'; +} + +export function isValidUndoCount(count: number): boolean { + return Number.isSafeInteger(count) && count > 0; +} + +export function freshBranchName(tree: Tree, agentId: string): string { + if (!tree.has(agentId)) return agentId; + let n = 2; + while (tree.has(`${agentId}~${n}`)) n += 1; + return `${agentId}~${n}`; +} + +export async function findUndoCut( + tree: Tree, + start: Branch, + turns: number, +): Promise { + let remaining = turns; + let branch: Branch | undefined = start; + let from: number | null = start.head; + while (branch !== undefined) { + for (let seq = from ?? -1; seq >= 0; seq--) { + const entry = branch.entryAt(seq); + if (entry === null || entry.type !== 'message') continue; + const data = await tree.resolve(entry); + if (!isUndoAnchor(data as HistoryMessage)) continue; + remaining -= 1; + if (remaining > 0) continue; + if (seq > 0) return { branch: branch.name, seq: seq - 1 }; + const { parentBranch, parentSeq } = branch.header; + if (parentBranch !== undefined && parentSeq !== undefined) { + return { branch: parentBranch, seq: parentSeq }; + } + return null; + } + const header: BranchHeader = branch.header; + const next: Branch | undefined = + header.parentBranch !== undefined && + header.parentSeq !== undefined && + tree.has(header.parentBranch) + ? tree.openBranch(header.parentBranch) + : undefined; + from = header.parentSeq ?? null; + branch = next; + } + throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough anchors`); +} + +export async function undoAgentTurns( + session: SessionActor, + tree: Tree, + agentId: string, + turns: number, +): Promise { + const entry = session.getSnapshot().context.agents[agentId]; + if (entry === undefined) { + throw new UndoError('unknown-agent', `unknown agent: '${agentId}'`); + } + if (!isValidUndoCount(turns)) { + throw new UndoError('invalid-count', `invalid undo count: ${turns}`); + } + const snapshot = entry.ref.getSnapshot(); + if (!snapshot.matches('idle')) { + throw new UndoError('busy', `agent is busy: '${agentId}'`); + } + if (!tree.has(snapshot.context.branchId)) { + throw new UndoError('insufficient', `cannot undo ${turns} turn(s): not enough anchors`); + } + const branch = tree.openBranch(snapshot.context.branchId); + await branch.settled(); + const cut = await findUndoCut(tree, branch, turns); + const branchId = freshBranchName(tree, agentId); + tree.createBranch(branchId, cut === null ? undefined : { from: cut }); + const loaded = await loadAgentState(tree, branchId); + session.send({ + type: 'agent.switch', + agentId, + input: { branchId, history: loaded.messages, turnId: loaded.turnId, reason: 'undo' }, + }); + if (entry.ref.getSnapshot().context.branchId !== branchId) { + throw new UndoError('busy', `agent is busy: '${agentId}'`); + } + return { agentId, branchId, ...loaded }; +} diff --git a/packages/agent-core-v2/src/human/store/backend/backend.ts b/packages/agent-core-v2/src/human/store/backend/backend.ts new file mode 100644 index 00000000000..beb15c83a8a --- /dev/null +++ b/packages/agent-core-v2/src/human/store/backend/backend.ts @@ -0,0 +1,19 @@ +export interface TreeBackend { + list(): Promise; + listBranches(tree: string): Promise; + read(tree: string, branch: string): Promise; + append(tree: string, branch: string, data: string): Promise; + write(tree: string, branch: string, content: string): Promise; + sync?(tree: string, branch: string): Promise; +} + +export interface BlobBackend { + has(ref: string): Promise; + read(ref: string): Promise; + write(ref: string, data: string): Promise; +} + +export interface StoreBackend { + readonly trees: TreeBackend; + readonly blobs: BlobBackend; +} diff --git a/packages/agent-core-v2/src/human/store/backend/memory.ts b/packages/agent-core-v2/src/human/store/backend/memory.ts new file mode 100644 index 00000000000..256f1b73226 --- /dev/null +++ b/packages/agent-core-v2/src/human/store/backend/memory.ts @@ -0,0 +1,60 @@ +import type { BlobBackend, StoreBackend, TreeBackend } from './backend'; + +export class MemoryTreeBackend implements TreeBackend { + readonly files = new Map>(); + + async list(): Promise { + return [...this.files.keys()].sort(); + } + + async listBranches(tree: string): Promise { + return [...(this.files.get(tree)?.keys() ?? [])].sort(); + } + + async read(tree: string, branch: string): Promise { + const content = this.files.get(tree)?.get(branch); + if (content === undefined) throw new Error(`ENOENT: no such branch ${tree}/${branch}`); + return content; + } + + async append(tree: string, branch: string, data: string): Promise { + const file = this.file(tree); + file.set(branch, (file.get(branch) ?? '') + data); + } + + async write(tree: string, branch: string, content: string): Promise { + this.file(tree).set(branch, content); + } + + private file(tree: string): Map { + let file = this.files.get(tree); + if (file === undefined) { + file = new Map(); + this.files.set(tree, file); + } + return file; + } +} + +export class MemoryBlobBackend implements BlobBackend { + readonly files = new Map(); + + async has(ref: string): Promise { + return this.files.has(ref); + } + + async read(ref: string): Promise { + const content = this.files.get(ref); + if (content === undefined) throw new Error(`ENOENT: no such blob ${ref}`); + return content; + } + + async write(ref: string, data: string): Promise { + this.files.set(ref, data); + } +} + +export class MemoryBackend implements StoreBackend { + readonly trees = new MemoryTreeBackend(); + readonly blobs = new MemoryBlobBackend(); +} diff --git a/packages/agent-core-v2/src/human/store/backend/node.ts b/packages/agent-core-v2/src/human/store/backend/node.ts new file mode 100644 index 00000000000..9842522ac0a --- /dev/null +++ b/packages/agent-core-v2/src/human/store/backend/node.ts @@ -0,0 +1,110 @@ +import { access, appendFile, mkdir, open, readFile, readdir, rename, writeFile } from 'node:fs/promises'; + +import type { BlobBackend, StoreBackend, TreeBackend } from './backend'; + +function isEnoent(error: unknown): boolean { + return error instanceof Error && 'code' in error && error.code === 'ENOENT'; +} + +class NodeTreeBackend implements TreeBackend { + constructor(private readonly dir: string) {} + + async list(): Promise { + let entries; + try { + entries = await readdir(this.dir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) return []; + throw error; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + } + + async listBranches(tree: string): Promise { + let files: string[]; + try { + files = await readdir(this.dirOf(tree)); + } catch (error) { + if (isEnoent(error)) return []; + throw error; + } + return files + .filter((file) => file.endsWith('.jsonl')) + .map((file) => file.slice(0, -'.jsonl'.length)) + .sort(); + } + + async read(tree: string, branch: string): Promise { + return readFile(this.path(tree, branch), 'utf8'); + } + + async append(tree: string, branch: string, data: string): Promise { + await mkdir(this.dirOf(tree), { recursive: true }); + await appendFile(this.path(tree, branch), data, 'utf8'); + } + + async write(tree: string, branch: string, content: string): Promise { + await mkdir(this.dirOf(tree), { recursive: true }); + const path = this.path(tree, branch); + await writeFile(`${path}.tmp`, content, 'utf8'); + await rename(`${path}.tmp`, path); + } + + async sync(tree: string, branch: string): Promise { + const handle = await open(this.path(tree, branch), 'r'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + } + + private dirOf(tree: string): string { + return `${this.dir}/${tree}`; + } + + private path(tree: string, branch: string): string { + return `${this.dirOf(tree)}/${branch}.jsonl`; + } +} + +class NodeBlobBackend implements BlobBackend { + constructor(private readonly dir: string) {} + + async has(ref: string): Promise { + try { + await access(this.path(ref)); + return true; + } catch { + return false; + } + } + + async read(ref: string): Promise { + return readFile(this.path(ref), 'utf8'); + } + + async write(ref: string, data: string): Promise { + await mkdir(this.dir, { recursive: true }); + const path = this.path(ref); + await writeFile(`${path}.tmp`, data, 'utf8'); + await rename(`${path}.tmp`, path); + } + + private path(ref: string): string { + return `${this.dir}/${ref}`; + } +} + +export class NodeBackend implements StoreBackend { + readonly trees: TreeBackend; + readonly blobs: BlobBackend; + + constructor(root: string) { + this.trees = new NodeTreeBackend(`${root}/trees`); + this.blobs = new NodeBlobBackend(`${root}/blobs`); + } +} diff --git a/packages/agent-core-v2/src/human/store/branch.ts b/packages/agent-core-v2/src/human/store/branch.ts new file mode 100644 index 00000000000..96f5bf34e7e --- /dev/null +++ b/packages/agent-core-v2/src/human/store/branch.ts @@ -0,0 +1,230 @@ +import { writeBlob } from './internal/blob'; +import { encodeHeader, encodeLine, parseHeader, parseLine } from './internal/codec'; +import type { TreeContext } from './internal/context'; +import type { AppendInput, BranchHeader, BranchRef, CorruptionKind, EntryLine, Payload } from './types'; +import { StoreError } from './types'; + +const textEncoder = new TextEncoder(); + +export type BranchResolver = (name: string) => Branch | undefined; + +export class Branch { + readonly tree: string; + readonly name: string; + readonly header: BranchHeader; + private readonly ctx: TreeContext; + private readonly resolveBranch: BranchResolver; + private readonly entries: (EntryLine | null)[]; + private degradedFlag: boolean; + private truncateAt: number | null; + private tail: Promise; + + private constructor( + ctx: TreeContext, + header: BranchHeader, + entries: (EntryLine | null)[], + degraded: boolean, + truncateAt: number | null, + resolveBranch: BranchResolver, + ) { + this.ctx = ctx; + this.header = header; + this.tree = header.tree; + this.name = header.branch; + this.entries = entries; + this.degradedFlag = degraded; + this.truncateAt = truncateAt; + this.resolveBranch = resolveBranch; + this.tail = Promise.resolve(); + } + + get degraded(): boolean { + return this.degradedFlag; + } + + get nextSeq(): number { + return this.entries.length; + } + + get head(): number | null { + return this.entries.length === 0 ? null : this.entries.length - 1; + } + + static create( + ctx: TreeContext, + tree: string, + name: string, + resolveBranch: BranchResolver, + from?: BranchRef, + ): Branch { + const header: BranchHeader = { + version: 1, + tree, + branch: name, + createdAt: Date.now(), + parentBranch: from?.branch, + parentSeq: from?.seq, + }; + const branch = new Branch(ctx, header, [], false, null, resolveBranch); + void branch + .enqueue(() => ctx.backend.trees.write(tree, name, encodeHeader(header))) + .catch(() => undefined); + return branch; + } + + static async load( + ctx: TreeContext, + tree: string, + name: string, + resolveBranch: BranchResolver, + ): Promise { + const content = await ctx.backend.trees.read(tree, name); + const physical = content.split('\n'); + if (physical.at(-1) === '') physical.pop(); + const entries: (EntryLine | null)[] = []; + let header: BranchHeader = { version: 1, tree, branch: name, createdAt: Date.now() }; + let degraded = false; + let truncateAt: number | null = null; + const report = (kind: CorruptionKind, seq: number | null, line: number, detail: string, raw?: string): void => { + ctx.notifyCorruption({ tree, branch: name, seq, line, kind, raw, detail }); + }; + const first = physical[0]; + if (first === undefined) { + report('header', null, 1, 'file is empty'); + degraded = true; + } else { + const parsedHeader = parseHeader(first); + if (parsedHeader.ok) { + header = parsedHeader.value; + } else { + report('header', null, 1, parsedHeader.error.detail, first); + degraded = true; + } + } + const startIndex = physical.length > 0 ? 1 : 0; + let truncated = false; + for (let i = startIndex; i < physical.length; i++) { + const raw = physical[i] ?? ''; + const expectedSeq = i - startIndex; + const result = parseLine(raw, expectedSeq); + if (!result.ok) { + const error = result.error; + if (error.kind === 'syntax' && i === physical.length - 1) { + await publish(ctx, tree, name, physical.slice(0, i)); + truncated = true; + break; + } + report(error.kind === 'seq' ? 'seq-gap' : error.kind, expectedSeq, i + 1, error.detail, raw); + if (error.kind === 'seq') { + degraded = true; + truncateAt = i; + break; + } + entries.push(null); + continue; + } + entries.push(result.value); + } + if (!truncated && content.length > 0 && !content.endsWith('\n')) { + await ctx.backend.trees.append(tree, name, '\n'); + } + return new Branch(ctx, header, entries, degraded, truncateAt, resolveBranch); + } + + append(input: AppendInput): Promise { + return this.enqueue(async () => { + this.assertWritable(); + const seq = this.entries.length; + const serialized = JSON.stringify(input.data ?? null); + const size = textEncoder.encode(serialized).length; + let payload: Payload; + if (size > this.ctx.offloadThreshold) { + const ref = await writeBlob(this.ctx.backend.blobs, serialized); + payload = { kind: input.kind, size, ref }; + } else { + payload = { kind: input.kind, size, data: input.data ?? null }; + } + const entry: EntryLine = { kind: 'entry', seq, ts: Date.now(), type: input.type, payload }; + await this.writeLine(entry); + this.entries.push(entry); + this.ctx.notifyAppend(this.tree, this.name, entry); + return entry; + }); + } + + repair(): Promise { + return this.enqueue(async () => { + if (!this.degradedFlag) return; + const content = await this.ctx.backend.trees.read(this.tree, this.name); + const physical = content.split('\n'); + if (physical.at(-1) === '') physical.pop(); + const kept = physical.slice(0, this.truncateAt ?? physical.length); + kept[0] = encodeHeader(this.header).replace(/\n$/, ''); + await publish(this.ctx, this.tree, this.name, kept); + this.degradedFlag = false; + this.truncateAt = null; + }); + } + + settled(): Promise { + return this.tail.then(() => undefined); + } + + entryAt(seq: number): EntryLine | null { + return this.entries[seq] ?? null; + } + + tip(): EntryLine | null { + for (let i = this.entries.length - 1; i >= 0; i--) { + const entry = this.entries[i]; + if (entry !== undefined && entry !== null) return entry; + } + return null; + } + + *walk(): Generator { + yield* this.walkOwn(this.entries.length - 1); + yield* this.walkParent(); + } + + private *walkOwn(from: number): Generator { + for (let i = from; i >= 0; i--) { + const entry = this.entries[i]; + if (entry !== undefined && entry !== null) yield entry; + } + } + + private *walkParent(): Generator { + const parentBranch = this.header.parentBranch; + const parentSeq = this.header.parentSeq; + if (parentBranch === undefined || parentSeq === undefined) return; + const parent = this.resolveBranch(parentBranch); + if (parent === undefined) return; + yield* parent.walkOwn(parentSeq); + yield* parent.walkParent(); + } + + private async writeLine(entry: EntryLine): Promise { + await this.ctx.backend.trees.append(this.tree, this.name, encodeLine(entry)); + if (this.ctx.fsync) await this.ctx.backend.trees.sync?.(this.tree, this.name); + } + + private assertWritable(): void { + if (this.degradedFlag) { + throw new StoreError('degraded', `branch ${this.tree}/${this.name} is degraded; call repair() first`); + } + } + + private enqueue(operation: () => Promise): Promise { + const result = this.tail.then(operation); + this.tail = result.then( + () => undefined, + () => undefined, + ); + return result; + } +} + +function publish(ctx: TreeContext, tree: string, name: string, lines: string[]): Promise { + return ctx.backend.trees.write(tree, name, `${lines.join('\n')}\n`); +} diff --git a/packages/agent-core-v2/src/human/store/index.ts b/packages/agent-core-v2/src/human/store/index.ts new file mode 100644 index 00000000000..d307d004f01 --- /dev/null +++ b/packages/agent-core-v2/src/human/store/index.ts @@ -0,0 +1,7 @@ +export * from './types'; +export * from './branch'; +export * from './tree'; +export * from './store'; +export * from './backend/backend'; +export * from './backend/memory'; +export * from './backend/node'; diff --git a/packages/agent-core-v2/src/human/store/internal/blob.ts b/packages/agent-core-v2/src/human/store/internal/blob.ts new file mode 100644 index 00000000000..fa29079d3c4 --- /dev/null +++ b/packages/agent-core-v2/src/human/store/internal/blob.ts @@ -0,0 +1,26 @@ +import type { BlobBackend } from '../backend/backend'; +import { StoreError } from '../types'; + +export async function sha256Hex(data: string): Promise { + const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join(''); +} + +export async function writeBlob(blobs: BlobBackend, data: string): Promise { + const ref = await sha256Hex(data); + if (!(await blobs.has(ref))) { + await blobs.write(ref, data); + } + return ref; +} + +export async function readBlob(blobs: BlobBackend, ref: string): Promise { + if (!(await blobs.has(ref))) { + throw new StoreError('blob-missing', `blob ${ref} is missing`); + } + const data = await blobs.read(ref); + if ((await sha256Hex(data)) !== ref) { + throw new StoreError('blob-crc', `blob ${ref} failed its hash check`); + } + return data; +} diff --git a/packages/agent-core-v2/src/human/store/internal/codec.ts b/packages/agent-core-v2/src/human/store/internal/codec.ts new file mode 100644 index 00000000000..53724b2ec67 --- /dev/null +++ b/packages/agent-core-v2/src/human/store/internal/codec.ts @@ -0,0 +1,144 @@ +import type { BranchHeader, EntryLine, Payload, Result } from '../types'; +import { err, ok } from '../types'; + +export interface CodecError { + kind: 'syntax' | 'schema' | 'seq'; + detail: string; +} + +class CodecException extends Error { + readonly kind: CodecError['kind']; + + constructor(kind: CodecError['kind'], detail: string) { + super(detail); + this.name = 'CodecException'; + this.kind = kind; + } +} + +function parseObject(raw: string): Record { + let value: unknown; + try { + value = JSON.parse(raw); + } catch (error) { + throw new CodecException('syntax', error instanceof Error ? error.message : 'is not valid JSON'); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CodecException('schema', 'is not a JSON object'); + } + return value as Record; +} + +function requireString(obj: Record, key: string): string { + const value = obj[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new CodecException('schema', `has invalid ${key}`); + } + return value; +} + +function requireTimestamp(obj: Record, key: string): number { + const value = obj[key]; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new CodecException('schema', `has invalid ${key}`); + } + return value; +} + +function optionalString(obj: Record, key: string): string | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new CodecException('schema', `has invalid ${key}`); + } + return value; +} + +function optionalSeq(obj: Record, key: string): number | undefined { + const value = obj[key]; + if (value === undefined) return undefined; + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + throw new CodecException('schema', `has invalid ${key}`); + } + return value; +} + +function decodeHeader(raw: string): BranchHeader { + const obj = parseObject(raw); + if (obj['kind'] !== 'header') throw new CodecException('schema', 'is not a header'); + if (obj['version'] !== 1) throw new CodecException('schema', 'has unsupported version'); + return { + version: 1, + tree: requireString(obj, 'tree'), + branch: requireString(obj, 'branch'), + createdAt: requireTimestamp(obj, 'createdAt'), + parentBranch: optionalString(obj, 'parentBranch'), + parentSeq: optionalSeq(obj, 'parentSeq'), + }; +} + +function decodePayload(value: unknown): Payload { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new CodecException('schema', 'has invalid payload'); + } + const obj = value as Record; + const kind = obj['kind']; + if (typeof kind !== 'string') throw new CodecException('schema', 'has invalid payload.kind'); + const size = obj['size']; + if (typeof size !== 'number' || !Number.isSafeInteger(size) || size < 0) { + throw new CodecException('schema', 'has invalid payload.size'); + } + const ref = obj['ref']; + if (ref !== undefined) { + if (typeof ref !== 'string' || ref.length === 0) { + throw new CodecException('schema', 'has invalid payload.ref'); + } + return { kind, size, ref }; + } + if (!('data' in obj)) throw new CodecException('schema', 'has invalid payload.data'); + return { kind, size, data: obj['data'] }; +} + +function decodeEntry(raw: string, expectedSeq: number): EntryLine { + const obj = parseObject(raw); + if (obj['kind'] !== 'entry') throw new CodecException('schema', 'is not an entry'); + const seq = obj['seq']; + if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0) { + throw new CodecException('schema', 'has invalid seq'); + } + if (seq !== expectedSeq) { + throw new CodecException('seq', `has seq ${seq}, expected ${expectedSeq}`); + } + return { + kind: 'entry', + seq, + ts: requireTimestamp(obj, 'ts'), + type: requireString(obj, 'type'), + payload: decodePayload(obj['payload']), + }; +} + +function wrap(decode: () => T): Result { + try { + return ok(decode()); + } catch (error) { + if (error instanceof CodecException) return err({ kind: error.kind, detail: error.message }); + throw error; + } +} + +export function encodeHeader(header: BranchHeader): string { + return `${JSON.stringify({ kind: 'header', ...header })}\n`; +} + +export function parseHeader(raw: string): Result { + return wrap(() => decodeHeader(raw)); +} + +export function encodeLine(entry: EntryLine): string { + return `${JSON.stringify(entry)}\n`; +} + +export function parseLine(raw: string, expectedSeq: number): Result { + return wrap(() => decodeEntry(raw, expectedSeq)); +} diff --git a/packages/agent-core-v2/src/human/store/internal/context.ts b/packages/agent-core-v2/src/human/store/internal/context.ts new file mode 100644 index 00000000000..767817de1f6 --- /dev/null +++ b/packages/agent-core-v2/src/human/store/internal/context.ts @@ -0,0 +1,10 @@ +import type { StoreBackend } from '../backend/backend'; +import type { CorruptionReport, EntryLine } from '../types'; + +export interface TreeContext { + backend: StoreBackend; + offloadThreshold: number; + fsync: boolean; + notifyAppend(tree: string, branch: string, entry: EntryLine): void; + notifyCorruption(report: CorruptionReport): void; +} diff --git a/packages/agent-core-v2/src/human/store/store.ts b/packages/agent-core-v2/src/human/store/store.ts new file mode 100644 index 00000000000..2a564425edd --- /dev/null +++ b/packages/agent-core-v2/src/human/store/store.ts @@ -0,0 +1,190 @@ +import type { StoreBackend } from './backend/backend'; +import { readBlob } from './internal/blob'; +import { parseHeader, parseLine } from './internal/codec'; +import type { TreeContext } from './internal/context'; +import { Tree } from './tree'; +import type { CorruptionReport, Subscriber, TreeStoreOptions } from './types'; +import { DEFAULT_OFFLOAD_THRESHOLD, StoreError, isOffloadedPayload } from './types'; + +const TREE_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; + +interface SubscriberEntry { + prefix: string; + subscriber: Subscriber; +} + +export class TreeStore { + private readonly backend: StoreBackend; + private readonly trees = new Map(); + private readonly subscribers: SubscriberEntry[] = []; + private readonly ctx: TreeContext; + + private constructor(backend: StoreBackend, opts: TreeStoreOptions) { + this.backend = backend; + this.ctx = { + backend, + offloadThreshold: opts.offloadThreshold ?? DEFAULT_OFFLOAD_THRESHOLD, + fsync: opts.fsync ?? false, + notifyAppend: (tree, branch, entry) => { + for (const { prefix, subscriber } of this.subscribers) { + if (entry.type.startsWith(prefix)) subscriber.onAppend?.(tree, branch, entry); + } + }, + notifyCorruption: (report) => { + for (const { subscriber } of this.subscribers) subscriber.onCorruption?.(report); + }, + }; + for (const { prefix, subscriber } of opts.subscribers ?? []) { + this.subscribers.push({ prefix, subscriber }); + } + } + + static async open(backend: StoreBackend, opts: TreeStoreOptions = {}): Promise { + const store = new TreeStore(backend, opts); + for (const name of await backend.trees.list()) { + const tree = new Tree(store.ctx, name); + for (const branchName of await backend.trees.listBranches(name)) { + await tree.loadBranch(branchName); + } + store.trees.set(name, tree); + } + return store; + } + + names(): string[] { + return [...this.trees.keys()]; + } + + async tree(name: string): Promise { + const existing = this.trees.get(name); + if (existing !== undefined) return existing; + if (!TREE_NAME_PATTERN.test(name)) { + throw new StoreError('invalid-name', `invalid tree name ${name}`); + } + const created = new Tree(this.ctx, name); + this.trees.set(name, created); + return created; + } + + subscribe(prefix: string, subscriber: Subscriber): () => void { + const entry: SubscriberEntry = { prefix, subscriber }; + this.subscribers.push(entry); + return () => { + const index = this.subscribers.indexOf(entry); + if (index >= 0) this.subscribers.splice(index, 1); + }; + } + + async verify(opts?: { blobs?: boolean }): Promise { + const reports: CorruptionReport[] = []; + for (const [name, tree] of this.trees) { + for (const branchName of tree.branches()) { + reports.push(...(await this.verifyBranch(name, branchName, tree, opts?.blobs === true))); + } + } + return reports; + } + + private async verifyBranch( + name: string, + branchName: string, + tree: Tree, + checkBlobs: boolean, + ): Promise { + const reports: CorruptionReport[] = []; + const branch = tree.openBranch(branchName); + let content: string; + try { + content = await this.backend.trees.read(name, branchName); + } catch (error) { + reports.push({ + tree: name, + branch: branchName, + seq: null, + line: 0, + kind: 'missing', + detail: error instanceof Error ? error.message : 'branch file is missing', + }); + return reports; + } + const physical = content.split('\n'); + if (physical.at(-1) === '') physical.pop(); + const first = physical[0] ?? ''; + const parsedHeader = parseHeader(first); + if (!parsedHeader.ok) { + reports.push({ + tree: name, + branch: branchName, + seq: null, + line: 1, + kind: 'header', + raw: first, + detail: parsedHeader.error.detail, + }); + } + const refs: string[] = []; + for (let i = 1; i < physical.length; i++) { + const raw = physical[i] ?? ''; + const expectedSeq = i - 1; + const result = parseLine(raw, expectedSeq); + if (!result.ok) { + reports.push({ + tree: name, + branch: branchName, + seq: expectedSeq, + line: i + 1, + kind: result.error.kind === 'seq' ? 'seq-gap' : result.error.kind, + raw, + detail: result.error.detail, + }); + continue; + } + if (isOffloadedPayload(result.value.payload)) refs.push(result.value.payload.ref); + } + const header = branch.header; + if (header.parentBranch !== undefined) { + const parent = tree.has(header.parentBranch) ? tree.openBranch(header.parentBranch) : undefined; + const parentExists = (await this.backend.trees.listBranches(name)).includes(header.parentBranch); + if (parent === undefined || !parentExists) { + reports.push({ + tree: name, + branch: branchName, + seq: null, + line: 1, + kind: 'parent-ref', + detail: `parent branch ${header.parentBranch} is missing`, + }); + } else if (header.parentSeq !== undefined && header.parentSeq >= parent.nextSeq) { + reports.push({ + tree: name, + branch: branchName, + seq: null, + line: 1, + kind: 'parent-ref', + detail: `parent seq ${header.parentSeq} is beyond ${header.parentBranch}`, + }); + } + } + if (checkBlobs) { + for (const ref of refs) { + try { + await readBlob(this.backend.blobs, ref); + } catch (error) { + if (error instanceof StoreError && (error.code === 'blob-missing' || error.code === 'blob-crc')) { + reports.push({ + tree: name, + branch: branchName, + seq: null, + line: 0, + kind: error.code, + detail: error.message, + }); + } else { + throw error; + } + } + } + } + return reports; + } +} diff --git a/packages/agent-core-v2/src/human/store/tree.ts b/packages/agent-core-v2/src/human/store/tree.ts new file mode 100644 index 00000000000..062d4fa290e --- /dev/null +++ b/packages/agent-core-v2/src/human/store/tree.ts @@ -0,0 +1,72 @@ +import { Branch } from './branch'; +import { readBlob } from './internal/blob'; +import type { TreeContext } from './internal/context'; +import type { BranchRef, EntryLine } from './types'; +import { isOffloadedPayload, StoreError } from './types'; + +const BRANCH_NAME_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._~-]*$/; + +export class Tree { + readonly name: string; + private readonly ctx: TreeContext; + private readonly branchMap = new Map(); + + constructor(ctx: TreeContext, name: string) { + this.ctx = ctx; + this.name = name; + } + + branches(): string[] { + return [...this.branchMap.keys()].sort(); + } + + has(name: string): boolean { + return this.branchMap.has(name); + } + + openBranch(name: string): Branch { + const branch = this.branchMap.get(name); + if (branch === undefined) { + throw new StoreError('unknown-branch', `unknown branch ${this.name}/${name}`); + } + return branch; + } + + createBranch(name: string, opts?: { from?: BranchRef }): Branch { + if (this.branchMap.has(name)) { + throw new StoreError('duplicate-branch', `branch ${this.name}/${name} already exists`); + } + if (!BRANCH_NAME_PATTERN.test(name)) { + throw new StoreError('invalid-name', `invalid branch name ${name}`); + } + const from = opts?.from; + if (from !== undefined) { + const parent = this.branchMap.get(from.branch); + if (parent === undefined) { + throw new StoreError('unknown-branch', `unknown branch ${this.name}/${from.branch}`); + } + if (!Number.isSafeInteger(from.seq) || from.seq < 0 || from.seq >= parent.nextSeq) { + throw new StoreError('invalid-target', `cannot fork ${from.branch} at ${from.seq}`); + } + } + const branch = Branch.create(this.ctx, this.name, name, (n) => this.branchMap.get(n), from); + this.branchMap.set(name, branch); + return branch; + } + + async loadBranch(name: string): Promise { + if (this.branchMap.has(name)) { + throw new StoreError('duplicate-branch', `branch ${this.name}/${name} already exists`); + } + const branch = await Branch.load(this.ctx, this.name, name, (n) => this.branchMap.get(n)); + this.branchMap.set(name, branch); + return branch; + } + + async resolve(entry: EntryLine): Promise { + if (isOffloadedPayload(entry.payload)) { + return JSON.parse(await readBlob(this.ctx.backend.blobs, entry.payload.ref)) as unknown; + } + return entry.payload.data; + } +} diff --git a/packages/agent-core-v2/src/human/store/types.ts b/packages/agent-core-v2/src/human/store/types.ts new file mode 100644 index 00000000000..89eb767a88b --- /dev/null +++ b/packages/agent-core-v2/src/human/store/types.ts @@ -0,0 +1,93 @@ +export type Result = { ok: true; value: T } | { ok: false; error: E }; + +export function ok(value: T): Result { + return { ok: true, value }; +} + +export function err(error: E): Result { + return { ok: false, error }; +} + +export class StoreError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'StoreError'; + this.code = code; + } +} + +export interface BranchHeader { + version: 1; + tree: string; + branch: string; + createdAt: number; + parentBranch?: string; + parentSeq?: number; +} + +export type Payload = + | { kind: string; size: number; data: unknown } + | { kind: string; size: number; ref: string }; + +export function isOffloadedPayload(payload: Payload): payload is { kind: string; size: number; ref: string } { + return 'ref' in payload; +} + +export interface EntryLine { + kind: 'entry'; + seq: number; + ts: number; + type: string; + payload: Payload; +} + +export type CorruptionKind = + | 'missing' + | 'syntax' + | 'schema' + | 'seq-gap' + | 'parent-ref' + | 'header' + | 'blob-missing' + | 'blob-crc'; + +export interface CorruptionReport { + tree: string; + branch: string; + seq: number | null; + line: number; + kind: CorruptionKind; + raw?: string; + detail: string; +} + +export interface BranchRef { + branch: string; + seq: number; +} + +export interface Subscriber { + onAppend?(tree: string, branch: string, entry: EntryLine): void; + onCorruption?(report: CorruptionReport): void; +} + +export interface SubscriberRegistration { + prefix: string; + subscriber: Subscriber; +} + +export interface AppendInput { + type: string; + kind: string; + data?: unknown; +} + +export interface TreeStoreOptions { + offloadThreshold?: number; + fsync?: boolean; + subscribers?: SubscriberRegistration[]; +} + +export const DEFAULT_OFFLOAD_THRESHOLD = 64 * 1024; diff --git a/packages/agent-core-v2/src/human/test/agent/machine.test.ts b/packages/agent-core-v2/src/human/test/agent/machine.test.ts new file mode 100644 index 00000000000..34b8251a979 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/agent/machine.test.ts @@ -0,0 +1,1381 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createActor, waitFor } from '#/xstate2'; + +const originalWarn = console.warn; +console.warn = (...args: unknown[]) => { + originalWarn(...args); + originalWarn(new Error('warn-trace').stack?.split('\n').slice(1, 16).join('\n')); +}; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { + createAssistantMessage, + createUserMessage, + extractText, + type AssistantMessage, + type StreamedMessagePart, + type ToolCall, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine, type LlmEvent } from '#/llm/requester/machine'; +import type { LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; +import type { LlmRetryOptions } from '#/llm/requester/retry'; +import { emptyUsage, type TokenUsage } from '#/llm/usage'; +import { connectPlugins } from '#/plugin'; +import { createTimingPlugin } from '#/timing/plugin'; +import { createAgentMachine, type AgentEmitted } from '#/agent/machine'; +import { estimateMessageTokens, estimateTextTokens } from '#/agent/context-usage'; +import { + createTurnMachine, + createUserEntry, + toInputMessages, + type AssistantEntry, + type HistoryMessage, +} from '#/agent/turn'; +import { MaxStepsExceededError } from '#/agent/errors'; +import { waitForTool } from '#/tool/wait-for'; +import { defineTool, type ToolDefinition } from '#/tool/tool'; +import type { ToolResult } from '#/tool/executor'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +function toolCall(id: string, name: string, args: string = '{}'): ToolCall { + return { type: 'function', id, name, arguments: args }; +} + +function streamMessage( + message: AssistantMessage, + onEvent: ((event: LlmRequestEvent) => void) | undefined, +): void { + for (const part of [...message.content, ...message.toolCalls]) { + onEvent?.({ type: 'llm.delta', part }); + } + onEvent?.({ type: 'llm.message-id', messageId: 'msg-stub' }); + onEvent?.({ + type: 'llm.finish', + finish: { finishReason: 'completed', rawFinishReason: 'stop' }, + }); + onEvent?.({ type: 'llm.done' }); +} + +function createStubRequester(responses: readonly AssistantMessage[]): LlmRequester { + let call = 0; + return { + generate: (_config, _content, { onEvent }) => { + const message = responses[Math.min(call, responses.length - 1)] as AssistantMessage; + call += 1; + streamMessage(message, onEvent); + return Promise.resolve(); + }, + }; +} + +function createTestAgentMachine( + tools: readonly ToolDefinition[], + requester: LlmRequester, + retry?: LlmRetryOptions, + abortTimeoutMs?: number, +) { + return createAgentMachine({ + tools, + turnActor: createTurnMachine(createLlmMachine({ requester, retry })), + abortTimeoutMs, + }); +} + +function stubTools( + execute: ToolDefinition['execute'], + ...names: string[] +): ToolDefinition[] { + return names.map((name) => + defineTool({ + name, + description: `stub ${name}`, + parameters: { type: 'object', properties: {} }, + execute, + }), + ); +} + +async function runAgent( + requester: LlmRequester, + tools: readonly ToolDefinition[], +): Promise { + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length > 1, + { timeout: 5000 }, + ); + return snapshot.context.messages; +} + +function rolesAndTexts(messages: readonly HistoryMessage[]): string[] { + return toInputMessages(messages).map((message) => `${message.role}:${extractText(message, '\n')}`); +} + +describe('agent machine tool failure', () => { + it('turns a thrown tool error into a tool message and continues to thinking', async () => { + const usage: TokenUsage = { + inputOther: 100, + output: 5, + inputCacheRead: 20, + inputCacheCreation: 10, + }; + const seenUsedContextTokens: (number | undefined)[] = []; + let call = 0; + const requester: LlmRequester = { + generate: (_config, content, { onEvent }) => { + seenUsedContextTokens.push(content.usedContextTokens); + call += 1; + if (call === 1) { + onEvent?.({ type: 'llm.usage', usage }); + streamMessage(createAssistantMessage([], [toolCall('call-1', 'fail_tool')]), onEvent); + } else { + streamMessage(createAssistantMessage([{ type: 'text', text: 'recovered' }]), onEvent); + } + return Promise.resolve(); + }, + }; + const tools = stubTools(() => Promise.reject(new Error('boom')), 'fail_tool'); + + const messages = await runAgent(requester, tools); + + expect(rolesAndTexts(messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:boom', + 'assistant:recovered', + ]); + const toolMessage = messages[2]?.message; + expect(toolMessage?.role === 'tool' && toolMessage.toolCallId).toBe('call-1'); + expect(seenUsedContextTokens).toEqual([ + estimateMessageTokens(createUserMessage('hi')) + estimateTextTokens(JSON.stringify(tools)), + 137, + ]); + }); + + it('continues with the remaining tool calls after a failure', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'fail_tool'), toolCall('call-2', 'ok_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const executed: string[] = []; + const tools = stubTools(({ toolCall: call }) => { + executed.push(call.name); + if (call.name === 'fail_tool') { + return Promise.reject(new Error('boom')); + } + return Promise.resolve({ content: [{ type: 'text', text: 'ok' }] }); + }, 'fail_tool', 'ok_tool'); + + const messages = await runAgent(requester, tools); + + expect(executed).toEqual(['fail_tool', 'ok_tool']); + expect(rolesAndTexts(messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:boom', + 'tool:ok', + 'assistant:done', + ]); + }); + + it('stringifies non-Error thrown values', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'fail_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const tools = stubTools(() => Promise.reject('plain failure'), 'fail_tool'); + + const messages = await runAgent(requester, tools); + + expect(rolesAndTexts(messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:plain failure', + 'assistant:done', + ]); + }); + + it('executes multiple tool calls concurrently and keeps toolCall order in messages', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool'), toolCall('call-2', 'fast_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const started: string[] = []; + const resolvers = new Map void>(); + const tools = stubTools(({ toolCall: call }) => { + started.push(call.name); + return new Promise((resolve) => { + resolvers.set(call.name, resolve); + }); + }, 'slow_tool', 'fast_tool'); + + const messagesPromise = runAgent(requester, tools); + await vi.waitFor(() => { + expect(started).toEqual(['slow_tool', 'fast_tool']); + }); + + resolvers.get('fast_tool')?.({ content: [{ type: 'text', text: 'fast' }] }); + resolvers.get('slow_tool')?.({ content: [{ type: 'text', text: 'slow' }] }); + const messages = await messagesPromise; + + expect(rolesAndTexts(messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:slow', + 'tool:fast', + 'assistant:done', + ]); + }); +}); + +describe('agent machine async tools', () => { + it('answers a detached tool call with an ack message and delivers the completion as a notification', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'bg_tool')]), + createAssistantMessage([{ type: 'text', text: 'waiting' }]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveBg: ((result: ToolResult) => void) | undefined; + const tools = stubTools(({ detach }) => { + detach?.({ text: 'async running: bg_tool' }); + return new Promise((resolve) => { + resolveBg = resolve; + }); + }, 'bg_tool'); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().value).toEqual({ idle: 'waiting' }); + }); + expect(actor.getSnapshot().status).toBe('active'); + expect( + actor.getSnapshot().context.background['call-1']?.ref.getSnapshot().status, + ).not.toBe('done'); + + resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 6, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:async running: bg_tool', + 'assistant:waiting', + 'user:[async tool completed] bg_tool (tool_call_id=call-1)\nbg-result', + 'assistant:done', + ]); + expect(actor.getSnapshot().context.background['call-1']).toBeUndefined(); + }); + + it('finishes the turn once sync results and async acks are in, delivering the completion later', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'bg_tool'), toolCall('call-2', 'sync_tool')]), + createAssistantMessage([{ type: 'text', text: 'turn2' }]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const resolvers = new Map void>(); + const tools = stubTools(({ toolCall: call, detach }) => { + if (call.name === 'bg_tool') { + detach?.({ text: 'async running: bg_tool' }); + } + return new Promise((resolve) => { + resolvers.set(call.name, resolve); + }); + }, 'bg_tool', 'sync_tool'); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(resolvers.has('sync_tool')).toBe(true); + }); + resolvers.get('sync_tool')?.({ content: [{ type: 'text', text: 'sync-ok' }] }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().value).toEqual({ idle: 'waiting' }); + }); + + resolvers.get('bg_tool')?.({ content: [{ type: 'text', text: 'bg-result' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 7, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:async running: bg_tool', + 'tool:sync-ok', + 'assistant:turn2', + 'user:[async tool completed] bg_tool (tool_call_id=call-1)\nbg-result', + 'assistant:done', + ]); + }); + + it('lets WaitFor reap a completed background task and delivers the notification in the same batch', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'bg_tool')]), + createAssistantMessage([], [toolCall('call-2', 'WaitFor', '{"task_id":"call-1","timeout":5}')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveBg: ((result: ToolResult) => void) | undefined; + const bgTool = defineTool({ + name: 'bg_tool', + description: 'test background tool', + parameters: { type: 'object', properties: {} }, + execute: ({ detach }) => { + detach?.({ text: 'async running: bg_tool' }); + return new Promise((resolve) => { + resolveBg = resolve; + }); + }, + }); + const tools = [bgTool, waitForTool]; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().context.turnTools['call-2']).toBeDefined(); + }); + resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 7, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:async running: bg_tool', + 'assistant:', + 'tool:completed: call-1', + 'user:[async tool completed] bg_tool (tool_call_id=call-1)\nbg-result', + 'assistant:done', + ]); + }); + + it('reports running tasks when WaitFor times out and delivers the completion later', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'bg_tool')]), + createAssistantMessage([], [ + toolCall('call-2', 'WaitFor', '{"task_id":"call-1","timeout":1}'), + ]), + createAssistantMessage([{ type: 'text', text: 'ack-timeout' }]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveBg: ((result: ToolResult) => void) | undefined; + const bgTool = defineTool({ + name: 'bg_tool', + description: 'test background tool', + parameters: { type: 'object', properties: {} }, + execute: ({ detach }) => { + detach?.({ text: 'async running: bg_tool' }); + return new Promise((resolve) => { + resolveBg = resolve; + }); + }, + }); + const tools = [bgTool, waitForTool]; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor( + () => { + expect(actor.getSnapshot().value).toEqual({ idle: 'waiting' }); + }, + { timeout: 4000 }, + ); + resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 8, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:async running: bg_tool', + 'assistant:', + 'tool:running: call-1\ntimedOut after 1000 ms', + 'assistant:ack-timeout', + 'user:[async tool completed] bg_tool (tool_call_id=call-1)\nbg-result', + 'assistant:done', + ]); + }); + + it('answers WaitFor immediately for an unknown task_id or when nothing is running', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'WaitFor', '{"task_id":"nope","timeout":5}')]), + createAssistantMessage([], [toolCall('call-2', 'WaitFor', '{"timeout":5}')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const messages = await runAgent(requester, [waitForTool]); + + expect(rolesAndTexts(messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:Task not found: nope', + 'assistant:', + 'tool:no async tool calls running', + 'assistant:done', + ]); + }); +}); + +describe('agent machine lifecycle', () => { + it('runs multiple turns on the same agent', async () => { + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'first' }]), + createAssistantMessage([{ type: 'text', text: 'second' }]), + ]); + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const completedTurns: number[] = []; + actor.on('turn.done', (event) => completedTurns.push(event.messages.length)); + actor.start(); + + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + + actor.send({ type: 'input.submit', message: createUserMessage('again') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:first', + 'user:again', + 'assistant:second', + ]); + expect(completedTurns).toEqual([2, 4]); + expect(actor.getSnapshot().status).toBe('active'); + }); + + it('queues input submitted during a turn and starts a new turn for it afterwards', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + createAssistantMessage([{ type: 'text', text: 'first' }]), + createAssistantMessage([{ type: 'text', text: 'second' }]), + ]); + let resolveTool: ((result: ToolResult) => void) | undefined; + const tools = stubTools( + () => + new Promise((resolve) => { + resolveTool = resolve; + }), + 'slow_tool', + ); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(resolveTool).toBeDefined(); + }); + actor.send({ type: 'input.submit', message: createUserMessage('mid-turn') }); + expect(actor.getSnapshot().context.queue).toHaveLength(1); + expect(actor.getSnapshot().context.notifications).toHaveLength(0); + + resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 6, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:slow', + 'assistant:first', + 'user:mid-turn', + 'assistant:second', + ]); + }); + + it('emits turn.failed on llm failure, returns to idle, and accepts new input', async () => { + let call = 0; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + call += 1; + if (call === 1) { + onEvent?.({ type: 'llm.failed.remote', error: { kind: 'unknown', message: 'llm down' } }); + return Promise.resolve(); + } + streamMessage(createAssistantMessage([{ type: 'text', text: 'recovered' }]), onEvent); + return Promise.resolve(); + }, + }; + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const failures: unknown[] = []; + actor.on('turn.failed', (event) => failures.push(event.error)); + actor.start(); + + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + const failedSnapshot = await waitFor( + actor, + (s) => s.matches('idle') && failures.length === 1, + { timeout: 5000 }, + ); + expect(rolesAndTexts(failedSnapshot.context.messages)).toEqual(['user:hi']); + expect(failures[0]).toMatchObject({ kind: 'unknown', message: 'llm down' }); + expect(actor.getSnapshot().status).toBe('active'); + + actor.send({ type: 'input.submit', message: createUserMessage('retry') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 3, + { timeout: 5000 }, + ); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'user:retry', + 'assistant:recovered', + ]); + }); +}); + +describe('agent machine input.notify', () => { + it('delivers a notified message at the next thinking step within the turn', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveTool: ((result: ToolResult) => void) | undefined; + const tools = stubTools( + () => + new Promise((resolve) => { + resolveTool = resolve; + }), + 'slow_tool', + ); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(resolveTool).toBeDefined(); + }); + actor.send({ + type: 'input.notify', + message: createUserMessage('stale'), + }); + expect(actor.getSnapshot().context.notifications).toHaveLength(1); + + resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 5, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:slow', + 'user:stale', + 'assistant:done', + ]); + }); + + it('ends the turn at a message-only step and starts a new turn for a pending notification', async () => { + let firstOnEvent: ((event: LlmRequestEvent) => void) | undefined; + let resolveFirst: (() => void) | undefined; + let call = 0; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + call += 1; + if (call === 1) { + firstOnEvent = onEvent; + return new Promise((resolve) => { + resolveFirst = resolve; + }); + } + streamMessage(createAssistantMessage([{ type: 'text', text: 'second' }]), onEvent); + return Promise.resolve(); + }, + }; + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const completedTurns: number[] = []; + actor.on('turn.done', (event) => completedTurns.push(event.messages.length)); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(resolveFirst).toBeDefined(); + }); + actor.send({ + type: 'input.notify', + message: createUserMessage('stale'), + }); + expect(actor.getSnapshot().context.notifications).toHaveLength(1); + + streamMessage(createAssistantMessage([{ type: 'text', text: 'first' }]), firstOnEvent); + resolveFirst?.(); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:first', + 'user:stale', + 'assistant:second', + ]); + expect(completedTurns).toEqual([2, 4]); + }); + + it('drives a turn immediately for a notification while idle', async () => { + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const llmDone: AssistantEntry[] = []; + actor.on('llm.done', (event) => { + llmDone.push(event.entry); + }); + actor.start(); + actor.send({ type: 'input.notify', message: createUserMessage('queued') }); + + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 2, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:queued', 'assistant:done']); + expect(llmDone.map((entry) => extractText(entry.message))).toEqual(['done']); + expect(llmDone[0]?.meta).toEqual({ + model: { provider: 'test', model: 'test-model' }, + source: 'llm', + usage: emptyUsage(), + headers: undefined, + finish: { finishReason: 'completed', rawFinishReason: 'stop' }, + messageId: 'msg-stub', + }); + }); +}); + +describe('agent machine input.reminder', () => { + it('stays pending while idle and is delivered at the next turn drain', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveTool: ((result: ToolResult) => void) | undefined; + const tools = stubTools( + () => + new Promise((resolve) => { + resolveTool = resolve; + }), + 'slow_tool', + ); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const consumedKeys: (string | undefined)[][] = []; + actor.on('turn.remindersConsumed', (event) => { + if (event.type === 'turn.remindersConsumed') { + consumedKeys.push(event.reminders.map((entry) => entry.meta.key)); + } + }); + actor.start(); + + actor.send({ + type: 'input.reminder', + key: 'todo', + message: createUserMessage('\nold\n'), + }); + actor.send({ + type: 'input.reminder', + key: 'todo', + message: createUserMessage('\nstale\n'), + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + const idleSnapshot = actor.getSnapshot(); + expect(idleSnapshot.matches('idle')).toBe(true); + expect(idleSnapshot.context.messages).toHaveLength(0); + expect(idleSnapshot.context.reminders).toHaveLength(1); + expect(idleSnapshot.context.reminders[0]?.meta).toEqual({ source: 'reminder', key: 'todo' }); + expect(extractText(idleSnapshot.context.reminders[0]?.message ?? createUserMessage(''))).toContain('stale'); + + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await vi.waitFor(() => { + expect(resolveTool).toBeDefined(); + }); + expect(actor.getSnapshot().context.reminders).toHaveLength(1); + + resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 5, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:slow', + 'user:\nstale\n', + 'assistant:done', + ]); + expect(snapshot.context.messages[3]?.meta).toEqual({ source: 'reminder', key: 'todo' }); + expect(snapshot.context.reminders).toHaveLength(0); + expect(consumedKeys).toEqual([['todo']]); + }); +}); + +describe('agent machine llm retry', () => { + it('retries a retryable llm failure within the turn and completes', async () => { + let call = 0; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + call += 1; + onEvent?.({ type: 'llm.sent' }); + if (call === 1) { + onEvent?.({ + type: 'llm.failed.remote', + error: { + kind: 'status', + statusCode: 500, + message: 'server error', + requestId: null, + retryAfterMs: null, + headers: null, + }, + }); + return Promise.resolve(); + } + streamMessage(createAssistantMessage([{ type: 'text', text: 'recovered' }]), onEvent); + return Promise.resolve(); + }, + }; + const ticks = [1000, 1100, 1200, 100000, 100100, 100140, 100200]; + const timingPlugin = createTimingPlugin({ now: () => ticks.shift() ?? Number.NaN }); + const tools: ToolDefinition[] = []; + const actor = createActor( + createTestAgentMachine(tools, requester, { maxAttemptsPerStep: 3 }), + { + input: { request: { model } }, + }, + ); + connectPlugins(actor, [timingPlugin]); + const retrying: Extract[] = []; + const failures: unknown[] = []; + actor.on('llm.retrying', (event) => retrying.push(event)); + actor.on('turn.failed', (event) => failures.push(event.error)); + actor.start(); + + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 2, + { timeout: 5000 }, + ); + + expect(call).toBe(2); + expect(retrying).toHaveLength(1); + expect(retrying[0]).toMatchObject({ + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 3, + statusCode: 500, + }); + expect(failures).toHaveLength(0); + expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:hi', 'assistant:recovered']); + + const delayMs = retrying[0]?.delayMs ?? 0; + expect(timingPlugin.timing()).toEqual({ + requestBuildMs: 100000 - 1200 - delayMs, + ttftMs: 100100 - 1200 - delayMs, + serverFirstTokenMs: 100, + streamDurationMs: 100, + serverDecodeMs: 60, + clientConsumeMs: 40, + }); + expect(ticks).toHaveLength(0); + }); +}); + +describe('agent machine input.steer', () => { + it('promotes a queued prompt into the current turn at the next thinking step', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + let resolveTool: ((result: ToolResult) => void) | undefined; + const tools = stubTools( + () => + new Promise((resolve) => { + resolveTool = resolve; + }), + 'slow_tool', + ); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(resolveTool).toBeDefined(); + }); + actor.send({ type: 'input.submit', id: 'p1', message: createUserMessage('steer me') }); + expect(actor.getSnapshot().context.queue).toHaveLength(1); + + actor.send({ type: 'input.steer', id: 'nope' }); + expect(actor.getSnapshot().context.queue).toHaveLength(1); + expect(actor.getSnapshot().context.notifications).toHaveLength(0); + + actor.send({ type: 'input.steer', id: 'p1' }); + expect(actor.getSnapshot().context.queue).toHaveLength(0); + expect(actor.getSnapshot().context.notifications).toHaveLength(1); + + resolveTool?.({ content: [{ type: 'text', text: 'slow' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 5, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:slow', + 'user:steer me', + 'assistant:done', + ]); + }); +}); + +describe('agent machine input.abort', () => { + it('salvages streamed content when aborting an in-flight llm request', async () => { + const signals: AbortSignal[] = []; + const requester: LlmRequester = { + generate: (_config, _content, { signal, onEvent }) => { + signals.push(signal); + const parts: StreamedMessagePart[] = [ + { type: 'text', text: 'hel' }, + { type: 'text', text: 'lo' }, + { type: 'function', id: 'call-1', name: 'slow_tool', arguments: '{"incom' }, + { type: 'text', text: ' ' }, + ]; + return new Promise((resolve) => { + for (const part of parts) { + onEvent?.({ type: 'llm.delta', part }); + } + signal.addEventListener('abort', () => { + onEvent?.({ type: 'llm.failed.remote', error: { kind: 'abort', message: 'aborted' } }); + resolve(); + }); + }); + }, + }; + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const aborted: HistoryMessage[][] = []; + const aborting: unknown[] = []; + actor.on('turn.aborted', (event) => aborted.push(event.messages)); + actor.on('turn.aborting', (event) => aborting.push(event)); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(signals).toHaveLength(1); + }); + actor.send({ type: 'input.abort' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && aborted.length === 1, + { timeout: 5000 }, + ); + + expect(signals[0]?.aborted).toBe(true); + expect(aborting).toHaveLength(1); + expect(rolesAndTexts(snapshot.context.messages)).toEqual(['user:hi', 'assistant:hello']); + expect(rolesAndTexts(aborted[0] ?? [])).toEqual(['user:hi', 'assistant:hello']); + const salvaged = aborted[0]?.[1]; + expect(salvaged?.message.role === 'assistant' && salvaged.message.toolCalls).toEqual([]); + expect(salvaged?.meta.source).toBe('salvaged'); + }); + + it('aborts running turn tools and completes the transcript with aborted tool messages', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + ]); + const signals: AbortSignal[] = []; + const tools = stubTools(({ signal }) => { + signals.push(signal); + return new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('tool stopped'))); + }); + }, 'slow_tool'); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(signals).toHaveLength(1); + }); + actor.send({ type: 'input.abort' }); + expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 3, + { timeout: 5000 }, + ); + + expect(signals[0]?.aborted).toBe(true); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:aborted', + ]); + }); + + it('waits for the real outcome of a tool that settles after the abort signal', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + ]); + const tools = stubTools( + ({ signal }) => + new Promise((resolve) => { + signal.addEventListener('abort', () => resolve({ content: [{ type: 'text', text: 'partial' }] })); + }), + 'slow_tool', + ); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + const aborted: HistoryMessage[][] = []; + actor.on('turn.aborted', (event) => aborted.push(event.messages)); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().context.turnTools['call-1']).toBeDefined(); + }); + actor.send({ type: 'input.abort' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && aborted.length === 1, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:partial', + ]); + }); + + it('forces the turn to aborted on a second abort when a tool ignores the signal', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + ]); + const signals: AbortSignal[] = []; + const tools = stubTools(({ signal }) => { + signals.push(signal); + return new Promise(() => {}); + }, 'slow_tool'); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(signals).toHaveLength(1); + }); + actor.send({ type: 'input.abort' }); + expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); + + actor.send({ type: 'input.abort' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 3, + { timeout: 5000 }, + ); + + expect(signals[0]?.aborted).toBe(true); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:aborted', + ]); + }); + + it('forces the turn to aborted when tools do not settle before the abort timeout', async () => { + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'slow_tool')]), + ]); + const tools = stubTools(() => new Promise(() => {}), 'slow_tool'); + const actor = createActor(createTestAgentMachine(tools, requester, undefined, 50), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(actor.getSnapshot().context.turnTools['call-1']).toBeDefined(); + }); + actor.send({ type: 'input.abort' }); + expect(actor.getSnapshot().value).toEqual({ running: 'aborting' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 3, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:aborted', + ]); + }); + + it('starts a new turn for queued prompts and notifications after abort', async () => { + let call = 0; + const requester: LlmRequester = { + generate: (_config, _content, { signal, onEvent }) => { + call += 1; + if (call === 1) { + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + onEvent?.({ + type: 'llm.failed.remote', + error: { kind: 'abort', message: 'aborted' }, + }); + resolve(); + }); + }); + } + streamMessage(createAssistantMessage([{ type: 'text', text: 'second' }]), onEvent); + return Promise.resolve(); + }, + }; + const tools: ToolDefinition[] = []; + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(call).toBe(1); + }); + actor.send({ type: 'input.submit', id: 'p1', message: createUserMessage('queued') }); + actor.send({ type: 'input.submit', id: 'p2', message: createUserMessage('steered') }); + actor.send({ type: 'input.steer', id: 'p2' }); + actor.send({ type: 'input.abort' }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'user:steered', + 'user:queued', + 'assistant:second', + ]); + }); + + it('keeps detached background tools running across an abort', async () => { + let call = 0; + const bgSignals: AbortSignal[] = []; + let resolveBg: ((result: ToolResult) => void) | undefined; + const requester: LlmRequester = { + generate: (_config, _content, { signal, onEvent }) => { + call += 1; + if (call === 1) { + streamMessage(createAssistantMessage([], [toolCall('call-1', 'bg_tool')]), onEvent); + return Promise.resolve(); + } + if (call === 2) { + return new Promise((resolve) => { + signal.addEventListener('abort', () => { + onEvent?.({ + type: 'llm.failed.remote', + error: { kind: 'abort', message: 'aborted' }, + }); + resolve(); + }); + }); + } + streamMessage(createAssistantMessage([{ type: 'text', text: 'done' }]), onEvent); + return Promise.resolve(); + }, + }; + const tools = stubTools(({ detach, signal }) => { + bgSignals.push(signal); + detach?.({ text: 'async running: bg_tool' }); + return new Promise((resolve) => { + resolveBg = resolve; + }); + }, 'bg_tool'); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(call).toBe(2); + }); + actor.send({ type: 'input.abort' }); + await vi.waitFor(() => { + expect(actor.getSnapshot().value).toEqual({ idle: 'waiting' }); + }); + expect(bgSignals[0]?.aborted).toBe(false); + expect(actor.getSnapshot().context.background['call-1']).toBeDefined(); + + resolveBg?.({ content: [{ type: 'text', text: 'bg-result' }] }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 5, + { timeout: 5000 }, + ); + + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:async running: bg_tool', + 'user:[async tool completed] bg_tool (tool_call_id=call-1)\nbg-result', + 'assistant:done', + ]); + }); +}); + +describe('agent machine max steps', () => { + it('resets the step budget on drained input and fails only on pure tool-call continuation', async () => { + let call = 0; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + call += 1; + streamMessage(createAssistantMessage([], [toolCall(`call-${call}`, 'ok_tool')]), onEvent); + return Promise.resolve(); + }, + }; + const resolvers: Array<() => void> = []; + const tools = stubTools( + () => + new Promise((resolve) => { + resolvers.push(() => resolve({ content: [{ type: 'text', text: 'ok' }] })); + }), + 'ok_tool', + ); + const actor = createActor( + createAgentMachine({ + tools, + turnActor: createTurnMachine(createLlmMachine({ requester })), + maxStepsPerTurn: 2, + }), + { input: { request: { model } } }, + ); + const failures: Extract[] = []; + actor.on('turn.failed', (event) => failures.push(event)); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + + await vi.waitFor(() => { + expect(call).toBe(1); + expect(resolvers).toHaveLength(1); + }); + resolvers[0]?.(); + + await vi.waitFor(() => { + expect(call).toBe(2); + expect(resolvers).toHaveLength(2); + }); + actor.send({ type: 'input.notify', message: createUserMessage('keep going') }); + resolvers[1]?.(); + + await vi.waitFor(() => { + expect(call).toBe(3); + expect(resolvers).toHaveLength(3); + }); + resolvers[2]?.(); + + await vi.waitFor(() => { + expect(call).toBe(4); + expect(resolvers).toHaveLength(4); + }); + resolvers[3]?.(); + + const snapshot = await waitFor(actor, (s) => s.matches('idle') && failures.length === 1, { + timeout: 5000, + }); + + expect(call).toBe(4); + expect(failures[0]?.interruptReason).toBe('max_steps'); + expect(failures[0]?.error).toBeInstanceOf(MaxStepsExceededError); + expect((failures[0]?.error as MaxStepsExceededError).code).toBe('loop.max_steps_exceeded'); + expect((failures[0]?.error as MaxStepsExceededError).details).toEqual({ maxSteps: 2 }); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:', + 'tool:ok', + 'assistant:', + 'tool:ok', + 'user:keep going', + 'assistant:', + 'tool:ok', + 'assistant:', + 'tool:ok', + ]); + expect(actor.getSnapshot().status).toBe('active'); + }); +}); + +describe('agent machine context reset', () => { + it('replaces messages, turnId and branchId when idle', async () => { + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'reply' }]), + ]); + const actor = createActor(createTestAgentMachine([], requester), { + input: { request: { model } }, + }); + actor.start(); + const resets: string[] = []; + actor.on('context.reset', (event) => { + if (event.type === 'context.reset') resets.push(event.branchId); + }); + const turnStarts: Array<{ turnId: number; branchId: string }> = []; + actor.on('turn.start', (event) => { + if (event.type === 'turn.start') { + turnStarts.push({ turnId: event.turnId, branchId: event.branchId }); + } + }); + + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + + actor.send({ + type: 'context.reset', + history: [createUserEntry(createUserMessage('seed'), { source: 'input' })], + turnId: 0, + branchId: 'main~2', + }); + + expect(actor.getSnapshot().context.messages).toHaveLength(1); + expect(actor.getSnapshot().context.turnId).toBe(0); + expect(actor.getSnapshot().context.branchId).toBe('main~2'); + expect(resets).toEqual(['main~2']); + + actor.send({ type: 'input.submit', message: createUserMessage('again') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 3, + { timeout: 5000 }, + ); + expect(snapshot.context.turnId).toBe(1); + expect(turnStarts).toEqual([ + { turnId: 1, branchId: 'main' }, + { turnId: 1, branchId: 'main~2' }, + ]); + }); + + it('ignores context.reset while running, including a turn driven by notify', async () => { + let calls = 0; + const releases: Array<() => void> = []; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + calls += 1; + return new Promise((resolve) => { + releases.push(() => { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'late' } }); + onEvent?.({ type: 'llm.done' }); + resolve(); + }); + }); + }, + }; + const actor = createActor(createTestAgentMachine([], requester), { + input: { request: { model } }, + }); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await vi.waitFor(() => expect(calls).toBe(1)); + + actor.send({ type: 'context.reset', history: [], turnId: 0, branchId: 'other' }); + expect(actor.getSnapshot().context.branchId).toBe('main'); + + releases[0]?.(); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + + actor.send({ type: 'input.notify', message: createUserMessage('note') }); + await vi.waitFor(() => expect(calls).toBe(2)); + actor.send({ type: 'context.reset', history: [], turnId: 0, branchId: 'other' }); + expect(actor.getSnapshot().context.branchId).toBe('main'); + + releases[1]?.(); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:late', + 'user:note', + 'assistant:late', + ]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/agent/persist.test.ts b/packages/agent-core-v2/src/human/test/agent/persist.test.ts new file mode 100644 index 00000000000..acc5e799428 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/agent/persist.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createActor, waitFor } from 'xstate'; + +import { collectPluginTools, connectPlugins } from '#/plugin'; +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { + createAssistantMessage, + createUserMessage, + type AssistantMessage, + type Message, + type ToolCall, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { createAgentMachine } from '#/agent/machine'; +import { loadAgentState, type TurnEntryData } from '#/agent/replay'; +import { persistAgent } from '#/persist/agent'; +import { createTurnMachine } from '#/agent/turn'; +import { MemoryBackend } from '#/store/backend/memory'; +import type { Branch } from '#/store/branch'; +import { TreeStore } from '#/store/store'; +import type { EntryLine } from '#/store/types'; +import { createTodoPlugin } from '#/todo/plugin'; +import { restoreTodoState, snapshotTodoState } from '#/todo/state'; +import { defineTool, type ToolDefinition } from '#/tool/tool'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +function toolCall(id: string, name: string, args: string = '{}'): ToolCall { + return { type: 'function', id, name, arguments: args }; +} + +function createStubRequester(responses: readonly AssistantMessage[]): LlmRequester { + let call = 0; + return { + generate: (_config, _content, { onEvent }) => { + const message = responses[Math.min(call, responses.length - 1)] as AssistantMessage; + call += 1; + for (const part of [...message.content, ...message.toolCalls]) { + onEvent?.({ type: 'llm.delta', part }); + } + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createTestAgentMachine(tools: readonly ToolDefinition[], requester: LlmRequester) { + return createAgentMachine({ + tools, + turnActor: createTurnMachine(createLlmMachine({ requester })), + }); +} + +const okTool = defineTool({ + name: 'ok_tool', + description: 'stub ok tool', + parameters: { type: 'object', properties: {} }, + execute: () => Promise.resolve({ content: [{ type: 'text', text: 'ok' }] }), +}); + +async function branchOf(store: TreeStore, tree: string, branch: string): Promise { + const loaded = await store.tree(tree); + return loaded.has(branch) ? loaded.openBranch(branch) : loaded.createBranch(branch); +} + +async function entriesOf(store: TreeStore, tree: string, branch: string): Promise { + const loaded = await store.tree(tree); + return [...loaded.openBranch(branch).walk()].toReversed(); +} + +function entryTypes(entries: readonly EntryLine[]): string[] { + return entries.map((entry) => entry.type); +} + +function turnData(entry: EntryLine): TurnEntryData { + return entry.payload.data as TurnEntryData; +} + +async function waitForEntries( + store: TreeStore, + tree: string, + branch: string, + count: number, +): Promise { + let entries: EntryLine[] = []; + await vi.waitFor(async () => { + entries = await entriesOf(store, tree, branch); + expect(entries).toHaveLength(count); + }); + return entries; +} + +describe('persistAgent', () => { + it('persists messages and turn boundaries in order', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const requester = createStubRequester([ + createAssistantMessage([], [toolCall('call-1', 'ok_tool')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const actor = createActor(createTestAgentMachine([okTool], requester), { + input: { request: { model } }, + }); + actor.start(); + const handle = persistAgent(actor, await branchOf(store, 'session', 'main')); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + + const entries = await waitForEntries(store, 'session', 'main', 6); + expect(entryTypes(entries)).toEqual([ + 'message', + 'turn', + 'message', + 'message', + 'message', + 'turn', + ]); + expect((entries[0] as EntryLine).payload.data).toMatchObject({ + message: { role: 'user' }, + meta: { source: 'input' }, + }); + expect(turnData(entries[1] as EntryLine)).toEqual({ phase: 'start', turnId: 1 }); + expect(turnData(entries[5] as EntryLine)).toEqual({ + phase: 'end', + turnId: 1, + outcome: 'done', + }); + handle.dispose(); + }); + + it('restores messages and turnId without duplicating entries', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const first = createStubRequester([createAssistantMessage([{ type: 'text', text: 'first' }])]); + const firstActor = createActor(createTestAgentMachine([okTool], first), { + input: { request: { model } }, + }); + firstActor.start(); + persistAgent(firstActor, await branchOf(store, 'session', 'main')); + firstActor.send({ type: 'input.submit', message: createUserMessage('hi') }); + const firstSnapshot = await waitFor( + firstActor, + (s) => s.matches('idle') && s.context.messages.length === 2, + { timeout: 5000 }, + ); + await waitForEntries(store, 'session', 'main', 4); + + const reopened = await TreeStore.open(fs); + const loaded = await loadAgentState(await reopened.tree('session'), 'main'); + expect(loaded.messages).toEqual(firstSnapshot.context.messages); + expect(loaded.turnId).toBe(1); + + const second = createStubRequester([createAssistantMessage([{ type: 'text', text: 'second' }])]); + const secondActor = createActor(createTestAgentMachine([okTool], second), { + input: { request: { model }, history: loaded.messages, turnId: loaded.turnId }, + }); + secondActor.start(); + persistAgent(secondActor, await branchOf(reopened, 'session', 'main')); + secondActor.send({ type: 'input.submit', message: createUserMessage('again') }); + const secondSnapshot = await waitFor( + secondActor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + + const entries = await waitForEntries(reopened, 'session', 'main', 8); + const messageEntries = entries.filter((entry) => entry.type === 'message'); + expect(messageEntries.map((entry) => entry.payload.data)).toEqual( + secondSnapshot.context.messages, + ); + const starts = entries + .filter((entry) => entry.type === 'turn') + .map((entry) => turnData(entry)) + .filter((data) => data.phase === 'start'); + expect(starts).toEqual([ + { phase: 'start', turnId: 1 }, + { phase: 'start', turnId: 2 }, + ]); + }); + + it('persists and restores todo state', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const todo = createTodoPlugin(); + const tools = collectPluginTools([todo]); + const requester = createStubRequester([ + createAssistantMessage( + [], + [ + toolCall( + 'call-todo', + 'TodoList', + JSON.stringify({ todos: [{ title: 'task a', status: 'in_progress' }] }), + ), + ], + ), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const actor = createActor(createTestAgentMachine(tools, requester), { + input: { request: { model } }, + }); + connectPlugins(actor, [todo]); + actor.start(); + persistAgent(actor, await branchOf(store, 'session', 'main'), { + states: { todo: () => snapshotTodoState(todo.state) }, + }); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + + const entries = await waitForEntries(store, 'session', 'main', 7); + expect(entryTypes(entries)).toEqual([ + 'message', + 'turn', + 'state', + 'message', + 'message', + 'message', + 'turn', + ]); + const stateEntry = entries.find((entry) => entry.type === 'state') as EntryLine; + expect(stateEntry.payload.data).toEqual({ + name: 'todo', + value: { todos: [{ title: 'task a', status: 'in_progress' }], lastWriteTurn: 1 }, + }); + + const reopened = await TreeStore.open(fs); + const loaded = await loadAgentState(await reopened.tree('session'), 'main'); + const restored = restoreTodoState(loaded.states['todo'], loaded.turnId); + expect(restored.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); + expect(restored.lastWriteTurn).toBe(1); + expect(restored.currentTurn).toBe(1); + }); + + it('restores offloaded messages through blob refs', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs, { offloadThreshold: 1 }); + const requester = createStubRequester([ + createAssistantMessage([{ type: 'text', text: 'a fairly long reply' }]), + ]); + const actor = createActor(createTestAgentMachine([okTool], requester), { + input: { request: { model } }, + }); + actor.start(); + persistAgent(actor, await branchOf(store, 'session', 'main')); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 2, + { timeout: 5000 }, + ); + await waitForEntries(store, 'session', 'main', 4); + + const reopened = await TreeStore.open(fs); + const loaded = await loadAgentState(await reopened.tree('session'), 'main'); + expect(loaded.messages).toEqual(snapshot.context.messages); + expect(loaded.turnId).toBe(1); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts b/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts new file mode 100644 index 00000000000..d7b274a8e24 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/kimi-oauth/credential-source.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import type { StreamedMessagePart, VideoURLPart } from '#/llm/message'; +import type { MediaVideoUploader } from '#/llm/media/upload'; +import type { LlmErrorMessage } from '#/llm/errors'; +import type { LlmModel } from '#/llm/model'; +import type { LlmRequestControl, LlmRequester } from '#/llm/requester/requester'; +import { + kimiOAuthCredentialSource, + withAuth, + withAuthUpload, + type CredentialSource, +} from '#/kimi-oauth/index'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type GenerateArgs = Parameters; + +function generateArgs(control: Partial = {}): GenerateArgs { + return [{ model }, { messages: [] }, { signal: new AbortController().signal, ...control }]; +} + +function statusError(status: number): LlmErrorMessage { + return { + kind: 'status', + statusCode: status, + message: `status ${status}`, + requestId: null, + retryAfterMs: null, + headers: null, + }; +} + +interface InnerCall { + readonly model: LlmModel; +} + +function createInner(plan: readonly (LlmErrorMessage | 'ok')[]) { + const calls: InnerCall[] = []; + const requester: LlmRequester = { + generate: (config, _content, { onEvent }) => { + calls.push({ model: config.model }); + const step = plan[Math.min(calls.length - 1, plan.length - 1)]; + if (step === 'ok') { + onEvent?.({ + type: 'llm.delta', + part: { type: 'text', text: `call-${calls.length}` }, + }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + onEvent?.({ type: 'llm.failed.remote', error: step }); + return Promise.resolve(); + }, + }; + return { requester, calls }; +} + +async function generateFailures( + requester: LlmRequester, + control: Partial = {}, +): Promise { + const failures: LlmErrorMessage[] = []; + await requester.generate( + ...generateArgs({ + ...control, + onEvent: (event) => { + if (event.type === 'llm.failed.remote' || event.type === 'llm.failed.syntax') { + failures.push(event.error); + } + }, + }), + ); + return failures; +} + +describe('withAuth', () => { + it('resolves credentials before each generate and forwards the resolved model', async () => { + const { requester, calls } = createInner(['ok']); + const source: CredentialSource = { + resolve: (m) => ({ ...m, apiKey: 'token-1' }), + }; + const wrapped = withAuth(requester, source); + + const parts: StreamedMessagePart[] = []; + await wrapped.generate( + ...generateArgs({ + onEvent: (event) => { + if (event.type === 'llm.delta') { + parts.push(event.part); + } + }, + }), + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.model).toEqual({ ...model, apiKey: 'token-1' }); + expect(parts).toEqual([{ type: 'text', text: 'call-1' }]); + }); + + it('retries once with forced credentials when canRecover accepts the error', async () => { + const { requester, calls } = createInner([statusError(401), 'ok']); + const resolveOptions: unknown[] = []; + const source: CredentialSource = { + resolve: (m, options) => { + resolveOptions.push(options); + return { ...m, apiKey: options?.force === true ? 'token-2' : 'token-1' }; + }, + canRecover: (_m, error) => statusErrorStatus(error) === 401, + }; + const wrapped = withAuth(requester, source); + + const parts: StreamedMessagePart[] = []; + await wrapped.generate( + ...generateArgs({ + onEvent: (event) => { + if (event.type === 'llm.delta') { + parts.push(event.part); + } + }, + }), + ); + + expect(calls).toHaveLength(2); + expect(calls[0]?.model.apiKey).toBe('token-1'); + expect(calls[1]?.model.apiKey).toBe('token-2'); + expect(resolveOptions).toEqual([undefined, { force: true }]); + expect(parts).toEqual([{ type: 'text', text: 'call-2' }]); + }); + + it('emits the failure when the retry also fails', async () => { + const { requester, calls } = createInner([statusError(401), statusError(401)]); + const source: CredentialSource = { + resolve: (m) => m, + canRecover: () => true, + }; + const wrapped = withAuth(requester, source); + + const failures = await generateFailures(wrapped); + + expect(calls).toHaveLength(2); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); + }); + + it('does not retry when canRecover rejects the error', async () => { + const { requester, calls } = createInner([statusError(401)]); + const source: CredentialSource = { + resolve: (m) => m, + canRecover: () => false, + }; + const wrapped = withAuth(requester, source); + + const failures = await generateFailures(wrapped); + + expect(calls).toHaveLength(1); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); + }); + + it('does not retry when the source has no canRecover', async () => { + const { requester, calls } = createInner([statusError(401)]); + const wrapped = withAuth(requester, { resolve: (m) => m }); + + const failures = await generateFailures(wrapped); + + expect(calls).toHaveLength(1); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); + }); + + it('does not retry when the signal is aborted', async () => { + const { requester, calls } = createInner([statusError(401)]); + const controller = new AbortController(); + controller.abort(); + const source: CredentialSource = { + resolve: (m) => m, + canRecover: () => true, + }; + const wrapped = withAuth(requester, source); + + const failures = await generateFailures(wrapped, { signal: controller.signal }); + + expect(calls).toHaveLength(1); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ kind: 'status', statusCode: 401 }); + }); + + it('wraps uploadVideo with the same credential flow', async () => { + const part: VideoURLPart = { type: 'video_url', videoUrl: { url: 'ms://file-1', id: 'file-1' } }; + const seen: (string | undefined)[] = []; + let attempts = 0; + const inner: MediaVideoUploader = (_video, options) => { + attempts += 1; + seen.push(options.model.apiKey); + if (attempts === 1) { + return Promise.reject(statusError(401)); + } + return Promise.resolve(part); + }; + const source: CredentialSource = { + resolve: (m, options) => ({ ...m, apiKey: options?.force === true ? 'fresh' : 'stale' }), + canRecover: () => true, + }; + const wrapped = withAuthUpload(inner, source); + + const result = await wrapped({ data: new Uint8Array([1]), mimeType: 'video/mp4' }, { model }); + + expect(result).toBe(part); + expect(seen).toEqual(['stale', 'fresh']); + }); + + it('does not retry the upload when the signal is aborted', async () => { + const failure = statusError(401); + let attempts = 0; + const inner: MediaVideoUploader = () => { + attempts += 1; + return Promise.reject(failure); + }; + const controller = new AbortController(); + controller.abort(); + const wrapped = withAuthUpload(inner, { + resolve: (m) => m, + canRecover: () => true, + }); + + await expect( + wrapped( + { data: new Uint8Array([1]), mimeType: 'video/mp4' }, + { model, signal: controller.signal }, + ), + ).rejects.toBe(failure); + expect(attempts).toBe(1); + }); +}); + +describe('kimiOAuthCredentialSource', () => { + function createTokens() { + const calls: (boolean | undefined)[] = []; + return { + calls, + tokens: { + getAccessToken: (options?: { readonly force?: boolean }) => { + calls.push(options?.force); + return Promise.resolve('access-token'); + }, + }, + }; + } + + it('resolves the model apiKey from the token provider', async () => { + const { calls, tokens } = createTokens(); + const source = kimiOAuthCredentialSource(tokens); + + const resolved = await source.resolve({ ...model, baseUrl: 'https://example.com/v1' }); + + expect(resolved).toEqual({ + ...model, + baseUrl: 'https://example.com/v1', + apiKey: 'access-token', + }); + expect(calls).toEqual([false]); + }); + + it('passes force through to the token provider', async () => { + const { calls, tokens } = createTokens(); + const source = kimiOAuthCredentialSource(tokens); + + await source.resolve(model, { force: true }); + + expect(calls).toEqual([true]); + }); + + it('recovers only from 401 errors', () => { + const { tokens } = createTokens(); + const source = kimiOAuthCredentialSource(tokens); + + expect(source.canRecover?.(model, statusError(401))).toBe(true); + expect(source.canRecover?.(model, Object.assign(new Error('x'), { statusCode: 401 }))).toBe( + true, + ); + expect(source.canRecover?.(model, statusError(403))).toBe(false); + expect(source.canRecover?.(model, new Error('boom'))).toBe(false); + expect(source.canRecover?.(model, 'nope')).toBe(false); + }); +}); + +function statusErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + const record = error as Record; + const status = record['status'] ?? record['statusCode']; + return typeof status === 'number' ? status : undefined; +} diff --git a/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts b/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts new file mode 100644 index 00000000000..777f70a79f9 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/cache-key.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage, type Message } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createAnthropicRequester } from '#/llm/requester/bases/anthropic/requester'; +import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; +import type { LlmClientContext } from '#/llm/requester/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'test-model', + capability: UNKNOWN_CAPABILITY, + baseUrl: 'https://example.test/v1', +}; +const messages: readonly Message[] = [createUserMessage('hi')]; + +const chatCompletionChunks: readonly Record[] = [ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], + }, +]; + +const anthropicStreamEvents: readonly Record[] = [ + { type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 1 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }, + { type: 'message_stop' }, +]; + +function createAsyncStream(chunks: readonly T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +function stubOpenAIClient(chunks: readonly Record[]): { + clientFactory: (request: LlmClientContext) => never; + body: () => Record; +} { + const captured: Record[] = []; + return { + clientFactory: () => + ({ + chat: { + completions: { + create: (params: Record) => { + captured.push(params); + return { + withResponse: async () => ({ + data: createAsyncStream(chunks), + response: new Response(null), + }), + }; + }, + }, + }, + }) as never, + body: () => { + const last = captured.at(-1); + if (last === undefined) throw new Error('expected client to be called'); + return last; + }, + }; +} + +function stubAnthropicClient(events: readonly Record[]): { + clientFactory: (request: LlmClientContext) => never; + body: () => Record; +} { + const captured: Record[] = []; + return { + clientFactory: () => + ({ + messages: { + create: (params: Record) => { + captured.push(params); + return { + withResponse: async () => ({ + data: createAsyncStream(events), + response: new Response(null), + }), + }; + }, + }, + }) as never, + body: () => { + const last = captured.at(-1); + if (last === undefined) throw new Error('expected client to be called'); + return last; + }, + }; +} + +describe('openai requester cacheKey', () => { + it('encodes the cache key as prompt_cache_key by default', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { + model, + cacheKey: 'session-1', + extraParams: { openai: { stop: ['END'], presence_penalty: 0.5, extra_body: { trace_id: 't1' } } }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['prompt_cache_key']).toBe('session-1'); + expect(client.body()['stop']).toEqual(['END']); + expect(client.body()['presence_penalty']).toBe(0.5); + expect(client.body()['extra_body']).toEqual({ trace_id: 't1' }); + }); + + it('lets a trait override the cache key params', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { cacheKey: (key) => ({ custom_cache: key }) }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model, cacheKey: 'session-1' }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['custom_cache']).toBe('session-1'); + expect(client.body()['prompt_cache_key']).toBeUndefined(); + }); + + it('omits prompt_cache_key when no cache key is given', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['prompt_cache_key']).toBeUndefined(); + }); +}); + +describe('anthropic requester cacheKey', () => { + it('encodes the cache key as metadata.user_id', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, cacheKey: 'session-1', extraParams: { anthropic: { top_k: 5 } } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['metadata']).toEqual({ user_id: 'session-1' }); + expect(client.body()['top_k']).toBe(5); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/errors.test.ts b/packages/agent-core-v2/src/human/test/llm/errors.test.ts new file mode 100644 index 00000000000..ef3e8a3bb45 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/errors.test.ts @@ -0,0 +1,325 @@ +import { + APIConnectionError as RawOpenAISDKConnectionError, + APIConnectionTimeoutError as RawOpenAISDKConnectionTimeoutError, + APIError as RawOpenAISDKAPIError, + OpenAIError as RawOpenAISDKError, +} from 'openai'; +import { describe, expect, it, vi } from 'vitest'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createAssistantMessage, createUserMessage, type Message } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { classifyKimiQuotaError } from '#/llm-kimi/errors'; +import { kimiOpenAITrait } from '#/llm-kimi/trait'; +import { createGoogleGenAIRequester } from '#/llm/requester/bases/google-genai/requester'; +import { convertOpenAIError } from '#/llm/requester/bases/openai/format'; +import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; +import type { LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'test-model', + capability: UNKNOWN_CAPABILITY, + baseUrl: 'https://example.test/v1', +}; +const messages: readonly Message[] = [createUserMessage('hi')]; + +describe('convertOpenAIError', () => { + it('converts abort errors to abort kind', () => { + expect(convertOpenAIError(new DOMException('x', 'AbortError'))).toEqual({ + kind: 'abort', + message: 'x', + }); + }); + + it('lets the hook win and hands it the raw error', () => { + const custom = { kind: 'provider', message: 'custom' } as const; + const raw = new RawOpenAISDKAPIError(500, {}, 'server error', new Headers()); + let hookArg: unknown; + const result = convertOpenAIError(raw, (error) => { + hookArg = error; + return custom; + }); + expect(result).toBe(custom); + expect(hookArg).toBe(raw); + }); + + it('maps connection errors to their kinds', () => { + expect( + convertOpenAIError(new RawOpenAISDKConnectionTimeoutError({ message: 'request timed out' })), + ).toEqual({ kind: 'timeout', message: 'request timed out' }); + expect( + convertOpenAIError(new RawOpenAISDKConnectionError({ message: 'connection refused' })), + ).toEqual({ kind: 'connection', message: 'connection refused' }); + }); + + it('maps 429 to rate_limit with retry-after', () => { + const raw = new RawOpenAISDKAPIError( + 429, + { error: { message: 'too many requests' } }, + 'too many requests', + new Headers({ 'retry-after': '3' }), + ); + expect(convertOpenAIError(raw)).toMatchObject({ + kind: 'rate_limit', + statusCode: 429, + retryAfterMs: 3000, + }); + }); + + it('maps insufficient_quota to quota_exhausted', () => { + const raw = new RawOpenAISDKAPIError( + 429, + { error: { message: 'insufficient_quota' } }, + 'insufficient_quota', + new Headers(), + ); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'quota_exhausted', statusCode: 429 }); + }); + + it('maps context overflow messages to context_overflow', () => { + const raw = new RawOpenAISDKAPIError( + 400, + { message: 'maximum context length exceeded' }, + undefined, + new Headers(), + ); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'context_overflow', statusCode: 400 }); + }); + + it('maps 413 too-large messages to request_too_large', () => { + const raw = new RawOpenAISDKAPIError( + 413, + { message: 'request entity too large' }, + undefined, + new Headers(), + ); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'request_too_large', statusCode: 413 }); + }); + + it('maps remaining status errors to their kinds', () => { + const overloaded = new RawOpenAISDKAPIError(529, {}, 'overloaded', new Headers()); + expect(convertOpenAIError(overloaded)).toMatchObject({ kind: 'overloaded', statusCode: 529 }); + const generic = new RawOpenAISDKAPIError(500, {}, 'server error', new Headers()); + expect(convertOpenAIError(generic)).toMatchObject({ kind: 'status', statusCode: 500 }); + }); + + it('maps 400 request-structure rejections to request_structure', () => { + const structural = [ + 'messages.142: `tool_use` ids were found without `tool_result` blocks immediately after: toolu_01MWFhDRqdbB4nzCJNuWYiun', + 'messages: `tool_use` ids must be unique', + 'text content blocks must be non-empty', + 'first message must use the `user` role', + 'roles must alternate', + "tool_call_id 'call_abc123' is not found", + "Messages with role 'tool' must be a response to a preceding message with 'tool_calls'", + "the message at position 3 with role 'assistant' must not be empty", + ]; + for (const message of structural) { + const raw = new RawOpenAISDKAPIError(400, { message }, undefined, new Headers()); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'request_structure', statusCode: 400 }); + } + const unprocessable = new RawOpenAISDKAPIError( + 422, + { message: 'roles must alternate' }, + undefined, + new Headers(), + ); + expect(convertOpenAIError(unprocessable)).toMatchObject({ + kind: 'request_structure', + statusCode: 422, + }); + const unrelated = new RawOpenAISDKAPIError( + 400, + { message: 'max_tokens must be positive' }, + undefined, + new Headers(), + ); + expect(convertOpenAIError(unrelated)).toMatchObject({ kind: 'status', statusCode: 400 }); + }); + + it('maps 400 image-format rejections to image_format', () => { + const imageFormat = [ + 'unsupported image format', + 'Could not process image', + 'The image data you provided does not represent a valid image', + "messages.0.content.1.image.source.base64.media_type: Input should be 'image/jpeg'", + ]; + for (const message of imageFormat) { + const raw = new RawOpenAISDKAPIError(400, { message }, undefined, new Headers()); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'image_format', statusCode: 400 }); + } + const notFormat = [ + 'too many images in request', + 'image input is disabled for this model', + "messages.0.content.1.video.source.base64.media_type: Input should be 'video/mp4'", + ]; + for (const message of notFormat) { + const raw = new RawOpenAISDKAPIError(400, { message }, undefined, new Headers()); + expect(convertOpenAIError(raw)).toMatchObject({ kind: 'status', statusCode: 400 }); + } + const wrongStatus = new RawOpenAISDKAPIError( + 422, + { message: 'unsupported image format' }, + undefined, + new Headers(), + ); + expect(convertOpenAIError(wrongStatus)).toMatchObject({ kind: 'status', statusCode: 422 }); + }); + + it('classifies a bare APIError by message', () => { + const raw = new RawOpenAISDKAPIError( + undefined, + undefined, + 'network connection failed', + undefined, + ); + expect(convertOpenAIError(raw)).toEqual({ + kind: 'connection', + message: 'network connection failed', + }); + }); + + it('wraps an OpenAIError as provider', () => { + expect(convertOpenAIError(new RawOpenAISDKError('boom'))).toEqual({ + kind: 'provider', + message: 'Error: boom', + }); + }); + + it('classifies a generic Error by message', () => { + expect(convertOpenAIError(new Error('deadline exceeded timeout'))).toEqual({ + kind: 'timeout', + message: 'deadline exceeded timeout', + }); + expect(convertOpenAIError(new Error('plain'))).toEqual({ + kind: 'provider', + message: 'Error: plain', + }); + }); + + it('wraps non-error values as unknown', () => { + expect(convertOpenAIError('nope')).toEqual({ kind: 'unknown', message: 'nope' }); + }); +}); + +describe('classifyKimiQuotaError', () => { + it('classifies by structured error code', () => { + const classified = classifyKimiQuotaError({ + status: 429, + message: 'quota', + code: 'exceeded_current_quota_error', + requestID: 'req-1', + headers: new Headers({ 'retry-after': '5', 'x-trace-id': 'trace-1' }), + }); + expect(classified).toMatchObject({ + kind: 'quota_exhausted', + statusCode: 429, + requestId: 'req-1', + retryAfterMs: 5000, + }); + if (classified?.kind === 'quota_exhausted') { + expect(classified.headers?.['x-trace-id']).toBe('trace-1'); + } + }); + + it('classifies by message wording', () => { + const classified = classifyKimiQuotaError({ + status: 429, + message: 'insufficient balance', + headers: new Headers(), + }); + expect(classified).toMatchObject({ kind: 'quota_exhausted', statusCode: 429 }); + }); + + it('ignores 429 without quota signals', () => { + expect( + classifyKimiQuotaError({ status: 429, message: 'slow down', headers: new Headers() }), + ).toBeUndefined(); + }); + + it('ignores non-429 errors', () => { + expect( + classifyKimiQuotaError({ status: 400, message: 'insufficient balance' }), + ).toBeUndefined(); + }); +}); + +describe('requester error conversion', () => { + function failingOpenAIClient(error: unknown) { + return () => + ({ + chat: { + completions: { + create: () => { + throw error; + }, + }, + }, + }) as never; + } + + async function generateEvents( + requester: LlmRequester, + input: readonly Message[] = messages, + ): Promise { + const events: LlmRequestEvent[] = []; + await requester.generate( + { model }, + { messages: input }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + return events; + } + + it('converts a 429 response to rate_limit', async () => { + const requester = createOpenAIRequester(undefined, { + clientFactory: failingOpenAIClient( + new RawOpenAISDKAPIError( + 429, + { error: { message: 'too many requests' } }, + 'too many requests', + new Headers(), + ), + ), + }); + const events = await generateEvents(requester); + expect(events.at(-1)).toMatchObject({ + type: 'llm.failed.remote', + error: { kind: 'rate_limit' }, + }); + }); + + it('converts a kimi quota response to quota_exhausted', async () => { + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: failingOpenAIClient( + new RawOpenAISDKAPIError( + 429, + { error: { message: 'check your account balance' } }, + 'check your account balance', + new Headers(), + ), + ), + }); + const events = await generateEvents(requester); + expect(events.at(-1)).toMatchObject({ + type: 'llm.failed.remote', + error: { kind: 'quota_exhausted' }, + }); + }); + + it('emits llm.failed.syntax for a local message syntax error without sending a request', async () => { + const clientFactory = vi.fn(() => ({}) as never); + const requester = createGoogleGenAIRequester(undefined, { clientFactory }); + const events = await generateEvents(requester, [ + createAssistantMessage([], [ + { type: 'function', id: 'call-1', name: 'some_tool', arguments: 'not json' }, + ]), + ]); + expect(events.at(-1)).toMatchObject({ + type: 'llm.failed.syntax', + error: { kind: 'syntax', code: 'request_format' }, + }); + expect(clientFactory).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/machine.test.ts b/packages/agent-core-v2/src/human/test/llm/machine.test.ts new file mode 100644 index 00000000000..16af2663196 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/machine.test.ts @@ -0,0 +1,494 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createActor } from '#/xstate2'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import type { LlmErrorMessage } from '#/llm/errors'; +import type { ContentPart, Message } from '#/llm/message'; +import { createMediaDegradeRecovery } from '#/llm/media/degrade'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine, type LlmEvent } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import type { LlmRetryOptions } from '#/llm/requester/retry'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type RetryingEvent = Extract; + +function statusError( + statusCode: number, + message: string, + retryAfterMs: number | null = null, +): LlmErrorMessage { + return { kind: 'status', statusCode, message, requestId: null, retryAfterMs, headers: null }; +} + +function createStubRequester( + plan: readonly (LlmErrorMessage | 'ok' | 'empty' | 'think_only' | 'filtered_empty')[], +) { + let calls = 0; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + const step = plan[Math.min(calls, plan.length - 1)]; + calls += 1; + if (step === 'ok') { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'done' } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + if (step === 'empty') { + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + if (step === 'think_only') { + onEvent?.({ type: 'llm.delta', part: { type: 'think', think: 'reasoning' } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + if (step === 'filtered_empty') { + onEvent?.({ + type: 'llm.finish', + finish: { finishReason: 'filtered', rawFinishReason: 'content_filter' }, + }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + if (step.kind === 'syntax') { + onEvent?.({ type: 'llm.failed.syntax', error: step }); + return Promise.resolve(); + } + onEvent?.({ type: 'llm.failed.remote', error: step }); + return Promise.resolve(); + }, + }; + return { requester, calls: () => calls }; +} + +function startRetryingActor(requester: LlmRequester, retry?: LlmRetryOptions) { + const actor = createActor(createLlmMachine({ requester, retry }), { + input: { config: { model }, content: { messages: [] as readonly Message[] } }, + }); + const retrying: RetryingEvent[] = []; + const failed: unknown[] = []; + actor.on('llm.retrying', (event) => retrying.push(event)); + actor.on('llm.failed.syntax', (event) => failed.push(event.error)); + actor.on('llm.failed.remote', (event) => failed.push(event.error)); + actor.start(); + return { actor, retrying, failed }; +} + +async function flush(): Promise { + await vi.advanceTimersByTimeAsync(0); +} + +describe('llm machine retry', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('retries retryable errors and succeeds', async () => { + const { requester, calls } = createStubRequester([ + 'empty', + 'think_only', + statusError(429, 'rate limited'), + statusError(500, 'server error'), + { kind: 'provider', message: 'Error: upstream error, status_code=429: too many requests' }, + 'ok', + ]); + const { actor, retrying, failed } = startRetryingActor(requester, { + maxAttemptsPerStep: 6, + }); + + await flush(); + expect(retrying).toHaveLength(1); + expect(retrying[0]).toMatchObject({ + failedAttempt: 1, + nextAttempt: 2, + maxAttempts: 6, + errorName: 'empty_response', + }); + expect(retrying[0]?.errorMessage).toContain('empty response (no content, no tool calls)'); + expect(retrying[0]?.errorMessage).toContain('Provider: test, model: test-model'); + + await vi.advanceTimersByTimeAsync((retrying[0] as RetryingEvent).delayMs + 1); + await flush(); + expect(retrying).toHaveLength(2); + expect(retrying[1]).toMatchObject({ + failedAttempt: 2, + nextAttempt: 3, + maxAttempts: 6, + errorName: 'empty_response', + }); + expect(retrying[1]?.errorMessage).toContain('only thinking content'); + + await vi.advanceTimersByTimeAsync((retrying[1] as RetryingEvent).delayMs + 1); + await flush(); + expect(retrying).toHaveLength(3); + expect(retrying[2]).toMatchObject({ + failedAttempt: 3, + nextAttempt: 4, + maxAttempts: 6, + statusCode: 429, + errorName: 'status', + }); + + await vi.advanceTimersByTimeAsync((retrying[2] as RetryingEvent).delayMs + 1); + await flush(); + expect(retrying).toHaveLength(4); + expect(retrying[3]).toMatchObject({ + failedAttempt: 4, + nextAttempt: 5, + maxAttempts: 6, + statusCode: 500, + }); + + await vi.advanceTimersByTimeAsync((retrying[3] as RetryingEvent).delayMs + 1); + await flush(); + expect(retrying).toHaveLength(5); + expect(retrying[4]).toMatchObject({ + failedAttempt: 5, + nextAttempt: 6, + maxAttempts: 6, + errorName: 'provider', + }); + + await vi.advanceTimersByTimeAsync((retrying[4] as RetryingEvent).delayMs + 1); + await flush(); + expect(actor.getSnapshot().value).toBe('succeeded'); + expect(calls()).toBe(6); + expect(failed).toHaveLength(0); + }); + + it('fails after maxAttemptsPerStep is exhausted', async () => { + const { requester, calls } = createStubRequester([ + statusError(503, 'unavailable'), + statusError(503, 'unavailable'), + statusError(503, 'unavailable'), + ]); + const { actor, retrying, failed } = startRetryingActor(requester, { + maxAttemptsPerStep: 3, + }); + + await flush(); + await vi.advanceTimersByTimeAsync((retrying[0] as RetryingEvent).delayMs + 1); + await flush(); + await vi.advanceTimersByTimeAsync((retrying[1] as RetryingEvent).delayMs + 1); + await flush(); + + expect(retrying).toHaveLength(2); + expect(calls()).toBe(3); + expect(actor.getSnapshot().value).toBe('failed'); + expect(failed).toHaveLength(1); + }); + + it('does not retry non-retryable errors', async () => { + const cases: readonly LlmErrorMessage[] = [ + { + kind: 'syntax', + code: 'request_format', + message: 'Tool call arguments must be valid JSON.', + }, + { + kind: 'request_structure', + statusCode: 400, + message: 'roles must alternate', + requestId: null, + retryAfterMs: null, + headers: null, + }, + ]; + for (const error of cases) { + const { requester, calls } = createStubRequester([error, 'ok']); + const { actor, retrying, failed } = startRetryingActor(requester, { + maxAttemptsPerStep: 5, + }); + + await flush(); + + expect(retrying).toHaveLength(0); + expect(calls()).toBe(1); + expect(actor.getSnapshot().value).toBe('failed'); + expect(failed).toHaveLength(1); + } + + const filtered = createStubRequester(['filtered_empty', 'ok']); + const filteredRun = startRetryingActor(filtered.requester, { maxAttemptsPerStep: 5 }); + + await flush(); + + expect(filteredRun.retrying).toHaveLength(0); + expect(filtered.calls()).toBe(1); + expect(filteredRun.actor.getSnapshot().value).toBe('failed'); + expect(filteredRun.failed[0]).toMatchObject({ + kind: 'empty_response', + finishReason: 'filtered', + rawFinishReason: 'content_filter', + }); + }); + + it('retries a non-retryable error when infiniteRetry is on', async () => { + const { requester, calls } = createStubRequester([statusError(400, 'bad request'), 'ok']); + const { actor, retrying } = startRetryingActor(requester, { infiniteRetry: true }); + + await flush(); + expect(retrying).toHaveLength(1); + expect(retrying[0]).toMatchObject({ failedAttempt: 1, nextAttempt: 2, maxAttempts: 10 }); + + await vi.advanceTimersByTimeAsync((retrying[0] as RetryingEvent).delayMs + 1); + await flush(); + expect(actor.getSnapshot().value).toBe('succeeded'); + expect(calls()).toBe(2); + }); + + it('prefers retryAfterMs from the error over the backoff delay', async () => { + const cases: readonly [LlmErrorMessage, number][] = [ + [statusError(500, 'server error', 1234), 1234], + [ + { + kind: 'rate_limit', + statusCode: 429, + message: 'rate limited', + requestId: null, + retryAfterMs: 2000, + headers: null, + }, + 2000, + ], + ]; + for (const [error, delayMs] of cases) { + const { requester } = createStubRequester([error, 'ok']); + const { retrying } = startRetryingActor(requester, { maxAttemptsPerStep: 3 }); + + await flush(); + + expect(retrying).toHaveLength(1); + expect((retrying[0] as RetryingEvent).delayMs).toBe(delayMs); + } + }); + + it('falls back to the backoff delay without retryAfterMs', async () => { + const { requester } = createStubRequester([statusError(500, 'server error'), 'ok']); + const { retrying } = startRetryingActor(requester, { maxAttemptsPerStep: 3 }); + + await flush(); + + expect(retrying).toHaveLength(1); + const delayMs = (retrying[0] as RetryingEvent).delayMs; + expect(delayMs).toBeGreaterThanOrEqual(500); + expect(delayMs).toBeLessThanOrEqual(625); + }); + + it('does not honor retryAfterMs on a non-retryable error', async () => { + const error: LlmErrorMessage = { + kind: 'quota_exhausted', + statusCode: 429, + message: 'quota exceeded', + requestId: null, + retryAfterMs: 1000, + headers: null, + }; + const { requester, calls } = createStubRequester([error]); + const { actor, retrying } = startRetryingActor(requester, { maxAttemptsPerStep: 5 }); + + await flush(); + + expect(retrying).toHaveLength(0); + expect(calls()).toBe(1); + expect(actor.getSnapshot().value).toBe('failed'); + }); + + it('does not retry without failure', async () => { + const { requester, calls } = createStubRequester(['ok']); + const { actor, retrying } = startRetryingActor(requester, { maxAttemptsPerStep: 5 }); + + await flush(); + + expect(actor.getSnapshot().value).toBe('succeeded'); + expect(retrying).toHaveLength(0); + expect(calls()).toBe(1); + }); +}); + +type RecoveringEvent = Extract; +type SentEvent = Extract; + +function tooLargeError(): LlmErrorMessage { + return { + kind: 'request_too_large', + statusCode: 413, + message: 'request entity too large', + requestId: null, + retryAfterMs: null, + headers: null, + }; +} + +function imageFormatError(): LlmErrorMessage { + return { + kind: 'image_format', + statusCode: 400, + message: 'unsupported image format', + requestId: null, + retryAfterMs: null, + headers: null, + }; +} + +function mediaMessage(text: string, images: number): Message { + const content: ContentPart[] = [{ type: 'text', text }]; + for (let index = 0; index < images; index += 1) { + content.push({ type: 'image_url', imageUrl: { url: `media://img-${text}-${index}` } }); + } + return { role: 'user', content }; +} + +function countImageParts(messages: readonly Message[]): number { + return messages.reduce( + (count, message) => + count + message.content.filter((part) => part.type === 'image_url').length, + 0, + ); +} + +function createCapturingRequester(plan: readonly (LlmErrorMessage | 'ok')[]) { + let calls = 0; + const seen: (readonly Message[])[] = []; + const requester: LlmRequester = { + generate: (_config, content, control) => { + seen.push(content.messages); + const step = plan[Math.min(calls, plan.length - 1)]; + calls += 1; + control.onEvent?.({ type: 'llm.sent' }); + if (step === 'ok') { + control.onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'done' } }); + control.onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + } + if (step.kind === 'syntax') { + control.onEvent?.({ type: 'llm.failed.syntax', error: step }); + return Promise.resolve(); + } + control.onEvent?.({ type: 'llm.failed.remote', error: step }); + return Promise.resolve(); + }, + }; + return { requester, calls: () => calls, seen }; +} + +async function drain(): Promise { + for (let index = 0; index < 10; index += 1) { + await flush(); + } +} + +describe('llm machine media recovery', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('degrades then strips media across request_too_large failures before failing', async () => { + const messages = [mediaMessage('a', 2), mediaMessage('b', 1), mediaMessage('c', 1)]; + const { requester, calls, seen } = createCapturingRequester([ + tooLargeError(), + tooLargeError(), + tooLargeError(), + ]); + const actor = createActor( + createLlmMachine({ requester, recovery: createMediaDegradeRecovery() }), + { input: { config: { model }, content: { messages } } }, + ); + const recovering: RecoveringEvent[] = []; + const sent: SentEvent[] = []; + const failed: unknown[] = []; + actor.on('llm.recovering', (event) => recovering.push(event)); + actor.on('llm.sent', (event) => sent.push(event)); + actor.on('llm.failed.remote', (event) => failed.push(event.error)); + actor.start(); + + await drain(); + + expect(calls()).toBe(3); + expect(actor.getSnapshot().value).toBe('failed'); + expect(recovering.map((event) => `${event.strategy}:${event.action}`)).toEqual([ + 'media-degrade:degraded', + 'media-degrade:stripped', + ]); + expect(sent.map((event) => event.recovery?.action)).toEqual([ + undefined, + 'degraded', + 'stripped', + ]); + expect(failed).toHaveLength(1); + expect(countImageParts(seen[0] ?? [])).toBe(4); + expect(countImageParts(seen[1] ?? [])).toBe(2); + expect(countImageParts(seen[2] ?? [])).toBe(0); + }); + + it('succeeds with degraded media after a request_too_large error', async () => { + const messages = [mediaMessage('a', 2), mediaMessage('b', 1), mediaMessage('c', 1)]; + const { requester, calls, seen } = createCapturingRequester([tooLargeError(), 'ok']); + const actor = createActor( + createLlmMachine({ requester, recovery: createMediaDegradeRecovery() }), + { input: { config: { model }, content: { messages } } }, + ); + const recovering: RecoveringEvent[] = []; + actor.on('llm.recovering', (event) => recovering.push(event)); + actor.start(); + + await drain(); + + expect(calls()).toBe(2); + expect(actor.getSnapshot().value).toBe('succeeded'); + expect(recovering).toHaveLength(1); + expect(countImageParts(seen[1] ?? [])).toBe(2); + }); + + it('strips media directly on image_format without degrading first', async () => { + const messages = [mediaMessage('a', 2), mediaMessage('b', 1), mediaMessage('c', 1)]; + const { requester, calls, seen } = createCapturingRequester([imageFormatError(), 'ok']); + const actor = createActor( + createLlmMachine({ requester, recovery: createMediaDegradeRecovery() }), + { input: { config: { model }, content: { messages } } }, + ); + const recovering: RecoveringEvent[] = []; + const sent: SentEvent[] = []; + actor.on('llm.recovering', (event) => recovering.push(event)); + actor.on('llm.sent', (event) => sent.push(event)); + actor.start(); + + await drain(); + + expect(calls()).toBe(2); + expect(actor.getSnapshot().value).toBe('succeeded'); + expect(recovering.map((event) => `${event.strategy}:${event.action}`)).toEqual([ + 'media-degrade:stripped', + ]); + expect(sent.map((event) => event.recovery?.action)).toEqual([undefined, 'stripped']); + expect(countImageParts(seen[1] ?? [])).toBe(0); + }); + + it('fails immediately on request_too_large without media', async () => { + const messages = [mediaMessage('plain', 0)]; + const { requester, calls } = createCapturingRequester([tooLargeError()]); + const actor = createActor( + createLlmMachine({ requester, recovery: createMediaDegradeRecovery() }), + { input: { config: { model }, content: { messages } } }, + ); + const recovering: RecoveringEvent[] = []; + actor.on('llm.recovering', (event) => recovering.push(event)); + actor.start(); + + await drain(); + + expect(calls()).toBe(1); + expect(recovering).toHaveLength(0); + expect(actor.getSnapshot().value).toBe('failed'); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/provider-catalog.test.ts b/packages/agent-core-v2/src/human/test/llm/provider-catalog.test.ts new file mode 100644 index 00000000000..090007cbff8 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/provider-catalog.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import type { LlmModel } from '#/llm/model'; +import type { CatalogModelDefinition } from '#/llm/provider-catalog'; +import { createProviderCatalog } from '#/llm/provider-catalog'; +import type { Provider } from '#/llm/provider/definition'; +import type { LlmRequester } from '#/llm/requester/requester'; + +const modelDef: CatalogModelDefinition = { + provider: 'test', + model: 'm1', + capability: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }, + maxContextSize: 4096, +}; + +function failingRequester(message: string): LlmRequester { + return { + generate: (_config, _content, { onEvent }) => { + onEvent?.({ + type: 'llm.failed.remote', + error: { + kind: 'status', + statusCode: 500, + message, + requestId: null, + retryAfterMs: null, + headers: null, + }, + }); + return Promise.resolve(); + }, + }; +} + +function stubProvider( + id: string, + requester: LlmRequester, + listModels: () => Promise = () => Promise.resolve([]), +): Provider { + return { + id, + protocols: ['openai'], + listModels, + resolveModel: () => { + throw new Error('unused'); + }, + createRequester: () => requester, + }; +} + +async function until(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100 && !predicate(); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + expect(predicate()).toBe(true); +} + +describe('providerCatalog ping', () => { + it('marks a failing model and emits changed, then clears the mark after a successful ping', async () => { + let failing = true; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + if (failing) { + onEvent?.({ + type: 'llm.failed.remote', + error: { + kind: 'status', + statusCode: 500, + message: 'boom', + requestId: null, + retryAfterMs: null, + headers: null, + }, + }); + } else { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'pong' } }); + onEvent?.({ type: 'llm.done' }); + } + return Promise.resolve(); + }, + }; + const catalog = await createProviderCatalog(); + const changed: string[][] = []; + catalog.onChanged((event) => changed.push([...event.providers])); + catalog.upsert({ provider: stubProvider('test', requester), models: [modelDef] }); + await until(() => changed.length >= 2); + + changed.length = 0; + catalog.ping('test', 'm1'); + await until(() => catalog.models('test').at(0)?.pingError === 'boom'); + expect(changed).toEqual([['test']]); + + failing = false; + changed.length = 0; + catalog.ping('test', 'm1'); + await until(() => changed.length > 0); + expect(catalog.models('test').at(0)?.pingError).toBeUndefined(); + catalog.stop(); + }); + + it('ignores pings for unknown providers and models', async () => { + const catalog = await createProviderCatalog(); + const changed: string[][] = []; + catalog.onChanged((event) => changed.push([...event.providers])); + catalog.upsert({ provider: stubProvider('test', failingRequester('boom')), models: [modelDef] }); + await until(() => changed.length >= 2); + + changed.length = 0; + catalog.ping('nope', 'm1'); + catalog.ping('test', 'nope'); + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(changed).toEqual([]); + expect(catalog.models('test').at(0)?.pingError).toBeUndefined(); + catalog.stop(); + }); + + it('pings through the latest provider instance after a re-upsert', async () => { + const catalog = await createProviderCatalog(); + catalog.upsert({ provider: stubProvider('test', failingRequester('first')), models: [modelDef] }); + catalog.upsert({ + provider: stubProvider('test', failingRequester('second')), + models: [modelDef], + }); + + catalog.ping('test', 'm1'); + + await until(() => catalog.models('test').at(0)?.pingError === 'second'); + expect(catalog.models('test').at(0)?.pingError).toBe('second'); + catalog.stop(); + }); + + it('defers a ping sent while refreshing until the refresh completes', async () => { + let pulls = 0; + let resolvePull: (models: readonly LlmModel[]) => void = () => {}; + const provider = stubProvider('test', failingRequester('boom'), () => { + pulls += 1; + return new Promise((resolve) => { + resolvePull = resolve; + }); + }); + const catalog = await createProviderCatalog(); + catalog.upsert({ provider, models: [modelDef] }); + await until(() => pulls === 1); + + catalog.ping('test', 'm1'); + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(catalog.models('test').at(0)?.pingError).toBeUndefined(); + + resolvePull([]); + await until(() => catalog.models('test').at(0)?.pingError === 'boom'); + catalog.stop(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/response-format.test.ts b/packages/agent-core-v2/src/human/test/llm/response-format.test.ts new file mode 100644 index 00000000000..fbce3e823e7 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/response-format.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { + createAssistantMessage, + createToolMessage, + createUserMessage, + type Message, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { ResponseFormat } from '#/llm/response-format'; +import type { LlmClientContext, LlmRequestEvent } from '#/llm/requester/requester'; +import { createAnthropicRequester } from '#/llm/requester/bases/anthropic/requester'; +import { createGoogleGenAIRequester } from '#/llm/requester/bases/google-genai/requester'; +import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; +import { createOpenAIResponsesRequester } from '#/llm/requester/bases/openai-responses/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'test-model', + capability: UNKNOWN_CAPABILITY, + baseUrl: 'https://example.test/v1', +}; +const genaiModel: LlmModel = { ...model, apiKey: 'test-key' }; +const messages: readonly Message[] = [createUserMessage('hi')]; + +const jsonObjectFormat: ResponseFormat = { type: 'json_object' }; +const jsonSchemaFormat: ResponseFormat = { + type: 'json_schema', + jsonSchema: { + name: 'answer', + schema: { type: 'object', properties: { answer: { type: 'string' } }, required: ['answer'] }, + strict: true, + description: 'structured answer', + }, +}; + +const chatCompletionChunks: readonly Record[] = [ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], + }, +]; + +const anthropicStreamEvents: readonly Record[] = [ + { type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 1 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }, + { type: 'message_stop' }, +]; + +const responsesStreamEvents: readonly Record[] = [ + { + type: 'response.completed', + response: { + id: 'resp_1', + status: 'completed', + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }, + }, +]; + +const googleGenAIStreamChunks: readonly Record[] = [ + { + candidates: [ + { content: { role: 'model', parts: [{ text: 'hi' }] }, finishReason: 'STOP' }, + ], + usageMetadata: { promptTokenCount: 1, candidatesTokenCount: 1 }, + }, +]; + +function createAsyncStream(chunks: readonly T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +interface ClientStub { + clientFactory: (request: LlmClientContext) => never; + body: () => Record; + called: () => boolean; +} + +function createClientStub( + client: (captured: Record[]) => unknown, +): ClientStub { + const captured: Record[] = []; + return { + clientFactory: () => client(captured) as never, + body: () => { + const last = captured.at(-1); + if (last === undefined) throw new Error('expected client to be called'); + return last; + }, + called: () => captured.length > 0, + }; +} + +function withResponseStream(chunks: readonly Record[]): { + withResponse: () => Promise<{ data: AsyncIterable>; response: Response }>; +} { + return { + withResponse: async () => ({ + data: createAsyncStream(chunks), + response: new Response(null), + }), + }; +} + +function stubOpenAIClient(chunks: readonly Record[]): ClientStub { + return createClientStub((captured) => ({ + chat: { + completions: { + create: (params: Record) => { + captured.push(params); + return withResponseStream(chunks); + }, + }, + }, + })); +} + +function stubResponsesClient(events: readonly Record[]): ClientStub { + return createClientStub((captured) => ({ + responses: { + create: (params: Record) => { + captured.push(params); + return withResponseStream(events); + }, + }, + })); +} + +function stubAnthropicClient(events: readonly Record[]): ClientStub { + return createClientStub((captured) => ({ + messages: { + create: (params: Record) => { + captured.push(params); + return withResponseStream(events); + }, + }, + })); +} + +function stubGoogleClient(chunks: readonly Record[]): ClientStub { + return createClientStub((captured) => ({ + models: { + generateContentStream: async (params: Record) => { + captured.push(params); + return createAsyncStream(chunks); + }, + }, + })); +} + +describe('openai requester responseFormat', () => { + it('maps json_object to response_format', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, responseFormat: jsonObjectFormat }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['response_format']).toEqual({ type: 'json_object' }); + }); + + it('maps json_schema to response_format.json_schema', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, responseFormat: jsonSchemaFormat }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['response_format']).toEqual({ + type: 'json_schema', + json_schema: { + name: 'answer', + schema: jsonSchemaFormat.jsonSchema.schema, + strict: true, + description: 'structured answer', + }, + }); + }); +}); + +describe('openai-responses requester responseFormat', () => { + it('maps json_schema to text.format', async () => { + const client = stubResponsesClient(responsesStreamEvents); + const requester = createOpenAIResponsesRequester(undefined, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { + model, + responseFormat: jsonSchemaFormat, + thinking: { effort: 'high' }, + extraParams: { + responses: { text: { verbosity: 'high' }, reasoning: { summary: 'detailed' } }, + }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['text']).toEqual({ + format: { + type: 'json_schema', + name: 'answer', + schema: jsonSchemaFormat.jsonSchema.schema, + strict: true, + description: 'structured answer', + }, + verbosity: 'high', + }); + expect(client.body()['reasoning']).toEqual({ effort: 'high', summary: 'detailed' }); + }); +}); + +describe('anthropic requester responseFormat', () => { + it('maps json_schema to output_config.format and keeps the thinking effort', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, thinking: { effort: 'high' }, responseFormat: jsonSchemaFormat }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['output_config']).toEqual({ + effort: 'high', + format: { type: 'json_schema', schema: jsonSchemaFormat.jsonSchema.schema }, + }); + }); + + it('fails with a syntax error for json_object', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + const events: LlmRequestEvent[] = []; + await requester.generate( + { model, responseFormat: jsonObjectFormat }, + { messages }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + expect(client.called()).toBe(false); + const failed = events.find((event) => event.type === 'llm.failed.syntax'); + expect(failed).toBeDefined(); + if (failed?.type !== 'llm.failed.syntax') throw new Error('expected llm.failed.syntax'); + expect(failed.error.code).toBe('request_format'); + }); +}); + +describe('google-genai requester responseFormat', () => { + it('maps response formats to config', async () => { + const client = stubGoogleClient(googleGenAIStreamChunks); + const requester = createGoogleGenAIRequester(undefined, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model: genaiModel, responseFormat: jsonSchemaFormat }, + { messages }, + { signal: new AbortController().signal }, + ); + let config = client.body()['config'] as Record | undefined; + expect(config?.['responseMimeType']).toBe('application/json'); + expect(config?.['responseJsonSchema']).toEqual(jsonSchemaFormat.jsonSchema.schema); + + await requester.generate( + { model: genaiModel, responseFormat: jsonObjectFormat }, + { messages }, + { signal: new AbortController().signal }, + ); + config = client.body()['config'] as Record | undefined; + expect(config?.['responseMimeType']).toBe('application/json'); + expect(config?.['responseJsonSchema']).toBeUndefined(); + }); +}); + +describe('requester toolMessageConversion', () => { + it('forces tool results to plain text when set to extract_text', async () => { + const toolMessages: readonly Message[] = [ + createUserMessage('hi'), + createAssistantMessage([], [ + { type: 'function', id: 'call_1', name: 'snap', arguments: '{}' }, + ]), + createToolMessage('call_1', [ + { type: 'text', text: 'shot taken' }, + { type: 'image_url', imageUrl: { url: 'https://example.test/shot.png' } }, + ]), + ]; + const expectedText = 'shot taken\n(image omitted: tool result converted to plain text)'; + + const openAIClient = stubOpenAIClient(chatCompletionChunks); + await createOpenAIRequester( + { toolMessageConversion: () => 'extract_text' }, + { clientFactory: openAIClient.clientFactory }, + ).generate( + { model }, + { messages: toolMessages }, + { signal: new AbortController().signal }, + ); + const chatMessages = openAIClient.body()['messages'] as Record[]; + expect(chatMessages.find((message) => message['role'] === 'tool')?.['content']).toBe( + expectedText, + ); + expect(chatMessages.filter((message) => message['role'] === 'user')).toHaveLength(1); + expect(JSON.stringify(chatMessages)).not.toContain('image_url'); + + const responsesClient = stubResponsesClient(responsesStreamEvents); + await createOpenAIResponsesRequester( + { toolMessageConversion: () => 'extract_text' }, + { clientFactory: responsesClient.clientFactory }, + ).generate( + { model }, + { messages: toolMessages }, + { signal: new AbortController().signal }, + ); + const inputItems = responsesClient.body()['input'] as Record[]; + expect( + inputItems.find((item) => item['type'] === 'function_call_output')?.['output'], + ).toBe(expectedText); + expect(JSON.stringify(inputItems)).not.toContain('input_image'); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/rewrite.test.ts b/packages/agent-core-v2/src/human/test/llm/rewrite.test.ts new file mode 100644 index 00000000000..f6bd5f516da --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/rewrite.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; + +import { applyPatterns, type Pattern, type Rewrite } from '#/llm/protocol/rewrite'; + +function pairToSum(name: string): Pattern { + return { + name, + rewrite(items, index): Rewrite | null { + const a = items[index]; + const b = items[index + 1]; + if (a === undefined || b === undefined) return null; + return { consumed: 2, replacement: [a + b] }; + }, + }; +} + +function dropOdd(): Pattern { + return { + name: 'dropOdd', + rewrite(items, index): Rewrite | null { + const value = items[index]; + if (value === undefined || value % 2 === 0) return null; + return { consumed: 1, replacement: [] }; + }, + }; +} + +function splitEven(): Pattern { + return { + name: 'splitEven', + rewrite(items, index): Rewrite | null { + const value = items[index]; + if (value === undefined || value % 2 !== 0) return null; + return { consumed: 1, replacement: [value / 2, value / 2] }; + }, + }; +} + +describe('applyPatterns', () => { + it('returns a copy unchanged when no pattern matches', () => { + const input = [2, 4, 6]; + const out = applyPatterns(input, [dropOdd()]); + expect(out).toEqual([2, 4, 6]); + expect(out).not.toBe(input); + }); + + it('applies patterns in order, one pass each', () => { + const out = applyPatterns([2, 4, 1, 1], [pairToSum('sum'), splitEven()]); + expect(out).toEqual([3, 3, 1, 1]); + }); + + it('later patterns see the output of earlier patterns', () => { + const out = applyPatterns([2, 2], [pairToSum('sum'), splitEven()]); + expect(out).toEqual([2, 2]); + }); + + it('supports run matching with multi-item consumption', () => { + const out = applyPatterns([1, 2, 3, 4, 5], [pairToSum('sum')]); + expect(out).toEqual([3, 7, 5]); + }); + + it('supports dropping items with an empty replacement', () => { + const out = applyPatterns([1, 2, 3, 4], [dropOdd()]); + expect(out).toEqual([2, 4]); + }); + + it('supports one-to-many replacement', () => { + const out = applyPatterns([1, 4, 3], [splitEven()]); + expect(out).toEqual([1, 2, 2, 3]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/thinking.test.ts b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts new file mode 100644 index 00000000000..df5f1bc05d5 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/thinking.test.ts @@ -0,0 +1,457 @@ +import { describe, expect, it } from 'vitest'; + +import { UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm/capability'; +import { + createAssistantMessage, + createMessageAccumulator, + createUserMessage, + type Message, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import type { TraitContext } from '#/llm/protocol/trait'; +import { + defaultThinkingEffortForModel, + modelSupportsThinking, + resolveThinkingEffortForModel, + resolveThinkingKeep, + type ModelThinkingMetadata, +} from '#/llm/thinking'; +import { kimiOpenAITrait } from '#/llm-kimi/trait'; +import { createOpenAIRequester } from '#/llm/requester/bases/openai/requester'; +import type { LlmClientContext, LlmRequestEvent } from '#/llm/requester/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'test-model', + capability: UNKNOWN_CAPABILITY, + baseUrl: 'https://example.test/v1', +}; +const ctx: TraitContext = { model }; +const messages: readonly Message[] = [createUserMessage('hi')]; + +function modelWith(meta: ModelThinkingMetadata): LlmModel { + return { ...model, ...meta }; +} + +function chatCompletionChunks( + deltas: readonly Record[] = [{ role: 'assistant', content: 'hi' }], +): Record[] { + return deltas.map((delta) => ({ + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta, finish_reason: null }], + })); +} + +function createAsyncStream(chunks: readonly T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +function openAIClient( + chunks: readonly Record[], + captured?: Record[], +): unknown { + return { + chat: { + completions: { + create: (params: Record) => { + captured?.push(params); + return { + withResponse: async () => ({ + data: createAsyncStream(chunks), + response: new Response(null), + }), + }; + }, + }, + }, + }; +} + +function stubOpenAIClient(chunks: readonly Record[]): { + clientFactory: (request: LlmClientContext) => never; + body: () => Record; +} { + const captured: Record[] = []; + return { + clientFactory: () => openAIClient(chunks, captured) as never, + body: () => { + const last = captured.at(-1); + if (last === undefined) throw new Error('expected client to be called'); + return last; + }, + }; +} + +function bodyMessages(body: Record): Record[] { + return body['messages'] as Record[]; +} + +describe('kimiOpenAITrait thinking', () => { + it('encodes thinking configs and resolves thinking defaults and keep', () => { + expect(kimiOpenAITrait.strictThinkingValidation).toBe(true); + expect(kimiOpenAITrait.withThinking?.({ effort: 'off' }, ctx)).toEqual({ + extra_body: { thinking: { type: 'disabled' } }, + }); + expect(kimiOpenAITrait.withThinking?.({ effort: 'on' }, ctx)).toEqual({ + extra_body: { thinking: { type: 'enabled' } }, + }); + expect(kimiOpenAITrait.withThinking?.({ effort: 'high', keep: 'all' }, ctx)).toEqual({ + extra_body: { thinking: { type: 'enabled', effort: 'high', keep: 'all' } }, + }); + + const thinkingCapability: ModelCapability = { + image_in: false, + video_in: false, + audio_in: false, + thinking: true, + tool_use: true, + }; + const thinkingModel = (meta: ModelThinkingMetadata): LlmModel => ({ + ...modelWith(meta), + capability: thinkingCapability, + }); + const declared = thinkingModel({ + supportEfforts: ['low', 'medium', 'high'], + defaultEffort: 'high', + }); + expect(modelSupportsThinking(declared)).toBe(true); + expect(modelSupportsThinking(model)).toBe(false); + expect(defaultThinkingEffortForModel(declared)).toBe('high'); + expect( + defaultThinkingEffortForModel(thinkingModel({ supportEfforts: ['low', 'medium', 'high'] })), + ).toBe('medium'); + expect(defaultThinkingEffortForModel(model)).toBe('off'); + + expect(resolveThinkingEffortForModel(' Max ', undefined, declared)).toBe('max'); + expect(resolveThinkingEffortForModel(undefined, { enabled: false }, declared)).toBe('off'); + expect(resolveThinkingEffortForModel(undefined, { effort: 'low' }, declared)).toBe('low'); + expect(resolveThinkingEffortForModel('high', { enabled: false }, declared)).toBe('high'); + expect(resolveThinkingEffortForModel(undefined, undefined, declared)).toBe('high'); + expect( + resolveThinkingEffortForModel( + undefined, + undefined, + thinkingModel({ supportEfforts: ['low', 'medium', 'high'] }), + ), + ).toBe('medium'); + expect(resolveThinkingEffortForModel('max', undefined, declared, true)).toBe('high'); + expect(resolveThinkingEffortForModel('on', undefined, declared, true)).toBe('high'); + expect(resolveThinkingEffortForModel('off', undefined, declared, true)).toBe('off'); + + const always = thinkingModel({ supportEfforts: ['low', 'high'], alwaysThinking: true }); + expect(resolveThinkingEffortForModel('off', undefined, always)).toBe('high'); + expect(resolveThinkingEffortForModel(undefined, { enabled: false, effort: 'low' }, always)).toBe( + 'low', + ); + + expect(resolveThinkingKeep(undefined, undefined, 'high')).toBe('all'); + expect(resolveThinkingKeep(undefined, undefined, 'off')).toBeUndefined(); + expect(resolveThinkingKeep('0', 'all', 'high')).toBeUndefined(); + expect(resolveThinkingKeep(undefined, 'none', 'high')).toBeUndefined(); + expect(resolveThinkingKeep('2', 'all', 'high')).toBe('2'); + expect(resolveThinkingKeep(undefined, '1', 'high')).toBe('1'); + }); + + it('preserves thinking only when keep is all and thinking is not disabled', () => { + expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on', keep: 'all' }, ctx)).toBe(true); + expect(kimiOpenAITrait.preserveThinking?.({ effort: 'off', keep: 'all' }, ctx)).toBeUndefined(); + expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on' }, ctx)).toBeUndefined(); + expect(kimiOpenAITrait.preserveThinking?.({ effort: 'on', keep: '1' }, ctx)).toBeUndefined(); + }); +}); + +describe('openai requester thinking', () => { + it('sends kimi thinking params at the top level', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { + model, + thinking: { effort: 'high', keep: 'all' }, + extraParams: { openai: { extra_body: { trace_id: 't1' } } }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['thinking']).toEqual({ type: 'enabled', effort: 'high', keep: 'all' }); + expect(client.body()['trace_id']).toBe('t1'); + expect(client.body()['reasoning_effort']).toBeUndefined(); + }); + + it('sends disabled thinking for off', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, thinking: { effort: 'off' } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['thinking']).toEqual({ type: 'disabled' }); + }); + + it('falls back to reasoning_effort when no trait handles thinking', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, thinking: { effort: 'high' } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['reasoning_effort']).toBe('high'); + expect(client.body()['thinking']).toBeUndefined(); + + await requester.generate( + { model: modelWith({ supportEfforts: ['low', 'high'] }), thinking: { effort: 'max' } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['reasoning_effort']).toBe('max'); + }); + + it('sends nothing for on without a trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, thinking: { effort: 'on' } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['reasoning_effort']).toBeUndefined(); + expect(client.body()['thinking']).toBeUndefined(); + }); + + it('sends the configured offEffort when thinking is off', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { + model: modelWith({ supportEfforts: ['low', 'high'], offEffort: 'none' }), + thinking: { effort: 'off' }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['reasoning_effort']).toBe('none'); + }); + + it('rejects unsatisfiable off requests with guidance', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const failing = async (target: LlmModel): Promise => { + const events: LlmRequestEvent[] = []; + await requester.generate( + { model: target, thinking: { effort: 'off' } }, + { messages }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + expect(events.some((event) => event.type === 'llm.sent')).toBe(false); + return events; + }; + const syntaxError = (events: LlmRequestEvent[]) => { + const failed = events.find((event) => event.type === 'llm.failed.syntax'); + if (failed?.type !== 'llm.failed.syntax') throw new Error('expected llm.failed.syntax'); + expect(failed.error.code).toBe('thinking_config'); + return failed.error.message; + }; + + const alwaysThinking = await failing( + modelWith({ supportEfforts: ['low', 'high'], alwaysThinking: true }), + ); + expect(syntaxError(alwaysThinking)).toContain('always reasons'); + + const noOffEffort = await failing(modelWith({ supportEfforts: ['low', 'high'] })); + expect(syntaxError(noOffEffort)).toContain('offEffort'); + }); + + it('rejects an effort outside the supported list under strict validation', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester( + { strictThinkingValidation: true }, + { clientFactory: client.clientFactory }, + ); + const events: LlmRequestEvent[] = []; + await requester.generate( + { model: modelWith({ supportEfforts: ['low', 'high'] }), thinking: { effort: 'max' } }, + { messages }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + const failed = events.find((event) => event.type === 'llm.failed.syntax'); + if (failed?.type !== 'llm.failed.syntax') throw new Error('expected llm.failed.syntax'); + expect(failed.error.code).toBe('thinking_config'); + expect(failed.error.message).toContain("'max'"); + expect(failed.error.message).toContain('low, high'); + expect(events.some((event) => event.type === 'llm.sent')).toBe(false); + }); + + it('rejects thinking efforts for a model known not to think', async () => { + const capability: ModelCapability = { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }; + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const events: LlmRequestEvent[] = []; + await requester.generate( + { model: { ...model, capability }, thinking: { effort: 'high' } }, + { messages }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + const failed = events.find((event) => event.type === 'llm.failed.syntax'); + if (failed?.type !== 'llm.failed.syntax') throw new Error('expected llm.failed.syntax'); + expect(failed.error.code).toBe('thinking_config'); + expect(failed.error.message).toContain('does not support thinking'); + expect(events.some((event) => event.type === 'llm.sent')).toBe(false); + }); + + it('keeps reasoning alive with medium effort when history has think parts', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage([{ type: 'think', think: 'abc' }, { type: 'text', text: 'hello' }]), + ], + }, + { signal: new AbortController().signal }, + ); + expect(client.body()['reasoning_effort']).toBe('medium'); + }); + + it('echoes think parts under reasoning_content by default', async () => { + const client = stubOpenAIClient(chatCompletionChunks()); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, thinking: { effort: 'off' } }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage([{ type: 'think', think: 'abc' }, { type: 'text', text: 'hello' }]), + ], + }, + { signal: new AbortController().signal }, + ); + const assistant = bodyMessages(client.body())[1]!; + expect(assistant['reasoning_content']).toBe('abc'); + expect(assistant['content']).toBe('hello'); + }); + + it('echoes an empty reasoning_content on think-less assistant messages only when keeping all', async () => { + const preserving = stubOpenAIClient(chatCompletionChunks()); + const preservingRequester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: preserving.clientFactory, + }); + await preservingRequester.generate( + { model, thinking: { effort: 'on', keep: 'all' } }, + { messages: [createUserMessage('hi'), createAssistantMessage([{ type: 'text', text: 'hello' }])] }, + { signal: new AbortController().signal }, + ); + expect(bodyMessages(preserving.body())[1]!['reasoning_content']).toBe(''); + + const plain = stubOpenAIClient(chatCompletionChunks()); + const plainRequester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: plain.clientFactory, + }); + await plainRequester.generate( + { model, thinking: { effort: 'on' } }, + { messages: [createUserMessage('hi'), createAssistantMessage([{ type: 'text', text: 'hello' }])] }, + { signal: new AbortController().signal }, + ); + expect('reasoning_content' in bodyMessages(plain.body())[1]!).toBe(false); + }); + + it('selects the outbound reasoning key from the trait declaration or inbound detection', async () => { + const declared = stubOpenAIClient(chatCompletionChunks()); + const declaredRequester = createOpenAIRequester( + { reasoningKey: () => 'reasoning' }, + { clientFactory: declared.clientFactory }, + ); + await declaredRequester.generate( + { model, thinking: { effort: 'off' } }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage([{ type: 'think', think: 'abc' }]), + ], + }, + { signal: new AbortController().signal }, + ); + const declaredAssistant = bodyMessages(declared.body())[1]!; + expect(declaredAssistant['reasoning']).toBe('abc'); + expect('reasoning_content' in declaredAssistant).toBe(false); + + let call = 0; + const captured: Record[] = []; + const clientFactory = () => { + call += 1; + const chunks = + call === 1 + ? chatCompletionChunks([{ role: 'assistant', content: 'hi', reasoning: 'detected' }]) + : chatCompletionChunks(); + return openAIClient(chunks, captured) as never; + }; + const detectedRequester = createOpenAIRequester(undefined, { clientFactory }); + await detectedRequester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + await detectedRequester.generate( + { model, thinking: { effort: 'off' } }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage([{ type: 'think', think: 'abc' }]), + ], + }, + { signal: new AbortController().signal }, + ); + const detectedAssistant = bodyMessages(captured[1]!)[1]!; + expect(detectedAssistant['reasoning']).toBe('abc'); + expect('reasoning_content' in detectedAssistant).toBe(false); + }); + + it('parses reasoning from stream deltas', async () => { + const collect = async (chunks: Record[]) => { + const client = stubOpenAIClient(chunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + const accumulator = createMessageAccumulator(); + await requester.generate( + { model }, + { messages }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.delta') { + accumulator.push(event.part); + } + }, + }, + ); + return accumulator.finish().content; + }; + await expect( + collect(chatCompletionChunks([{ reasoning: 'stream-think' }, { content: 'hi' }])), + ).resolves.toContainEqual({ type: 'think', think: 'stream-think' }); + await expect( + collect(chatCompletionChunks([{ reasoning: '' }, { content: 'hi' }])), + ).resolves.toContainEqual({ type: 'think', think: '' }); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/trait.test.ts b/packages/agent-core-v2/src/human/test/llm/trait.test.ts new file mode 100644 index 00000000000..826e551b77f --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/trait.test.ts @@ -0,0 +1,1705 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { isUnknownCapability, UNKNOWN_CAPABILITY, type ModelCapability } from '#/llm/capability'; +import type { FinishInfo } from '#/llm/finish-reason'; +import { + createAssistantMessage, + createToolMessage, + createUserMessage, + extractText, + isToolCall, + type Message, + type StreamedMessagePart, + type ToolDescription, + type VideoURLPart, +} from '#/llm/message'; +import { createMemoryMediaUploadCache } from '#/llm/media/cache'; +import { createMediaRefResolver } from '#/llm/media/resolver'; +import { createMemoryMediaSource } from '#/llm/media/source'; +import type { LlmModel } from '#/llm/model'; +import { createProvider } from '#/llm/provider/definition'; +import { KimiFiles } from '#/llm-kimi/files'; +import { kimiMediaContribution } from '#/llm-kimi/media'; +import { kimiProvider } from '#/llm-kimi/provider'; +import { + KIMI_API_KEY_ENV, + KIMI_BASE_URL_ENV, + KIMI_DEFAULT_BASE_URL, + kimiAnthropicTrait, + kimiOpenAITrait, +} from '#/llm-kimi/trait'; +import { anthropicProvider, openaiProvider } from '#/llm/provider/providers/standard'; +import type { LlmClientContext, LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; +import type { TokenUsage } from '#/llm/usage'; +import { + normalizeToolCallIdsForProvider, + sanitizeToolCallId, +} from '#/llm/requester/bases/tool-call-id'; +import { createAnthropicRequester } from '#/llm/requester/bases/anthropic/requester'; +import { createGoogleGenAIRequester } from '#/llm/requester/bases/google-genai/requester'; +import { createOpenAIResponsesRequester } from '#/llm/requester/bases/openai-responses/requester'; +import { + createOpenAIRequester, + openAIBase, +} from '#/llm/requester/bases/openai/requester'; + +const model: LlmModel = { + provider: 'test', + model: 'test-model', + baseUrl: 'https://example.test/v1', + capability: UNKNOWN_CAPABILITY, +}; +const messages: readonly Message[] = [createUserMessage('hi')]; + +async function generateAndCollectUsage( + requester: LlmRequester, +): Promise { + let usage: TokenUsage | undefined; + await requester.generate( + { model }, + { messages }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.usage') { + usage = event.usage; + } + }, + }, + ); + return usage; +} + +const TRAIT_CAPABILITY: ModelCapability = { + image_in: true, + video_in: true, + audio_in: true, + thinking: true, + tool_use: true, +}; + +const chatCompletionChunks: readonly Record[] = [ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta: { role: 'assistant', content: 'hi' }, finish_reason: 'stop' }], + }, +]; + +const anthropicStreamEvents: readonly Record[] = [ + { type: 'message_start', message: { id: 'msg_1', usage: { input_tokens: 10, output_tokens: 1 } } }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'hi' } }, + { type: 'content_block_stop', index: 0 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }, + { type: 'message_stop' }, +]; + +function createAsyncStream(chunks: readonly T[]): AsyncIterable { + return { + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) { + yield chunk; + } + }, + }; +} + +function withResponseStream(chunks: readonly Record[]): { + withResponse: () => Promise<{ data: AsyncIterable>; response: Response }>; +} { + return { + withResponse: async () => ({ + data: createAsyncStream(chunks), + response: new Response(null), + }), + }; +} + +interface CapturedClientCall { + params: Record; + headers?: Record; + options?: { headers?: Record }; + beta?: boolean; +} + +interface ClientStub { + clientFactory: (request: LlmClientContext) => never; + body: () => Record; + headers: () => Record | undefined; + requestHeaders: () => Record | undefined; + called: () => boolean; + betaCalled: () => boolean; +} + +function createClientStub( + build: (captured: CapturedClientCall[], request: LlmClientContext) => unknown, +): ClientStub { + const captured: CapturedClientCall[] = []; + return { + clientFactory: (request) => build(captured, request) as never, + body: () => { + const last = captured.at(-1); + if (last === undefined) throw new Error('expected client to be called'); + return last.params; + }, + headers: () => captured.at(-1)?.headers, + requestHeaders: () => captured.at(-1)?.options?.headers, + called: () => captured.length > 0, + betaCalled: () => captured.at(-1)?.beta === true, + }; +} + +function stubOpenAIClient(chunks: readonly Record[]): ClientStub { + return createClientStub((captured, request) => ({ + chat: { + completions: { + create: (params: Record) => { + captured.push({ params, headers: request.headers }); + return withResponseStream(chunks); + }, + }, + }, + })); +} + +function stubResponsesClient(events: readonly Record[]): ClientStub { + return createClientStub((captured, request) => ({ + responses: { + create: (params: Record) => { + captured.push({ params, headers: request.headers }); + return withResponseStream(events); + }, + }, + })); +} + +function stubAnthropicClient(events: readonly Record[]): ClientStub { + return createClientStub((captured, request) => ({ + messages: { + create: ( + params: Record, + options?: { headers?: Record }, + ) => { + captured.push({ params, headers: request.headers, options }); + return withResponseStream(events); + }, + }, + beta: { + messages: { + create: ( + params: Record, + options?: { headers?: Record }, + ) => { + captured.push({ params, headers: request.headers, options, beta: true }); + return withResponseStream(events); + }, + }, + }, + })); +} + +function stubGoogleClient(chunks: readonly Record[]): ClientStub { + return createClientStub((captured, request) => ({ + models: { + generateContentStream: async (params: Record) => { + captured.push({ params, headers: request.headers }); + return createAsyncStream(chunks); + }, + }, + })); +} + +describe('defaultHeaders', () => { + it('sends trait-declared headers on openai requests', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { defaultHeaders: () => ({ 'x-trait': 'a' }) }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.headers()?.['x-trait']).toBe('a'); + }); + + it('sends model defaultHeaders on openai requests', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model: { ...model, defaultHeaders: { 'x-model': 'b' } } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.headers()?.['x-model']).toBe('b'); + }); + + it('lets model headers override trait headers', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { defaultHeaders: () => ({ 'x-k': 'trait' }) }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model: { ...model, defaultHeaders: { 'x-k': 'model' } } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.headers()?.['x-k']).toBe('model'); + }); + + it('sends merged headers on anthropic requests', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester( + { defaultHeaders: () => ({ 'x-trait': 'a' }) }, + { clientFactory: client.clientFactory }, + ); + let finish: FinishInfo | undefined; + let messageId: string | undefined; + await requester.generate( + { model: { ...model, defaultHeaders: { 'x-model': 'b' } } }, + { messages }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.finish') finish = event.finish; + if (event.type === 'llm.message-id') messageId = event.messageId; + }, + }, + ); + expect(client.headers()?.['x-trait']).toBe('a'); + expect(client.headers()?.['x-model']).toBe('b'); + expect(finish).toEqual({ finishReason: 'completed', rawFinishReason: 'end_turn' }); + expect(messageId).toBe('msg_1'); + }); +}); + +describe('capability', () => { + it('resolves capabilities from the base prefixes and the trait hook', () => { + const reasoning = openaiProvider.resolveModel('o1').capability; + expect(reasoning.thinking).toBe(true); + expect(reasoning.tool_use).toBe(true); + expect(reasoning.image_in).toBe(false); + const vision = openaiProvider.resolveModel('gpt-4o').capability; + expect(vision.image_in).toBe(true); + expect(vision.thinking).toBe(false); + const textOnly = openaiProvider.resolveModel('gpt-3.5-turbo').capability; + expect(textOnly.tool_use).toBe(true); + expect(textOnly.image_in).toBe(false); + expect(textOnly.thinking).toBe(false); + expect(isUnknownCapability(openaiProvider.resolveModel('no-such-model').capability)).toBe( + true, + ); + + const thinkingVision = anthropicProvider.resolveModel('claude-sonnet-4-20250514').capability; + expect(thinkingVision.thinking).toBe(true); + expect(thinkingVision.image_in).toBe(true); + const legacyVision = anthropicProvider.resolveModel('claude-3-haiku').capability; + expect(legacyVision.image_in).toBe(true); + expect(legacyVision.thinking).toBe(false); + expect( + isUnknownCapability(anthropicProvider.resolveModel('no-such-model').capability), + ).toBe(true); + + const traitCapProvider = createProvider({ + id: 'test-trait-cap', + protocols: { openai: { base: openAIBase, trait: { capability: () => TRAIT_CAPABILITY } } }, + }); + expect(traitCapProvider.resolveModel('o1').capability).toBe(TRAIT_CAPABILITY); + }); + + it('enriches listModels seeds and returns an empty list without a model source', async () => { + await expect(openaiProvider.listModels()).resolves.toEqual([]); + + const provider = createProvider({ + id: 'test-list', + protocols: { openai: { base: openAIBase } }, + models: async () => [ + { model: 'gpt-4o' }, + { model: 'seed-cap', capability: TRAIT_CAPABILITY, baseUrl: 'https://seed.test/v1' }, + { model: 'unknown-x' }, + ], + }); + const listed = await provider.listModels(); + expect(listed).toHaveLength(3); + expect(listed[0]).toMatchObject({ + provider: 'test-list', + model: 'gpt-4o', + capability: { image_in: true, thinking: false }, + }); + expect(listed[1]).toMatchObject({ + model: 'seed-cap', + capability: TRAIT_CAPABILITY, + baseUrl: 'https://seed.test/v1', + }); + expect(isUnknownCapability((listed[2] as LlmModel).capability)).toBe(true); + }); +}); + +describe('media', () => { + const mediaModel: LlmModel = { + provider: 'test-media', + model: 'test-model', + capability: TRAIT_CAPABILITY, + }; + const uploadedPart: VideoURLPart = { + type: 'video_url', + videoUrl: { url: 'ms://file-1', id: 'file-1' }, + }; + + function videoRefMessage(url: string): Message { + return { role: 'user', content: [{ type: 'video_url', videoUrl: { url } }] }; + } + + it('rejects a non-video mime type', async () => { + await expect( + kimiMediaContribution.uploadVideo!( + { data: new Uint8Array([1]), mimeType: 'image/png' }, + { model }, + ), + ).rejects.toThrow('Expected a video mime type'); + }); + + it('requires an api key', async () => { + const files = new KimiFiles({ baseUrl: 'https://example.test/v1' }); + await expect( + files.uploadVideo({ data: new Uint8Array([1]), mimeType: 'video/mp4' }), + ).rejects.toThrow('apiKey is required'); + }); + + it('uploads a video ref once and serves later requests from the cache', async () => { + const uploadVideo = vi.fn(async () => uploadedPart); + const provider = createProvider({ + id: 'test-media', + protocols: { openai: { base: openAIBase } }, + media: { uploadVideo }, + }); + const resolver = createMediaRefResolver({ + providers: [provider], + source: createMemoryMediaSource({ + 'ref-1': { bytes: new Uint8Array([1, 2, 3]), mimeType: 'video/mp4' }, + }), + cache: createMemoryMediaUploadCache(), + }); + const messages = [videoRefMessage('media://ref-1')]; + const ctx = { model: mediaModel, signal: new AbortController().signal }; + + const first = await resolver.resolve(messages, ctx); + const second = await resolver.resolve(messages, ctx); + + expect(uploadVideo).toHaveBeenCalledTimes(1); + expect(first[0]?.content).toEqual([uploadedPart]); + expect(second[0]?.content).toEqual([uploadedPart]); + }); + + it('degrades to a text part when the media source has no bytes', async () => { + const provider = createProvider({ + id: 'test-media', + protocols: { openai: { base: openAIBase } }, + media: { uploadVideo: vi.fn() }, + }); + const resolver = createMediaRefResolver({ + providers: [provider], + source: createMemoryMediaSource(), + cache: createMemoryMediaUploadCache(), + }); + const resolved = await resolver.resolve([videoRefMessage('media://missing')], { + model: mediaModel, + signal: new AbortController().signal, + }); + expect(resolved[0]?.content).toEqual([ + { type: 'text', text: '[video omitted: media unavailable]' }, + ]); + }); + + it('inlines video bytes when the provider declares inline video and no uploader exists', async () => { + const provider = createProvider({ + id: 'test-media', + protocols: { openai: { base: openAIBase } }, + media: { inlineVideo: true }, + }); + const resolver = createMediaRefResolver({ + providers: [provider], + source: createMemoryMediaSource({ + 'ref-1': { bytes: new Uint8Array([1, 2, 3]), mimeType: 'video/mp4' }, + }), + cache: createMemoryMediaUploadCache(), + }); + const resolved = await resolver.resolve([videoRefMessage('media://ref-1')], { + model: mediaModel, + signal: new AbortController().signal, + }); + expect(resolved[0]?.content).toEqual([ + { + type: 'video_url', + videoUrl: { url: `data:video/mp4;base64,${Buffer.from([1, 2, 3]).toString('base64')}` }, + }, + ]); + }); +}); + + +describe('endpoint', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('injects the endpoint from env and trait defaults at request time', async () => { + vi.stubEnv(KIMI_BASE_URL_ENV, ''); + vi.stubEnv(KIMI_API_KEY_ENV, 'env-key'); + const seen: LlmModel[] = []; + const client = createClientStub((captured, request) => { + seen.push(request.model); + return { + chat: { + completions: { + create: (params: Record) => { + captured.push({ params, headers: request.headers }); + return withResponseStream(chatCompletionChunks); + }, + }, + }, + }; + }); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + const signal = new AbortController().signal; + + await requester.generate( + { model: kimiProvider.resolveModel('kimi-k3') }, + { messages }, + { signal }, + ); + vi.stubEnv(KIMI_BASE_URL_ENV, 'https://example.test/v9'); + await requester.generate( + { model: kimiProvider.resolveModel('kimi-k3') }, + { messages }, + { signal }, + ); + await requester.generate( + { model: kimiProvider.resolveModel('kimi-k3', { baseUrl: 'https://explicit.test/v1' }) }, + { messages }, + { signal }, + ); + + expect(seen.map((entry) => entry.baseUrl)).toEqual([ + KIMI_DEFAULT_BASE_URL, + 'https://example.test/v9', + 'https://explicit.test/v1', + ]); + expect(seen[0]?.apiKey).toBe('env-key'); + }); + + it('selects protocols by name and rejects undeclared ones', () => { + expect(kimiProvider.protocols).toEqual(['openai', 'anthropic_beta', 'openai_responses']); + expect(() => kimiProvider.createRequester('google-genai')).toThrow( + "provider 'kimi' has no protocol 'google-genai'", + ); + expect(() => kimiProvider.resolveModel('kimi-k3', { protocol: 'google-genai' })).toThrow( + "provider 'kimi' has no protocol 'google-genai'", + ); + + const requester: LlmRequester = { generate: () => Promise.resolve() }; + const passthrough = createProvider({ + id: 'test-passthrough', + protocols: { openai: { base: { createRequester: () => requester } } }, + }); + expect(passthrough.createRequester()).toBe(requester); + expect(passthrough.createRequester('openai')).toBe(requester); + + const resolved = kimiProvider.resolveModel('kimi-k3', { + baseUrl: 'https://example.test/v1', + apiKey: 'k', + defaultHeaders: { 'x-h': 'v' }, + }); + expect(resolved).toEqual({ + provider: 'kimi', + model: 'kimi-k3', + capability: resolved.capability, + baseUrl: 'https://example.test/v1', + apiKey: 'k', + defaultHeaders: { 'x-h': 'v' }, + }); + expect(isUnknownCapability(resolved.capability)).toBe(true); + }); +}); + +describe('convertTool', () => { + const tools: readonly ToolDescription[] = [ + { name: '$web_search', description: 'search the web', parameters: { type: 'object' } }, + { + name: 'get_weather', + description: 'get weather', + parameters: { type: 'object', properties: { unit: { enum: ['c', 'f'] } } }, + }, + ]; + + it('maps $-prefixed tools to builtin_function', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, tools }, + { messages }, + { signal: new AbortController().signal }, + ); + const bodyTools = client.body()['tools'] as Record[]; + expect(bodyTools[0]).toEqual({ + type: 'builtin_function', + function: { name: '$web_search' }, + }); + }); + + it('normalizes tool schemas for kimi', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, tools }, + { messages }, + { signal: new AbortController().signal }, + ); + const bodyTools = client.body()['tools'] as Record[]; + const weather = bodyTools[1] as { function: { parameters: Record } }; + const properties = weather.function.parameters['properties'] as Record< + string, + Record + >; + expect(properties['unit']?.['type']).toBe('string'); + }); + + it('uses the default tool mapping without a trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, tools: [tools[1]!] }, + { messages }, + { signal: new AbortController().signal }, + ); + const bodyTools = client.body()['tools'] as Record[]; + const weather = bodyTools[0] as { function: { parameters: Record } }; + const properties = weather.function.parameters['properties'] as Record< + string, + Record + >; + expect(properties['unit']?.['type']).toBeUndefined(); + }); +}); + +describe('message-level tools', () => { + const declared: readonly ToolDescription[] = [ + { name: 'get_weather', description: 'get weather', parameters: { type: 'object' } }, + ]; + + it('serializes system message tools for kimi', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model }, + { messages: [{ role: 'system', content: [], tools: [...declared] }, ...messages] }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + expect(bodyMessages[0]?.['tools']).toEqual([ + { + type: 'function', + function: { name: 'get_weather', description: 'get weather', parameters: { type: 'object' } }, + }, + ]); + }); + + it('drops system message tools without a trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { messages: [{ role: 'system', content: [], tools: [...declared] }, ...messages] }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + expect(bodyMessages[0]?.['tools']).toBeUndefined(); + }); +}); + +describe('withMaxCompletionTokens', () => { + it('encodes max completion tokens via the kimi trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + await requester.generate( + { model, maxCompletionTokens: 1000 }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_completion_tokens']).toBe(1000); + expect(client.body()['max_tokens']).toBeUndefined(); + }); + + it('uses max_completion_tokens for reasoning models without a trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model: { ...model, model: 'gpt-5.1' }, maxCompletionTokens: 1000 }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_completion_tokens']).toBe(1000); + expect(client.body()['max_tokens']).toBeUndefined(); + }); + + it('uses max_tokens for other models without a trait', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model: { ...model, model: 'gpt-4o' }, maxCompletionTokens: 1000 }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(1000); + expect(client.body()['max_completion_tokens']).toBeUndefined(); + }); + + it('caps by the remaining context budget', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model: { ...model, model: 'gpt-4o' }, maxCompletionTokens: 1000, maxContextTokens: 500 }, + { messages, usedContextTokens: 200 }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(300); + }); + + it('passes max_tokens on the anthropic request path', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, maxCompletionTokens: 1000, maxContextTokens: 500 }, + { messages, usedContextTokens: 200 }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(300); + await requester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(128000); + + const sonnet35 = { ...model, model: 'claude-3-5-sonnet-20241022' }; + await requester.generate( + { model: sonnet35 }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(8192); + await requester.generate( + { model: sonnet35, maxCompletionTokens: 128000 }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(8192); + await requester.generate( + { model: { ...model, model: 'claude-sonnet-4-2' } }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['max_tokens']).toBe(64000); + }); +}); + +describe('buildParams', () => { + it('lets the trait reshape the final params', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { + buildParams: (params) => ({ ...params, x_custom: 1 }), + }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + expect(client.body()['x_custom']).toBe(1); + }); +}); + +describe('extractUsage', () => { + it('reads usage from choices when the top level is absent', async () => { + const client = stubOpenAIClient([ + { + id: 'c1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [ + { + index: 0, + delta: { content: 'hi' }, + finish_reason: 'stop', + usage: { prompt_tokens: 3, completion_tokens: 5 }, + }, + ], + }, + ]); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + const usage = await generateAndCollectUsage(requester); + expect(usage?.output).toBe(5); + expect(usage?.inputOther).toBe(3); + }); + + it('reads usage from stream choice chunks', async () => { + const client = stubOpenAIClient([ + { id: 'c1', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: null }] }, + { id: 'c1', object: 'chat.completion.chunk', created: 0, model: 'test-model', choices: [{ index: 0, delta: {}, finish_reason: 'stop', usage: { prompt_tokens: 4, completion_tokens: 6 } }] }, + ]); + const requester = createOpenAIRequester(kimiOpenAITrait, { + clientFactory: client.clientFactory, + }); + const usage = await generateAndCollectUsage(requester); + expect(usage?.output).toBe(6); + expect(usage?.inputOther).toBe(4); + }); + + it('parses top-level usage without a trait', async () => { + const client = stubOpenAIClient([ + { + id: 'chatcmpl-1', + object: 'chat.completion.chunk', + created: 0, + model: 'test-model', + choices: [{ index: 0, delta: { content: 'hi' }, finish_reason: 'stop' }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ]); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + let usage: TokenUsage | undefined; + let finish: FinishInfo | undefined; + let messageId: string | undefined; + await requester.generate( + { model }, + { messages }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.usage') usage = event.usage; + if (event.type === 'llm.finish') finish = event.finish; + if (event.type === 'llm.message-id') messageId = event.messageId; + }, + }, + ); + expect(usage?.output).toBe(1); + expect(finish).toEqual({ finishReason: 'completed', rawFinishReason: 'stop' }); + expect(messageId).toBe('chatcmpl-1'); + }); +}); + + +describe('toolCallIdPolicy', () => { + it('sanitizes unsafe characters and truncates', () => { + expect(sanitizeToolCallId('call|abc def/ghi')).toBe('call_abc_def_ghi'); + expect(sanitizeToolCallId('a'.repeat(100), 64)).toHaveLength(64); + }); + + it('rewrites both sides of a tool call consistently', () => { + const history = [ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call|abc', name: 'get_weather', arguments: '{}' }], + ), + createToolMessage('call|abc', 'sunny'), + ]; + const normalized = normalizeToolCallIdsForProvider(history, { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, + }); + const assistant = normalized[1]!; + if (assistant.role !== 'assistant') throw new Error('expected assistant message'); + const tool = normalized[2]!; + if (tool.role !== 'tool') throw new Error('expected tool message'); + expect(assistant.toolCalls[0]?.id).toBe('call_abc'); + expect(tool.toolCallId).toBe('call_abc'); + }); + + it('dedupes collisions and replaces empty ids', () => { + const colliding = normalizeToolCallIdsForProvider( + [ + createAssistantMessage( + [{ type: 'text', text: '' }], + [ + { type: 'function', id: 'a b', name: 'f', arguments: null }, + { type: 'function', id: 'a/b', name: 'g', arguments: null }, + ], + ), + ], + { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, + }, + ); + const collidingAssistant = colliding[0]!; + if (collidingAssistant.role !== 'assistant') throw new Error('expected assistant message'); + expect(collidingAssistant.toolCalls.map((toolCall) => toolCall.id)).toEqual(['a_b', 'a_b_2']); + + const empty = normalizeToolCallIdsForProvider( + [ + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: '', name: 'f', arguments: null }], + ), + ], + { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, + }, + ); + const emptyAssistant = empty[0]!; + if (emptyAssistant.role !== 'assistant') throw new Error('expected assistant message'); + expect(emptyAssistant.toolCalls[0]?.id).toBe('tool_call'); + }); + + it('keeps safe ids unchanged', () => { + const history = [ + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call_1', name: 'f', arguments: null }], + ), + ]; + const normalized = normalizeToolCallIdsForProvider(history, { + normalize: (id) => sanitizeToolCallId(id, 64), + maxLength: 64, + }); + const assistant = normalized[0]!; + if (assistant.role !== 'assistant') throw new Error('expected assistant message'); + expect(assistant.toolCalls[0]?.id).toBe('call_1'); + }); + + it('sanitizes tool call ids on the openai request path', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call|abc', name: 'get_weather', arguments: '{}' }], + ), + createToolMessage('call|abc', 'sunny'), + ], + }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const toolCalls = bodyMessages[1]?.['tool_calls'] as Record[]; + expect(toolCalls[0]?.['id']).toBe('call_abc'); + expect(bodyMessages[2]?.['tool_call_id']).toBe('call_abc'); + }); + + it('lets the trait override the policy', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { + toolCallIdPolicy: () => ({ + normalize: (id) => sanitizeToolCallId(id, 4), + maxLength: 4, + }), + }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call_abcdef', name: 'f', arguments: null }], + ), + ], + }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const toolCalls = bodyMessages[1]?.['tool_calls'] as Record[]; + expect(toolCalls[0]?.['id']).toBe('call'); + }); + + it('sanitizes tool call ids on the anthropic request path', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call|abc', name: 'get_weather', arguments: '{}' }], + ), + createToolMessage('call|abc', 'sunny'), + ], + }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + const blocks = bodyMessages.flatMap( + (message) => message['content'] as Record[], + ); + const toolUse = blocks.find((block) => block['type'] === 'tool_use'); + const toolResult = blocks.find((block) => block['type'] === 'tool_result'); + expect(toolUse?.['id']).toBe('call_abc'); + expect(toolResult?.['tool_use_id']).toBe('call_abc'); + }); +}); + + +describe('mergeHistory', () => { + it('lets the trait merge the converted history', async () => { + const client = stubOpenAIClient(chatCompletionChunks); + const requester = createOpenAIRequester( + { + mergeHistory: (history) => [...history, { role: 'user', content: 'extra' }], + }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + const bodyMessages = client.body()['messages'] as Record[]; + expect(bodyMessages.at(-1)).toEqual({ role: 'user', content: 'extra' }); + }); +}); + +describe('anthropic trait dialect', () => { + it('lets the trait reshape messages, history, and tools', async () => { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester( + { + convertMessage: (message, converted) => { + if (extractText(message) === 'drop me') { + return null; + } + return { + ...converted, + content: [ + ...(converted['content'] as Record[]), + { type: 'text', text: 'suffix' }, + ], + }; + }, + mergeHistory: (history) => [ + ...history, + { role: 'user', content: [{ type: 'text', text: 'extra' }] }, + ], + convertTool: (tool) => ({ + name: `x_${tool.name}`, + description: tool.description, + input_schema: tool.parameters, + }), + }, + { clientFactory: client.clientFactory }, + ); + await requester.generate( + { + model, + tools: [{ name: 'get_weather', description: 'Get weather', parameters: { type: 'object' } }], + }, + { messages: [createUserMessage('hi'), createUserMessage('drop me')] }, + { signal: new AbortController().signal }, + ); + const body = client.body(); + const bodyMessages = body['messages'] as Record[]; + expect(bodyMessages).toHaveLength(2); + expect(bodyMessages[0]?.['content']).toEqual([ + { type: 'text', text: 'hi' }, + { type: 'text', text: 'suffix' }, + ]); + expect(bodyMessages[1]?.['content']).toEqual([ + { type: 'text', text: 'extra', cache_control: { type: 'ephemeral' } }, + ]); + const bodyTools = body['tools'] as Record[]; + expect(bodyTools).toHaveLength(1); + expect(bodyTools[0]?.['name']).toBe('x_get_weather'); + expect(bodyTools[0]?.['cache_control']).toEqual({ type: 'ephemeral' }); + }); +}); + +describe('anthropic user message merging', () => { + function bodyMessages(body: Record): Record[] { + return body['messages'] as Record[]; + } + + async function generate(history: readonly Message[]): Promise[]> { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model }, + { messages: history }, + { signal: new AbortController().signal }, + ); + return bodyMessages(client.body()); + } + + it('keeps a plain user text and a following tool result separate', async () => { + const merged = await generate([createUserMessage('hi'), createToolMessage('call_1', 'sunny')]); + expect(merged).toHaveLength(2); + expect(merged[0]?.['content']).toEqual([{ type: 'text', text: 'hi' }]); + expect((merged[1]?.['content'] as Record[])[0]?.['type']).toBe( + 'tool_result', + ); + }); + + it('merges user text into a preceding tool result message', async () => { + const merged = await generate([createToolMessage('call_1', 'sunny'), createUserMessage('hi')]); + expect(merged).toHaveLength(1); + const content = merged[0]?.['content'] as Record[]; + expect(content.map((block) => block['type'])).toEqual(['tool_result', 'text']); + }); + + it('merges consecutive tool result messages', async () => { + const merged = await generate([ + createToolMessage('call_1', 'sunny'), + createToolMessage('call_2', 'rainy'), + ]); + expect(merged).toHaveLength(1); + const content = merged[0]?.['content'] as Record[]; + expect(content).toHaveLength(2); + expect(content.every((block) => block['type'] === 'tool_result')).toBe(true); + }); + + it('does not merge adjacent assistant messages', async () => { + const merged = await generate([ + createAssistantMessage([{ type: 'text', text: 'a' }]), + createAssistantMessage([{ type: 'text', text: 'b' }]), + ]); + expect(merged).toHaveLength(2); + expect(merged[0]?.['role']).toBe('assistant'); + expect(merged[1]?.['role']).toBe('assistant'); + }); +}); + + +describe('anthropic cache control', () => { + async function generate( + history: readonly Message[], + tools: ToolDescription[] = [], + systemPrompt?: string, + ): Promise> { + const client = stubAnthropicClient(anthropicStreamEvents); + const requester = createAnthropicRequester(undefined, { clientFactory: client.clientFactory }); + await requester.generate( + { model, systemPrompt, tools }, + { messages: history }, + { signal: new AbortController().signal }, + ); + return client.body(); + } + + it('marks the last block of the last message and the last tool', async () => { + const body = await generate( + [createUserMessage('hi')], + [ + { name: 'get_weather', description: 'Get weather', parameters: { type: 'object' } }, + { name: 'get_time', description: 'Get time', parameters: { type: 'object' } }, + ], + ); + const messages = body['messages'] as Record[]; + const content = messages[0]?.['content'] as Record[]; + expect(content[0]?.['cache_control']).toEqual({ type: 'ephemeral' }); + const tools = body['tools'] as Record[]; + expect(tools[0]?.['cache_control']).toBeUndefined(); + expect(tools.at(-1)?.['cache_control']).toEqual({ type: 'ephemeral' }); + }); + + it('marks only the last block', async () => { + const body = await generate([ + createUserMessage('one'), + createAssistantMessage([{ type: 'text', text: 'two' }]), + createUserMessage('three'), + ]); + const messages = body['messages'] as Record[]; + const first = messages[0]?.['content'] as Record[]; + const assistant = messages[1]?.['content'] as Record[]; + const last = messages[2]?.['content'] as Record[]; + expect(first[0]?.['cache_control']).toBeUndefined(); + expect(assistant[0]?.['cache_control']).toBeUndefined(); + expect(last[0]?.['cache_control']).toEqual({ type: 'ephemeral' }); + }); + + it('marks a trailing tool result block', async () => { + const body = await generate([ + createUserMessage('hi'), + createAssistantMessage( + [{ type: 'text', text: '' }], + [{ type: 'function', id: 'call_1', name: 'get_weather', arguments: '{}' }], + ), + createToolMessage('call_1', 'sunny'), + ]); + const messages = body['messages'] as Record[]; + const last = messages.at(-1)?.['content'] as Record[]; + expect(last.at(-1)?.['cache_control']).toEqual({ type: 'ephemeral' }); + }); + + it('marks the system block', async () => { + const body = await generate([createUserMessage('hi')], [], 'be brief'); + const system = body['system'] as Record[]; + expect(system[0]?.['cache_control']).toEqual({ type: 'ephemeral' }); + }); +}); + + +describe('anthropic thinking kwargs', () => { + it('applies the kimi thinking trait, the anthropic-beta protocol, and thinking echo rules', async () => { + const client = stubAnthropicClient([ + { type: 'message_start', message: { usage: { input_tokens: 10, output_tokens: 1 } } }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'redacted_thinking', data: 'enc_data_1' }, + }, + { type: 'content_block_stop', index: 0 }, + { type: 'content_block_start', index: 1, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: 'hi' } }, + { type: 'content_block_stop', index: 1 }, + { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2 } }, + { type: 'message_stop' }, + ]); + const requester = createAnthropicRequester(kimiAnthropicTrait, { + betaApi: true, + clientFactory: client.clientFactory, + }); + const parts: StreamedMessagePart[] = []; + await requester.generate( + { model, thinking: { effort: 'high' } }, + { + messages: [ + createAssistantMessage( + [{ type: 'think', think: 'reasoning', encrypted: 'sig_1' }], + [{ type: 'function', id: 'call_1', name: 'get_weather', arguments: '{}' }], + ), + createToolMessage('call_1', 'sunny'), + createUserMessage('hi'), + ], + }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.delta') parts.push(event.part); + }, + }, + ); + expect(parts).toContainEqual({ type: 'think', think: '', encrypted: 'enc_data_1' }); + let body = client.body(); + expect(body['thinking']).toEqual({ type: 'enabled' }); + expect(body['output_config']).toEqual({ effort: 'high' }); + expect(body['betaFeatures']).toBeUndefined(); + expect(body['betas']).toEqual(['context-management-2025-06-27']); + expect(body['max_tokens']).toBe(128000); + expect(client.betaCalled()).toBe(true); + let bodyMessages = body['messages'] as Record[]; + expect(bodyMessages[0]?.['content']).toEqual([ + { type: 'thinking', thinking: 'reasoning', signature: 'sig_1' }, + { type: 'tool_use', id: 'call_1', name: 'get_weather', input: {} }, + ]); + + await requester.generate( + { model, thinking: { effort: 'on', keep: 'all' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toEqual(['context-management-2025-06-27']); + expect(client.betaCalled()).toBe(true); + + const betaFeatureTrait = { + withThinking: () => ({ + thinking: { type: 'enabled' }, + betaFeatures: ['interleaved-thinking-2025-05-14', 'custom-beta'], + }), + }; + const betaRequester = createAnthropicRequester(betaFeatureTrait, { + betaApi: true, + clientFactory: client.clientFactory, + }); + await betaRequester.generate( + { model, thinking: { effort: 'on' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['betaFeatures']).toBeUndefined(); + expect(body['betas']).toEqual(['interleaved-thinking-2025-05-14', 'custom-beta']); + expect(client.betaCalled()).toBe(true); + + await betaRequester.generate( + { model, thinking: { effort: 'on', keep: 'all' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toEqual([ + 'interleaved-thinking-2025-05-14', + 'custom-beta', + 'context-management-2025-06-27', + ]); + + const plainBetaRequester = createAnthropicRequester(betaFeatureTrait, { + clientFactory: client.clientFactory, + }); + await plainBetaRequester.generate( + { model, thinking: { effort: 'on' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['betaFeatures']).toBeUndefined(); + expect(body['betas']).toBeUndefined(); + expect(client.requestHeaders()?.['anthropic-beta']).toBe( + 'interleaved-thinking-2025-05-14,custom-beta', + ); + expect(client.betaCalled()).toBe(false); + + const defaultRequester = createAnthropicRequester(undefined, { + clientFactory: client.clientFactory, + }); + await defaultRequester.generate( + { model }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['betas']).toBeUndefined(); + expect(client.requestHeaders()?.['anthropic-beta']).toBe('interleaved-thinking-2025-05-14'); + expect(client.betaCalled()).toBe(false); + + await defaultRequester.generate( + { model, thinking: { effort: 'high' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['betas']).toBeUndefined(); + expect(client.requestHeaders()?.['anthropic-beta']).toBeUndefined(); + expect(client.betaCalled()).toBe(false); + + await defaultRequester.generate( + { model: { ...model, model: 'claude-sonnet-4-5' }, thinking: { effort: 'high' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(client.requestHeaders()?.['anthropic-beta']).toBe('interleaved-thinking-2025-05-14'); + expect(client.betaCalled()).toBe(false); + + await defaultRequester.generate( + { + model: { + ...model, + model: 'claude-sonnet-4-5', + supportEfforts: ['low', 'medium', 'high', 'xhigh'], + }, + thinking: { effort: 'xhigh' }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['thinking']).toEqual({ type: 'adaptive', display: 'summarized' }); + expect(body['output_config']).toEqual({ effort: 'xhigh' }); + expect(client.requestHeaders()?.['anthropic-beta']).toBeUndefined(); + + await defaultRequester.generate( + { + model: { ...model, supportEfforts: ['low', 'medium', 'high'], adaptiveThinking: false }, + thinking: { effort: 'high' }, + }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['thinking']).toEqual({ type: 'enabled', budget_tokens: 32000 }); + expect(body['output_config']).toBeUndefined(); + expect(client.requestHeaders()?.['anthropic-beta']).toBe('interleaved-thinking-2025-05-14'); + + await defaultRequester.generate( + { model, thinking: { effort: 'high', keep: 'all' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['context_management']).toEqual({ + edits: [{ type: 'clear_thinking_20251015', keep: 'all' }], + }); + expect(body['betas']).toEqual(['context-management-2025-06-27']); + expect(client.betaCalled()).toBe(true); + + await requester.generate( + { model, thinking: { effort: 'off' } }, + { messages }, + { signal: new AbortController().signal }, + ); + body = client.body(); + expect(body['thinking']).toEqual({ type: 'disabled' }); + expect(body['output_config']).toBeUndefined(); + + const unsignedHistory: Message[] = [ + createAssistantMessage([{ type: 'think', think: 'loose reasoning' }]), + createUserMessage('hi'), + ]; + await requester.generate( + { model, thinking: { effort: 'off' } }, + { messages: unsignedHistory }, + { signal: new AbortController().signal }, + ); + body = client.body(); + bodyMessages = body['messages'] as Record[]; + expect(bodyMessages[0]?.['content']).toEqual([ + { type: 'thinking', thinking: 'loose reasoning' }, + ]); + + await requester.generate( + { model, thinking: { effort: 'off' } }, + { + messages: [ + createAssistantMessage([{ type: 'think', think: '' }]), + createUserMessage('hi'), + ], + }, + { signal: new AbortController().signal }, + ); + body = client.body(); + bodyMessages = body['messages'] as Record[]; + expect(bodyMessages[0]?.['content']).toEqual([{ type: 'thinking', thinking: '' }]); + + await requester.generate( + { model: { ...model, model: 'claude-sonnet-4-5' }, thinking: { effort: 'off' } }, + { messages: unsignedHistory }, + { signal: new AbortController().signal }, + ); + body = client.body(); + bodyMessages = body['messages'] as Record[]; + expect(bodyMessages).toHaveLength(1); + expect(bodyMessages[0]?.['role']).toBe('user'); + + const events: LlmRequestEvent[] = []; + await defaultRequester.generate( + { model: { ...model, model: 'claude-fable-5' }, thinking: { effort: 'off' } }, + { messages }, + { signal: new AbortController().signal, onEvent: (event) => events.push(event) }, + ); + const failed = events.find((event) => event.type === 'llm.failed.syntax'); + expect(failed).toBeDefined(); + if (failed?.type !== 'llm.failed.syntax') throw new Error('expected llm.failed.syntax'); + expect(failed.error.code).toBe('thinking_config'); + expect(failed.error.message).toContain('always reasons'); + expect(events.some((event) => event.type === 'llm.sent')).toBe(false); + }); +}); + +describe('openai responses base', () => { + it('builds the responses request shape and parses the stream', async () => { + const client = stubResponsesClient([ + { type: 'response.created', response: { id: 'resp_1', status: 'in_progress' } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { type: 'reasoning', id: 'r_1', summary: [] }, + }, + { type: 'response.reasoning_summary_text.delta', delta: 'thinking' }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + type: 'reasoning', + id: 'r_1', + summary: [{ type: 'summary_text', text: 'thinking' }], + encrypted_content: 'enc_1', + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + type: 'function_call', + id: 'fc_1', + call_id: 'call_1', + name: 'get_weather', + arguments: '', + }, + }, + { + type: 'response.function_call_arguments.delta', + item_id: 'fc_1', + output_index: 1, + delta: '{"city"', + }, + { + type: 'response.function_call_arguments.done', + item_id: 'fc_1', + output_index: 1, + arguments: '{"city":"sf"}', + }, + { type: 'response.output_text.delta', delta: 'sunny' }, + { + type: 'response.completed', + response: { + id: 'resp_1', + status: 'completed', + usage: { input_tokens: 12, output_tokens: 7, input_tokens_details: { cached_tokens: 5 } }, + }, + }, + ]); + const requester = createOpenAIResponsesRequester(undefined, { + clientFactory: client.clientFactory, + }); + const parts: StreamedMessagePart[] = []; + let usage: TokenUsage | undefined; + let finish: FinishInfo | undefined; + let messageId: string | undefined; + await requester.generate( + { + model, + systemPrompt: 'be brief', + tools: [{ name: 'get_weather', description: 'Get weather', parameters: { type: 'object' } }], + thinking: { effort: 'high' }, + maxCompletionTokens: 500, + }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage( + [ + { type: 'think', think: 'hmm', encrypted: 'enc_0' }, + { type: 'text', text: 'checking' }, + ], + [{ type: 'function', id: 'call|abc', name: 'get_weather', arguments: '{"city":"sf"}' }], + ), + createToolMessage('call|abc', 'sunny'), + ], + }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.delta') parts.push(event.part); + if (event.type === 'llm.usage') usage = event.usage; + if (event.type === 'llm.finish') finish = event.finish; + if (event.type === 'llm.message-id') messageId = event.messageId; + }, + }, + ); + const body = client.body(); + expect(body['instructions']).toBe('be brief'); + expect(body['store']).toBe(false); + expect(body['stream']).toBe(true); + expect(body['reasoning']).toEqual({ effort: 'high', summary: 'auto' }); + expect(body['include']).toEqual(['reasoning.encrypted_content']); + expect(body['max_output_tokens']).toBe(500); + const input = body['input'] as Record[]; + expect(input[0]).toEqual({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hi' }], + }); + expect(input[1]).toEqual({ + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'hmm' }], + encrypted_content: 'enc_0', + }); + expect(input[2]).toEqual({ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'checking', annotations: [] }], + }); + expect(input[3]).toEqual({ + type: 'function_call', + call_id: 'call', + name: 'get_weather', + arguments: '{"city":"sf"}', + }); + expect(input[4]).toEqual({ + type: 'function_call_output', + call_id: 'call', + output: [{ type: 'input_text', text: 'sunny' }], + }); + const bodyTools = body['tools'] as Record[]; + expect(bodyTools[0]).toEqual({ + type: 'function', + name: 'get_weather', + description: 'Get weather', + parameters: { type: 'object' }, + strict: false, + }); + expect(usage).toEqual({ + inputOther: 7, + output: 7, + inputCacheRead: 5, + inputCacheCreation: 0, + raw: { input_tokens: 12, output_tokens: 7, input_tokens_details: { cached_tokens: 5 } }, + }); + expect(parts).toContainEqual({ type: 'think', think: 'thinking' }); + expect(parts).toContainEqual({ type: 'think', think: '', encrypted: 'enc_1' }); + expect(parts).toContainEqual({ + type: 'function', + id: 'call_1', + name: 'get_weather', + arguments: '', + _streamIndex: 'fc_1', + }); + expect(parts).toContainEqual({ + type: 'tool_call_part', + argumentsPart: '{"city"', + index: 'fc_1', + }); + expect(parts).toContainEqual({ + type: 'tool_call_part', + argumentsPart: ':"sf"}', + index: 'fc_1', + }); + expect(parts).toContainEqual({ type: 'text', text: 'sunny' }); + expect(finish).toEqual({ finishReason: 'completed', rawFinishReason: 'completed' }); + expect(messageId).toBe('resp_1'); + }); +}); + +describe('google genai base', () => { + it('converts contents and maps usageMetadata', async () => { + const client = stubGoogleClient([ + { + responseId: 'gemini-resp-1', + candidates: [ + { + content: { + role: 'model', + parts: [ + { text: 'hmm', thought: true, thoughtSignature: 'sig_1' }, + { text: 'sunny' }, + { functionCall: { name: 'get_weather', args: { city: 'sf' } } }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, cachedContentTokenCount: 4 }, + }, + ]); + const requester = createGoogleGenAIRequester(undefined, { + clientFactory: client.clientFactory, + }); + const parts: StreamedMessagePart[] = []; + let usage: TokenUsage | undefined; + let finish: FinishInfo | undefined; + let messageId: string | undefined; + await requester.generate( + { + model: { ...model, model: 'gemini-2.5-flash', apiKey: 'test-key' }, + systemPrompt: 'be brief', + tools: [{ name: 'get_weather', description: 'Get weather', parameters: { type: 'object' } }], + thinking: { effort: 'medium' }, + maxCompletionTokens: 500, + }, + { + messages: [ + createUserMessage('hi'), + createAssistantMessage( + [ + { type: 'think', think: 'hmm', encrypted: 'sig_0' }, + { type: 'text', text: 'checking' }, + ], + [ + { + type: 'function', + id: 'get_weather_abc', + name: 'get_weather', + arguments: '{"city":"sf"}', + extras: { thought_signature_b64: 'sig_call' }, + }, + ], + ), + createToolMessage('get_weather_abc', 'sunny'), + ], + }, + { + signal: new AbortController().signal, + onEvent: (event) => { + if (event.type === 'llm.delta') parts.push(event.part); + if (event.type === 'llm.usage') usage = event.usage; + if (event.type === 'llm.finish') finish = event.finish; + if (event.type === 'llm.message-id') messageId = event.messageId; + }, + }, + ); + const body = client.body(); + const contents = body['contents'] as Record[]; + expect(contents[0]).toEqual({ role: 'user', parts: [{ text: 'hi' }] }); + expect(contents[1]).toEqual({ + role: 'model', + parts: [ + { text: 'hmm', thought: true, thoughtSignature: 'sig_0' }, + { text: 'checking' }, + { + functionCall: { name: 'get_weather', args: { city: 'sf' } }, + thoughtSignature: 'sig_call', + }, + ], + }); + expect(contents[2]).toEqual({ + role: 'user', + parts: [ + { functionResponse: { name: 'get_weather', response: { output: 'sunny' }, parts: [] } }, + ], + }); + const config = body['config'] as Record; + expect(config['systemInstruction']).toBe('be brief'); + expect(config['maxOutputTokens']).toBe(500); + expect(config['thinkingConfig']).toEqual({ + includeThoughts: true, + thinkingBudget: 4096, + }); + const bodyTools = config['tools'] as Record[]; + expect(bodyTools[0]).toEqual({ + functionDeclarations: [ + { name: 'get_weather', description: 'Get weather', parametersJsonSchema: { type: 'object' } }, + ], + }); + expect(usage).toEqual({ + inputOther: 6, + output: 5, + inputCacheRead: 4, + inputCacheCreation: 0, + raw: { promptTokenCount: 10, candidatesTokenCount: 5, cachedContentTokenCount: 4 }, + }); + expect(parts).toContainEqual({ type: 'think', think: 'hmm', encrypted: 'sig_1' }); + expect(parts).toContainEqual({ type: 'text', text: 'sunny' }); + const functionPart = parts.find(isToolCall); + expect(functionPart?.name).toBe('get_weather'); + expect(functionPart?.arguments).toBe('{"city":"sf"}'); + expect(finish).toEqual({ finishReason: 'completed', rawFinishReason: 'STOP' }); + expect(messageId).toBe('gemini-resp-1'); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/llm/usage.test.ts b/packages/agent-core-v2/src/human/test/llm/usage.test.ts new file mode 100644 index 00000000000..f20e09caa32 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/llm/usage.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; + +import type { StreamParseSink } from '#/llm/protocol/format'; +import { anthropicFormat } from '#/llm/requester/bases/anthropic/format'; +import { openAIFormat } from '#/llm/requester/bases/openai/format'; +import type { TokenUsage } from '#/llm/usage'; + +function createSink() { + const usages: Partial[] = []; + const sink: StreamParseSink = { + onDelta: () => {}, + onFinish: () => {}, + onUsage: (usage) => usages.push(usage), + }; + return { sink, usages }; +} + +describe('openAIFormat stream usage', () => { + it('emits usage from the usage chunk', () => { + const { sink, usages } = createSink(); + const raw = { prompt_tokens: 120, completion_tokens: 30, total_tokens: 150 }; + openAIFormat.createStreamParser()({ choices: [], usage: raw }, sink); + expect(usages).toEqual([ + { inputOther: 120, output: 30, inputCacheRead: 0, inputCacheCreation: 0, raw }, + ]); + }); + + it('splits cached tokens out of the prompt total', () => { + const { sink, usages } = createSink(); + const raw = { + prompt_tokens: 200, + completion_tokens: 50, + prompt_tokens_details: { cached_tokens: 80 }, + completion_tokens_details: { reasoning_tokens: 12 }, + }; + openAIFormat.createStreamParser()({ usage: raw }, sink); + expect(usages).toEqual([ + { inputOther: 120, output: 50, inputCacheRead: 80, inputCacheCreation: 0, raw }, + ]); + }); + + it('emits nothing for chunks without usage', () => { + const { sink, usages } = createSink(); + const parse = openAIFormat.createStreamParser(); + parse({ choices: [] }, sink); + parse({ usage: null }, sink); + expect(usages).toEqual([]); + }); +}); + +describe('anthropicFormat stream usage', () => { + it('emits message_start input usage and message_delta output usage as they arrive', () => { + const { sink, usages } = createSink(); + const parse = anthropicFormat.createStreamParser(); + const startRaw = { + input_tokens: 500, + output_tokens: 1, + cache_read_input_tokens: 300, + cache_creation_input_tokens: 100, + }; + const deltaRaw = { output_tokens: 87 }; + parse({ type: 'message_start', message: { usage: startRaw } }, sink); + parse({ type: 'message_delta', usage: deltaRaw }, sink); + expect(usages).toEqual([ + { + inputOther: 500, + inputCacheRead: 300, + inputCacheCreation: 100, + raw: startRaw, + }, + { output: 87, raw: deltaRaw }, + ]); + }); + + it('emits a message_delta usage on its own', () => { + const { sink, usages } = createSink(); + anthropicFormat.createStreamParser()( + { type: 'message_delta', usage: { output_tokens: 87 } }, + sink, + ); + expect(usages).toEqual([{ output: 87, raw: { output_tokens: 87 } }]); + }); + + it('emits nothing for events without usage', () => { + const { sink, usages } = createSink(); + anthropicFormat.createStreamParser()( + { type: 'content_block_delta', delta: { text: 'hi' } }, + sink, + ); + expect(usages).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/media/tool.test.ts b/packages/agent-core-v2/src/human/test/media/tool.test.ts new file mode 100644 index 00000000000..06ba1a91a3f --- /dev/null +++ b/packages/agent-core-v2/src/human/test/media/tool.test.ts @@ -0,0 +1,216 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createActor, waitFor } from '#/xstate2'; + +import { createAgentMachine } from '#/agent/machine'; +import { createTurnMachine } from '#/agent/turn'; +import type { ModelCapability } from '#/llm/capability'; +import { + createAssistantMessage, + createUserMessage, + type AssistantMessage, + type Message, + type ToolCall, + type VideoURLPart, +} from '#/llm/message'; +import { createMemoryMediaUploadCache } from '#/llm/media/cache'; +import { createMediaRefResolver } from '#/llm/media/resolver'; +import { createMemoryMediaStore } from '#/llm/media/store'; +import type { LlmModel } from '#/llm/model'; +import { createProvider } from '#/llm/provider/definition'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { openAIFormat } from '#/llm/requester/bases/openai/format'; +import { openAIBase } from '#/llm/requester/bases/openai/requester'; +import { createReadMediaFileTool } from '#/media/tool'; + +const CAPABILITY: ModelCapability = { + image_in: true, + video_in: true, + audio_in: false, + thinking: false, + tool_use: true, +}; + +const tmpDirs: string[] = []; + +function tmpWorkspace(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-core-v3-media-')); + tmpDirs.push(dir); + return dir; +} + +function toolCall(id: string, name: string, args: string): ToolCall { + return { type: 'function', id, name, arguments: args }; +} + +afterEach(() => { + for (const dir of tmpDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('ReadMediaFile tool', () => { + it('stores the file bytes and returns a media ref part', async () => { + const workspaceDir = tmpWorkspace(); + fs.writeFileSync(path.join(workspaceDir, 'pic.png'), new Uint8Array([1, 2, 3])); + const store = createMemoryMediaStore(); + const tool = createReadMediaFileTool({ store, workspaceDir, capability: CAPABILITY }); + + const result = await tool.execute({ + toolCall: toolCall('call-1', 'ReadMediaFile', '{"path":"pic.png"}'), + signal: new AbortController().signal, + }); + + expect(result.isError).toBeUndefined(); + const mediaPart = result.content[1]; + if (mediaPart?.type !== 'image_url') throw new Error('expected an image part'); + expect(mediaPart.imageUrl.url.startsWith('media://')).toBe(true); + const ref = mediaPart.imageUrl.url.slice('media://'.length); + const stored = await store.get(ref); + expect(stored?.bytes).toEqual(new Uint8Array([1, 2, 3])); + expect(stored?.mimeType).toBe('image/png'); + expect(result.content[0]).toEqual({ + type: 'text', + text: ``, + }); + }); + + it('returns an error for a missing file', async () => { + const workspaceDir = tmpWorkspace(); + const tool = createReadMediaFileTool({ + store: createMemoryMediaStore(), + workspaceDir, + capability: CAPABILITY, + }); + + const result = await tool.execute({ + toolCall: toolCall('call-1', 'ReadMediaFile', '{"path":"no-such.png"}'), + signal: new AbortController().signal, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: 'text', text: expect.stringContaining('Failed to read') }]); + }); + + it('rejects a video when the model lacks video input capability', async () => { + const workspaceDir = tmpWorkspace(); + fs.writeFileSync(path.join(workspaceDir, 'movie.mp4'), new Uint8Array([1, 2, 3])); + const tool = createReadMediaFileTool({ + store: createMemoryMediaStore(), + workspaceDir, + capability: { ...CAPABILITY, video_in: false }, + }); + + const result = await tool.execute({ + toolCall: toolCall('call-1', 'ReadMediaFile', '{"path":"movie.mp4"}'), + signal: new AbortController().signal, + }); + + expect(result.isError).toBe(true); + expect(result.content).toEqual([ + { type: 'text', text: expect.stringContaining('does not support video input') }, + ]); + }); +}); + +describe('media stack wiring', () => { + it('resolves the tool message media ref into an uploaded part before generate', async () => { + const workspaceDir = tmpWorkspace(); + fs.writeFileSync(path.join(workspaceDir, 'movie.mp4'), new Uint8Array([1, 2, 3])); + const model: LlmModel = { provider: 'test-media', model: 'test-model', capability: CAPABILITY }; + const uploadedPart: VideoURLPart = { + type: 'video_url', + videoUrl: { url: 'ms://file-1', id: 'file-1' }, + }; + const uploadVideo = vi.fn(async () => uploadedPart); + const provider = createProvider({ + id: 'test-media', + protocols: { openai: { base: openAIBase } }, + media: { uploadVideo }, + }); + const store = createMemoryMediaStore(); + const capability = CAPABILITY; + const tools = + capability.image_in || capability.video_in + ? [createReadMediaFileTool({ store, workspaceDir, capability })] + : []; + expect(tools).toHaveLength(1); + + const seenMessages: (readonly Message[])[] = []; + const responses: readonly AssistantMessage[] = [ + createAssistantMessage( + [], + [toolCall('call-1', 'ReadMediaFile', '{"path":"movie.mp4"}')], + ), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]; + let call = 0; + const requester: LlmRequester = { + generate: (_config, { messages }, { onEvent }) => { + seenMessages.push(messages); + const message = responses[Math.min(call, responses.length - 1)] as AssistantMessage; + call += 1; + for (const part of [...message.content, ...message.toolCalls]) { + onEvent?.({ type: 'llm.delta', part }); + } + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; + + const actor = createActor( + createAgentMachine({ + tools, + turnActor: createTurnMachine( + createLlmMachine({ + requester, + messageResolvers: [ + createMediaRefResolver({ + providers: [provider], + source: store, + cache: createMemoryMediaUploadCache(), + }), + ], + }), + ), + }), + { input: { request: { model } } }, + ); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('watch this') }); + await waitFor(actor, (s) => s.matches('idle') && s.context.messages.length > 1, { + timeout: 5000, + }); + + expect(seenMessages).toHaveLength(2); + const toolMessage = seenMessages[1]?.find((message) => message.role === 'tool'); + expect(toolMessage?.content).toEqual([ + { type: 'text', text: `' }, + ]); + expect(uploadVideo).toHaveBeenCalledTimes(1); + + const wire = openAIFormat.formatRequest({ + model, + messages: seenMessages[1] as readonly Message[], + tools: [], + ctx: { model }, + }); + const wireMessages = wire.params.messages as unknown as Record[]; + const toolWire = wireMessages.find((message) => message['role'] === 'tool'); + expect(String(toolWire?.['content'])).not.toContain('video omitted'); + const mediaUser = wireMessages.find( + (message) => message['role'] === 'user' && Array.isArray(message['content']), + ); + expect(mediaUser?.['content']).toContainEqual({ + type: 'video_url', + video_url: { url: 'ms://file-1', id: 'file-1' }, + }); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/models-dev/models-dev.test.ts b/packages/agent-core-v2/src/human/test/models-dev/models-dev.test.ts new file mode 100644 index 00000000000..1d3bb427452 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/models-dev/models-dev.test.ts @@ -0,0 +1,539 @@ +import { describe, expect, it } from 'vitest'; + +import { modelsDevProviderModels, resolveModelsDevImport } from '#/models-dev/models-dev'; +import type { Provider } from '#/llm/provider/definition'; +import { + createMemoryProviderCatalogStore, + createProviderCatalog, + type CatalogModelDefinition, + type ProviderCatalogRefreshFailed, +} from '#/llm/provider-catalog'; + +function byId( + models: readonly CatalogModelDefinition[], +): Map { + return new Map(models.map((model) => [model.model, model])); +} + +describe('resolveModelsDevImport', () => { + it('resolves the wire and the endpoint decision', () => { + expect(resolveModelsDevImport({ id: 'anthropic', npm: '@ai-sdk/anthropic' })).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + }); + expect(resolveModelsDevImport({ id: 'openai', npm: '@ai-sdk/openai' })).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: false, + }); + expect( + resolveModelsDevImport({ id: 'google-vertex', npm: '@ai-sdk/google-vertex' }), + ).toEqual({ kind: 'ok', wire: 'google-vertex', guessed: false }); + expect(resolveModelsDevImport({ id: 'gemini', npm: '@ai-sdk/google' })).toMatchObject({ + kind: 'ok', + wire: 'google-genai', + }); + expect(resolveModelsDevImport({ id: 'x', type: 'openai_responses' })).toEqual({ + kind: 'needs-base-url', + wire: 'openai_responses', + guessed: false, + }); + expect(resolveModelsDevImport({ id: 'x', type: 'not-a-wire' })).toEqual({ + kind: 'invalid', + reason: 'unknown-explicit-type', + }); + expect( + resolveModelsDevImport({ id: 'x', type: 'kokub', npm: '@ai-sdk/openai-compatible' }), + ).toEqual({ kind: 'invalid', reason: 'unknown-explicit-type' }); + expect( + resolveModelsDevImport({ id: 'amazon-bedrock', npm: '@ai-sdk/amazon-bedrock' }), + ).toEqual({ kind: 'invalid', reason: 'proprietary-sdk' }); + expect(resolveModelsDevImport({ id: 'cohere', npm: '@ai-sdk/cohere' })).toEqual({ + kind: 'invalid', + reason: 'proprietary-sdk', + }); + expect(resolveModelsDevImport({ id: 'xai', npm: '@ai-sdk/xai' })).toEqual({ + kind: 'needs-base-url', + wire: 'openai', + guessed: true, + }); + expect( + resolveModelsDevImport({ + id: 'kimi-for-coding', + npm: '@ai-sdk/anthropic', + api: 'https://api.kimi.com/coding/v1', + }), + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://api.kimi.com/coding', + }); + expect( + resolveModelsDevImport({ + id: 'openrouter', + npm: '@openrouter/ai-sdk-provider', + api: 'https://openrouter.ai/api/v1', + }), + ).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: true, + baseUrl: 'https://openrouter.ai/api/v1', + }); + expect( + resolveModelsDevImport({ + id: 'neon', + npm: '@ai-sdk/openai-compatible', + api: '${NEON_BASE_URL}/v1', + }), + ).toEqual({ kind: 'needs-base-url', wire: 'openai', guessed: false }); + expect(resolveModelsDevImport({ id: 'xai', npm: '@ai-sdk/xai' }, ' https://api.x.ai/v1 ')).toEqual( + { kind: 'ok', wire: 'openai', guessed: true, baseUrl: 'https://api.x.ai/v1' }, + ); + expect( + resolveModelsDevImport( + { id: 'google-vertex-anthropic', npm: '@ai-sdk/google-vertex/anthropic' }, + 'https://gateway.example.test/v1', + ), + ).toEqual({ + kind: 'ok', + wire: 'anthropic', + guessed: false, + baseUrl: 'https://gateway.example.test', + }); + expect( + resolveModelsDevImport( + { id: 'openai', npm: '@ai-sdk/openai', api: 'https://api.openai.com/v1' }, + 'https://proxy.example.test/v1', + ), + ).toEqual({ + kind: 'ok', + wire: 'openai', + guessed: false, + baseUrl: 'https://proxy.example.test/v1', + }); + expect(resolveModelsDevImport({ id: 'x', type: 'openai' }, ' ')).toEqual({ + kind: 'invalid', + reason: 'empty-base-url', + }); + expect( + resolveModelsDevImport({ id: 'x', type: 'openai' }, 'https://${HOST}.example.test'), + ).toEqual({ kind: 'invalid', reason: 'placeholder-base-url' }); + }); +}); + +describe('modelsDevProviderModels', () => { + it('normalizes entries and applies overrides', () => { + const models = byId( + modelsDevProviderModels('openai', { + id: 'openai', + npm: '@ai-sdk/openai', + models: { + 'gpt-5': { + id: 'gpt-5', + name: 'GPT-5', + limit: { context: 400000, input: 272000, output: 128000 }, + reasoning_options: [{ type: 'effort', values: ['low', 'medium', 'high'] }], + modalities: { input: ['text', 'image', 'video', 'audio'], output: ['text'] }, + interleaved: { field: 'reasoning_details' }, + }, + 'grok-4': { + id: 'grok-4', + limit: { context: 256000 }, + reasoning_options: [{ type: 'effort', values: ['none', 'low', 'high'] }], + }, + 'null-tier': { + id: 'null-tier', + limit: { context: 64000 }, + reasoning_options: [{ type: 'effort', values: [null, 'low'] }], + }, + 'gpt-5-pro': { + id: 'gpt-5-pro', + limit: { context: 400000, input: 500000 }, + reasoning_options: [{ type: 'effort', values: ['medium', 'high'] }], + }, + 'toggle-model': { + id: 'toggle-model', + limit: { context: 128000 }, + reasoning: true, + reasoning_options: [{ type: 'toggle' }], + tool_call: false, + }, + 'old-model': { id: 'old-model', limit: { context: 8000 }, status: 'deprecated' }, + 'alpha-model': { id: 'alpha-model', limit: { context: 8000 }, status: 'alpha' }, + 'text-embedding-3': { id: 'text-embedding-3', limit: { context: 8000 } }, + 'image-only': { + id: 'image-only', + limit: { context: 8000 }, + modalities: { output: ['image'] }, + }, + 'no-limit': { id: 'no-limit' }, + }, + }), + ); + const gpt5 = models.get('gpt-5'); + expect(gpt5?.displayName).toBe('GPT-5'); + expect(gpt5?.capability).toEqual({ + image_in: true, + video_in: true, + audio_in: true, + thinking: true, + tool_use: true, + dynamically_loaded_tools: false, + }); + expect(gpt5?.maxContextSize).toBe(400000); + expect(gpt5?.maxInputSize).toBe(272000); + expect(gpt5?.maxOutputSize).toBe(128000); + expect(gpt5?.supportEfforts).toEqual(['low', 'medium', 'high']); + expect(gpt5?.offEffort).toBeUndefined(); + expect(gpt5?.alwaysThinking).toBe(true); + expect(gpt5?.reasoningKey).toBe('reasoning_details'); + const grok = models.get('grok-4'); + expect(grok?.supportEfforts).toEqual(['low', 'high']); + expect(grok?.offEffort).toBe('none'); + expect(models.get('null-tier')?.offEffort).toBe('none'); + const pro = models.get('gpt-5-pro'); + expect(pro?.maxInputSize).toBe(400000); + expect(pro?.alwaysThinking).toBe(true); + const toggle = models.get('toggle-model'); + expect(toggle?.capability.thinking).toBe(true); + expect(toggle?.capability.tool_use).toBe(false); + expect(toggle?.supportEfforts).toBeUndefined(); + expect(models.has('old-model')).toBe(false); + expect(models.has('alpha-model')).toBe(false); + expect(models.has('text-embedding-3')).toBe(false); + expect(models.has('image-only')).toBe(false); + expect(models.has('no-limit')).toBe(false); + const gateway = byId( + modelsDevProviderModels('zenmux', { + id: 'zenmux', + npm: '@ai-sdk/openai-compatible', + api: 'https://zenmux.example.test/api/v1', + models: { + 'claude-via-gateway': { + id: 'claude-via-gateway', + limit: { context: 200000 }, + provider: { + npm: '@ai-sdk/anthropic', + api: 'https://zenmux.example.test/api/anthropic/v1', + }, + }, + 'same-wire-custom-endpoint': { + id: 'same-wire-custom-endpoint', + limit: { context: 32000 }, + provider: { api: 'https://special.example.test/v1' }, + }, + 'placeholder-override': { + id: 'placeholder-override', + limit: { context: 32000 }, + provider: { npm: '@ai-sdk/openai', api: '${PLACEHOLDER}/v1' }, + }, + 'bedrock-override': { + id: 'bedrock-override', + limit: { context: 32000 }, + provider: { npm: '@ai-sdk/amazon-bedrock' }, + }, + }, + }), + ); + const claude = gateway.get('claude-via-gateway'); + expect(claude?.protocol).toBe('anthropic'); + expect(claude?.baseUrl).toBe('https://zenmux.example.test/api/anthropic'); + expect(claude?.supportEfforts).toBeUndefined(); + expect(claude?.capability.thinking).toBe(false); + expect(gateway.get('same-wire-custom-endpoint')?.baseUrl).toBe( + 'https://special.example.test/v1', + ); + expect(gateway.get('same-wire-custom-endpoint')?.protocol).toBeUndefined(); + expect(gateway.has('placeholder-override')).toBe(false); + expect(gateway.has('bedrock-override')).toBe(false); + + const anthropic = byId( + modelsDevProviderModels('anthropic', { + id: 'anthropic', + npm: '@ai-sdk/anthropic', + models: { + 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', limit: { context: 200000 } }, + 'claude-fable-5': { id: 'claude-fable-5', limit: { context: 200000 } }, + 'claude-latest': { id: 'claude-latest', limit: { context: 200000 } }, + 'kimi-k3': { + id: 'kimi-k3', + limit: { context: 262144 }, + reasoning_options: [{ type: 'effort', values: ['low', 'high'] }], + }, + 'glm-5': { id: 'glm-5', limit: { context: 128000 } }, + }, + }), + ); + expect(anthropic.get('claude-sonnet-4-5')?.supportEfforts).toBeUndefined(); + const fable = anthropic.get('claude-fable-5'); + expect(fable?.supportEfforts).toBeUndefined(); + expect(fable?.alwaysThinking).toBeUndefined(); + expect(anthropic.get('claude-latest')?.supportEfforts).toBeUndefined(); + const kimi = anthropic.get('kimi-k3'); + expect(kimi?.supportEfforts).toEqual(['low', 'high']); + expect(kimi?.alwaysThinking).toBeUndefined(); + const glm = anthropic.get('glm-5'); + expect(glm?.supportEfforts).toBeUndefined(); + expect(glm?.capability.thinking).toBe(false); + }); +}); + +describe('providerCatalog', () => { + it('adds, refreshes in one batch, removes, and persists through the store', async () => { + const store = createMemoryProviderCatalogStore(); + await store.save({ + providers: { + openai: { + discovered: {}, + override: {}, + info: { customHeaders: { 'x-keep': '1' } }, + }, + }, + }); + const catalog = await createProviderCatalog({ store }); + expect(catalog.providers()).toEqual(['openai']); + expect(catalog.models('openai')).toEqual([]); + + let addPulls = 0; + const stubProvider = (id: string, protocols: Provider['protocols']): Provider => ({ + id, + protocols, + listModels: async () => { + addPulls += 1; + return []; + }, + resolveModel: () => { + throw new Error('unused'); + }, + createRequester: () => { + throw new Error('unused'); + }, + }); + + const imported = modelsDevProviderModels('openai', { + id: 'openai', + npm: '@ai-sdk/openai', + models: { + 'gpt-5': { + id: 'gpt-5', + limit: { context: 400000, input: 272000 }, + modalities: { input: ['text', 'image'], output: ['text'] }, + reasoning_options: [{ type: 'effort', values: ['low', 'high'] }], + }, + }, + }); + catalog.upsert({ + provider: stubProvider('openai', ['openai']), + info: { customHeaders: { 'x-keep': '2' } }, + models: [ + ...imported.map((model) => ({ + ...model, + overrides: { maxOutputSize: 64000, displayName: 'GPT-5 Turbo' }, + })), + { + provider: 'openai', + model: 'gpt-5-wire', + capability: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }, + maxContextSize: 128000, + name: 'gpt-5-alias', + aliases: ['g5w'], + apiKey: 'sk-test', + defaultHeaders: { 'x-key': 'v' }, + }, + ], + }); + catalog.upsert({ + provider: stubProvider('anthropic', ['anthropic']), + info: { defaultModel: 'claude-sonnet-4-5' }, + models: [ + { + provider: 'anthropic', + model: 'claude-sonnet-4-5', + capability: { + image_in: true, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }, + maxContextSize: 200000, + }, + ], + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(addPulls).toBe(2); + expect(catalog.providerInfo('openai')?.customHeaders).toEqual({ 'x-keep': '2' }); + const fromAdded = byId(catalog.models('openai')).get('gpt-5'); + expect(fromAdded?.capability.image_in).toBe(true); + expect(fromAdded?.maxContextSize).toBe(400000); + expect(fromAdded?.maxInputSize).toBe(272000); + expect(fromAdded?.maxOutputSize).toBe(64000); + expect(fromAdded?.displayName).toBe('GPT-5 Turbo'); + expect(fromAdded?.supportEfforts).toEqual(['low', 'high']); + + const asIs = byId(catalog.models('anthropic')).get('claude-sonnet-4-5'); + expect(asIs?.capability.thinking).toBe(false); + expect(asIs?.supportEfforts).toBeUndefined(); + + const withCredentials = byId(catalog.models('openai')).get('gpt-5-wire'); + expect(withCredentials?.name).toBe('gpt-5-alias'); + expect(withCredentials?.apiKey).toBe('sk-test'); + expect(withCredentials?.defaultHeaders).toEqual({ 'x-key': 'v' }); + expect(catalog.models('openai').map((model) => model.model)).toEqual(['gpt-5', 'gpt-5-wire']); + expect(catalog.models('anthropic').map((model) => model.model)).toEqual(['claude-sonnet-4-5']); + expect(catalog.providerInfo('anthropic')?.defaultModel).toBe('claude-sonnet-4-5'); + + const changes: string[][] = []; + catalog.onChanged((event) => changes.push([...event.providers])); + + let openaiPulls = 0; + let googlePulls = 0; + const provider: Provider = { + id: 'openai', + protocols: ['openai'], + listModels: async () => { + openaiPulls += 1; + return [ + { + provider: 'openai', + model: 'gpt-5', + capability: { + image_in: false, + video_in: true, + audio_in: false, + thinking: true, + tool_use: true, + }, + maxContextSize: 100000, + }, + { + provider: 'openai', + model: 'gpt-mini', + capability: { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }, + maxContextSize: 32000, + baseUrl: 'https://seed.test/v1', + }, + ]; + }, + resolveModel: () => { + throw new Error('unused'); + }, + createRequester: () => { + throw new Error('unused'); + }, + }; + const google: Provider = { + id: 'google', + protocols: ['google-genai'], + listModels: async () => { + googlePulls += 1; + return [ + { + provider: 'google', + model: 'gemini-4', + capability: { + image_in: true, + video_in: false, + audio_in: false, + thinking: false, + tool_use: true, + }, + maxContextSize: 1000000, + }, + ]; + }, + resolveModel: () => { + throw new Error('unused'); + }, + createRequester: () => { + throw new Error('unused'); + }, + }; + catalog.refresh(provider); + catalog.refresh(provider); + catalog.refresh(google); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(openaiPulls).toBe(1); + expect(googlePulls).toBe(1); + expect(changes).toEqual([['google', 'openai']]); + + const merged = byId(catalog.models('openai')).get('gpt-5'); + expect(merged?.capability.image_in).toBe(true); + expect(merged?.capability.video_in).toBe(true); + expect(merged?.capability.thinking).toBe(true); + expect(merged?.maxContextSize).toBe(400000); + expect(merged?.supportEfforts).toEqual(['low', 'high']); + + const discoveredOnly = byId(catalog.models('openai')).get('gpt-mini'); + expect(discoveredOnly?.maxContextSize).toBe(32000); + expect(discoveredOnly?.baseUrl).toBe('https://seed.test/v1'); + expect(byId(catalog.models('google')).get('gemini-4')?.maxContextSize).toBe(1000000); + expect(byId(catalog.models('openai')).has('no-such-model')).toBe(false); + expect(catalog.models('openai').map((model) => model.model)).toEqual([ + 'gpt-5', + 'gpt-5-wire', + 'gpt-mini', + ]); + expect(catalog.providers()).toEqual(['anthropic', 'google', 'openai']); + + const failing: Provider = { + ...provider, + listModels: () => Promise.reject(new Error('boom')), + }; + const failures: ProviderCatalogRefreshFailed[] = []; + catalog.onRefreshFailed((event) => failures.push(event)); + catalog.refresh(failing); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(failures).toHaveLength(1); + expect(failures[0]?.providerId).toBe('openai'); + expect((failures[0]?.error as Error).message).toBe('boom'); + expect(byId(catalog.models('openai')).get('gpt-mini')?.maxContextSize).toBe(32000); + expect(changes).toHaveLength(1); + + catalog.upsert({ + provider: stubProvider('anthropic', ['anthropic']), + info: { defaultModel: 'claude-fable-5' }, + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + expect(catalog.models('anthropic')).toEqual([]); + expect(catalog.providerInfo('anthropic')?.defaultModel).toBe('claude-fable-5'); + expect(changes).toEqual([['google', 'openai'], ['anthropic'], ['anthropic']]); + + catalog.remove('anthropic'); + expect(catalog.providers()).toEqual(['google', 'openai']); + expect(catalog.models('anthropic')).toEqual([]); + expect(changes).toEqual([['google', 'openai'], ['anthropic'], ['anthropic'], ['anthropic']]); + + const reopened = await createProviderCatalog({ store }); + expect(reopened.providers()).toEqual(['google', 'openai']); + expect(reopened.models('openai')).toHaveLength(3); + expect(byId(reopened.models('openai')).get('gpt-5')?.maxContextSize).toBe(400000); + expect(byId(reopened.models('google')).get('gemini-4')?.maxContextSize).toBe(1000000); + expect(reopened.models('anthropic')).toEqual([]); + expect(reopened.providerInfo('openai')?.customHeaders).toEqual({ 'x-keep': '2' }); + expect(reopened.providerInfo('anthropic')).toBeUndefined(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/session/machine.test.ts b/packages/agent-core-v2/src/human/test/session/machine.test.ts new file mode 100644 index 00000000000..ca94bc2f2aa --- /dev/null +++ b/packages/agent-core-v2/src/human/test/session/machine.test.ts @@ -0,0 +1,320 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createActor, waitFor, type ActorRefFrom } from '#/xstate2'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { + createAssistantMessage, + createUserMessage, + extractText, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { emptyUsage } from '#/llm/usage'; +import { createAgentMachine } from '#/agent/machine'; +import { + createAssistantEntry, + createTurnMachine, + createUserEntry, + toInputMessages, + type HistoryMessage, +} from '#/agent/turn'; +import { + createSessionMachine, + type AgentActorRef, +} from '#/session/machine'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type SessionActor = ActorRefFrom>; + +function createEchoRequester(): LlmRequester { + return { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createTestSession(requester: LlmRequester): SessionActor { + const session = createActor( + createSessionMachine({ + agent: createAgentMachine({ + tools: [], + turnActor: createTurnMachine(createLlmMachine({ requester })), + }), + }), + { input: { request: { model } } }, + ); + session.start(); + return session; +} + +function agentRef(session: SessionActor, agentId: string): AgentActorRef { + const entry = session.getSnapshot().context.agents[agentId]; + expect(entry).toBeDefined(); + return (entry as { ref: AgentActorRef }).ref; +} + +function submit(session: SessionActor, agentId: string, text: string): void { + session.send({ + type: 'agent.send', + agentId, + event: { type: 'input.submit', message: createUserMessage(text) }, + }); +} + +async function waitIdle(ref: AgentActorRef, messageCount: number) { + return waitFor( + ref, + (snapshot) => snapshot.matches('idle') && snapshot.context.messages.length === messageCount, + { timeout: 5000 }, + ); +} + +function rolesAndTexts(messages: readonly HistoryMessage[]): string[] { + return toInputMessages(messages).map((message) => `${message.role}:${extractText(message)}`); +} + +describe('session machine agent lifecycle', () => { + it('generates default agent ids for anonymous creates', async () => { + const session = createTestSession(createEchoRequester()); + const created: string[] = []; + session.on('agent.created', (event) => created.push(event.agentId)); + + session.send({ type: 'agent.create' }); + session.send({ type: 'agent.create' }); + + expect(created).toEqual(['agent-1', 'agent-2']); + expect(Object.keys(session.getSnapshot().context.agents).toSorted()).toEqual(['agent-1', 'agent-2']); + }); + + it('creates a agent with restored messages and turnId', async () => { + const session = createTestSession(createEchoRequester()); + const history: HistoryMessage[] = [ + createUserEntry(createUserMessage('old'), { source: 'input' }), + createAssistantEntry(createAssistantMessage([{ type: 'text', text: 'echo:old' }]), { + source: 'llm', + usage: emptyUsage(), + }), + ]; + session.send({ type: 'agent.create', agentId: 'restored', input: { history, turnId: 7 } }); + + const ref = agentRef(session, 'restored'); + expect(ref.getSnapshot().context.turnId).toBe(7); + submit(session, 'restored', 'new'); + const snapshot = await waitIdle(ref, 4); + + expect(snapshot.context.turnId).toBe(8); + expect(rolesAndTexts(snapshot.context.messages)).toEqual([ + 'user:old', + 'assistant:echo:old', + 'user:new', + 'assistant:echo:new', + ]); + }); + + it('rejects a duplicate agent id and keeps the existing agent', async () => { + const session = createTestSession(createEchoRequester()); + const errors: string[] = []; + session.on('agent.error', (event) => errors.push(event.error)); + + session.send({ type: 'agent.create', agentId: 'a' }); + const first = agentRef(session, 'a'); + session.send({ type: 'agent.create', agentId: 'a' }); + + expect(errors).toEqual([`duplicate agent id: 'a'`]); + expect(agentRef(session, 'a')).toBe(first); + }); + + it('stops a agent and removes it from the registry', async () => { + const session = createTestSession(createEchoRequester()); + session.send({ type: 'agent.create', agentId: 'a' }); + const ref = agentRef(session, 'a'); + const stopped: string[] = []; + session.on('agent.stopped', (event) => stopped.push(event.agentId)); + + session.send({ type: 'agent.stop', agentId: 'a' }); + + expect(stopped).toEqual(['a']); + expect(session.getSnapshot().context.agents['a']).toBeUndefined(); + expect(ref.getSnapshot().status).toBe('stopped'); + }); + + it('emits agent.error when routing to an unknown agent', async () => { + const session = createTestSession(createEchoRequester()); + const errors: string[] = []; + session.on('agent.error', (event) => errors.push(event.error)); + + submit(session, 'nope', 'hi'); + session.send({ type: 'agent.stop', agentId: 'nope' }); + + expect(errors).toEqual([`unknown agent: 'nope'`, `unknown agent: 'nope'`]); + }); +}); + +describe('session machine concurrent agents', () => { + it('runs multiple agents at the same time with isolated contexts', async () => { + const seen: string[] = []; + const resolvers = new Map void>(); + const requester: LlmRequester = { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + seen.push(text); + return new Promise((resolve) => { + resolvers.set(text, () => { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + resolve(); + }); + }); + }, + }; + const session = createTestSession(requester); + session.send({ type: 'agent.create', agentId: 'a' }); + session.send({ type: 'agent.create', agentId: 'b' }); + + submit(session, 'a', 'hello-a'); + submit(session, 'b', 'hello-b'); + + await vi.waitFor(() => { + expect(seen.toSorted()).toEqual(['hello-a', 'hello-b']); + }); + expect(agentRef(session, 'a').getSnapshot().value).toEqual({ running: 'active' }); + expect(agentRef(session, 'b').getSnapshot().value).toEqual({ running: 'active' }); + + resolvers.get('hello-a')?.(); + resolvers.get('hello-b')?.(); + const [snapshotA, snapshotB] = await Promise.all([ + waitIdle(agentRef(session, 'a'), 2), + waitIdle(agentRef(session, 'b'), 2), + ]); + + expect(rolesAndTexts(snapshotA.context.messages)).toEqual([ + 'user:hello-a', + 'assistant:echo:hello-a', + ]); + expect(rolesAndTexts(snapshotB.context.messages)).toEqual([ + 'user:hello-b', + 'assistant:echo:hello-b', + ]); + }); +}); + +describe('session machine agent fork', () => { + it('forks a agent with the source context and diverges afterwards', async () => { + const session = createTestSession(createEchoRequester()); + session.send({ type: 'agent.create', agentId: 'a' }); + submit(session, 'a', 'hi'); + const snapshotA = await waitIdle(agentRef(session, 'a'), 2); + expect(snapshotA.context.turnId).toBe(1); + + const forked: string[] = []; + session.on('agent.forked', (event) => forked.push(event.agentId)); + session.send({ type: 'agent.fork', sourceId: 'a', agentId: 'b' }); + expect(forked).toEqual(['b']); + + const refB = agentRef(session, 'b'); + const forkSnapshot = refB.getSnapshot(); + expect(forkSnapshot.value).toEqual({ idle: 'ready' }); + expect(forkSnapshot.context.turnId).toBe(1); + expect(rolesAndTexts(forkSnapshot.context.messages)).toEqual([ + 'user:hi', + 'assistant:echo:hi', + ]); + + submit(session, 'b', 'fork-hi'); + const snapshotB = await waitIdle(refB, 4); + + expect(snapshotB.context.turnId).toBe(2); + expect(rolesAndTexts(snapshotB.context.messages)).toEqual([ + 'user:hi', + 'assistant:echo:hi', + 'user:fork-hi', + 'assistant:echo:fork-hi', + ]); + expect(agentRef(session, 'a').getSnapshot().context.messages).toHaveLength(2); + expect(agentRef(session, 'a').getSnapshot().context.turnId).toBe(1); + }); + + it('emits agent.error when forking an unknown source', async () => { + const session = createTestSession(createEchoRequester()); + const errors: string[] = []; + session.on('agent.error', (event) => errors.push(event.error)); + + session.send({ type: 'agent.fork', sourceId: 'nope', agentId: 'b' }); + + expect(errors).toEqual([`unknown agent: 'nope'`]); + expect(session.getSnapshot().context.agents['b']).toBeUndefined(); + }); +}); + + +describe('session machine agent switch', () => { + it('switches an idle agent to a new branch context and emits agent.switched', async () => { + const session = createTestSession(createEchoRequester()); + session.send({ type: 'agent.create', agentId: 'main' }); + submit(session, 'main', 'hi'); + await waitIdle(agentRef(session, 'main'), 2); + + const switched: Array<{ agentId: string; branchId: string; reason?: string }> = []; + session.on('agent.switched', (event) => { + if (event.type === 'agent.switched') { + switched.push({ agentId: event.agentId, branchId: event.branchId, reason: event.reason }); + } + }); + + session.send({ + type: 'agent.switch', + agentId: 'main', + input: { branchId: 'main~2', history: [], turnId: 0, reason: 'undo' }, + }); + + expect(switched).toEqual([{ agentId: 'main', branchId: 'main~2', reason: 'undo' }]); + const snapshot = agentRef(session, 'main').getSnapshot(); + expect(snapshot.context.branchId).toBe('main~2'); + expect(snapshot.context.messages).toEqual([]); + expect(snapshot.context.turnId).toBe(0); + }); + + it('emits agent.error when switching an unknown or busy agent', async () => { + let release: (() => void) | undefined; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => + new Promise((resolve) => { + release = () => { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'late' } }); + onEvent?.({ type: 'llm.done' }); + resolve(); + }; + }), + }; + const session = createTestSession(requester); + const errors: string[] = []; + session.on('agent.error', (event) => errors.push(event.error)); + session.send({ type: 'agent.create', agentId: 'main' }); + + session.send({ + type: 'agent.switch', + agentId: 'nope', + input: { branchId: 'x', history: [], turnId: 0 }, + }); + + submit(session, 'main', 'hi'); + await vi.waitFor(() => expect(release).toBeDefined()); + session.send({ + type: 'agent.switch', + agentId: 'main', + input: { branchId: 'main~2', history: [], turnId: 0 }, + }); + + expect(errors).toEqual([`unknown agent: 'nope'`, `agent is busy: 'main'`]); + expect(agentRef(session, 'main').getSnapshot().context.branchId).toBe('main'); + release?.(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts b/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts new file mode 100644 index 00000000000..b07679de7e6 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/session/migrate-v2.test.ts @@ -0,0 +1,563 @@ +import { mkdtemp, mkdir, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createActor, waitFor } from '#/xstate2'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage, extractText } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { createAgentMachine } from '#/agent/machine'; +import { createTurnMachine } from '#/agent/turn'; +import { createSessionMachine, type AgentActorRef } from '#/session/machine'; +import { undoAgentTurns } from '#/session/undo'; +import { loadSessionState, persistSession } from '#/persist/session'; +import { openSessionStore } from '#/persist/open'; +import { migrateV2Session } from '#/persist/v2/migrate'; + +const MAIN = 'main'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type SessionActor = ReturnType; + +function createEchoRequester(): LlmRequester { + return { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createTestSession() { + const session = createActor( + createSessionMachine({ + agent: createAgentMachine({ + tools: [], + turnActor: createTurnMachine(createLlmMachine({ requester: createEchoRequester() })), + }), + }), + { input: { request: { model } } }, + ); + session.start(); + return session; +} + +function agentRef(session: SessionActor, agentId: string): AgentActorRef { + const entry = session.getSnapshot().context.agents[agentId]; + expect(entry).toBeDefined(); + return (entry as { ref: AgentActorRef }).ref; +} + +function submit(session: SessionActor, agentId: string, text: string): void { + session.send({ + type: 'agent.send', + agentId, + event: { type: 'input.submit', message: createUserMessage(text) }, + }); +} + +interface V2AgentFixture { + records: Record[]; + blobs?: Record; + header?: string | null; +} + +interface V2SessionFixture { + meta?: Record; + agents: Record; +} + +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +async function makeV2SessionDir(fixture: V2SessionFixture): Promise { + const dir = await mkdtemp(join(tmpdir(), 'v3-migrate-v2-')); + dirs.push(dir); + const meta = fixture.meta ?? { + id: 'session_test', + version: 2, + cwd: '/work', + createdAt: 1700000000000, + updatedAt: 1700000000001, + archived: false, + agents: {}, + custom: {}, + }; + await writeFile(join(dir, 'state.json'), JSON.stringify(meta)); + for (const [agentId, agent] of Object.entries(fixture.agents)) { + const agentDir = join(dir, 'agents', agentId); + await mkdir(agentDir, { recursive: true }); + const header = + agent.header === null + ? [] + : [agent.header ?? JSON.stringify({ type: 'metadata', protocol_version: '1.5', created_at: 1700000000000 })]; + const lines = [...header, ...agent.records.map((record) => JSON.stringify(record))]; + await writeFile(join(agentDir, 'wire.jsonl'), `${lines.join('\n')}\n`); + if (agent.blobs !== undefined) { + const blobsDir = join(agentDir, 'blobs'); + await mkdir(blobsDir, { recursive: true }); + for (const [hash, content] of Object.entries(agent.blobs)) { + await writeFile(join(blobsDir, hash), content); + } + } + } + return dir; +} + +function appendUser(text: string, agentId = MAIN, origin?: unknown): Record { + return { + type: 'context.append_message', + agentId, + time: 1, + message: { role: 'user', content: [{ type: 'text', text }], toolCalls: [], origin: origin ?? { kind: 'user' } }, + }; +} + +function turnPrompt(agentId = MAIN): Record { + return { type: 'turn.prompt', agentId, time: 1, input: [{ type: 'text', text: 'x' }], origin: { kind: 'user' } }; +} + +function stepBegin(uuid: string, agentId = MAIN): Record { + return { type: 'context.append_loop_event', agentId, time: 1, event: { type: 'step.begin', uuid, turnId: '0' } }; +} + +function contentPart(stepUuid: string, text: string, agentId = MAIN): Record { + return { type: 'context.append_loop_event', agentId, time: 1, event: { type: 'content.part', stepUuid, part: { type: 'text', text } } }; +} + +function toolCall(stepUuid: string, toolCallId: string, name: string, args: unknown, agentId = MAIN): Record { + return { type: 'context.append_loop_event', agentId, time: 1, event: { type: 'tool.call', stepUuid, toolCallId, name, args } }; +} + +function toolResult(toolCallId: string, output: unknown, agentId = MAIN): Record { + return { type: 'context.append_loop_event', agentId, time: 1, event: { type: 'tool.result', toolCallId, result: { output } } }; +} + +function stepEnd(uuid: string, extra: Record = {}, agentId = MAIN): Record { + return { type: 'context.append_loop_event', agentId, time: 1, event: { type: 'step.end', uuid, ...extra } }; +} + +function assistantStep(uuid: string, text: string, agentId = MAIN): Record[] { + return [stepBegin(uuid, agentId), contentPart(uuid, text, agentId), stepEnd(uuid, { finishReason: 'end_turn' }, agentId)]; +} + +async function loadMigrated(dir: string): Promise>> { + await migrateV2Session(dir); + const opened = await openSessionStore(dir); + return loadSessionState(opened.tree); +} + +describe('migrateV2Session', () => { + it('migrates a basic conversation with assistant meta, turn counter and session meta', async () => { + const dir = await makeV2SessionDir({ + meta: { + id: 'session_basic', + version: 2, + cwd: '/work', + createdAt: 1700000000000, + updatedAt: 1700000000001, + archived: false, + title: 'My Session', + titleKind: 'custom', + isCustomTitle: true, + agents: {}, + custom: {}, + }, + agents: { + [MAIN]: { + records: [ + turnPrompt(), + appendUser('hi'), + { type: 'llm.request', agentId: MAIN, time: 1, kind: 'loop', provider: 'prov', model: 'mod', toolSelect: 'auto', systemPromptHash: 'h', toolsHash: 't', messageCount: 1 }, + stepBegin('s1'), + contentPart('s1', 'hello '), + contentPart('s1', 'world'), + toolCall('s1', 'c1', 'bash', { cmd: 'ls' }), + toolResult('c1', 'file.txt'), + stepEnd('s1', { + finishReason: 'tool_use', + usage: { inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }, + rawFinishReason: 'stop_raw', + messageId: 'msg_v2_1', + }), + stepBegin('s2'), + contentPart('s2', 'second'), + stepEnd('s2', { finishReason: 'end_turn' }), + { type: 'turn.ended', agentId: MAIN, time: 1, turnId: 0, reason: 'completed' }, + ], + }, + }, + }); + const loaded = await loadMigrated(dir); + expect(loaded.agents.map((agent) => agent.agentId)).toEqual([MAIN]); + const agent = loaded.agents[0]!; + expect(agent.messages.map((entry) => entry.message.role)).toEqual([ + 'user', + 'assistant', + 'tool', + 'assistant', + ]); + expect(agent.messages.map((entry) => extractText(entry.message))).toEqual([ + 'hi', + 'hello world', + 'file.txt', + 'second', + ]); + expect(agent.messages[0]?.meta.source).toBe('input'); + const first = agent.messages[1]; + expect(first?.message.role).toBe('assistant'); + if (first?.message.role === 'assistant') { + expect(first.message.content).toEqual([ + { type: 'text', text: 'hello ' }, + { type: 'text', text: 'world' }, + ]); + expect(first.message.toolCalls).toEqual([ + { type: 'function', id: 'c1', name: 'bash', arguments: '{"cmd":"ls"}' }, + ]); + expect(first.meta.usage).toEqual({ inputOther: 1, output: 2, inputCacheRead: 3, inputCacheCreation: 4 }); + expect(first.meta.finish).toEqual({ finishReason: 'tool_calls', rawFinishReason: 'stop_raw' }); + expect(first.meta.messageId).toBe('msg_v2_1'); + expect(first.meta.model).toEqual({ provider: 'prov', model: 'mod' }); + } + expect(agent.messages[2]?.meta.source).toBe('tool'); + const second = agent.messages[3]; + if (second?.message.role === 'assistant') { + expect(second.meta.finish).toEqual({ finishReason: 'completed', rawFinishReason: null }); + } + expect(agent.turnId).toBe(0); + expect(loaded.meta).toMatchObject({ + id: 'session_basic', + version: 2, + cwd: '/work', + title: 'My Session', + titleKind: 'custom', + archived: false, + }); + expect(loaded.meta).not.toHaveProperty('isCustomTitle'); + }); + + it('settles an interrupted tail and synthesizes missing tool results', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + turnPrompt(), + appendUser('do'), + stepBegin('s1'), + contentPart('s1', 'working'), + toolCall('s1', 'c1', 'bash', { cmd: 'x' }), + ], + }, + }, + }); + const loaded = await loadMigrated(dir); + const agent = loaded.agents[0]!; + expect(agent.messages.map((entry) => entry.message.role)).toEqual(['user', 'assistant', 'tool']); + expect(extractText(agent.messages[2]!.message)).toBe( + 'Tool execution was interrupted before its result was recorded. Do not assume the tool completed successfully.', + ); + const assistant = agent.messages[1]; + if (assistant?.message.role === 'assistant') { + expect(assistant.message.toolCalls.map((call) => call.id)).toEqual(['c1']); + } + }); + + it('materializes undo and stops at a compaction boundary', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + turnPrompt(), + appendUser('one'), + ...assistantStep('s1', 'a1'), + appendUser('two'), + ...assistantStep('s2', 'a2'), + { type: 'context.undo', agentId: MAIN, time: 1, count: 1 }, + ], + }, + }, + }); + const loaded = await loadMigrated(dir); + expect(loaded.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual(['one', 'a1']); + + const boundary = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + turnPrompt(), + appendUser('old'), + { + type: 'context.apply_compaction', + agentId: MAIN, + time: 1, + summary: 'SUM', + compactedCount: 1, + keptUserMessageCount: 1, + }, + appendUser('new'), + { type: 'context.undo', agentId: MAIN, time: 1, count: 2 }, + ], + }, + }, + }); + const loadedBoundary = await loadMigrated(boundary); + expect(loadedBoundary.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual([ + 'old', + 'SUM', + 'new', + ]); + }); + + it('materializes compaction in modern, elided and legacy shapes', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + appendUser('u1'), + ...assistantStep('s1', 'a1'), + appendUser('u2', MAIN, { kind: 'task', taskId: 't1', status: 'done', notificationId: 'n1' }), + appendUser('u3'), + { + type: 'context.apply_compaction', + agentId: MAIN, + time: 1, + summary: 'S', + contextSummary: 'CTX', + compactedCount: 3, + keptUserMessageCount: 2, + }, + ], + }, + }, + }); + const loaded = await loadMigrated(dir); + const agent = loaded.agents[0]!; + expect(agent.messages.map((entry) => extractText(entry.message))).toEqual(['u1', 'u3', 'CTX']); + expect(agent.messages.map((entry) => entry.meta.source)).toEqual(['input', 'input', 'compaction_summary']); + + const big = 'x'.repeat(90_000); + const elided = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + appendUser(big), + { + type: 'context.apply_compaction', + agentId: MAIN, + time: 1, + summary: 'BIG-SUM', + compactedCount: 1, + keptUserMessageCount: 2, + }, + ], + }, + }, + }); + const loadedElided = await loadMigrated(elided); + const elidedMessages = loadedElided.agents[0]!.messages; + expect(elidedMessages.map((entry) => entry.meta.source)).toEqual([ + 'input', + 'injection', + 'input', + 'compaction_summary', + ]); + expect(extractText(elidedMessages[0]!.message)).toHaveLength(8_000); + expect(extractText(elidedMessages[1]!.message)).toContain('roughly 2499 tokens'); + expect(extractText(elidedMessages[2]!.message)).toHaveLength(72_000); + expect(extractText(elidedMessages[3]!.message)).toBe('BIG-SUM'); + + const legacy = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + appendUser('u1'), + appendUser('u2'), + appendUser('u3'), + { type: 'context.apply_compaction', agentId: MAIN, time: 1, summary: 'LEG', compactedCount: 2 }, + ], + }, + }, + }); + const loadedLegacy = await loadMigrated(legacy); + expect(loadedLegacy.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual(['LEG', 'u3']); + }); + + it('migrates multiple agents with todo state', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { records: [turnPrompt(), appendUser('main-msg')] }, + 'agent-1': { + records: [ + turnPrompt('agent-1'), + appendUser('sub', 'agent-1', { kind: 'task', taskId: 't1', status: 'done', notificationId: 'n1' }), + { + type: 'tools.update_store', + agentId: 'agent-1', + time: 1, + key: 'todo', + value: [ + { title: 'task a', status: 'in_progress' }, + { title: 'missing status' }, + ], + }, + ], + }, + }, + }); + const loaded = await loadMigrated(dir); + expect(loaded.agents.map((agent) => agent.agentId)).toEqual(['agent-1', MAIN]); + const sub = loaded.agents[0]!; + expect(sub.messages[0]?.meta.source).toBe('task'); + expect(sub.states['todo']).toEqual({ + todos: [{ title: 'task a', status: 'in_progress' }], + lastWriteTurn: 0, + }); + }); + + it('resolves blobrefs inline and substitutes missing media', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [ + { + type: 'context.append_message', + agentId: MAIN, + time: 1, + message: { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url: 'blobref:image/png;hash1' } }], + toolCalls: [], + }, + }, + { + type: 'context.append_message', + agentId: MAIN, + time: 1, + message: { + role: 'user', + content: [{ type: 'image_url', imageUrl: { url: 'blobref:image/png;nohash' } }], + toolCalls: [], + }, + }, + ], + blobs: { hash1: 'fakepng' }, + }, + }, + }); + const loaded = await loadMigrated(dir); + const agent = loaded.agents[0]!; + const first = agent.messages[0]?.message.content[0]; + expect(first).toEqual({ + type: 'image_url', + imageUrl: { url: `data:image/png;base64,${Buffer.from('fakepng').toString('base64')}` }, + }); + const second = agent.messages[1]?.message.content[0]; + expect(second).toEqual({ type: 'image_url', imageUrl: { url: '[media missing]' } }); + }); + + it('handles legacy protocol versions and rejects newer ones', async () => { + const headerless = await makeV2SessionDir({ + agents: { [MAIN]: { header: null, records: [appendUser('legacy')] } }, + }); + const loadedHeaderless = await loadMigrated(headerless); + expect(loadedHeaderless.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual(['legacy']); + + const v10 = await makeV2SessionDir({ + agents: { + [MAIN]: { + header: JSON.stringify({ type: 'metadata', protocol_version: '1.0', created_at: 1700000000000 }), + records: [ + { + type: 'context.append_message', + agentId: MAIN, + time: 1, + message: { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c1', function: { name: 'bash', arguments: '{"a":1}' } }], + }, + }, + ], + }, + }, + }); + const loadedV10 = await loadMigrated(v10); + const assistant = loadedV10.agents[0]!.messages[0]; + if (assistant?.message.role === 'assistant') { + expect(assistant.message.toolCalls).toEqual([ + { type: 'function', id: 'c1', name: 'bash', arguments: '{"a":1}' }, + ]); + } + + const newer = await makeV2SessionDir({ + agents: { + [MAIN]: { + header: JSON.stringify({ type: 'metadata', protocol_version: '9.9', created_at: 1700000000000 }), + records: [appendUser('future')], + }, + }, + }); + await expect(migrateV2Session(newer)).rejects.toMatchObject({ code: 'unsupported-wire-version' }); + }); + + it('openSessionStore migrates once, and the restored session continues and undoes', async () => { + const dir = await makeV2SessionDir({ + agents: { + [MAIN]: { + records: [turnPrompt(), appendUser('first'), ...assistantStep('s1', 'first-reply')], + }, + }, + }); + const first = await openSessionStore(dir); + expect(first.migrated).toBe(true); + + const loaded = await loadSessionState(first.tree); + const session = createTestSession(); + const persistence = persistSession(session, first.tree, { + branches: new Map(loaded.agents.map((agent) => [agent.agentId, agent.branch])), + }); + for (const agent of loaded.agents) { + session.send({ + type: 'agent.create', + agentId: agent.agentId, + input: { history: agent.messages, turnId: agent.turnId, branchId: agent.branch }, + }); + } + submit(session, MAIN, 'again'); + await waitFor(agentRef(session, MAIN), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + + const undone = await undoAgentTurns(session, first.tree, MAIN, 1); + expect(undone.messages.map((entry) => extractText(entry.message))).toEqual(['first', 'first-reply']); + await persistence.flush(); + persistence.dispose(); + session.stop(); + + const second = await openSessionStore(dir); + expect(second.migrated).toBe(false); + const reloaded = await loadSessionState(second.tree); + expect(reloaded.agents.map((agent) => agent.agentId)).toEqual([MAIN]); + expect(reloaded.agents[0]!.messages.map((entry) => extractText(entry.message))).toEqual([ + 'first', + 'first-reply', + ]); + const names = await readdir(dir); + expect(names.filter((name) => name.startsWith('.v3-migrate'))).toEqual([]); + expect(names).toContain('state.json'); + expect(names).toContain('agents'); + expect(names).toContain('trees'); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/session/persist.test.ts b/packages/agent-core-v2/src/human/test/session/persist.test.ts new file mode 100644 index 00000000000..b3e6e53f9db --- /dev/null +++ b/packages/agent-core-v2/src/human/test/session/persist.test.ts @@ -0,0 +1,500 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createActor, waitFor, type ActorRefFrom } from '#/xstate2'; + +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createAssistantMessage, createUserMessage, extractText, type AssistantMessage, type Message, type ToolCall } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import { createAgentMachine } from '#/agent/machine'; +import { createTurnMachine, toInputMessages, type HistoryMessage } from '#/agent/turn'; +import { + createSessionMachine, + type AgentActorRef, +} from '#/session/machine'; +import { undoAgentTurns } from '#/session/undo'; +import { loadSessionState, persistSession } from '#/persist/session'; +import { collectPluginTools, connectPlugins } from '#/plugin'; +import { createTodoPlugin } from '#/todo/plugin'; +import { restoreTodoState, snapshotTodoState } from '#/todo/state'; +import { MemoryBackend } from '#/store/backend/memory'; +import type { Branch } from '#/store/branch'; +import type { Tree } from '#/store/tree'; +import { TreeStore } from '#/store/store'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +type SessionActor = ActorRefFrom>; + +function toolCall(id: string, name: string, args: string = '{}'): ToolCall { + return { type: 'function', id, name, arguments: args }; +} + +function createStubRequester(responses: readonly AssistantMessage[]): LlmRequester { + let call = 0; + return { + generate: (_config, _content, { onEvent }) => { + const message = responses[Math.min(call, responses.length - 1)]; + call += 1; + for (const part of [...message.content, ...message.toolCalls]) { + onEvent?.({ type: 'llm.delta', part }); + } + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createEchoRequester(): LlmRequester { + return { + generate: (_config, { messages }, { onEvent }) => { + const last = messages.at(-1); + const text = last !== undefined && last.role === 'user' ? extractText(last) : ''; + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: `echo:${text}` } }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; +} + +function createTestSession(requester: LlmRequester): SessionActor { + const session = createActor( + createSessionMachine({ + agent: createAgentMachine({ + tools: [], + turnActor: createTurnMachine(createLlmMachine({ requester })), + }), + }), + { input: { request: { model } } }, + ); + session.start(); + return session; +} + +function agentRef(session: SessionActor, agentId: string): AgentActorRef { + const entry = session.getSnapshot().context.agents[agentId]; + expect(entry).toBeDefined(); + return (entry as { ref: AgentActorRef }).ref; +} + +function submit(session: SessionActor, agentId: string, text: string): void { + session.send({ + type: 'agent.send', + agentId, + event: { type: 'input.submit', message: createUserMessage(text) }, + }); +} + +function chainMessages(branch: Branch): Message[] { + return toInputMessages( + [...branch.walk()] + .toReversed() + .filter((entry) => entry.type === 'message') + .map((entry) => entry.payload.data as HistoryMessage), + ); +} + +async function restoreSession( + tree: Tree, +): Promise<{ + session: SessionActor; + loaded: Awaited>; + persistence: ReturnType; +}> { + const loaded = await loadSessionState(tree); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree, { + branches: new Map(loaded.agents.map((agent) => [agent.agentId, agent.branch])), + }); + for (const agent of loaded.agents) { + session.send({ + type: 'agent.create', + agentId: agent.agentId, + input: { history: agent.messages, turnId: agent.turnId, branchId: agent.branch }, + }); + } + return { session, loaded, persistence }; +} + +describe('persistSession', () => { + it('persists and restores a full session with a forked agent', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree); + + session.send({ type: 'agent.create', agentId: 'main' }); + submit(session, 'main', 'hi'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + await persistence.flush(); + + session.send({ type: 'agent.fork', sourceId: 'main', agentId: 'fork' }); + submit(session, 'fork', 'fork-hi'); + submit(session, 'main', 'main-hi'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + await waitFor(agentRef(session, 'fork'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + await persistence.flush(); + + expect(tree.branches()).toEqual(['_session', 'fork', 'main']); + const mainBranch = tree.openBranch('main'); + const forkBranch = tree.openBranch('fork'); + expect(forkBranch.header.parentBranch).toBe('main'); + expect(forkBranch.header.parentSeq).toBe(3); + expect(chainMessages(mainBranch).map(extractText)).toEqual([ + 'hi', + 'echo:hi', + 'main-hi', + 'echo:main-hi', + ]); + expect(chainMessages(forkBranch).map(extractText)).toEqual([ + 'hi', + 'echo:hi', + 'fork-hi', + 'echo:fork-hi', + ]); + expect(forkBranch.nextSeq).toBe(4); + + await persistence.flush(); + persistence.dispose(); + session.stop(); + + const reopened = await TreeStore.open(fs); + const { session: restored, loaded, persistence: restoredPersistence } = await restoreSession( + await reopened.tree('sess'), + ); + expect(loaded.agents.map((agent) => agent.agentId).sort()).toEqual(['fork', 'main']); + for (const agent of loaded.agents) { + expect(agent.turnId).toBe(2); + expect(agentRef(restored, agent.agentId).getSnapshot().context.messages).toEqual(agent.messages); + } + + submit(restored, 'main', 'again'); + await waitFor(agentRef(restored, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { + timeout: 5000, + }); + await restoredPersistence.flush(); + const mainMessages = chainMessages((await reopened.tree('sess')).openBranch('main')); + expect(mainMessages.map(extractText)).toEqual([ + 'hi', + 'echo:hi', + 'main-hi', + 'echo:main-hi', + 'again', + 'echo:again', + ]); + }); + + it('skips cleanly stopped agents on restore', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree); + + session.send({ type: 'agent.create', agentId: 'main' }); + session.send({ type: 'agent.create', agentId: 'temp' }); + submit(session, 'main', 'hi'); + submit(session, 'temp', 'temp-hi'); + await waitFor(agentRef(session, 'temp'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + session.send({ type: 'agent.stop', agentId: 'temp' }); + await persistence.flush(); + + const logEntries = [...tree.openBranch('_session').walk()] + .toReversed() + .map((entry) => `${entry.type}:${(entry.payload.data as { agentId: string }).agentId}`); + expect(logEntries).toEqual(['agent.open:main', 'agent.open:temp', 'agent.close:temp']); + + persistence.dispose(); + session.stop(); + + const reopened = await TreeStore.open(fs); + const loaded = await loadSessionState(await reopened.tree('sess')); + expect(loaded.agents.map((agent) => agent.agentId)).toEqual(['main']); + }); + + it('gives a recreated anonymous agent a fresh branch', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree); + + session.send({ type: 'agent.create' }); + submit(session, 'agent-1', 'hi'); + await waitFor(agentRef(session, 'agent-1'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + session.send({ type: 'agent.stop', agentId: 'agent-1' }); + await persistence.flush(); + persistence.dispose(); + session.stop(); + + const reopened = await TreeStore.open(fs); + const loaded = await loadSessionState(await reopened.tree('sess')); + expect(loaded.agents).toEqual([]); + + const restored = createTestSession(createEchoRequester()); + const restoredPersistence = persistSession(restored, await reopened.tree('sess')); + restored.send({ type: 'agent.create', input: { branchId: 'agent-1~2' } }); + submit(restored, 'agent-1', 'fresh'); + await waitFor(agentRef(restored, 'agent-1'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + await restoredPersistence.flush(); + + const branches = (await reopened.tree('sess')).branches(); + expect(branches).toEqual(['_session', 'agent-1', 'agent-1~2']); + expect(chainMessages((await reopened.tree('sess')).openBranch('agent-1~2')).map(extractText)).toEqual([ + 'fresh', + 'echo:fresh', + ]); + }); +}); + + +describe('undoAgentTurns', () => { + it('undoes the last turn by switching to a forked branch and restores it after reopen', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree); + + session.send({ type: 'agent.create', agentId: 'main' }); + submit(session, 'main', 'first'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + submit(session, 'main', 'second'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + await persistence.flush(); + + const result = await undoAgentTurns(session, tree, 'main', 1); + expect(result.branchId).toBe('main~2'); + expect(result.turnId).toBe(1); + expect(toInputMessages(result.messages).map(extractText)).toEqual(['first', 'echo:first']); + + const snapshot = agentRef(session, 'main').getSnapshot(); + expect(snapshot.context.branchId).toBe('main~2'); + expect(snapshot.context.turnId).toBe(1); + expect(snapshot.context.messages).toHaveLength(2); + + const undoBranch = tree.openBranch('main~2'); + expect(undoBranch.header.parentBranch).toBe('main'); + expect(undoBranch.header.parentSeq).toBe(3); + expect(chainMessages(tree.openBranch('main')).map(extractText)).toEqual([ + 'first', + 'echo:first', + 'second', + 'echo:second', + ]); + + submit(session, 'main', 'third'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + await persistence.flush(); + expect(chainMessages(undoBranch).map(extractText)).toEqual([ + 'first', + 'echo:first', + 'third', + 'echo:third', + ]); + expect(chainMessages(tree.openBranch('main')).map(extractText)).toHaveLength(4); + + const logEntries = [...tree.openBranch('_session').walk()].toReversed(); + expect(logEntries.map((entry) => entry.type)).toEqual(['agent.open', 'agent.switch']); + expect(logEntries[1]?.payload.data).toMatchObject({ + agentId: 'main', + branch: 'main~2', + reason: 'undo', + from: { branch: 'main', seq: 7 }, + }); + + persistence.dispose(); + session.stop(); + + const reopened = await TreeStore.open(fs); + const { session: restored, loaded } = await restoreSession(await reopened.tree('sess')); + expect(loaded.agents).toHaveLength(1); + expect(loaded.agents[0]?.branch).toBe('main~2'); + expect(loaded.agents[0]?.turnId).toBe(2); + expect(toInputMessages(loaded.agents[0]?.messages ?? []).map(extractText)).toEqual([ + 'first', + 'echo:first', + 'third', + 'echo:third', + ]); + expect(agentRef(restored, 'main').getSnapshot().context.branchId).toBe('main~2'); + }); + + it('rejects undo when anchors are insufficient or the agent is busy', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + let release: (() => void) | undefined; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => + new Promise((resolve) => { + release = () => { + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'late' } }); + onEvent?.({ type: 'llm.done' }); + resolve(); + }; + }), + }; + const session = createTestSession(requester); + persistSession(session, tree); + session.send({ type: 'agent.create', agentId: 'main' }); + + await expect(undoAgentTurns(session, tree, 'main', 1)).rejects.toMatchObject({ + reason: 'insufficient', + }); + + submit(session, 'main', 'hi'); + await vi.waitFor(() => expect(release).toBeDefined()); + await expect(undoAgentTurns(session, tree, 'main', 1)).rejects.toMatchObject({ reason: 'busy' }); + release?.(); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle'), { timeout: 5000 }); + + await expect(undoAgentTurns(session, tree, 'main', 5)).rejects.toMatchObject({ + reason: 'insufficient', + }); + await expect(undoAgentTurns(session, tree, 'nope', 1)).rejects.toMatchObject({ + reason: 'unknown-agent', + }); + await expect(undoAgentTurns(session, tree, 'main', 0)).rejects.toMatchObject({ + reason: 'invalid-count', + }); + }); + + it('rolls back plugin state from the undo result', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const todo = createTodoPlugin(); + const tools = collectPluginTools([todo]); + const requester = createStubRequester([ + createAssistantMessage( + [], + [ + toolCall( + 'call-todo-1', + 'TodoList', + JSON.stringify({ todos: [{ title: 'task a', status: 'in_progress' }] }), + ), + ], + ), + createAssistantMessage([{ type: 'text', text: 'noted' }]), + createAssistantMessage( + [], + [ + toolCall( + 'call-todo-2', + 'TodoList', + JSON.stringify({ todos: [{ title: 'task a', status: 'done' }] }), + ), + ], + ), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]); + const session = createActor( + createSessionMachine({ + agent: createAgentMachine({ + tools, + turnActor: createTurnMachine(createLlmMachine({ requester })), + }), + }), + { input: { request: { model } } }, + ); + session.start(); + persistSession(session, tree, { + states: () => ({ todo: () => snapshotTodoState(todo.state) }), + }); + session.send({ type: 'agent.create', agentId: 'main' }); + connectPlugins(agentRef(session, 'main'), [todo]); + + submit(session, 'main', 'hi'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + expect(todo.state.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); + + submit(session, 'main', 'finish'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 8, { + timeout: 5000, + }); + expect(todo.state.todos).toEqual([{ title: 'task a', status: 'done' }]); + + const result = await undoAgentTurns(session, tree, 'main', 1); + expect(result.states['todo']).toMatchObject({ + todos: [{ title: 'task a', status: 'in_progress' }], + lastWriteTurn: 1, + }); + + Object.assign(todo.state, restoreTodoState(result.states['todo'], result.turnId)); + expect(todo.state.todos).toEqual([{ title: 'task a', status: 'in_progress' }]); + expect(todo.state.currentTurn).toBe(1); + }); + + it('undoes twice by descending the parent chain', async () => { + const fs = new MemoryBackend(); + const store = await TreeStore.open(fs); + const tree = await store.tree('sess'); + const session = createTestSession(createEchoRequester()); + const persistence = persistSession(session, tree); + + session.send({ type: 'agent.create', agentId: 'main' }); + submit(session, 'main', 'one'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 2, { + timeout: 5000, + }); + submit(session, 'main', 'two'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 4, { + timeout: 5000, + }); + submit(session, 'main', 'three'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { + timeout: 5000, + }); + await persistence.flush(); + + const first = await undoAgentTurns(session, tree, 'main', 1); + expect(first.branchId).toBe('main~2'); + expect(toInputMessages(first.messages).map(extractText)).toEqual([ + 'one', + 'echo:one', + 'two', + 'echo:two', + ]); + + submit(session, 'main', 'three-alt'); + await waitFor(agentRef(session, 'main'), (s) => s.matches('idle') && s.context.messages.length === 6, { + timeout: 5000, + }); + await persistence.flush(); + + const second = await undoAgentTurns(session, tree, 'main', 2); + expect(second.branchId).toBe('main~3'); + expect(second.turnId).toBe(1); + expect(toInputMessages(second.messages).map(extractText)).toEqual(['one', 'echo:one']); + const secondBranch = tree.openBranch('main~3'); + expect(secondBranch.header.parentBranch).toBe('main'); + expect(secondBranch.header.parentSeq).toBe(3); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/store/codec.test.ts b/packages/agent-core-v2/src/human/test/store/codec.test.ts new file mode 100644 index 00000000000..74d45e8a6f6 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/store/codec.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { encodeHeader, encodeLine, parseHeader, parseLine } from '#/store/internal/codec'; +import type { BranchHeader, EntryLine } from '#/store/types'; + +describe('codec header', () => { + it('round-trips a minimal header', () => { + const header: BranchHeader = { version: 1, tree: 'chat', branch: 'main', createdAt: 1788300000000 }; + expect(parseHeader(encodeHeader(header))).toEqual({ ok: true, value: header }); + }); + + it('round-trips a fork header', () => { + const header: BranchHeader = { + version: 1, + tree: 'chat', + branch: 'fork', + createdAt: 1, + parentBranch: 'main', + parentSeq: 7, + }; + expect(parseHeader(encodeHeader(header))).toEqual({ ok: true, value: header }); + }); + + it('rejects invalid json', () => { + const result = parseHeader('{not json'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('syntax'); + }); + + it('rejects a non-header line', () => { + const result = parseHeader('{"kind":"entry"}'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('schema'); + }); + + it('rejects an unsupported version', () => { + const result = parseHeader('{"kind":"header","version":2,"tree":"a","branch":"b","createdAt":1}'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('schema'); + }); + + it('rejects a header without branch', () => { + const result = parseHeader('{"kind":"header","version":1,"tree":"a","createdAt":1}'); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('schema'); + }); +}); + +describe('codec line', () => { + const entry: EntryLine = { + kind: 'entry', + seq: 2, + ts: 100, + type: 'chat.message', + payload: { kind: 'text', size: 5, data: 'hello' }, + }; + + it('round-trips an entry', () => { + const encoded = encodeLine(entry); + expect(encoded.endsWith('\n')).toBe(true); + expect(parseLine(encoded, 2)).toEqual({ ok: true, value: entry }); + }); + + it('round-trips an offloaded payload', () => { + const offloaded: EntryLine = { ...entry, payload: { kind: 'json', size: 99999, ref: 'abc123' } }; + expect(parseLine(encodeLine(offloaded), 2)).toEqual({ ok: true, value: offloaded }); + }); + + it('detects a seq mismatch', () => { + const result = parseLine(encodeLine(entry), 5); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('seq'); + }); + + it('rejects invalid json', () => { + const result = parseLine('{"kind":"entry","seq":', 2); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('syntax'); + }); + + it('rejects an unknown kind', () => { + const result = parseLine('{"kind":"mystery","seq":2,"ts":1}', 2); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('schema'); + }); + + it('rejects a payload without data or ref', () => { + const result = parseLine( + '{"kind":"entry","seq":2,"ts":1,"type":"a.b","payload":{"kind":"t","size":1}}', + 2, + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.kind).toBe('schema'); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/store/store.test.ts b/packages/agent-core-v2/src/human/test/store/store.test.ts new file mode 100644 index 00000000000..f9efb58a782 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/store/store.test.ts @@ -0,0 +1,356 @@ +import { describe, expect, it } from 'vitest'; + +import { MemoryBackend } from '#/store/backend/memory'; +import { TreeStore } from '#/store/store'; +import type { CorruptionReport, EntryLine } from '#/store/types'; + +function openStore(backend: MemoryBackend = new MemoryBackend(), opts?: { offloadThreshold?: number }) { + return TreeStore.open(backend, opts); +} + +function entryData(text: string) { + return { type: 'chat.message', kind: 'text', data: { text } }; +} + +function refOf(entry: EntryLine): string { + if (!('ref' in entry.payload)) throw new Error('expected an offloaded payload'); + return entry.payload.ref; +} + +const HEADER = '{"kind":"header","version":1,"tree":"chat","branch":"main","createdAt":1}'; + +function entryJson(seq: number, text: string): string { + return `{"kind":"entry","seq":${seq},"ts":${seq + 1},"type":"chat.message","payload":{"kind":"text","size":1,"data":"${text}"}}`; +} + +function fileOf(backend: MemoryBackend, tree: string, branch: string): string { + const content = backend.trees.files.get(tree)?.get(branch); + expect(content).toBeDefined(); + return content as string; +} + +describe('append', () => { + it('appends linearly within a branch file', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + const first = await main.append(entryData('a')); + const second = await main.append(entryData('b')); + expect(first.seq).toBe(0); + expect(second.seq).toBe(1); + expect(main.head).toBe(1); + expect(main.nextSeq).toBe(2); + expect(fileOf(backend, 'chat', 'main').trim().split('\n')).toHaveLength(3); + }); + + it('rejects appends on a degraded branch', async () => { + const backend = new MemoryBackend(); + backend.trees.files.set('chat', new Map([['main', `${HEADER}\n${entryJson(0, 'a')}\n${entryJson(5, 'b')}\n`]])); + const store = await openStore(backend); + const main = (await store.tree('chat')).openBranch('main'); + expect(main.degraded).toBe(true); + await expect(main.append(entryData('x'))).rejects.toThrow('degraded'); + }); +}); + +describe('branch management', () => { + it('rejects duplicate, invalid, and unknown branches', async () => { + const store = await openStore(); + const tree = await store.tree('chat'); + tree.createBranch('main'); + expect(() => tree.createBranch('main')).toThrow('already exists'); + expect(() => tree.createBranch('bad name')).toThrow('invalid branch name'); + expect(() => tree.openBranch('nope')).toThrow('unknown branch'); + expect(() => tree.createBranch('x', { from: { branch: 'nope', seq: 0 } })).toThrow('unknown branch'); + }); + + it('rejects a fork point outside the parent branch', async () => { + const store = await openStore(); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + await main.append(entryData('a')); + expect(() => tree.createBranch('x', { from: { branch: 'main', seq: 1 } })).toThrow('cannot fork'); + expect(() => tree.createBranch('x', { from: { branch: 'main', seq: -1 } })).toThrow('cannot fork'); + expect(() => tree.createBranch('x', { from: { branch: 'main', seq: 0 } })).not.toThrow(); + }); + + it('rejects invalid tree names', async () => { + const store = await openStore(); + await expect(store.tree('bad name')).rejects.toThrow('invalid tree name'); + }); +}); + +describe('fork', () => { + it('forks zero-copy with a header link and a chained walk', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + await main.append(entryData('a')); + await main.append(entryData('b')); + await main.append(entryData('c')); + const forked = tree.createBranch('fork', { from: { branch: 'main', seq: 1 } }); + expect(forked.header.parentBranch).toBe('main'); + expect(forked.header.parentSeq).toBe(1); + await forked.settled(); + expect(fileOf(backend, 'chat', 'fork').trim().split('\n')).toHaveLength(1); + await forked.append(entryData('d')); + const walked = [...forked.walk()]; + expect(walked.map((entry) => entry.payload.data)).toEqual([ + { text: 'd' }, + { text: 'b' }, + { text: 'a' }, + ]); + await main.append(entryData('e')); + expect([...forked.walk()].map((entry) => entry.payload.data)).toEqual([ + { text: 'd' }, + { text: 'b' }, + { text: 'a' }, + ]); + expect(await store.verify()).toEqual([]); + }); + + it('reports a missing parent branch on verify', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + await main.append(entryData('a')); + const forked = tree.createBranch('fork', { from: { branch: 'main', seq: 0 } }); + await forked.settled(); + backend.trees.files.get('chat')?.delete('main'); + const reports = await store.verify(); + expect(reports.some((report) => report.kind === 'parent-ref' && report.detail.includes('main'))).toBe(true); + }); + + it('reports a parent seq beyond the parent branch on verify', async () => { + const backend = new MemoryBackend(); + backend.trees.files.set( + 'chat', + new Map([ + ['main', `${HEADER}\n${entryJson(0, 'a')}\n`], + [ + 'fork', + '{"kind":"header","version":1,"tree":"chat","branch":"fork","createdAt":2,"parentBranch":"main","parentSeq":9}\n', + ], + ]), + ); + const store = await openStore(backend); + const reports = await store.verify(); + expect(reports.some((report) => report.kind === 'parent-ref' && report.detail.includes('beyond'))).toBe(true); + }); +}); + +describe('concurrent branches', () => { + it('appends to multiple active branches independently', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const tree = await store.tree('chat'); + const first = tree.createBranch('a'); + const second = tree.createBranch('b'); + const [a0, b0, a1, b1] = await Promise.all([ + first.append(entryData('a0')), + second.append(entryData('b0')), + first.append(entryData('a1')), + second.append(entryData('b1')), + ]); + expect([a0?.seq, a1?.seq]).toEqual([0, 1]); + expect([b0?.seq, b1?.seq]).toEqual([0, 1]); + expect([...first.walk()].map((entry) => entry.payload.data)).toEqual([{ text: 'a1' }, { text: 'a0' }]); + expect([...second.walk()].map((entry) => entry.payload.data)).toEqual([{ text: 'b1' }, { text: 'b0' }]); + expect(fileOf(backend, 'chat', 'a')).not.toContain('b0'); + expect(fileOf(backend, 'chat', 'b')).not.toContain('a0'); + }); +}); + +describe('offload', () => { + it('offloads oversized payloads and resolves them lazily', async () => { + const store = await openStore(new MemoryBackend(), { offloadThreshold: 16 }); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + const big = { text: 'x'.repeat(100) }; + const entry = await main.append({ type: 'chat.message', kind: 'json', data: big }); + expect(await tree.resolve(entry)).toEqual(big); + const small = await main.append({ type: 'chat.message', kind: 'json', data: { a: 1 } }); + expect('data' in small.payload).toBe(true); + }); + + it('deduplicates identical blob content', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend, { offloadThreshold: 16 }); + const main = (await store.tree('chat')).createBranch('main'); + const big = { text: 'y'.repeat(100) }; + const first = await main.append({ type: 'chat.message', kind: 'json', data: big }); + const second = await main.append({ type: 'chat.message', kind: 'json', data: big }); + expect(refOf(second)).toBe(refOf(first)); + expect([...backend.blobs.files.keys()]).toHaveLength(1); + }); + + it('detects blob tampering on resolve and on verify', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend, { offloadThreshold: 16 }); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + const entry = await main.append({ type: 'chat.message', kind: 'json', data: { text: 'z'.repeat(100) } }); + backend.blobs.files.set(refOf(entry), 'tampered'); + await expect(tree.resolve(entry)).rejects.toThrow('hash check'); + const reports = await store.verify({ blobs: true }); + expect(reports.some((report) => report.kind === 'blob-crc')).toBe(true); + }); + + it('reports a missing blob on verify', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend, { offloadThreshold: 16 }); + const main = (await store.tree('chat')).createBranch('main'); + const entry = await main.append({ type: 'chat.message', kind: 'json', data: { text: 'q'.repeat(100) } }); + backend.blobs.files.delete(refOf(entry)); + const reports = await store.verify({ blobs: true }); + expect(reports.some((report) => report.kind === 'blob-missing')).toBe(true); + }); +}); + +describe('persistence', () => { + it('restores trees, branches, and entries across reopen', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const a = (await store.tree('a')).createBranch('main'); + await a.append(entryData('1')); + const b = (await store.tree('b')).createBranch('main'); + await b.append(entryData('2')); + await a.append(entryData('3')); + const reopened = await openStore(backend); + expect(reopened.names().sort()).toEqual(['a', 'b']); + const loaded = (await reopened.tree('a')).openBranch('main'); + expect([...loaded.walk()].map((entry) => entry.payload.data)).toEqual([{ text: '3' }, { text: '1' }]); + const appended = await loaded.append(entryData('4')); + expect(appended.seq).toBe(2); + }); + + it('restores fork links across reopen', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const tree = await store.tree('chat'); + const main = tree.createBranch('main'); + await main.append(entryData('a')); + await main.append(entryData('b')); + const forked = tree.createBranch('fork', { from: { branch: 'main', seq: 0 } }); + await forked.append(entryData('c')); + const reopened = await openStore(backend); + const loaded = (await reopened.tree('chat')).openBranch('fork'); + expect(loaded.header.parentBranch).toBe('main'); + expect(loaded.header.parentSeq).toBe(0); + expect([...loaded.walk()].map((entry) => entry.payload.data)).toEqual([{ text: 'c' }, { text: 'a' }]); + }); + + it('terminates an unterminated tail on load', async () => { + const backend = new MemoryBackend(); + backend.trees.files.set('chat', new Map([['main', `${HEADER}\n${entryJson(0, 'a')}`]])); + const store = await openStore(backend); + await store.tree('chat'); + expect(fileOf(backend, 'chat', 'main').endsWith('\n')).toBe(true); + }); +}); + +describe('corruption', () => { + it('truncates a torn tail on load', async () => { + const backend = new MemoryBackend(); + const store = await openStore(backend); + const main = (await store.tree('chat')).createBranch('main'); + await main.append(entryData('a')); + await main.append(entryData('b')); + await backend.trees.append('chat', 'main', '{"kind":"entry","seq":2,"ts":1'); + const reopened = await openStore(backend); + const loaded = (await reopened.tree('chat')).openBranch('main'); + expect(loaded.nextSeq).toBe(2); + expect(fileOf(backend, 'chat', 'main').trim().split('\n')).toHaveLength(3); + const appended = await loaded.append(entryData('c')); + expect(appended.seq).toBe(2); + }); + + it('quarantines a corrupt middle line and keeps the rest usable', async () => { + const backend = new MemoryBackend(); + const reports: CorruptionReport[] = []; + backend.trees.files.set( + 'chat', + new Map([['main', `${HEADER}\n${entryJson(0, 'a')}\nnot-json\n${entryJson(2, 'c')}\n`]]), + ); + const store = await TreeStore.open(backend, { + subscribers: [{ prefix: '', subscriber: { onCorruption: (report) => reports.push(report) } }], + }); + const main = (await store.tree('chat')).openBranch('main'); + expect(main.degraded).toBe(false); + expect(main.nextSeq).toBe(3); + expect(reports).toHaveLength(1); + expect(reports[0]?.kind).toBe('syntax'); + expect(reports[0]?.seq).toBe(1); + expect([...main.walk()].map((entry) => entry.seq)).toEqual([2, 0]); + const appended = await main.append(entryData('d')); + expect(appended.seq).toBe(3); + }); + + it('degrades on a seq gap and recovers via repair', async () => { + const backend = new MemoryBackend(); + backend.trees.files.set( + 'chat', + new Map([['main', `${HEADER}\n${entryJson(0, 'a')}\n${entryJson(5, 'b')}\n`]]), + ); + const store = await openStore(backend); + const main = (await store.tree('chat')).openBranch('main'); + expect(main.degraded).toBe(true); + await expect(main.append(entryData('x'))).rejects.toThrow('degraded'); + await main.repair(); + expect(main.degraded).toBe(false); + const appended = await main.append(entryData('y')); + expect(appended.seq).toBe(1); + expect(fileOf(backend, 'chat', 'main').trim().split('\n')).toHaveLength(3); + }); + + it('degrades on an invalid header', async () => { + const backend = new MemoryBackend(); + backend.trees.files.set('chat', new Map([['main', `not-a-header\n${entryJson(0, 'a')}\n`]])); + const store = await openStore(backend); + const main = (await store.tree('chat')).openBranch('main'); + expect(main.degraded).toBe(true); + const reports = await store.verify(); + expect(reports.some((report) => report.kind === 'header')).toBe(true); + }); +}); + +describe('subscribers', () => { + it('routes onAppend by type prefix', async () => { + const store = await openStore(); + const appended: string[] = []; + const unsubscribe = store.subscribe('chat', { + onAppend: (_tree, _branch, entry) => appended.push(entry.type), + }); + const main = (await store.tree('chat')).createBranch('main'); + await main.append({ type: 'chat.message', kind: 'text', data: 'a' }); + await main.append({ type: 'tool.result', kind: 'json', data: 1 }); + expect(appended).toEqual(['chat.message']); + unsubscribe(); + await main.append({ type: 'chat.message', kind: 'text', data: 'b' }); + expect(appended).toHaveLength(1); + }); +}); + +describe('walk and tip', () => { + it('walks a branch from its tip', async () => { + const store = await openStore(); + const main = (await store.tree('chat')).createBranch('main'); + await main.append(entryData('a')); + await main.append(entryData('b')); + await main.append(entryData('c')); + expect(main.head).toBe(2); + expect(main.tip()?.payload.data).toEqual({ text: 'c' }); + expect([...main.walk()].map((entry) => entry.seq)).toEqual([2, 1, 0]); + }); + + it('walks nothing on an empty branch', async () => { + const store = await openStore(); + const main = (await store.tree('chat')).createBranch('main'); + expect(main.head).toBeNull(); + expect(main.tip()).toBeNull(); + expect([...main.walk()]).toEqual([]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/todo/plugin.test.ts b/packages/agent-core-v2/src/human/test/todo/plugin.test.ts new file mode 100644 index 00000000000..c47306fd865 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/todo/plugin.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { extractText, type SystemMessage, type ToolCall, type UserMessage } from '#/llm/message'; +import type { AgentEmitted } from '#/agent/machine'; +import { connectPlugins, type AgentPluginTarget, type Plugin } from '#/plugin'; +import type { ToolExecuteInput } from '#/tool/executor'; +import type { ToolDefinition } from '#/tool/tool'; +import { createTodoPlugin, type TodoPlugin } from '#/todo/plugin'; + +function toolCall(args: unknown): ToolCall { + return { type: 'function', id: 'call-1', name: 'TodoList', arguments: JSON.stringify(args) }; +} + +function executeInput(args: unknown): ToolExecuteInput { + return { toolCall: toolCall(args), signal: new AbortController().signal }; +} + +function pluginTool(plugin: TodoPlugin): ToolDefinition { + const tool = plugin.tools()[0]; + if (tool === undefined) throw new Error('expected the todo plugin to provide a tool'); + return tool; +} + +function createTarget() { + const handlers: ((event: AgentEmitted) => void)[] = []; + const notified: UserMessage[] = []; + const reminded: { key: string; message: UserMessage | SystemMessage }[] = []; + const target: AgentPluginTarget = { + kind: 'agent', + on: (_type, handler) => { + handlers.push(handler); + }, + notify: (message) => { + notified.push(message); + }, + remind: (key, message) => { + reminded.push({ key, message }); + }, + }; + const turnStart = () => { + for (const handler of handlers) { + handler({ type: 'turn.start', turnId: handlers.length, branchId: 'main' }); + } + }; + return { target, notified, reminded, turnStart }; +} + +describe('todo plugin tool', () => { + it('reads an empty list', async () => { + const plugin = createTodoPlugin(); + const result = await pluginTool(plugin).execute(executeInput({})); + expect(result.content).toEqual([{ type: 'text', text: 'Todo list is empty.' }]); + }); + + it('replaces the list and reads it back', async () => { + const plugin = createTodoPlugin(); + const result = await pluginTool(plugin).execute( + executeInput({ + todos: [ + { title: 'task a', status: 'in_progress' }, + { title: 'task b', status: 'pending' }, + ], + }), + ); + expect(result.content).toEqual([ + { type: 'text', text: expect.stringContaining('Todo list updated.') }, + ]); + expect(result.content).toEqual([ + { type: 'text', text: expect.stringContaining('[in_progress] task a') }, + ]); + + const read = await pluginTool(plugin).execute(executeInput({})); + expect(read.content).toEqual([ + { type: 'text', text: 'Current todo list:\n [in_progress] task a\n [pending] task b' }, + ]); + }); + + it('clears the list with an empty array', async () => { + const plugin = createTodoPlugin(); + await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'pending' }] })); + const result = await pluginTool(plugin).execute(executeInput({ todos: [] })); + expect(result.content).toEqual([{ type: 'text', text: 'Todo list cleared.' }]); + + const read = await pluginTool(plugin).execute(executeInput({})); + expect(read.content).toEqual([{ type: 'text', text: 'Todo list is empty.' }]); + }); + + it('drops malformed items on write', async () => { + const plugin = createTodoPlugin(); + await pluginTool(plugin).execute( + executeInput({ + todos: [{ title: 'task a' }, { title: 'task b', status: 'done' }, 'junk'], + }), + ); + const read = await pluginTool(plugin).execute(executeInput({})); + expect(read.content).toEqual([ + { type: 'text', text: 'Current todo list:\n [done] task b' }, + ]); + }); +}); + +describe('todo plugin reminder', () => { + it('notifies once when the list goes stale', async () => { + const plugin = createTodoPlugin(); + const { target, notified, turnStart } = createTarget(); + plugin.connect?.(target); + + turnStart(); + await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'pending' }] })); + + turnStart(); + expect(notified).toHaveLength(0); + + turnStart(); + expect(notified).toHaveLength(1); + const text = extractText(notified[0]); + expect(text).toContain(''); + expect(text).toContain('[pending] task a'); + + turnStart(); + expect(notified).toHaveLength(1); + }); + + it('stays silent when every item is done', async () => { + const plugin = createTodoPlugin(); + const { target, notified, turnStart } = createTarget(); + plugin.connect?.(target); + + turnStart(); + await pluginTool(plugin).execute(executeInput({ todos: [{ title: 'task a', status: 'done' }] })); + turnStart(); + turnStart(); + expect(notified).toHaveLength(0); + }); + + it('stays silent while the list is empty', () => { + const plugin = createTodoPlugin(); + const { target, notified, turnStart } = createTarget(); + plugin.connect?.(target); + + turnStart(); + turnStart(); + turnStart(); + expect(notified).toHaveLength(0); + }); +}); + +describe('connectPlugins notify channel', () => { + it('maps target.notify to an input.notify event on the actor', () => { + const sent: unknown[] = []; + const plugin = createTodoPlugin(); + const probe: Plugin = { + name: 'probe', + tools: () => [], + connect(target) { + if (target.kind !== 'agent') return; + target.notify({ role: 'user', content: [{ type: 'text', text: 'hello' }] }); + }, + }; + connectPlugins( + { + on: () => undefined, + send: (event) => { + sent.push(event); + }, + }, + [probe], + ); + expect(plugin.name).toBe('todo'); + expect(sent).toEqual([ + { + type: 'input.notify', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + }, + ]); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts b/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts new file mode 100644 index 00000000000..6de2f54b836 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/tool-select/plugin.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; + +import { createActor, waitFor } from '#/xstate2'; +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { + createAssistantMessage, + createUserMessage, + extractText, + type SystemMessage, + type ToolCall, + type UserMessage, +} from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequestConfig, LlmRequester, LlmRequestEvent } from '#/llm/requester/requester'; +import { connectPlugins, type AgentPluginTarget } from '#/plugin'; +import { createAgentMachine, type AgentEmitted } from '#/agent/machine'; +import { createTurnMachine, type HistoryMessage } from '#/agent/turn'; +import type { ToolExecuteInput } from '#/tool/executor'; +import { defineTool, type ToolDefinition } from '#/tool/tool'; +import { + createToolSelectState, + SELECT_TOOLS_TOOL_NAME, + type ToolSelectState, +} from '#/tool-select/state'; +import { createSelectToolsTool, deferTool } from '#/tool-select/tool'; +import { + createToolSelectPlugin, + DYNAMIC_TOOL_SCHEMA_REMINDER_KEY, + LOADABLE_TOOLS_REMINDER_KEY, +} from '#/tool-select/plugin'; +import { createToolSelectMessageResolver } from '#/tool-select/resolver'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +function toolCall(name: string, args: unknown, id = 'call-1'): ToolCall { + return { type: 'function', id, name, arguments: JSON.stringify(args) }; +} + +function executeInput(name: string, args: unknown): ToolExecuteInput { + return { toolCall: toolCall(name, args), signal: new AbortController().signal }; +} + +function weatherTool(execute?: ToolDefinition['execute']): ToolDefinition { + return defineTool({ + name: 'get_weather', + description: 'get weather', + parameters: { type: 'object', properties: { city: { type: 'string' } } }, + execute: + execute ?? + (() => Promise.resolve({ content: [{ type: 'text', text: 'sunny' }] })), + }); +} + +function enabledState(loadable: readonly ToolDefinition[] = [weatherTool()]): ToolSelectState { + return createToolSelectState({ loadable: () => loadable, enabled: () => true }); +} + +describe('select_tools tool', () => { + it('errors when the feature is not enabled', async () => { + const state = createToolSelectState({ loadable: () => [weatherTool()], enabled: () => false }); + const result = await createSelectToolsTool(state).execute( + executeInput(SELECT_TOOLS_TOOL_NAME, { names: ['get_weather'] }), + ); + expect(result.isError).toBe(true); + expect(extractText({ role: 'tool', toolCallId: 'call-1', content: result.content })).toContain( + 'not available', + ); + }); + + it('loads known tools, reports already available and unknown names', async () => { + const state = enabledState(); + const tool = createSelectToolsTool(state); + + const first = await tool.execute( + executeInput(SELECT_TOOLS_TOOL_NAME, { names: ['get_weather', 'missing'] }), + ); + const firstText = extractText({ role: 'tool', toolCallId: 'call-1', content: first.content }); + expect(firstText).toContain('Loaded: get_weather'); + expect(firstText).toContain('Unknown tool: missing'); + expect(state.pendingSchemas().map((schema) => schema.name)).toEqual(['get_weather']); + + state.markSchemasLanded(); + const second = await tool.execute( + executeInput(SELECT_TOOLS_TOOL_NAME, { names: ['get_weather'] }), + ); + expect(extractText({ role: 'tool', toolCallId: 'call-1', content: second.content })).toContain( + 'Already available: get_weather', + ); + }); + + it('rejects an empty names list', async () => { + const state = enabledState(); + const result = await createSelectToolsTool(state).execute( + executeInput(SELECT_TOOLS_TOOL_NAME, { names: [] }), + ); + expect(result.isError).toBe(true); + }); +}); + +describe('tool select announcements', () => { + it('diffs against the announced set and folds removals', () => { + const tools = [weatherTool()]; + const state = createToolSelectState({ loadable: () => tools, enabled: () => true }); + + const first = state.announcement(); + expect(first).toContain(''); + expect(first).toContain('get_weather'); + + state.markAnnounced(); + expect(state.announcement()).toBeUndefined(); + + tools.pop(); + const removed = state.announcement(); + expect(removed).toContain(''); + expect(removed).toContain('get_weather'); + }); + + it('stays silent when disabled', () => { + const state = createToolSelectState({ loadable: () => [weatherTool()], enabled: () => false }); + expect(state.announcement()).toBeUndefined(); + }); +}); + +describe('deferTool', () => { + it('intercepts calls to tools that are not loaded', async () => { + const state = enabledState(); + const deferred = deferTool(weatherTool(), state); + expect(deferred.deferred).toBe(true); + + const result = await deferred.execute(executeInput('get_weather', { city: 'sh' })); + expect(result.isError).toBe(true); + expect(extractText({ role: 'tool', toolCallId: 'call-1', content: result.content })).toContain( + 'Call select_tools with ["get_weather"] first', + ); + }); + + it('passes through once the tool is loaded', async () => { + const state = enabledState(); + const deferred = deferTool(weatherTool(), state); + state.load(['get_weather']); + const result = await deferred.execute(executeInput('get_weather', { city: 'sh' })); + expect(result.isError).toBeUndefined(); + expect(extractText({ role: 'tool', toolCallId: 'call-1', content: result.content })).toBe( + 'sunny', + ); + }); +}); + +function createTarget() { + const handlers = new Map void)[]>(); + const reminded: { key: string; message: UserMessage | SystemMessage }[] = []; + const target: AgentPluginTarget = { + kind: 'agent', + on: (type, handler) => { + handlers.set(type, [...(handlers.get(type) ?? []), handler]); + }, + notify: () => undefined, + remind: (key, message) => { + reminded.push({ key, message }); + }, + }; + const emit = (event: AgentEmitted) => { + for (const handler of handlers.get(event.type) ?? []) handler(event); + }; + return { target, reminded, emit }; +} + +describe('tool select plugin', () => { + it('announces on turn start and pushes schemas after a tool completes', async () => { + const state = enabledState(); + const plugin = createToolSelectPlugin(state); + const { target, reminded, emit } = createTarget(); + plugin.connect?.(target); + + emit({ type: 'turn.start', turnId: 1, branchId: 'main' }); + expect(reminded).toHaveLength(1); + expect(reminded[0]?.key).toBe(LOADABLE_TOOLS_REMINDER_KEY); + expect(extractText(reminded[0]?.message as UserMessage)).toContain('get_weather'); + + state.load(['get_weather']); + emit({ type: 'tool.done', toolCallId: 'call-1', result: { content: [] } }); + expect(reminded).toHaveLength(2); + expect(reminded[1]?.key).toBe(DYNAMIC_TOOL_SCHEMA_REMINDER_KEY); + const schemaMessage = reminded[1]?.message as SystemMessage; + expect(schemaMessage.role).toBe('system'); + expect(schemaMessage.tools?.map((tool) => tool.name)).toEqual(['get_weather']); + + emit({ + type: 'turn.remindersConsumed', + reminders: [ + { message: reminded[0]?.message as UserMessage, meta: { source: 'reminder', key: LOADABLE_TOOLS_REMINDER_KEY } }, + { message: schemaMessage, meta: { source: 'reminder', key: DYNAMIC_TOOL_SCHEMA_REMINDER_KEY } }, + ], + }); + expect(state.isLoaded('get_weather')).toBe(true); + expect(state.pendingSchemas()).toEqual([]); + + emit({ type: 'tool.done', toolCallId: 'call-2', result: { content: [] } }); + expect(reminded).toHaveLength(2); + }); + + it('resets state on context.reset', () => { + const state = enabledState(); + const plugin = createToolSelectPlugin(state); + const { target, emit } = createTarget(); + plugin.connect?.(target); + + emit({ type: 'turn.start', turnId: 1, branchId: 'main' }); + state.load(['get_weather']); + emit({ type: 'context.reset', branchId: 'main' }); + expect(state.isLoaded('get_weather')).toBe(false); + expect(state.announcement()).toContain('get_weather'); + }); +}); + +describe('tool select message resolver', () => { + const declaration: SystemMessage = { + role: 'system', + content: [], + tools: [ + { name: 'get_weather', description: 'get weather', parameters: { type: 'object' } }, + { name: 'get_time', description: 'get time', parameters: { type: 'object' } }, + ], + }; + + it('strips tool declarations when disabled', async () => { + const state = createToolSelectState({ loadable: () => [weatherTool()], enabled: () => false }); + const resolver = createToolSelectMessageResolver(state); + const resolved = await resolver.resolve([declaration, createUserMessage('hi')], { + model, + signal: new AbortController().signal, + }); + expect(resolved).toEqual([createUserMessage('hi')]); + }); + + it('drops declarations for tools that are no longer loadable', async () => { + const state = enabledState(); + const resolver = createToolSelectMessageResolver(state); + const resolved = await resolver.resolve([declaration], { + model, + signal: new AbortController().signal, + }); + const message = resolved[0] as SystemMessage; + expect(message.tools?.map((tool) => tool.name)).toEqual(['get_weather']); + }); +}); + +describe('tool select agent flow', () => { + function streamMessage( + message: ReturnType, + onEvent: ((event: LlmRequestEvent) => void) | undefined, + ): void { + for (const part of [...message.content, ...message.toolCalls]) { + onEvent?.({ type: 'llm.delta', part }); + } + onEvent?.({ + type: 'llm.finish', + finish: { finishReason: 'completed', rawFinishReason: 'stop' }, + }); + onEvent?.({ type: 'llm.done' }); + } + + it('loads a deferred tool via select_tools and calls it with its schema in context', async () => { + const configs: LlmRequestConfig[] = []; + const responses = [ + createAssistantMessage([], [toolCall(SELECT_TOOLS_TOOL_NAME, { names: ['get_weather'] })]), + createAssistantMessage([], [toolCall('get_weather', { city: 'sh' }, 'call-2')]), + createAssistantMessage([{ type: 'text', text: 'done' }]), + ]; + let call = 0; + const requester: LlmRequester = { + generate: (config, _content, { onEvent }) => { + configs.push(config); + streamMessage(responses[Math.min(call, responses.length - 1)], onEvent); + call += 1; + return Promise.resolve(); + }, + }; + + const executed: string[] = []; + const state = enabledState(); + const plugin = createToolSelectPlugin(state); + const deferred = deferTool( + weatherTool(() => { + executed.push('get_weather'); + return Promise.resolve({ content: [{ type: 'text', text: 'sunny' }] }); + }), + state, + ); + const actor = createActor( + createAgentMachine({ + tools: [createSelectToolsTool(state), deferred], + turnActor: createTurnMachine(createLlmMachine({ requester })), + }), + { input: { request: { model } } }, + ); + connectPlugins(actor, [plugin]); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('weather?') }); + const snapshot = await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length > 1, + { timeout: 5000 }, + ); + + const firstTools = configs[0]?.tools?.map((tool) => tool.name) ?? []; + expect(firstTools).toContain(SELECT_TOOLS_TOOL_NAME); + expect(firstTools).not.toContain('get_weather'); + expect(executed).toEqual(['get_weather']); + + const schemaEntry = snapshot.context.messages.find( + (entry: HistoryMessage) => entry.message.role === 'system', + ); + expect(schemaEntry?.meta.key).toBe(DYNAMIC_TOOL_SCHEMA_REMINDER_KEY); + const schemaMessage = schemaEntry?.message as SystemMessage; + expect(schemaMessage.tools?.map((tool) => tool.name)).toEqual(['get_weather']); + + const secondRequest = configs[1]; + expect(secondRequest).toBeDefined(); + }); +}); diff --git a/packages/agent-core-v2/src/human/test/usage/machine.test.ts b/packages/agent-core-v2/src/human/test/usage/machine.test.ts new file mode 100644 index 00000000000..19439abd951 --- /dev/null +++ b/packages/agent-core-v2/src/human/test/usage/machine.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest'; +import { createActor, waitFor } from '#/xstate2'; + +import { connectPlugins } from '#/plugin'; +import { UNKNOWN_CAPABILITY } from '#/llm/capability'; +import { createUserMessage } from '#/llm/message'; +import type { LlmModel } from '#/llm/model'; +import { createLlmMachine } from '#/llm/requester/machine'; +import type { LlmRequester } from '#/llm/requester/requester'; +import type { TokenUsage } from '#/llm/usage'; +import { createAgentMachine } from '#/agent/machine'; +import { createTurnMachine } from '#/agent/turn'; +import { createUsageMachine } from '#/usage/machine'; +import type { UsageEmitted } from '#/usage/machine'; +import { createUsagePlugin } from '#/usage/plugin'; +import type { UsageRecord } from '#/usage/usage'; +import { createTimingPlugin } from '#/timing/plugin'; + +const model: LlmModel = { provider: 'test', model: 'test-model', capability: UNKNOWN_CAPABILITY }; + +function usage(inputOther: number, output: number): TokenUsage { + return { inputOther, output, inputCacheRead: 0, inputCacheCreation: 0 }; +} + +function record( + inputOther: number, + output: number, + extra?: { model?: LlmModel; turnId?: number }, +): UsageRecord { + return { usage: usage(inputOther, output), model: extra?.model, turnId: extra?.turnId, at: 0 }; +} + +describe('usage machine', () => { + it('accumulates total, byModel and byTurn across usage.record events', () => { + const actor = createActor(createUsageMachine()); + actor.start(); + + actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) }); + actor.send({ type: 'usage.record', record: record(5, 3, { model, turnId: 2 }) }); + actor.send({ type: 'usage.record', record: record(100, 0) }); + + const { records, summary } = actor.getSnapshot().context; + expect(records).toHaveLength(3); + expect(summary.total).toEqual({ + inputOther: 115, + output: 5, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + expect(summary.byModel).toEqual({ + 'test-model': { inputOther: 15, output: 5, inputCacheRead: 0, inputCacheCreation: 0 }, + }); + expect(summary.byTurn).toEqual({ + 1: { inputOther: 10, output: 2, inputCacheRead: 0, inputCacheCreation: 0 }, + 2: { inputOther: 5, output: 3, inputCacheRead: 0, inputCacheCreation: 0 }, + }); + }); + + it('groups byModel by baseUrl + model, ignoring provider', () => { + const actor = createActor(createUsageMachine()); + actor.start(); + + const a1: LlmModel = { provider: 'p1', model: 'm', capability: UNKNOWN_CAPABILITY, baseUrl: 'https://a.test/v1' }; + const a2: LlmModel = { provider: 'p2', model: 'm', capability: UNKNOWN_CAPABILITY, baseUrl: 'https://a.test/v1' }; + const b: LlmModel = { provider: 'p1', model: 'm', capability: UNKNOWN_CAPABILITY, baseUrl: 'https://b.test/v1' }; + actor.send({ type: 'usage.record', record: record(10, 2, { model: a1 }) }); + actor.send({ type: 'usage.record', record: record(5, 3, { model: a2 }) }); + actor.send({ type: 'usage.record', record: record(1, 1, { model: b }) }); + + const { summary } = actor.getSnapshot().context; + expect(summary.byModel).toEqual({ + 'https://a.test/v1#m': { inputOther: 15, output: 5, inputCacheRead: 0, inputCacheCreation: 0 }, + 'https://b.test/v1#m': { inputOther: 1, output: 1, inputCacheRead: 0, inputCacheCreation: 0 }, + }); + }); + + it('emits usage.updated with the record and running summary', () => { + const actor = createActor(createUsageMachine()); + const emitted: UsageEmitted[] = []; + actor.on('usage.updated', (event) => emitted.push(event)); + actor.start(); + + actor.send({ type: 'usage.record', record: record(10, 2, { model, turnId: 1 }) }); + actor.send({ type: 'usage.record', record: record(5, 3, { model, turnId: 1 }) }); + + expect(emitted).toHaveLength(2); + expect(emitted[1]?.record.usage).toEqual(usage(5, 3)); + expect(emitted[1]?.summary.total).toEqual({ + inputOther: 15, + output: 5, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + expect(emitted[1]?.summary.byTurn[1]).toEqual({ + inputOther: 15, + output: 5, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + }); +}); + +describe('usage plugin', () => { + it('collects usage from every llm.usage and groups it by turn', async () => { + const ticks = [1000, 1100, 1200, 1230, 1300, 2000, 2100, 2200, 2240, 2300]; + const requester: LlmRequester = { + generate: (_config, _content, { onEvent }) => { + onEvent?.({ type: 'llm.sent' }); + onEvent?.({ type: 'llm.delta', part: { type: 'text', text: 'ok' } }); + onEvent?.({ type: 'llm.usage', usage: usage(10, 2) }); + onEvent?.({ type: 'llm.done' }); + return Promise.resolve(); + }, + }; + const plugin = createUsagePlugin({ model }); + const timingPlugin = createTimingPlugin({ now: () => ticks.shift() ?? Number.NaN }); + const actor = createActor( + createAgentMachine({ + turnActor: createTurnMachine(createLlmMachine({ requester })), + }), + { input: { request: { model } } }, + ); + connectPlugins(actor, [plugin, timingPlugin]); + actor.start(); + actor.send({ type: 'input.submit', message: createUserMessage('hi') }); + actor.send({ type: 'input.submit', message: createUserMessage('again') }); + await waitFor( + actor, + (s) => s.matches('idle') && s.context.messages.length === 4, + { timeout: 5000 }, + ); + + const { records, summary } = plugin.actor.getSnapshot().context; + expect(records).toHaveLength(2); + expect(records.map((r) => r.turnId)).toEqual([1, 2]); + expect(records.map((r) => r.model)).toEqual([model, model]); + expect(summary.total).toEqual({ + inputOther: 20, + output: 4, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + expect(summary.byModel['test-model']).toEqual(summary.total); + expect(summary.byTurn[1]).toEqual({ + inputOther: 10, + output: 2, + inputCacheRead: 0, + inputCacheCreation: 0, + }); + expect(summary.byTurn[2]).toEqual(summary.byTurn[1]); + + expect(timingPlugin.timing()).toEqual({ + requestBuildMs: 100, + ttftMs: 200, + serverFirstTokenMs: 100, + streamDurationMs: 100, + serverDecodeMs: 60, + clientConsumeMs: 40, + }); + expect(ticks).toHaveLength(0); + }); +}); diff --git a/packages/agent-core-v2/src/human/timing/plugin.ts b/packages/agent-core-v2/src/human/timing/plugin.ts new file mode 100644 index 00000000000..0999ede6d07 --- /dev/null +++ b/packages/agent-core-v2/src/human/timing/plugin.ts @@ -0,0 +1,124 @@ +import type { Plugin } from '#/plugin'; + +export interface LlmRequestTiming { + readonly requestBuildMs?: number; + readonly ttftMs: number; + readonly serverFirstTokenMs: number; + readonly streamDurationMs: number; + readonly serverDecodeMs: number; + readonly clientConsumeMs: number; +} + +export interface TimingPlugin extends Plugin { + readonly name: 'timing'; + timing(): LlmRequestTiming | undefined; +} + +export function createTimingPlugin(input?: { now?: () => number }): TimingPlugin { + const now = input?.now ?? Date.now; + let current: LlmRequestTiming | undefined; + let lastEventAt: number | undefined; + let retryAnchor: { at: number; delayMs: number } | undefined; + let sentAt: number | undefined; + let attemptStartedAt: number | undefined; + let firstDeltaAt: number | undefined; + let lastHandledAt = 0; + let serverDecodeMs = 0; + let clientConsumeMs = 0; + + const resetWindow = (): void => { + sentAt = undefined; + attemptStartedAt = undefined; + firstDeltaAt = undefined; + serverDecodeMs = 0; + clientConsumeMs = 0; + }; + + return { + name: 'timing', + timing: () => current, + connect(target) { + if (target.kind !== 'agent') return; + const mark = (): void => { + lastEventAt = now(); + }; + target.on('turn.start', () => { + retryAnchor = undefined; + lastEventAt = now(); + }); + target.on('tool.async', mark); + target.on('tool.done', mark); + target.on('tool.failed', mark); + target.on('tool.aborted', mark); + target.on('llm.sent', () => { + const t = now(); + attemptStartedAt = + retryAnchor === undefined ? lastEventAt : retryAnchor.at + retryAnchor.delayMs; + retryAnchor = undefined; + sentAt = t; + firstDeltaAt = undefined; + serverDecodeMs = 0; + clientConsumeMs = 0; + lastEventAt = t; + }); + target.on('llm.delta', () => { + const arrivedAt = now(); + if (sentAt === undefined) return; + if (firstDeltaAt === undefined) { + firstDeltaAt = arrivedAt; + } else { + serverDecodeMs += arrivedAt - lastHandledAt; + } + const handledAt = now(); + clientConsumeMs += handledAt - arrivedAt; + lastHandledAt = handledAt; + lastEventAt = handledAt; + }); + target.on('llm.done', () => { + const t = now(); + if (sentAt !== undefined && firstDeltaAt !== undefined) { + serverDecodeMs += t - lastHandledAt; + current = { + requestBuildMs: + attemptStartedAt === undefined + ? undefined + : Math.max(0, sentAt - attemptStartedAt), + ttftMs: Math.max(0, firstDeltaAt - (attemptStartedAt ?? sentAt)), + serverFirstTokenMs: Math.max(0, firstDeltaAt - sentAt), + streamDurationMs: Math.max(0, t - firstDeltaAt), + serverDecodeMs: Math.max(0, serverDecodeMs), + clientConsumeMs: Math.max(0, clientConsumeMs), + }; + } + resetWindow(); + lastEventAt = t; + }); + target.on('llm.retrying', (event) => { + const t = now(); + if (event.type === 'llm.retrying') { + retryAnchor = { at: t, delayMs: event.delayMs }; + } + resetWindow(); + lastEventAt = t; + }); + target.on('llm.recovering', () => { + const t = now(); + retryAnchor = undefined; + resetWindow(); + lastEventAt = t; + }); + target.on('llm.failed.syntax', () => { + const t = now(); + retryAnchor = undefined; + resetWindow(); + lastEventAt = t; + }); + target.on('llm.failed.remote', () => { + const t = now(); + retryAnchor = undefined; + resetWindow(); + lastEventAt = t; + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/todo/plugin.ts b/packages/agent-core-v2/src/human/todo/plugin.ts new file mode 100644 index 00000000000..404089b4561 --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/plugin.ts @@ -0,0 +1,37 @@ +import { createUserMessage } from '#/llm/message'; +import type { Plugin } from '#/plugin'; + +import { createTodoState, type TodoState } from './state'; +import { createTodoListTool } from './tool'; +import { renderTodoList } from './todoItem'; + +const STALE_TURNS = 2; + +export interface TodoPlugin extends Plugin { + readonly name: 'todo'; + readonly state: TodoState; +} + +export function createTodoPlugin(state: TodoState = createTodoState()): TodoPlugin { + const tool = createTodoListTool(state); + return { + name: 'todo', + state, + tools: () => [tool], + connect(target) { + if (target.kind !== 'agent') return; + target.on('turn.start', (event) => { + if (event.type !== 'turn.start') return; + state.currentTurn += 1; + if (state.todos.length === 0) return; + if (state.todos.every((todo) => todo.status === 'done')) return; + if (state.currentTurn - state.lastWriteTurn !== STALE_TURNS) return; + target.notify( + createUserMessage( + `\nThe todo list has not been updated recently. If the work is still in progress, update the list to reflect the current progress.\n${renderTodoList(state.todos)}\n`, + ), + ); + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/todo/state.ts b/packages/agent-core-v2/src/human/todo/state.ts new file mode 100644 index 00000000000..dae96de98bb --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/state.ts @@ -0,0 +1,33 @@ +import { readTodoItems, type TodoItem } from './todoItem'; + +export interface TodoState { + todos: readonly TodoItem[]; + currentTurn: number; + lastWriteTurn: number; +} + +export function createTodoState(): TodoState { + return { todos: [], currentTurn: 0, lastWriteTurn: 0 }; +} + +export interface PersistedTodoState { + todos: readonly TodoItem[]; + lastWriteTurn: number; +} + +export function snapshotTodoState(state: TodoState): PersistedTodoState { + return { todos: state.todos, lastWriteTurn: state.lastWriteTurn }; +} + +export function restoreTodoState(value: unknown, currentTurn: number): TodoState { + const state = createTodoState(); + state.currentTurn = currentTurn; + if (typeof value === 'object' && value !== null) { + const record = value as Record; + state.todos = readTodoItems(record['todos']); + if (typeof record['lastWriteTurn'] === 'number') { + state.lastWriteTurn = record['lastWriteTurn']; + } + } + return state; +} diff --git a/packages/agent-core-v2/src/human/todo/todo-list-write-reminder.md b/packages/agent-core-v2/src/human/todo/todo-list-write-reminder.md new file mode 100644 index 00000000000..0833a533ca2 --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/todo-list-write-reminder.md @@ -0,0 +1 @@ +Ensure that you continue to use the todo list to track progress. Mark tasks done immediately after finishing them, and keep exactly one task in_progress when work is underway. diff --git a/packages/agent-core-v2/src/human/todo/todo-list.md b/packages/agent-core-v2/src/human/todo/todo-list.md new file mode 100644 index 00000000000..3dc3c08dc48 --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/todo-list.md @@ -0,0 +1,30 @@ +Use this tool to maintain a structured TODO list as you work through a multi-step task. Use it proactively and often when progress tracking helps the current work. This is especially useful in long-running investigations and implementation tasks with several tool calls; in plan mode, write the plan to the plan file rather than tracking it here. + +**When to use:** +- Multi-step tasks that span several tool calls +- Tracking investigation progress across a large codebase search +- Planning a sequence of edits before making them +- After receiving new multi-step instructions, capture the requirements as todos +- Before starting a tracked task, mark exactly one item as `in_progress` +- Immediately after finishing a tracked task, mark it `done`; do not batch completions at the end + +**When NOT to use:** +- Single-shot answers that complete in one or two tool calls +- Trivial requests where tracking adds no clarity +- Purely conversational or informational replies + +**Avoid churn:** +- Do not re-call this tool when nothing meaningful has changed since the last call — update the list only after real progress. +- When unsure of the current state, call query mode first (omit `todos`) to check the list before deciding what to update. +- If no available tool can move any task forward, tell the user where you are stuck instead of repeatedly re-ordering the same todos. + +**How to use:** +- Call with `todos: [...]` to replace the full list. Statuses: pending / in_progress / done. +- Call with no `todos` argument to retrieve the current list without changing it. +- Call with `todos: []` to clear the list. +- Keep titles short and actionable (e.g. "Read session-control.ts", "Add planMode flag to TurnManager"). +- Update statuses as you make progress. +- When work is underway, keep exactly one task `in_progress`. +- Only mark a task `done` when it is fully accomplished. +- Never mark a task `done` if tests are failing, implementation is partial, unresolved errors remain, or required files/dependencies could not be found. +- If you encounter a blocker, keep the blocked task `in_progress` or add a new pending task describing what must be resolved. diff --git a/packages/agent-core-v2/src/human/todo/todoItem.ts b/packages/agent-core-v2/src/human/todo/todoItem.ts new file mode 100644 index 00000000000..6da0ed36e26 --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/todoItem.ts @@ -0,0 +1,49 @@ +export const TODO_LIST_TOOL_NAME = 'TodoList' as const; + +export type TodoStatus = 'pending' | 'in_progress' | 'done'; + +export interface TodoItem { + readonly title: string; + readonly status: TodoStatus; +} + +export function readTodoItems(raw: unknown): readonly TodoItem[] { + if (!Array.isArray(raw)) return []; + return raw.filter(isTodoItem).map((todo) => ({ + title: todo.title, + status: todo.status, + })); +} + +export function isTodoItem(value: unknown): value is TodoItem { + if (typeof value !== 'object' || value === null) return false; + const record = value as Record; + return typeof record['title'] === 'string' && isTodoStatus(record['status']); +} + +function isTodoStatus(value: unknown): value is TodoStatus { + return value === 'pending' || value === 'in_progress' || value === 'done'; +} + +export function renderTodoList(todos: readonly TodoItem[], title = 'Current todo list:'): string { + if (todos.length === 0) { + return 'Todo list is empty.'; + } + const lines = todos.map((todo) => ` ${statusMarker(todo.status)} ${todo.title}`); + return [title, ...lines].join('\n'); +} + +function statusMarker(status: TodoStatus): string { + switch (status) { + case 'pending': + return '[pending]'; + case 'in_progress': + return '[in_progress]'; + case 'done': + return '[done]'; + default: { + const exhaustive: never = status; + return exhaustive; + } + } +} diff --git a/packages/agent-core-v2/src/human/todo/tool.ts b/packages/agent-core-v2/src/human/todo/tool.ts new file mode 100644 index 00000000000..df232d5f255 --- /dev/null +++ b/packages/agent-core-v2/src/human/todo/tool.ts @@ -0,0 +1,55 @@ +import { defineTool, type ToolDefinition } from '#/tool/tool'; + +import type { TodoState } from './state'; +import { readTodoItems, renderTodoList, TODO_LIST_TOOL_NAME } from './todoItem'; +import DESCRIPTION from './todo-list.md?raw'; +import TODO_LIST_WRITE_REMINDER from './todo-list-write-reminder.md?raw'; + +export function createTodoListTool(state: TodoState): ToolDefinition { + return defineTool({ + name: TODO_LIST_TOOL_NAME, + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + todos: { + type: 'array', + description: + 'The updated todo list. Omit to read the current todo list without making changes. Pass an empty array to clear the list.', + items: { + type: 'object', + properties: { + title: { type: 'string', description: 'Short, actionable title for the todo.' }, + status: { + type: 'string', + enum: ['pending', 'in_progress', 'done'], + description: 'Current status of the todo.', + }, + }, + required: ['title', 'status'], + }, + }, + }, + }, + async execute({ toolCall }) { + const args = JSON.parse(toolCall.arguments ?? '{}') as { todos?: unknown }; + if (args.todos === undefined) { + return { content: [{ type: 'text', text: renderTodoList(state.todos) }] }; + } + const next = readTodoItems(args.todos); + state.todos = next; + state.lastWriteTurn = state.currentTurn; + if (next.length === 0) { + return { content: [{ type: 'text', text: 'Todo list cleared.' }] }; + } + return { + content: [ + { + type: 'text', + text: `Todo list updated.\n${renderTodoList(next)}\n\n${TODO_LIST_WRITE_REMINDER.trim()}`, + }, + ], + }; + }, + }); +} diff --git a/packages/agent-core-v2/src/human/tool-select/plugin.ts b/packages/agent-core-v2/src/human/tool-select/plugin.ts new file mode 100644 index 00000000000..56c846ff976 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool-select/plugin.ts @@ -0,0 +1,47 @@ +import { createUserMessage } from '#/llm/message'; +import type { Plugin } from '#/plugin'; + +import type { ToolSelectState } from './state'; +import { createSelectToolsTool } from './tool'; + +export const LOADABLE_TOOLS_REMINDER_KEY = 'loadable-tools'; +export const DYNAMIC_TOOL_SCHEMA_REMINDER_KEY = 'dynamic-tool-schemas'; + +export interface ToolSelectPlugin extends Plugin { + readonly name: 'tool-select'; +} + +export function createToolSelectPlugin(state: ToolSelectState): ToolSelectPlugin { + return { + name: 'tool-select', + tools: () => [createSelectToolsTool(state)], + connect(target) { + if (target.kind !== 'agent') return; + target.on('turn.start', (event) => { + if (event.type !== 'turn.start') return; + if (!state.enabled()) return; + const announcement = state.announcement(); + if (announcement === undefined) return; + target.remind(LOADABLE_TOOLS_REMINDER_KEY, createUserMessage(announcement)); + }); + const pushSchemas = () => { + if (!state.enabled()) return; + const tools = state.pendingSchemas(); + if (tools.length === 0) return; + target.remind(DYNAMIC_TOOL_SCHEMA_REMINDER_KEY, { role: 'system', content: [], tools }); + }; + target.on('tool.done', pushSchemas); + target.on('tool.failed', pushSchemas); + target.on('turn.remindersConsumed', (event) => { + if (event.type !== 'turn.remindersConsumed') return; + for (const entry of event.reminders) { + if (entry.meta.key === LOADABLE_TOOLS_REMINDER_KEY) state.markAnnounced(); + if (entry.meta.key === DYNAMIC_TOOL_SCHEMA_REMINDER_KEY) state.markSchemasLanded(); + } + }); + target.on('context.reset', () => { + state.reset(); + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/tool-select/resolver.ts b/packages/agent-core-v2/src/human/tool-select/resolver.ts new file mode 100644 index 00000000000..9455c6c6cec --- /dev/null +++ b/packages/agent-core-v2/src/human/tool-select/resolver.ts @@ -0,0 +1,39 @@ +import type { Message } from '#/llm/message'; +import type { MessageResolver } from '#/llm/requester/machine'; + +import type { ToolSelectState } from './state'; + +export function createToolSelectMessageResolver(state: ToolSelectState): MessageResolver { + return { + id: 'tool-select', + resolve: (messages) => { + let shaped: Message[] | undefined; + for (let i = 0; i < messages.length; i += 1) { + const message = messages[i] as Message; + const next = shapeMessage(message, state); + if (next === message) { + if (shaped !== undefined) shaped.push(message); + continue; + } + shaped ??= messages.slice(0, i); + if (next !== undefined) shaped.push(next); + } + return Promise.resolve(shaped ?? messages); + }, + }; +} + +function shapeMessage(message: Message, state: ToolSelectState): Message | undefined { + if (message.role !== 'system' || message.tools === undefined || message.tools.length === 0) { + return message; + } + const kept = state.enabled() + ? message.tools.filter((tool) => state.isLoadable(tool.name)) + : []; + if (kept.length === message.tools.length) return message; + if (kept.length > 0) return { ...message, tools: kept }; + if (message.content.length === 0) return undefined; + const { tools: _tools, ...rest } = message; + void _tools; + return rest; +} diff --git a/packages/agent-core-v2/src/human/tool-select/state.ts b/packages/agent-core-v2/src/human/tool-select/state.ts new file mode 100644 index 00000000000..08be088d9d7 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool-select/state.ts @@ -0,0 +1,129 @@ +import type { ModelCapability } from '#/llm/capability'; +import type { ToolDescription } from '#/llm/message'; +import type { ToolDefinition } from '#/tool/tool'; + +export const SELECT_TOOLS_TOOL_NAME = 'select_tools'; + +export interface LoadToolsResult { + readonly toLoad: readonly string[]; + readonly alreadyAvailable: readonly string[]; + readonly unknown: readonly string[]; +} + +export interface ToolSelectState { + enabled(): boolean; + isLoadable(name: string): boolean; + isLoaded(name: string): boolean; + load(names: readonly string[]): LoadToolsResult; + pendingSchemas(): ToolDescription[]; + announcement(): string | undefined; + markSchemasLanded(): void; + markAnnounced(): void; + reset(): void; +} + +export interface CreateToolSelectStateOptions { + loadable: () => readonly ToolDefinition[]; + enabled: () => boolean; +} + +export function isToolSelectEnabled(capability: ModelCapability): boolean { + return capability.dynamically_loaded_tools === true && capability.tool_use; +} + +export function renderLoadableToolsAnnouncement( + added: readonly string[], + removed: readonly string[], +): string { + const sections: string[] = []; + if (added.length > 0) { + sections.push(`\n${added.join('\n')}\n`); + } + if (removed.length > 0) { + sections.push(`\n${removed.join('\n')}\n`); + } + sections.push( + 'Use the select_tools tool with exact names to load full tool definitions before calling them. ' + + 'Names listed as removed are no longer loadable — do not select them. ' + + 'Fold all announcements in this conversation in order to get the current list.', + ); + return sections.join('\n\n'); +} + +export function createToolSelectState({ + loadable, + enabled, +}: CreateToolSelectStateOptions): ToolSelectState { + const pending = new Set(); + const landed = new Set(); + const announced = new Set(); + let pendingAnnouncement: { added: string[]; removed: string[] } | undefined; + + const loadableNames = () => new Set(loadable().map((tool) => tool.name)); + + const schemaOf = (name: string): ToolDescription | undefined => { + const tool = loadable().find((entry) => entry.name === name); + if (tool === undefined) return undefined; + return { name: tool.name, description: tool.description, parameters: tool.parameters }; + }; + + return { + enabled, + isLoadable: (name) => loadableNames().has(name), + isLoaded: (name) => pending.has(name) || landed.has(name), + load: (names) => { + const loadableSet = loadableNames(); + const toLoad: string[] = []; + const alreadyAvailable: string[] = []; + const unknown: string[] = []; + for (const name of new Set(names)) { + if (pending.has(name) || landed.has(name)) { + alreadyAvailable.push(name); + } else if (loadableSet.has(name)) { + toLoad.push(name); + } else { + unknown.push(name); + } + } + for (const name of toLoad) pending.add(name); + return { toLoad, alreadyAvailable, unknown }; + }, + pendingSchemas: () => + [...pending] + .toSorted((a, b) => a.localeCompare(b)) + .flatMap((name) => { + const schema = schemaOf(name); + return schema === undefined ? [] : [schema]; + }), + announcement: () => { + if (!enabled()) return undefined; + const names = loadable() + .map((tool) => tool.name) + .toSorted((a, b) => a.localeCompare(b)); + const namesSet = new Set(names); + const added = names.filter((name) => !announced.has(name)); + const removed = [...announced] + .filter((name) => !namesSet.has(name)) + .toSorted((a, b) => a.localeCompare(b)); + if (added.length === 0 && removed.length === 0) return undefined; + pendingAnnouncement = { added, removed }; + return renderLoadableToolsAnnouncement(added, removed); + }, + markSchemasLanded: () => { + for (const name of pending) landed.add(name); + pending.clear(); + }, + markAnnounced: () => { + if (pendingAnnouncement === undefined) return; + for (const name of pendingAnnouncement.added) announced.add(name); + for (const name of pendingAnnouncement.removed) announced.delete(name); + pendingAnnouncement = undefined; + }, + reset: () => { + pending.clear(); + landed.clear(); + announced.clear(); + pendingAnnouncement = undefined; + }, + }; +} diff --git a/packages/agent-core-v2/src/human/tool-select/tool.ts b/packages/agent-core-v2/src/human/tool-select/tool.ts new file mode 100644 index 00000000000..bf3e99ead1f --- /dev/null +++ b/packages/agent-core-v2/src/human/tool-select/tool.ts @@ -0,0 +1,87 @@ +import { defineTool, type ToolDefinition } from '#/tool/tool'; + +import { SELECT_TOOLS_TOOL_NAME, type ToolSelectState } from './state'; + +const DESCRIPTION = + 'Load one or more tools by name so you can call them. ' + + 'All available tool names are listed in the / announcements ' + + 'in the system context — fold them in order to get the current list. ' + + 'Pass the exact name(s) you need; their full definitions become available immediately, ' + + 'so you can call them directly in your next tool call.'; + +export function createSelectToolsTool(state: ToolSelectState): ToolDefinition { + return defineTool({ + name: SELECT_TOOLS_TOOL_NAME, + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + names: { + type: 'array', + description: 'Exact tool names to load, taken from the latest announced tool list.', + items: { type: 'string' }, + minItems: 1, + }, + }, + required: ['names'], + additionalProperties: false, + }, + async execute({ toolCall }) { + if (!state.enabled()) { + return { + content: [ + { type: 'text', text: 'select_tools is not available for the current model.' }, + ], + isError: true, + }; + } + const args = JSON.parse(toolCall.arguments ?? '{}') as { names?: unknown }; + const names = Array.isArray(args.names) + ? args.names.filter((name): name is string => typeof name === 'string') + : []; + if (names.length === 0) { + return { + content: [{ type: 'text', text: 'Provide at least one tool name in names.' }], + isError: true, + }; + } + const { toLoad, alreadyAvailable, unknown } = state.load(names); + const lines: string[] = []; + if (toLoad.length > 0) lines.push(`Loaded: ${toLoad.join(', ')}`); + if (alreadyAvailable.length > 0) { + lines.push(`Already available: ${alreadyAvailable.join(', ')}`); + } + for (const name of unknown) { + lines.push(`Unknown tool: ${name}. Pick from the latest announced tools list.`); + } + const isError = toLoad.length === 0 && alreadyAvailable.length === 0; + return { + content: [{ type: 'text', text: lines.join('\n') }], + isError: isError ? true : undefined, + }; + }, + }); +} + +export function deferTool(tool: ToolDefinition, state: ToolSelectState): ToolDefinition { + return defineTool({ + ...tool, + deferred: true, + async execute(input) { + if (!state.isLoaded(tool.name)) { + return { + content: [ + { + type: 'text', + text: + `Tool "${tool.name}" is available but not loaded. ` + + `Call select_tools with ["${tool.name}"] first, then call the tool.`, + }, + ], + isError: true, + }; + } + return tool.execute(input); + }, + }); +} diff --git a/packages/agent-core-v2/src/human/tool/executor.ts b/packages/agent-core-v2/src/human/tool/executor.ts new file mode 100644 index 00000000000..441f6250968 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool/executor.ts @@ -0,0 +1,40 @@ +import type { ContentPart, ToolCall } from '#/llm/message'; + +export interface ToolUpdate { + key: string; + text: string; + percent?: number; +} + +export interface ToolResult { + content: ContentPart[]; + isError?: boolean; +} + +export interface ToolDetachAck { + text: string; +} + +export interface TaskWaitInput { + taskId?: string; + timeoutMs: number; +} + +export interface TaskWaitOutcome { + completed: string[]; + running: string[]; + unknown: string[]; + timedOut: boolean; +} + +export interface ToolExecuteInput { + toolCall: ToolCall; + signal: AbortSignal; + onUpdate?: (update: ToolUpdate) => void; + detach?: (ack: ToolDetachAck) => void; + waitForTasks?: (input: TaskWaitInput) => Promise; +} + +export interface ToolExecutor { + execute(input: ToolExecuteInput): Promise; +} diff --git a/packages/agent-core-v2/src/human/tool/machine.ts b/packages/agent-core-v2/src/human/tool/machine.ts new file mode 100644 index 00000000000..b3cfb9c510e --- /dev/null +++ b/packages/agent-core-v2/src/human/tool/machine.ts @@ -0,0 +1,264 @@ +import { assign, emit, fromCallback, fromPromise, sendTo, setup } from '#/xstate2'; + +import type { ToolCall } from '#/llm/message'; + +import type { TaskWaitInput, TaskWaitOutcome, ToolExecutor, ToolResult, ToolUpdate } from './executor'; + +export interface ToolInput { + toolCall: ToolCall; + waitForTasks?: (input: TaskWaitInput) => Promise; +} + +export type ToolEvent = + | { type: 'tool.update'; toolCallId: string; update: ToolUpdate } + | { type: 'tool.async'; toolCallId: string; text: string } + | { type: 'tool.done'; toolCallId: string; result: ToolResult } + | { type: 'tool.failed'; toolCallId: string; error: unknown } + | { type: 'tool.aborted'; toolCallId: string } + | { type: 'tool.abort' }; + +export type ToolOutput = + | { type: 'succeeded'; result: ToolResult } + | { type: 'failed'; error: unknown } + | { type: 'aborted' }; + +export interface ToolBeforeInput { + toolCall: ToolCall; +} + +export type ToolBeforeDecision = + | { type: 'proceed'; toolCall?: ToolCall } + | { type: 'denied'; result: ToolResult }; + +export interface ToolAfterInput { + toolCall: ToolCall; + result: ToolResult; +} + +export interface ToolMachineContext { + input: ToolInput; + toolCall: ToolCall; + outcome?: 'succeeded' | 'failed' | 'aborted'; + result?: ToolResult; + error?: unknown; +} + +function createExecuteActor(executor: ToolExecutor) { + return fromCallback(({ input, sendBack, receive }) => { + const controller = new AbortController(); + const toolCallId = input.toolCall.id; + let detached = false; + receive((event) => { + if (event.type === 'tool.abort') { + controller.abort(); + } + }); + void (async () => { + try { + const result = await executor.execute({ + toolCall: input.toolCall, + signal: controller.signal, + onUpdate: (update) => sendBack({ type: 'tool.update', toolCallId, update }), + detach: (ack) => { + if (detached) return; + detached = true; + sendBack({ type: 'tool.async', toolCallId, text: ack.text }); + }, + waitForTasks: input.waitForTasks, + }); + sendBack({ type: 'tool.done', toolCallId, result }); + } catch (error) { + if (controller.signal.aborted) { + sendBack({ type: 'tool.aborted', toolCallId }); + } else { + sendBack({ type: 'tool.failed', toolCallId, error }); + } + } + })(); + return () => controller.abort(); + }); +} + +export function createToolMachine(executor: ToolExecutor) { + const executeActor = createExecuteActor(executor); + return setup({ + types: { + input: {} as ToolInput, + context: {} as ToolMachineContext, + events: {} as ToolEvent, + emitted: {} as ToolEvent, + output: {} as ToolOutput, + }, + actors: { + beforeActor: fromPromise( + async ({ input }) => ({ type: 'proceed', toolCall: input.toolCall }), + ), + executeActor, + afterActor: fromPromise(async ({ input }) => input.result), + }, + actions: { + forwardToParent: ({ self, event }) => { + self._parent?.send(event); + }, + }, + }).createMachine({ + id: 'tool', + initial: 'before', + context: ({ input }) => ({ input, toolCall: input.toolCall }), + states: { + before: { + invoke: { + src: 'beforeActor', + input: ({ context }) => ({ toolCall: context.toolCall }), + onDone: [ + { + guard: ({ event }) => event.output.type === 'denied', + target: 'after', + actions: assign({ + result: ({ event }) => (event.output as { result: ToolResult }).result, + }), + }, + { + target: 'executing', + actions: assign({ + toolCall: ({ context, event }) => + (event.output as { toolCall?: ToolCall }).toolCall ?? context.toolCall, + }), + }, + ], + onError: { + target: 'failed', + actions: [ + assign({ outcome: 'failed', error: ({ event }) => event.error }), + emit(({ context, event }) => ({ + type: 'tool.failed' as const, + toolCallId: context.toolCall.id, + error: event.error, + })), + ({ self, context, event }) => { + self._parent?.send({ + type: 'tool.failed', + toolCallId: context.toolCall.id, + error: event.error, + }); + }, + ], + }, + }, + on: { + 'tool.abort': { + target: 'aborted', + actions: [ + assign({ outcome: 'aborted' as const }), + emit(({ context }) => ({ + type: 'tool.aborted' as const, + toolCallId: context.toolCall.id, + })), + ({ self, context }) => { + self._parent?.send({ + type: 'tool.aborted', + toolCallId: context.toolCall.id, + }); + }, + ], + }, + }, + }, + executing: { + invoke: { + id: 'execute', + src: 'executeActor', + input: ({ context }) => ({ + toolCall: context.toolCall, + waitForTasks: context.input.waitForTasks, + }), + }, + on: { + 'tool.update': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'tool.async': { + actions: [emit(({ event }) => event), 'forwardToParent'], + }, + 'tool.done': { + target: 'after', + actions: assign({ result: ({ event }) => event.result }), + }, + 'tool.failed': { + target: 'failed', + actions: [ + assign({ outcome: 'failed', error: ({ event }) => event.error }), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.aborted': { + target: 'aborted', + actions: [ + assign({ outcome: 'aborted' as const }), + emit(({ event }) => event), + 'forwardToParent', + ], + }, + 'tool.abort': { + actions: sendTo('execute', ({ event }) => event), + }, + }, + }, + after: { + invoke: { + src: 'afterActor', + input: ({ context }) => ({ + toolCall: context.toolCall, + result: context.result as ToolResult, + }), + onDone: { + target: 'succeeded', + actions: [ + assign({ outcome: 'succeeded', result: ({ event }) => event.output }), + emit(({ context, event }) => ({ + type: 'tool.done' as const, + toolCallId: context.toolCall.id, + result: event.output, + })), + ({ self, context, event }) => { + self._parent?.send({ + type: 'tool.done', + toolCallId: context.toolCall.id, + result: event.output, + }); + }, + ], + }, + onError: { + target: 'failed', + actions: [ + assign({ outcome: 'failed', error: ({ event }) => event.error }), + emit(({ context, event }) => ({ + type: 'tool.failed' as const, + toolCallId: context.toolCall.id, + error: event.error, + })), + ({ self, context, event }) => { + self._parent?.send({ + type: 'tool.failed', + toolCallId: context.toolCall.id, + error: event.error, + }); + }, + ], + }, + }, + }, + succeeded: { type: 'final' }, + failed: { type: 'final' }, + aborted: { type: 'final' }, + }, + output: ({ context }): ToolOutput => + context.outcome === 'failed' + ? { type: 'failed', error: context.error } + : context.outcome === 'aborted' + ? { type: 'aborted' } + : { type: 'succeeded', result: context.result as ToolResult }, + }); +} diff --git a/packages/agent-core-v2/src/human/tool/tool.ts b/packages/agent-core-v2/src/human/tool/tool.ts new file mode 100644 index 00000000000..254eff336a2 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool/tool.ts @@ -0,0 +1,14 @@ +import type { ToolDescription } from '#/llm/message'; + +import type { ToolExecuteInput, ToolResult } from './executor'; + +export interface ToolDefinition extends ToolDescription { + execute(input: ToolExecuteInput): Promise; +} + +export function defineTool(tool: ToolDefinition): ToolDefinition { + if (tool.name.trim() === '') { + throw new Error('tool name must not be empty'); + } + return Object.freeze(tool); +} diff --git a/packages/agent-core-v2/src/human/tool/wait-for.md b/packages/agent-core-v2/src/human/tool/wait-for.md new file mode 100644 index 00000000000..30ebbc8fa64 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool/wait-for.md @@ -0,0 +1,16 @@ +Wait for background tasks to finish without ending the current turn. + +Use this when your next step depends on the result of a running background task (a sub-agent, a background bash command, or a background AskUserQuestion). The call suspends inside the current turn until the task finishes or the timeout elapses, then returns the outcome so you can keep working in the same turn. While waiting, no LLM requests are made. + +Guidelines: + +- Do not call WaitFor right after dispatching work whose result you do not need yet — finished background tasks notify you automatically. WaitFor is for the moment you genuinely cannot proceed without a result. +- `timeout` is required, in seconds, capped at 600. To wait longer, call WaitFor again; waking up periodically also lets you re-evaluate the situation. +- A timeout is not an error: the result lists the tasks that are still running, and you decide whether to wait again or do other work meanwhile. +- Without `task_id`, the wait ends as soon as any background task that was running at call time finishes. Tasks started during the wait are not covered by it; their completion arrives via the usual automatic notification. +- With `task_id`, the wait ends when that task finishes. An unknown `task_id` is an error; a task that has already finished returns immediately. +- When no background tasks are running, WaitFor returns immediately without waiting. +- When the wait ends because a task finished, the result also lists other tasks that finished during the wait window, so failures surface with context. +- Waiting has no side effects on the waited tasks: WaitFor never stops a task, and interrupting the wait (for example, a user interruption) leaves every task running. +- A finished task's result is delivered exactly once: tasks reported by WaitFor do not also produce an automatic completion notification. +- You can only wait for background tasks started by this agent; task IDs belonging to other agents are unknown here. diff --git a/packages/agent-core-v2/src/human/tool/wait-for.ts b/packages/agent-core-v2/src/human/tool/wait-for.ts new file mode 100644 index 00000000000..03786f6c320 --- /dev/null +++ b/packages/agent-core-v2/src/human/tool/wait-for.ts @@ -0,0 +1,103 @@ +import type { TaskWaitOutcome, ToolResult } from './executor'; +import { defineTool, type ToolDefinition } from './tool'; +import DESCRIPTION from './wait-for.md?raw'; + +export const WAIT_FOR_MAX_TIMEOUT_S = 600; + +interface WaitForArguments { + taskId?: string; + timeoutMs: number; +} + +function parseWaitForArguments( + raw: string | null, +): { args: WaitForArguments; parseError?: undefined } | { args?: undefined; parseError: string } { + let parsed: unknown = {}; + if (raw !== null && raw.trim() !== '') { + try { + parsed = JSON.parse(raw); + } catch { + return { parseError: `invalid WaitFor arguments: ${raw}` }; + } + } + if (typeof parsed !== 'object' || parsed === null) { + return { parseError: `invalid WaitFor arguments: ${raw}` }; + } + const { task_id, timeout } = parsed as { task_id?: unknown; timeout?: unknown }; + if ( + typeof timeout !== 'number' || + !Number.isInteger(timeout) || + timeout < 1 || + timeout > WAIT_FOR_MAX_TIMEOUT_S + ) { + return { parseError: `invalid WaitFor arguments: ${raw}` }; + } + return { + args: { + taskId: typeof task_id === 'string' ? task_id : undefined, + timeoutMs: timeout * 1000, + }, + }; +} + +function formatWaitForOutcome(outcome: TaskWaitOutcome, timeoutMs: number): string { + const lines: string[] = []; + if (outcome.completed.length === 0 && outcome.running.length === 0) { + lines.push('no async tool calls running'); + } + if (outcome.completed.length > 0) { + lines.push(`completed: ${outcome.completed.join(', ')}`); + } + if (outcome.running.length > 0) { + lines.push(`running: ${outcome.running.join(', ')}`); + } + if (outcome.timedOut) { + lines.push(`timedOut after ${timeoutMs} ms`); + } + return lines.join('\n'); +} + +export const waitForTool: ToolDefinition = defineTool({ + name: 'WaitFor', + description: DESCRIPTION, + parameters: { + type: 'object', + properties: { + timeout: { + type: 'integer', + minimum: 1, + maximum: WAIT_FOR_MAX_TIMEOUT_S, + description: `Maximum time to wait, in seconds (1-${String(WAIT_FOR_MAX_TIMEOUT_S)}). A timeout is not an error: the tool returns the tasks that are still running, and you can call it again to keep waiting.`, + }, + task_id: { + type: 'string', + description: + 'The background task ID to wait for. When omitted, the wait ends as soon as any background task that was running at call time finishes.', + }, + }, + required: ['timeout'], + }, + async execute({ toolCall, waitForTasks }): Promise { + const parsed = parseWaitForArguments(toolCall.arguments); + if (parsed.parseError !== undefined) { + return { content: [{ type: 'text', text: parsed.parseError }], isError: true }; + } + if (waitForTasks === undefined) { + return { + content: [{ type: 'text', text: 'WaitFor requires background task support from the agent' }], + isError: true, + }; + } + const outcome = await waitForTasks({ + taskId: parsed.args.taskId, + timeoutMs: parsed.args.timeoutMs, + }); + if (outcome.unknown.length > 0) { + return { + content: [{ type: 'text', text: `Task not found: ${outcome.unknown.join(', ')}` }], + isError: true, + }; + } + return { content: [{ type: 'text', text: formatWaitForOutcome(outcome, parsed.args.timeoutMs) }] }; + }, +}); diff --git a/packages/agent-core-v2/src/human/usage/machine.ts b/packages/agent-core-v2/src/human/usage/machine.ts new file mode 100644 index 00000000000..c18e786ddcd --- /dev/null +++ b/packages/agent-core-v2/src/human/usage/machine.ts @@ -0,0 +1,43 @@ +import { assign, emit, setup } from '#/xstate2'; + +import { accumulateUsage, emptyUsageSummary, type UsageRecord, type UsageSummary } from './usage'; + +export type UsageEvent = { type: 'usage.record'; record: UsageRecord }; + +export type UsageEmitted = { type: 'usage.updated'; record: UsageRecord; summary: UsageSummary }; + +export interface UsageMachineContext { + records: UsageRecord[]; + summary: UsageSummary; +} + +export function createUsageMachine() { + return setup({ + types: { + context: {} as UsageMachineContext, + events: {} as UsageEvent, + emitted: {} as UsageEmitted, + }, + }).createMachine({ + id: 'usage', + context: { + records: [], + summary: emptyUsageSummary(), + }, + on: { + 'usage.record': { + actions: [ + assign(({ context, event }) => ({ + records: [...context.records, event.record], + summary: accumulateUsage(context.summary, event.record), + })), + emit(({ context }) => ({ + type: 'usage.updated' as const, + record: context.records[context.records.length - 1] as UsageRecord, + summary: context.summary, + })), + ], + }, + }, + }); +} diff --git a/packages/agent-core-v2/src/human/usage/plugin.ts b/packages/agent-core-v2/src/human/usage/plugin.ts new file mode 100644 index 00000000000..f862504db46 --- /dev/null +++ b/packages/agent-core-v2/src/human/usage/plugin.ts @@ -0,0 +1,44 @@ +import { createActor, type ActorRefFrom } from '#/xstate2'; + +import type { LlmModel } from '#/llm/model'; +import type { Plugin } from '#/plugin'; + +import { createUsageMachine } from './machine'; + +export type UsageActor = ActorRefFrom>; + +export interface UsagePlugin extends Plugin { + readonly name: 'usage'; + readonly actor: UsageActor; +} + +export function createUsagePlugin(input?: { model?: LlmModel }): UsagePlugin { + const actor = createActor(createUsageMachine()); + actor.start(); + let currentTurnId: number | undefined; + return { + name: 'usage', + actor, + connect(target) { + if (target.kind !== 'agent') return; + target.on('turn.start', (event) => { + if (event.type === 'turn.start') { + currentTurnId = event.turnId; + } + }); + target.on('llm.usage', (event) => { + if (event.type === 'llm.usage') { + actor.send({ + type: 'usage.record', + record: { + usage: event.usage, + model: input?.model, + turnId: currentTurnId, + at: Date.now(), + }, + }); + } + }); + }, + }; +} diff --git a/packages/agent-core-v2/src/human/usage/usage.ts b/packages/agent-core-v2/src/human/usage/usage.ts new file mode 100644 index 00000000000..5d894ce4e54 --- /dev/null +++ b/packages/agent-core-v2/src/human/usage/usage.ts @@ -0,0 +1,49 @@ +import { modelKey, type LlmModel } from '#/llm/model'; +import { emptyUsage, type TokenUsage } from '#/llm/usage'; + +export interface UsageRecord { + usage: Partial; + model?: LlmModel; + turnId?: number; + at: number; +} + +export interface UsageSummary { + total: TokenUsage; + byModel: Record; + byTurn: Record; +} + +export function emptyUsageSummary(): UsageSummary { + return { total: emptyUsage(), byModel: {}, byTurn: {} }; +} + +function addUsage(base: TokenUsage | undefined, usage: Partial): TokenUsage { + const next = base ?? emptyUsage(); + return { + inputOther: next.inputOther + (usage.inputOther ?? 0), + output: next.output + (usage.output ?? 0), + inputCacheRead: next.inputCacheRead + (usage.inputCacheRead ?? 0), + inputCacheCreation: next.inputCacheCreation + (usage.inputCacheCreation ?? 0), + }; +} + +export function accumulateUsage(summary: UsageSummary, record: UsageRecord): UsageSummary { + return { + total: addUsage(summary.total, record.usage), + byModel: + record.model === undefined + ? summary.byModel + : { + ...summary.byModel, + [modelKey(record.model)]: addUsage( + summary.byModel[modelKey(record.model)], + record.usage, + ), + }, + byTurn: + record.turnId === undefined + ? summary.byTurn + : { ...summary.byTurn, [record.turnId]: addUsage(summary.byTurn[record.turnId], record.usage) }, + }; +} diff --git a/packages/agent-core-v2/src/human/xstate2.ts b/packages/agent-core-v2/src/human/xstate2.ts new file mode 100644 index 00000000000..ec4ed0435a3 --- /dev/null +++ b/packages/agent-core-v2/src/human/xstate2.ts @@ -0,0 +1,36 @@ +import { createActor as createXStateActor } from 'xstate'; +import type { Actor, ActorOptions, AnyActorLogic, InspectionEvent } from 'xstate'; + +export * from 'xstate'; + +function reportUnhandled(event: InspectionEvent): void { + if (event.type !== '@xstate.microstep' || event._transitions.length > 0) { + return; + } + if (event.event.type.startsWith('xstate.')) { + return; + } + console.warn( + `[agent-core-v3] unhandled event "${event.event.type}" in actor "${event.actorRef.sessionId}"`, + ); +} + +function createActorWithInspect( + logic: TLogic, + options?: ActorOptions, +): Actor { + const inspect = options?.inspect; + return createXStateActor(logic, { + ...options, + inspect: (event) => { + reportUnhandled(event); + if (typeof inspect === 'function') { + inspect(event); + } else { + inspect?.next?.(event); + } + }, + }); +} + +export const createActor = createActorWithInspect as typeof createXStateActor; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index f37d4b40a0f..c1a9d4785ad 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -121,20 +121,49 @@ export * from '#/session/state/sessionState'; import '#/session/state/sessionStateService'; export * from '#/agent/state/agentState'; import '#/agent/state/agentStateService'; -export * from '#/kosong/contract/capability'; -export * from '#/kosong/contract/errors'; -export * from '#/kosong/contract/message'; -export * from '#/kosong/contract/messageHelpers'; -export * from '#/kosong/contract/tool'; -export * from '#/kosong/contract/usage'; -export * from '#/kosong/contract/provider'; -export * from '#/kosong/contract/generate'; -export * from '#/kosong/contract/requestTrace'; +export * from '#/llm-adapter/contract/capability'; +export * from '#/llm-adapter/contract/errors'; +export { + createAssistantMessage, + createToolMessage, + createUserMessage, + isToolDeclarationOnlyMessage, + mergeInPlace, + type Message, +} from '#/llm-adapter/contract/message'; +export { + extractText, + getTextContent, + isContentPart, + isToolCall, + isToolCallPart, + type AudioURLPart, + type ContentPart, + type ImageURLPart, + type Role, + type StreamedMessagePart, + type TextPart, + type ThinkPart, + type ToolCall, + type ToolCallPart, + type VideoURLPart, +} from '#human/llm/message'; +export type { ToolDescription as Tool } from '#human/llm/message'; +export { addUsage, emptyUsage, grandTotal, inputTotal, type TokenUsage } from '#human/llm/usage'; +export type { FinishReason } from '#human/llm/finish-reason'; export type { - ExtraBody, - GenerationKwargs, - KimiThinkingConfig, -} from '#/kosong/provider/providers/kimi/kimi.contrib'; + JsonObjectResponseFormat, + JsonSchemaObject, + JsonSchemaResponseFormat, + ResponseFormat, +} from '#human/llm/response-format'; +export type { ThinkingEffort, ThinkingRequestOptions } from '#human/llm/thinking'; +export type { VideoUploadInput } from '#human/llm/media/upload'; +export type { ToolCallIdPolicy } from '#human/llm/requester/requester'; +export type { ProviderRequestAuth } from '#/llm-adapter/model/catalog'; +export type { SamplingOptions } from '#/llm-adapter/model/model-requester'; +export * from '#/llm-adapter/contract/request-trace'; +export type { ExtraBody, KimiThinkingConfig } from '#human/llm-kimi/trait'; export * from '#/app/sessionIndex/sessionIndex'; export * from '#/app/sessionIndex/sessionIndexService'; @@ -158,32 +187,31 @@ export * from '#/app/config/configEvents'; export * from '#/app/config/configService'; export * from '#/app/config/configSectionContributions'; import '#/app/kosongConfig/configSection'; -export * from '#/kosong/provider/provider'; -export * from '#/kosong/provider/providerService'; -export * from '#/kosong/provider/providerDefinition'; -export * from '#/kosong/provider/protocolAdapterRegistry'; +export * from '#/llm-adapter/provider/provider'; +export * from '#/llm-adapter/provider/provider-service'; +export * from '#/llm-adapter/provider/provider-definition'; +export * from '#/llm-adapter/protocol/protocolAdapterRegistry'; import '#/features/skill/catalog/configSection'; import '#/app/remoteControl/flag'; import '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/configSection'; export * from '#/app/agentIdentity/agentIdentity'; export * from '#/app/agentIdentity/agentIdentityService'; -import '#/kosong/protocol/errors'; -export * from '#/kosong/protocol/errors'; -export * from '#/kosong/protocol/protocol'; -export * from '#/kosong/protocol/protocolBase'; -export * from '#/kosong/protocol/protocolTrait'; +import '#/llm-adapter/protocol/errors'; +export * from '#/llm-adapter/protocol/errors'; +export * from '#/llm-adapter/protocol/protocol'; +export * from '#/llm-adapter/protocol/protocol-base'; import '#/app/kosongConfig/envOverlay'; -export * from '#/kosong/model/completionBudget'; -export * from '#/kosong/model/hostRequestHeaders'; -export * from '#/kosong/model/model'; -export * from '#/kosong/model/model.types'; -export * from '#/kosong/model/modelService'; -export * from '#/kosong/model/thinking'; -export * from '#/kosong/model/catalog'; -export * from '#/kosong/model/catalogService'; -export * from '#/kosong/model/modelRequester'; -import '#/kosong/model/errors'; +export * from '#/llm-adapter/model/completion-budget'; +export * from '#/llm-adapter/model/host-request-headers'; +export * from '#/llm-adapter/model/model'; +export * from '#/llm-adapter/model/model.types'; +export * from '#/llm-adapter/model/model-service'; +export * from '#/llm-adapter/model/thinking'; +export * from '#/llm-adapter/model/catalog'; +export * from '#/llm-adapter/model/catalog-service'; +export * from '#/llm-adapter/model/model-requester'; +import '#/llm-adapter/model/errors'; export { MODEL_CATALOG_SECTION, ModelCatalogConfigSchema, @@ -191,7 +219,7 @@ export { } from '#/app/kosongConfig/configSection'; export * from '#/app/kosongConfig/kosongConfig'; export * from '#/app/kosongConfig/kosongConfigService'; -export * from '#/kosong/model/modelOAuth'; +export * from '#/llm-adapter/model/model-oauth'; export * from '#/app/kosongConfig/oauthTokenAdapter'; export * from '#/app/kosongConfig/hostRequestHeadersAdapter'; export * from '#/app/kosongConfig/discovery'; @@ -201,11 +229,6 @@ export * from '#/app/kosongConfig/modelsDevImport'; export * from '#/app/kosongConfig/modelsDevImportService'; export * from '#/app/kosongConfig/modelsDevUpstream'; export * from '#/app/kosongConfig/modelsDev'; -import '#/kosong/provider/bases/anthropic/index'; -import '#/kosong/provider/bases/google-genai/index'; -import '#/kosong/provider/bases/openai/index'; -import '#/kosong/provider/providers/kimi/kimi.contrib'; -import '#/kosong/provider/providers/standard.contrib'; export * from '#/app/agentProfileCatalog/agentProfileCatalog'; export * from '#/app/agentProfileCatalog/agentProfileContribution'; export * from '#/app/agentProfileCatalog/agentProfileRegistry'; @@ -658,9 +681,6 @@ export * from '#/_base/utils/timer'; import '#/agent/loop/configSection'; export * from '#/agent/loop/loop'; export * from '#/agent/loop/loopService'; -export * from '#/agent/loop/loopContinuation'; -export * from '#/agent/loop/loopContinuationService'; -export * from '#/agent/loop/handoffStep'; export * from '#/agent/interruptionReminder/interruptionReminder'; export * from '#/agent/interruptionReminder/interruptionReminderService'; export * from '#/agent/interruptionReminder/interruptionReminderOps'; @@ -716,8 +736,6 @@ export * from '#/agent/shellCommand/shellCommandService'; export * from '#/agent/agentContext/agentContext'; export * from '#/agent/agentContext/agentSpace'; export * from '#/agent/scopeContext/scopeContext'; -export * from '#/agent/stepRetry/stepRetry'; -export * from '#/agent/stepRetry/stepRetryService'; export * from '#/features/sessionInit/sessionInit'; export * from '#/features/sessionInit/sessionInitService'; export * from '#/features/sessionInit/profile/init'; diff --git a/packages/agent-core-v2/src/kosong/contract/generate.ts b/packages/agent-core-v2/src/kosong/contract/generate.ts deleted file mode 100644 index e23aa8404e0..00000000000 --- a/packages/agent-core-v2/src/kosong/contract/generate.ts +++ /dev/null @@ -1,250 +0,0 @@ -import { APIEmptyResponseError, createAbortError } from './errors'; -import { - isContentPart, - isToolCall, - isToolCallPart, - mergeInPlace, - type Message, - type StreamedMessagePart, - type ToolCall, -} from './message'; -import type { ChatProvider, FinishReason, GenerateOptions, StreamedMessage } from './provider'; -import type { Tool } from './tool'; -import type { TokenUsage } from './usage'; - -type StoredToolCall = Omit; - -export interface GenerateResult { - readonly id: string | null; - readonly message: Message; - readonly usage: TokenUsage | null; - readonly finishReason: FinishReason | null; - readonly rawFinishReason: string | null; - readonly traceId?: string | null; -} - -export interface GenerateCallbacks { - onMessagePart?: (part: StreamedMessagePart) => void | Promise; - onToolCall?: (toolCall: ToolCall) => void | Promise; -} - -export async function generate( - provider: ChatProvider, - systemPrompt: string, - tools: Tool[], - history: Message[], - callbacks?: GenerateCallbacks, - options?: GenerateOptions, -): Promise { - const message: Message = { role: 'assistant', content: [], toolCalls: [] }; - let pendingPart: StreamedMessagePart | null = null; - - const toolCallIndexMap = new Map(); - - if (options?.signal?.aborted) { - throw createAbortError(); - } - - const wireTools = tools.some((tool) => tool.deferred === true) - ? tools.filter((tool) => tool.deferred !== true) - : tools; - - options?.onRequestStart?.(); - const stream = await provider.generate(systemPrompt, wireTools, history, options); - if (stream.traceId !== undefined) { - options?.onTraceId?.(stream.traceId); - } - - await throwIfAborted(options?.signal, stream); - - let serverDecodeMs = 0; - let clientConsumeMs = 0; - let firstPartAt: number | undefined; - let lastResumeAt = 0; - - for await (const part of stream) { - const arrivedAt = Date.now(); - if (firstPartAt === undefined) { - firstPartAt = arrivedAt; - } else { - serverDecodeMs += arrivedAt - lastResumeAt; - } - - try { - await throwIfAborted(options?.signal, stream); - - if (callbacks?.onMessagePart !== undefined) { - await callbacks.onMessagePart(deepCopyPart(part)); - await throwIfAborted(options?.signal, stream); - } - - if ( - isToolCallPart(part) && - part.index !== undefined && - !isPendingToolCallAtIndex(pendingPart, part.index) - ) { - const arrayIdx = toolCallIndexMap.get(part.index); - if (arrayIdx !== undefined) { - const target = message.toolCalls[arrayIdx]; - if (target !== undefined && part.argumentsPart !== null) { - target.arguments = - target.arguments === null - ? part.argumentsPart - : target.arguments + part.argumentsPart; - } - continue; - } - } - - if (pendingPart === null) { - pendingPart = part; - } else if (!mergeInPlace(pendingPart, part)) { - flushPart(message, pendingPart, toolCallIndexMap); - pendingPart = part; - } - } finally { - lastResumeAt = Date.now(); - clientConsumeMs += lastResumeAt - arrivedAt; - } - } - - await throwIfAborted(options?.signal, stream); - if (firstPartAt !== undefined) { - serverDecodeMs += Date.now() - lastResumeAt; - } - options?.onStreamEnd?.( - firstPartAt === undefined ? undefined : { serverDecodeMs, clientConsumeMs }, - ); - - if (pendingPart !== null) { - flushPart(message, pendingPart, toolCallIndexMap); - } - if (message.content.length === 0 && message.toolCalls.length === 0) { - throw new APIEmptyResponseError( - 'The API returned an empty response (no content, no tool calls).' + - formatFinishReasonHint(stream) + - ` Provider: ${provider.name}, model: ${provider.modelName}`, - { - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }, - ); - } - - const hasThink = message.content.some((p) => p.type === 'think'); - const hasText = message.content.some((p) => p.type === 'text' && p.text.trim().length > 0); - const hasToolCalls = message.toolCalls.length > 0; - - if (hasThink && !hasText && !hasToolCalls) { - throw new APIEmptyResponseError( - 'The API returned a response containing only thinking content ' + - 'without any text or tool calls. This usually indicates the ' + - 'stream was interrupted or the output token budget was exhausted ' + - 'during reasoning.' + - formatFinishReasonHint(stream) + - ` Provider: ${provider.name}, model: ${provider.modelName}`, - { - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }, - ); - } - - if (callbacks?.onToolCall !== undefined) { - for (const toolCall of message.toolCalls) { - await throwIfAborted(options?.signal, stream); - await callbacks.onToolCall(toolCall); - } - } - - const result: GenerateResult = { - id: stream.id, - message, - usage: stream.usage, - finishReason: stream.finishReason, - rawFinishReason: stream.rawFinishReason, - }; - if (stream.traceId !== undefined) { - return { ...result, traceId: stream.traceId }; - } - return result; -} - -type CancelableStream = StreamedMessage & { - cancel?: () => unknown; - return?: () => unknown; -}; - -async function cancelStream(stream: StreamedMessage): Promise { - const cancelable = stream as CancelableStream; - - try { - await cancelable.cancel?.(); - } catch {} - - try { - await cancelable.return?.(); - } catch {} -} - -async function throwIfAborted(signal?: AbortSignal, stream?: StreamedMessage): Promise { - if (!signal?.aborted) { - return; - } - - if (stream !== undefined) { - await cancelStream(stream); - } - - throw createAbortError(); -} - -function isPendingToolCallAtIndex( - pending: StreamedMessagePart | null, - index: number | string, -): pending is ToolCall { - return pending !== null && isToolCall(pending) && pending._streamIndex === index; -} - -function flushPart( - message: Message, - part: StreamedMessagePart, - toolCallIndexMap: Map, -): void { - if (isContentPart(part)) { - message.content.push(part); - return; - } - if (isToolCall(part)) { - const streamIndex = part._streamIndex; - const stored: StoredToolCall = { - type: 'function', - id: part.id, - name: part.name, - arguments: part.arguments, - extras: part.extras, - }; - const ordinal = message.toolCalls.length; - message.toolCalls.push(stored as ToolCall); - if (streamIndex !== undefined) { - toolCallIndexMap.set(streamIndex, ordinal); - } - } -} - -function formatFinishReasonHint(stream: StreamedMessage): string { - if (stream.finishReason === null && stream.rawFinishReason === null) return ''; - - const raw = - stream.rawFinishReason === null ? '' : `, rawFinishReason=${stream.rawFinishReason}`; - const filteredHint = - stream.finishReason === 'filtered' - ? ' The provider filtered the response before visible output was emitted.' - : ''; - - return ` Provider stop details: finishReason=${stream.finishReason ?? 'unknown'}${raw}.${filteredHint}`; -} - -function deepCopyPart(part: StreamedMessagePart): StreamedMessagePart { - return structuredClone(part); -} diff --git a/packages/agent-core-v2/src/kosong/contract/message.ts b/packages/agent-core-v2/src/kosong/contract/message.ts deleted file mode 100644 index 9c1cca28c0f..00000000000 --- a/packages/agent-core-v2/src/kosong/contract/message.ts +++ /dev/null @@ -1,150 +0,0 @@ -import type { Tool } from './tool'; - -export type Role = 'system' | 'user' | 'assistant' | 'tool'; - -export interface TextPart { - type: 'text'; - text: string; -} - -export interface ThinkPart { - type: 'think'; - think: string; - encrypted?: string; -} - -export interface ImageURLPart { - type: 'image_url'; - imageUrl: { url: string; id?: string; name?: string }; -} - -export interface AudioURLPart { - type: 'audio_url'; - audioUrl: { url: string; id?: string }; -} - -export interface VideoURLPart { - type: 'video_url'; - videoUrl: { url: string; id?: string; name?: string }; -} - -export type ContentPart = TextPart | ThinkPart | ImageURLPart | AudioURLPart | VideoURLPart; - -export interface ToolCall { - type: 'function'; - id: string; - name: string; - arguments: string | null; - extras?: Record; - _streamIndex?: number | string; -} - -export interface ToolCallPart { - type: 'tool_call_part'; - argumentsPart: string | null; - index?: number | string; -} - -export type StreamedMessagePart = ContentPart | ToolCall | ToolCallPart; - -export interface Message { - readonly role: Role; - readonly name?: string; - readonly content: ContentPart[]; - readonly toolCalls: ToolCall[]; - readonly toolCallId?: string; - readonly partial?: boolean; - readonly tools?: readonly Tool[]; -} - -export function isContentPart(part: StreamedMessagePart): part is ContentPart { - const t = part.type; - return ( - t === 'text' || t === 'think' || t === 'image_url' || t === 'audio_url' || t === 'video_url' - ); -} - -export function isToolDeclarationOnlyMessage(message: Message): boolean { - return ( - message.tools !== undefined && - message.tools.length > 0 && - message.content.length === 0 && - message.toolCalls.length === 0 - ); -} - -export function isToolCall(part: StreamedMessagePart): part is ToolCall { - return part.type === 'function'; -} - -export function isToolCallPart(part: StreamedMessagePart): part is ToolCallPart { - return part.type === 'tool_call_part'; -} - -export function mergeInPlace(target: StreamedMessagePart, source: StreamedMessagePart): boolean { - if (target.type === 'text' && source.type === 'text') { - target.text += source.text; - return true; - } - - if (target.type === 'think' && source.type === 'think') { - if (target.encrypted !== undefined) { - return false; - } - target.think += source.think; - if (source.encrypted !== undefined) { - target.encrypted = source.encrypted; - } - return true; - } - - if (target.type === 'function' && source.type === 'tool_call_part') { - if (source.argumentsPart !== null) { - target.arguments = - target.arguments === null - ? source.argumentsPart - : target.arguments + source.argumentsPart; - } - return true; - } - - return false; -} - -export function extractText(message: Message, sep: string = ''): string { - return message.content - .filter((part): part is TextPart => part.type === 'text') - .map((part) => part.text) - .join(sep); -} - -export function getTextContent(message: Message): string { - return extractText(message); -} - -export function createUserMessage(content: string): Message { - return { - role: 'user', - content: [{ type: 'text', text: content }], - toolCalls: [], - }; -} - -export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { - return { - role: 'assistant', - content, - toolCalls: toolCalls ?? [], - }; -} - -export function createToolMessage(toolCallId: string, output: string | ContentPart[]): Message { - const content: ContentPart[] = - typeof output === 'string' ? [{ type: 'text', text: output }] : output; - return { - role: 'tool', - content, - toolCalls: [], - toolCallId, - }; -} diff --git a/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts b/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts deleted file mode 100644 index c65713f7748..00000000000 --- a/packages/agent-core-v2/src/kosong/contract/messageHelpers.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { - createAssistantMessage, - createToolMessage, - createUserMessage, - extractText, - isContentPart, - isToolCall, - isToolCallPart, - isToolDeclarationOnlyMessage, - mergeInPlace, -} from './message'; diff --git a/packages/agent-core-v2/src/kosong/contract/provider.ts b/packages/agent-core-v2/src/kosong/contract/provider.ts deleted file mode 100644 index 1690ff40d83..00000000000 --- a/packages/agent-core-v2/src/kosong/contract/provider.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { Message, StreamedMessagePart, VideoURLPart } from './message'; -import type { Tool } from './tool'; -import type { TokenUsage } from './usage'; - -export type ThinkingEffort = 'off' | 'on' | (string & {}); - -export type JsonSchemaObject = Record; - -export interface JsonObjectResponseFormat { - readonly type: 'json_object'; -} - -export interface JsonSchemaResponseFormat { - readonly type: 'json_schema'; - readonly jsonSchema: { - readonly name: string; - readonly schema: JsonSchemaObject; - readonly strict?: boolean; - readonly description?: string; - }; -} - -export type ResponseFormat = JsonObjectResponseFormat | JsonSchemaResponseFormat; - -export type FinishReason = - | 'completed' - | 'tool_calls' - | 'truncated' - | 'filtered' - | 'paused' - | 'other'; - -export interface StreamedMessage { - [Symbol.asyncIterator](): AsyncIterator; - readonly id: string | null; - readonly usage: TokenUsage | null; - readonly finishReason: FinishReason | null; - readonly rawFinishReason: string | null; - readonly traceId?: string | null; -} - -export interface ProviderRequestAuth { - apiKey?: string; - headers?: Record; -} - -export interface SamplingOptions { - readonly temperature?: number; - readonly topP?: number; -} - -export interface ThinkingRequestOptions { - readonly effort: ThinkingEffort; - readonly keep?: string; -} - -export interface ToolCallIdPolicy { - normalize: (id: string) => string; - maxLength?: number; -} - -export interface StreamDecodeStats { - readonly serverDecodeMs: number; - readonly clientConsumeMs: number; -} - -export interface VideoUploadInput { - readonly data: Uint8Array; - readonly mimeType: string; - readonly filename?: string | undefined; -} - -export interface GenerateOptions { - signal?: AbortSignal; - auth?: ProviderRequestAuth; - responseFormat?: ResponseFormat; - cacheKey?: string; - sampling?: SamplingOptions; - thinking?: ThinkingRequestOptions; - maxCompletionTokens?: number; - usedContextTokens?: number; - maxContextTokens?: number; - onRequestStart?: () => void; - onRequestSent?: () => void; - onStreamEnd?: (stats?: StreamDecodeStats) => void; - onTraceId?: (traceId: string | null) => void; -} - -export interface ChatProvider { - readonly name: string; - readonly modelName: string; - readonly thinkingEffort: ThinkingEffort | null; - readonly maxCompletionTokens?: number; - generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise; - uploadVideo?(input: string | VideoUploadInput, options?: GenerateOptions): Promise; -} diff --git a/packages/agent-core-v2/src/kosong/contract/tool.ts b/packages/agent-core-v2/src/kosong/contract/tool.ts deleted file mode 100644 index b070862b4de..00000000000 --- a/packages/agent-core-v2/src/kosong/contract/tool.ts +++ /dev/null @@ -1,6 +0,0 @@ -export interface Tool { - name: string; - description: string; - parameters: Record; - deferred?: true; -} diff --git a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts b/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts deleted file mode 100644 index 2cc41870ed0..00000000000 --- a/packages/agent-core-v2/src/kosong/model/modelRequesterImpl.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { AsyncEventQueue } from '#/_base/asyncEventQueue'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import { APIStatusError, isAbortError, VideoUploadUnsupportedError } from '#/kosong/contract/errors'; -import { generate, type GenerateResult } from '#/kosong/contract/generate'; -import type { - ChatProvider, - GenerateOptions, - ProviderRequestAuth, - StreamDecodeStats, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import { translateProviderError } from '#/kosong/protocol/errors'; -import type { IProtocolAdapterRegistry } from '#/kosong/protocol/protocol'; - -import type { AuthProvider, Model } from './catalog'; -import type { - ModelRequestEvent, - ModelRequestInput, - ModelRequestParams, - ModelRequester, - ModelRequestTiming, -} from './modelRequester'; - -export class ModelRequesterImpl implements ModelRequester { - private cachedChatProvider: ChatProvider | undefined; - - constructor( - readonly model: Model, - private readonly protocolRegistry: IProtocolAdapterRegistry, - ) {} - - private resolveChatProvider(): ChatProvider { - if (this.cachedChatProvider !== undefined) return this.cachedChatProvider; - const model = this.model; - this.cachedChatProvider = this.protocolRegistry.createChatProvider({ - protocol: model.protocol, - providerType: model.providerType, - baseUrl: model.baseUrl, - modelName: model.name, - defaultHeaders: model.headers, - providerOptions: model.providerOptions, - }); - return this.cachedChatProvider; - } - - request( - input: ModelRequestInput, - signal?: AbortSignal, - params?: ModelRequestParams, - ): AsyncIterable { - const queue = new AsyncEventQueue(); - void this.runRequest(input, signal, queue, params).then( - () => queue.end(), - (error) => queue.fail(error), - ); - return queue; - } - - async uploadVideo( - input: string | VideoUploadInput, - options?: { readonly signal?: AbortSignal }, - ): Promise { - const provider = this.resolveChatProvider(); - if (provider.uploadVideo === undefined) { - throw new VideoUploadUnsupportedError( - `Model "${this.model.id}" (protocol=${this.model.protocol}) does not support video upload`, - ); - } - const uploadVideo = provider.uploadVideo.bind(provider); - return this.runWithAuthRefresh((auth) => - uploadVideo(input, { signal: options?.signal, auth }), - ); - } - - private async runRequest( - input: ModelRequestInput, - signal: AbortSignal | undefined, - queue: AsyncEventQueue, - params?: ModelRequestParams, - ): Promise { - signal?.throwIfAborted(); - const provider = this.resolveChatProvider(); - - let requestStartedAt = Date.now(); - let requestSentAt: number | undefined; - let firstChunkAt: number | undefined; - let streamEndedAt: number | undefined; - let decodeStats: StreamDecodeStats | undefined; - - const options: GenerateOptions = { - signal, - cacheKey: params?.cacheKey, - sampling: params?.sampling, - thinking: - params?.thinkingEffort === undefined - ? undefined - : { effort: params.thinkingEffort, keep: params.thinkingKeep }, - maxCompletionTokens: params?.maxCompletionTokens, - usedContextTokens: params?.usedContextTokens, - maxContextTokens: params?.maxContextTokens, - onRequestStart: () => { - requestStartedAt = Date.now(); - }, - onRequestSent: () => { - requestSentAt = Date.now(); - }, - onStreamEnd: (stats) => { - streamEndedAt = Date.now(); - decodeStats = stats; - }, - onTraceId: params?.onTraceId, - responseFormat: input.responseFormat, - }; - - let result: GenerateResult; - try { - result = await this.runWithAuthRefresh((auth) => { - requestStartedAt = Date.now(); - return generate( - provider, - input.systemPrompt, - [...input.tools], - [...input.messages], - { - onMessagePart: (part) => { - firstChunkAt ??= Date.now(); - queue.push({ type: 'part', part }); - }, - }, - { ...options, auth }, - ); - }); - } catch (error) { - if (isAbortError(error) || signal?.aborted === true) throw error; - throw translateProviderError(error); - } - - if (result.usage !== undefined && result.usage !== null) { - queue.push({ type: 'usage', usage: result.usage, model: this.model.name }); - } - queue.push({ - type: 'finish', - message: result.message, - providerFinishReason: result.finishReason ?? undefined, - rawFinishReason: result.rawFinishReason ?? undefined, - id: result.id ?? undefined, - traceId: result.traceId ?? undefined, - }); - if (firstChunkAt !== undefined) { - queue.push({ - type: 'timing', - ...buildStreamTiming( - requestStartedAt, - requestSentAt, - firstChunkAt, - streamEndedAt, - decodeStats, - ), - }); - } - } - - private async runWithAuthRefresh( - run: (auth: ProviderRequestAuth | undefined) => Promise, - ): Promise { - const auth = await this.authProvider.getAuth(); - try { - return await run(auth); - } catch (error) { - if (!this.shouldForceRefresh(error)) throw error; - } - - const refreshedAuth = await this.authProvider.getAuth({ force: true }); - try { - return await run(refreshedAuth); - } catch (error) { - if (isUnauthorizedStatusError(error)) throw translateProviderError(error); - throw error; - } - } - - private get authProvider(): AuthProvider { - return this.model.authProvider; - } - - private shouldForceRefresh(error: unknown): boolean { - return this.authProvider.canRefresh === true && isUnauthorizedStatusError(error); - } -} - -function isUnauthorizedStatusError(error: unknown): error is APIStatusError { - return error instanceof APIStatusError && error.statusCode === 401; -} - -type MutableModelRequestTiming = { -readonly [K in keyof ModelRequestTiming]: ModelRequestTiming[K] }; - -export function buildStreamTiming( - requestStartedAt: number, - requestSentAt: number | undefined, - firstChunkAt: number, - streamEndedAt: number | undefined, - decodeStats: StreamDecodeStats | undefined, -): ModelRequestTiming { - const outputEndedAt = streamEndedAt ?? Date.now(); - const timing: MutableModelRequestTiming = { - firstTokenLatencyMs: Math.max(0, firstChunkAt - requestStartedAt), - streamDurationMs: Math.max(0, outputEndedAt - firstChunkAt), - }; - if (requestSentAt !== undefined) { - const sentAt = Math.min(Math.max(requestSentAt, requestStartedAt), firstChunkAt); - timing.requestBuildMs = sentAt - requestStartedAt; - timing.serverFirstTokenMs = firstChunkAt - sentAt; - } - if (decodeStats !== undefined) { - timing.serverDecodeMs = Math.max(0, decodeStats.serverDecodeMs); - timing.clientConsumeMs = Math.max(0, decodeStats.clientConsumeMs); - } - return timing; -} diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts b/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts deleted file mode 100644 index 199800346ea..00000000000 --- a/packages/agent-core-v2/src/kosong/protocol/protocolBase.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { BugIndicatingError } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ChatProvider } from '#/kosong/contract/provider'; - -import type { Protocol, ProtocolAdapterConfig } from './protocol'; -import type { ResolvedTrait } from './protocolTrait'; - -export type ProtocolBaseId = Protocol; - -export interface ProtocolBaseContext { - readonly config: ProtocolAdapterConfig; - readonly traits: readonly ResolvedTrait[]; -} - -export interface ProtocolBaseDefinition { - readonly id: ProtocolBaseId; - capability?(modelName: string): ModelCapability | undefined; - createChatProvider(context: ProtocolBaseContext): ChatProvider; -} - -export interface ResolvedAdapterIdentity { - readonly baseId: ProtocolBaseId; - readonly traits: readonly ResolvedTrait[]; -} - -const protocolBases = new Map(); - -export function registerProtocolBase(definition: ProtocolBaseDefinition): void { - if (protocolBases.has(definition.id)) { - throw new BugIndicatingError(`protocol base '${definition.id}' is already registered`); - } - protocolBases.set(definition.id, definition); -} - -export function getProtocolBase(id: ProtocolBaseId): ProtocolBaseDefinition | undefined { - return protocolBases.get(id); -} - -export function listProtocolBases(): readonly ProtocolBaseDefinition[] { - return [...protocolBases.values()]; -} diff --git a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts b/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts deleted file mode 100644 index 3670b4a4c4a..00000000000 --- a/packages/agent-core-v2/src/kosong/protocol/protocolTrait.ts +++ /dev/null @@ -1,119 +0,0 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ChatProviderError } from '#/kosong/contract/errors'; -import type { Message, VideoURLPart } from '#/kosong/contract/message'; -import type { - GenerateOptions, - ThinkingEffort, - ToolCallIdPolicy, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; - -import type { ProtocolAdapterConfig } from './protocol'; - -export interface TraitContext { - readonly config: ProtocolAdapterConfig; - readonly providerId?: string; -} - -export interface ProtocolEndpoint { - readonly apiKeyEnv?: string; - readonly baseUrlEnv?: string; - readonly defaultBaseUrl?: string; -} - -export interface ProtocolTrait { - readonly strictThinkingValidation?: boolean; - - provides?(ctx: TraitContext): Record | undefined; - - endpoint?(ctx: TraitContext): ProtocolEndpoint | undefined; - - defaultHeaders?(ctx: TraitContext): Record | undefined; - - convertTool?(tool: Tool, ctx: TraitContext): Record | undefined; - - convertMessage?( - message: Message, - converted: Record, - ctx: TraitContext, - ): Record | null; - - mergeHistory?( - messages: readonly Record[], - ctx: TraitContext, - ): Record[] | undefined; - - buildParams?( - params: Record, - ctx: TraitContext, - ): Record | undefined; - - toolCallIdPolicy?(ctx: TraitContext): ToolCallIdPolicy | undefined; - - convertError?(error: unknown, ctx: TraitContext): ChatProviderError | undefined; - - withThinking?( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: Record, - ctx: TraitContext, - ): Record | undefined; - - preserveThinking?( - generationKwargs: Record, - ctx: TraitContext, - ): boolean | undefined; - - withMaxCompletionTokens?( - maxCompletionTokens: number, - ctx: TraitContext, - ): Record | undefined; - - cacheKey?(key: string, ctx: TraitContext): Record | undefined; - - extractUsage?( - chunk: Record, - ctx: TraitContext, - ): Record | null | undefined; - - reasoningKey?(ctx: TraitContext): string | undefined; - - capability?(modelName: string, ctx: TraitContext): ModelCapability | undefined; - - uploadVideo?( - input: string | VideoUploadInput, - options: GenerateOptions | undefined, - ctx: TraitContext, - ): Promise; -} - -export interface ResolvedTrait { - readonly trait: ProtocolTrait; - readonly context: TraitContext; -} - -export function traitDefaultHeaders( - traits: readonly ResolvedTrait[], -): Record | undefined { - let headers: Record | undefined; - for (const { trait, context } of traits) { - if (trait.defaultHeaders === undefined) continue; - const declared = trait.defaultHeaders(context); - if (declared === undefined) continue; - headers = { ...headers, ...declared }; - } - return headers; -} - -export function traitConvertError( - traits: readonly ResolvedTrait[], -): ((error: unknown) => ChatProviderError | undefined) | undefined { - let bound: ((error: unknown) => ChatProviderError | undefined) | undefined; - for (const { trait, context } of traits) { - if (trait.convertError === undefined) continue; - const declared = trait.convertError.bind(trait); - bound = (error) => declared(error, context); - } - return bound; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts deleted file mode 100644 index 64fc2d70b5e..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.contrib.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { AnthropicChatProvider, getAnthropicModelCapability } from './anthropic'; -import { composeAnthropicHooks } from './anthropicHooks'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from '../openai/openaiHooks'; - -registerProtocolBase({ - id: 'anthropic', - capability: getAnthropicModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new AnthropicChatProvider({ - ...(traitProvides(traits) as Partial[0]>), - model: config.modelName, - ...compactObject({ - apiKey: config.apiKey ?? firstProcessEnv(endpoint?.apiKeyEnv), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - defaultMaxTokens: config.providerOptions?.defaultMaxTokens, - adaptiveThinking: config.providerOptions?.adaptiveThinking, - supportEfforts: config.providerOptions?.supportEfforts, - betaApi: config.providerOptions?.betaApi, - metadata: - config.providerOptions?.metadata === undefined - ? undefined - : { ...config.providerOptions.metadata }, - hooks: composeAnthropicHooks(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts deleted file mode 100644 index 77576d00fcc..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropic.ts +++ /dev/null @@ -1,1185 +0,0 @@ -import Anthropic, { - APIError as AnthropicAPIError, - APIConnectionError as AnthropicConnectionError, - AnthropicError, - APIConnectionTimeoutError as AnthropicTimeoutError, -} from '@anthropic-ai/sdk'; -import type { - Tool as AnthropicTool, - ContentBlockParam, - MessageCreateParams, - MessageCreateParamsStreaming, - MessageParam, - MessageStreamEvent, - RawContentBlockDeltaEvent, - RawContentBlockStartEvent, - RawMessageStartEvent, - TextBlockParam, - ThinkingBlockParam, - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages/messages.js'; - -import { - APIConnectionError, - APITimeoutError, - ChatProviderError, - classifyBaseApiError, - normalizeAPIStatusError, - parseRetryAfterMs, - throwIfAbortError, -} from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, -} from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { - BUDGET_THINKING_EFFORTS, - inferAnthropicModelProfile, - matchKnownAnthropicModelProfile, - parseAnthropicModelVersion, - type AnthropicModelProfile, - type AnthropicModelVersion, -} from './anthropic-profile'; -import { mergeConsecutiveUserMessages } from '../merge-user-messages'; -import { mergeRequestHeaders, resolveAuthBackedClient } from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; - -function normalizeAnthropicStopReason(raw: string | null | undefined): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - switch (raw) { - case 'end_turn': - case 'stop_sequence': - return { finishReason: 'completed', rawFinishReason: raw }; - case 'max_tokens': - return { finishReason: 'truncated', rawFinishReason: raw }; - case 'tool_use': - return { finishReason: 'tool_calls', rawFinishReason: raw }; - case 'pause_turn': - return { finishReason: 'paused', rawFinishReason: raw }; - case 'refusal': - return { finishReason: 'filtered', rawFinishReason: raw }; - default: - return { finishReason: 'other', rawFinishReason: raw }; - } -} - -export interface AnthropicGenerationKwargs { - max_tokens?: number | undefined; - temperature?: number | undefined; - top_k?: number | undefined; - top_p?: number | undefined; - thinking?: MessageCreateParams['thinking'] | undefined; - output_config?: MessageCreateParams['output_config'] | undefined; - betaFeatures?: string[] | undefined; - contextManagement?: AnthropicContextManagement; -} - -interface AnthropicContextManagement { - edits: Array<{ type: string; keep?: unknown }>; -} - -export interface AnthropicHooks { - withThinking?( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: AnthropicGenerationKwargs, - ): AnthropicGenerationKwargs | undefined; - convertError?: (error: unknown) => ChatProviderError | undefined; -} - -export interface AnthropicOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - defaultMaxTokens?: number | undefined; - betaFeatures?: string[] | undefined; - defaultHeaders?: Record; - metadata?: Record | undefined; - stream?: boolean | undefined; - adaptiveThinking?: boolean | undefined; - supportEfforts?: readonly string[] | undefined; - betaApi?: boolean | undefined; - thinkingEffort?: ThinkingEffort | undefined; - clientFactory?: (auth: ProviderRequestAuth) => Anthropic; - hooks?: AnthropicHooks | undefined; -} - -const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; -const CONTEXT_MANAGEMENT_BETA = 'context-management-2025-06-27'; -const CLEAR_THINKING_EDIT = 'clear_thinking_20251015'; -const ANTHROPIC_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), - maxLength: 64, -}; - -function applyResponseFormat( - kwargs: Record, - format: ResponseFormat | undefined, -): void { - if (format === undefined) return; - if (format.type === 'json_object') { - throw new ChatProviderError( - 'Anthropic provider requires a JSON schema for structured response output.', - ); - } - const outputConfig = - kwargs['output_config'] !== undefined && kwargs['output_config'] !== null - ? { ...(kwargs['output_config'] as Record) } - : {}; - outputConfig['format'] = { - type: 'json_schema', - schema: format.jsonSchema.schema, - }; - kwargs['output_config'] = outputConfig; -} - -const CEILING_BY_FAMILY_VERSION: Readonly> = { - 'fable-5': 128000, - 'mythos-5': 128000, - 'opus-4-8': 128000, - 'opus-4-7': 128000, - 'opus-4-6': 128000, - 'opus-4-5': 64000, - 'opus-4-1': 32000, - 'opus-4-0': 32000, - 'opus-4': 32000, - 'sonnet-5': 128000, - 'sonnet-4-6': 128000, - 'sonnet-4-5': 64000, - 'sonnet-4-0': 64000, - 'sonnet-4': 64000, - 'haiku-4-5': 64000, - 'haiku-4': 64000, - 'opus-3-5': 8192, - 'sonnet-3-5': 8192, - 'sonnet-3-7': 8192, - 'haiku-3-5': 8192, - 'opus-3': 4096, - 'sonnet-3': 4096, - 'haiku-3': 4096, -}; - -const FALLBACK_MAX_TOKENS = 128000; - -function lookupClaudeCeiling(version: AnthropicModelVersion): number | undefined { - const { family, major, minor } = version; - if (minor !== null) { - for (let candidate = minor; candidate >= 0; candidate--) { - const ceiling = CEILING_BY_FAMILY_VERSION[`${family}-${major}-${candidate}`]; - if (ceiling !== undefined) return ceiling; - } - } - return CEILING_BY_FAMILY_VERSION[`${family}-${major}`]; -} - -export function resolveDefaultMaxTokens(model: string, override?: number): number { - const parsed = parseAnthropicModelVersion(model, true); - const ceiling = parsed === null ? undefined : lookupClaudeCeiling(parsed); - if (ceiling === undefined) { - return override ?? FALLBACK_MAX_TOKENS; - } - return override === undefined ? ceiling : Math.min(override, ceiling); -} - -function requiresAdaptiveThinking(efforts: readonly string[]): boolean { - return efforts.some((effort) => effort !== 'low' && effort !== 'medium' && effort !== 'high'); -} - -function resolveThinkingProfile( - model: string, - supportEfforts: readonly string[] | undefined, - adaptiveThinking: boolean | undefined, -): AnthropicModelProfile { - const inferred = inferAnthropicModelProfile(model); - if (adaptiveThinking === false) { - return { - ...inferred, - mode: 'budget', - efforts: supportEfforts ?? BUDGET_THINKING_EFFORTS, - supportsEffortParam: false, - }; - } - - if (adaptiveThinking === true) { - return { - ...inferred, - mode: 'adaptive', - efforts: supportEfforts ?? inferred.efforts, - supportsEffortParam: true, - }; - } - - if (supportEfforts === undefined) { - return inferred; - } - return { - ...inferred, - mode: requiresAdaptiveThinking(supportEfforts) ? 'adaptive' : inferred.mode, - efforts: supportEfforts, - supportsEffortParam: requiresAdaptiveThinking(supportEfforts) || inferred.supportsEffortParam, - }; -} - -function budgetTokensForEffort(effort: ThinkingEffort): number | undefined { - if (effort === 'low') return 1024; - if (effort === 'medium') return 4096; - if (effort === 'on' || effort === 'high') return 32_000; - return undefined; -} - -const CACHE_CONTROL = { type: 'ephemeral' as const }; - -type CacheableBlock = ContentBlockParam & { cache_control?: { type: 'ephemeral' } }; - -function shouldPreserveUnsignedThinking(model: string): boolean { - return ( - parseAnthropicModelVersion(model) === null && - matchKnownAnthropicModelProfile(model) === undefined - ); -} - -const CACHEABLE_TYPES = new Set([ - 'text', - 'image', - 'document', - 'search_result', - 'tool_use', - 'tool_result', - 'server_tool_use', - 'web_search_tool_result', -]); - -function injectCacheControlOnLastBlock(messages: MessageParam[]): void { - const lastMessage = messages.at(-1); - if (lastMessage === undefined) return; - const content = lastMessage.content; - if (!Array.isArray(content) || content.length === 0) return; - const lastBlock = content.at(-1) as CacheableBlock | undefined; - if (lastBlock === undefined) return; - if (CACHEABLE_TYPES.has(lastBlock.type)) { - lastBlock.cache_control = CACHE_CONTROL; - } -} - -function isToolResultOnly(message: MessageParam): boolean { - if (message.role !== 'user') return false; - const content = message.content; - if (!Array.isArray(content) || content.length === 0) return false; - return content.every((block) => block.type === 'tool_result'); -} - -interface AnthropicImageBlock { - type: 'image'; - source: { type: 'base64'; data: string; media_type: string } | { type: 'url'; url: string }; - cache_control?: { type: 'ephemeral' }; -} - -interface AnthropicVideoBlock { - type: 'video'; - source: { type: 'base64'; media_type: string; data: string } | { type: 'url'; url: string }; -} - -const OMITTED_MEDIA_PLACEHOLDER = { - audio_url: '(audio omitted: not supported by this provider)', -} as const; - -const SUPPORTED_B64_MEDIA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); - -const SUPPORTED_B64_VIDEO_TYPES = new Set([ - 'video/mp4', - 'video/mpeg', - 'video/quicktime', - 'video/webm', - 'video/x-matroska', - 'video/x-msvideo', - 'video/x-flv', - 'video/3gpp', -]); - -function imageUrlPartToAnthropic(url: string): AnthropicImageBlock { - if (url.startsWith('data:')) { - const withoutScheme = url.slice(5); - const parts = withoutScheme.split(';base64,', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { - throw new ChatProviderError(`Invalid data URL for image: ${url}`); - } - const mediaType = parts[0]; - const data = parts[1]; - if (!SUPPORTED_B64_MEDIA_TYPES.has(mediaType)) { - throw new ChatProviderError( - `Unsupported media type for base64 image: ${mediaType}, url: ${url}`, - ); - } - return { - type: 'image', - source: { type: 'base64', data, media_type: mediaType }, - }; - } - return { - type: 'image', - source: { type: 'url', url }, - }; -} - -function videoUrlPartToAnthropic(url: string): AnthropicVideoBlock { - if (url.startsWith('data:')) { - const withoutScheme = url.slice(5); - const parts = withoutScheme.split(';base64,', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) { - throw new ChatProviderError(`Invalid data URL for video: ${url}`); - } - const mediaType = parts[0]; - const data = parts[1]; - if (!SUPPORTED_B64_VIDEO_TYPES.has(mediaType)) { - throw new ChatProviderError( - `Unsupported media type for base64 video: ${mediaType}, url: ${url}`, - ); - } - return { - type: 'video', - source: { type: 'base64', media_type: mediaType, data }, - }; - } - - return { - type: 'video', - source: { type: 'url', url }, - }; -} - -interface AnthropicToolParam extends AnthropicTool { - cache_control?: { type: 'ephemeral' } | null; -} - -function convertTool(tool: Tool): AnthropicToolParam { - return { - name: tool.name, - description: tool.description, - input_schema: tool.parameters as AnthropicTool['input_schema'], - }; -} - -function toolResultToBlock(toolCallId: string, content: ContentPart[]): ToolResultBlockParam { - const blocks: Array = []; - for (const part of content) { - if (part.type === 'text') { - if (part.text) { - blocks.push({ type: 'text', text: part.text }); - } - } else if (part.type === 'image_url') { - blocks.push(imageUrlPartToAnthropic(part.imageUrl.url)); - } else if (part.type === 'video_url') { - blocks.push(videoUrlPartToAnthropic(part.videoUrl.url)); - } else if (part.type === 'audio_url') { - const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; - const last = blocks.at(-1); - if (!(last?.type === 'text' && last.text === placeholder)) { - blocks.push({ type: 'text', text: placeholder }); - } - } - } - return { - type: 'tool_result', - tool_use_id: toolCallId, - content: blocks, - } as ToolResultBlockParam; -} - -function convertMessage(message: Message, model: string): MessageParam { - const role = message.role; - - if (role === 'system') { - const text = message.content - .filter((p) => p.type === 'text') - .map((p) => p.text) - .join('\n'); - return { - role: 'user', - content: [{ type: 'text', text: `${text}` }], - }; - } - - if (role === 'tool') { - if (message.toolCallId === undefined) { - throw new ChatProviderError('Tool message missing `toolCallId`.'); - } - const block = toolResultToBlock(message.toolCallId, message.content); - return { role: 'user', content: [block as ContentBlockParam] }; - } - - const blocks: ContentBlockParam[] = []; - for (const part of message.content) { - if (part.type === 'text') { - blocks.push({ type: 'text', text: part.text } satisfies TextBlockParam); - } else if (part.type === 'image_url') { - blocks.push(imageUrlPartToAnthropic(part.imageUrl.url) as unknown as ContentBlockParam); - } else if (part.type === 'think') { - if (part.encrypted !== undefined) { - blocks.push({ - type: 'thinking', - thinking: part.think, - signature: part.encrypted, - } satisfies ThinkingBlockParam); - } else if (shouldPreserveUnsignedThinking(model)) { - blocks.push({ type: 'thinking', thinking: part.think } as unknown as ThinkingBlockParam); - } - } else if (part.type === 'video_url') { - blocks.push(videoUrlPartToAnthropic(part.videoUrl.url) as unknown as ContentBlockParam); - } else if (part.type === 'audio_url') { - const placeholder = OMITTED_MEDIA_PLACEHOLDER[part.type]; - const last = blocks.at(-1); - if (!(last?.type === 'text' && last.text === placeholder)) { - blocks.push({ type: 'text', text: placeholder } satisfies TextBlockParam); - } - } - } - - if (message.toolCalls.length > 0) { - for (const tc of message.toolCalls) { - let toolInput: Record = {}; - if (tc.arguments) { - try { - const parsed: unknown = JSON.parse(tc.arguments); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - toolInput = parsed as Record; - } else { - throw new ChatProviderError('Tool call arguments must be a JSON object.'); - } - } catch (error) { - if (error instanceof ChatProviderError) throw error; - throw new ChatProviderError('Tool call arguments must be valid JSON.'); - } - } - blocks.push({ - type: 'tool_use', - id: tc.id, - name: tc.name, - input: toolInput, - } satisfies ToolUseBlockParam); - } - } - - return { role: role, content: blocks }; -} - -function shouldKeepConvertedMessage(message: MessageParam): boolean { - return message.role !== 'assistant' || message.content.length > 0; -} - -export function convertAnthropicError( - error: unknown, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - throwIfAbortError(error); - if (error instanceof ChatProviderError) { - return error; - } - const hooked = convertErrorHook?.(error); - if (hooked !== undefined) { - return hooked; - } - if (error instanceof AnthropicTimeoutError) { - return new APITimeoutError(error.message); - } - if (error instanceof AnthropicConnectionError) { - return new APIConnectionError(error.message); - } - if (error instanceof AnthropicAPIError && typeof error.status === 'number') { - const reqId = error.requestID ?? null; - return normalizeAPIStatusError( - error.status, - error.message, - reqId, - parseRetryAfterMs(error.headers), - ); - } - if (error instanceof AnthropicError) { - return new ChatProviderError(`Anthropic error: ${error.message}`); - } - if (error instanceof Error) { - return classifyBaseApiError(error.message); - } - return new ChatProviderError(`Error: ${String(error)}`); -} - -class AnthropicStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage = { - inputOther: 0, - output: 0, - inputCacheRead: 0, - inputCacheCreation: 0, - }; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: unknown, - isStream: boolean, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse(response as AsyncIterable); - } else { - this._iter = this._convertNonStreamResponse( - response as { - id: string; - stop_reason?: string | null; - usage: { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - content: Array<{ - type: string; - text?: string; - thinking?: string; - signature?: string; - data?: string; - id?: string; - name?: string; - input?: unknown; - }>; - }, - ); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureStopReason(raw: string | null | undefined): void { - const normalized = normalizeAnthropicStopReason(raw); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _extractUsage(usage: { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }): void { - this._usage = { - inputOther: usage.input_tokens ?? 0, - output: usage.output_tokens ?? 0, - inputCacheRead: usage.cache_read_input_tokens ?? 0, - inputCacheCreation: usage.cache_creation_input_tokens ?? 0, - }; - } - - private async *_convertNonStreamResponse(response: { - id: string; - stop_reason?: string | null; - usage: { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }; - content: Array<{ - type: string; - text?: string; - thinking?: string; - signature?: string; - data?: string; - id?: string; - name?: string; - input?: unknown; - }>; - }): AsyncGenerator { - this._id = response.id; - this._extractUsage(response.usage); - this._captureStopReason(response.stop_reason); - - for (const block of response.content) { - switch (block.type) { - case 'text': - if (block.text !== undefined) { - yield { type: 'text', text: block.text }; - } - break; - case 'thinking': - yield block.signature !== undefined - ? { type: 'think' as const, think: block.thinking ?? '', encrypted: block.signature } - : { type: 'think' as const, think: block.thinking ?? '' }; - break; - case 'redacted_thinking': - yield block.data !== undefined - ? { type: 'think' as const, think: '', encrypted: block.data } - : { type: 'think' as const, think: '' }; - break; - case 'tool_use': - yield { - type: 'function', - id: block.id ?? crypto.randomUUID(), - name: block.name ?? '', - arguments: block.input !== undefined ? JSON.stringify(block.input) : null, - } satisfies ToolCall; - break; - } - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - ): AsyncGenerator { - const toolUseBlockIndexes = new Set(); - - try { - for await (const event of response) { - const evt = event as unknown as Record; - const eventType = evt['type'] as string; - - if (eventType === 'message_start') { - const startEvt = evt as unknown as RawMessageStartEvent; - this._id = startEvt.message.id; - this._extractUsage( - startEvt.message.usage as { - input_tokens?: number; - output_tokens?: number; - cache_read_input_tokens?: number; - cache_creation_input_tokens?: number; - }, - ); - } else if (eventType === 'content_block_start') { - const blockEvt = evt as unknown as RawContentBlockStartEvent; - const block = blockEvt.content_block; - const blockIndex = blockEvt.index; - switch (block.type) { - case 'text': - yield { type: 'text', text: block.text }; - break; - case 'thinking': - yield { type: 'think', think: block.thinking ?? '' }; - break; - case 'redacted_thinking': - yield { - type: 'think', - think: '', - encrypted: (block as unknown as { data: string }).data, - }; - break; - case 'tool_use': - toolUseBlockIndexes.add(blockIndex); - yield { - type: 'function', - id: block.id, - name: block.name, - arguments: '', - _streamIndex: blockIndex, - } satisfies ToolCall; - break; - } - } else if (eventType === 'content_block_delta') { - const deltaEvt = evt as unknown as RawContentBlockDeltaEvent; - const delta = deltaEvt.delta; - const blockIndex = deltaEvt.index; - switch (delta.type) { - case 'text_delta': - yield { type: 'text', text: delta.text }; - break; - case 'thinking_delta': - yield { type: 'think', think: delta.thinking ?? '' }; - break; - case 'input_json_delta': - yield { - type: 'tool_call_part', - argumentsPart: delta.partial_json, - index: blockIndex, - }; - break; - case 'signature_delta': - yield { - type: 'think', - think: '', - encrypted: delta.signature, - }; - break; - } - } else if (eventType === 'content_block_stop') { - } else if (eventType === 'message_delta') { - const deltaUsage = (evt as { usage?: Record }).usage; - if (deltaUsage !== undefined) { - if (typeof deltaUsage['output_tokens'] === 'number') { - this._usage.output = deltaUsage['output_tokens']; - } - if (typeof deltaUsage['cache_read_input_tokens'] === 'number') { - this._usage.inputCacheRead = deltaUsage['cache_read_input_tokens']; - } - if (typeof deltaUsage['cache_creation_input_tokens'] === 'number') { - this._usage.inputCacheCreation = deltaUsage['cache_creation_input_tokens']; - } - if (typeof deltaUsage['input_tokens'] === 'number') { - this._usage.inputOther = deltaUsage['input_tokens']; - } - } - const messageDeltaPayload = (evt as { delta?: Record }).delta; - if (messageDeltaPayload !== undefined && 'stop_reason' in messageDeltaPayload) { - this._captureStopReason( - messageDeltaPayload['stop_reason'] as string | null | undefined, - ); - } - } - } - } catch (error: unknown) { - throw convertAnthropicError(error, this._convertErrorHook); - } - } -} - -export class AnthropicChatProvider implements ChatProvider { - readonly name: string = 'anthropic'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _client: Anthropic | undefined; - private readonly _generationKwargs: AnthropicGenerationKwargs; - private readonly _metadata: Record | undefined; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => Anthropic) | undefined; - private readonly _adaptiveThinking: boolean | undefined; - private readonly _supportEfforts: readonly string[] | undefined; - private readonly _betaApi: boolean; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _explicitMaxTokens: boolean; - private readonly _hooks: AnthropicHooks | undefined; - - constructor(options: AnthropicOptions) { - this._model = options.model; - this._stream = options.stream ?? true; - this._metadata = options.metadata; - this._adaptiveThinking = options.adaptiveThinking; - this._supportEfforts = options.supportEfforts; - this._betaApi = options.betaApi ?? false; - this._thinkingEffort = options.thinkingEffort; - this._hooks = options.hooks; - this._apiKey = - options.apiKey === undefined || options.apiKey.length === 0 ? undefined : options.apiKey; - this._baseUrl = options.baseUrl; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - this._explicitMaxTokens = options.defaultMaxTokens !== undefined; - this._generationKwargs = { - max_tokens: options.defaultMaxTokens ?? resolveDefaultMaxTokens(options.model), - betaFeatures: options.betaFeatures ?? [INTERLEAVED_THINKING_BETA], - }; - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const system: TextBlockParam[] | undefined = systemPrompt - ? [ - { - type: 'text', - text: systemPrompt, - cache_control: CACHE_CONTROL, - } as TextBlockParam, - ] - : undefined; - - const messages = mergeConsecutiveUserMessages( - normalizeToolCallIdsForProvider( - history.filter((msg) => !isToolDeclarationOnlyMessage(msg)), - ANTHROPIC_TOOL_CALL_ID_POLICY, - ) - .map((msg) => convertMessage(msg, this._model)) - .filter(shouldKeepConvertedMessage), - { - isUser: (message) => message.role === 'user', - isToolResultOnly, - merge: (last, next) => ({ - ...last, - content: [ - ...(last.content as ContentBlockParam[]), - ...(next.content as ContentBlockParam[]), - ], - }), - }, - ); - - injectCacheControlOnLastBlock(messages); - - let kwargs: AnthropicGenerationKwargs = { ...this._generationKwargs }; - let useBetaApi = this._betaApi; - - let metadata = this._metadata; - if (options?.cacheKey !== undefined) { - metadata = { ...metadata, user_id: options.cacheKey }; - } - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - const hooked = this._hooks?.withThinking?.( - thinking.effort, - { keep: thinking.keep }, - { ...kwargs }, - ); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - kwargs = { ...kwargs, ...this._encodeThinking(thinking.effort, kwargs) }; - } - if (thinking.keep !== undefined) { - kwargs = { ...kwargs, ...applyThinkingKeep(kwargs, thinking.keep) }; - useBetaApi = true; - } - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - cap = Math.max(1, cap); - const requestedCap = resolveDefaultMaxTokens(this._model, cap); - const existingCap = kwargs.max_tokens; - kwargs = { - ...kwargs, - max_tokens: - existingCap === undefined || this._explicitMaxTokens - ? (existingCap ?? requestedCap) - : Math.min(existingCap, requestedCap), - }; - } - - const requestKwargs: Record = {}; - if (kwargs.max_tokens !== undefined) { - requestKwargs['max_tokens'] = kwargs.max_tokens; - } - if (kwargs.temperature !== undefined) { - requestKwargs['temperature'] = kwargs.temperature; - } - if (kwargs.top_k !== undefined) { - requestKwargs['top_k'] = kwargs.top_k; - } - if (kwargs.top_p !== undefined) { - requestKwargs['top_p'] = kwargs.top_p; - } - if (kwargs.thinking !== undefined) { - requestKwargs['thinking'] = kwargs.thinking; - } - if (kwargs.output_config !== undefined) { - requestKwargs['output_config'] = kwargs.output_config; - } - if (kwargs.contextManagement !== undefined) { - requestKwargs['context_management'] = kwargs.contextManagement; - } - applyResponseFormat(requestKwargs, options?.responseFormat); - - const betas = kwargs.betaFeatures ?? []; - const extraHeaders: Record = {}; - if (!useBetaApi && betas.length > 0) { - extraHeaders['anthropic-beta'] = betas.join(','); - } - - const anthropicTools: AnthropicToolParam[] = tools.map((t) => convertTool(t)); - if (anthropicTools.length > 0) { - const lastTool = anthropicTools.at(-1); - if (lastTool !== undefined) { - lastTool.cache_control = CACHE_CONTROL; - } - } - - const createParams: Record = { - model: this._model, - messages, - ...requestKwargs, - }; - - if (system !== undefined) { - createParams['system'] = system; - } - - if (anthropicTools.length > 0) { - createParams['tools'] = anthropicTools; - } - - if (metadata !== undefined) { - createParams['metadata'] = metadata; - } - - if (useBetaApi && betas.length > 0) { - createParams['betas'] = betas; - } - - const requestOptions: Record = {}; - const headers = mergeRequestHeaders(extraHeaders, options?.auth?.headers); - if (headers !== undefined) { - requestOptions['headers'] = headers; - } - if (options?.signal) { - requestOptions['signal'] = options.signal; - } - const finalRequestOptions = Object.keys(requestOptions).length > 0 ? requestOptions : undefined; - const client = this._createClient(options?.auth); - options?.onRequestSent?.(); - - if (this._stream) { - try { - const stream = useBetaApi - ? await client.beta.messages.create( - { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, - finalRequestOptions, - ) - : await client.messages.create( - { ...createParams, stream: true } as unknown as MessageCreateParamsStreaming, - finalRequestOptions, - ); - return new AnthropicStreamedMessage(stream, true, this._hooks?.convertError); - } catch (error: unknown) { - throw convertAnthropicError(error, this._hooks?.convertError); - } - } - - try { - const response = useBetaApi - ? await client.beta.messages.create( - { ...createParams, stream: false } as unknown as MessageCreateParams, - finalRequestOptions, - ) - : await client.messages.create( - { ...createParams, stream: false } as unknown as MessageCreateParams, - finalRequestOptions, - ); - return new AnthropicStreamedMessage(response, false, this._hooks?.convertError); - } catch (error: unknown) { - throw convertAnthropicError(error, this._hooks?.convertError); - } - } - - private _encodeThinking( - effort: ThinkingEffort, - kwargs: AnthropicGenerationKwargs, - ): AnthropicGenerationKwargs { - const profile = resolveThinkingProfile( - this._model, - this._supportEfforts, - this._adaptiveThinking, - ); - - let newBetas = [...(kwargs.betaFeatures ?? [])]; - if (profile.mode === 'adaptive') { - newBetas = newBetas.filter((b) => b !== INTERLEAVED_THINKING_BETA); - } - - if (effort === 'off') { - return { - thinking: { type: 'disabled' }, - output_config: undefined, - betaFeatures: newBetas, - }; - } - - if (profile.mode === 'adaptive') { - return { - thinking: { type: 'adaptive', display: 'summarized' }, - output_config: - effort === 'on' ? undefined : ({ effort } as MessageCreateParams['output_config']), - betaFeatures: newBetas, - }; - } - - const budgetTokens = budgetTokensForEffort(effort); - const patch: AnthropicGenerationKwargs = { - thinking: - budgetTokens === undefined - ? ({ type: 'enabled' } as MessageCreateParams['thinking']) - : { type: 'enabled', budget_tokens: budgetTokens }, - betaFeatures: newBetas, - }; - if ((profile.supportsEffortParam || budgetTokens === undefined) && effort !== 'on') { - patch.output_config = { effort } as MessageCreateParams['output_config']; - } else { - patch.output_config = undefined; - } - return patch; - } - - private _createClient(auth: ProviderRequestAuth | undefined): Anthropic { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => this._buildClient(this._requireApiKey(a)), - ); - } - - private _requireApiKey(auth: ProviderRequestAuth | undefined): string { - const apiKey = auth?.apiKey ?? this._apiKey; - if (apiKey === undefined || apiKey.length === 0) { - throw new ChatProviderError( - 'AnthropicChatProvider: apiKey is required. Provide it via constructor options, options.auth.apiKey on each request, or an OAuth login. The Anthropic adapter does not read shell API-key environment variables.', - ); - } - return apiKey; - } - - private _anthropicCustomHeaderEnvNames(): string[] { - const customHeaders = process.env['ANTHROPIC_CUSTOM_HEADERS']; - if (customHeaders === undefined || customHeaders.length === 0) return []; - - const names: string[] = []; - for (const line of customHeaders.split('\n')) { - const colonIndex = line.indexOf(':'); - if (colonIndex < 0) continue; - - const name = line.slice(0, colonIndex).trim().toLowerCase(); - if (name.length > 0) names.push(name); - } - return names; - } - - private _buildDefaultHeaders(apiKey: string): Record { - const defaultHeaders: Record = { authorization: null }; - for (const name of this._anthropicCustomHeaderEnvNames()) { - defaultHeaders[name] = null; - } - for (const [name, value] of Object.entries(this._defaultHeaders ?? {})) { - defaultHeaders[name.toLowerCase()] = value; - } - defaultHeaders['x-api-key'] = apiKey; - return defaultHeaders; - } - - private _buildClient(apiKey: string): Anthropic { - return new Anthropic({ - apiKey, - authToken: null, - baseURL: this._baseUrl ?? null, - defaultHeaders: this._buildDefaultHeaders(apiKey), - maxRetries: 0, - }); - } -} - -function applyThinkingKeep( - kwargs: AnthropicGenerationKwargs, - keep: string, -): AnthropicGenerationKwargs { - const current = kwargs.betaFeatures ?? []; - const betaFeatures = current.includes(CONTEXT_MANAGEMENT_BETA) - ? current - : [...current, CONTEXT_MANAGEMENT_BETA]; - const existingEdits = kwargs.contextManagement?.edits ?? []; - const edits = [ - { type: CLEAR_THINKING_EDIT, keep }, - ...existingEdits.filter((edit) => edit.type !== CLEAR_THINKING_EDIT), - ]; - return { - contextManagement: { edits }, - betaFeatures, - }; -} - -const CLAUDE_VISION_TOOL_PREFIXES = ['claude-3-', 'claude-3.5-', 'claude-3.7-'] as const; - -const CLAUDE_THINKING_VISION_TOOL_PREFIXES = [ - 'claude-opus-4', - 'claude-sonnet-4', - 'claude-haiku-4', - 'claude-fable', -] as const; - -const ANTHROPIC_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -const ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export function getAnthropicModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (CLAUDE_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return ANTHROPIC_VISION_TOOL_CAPABILITY; - } - if (CLAUDE_THINKING_VISION_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return ANTHROPIC_THINKING_VISION_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts deleted file mode 100644 index 1f266e102da..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/anthropicHooks.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { traitConvertError, type ResolvedTrait } from '#/kosong/protocol/protocolTrait'; - -import type { AnthropicHooks } from './anthropic'; - -export function composeAnthropicHooks( - traits: readonly ResolvedTrait[], -): AnthropicHooks | undefined { - const hooks: AnthropicHooks = {}; - - const thinkingTraits = traits.filter(({ trait }) => trait.withThinking !== undefined); - if (thinkingTraits.length > 0) { - const { trait, context } = thinkingTraits.at(-1)!; - hooks.withThinking = (effort, options, kwargs) => - trait.withThinking!(effort, options, { ...kwargs }, context); - } - - const convertError = traitConvertError(traits); - if (convertError !== undefined) { - hooks.convertError = convertError; - } - - return Object.keys(hooks).length > 0 ? hooks : undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts deleted file mode 100644 index 7c82d9fe97f..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/anthropic/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './anthropic.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts deleted file mode 100644 index b9d2624690f..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getGoogleGenAIModelCapability, GoogleGenAIChatProvider } from './google-genai'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from '../openai/openaiHooks'; - -registerProtocolBase({ - id: 'google-genai', - capability: getGoogleGenAIModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new GoogleGenAIChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - vertexai: config.providerOptions?.vertexai, - project: config.providerOptions?.project, - location: config.providerOptions?.location, - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts deleted file mode 100644 index b6bc1d5c0b1..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/google-genai.ts +++ /dev/null @@ -1,923 +0,0 @@ -import { ApiError as GoogleApiError, GoogleGenAI as GenAIClient } from '@google/genai'; - -import { - APIConnectionError, - APITimeoutError, - ChatProviderError, - normalizeAPIStatusError, -} from '#/kosong/contract/errors'; -import type { Message, StreamedMessagePart, ThinkPart, ToolCall } from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { mergeConsecutiveUserMessages } from '../merge-user-messages'; -import { requireProviderApiKey, resolveAuthBackedClient } from '../request-auth'; - -function normalizeGoogleGenAIFinishReason(raw: unknown): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - let rawString: string; - if (typeof raw === 'string') { - rawString = raw.toUpperCase(); - } else if (typeof raw === 'number' || typeof raw === 'bigint' || typeof raw === 'boolean') { - rawString = String(raw).toUpperCase(); - } else { - return { finishReason: null, rawFinishReason: null }; - } - if (rawString === 'FINISH_REASON_UNSPECIFIED' || rawString === '') { - return { finishReason: null, rawFinishReason: null }; - } - switch (rawString) { - case 'STOP': - return { finishReason: 'completed', rawFinishReason: rawString }; - case 'MAX_TOKENS': - return { finishReason: 'truncated', rawFinishReason: rawString }; - case 'SAFETY': - case 'RECITATION': - case 'BLOCKLIST': - case 'PROHIBITED_CONTENT': - case 'SPII': - case 'IMAGE_SAFETY': - return { finishReason: 'filtered', rawFinishReason: rawString }; - case 'MALFORMED_FUNCTION_CALL': - case 'OTHER': - case 'LANGUAGE': - return { finishReason: 'other', rawFinishReason: rawString }; - default: - return { finishReason: 'other', rawFinishReason: rawString }; - } -} - -export interface GoogleGenAIOptions { - apiKey?: string | undefined; - model: string; - baseUrl?: string; - vertexai?: boolean | undefined; - project?: string | undefined; - location?: string | undefined; - stream?: boolean | undefined; - thinkingEffort?: ThinkingEffort | undefined; - defaultHeaders?: Record; - clientFactory?: (auth: ProviderRequestAuth) => GenAIClient; -} - -export interface GoogleGenAIGenerationKwargs { - maxOutputTokens?: number; - temperature?: number; - topK?: number; - topP?: number; - thinkingConfig?: ThinkingConfig; - [key: string]: unknown; -} - -interface ThinkingConfig { - includeThoughts?: boolean; - thinkingBudget?: number; - thinkingLevel?: string; -} - -interface GoogleFunctionDeclaration { - name: string; - description: string; - parametersJsonSchema: Record; -} - -interface GoogleTool { - functionDeclarations: GoogleFunctionDeclaration[]; -} - -function toolToGoogleGenAI(tool: Tool): GoogleTool { - return { - functionDeclarations: [ - { - name: tool.name, - description: tool.description, - parametersJsonSchema: tool.parameters, - }, - ], - }; -} - -function applyResponseFormat( - config: Record, - format: ResponseFormat | undefined, -): void { - if (format === undefined) return; - config['responseMimeType'] = 'application/json'; - delete config['responseSchema']; - delete config['responseJsonSchema']; - if (format.type === 'json_schema') { - config['responseJsonSchema'] = format.jsonSchema.schema; - } -} - -interface GoogleContent { - role: string; - parts: GooglePart[]; -} - -interface GooglePart { - text?: string; - thought?: boolean; - functionCall?: { name: string; args: Record }; - functionResponse?: { - name: string; - response: Record; - parts: unknown[]; - }; - thoughtSignature?: string; - [key: string]: unknown; -} - -function toolCallIdToName(toolCallId: string, toolNameById: Map): string { - const name = toolNameById.get(toolCallId); - if (name !== undefined) return name; - const withoutEntropy = toolCallId.replace(/_[0-9a-f]{8}$/, ''); - const match = /^(.+)_[^_]+$/.exec(withoutEntropy); - return match?.[1] ?? withoutEntropy; -} - -function convertMediaUrl( - url: string, - fallbackMimeType: string, -): - | { inlineData: { mimeType: string; data: string } } - | { fileData: { fileUri: string; mimeType: string } } { - if (url.startsWith('data:')) { - const commaIndex = url.indexOf(','); - if (commaIndex === -1) { - return { fileData: { fileUri: url, mimeType: fallbackMimeType } }; - } - const meta = url.slice(0, commaIndex); - const data = url.slice(commaIndex + 1); - const colonIndex = meta.indexOf(':'); - const semiIndex = meta.indexOf(';'); - const mimeType = - colonIndex !== -1 && semiIndex !== -1 - ? meta.slice(colonIndex + 1, semiIndex) - : fallbackMimeType; - return { inlineData: { mimeType, data } }; - } - let mimeType = fallbackMimeType; - try { - const pathname = new URL(url).pathname.toLowerCase(); - if (pathname.endsWith('.png')) mimeType = 'image/png'; - else if (pathname.endsWith('.jpg') || pathname.endsWith('.jpeg')) mimeType = 'image/jpeg'; - else if (pathname.endsWith('.gif')) mimeType = 'image/gif'; - else if (pathname.endsWith('.webp')) mimeType = 'image/webp'; - else if (pathname.endsWith('.mp3') || pathname.endsWith('.mpeg')) mimeType = 'audio/mpeg'; - else if (pathname.endsWith('.wav')) mimeType = 'audio/wav'; - else if (pathname.endsWith('.ogg')) mimeType = 'audio/ogg'; - } catch {} - return { fileData: { fileUri: url, mimeType } }; -} - -function createAbortError(): DOMException { - return new DOMException('The operation was aborted.', 'AbortError'); -} - -async function abortPromise(signal: AbortSignal | undefined): Promise { - if (signal === undefined) { - return new Promise(() => {}); - } - if (signal.aborted) { - throw createAbortError(); - } - return new Promise((_, reject) => { - signal.addEventListener( - 'abort', - () => { - reject(createAbortError()); - }, - { once: true }, - ); - }); -} - -function messageToGoogleGenAI(message: Message): GoogleContent { - if (message.role === 'tool') { - throw new ChatProviderError( - 'Tool messages must be converted via messagesToGoogleGenAIContents.', - ); - } - - const role = message.role === 'assistant' ? 'model' : message.role; - const parts: GooglePart[] = []; - - for (const part of message.content) { - switch (part.type) { - case 'text': - parts.push({ text: part.text }); - break; - case 'think': { - const thoughtPart: GooglePart = { text: part.think, thought: true }; - if (part.encrypted !== undefined && part.encrypted.length > 0) { - thoughtPart.thoughtSignature = part.encrypted; - } - parts.push(thoughtPart); - break; - } - case 'image_url': - parts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); - break; - case 'audio_url': - parts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); - break; - case 'video_url': - parts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); - break; - } - } - - for (const toolCall of message.toolCalls) { - let args: Record = {}; - if (toolCall.arguments) { - try { - const parsed: unknown = JSON.parse(toolCall.arguments); - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - args = parsed as Record; - } else { - throw new ChatProviderError('Tool call arguments must be a JSON object.'); - } - } catch (error) { - if (error instanceof ChatProviderError) throw error; - throw new ChatProviderError('Tool call arguments must be valid JSON.'); - } - } - - const functionCallPart: GooglePart = { - functionCall: { - name: toolCall.name, - args, - }, - }; - - if (toolCall.extras && 'thought_signature_b64' in toolCall.extras) { - functionCallPart['thoughtSignature'] = toolCall.extras['thought_signature_b64'] as string; - } - - parts.push(functionCallPart); - } - - return { role, parts }; -} - -function toolMessageToFunctionResponseParts( - message: Message, - toolNameById: Map, -): GooglePart[] { - if (message.role !== 'tool') { - throw new ChatProviderError('Expected a tool message.'); - } - if (message.toolCallId === undefined) { - throw new ChatProviderError('Tool response is missing `toolCallId`.'); - } - - let textOutput = ''; - const mediaParts: GooglePart[] = []; - for (const part of message.content) { - switch (part.type) { - case 'text': - if (part.text) textOutput += part.text; - break; - case 'image_url': - mediaParts.push(convertMediaUrl(part.imageUrl.url, 'image/jpeg')); - break; - case 'audio_url': - mediaParts.push(convertMediaUrl(part.audioUrl.url, 'audio/mpeg')); - break; - case 'video_url': - mediaParts.push(convertMediaUrl(part.videoUrl.url, 'video/mp4')); - break; - case 'think': - break; - } - } - - const functionResponsePart: GooglePart = { - functionResponse: { - name: toolCallIdToName(message.toolCallId, toolNameById), - response: { output: textOutput }, - parts: [], - }, - }; - - return [functionResponsePart, ...mediaParts]; -} - -export function messagesToGoogleGenAIContents(messages: Message[]): GoogleContent[] { - const contents: GoogleContent[] = []; - const toolNameById = new Map(); - - let i = 0; - while (i < messages.length) { - const message = messages[i]; - if (message === undefined) break; - - if (isToolDeclarationOnlyMessage(message)) { - i += 1; - continue; - } - - if (message.role === 'system') { - const text = message.content - .filter((p): p is { type: 'text'; text: string } => p.type === 'text') - .map((p) => p.text) - .join('\n'); - if (text.length > 0) { - contents.push({ - role: 'user', - parts: [{ text: `${text}` }], - }); - } - i += 1; - continue; - } - - if (message.role === 'assistant' && message.toolCalls.length > 0) { - contents.push(messageToGoogleGenAI(message)); - const expectedToolCallIds: string[] = []; - for (const toolCall of message.toolCalls) { - toolNameById.set(toolCall.id, toolCall.name); - expectedToolCallIds.push(toolCall.id); - } - - let j = i + 1; - const toolMessages: Message[] = []; - while (j < messages.length) { - const toolMsg = messages[j]; - if (toolMsg === undefined || toolMsg.role !== 'tool') break; - toolMessages.push(toolMsg); - j += 1; - } - - if (toolMessages.length > 0) { - const toolMsgById = new Map(); - const seenToolCallIds = new Set(); - for (const toolMsg of toolMessages) { - if (toolMsg.toolCallId === undefined) { - throw new ChatProviderError('Tool response is missing `toolCallId`.'); - } - if (seenToolCallIds.has(toolMsg.toolCallId)) { - throw new ChatProviderError(`Duplicate tool response for id: ${toolMsg.toolCallId}`); - } - seenToolCallIds.add(toolMsg.toolCallId); - toolMsgById.set(toolMsg.toolCallId, toolMsg); - } - - const sortedToolMessages: Message[] = []; - for (const expectedId of expectedToolCallIds) { - const msg = toolMsgById.get(expectedId); - if (msg === undefined) { - throw new ChatProviderError(`Missing tool responses for ids: ${expectedId}`); - } - sortedToolMessages.push(msg); - toolMsgById.delete(expectedId); - } - if (toolMsgById.size > 0) { - throw new ChatProviderError( - `Unexpected tool responses for ids: ${JSON.stringify([...toolMsgById.keys()])}`, - ); - } - - const parts: GooglePart[] = []; - for (const toolMsg of sortedToolMessages) { - parts.push(...toolMessageToFunctionResponseParts(toolMsg, toolNameById)); - } - contents.push({ role: 'user', parts }); - i = j; - continue; - } - - i += 1; - continue; - } - - if (message.role === 'tool') { - const parts: GooglePart[] = toolMessageToFunctionResponseParts(message, toolNameById); - contents.push({ role: 'user', parts }); - i += 1; - continue; - } - - contents.push(messageToGoogleGenAI(message)); - i += 1; - } - - return mergeConsecutiveUserMessages(contents, { - isUser: (content) => content.role === 'user', - isToolResultOnly: (content) => - content.parts.length > 0 && - content.parts.every((part) => part.functionResponse !== undefined), - merge: (last, next) => { - const lastStartsWithFunctionResponse = - last.parts[0]?.functionResponse !== undefined; - const nextHasFunctionResponse = next.parts.some( - (part) => part.functionResponse !== undefined, - ); - if (lastStartsWithFunctionResponse && !nextHasFunctionResponse) { - return { ...next, parts: [...next.parts, ...last.parts] }; - } - return { ...last, parts: [...last.parts, ...next.parts] }; - }, - }); -} - -export class GoogleGenAIStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: AsyncIterable> | Record, - isStream: boolean, - signal?: AbortSignal, - ) { - if (isStream) { - this._iter = this._convertStreamResponse( - response as AsyncIterable>, - signal, - ); - } else { - this._iter = this._convertNonStreamResponse(response as Record, signal); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReason(response: Record): void { - const candidates = response['candidates'] as unknown[] | undefined; - if (!candidates || candidates.length === 0) { - return; - } - const first = candidates[0] as Record | undefined; - if (first === undefined) { - return; - } - const raw = first['finishReason'] ?? first['finish_reason']; - if (raw === undefined) { - return; - } - const normalized = normalizeGoogleGenAIFinishReason(raw); - if (normalized.finishReason !== null || normalized.rawFinishReason !== null) { - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - } - - private _extractChunkParts(response: Record): StreamedMessagePart[] { - const parts: StreamedMessagePart[] = []; - - const candidates = response['candidates'] as unknown[] | undefined; - for (const candidate of candidates ?? []) { - const cand = candidate as Record; - const content = cand['content'] as Record | undefined; - const contentParts = content?.['parts'] as unknown[] | undefined; - if (!contentParts) continue; - - for (const part of contentParts) { - const p = part as Record; - if (p['thought'] === true && typeof p['text'] === 'string') { - const thoughtSignature = p['thoughtSignature'] ?? p['thought_signature']; - const thinkPart: ThinkPart = { type: 'think', think: p['text'] }; - if (typeof thoughtSignature === 'string' && thoughtSignature.length > 0) { - thinkPart.encrypted = thoughtSignature; - } - parts.push(thinkPart); - } else if (p['text']) { - parts.push({ type: 'text', text: p['text'] as string }); - } else if (p['functionCall'] || p['function_call']) { - const fc = (p['functionCall'] ?? p['function_call']) as Record; - const name = fc['name'] as string; - if (!name) continue; - const id_ = (fc['id'] as string) ?? crypto.randomUUID(); - const toolCallId = `${name}_${id_}_${crypto.randomUUID().replaceAll('-', '').slice(0, 8)}`; - const thoughtSigB64 = p['thoughtSignature'] ?? p['thought_signature']; - const toolCall: ToolCall = { - type: 'function', - id: toolCallId, - name, - arguments: fc['args'] ? JSON.stringify(fc['args']) : '{}', - }; - if (typeof thoughtSigB64 === 'string' && thoughtSigB64.length > 0) { - toolCall.extras = { thought_signature_b64: thoughtSigB64 }; - } - parts.push(toolCall); - } - } - } - - return parts; - } - - private _extractUsage(response: Record): void { - const usageMetadata = response['usageMetadata'] as Record | undefined; - if (usageMetadata) { - const promptTokenCount = - typeof usageMetadata['promptTokenCount'] === 'number' - ? usageMetadata['promptTokenCount'] - : 0; - const cachedContentTokenCount = - typeof usageMetadata['cachedContentTokenCount'] === 'number' - ? usageMetadata['cachedContentTokenCount'] - : 0; - this._usage = { - inputOther: Math.max(promptTokenCount - cachedContentTokenCount, 0), - output: (usageMetadata['candidatesTokenCount'] as number) ?? 0, - inputCacheRead: cachedContentTokenCount, - inputCacheCreation: 0, - }; - } - } - - private _extractId(response: Record): void { - if (response['responseId'] !== undefined) { - this._id = response['responseId'] as string; - } - } - - private _throwIfAborted(signal: AbortSignal | undefined): void { - if (signal !== undefined && signal.aborted) { - throw createAbortError(); - } - } - - private async *_convertNonStreamResponse( - response: Record, - signal?: AbortSignal, - ): AsyncGenerator { - this._throwIfAborted(signal); - this._extractUsage(response); - this._extractId(response); - this._captureFinishReason(response); - for (const part of this._extractChunkParts(response)) { - this._throwIfAborted(signal); - yield part; - } - } - - private async *_convertStreamResponse( - response: AsyncIterable>, - signal?: AbortSignal, - ): AsyncGenerator { - try { - for await (const chunk of response) { - this._throwIfAborted(signal); - this._extractUsage(chunk); - this._extractId(chunk); - this._captureFinishReason(chunk); - for (const part of this._extractChunkParts(chunk)) { - this._throwIfAborted(signal); - yield part; - } - } - } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw error; - } - throw convertGoogleGenAIError(error); - } - } -} - -const NETWORK_RE = /network|connection|connect|disconnect|fetch failed/i; -const TIMEOUT_RE = /timed?\s*out|timeout|deadline/i; - -export function convertGoogleGenAIError(error: unknown): ChatProviderError { - if (error instanceof GoogleApiError) { - return normalizeAPIStatusError( - error.status, - error.message, - undefined, - parseRetryInfoDelayMs(error.message), - ); - } - if (error instanceof Error) { - const msg = error.message; - if (TIMEOUT_RE.test(msg)) { - return new APITimeoutError(msg); - } - if (NETWORK_RE.test(msg) || (error instanceof TypeError && msg.includes('fetch'))) { - return new APIConnectionError(msg); - } - const statusCode = (error as { code?: number }).code; - if (typeof statusCode === 'number') { - return normalizeAPIStatusError(statusCode, msg); - } - return new ChatProviderError(`GoogleGenAI error: ${msg}`); - } - return new ChatProviderError(`GoogleGenAI error: ${String(error)}`); -} - -function parseRetryInfoDelayMs(message: string): number | null { - const jsonStart = message.indexOf('{'); - if (jsonStart < 0) return null; - try { - const body: unknown = JSON.parse(message.slice(jsonStart)); - if (typeof body !== 'object' || body === null) return null; - const details = (body as { error?: { details?: unknown } }).error?.details; - if (!Array.isArray(details)) return null; - for (const detail of details) { - if (typeof detail !== 'object' || detail === null) continue; - const type = (detail as { '@type'?: unknown })['@type']; - if (typeof type !== 'string' || !type.endsWith('google.rpc.RetryInfo')) continue; - const retryDelay = (detail as { retryDelay?: unknown }).retryDelay; - if (typeof retryDelay !== 'string') continue; - const match = /^(\d+(?:\.\d+)?)s$/.exec(retryDelay.trim()); - if (match?.[1] === undefined) continue; - const seconds = Number.parseFloat(match[1]); - if (!Number.isFinite(seconds) || seconds < 0) continue; - return Math.round(seconds * 1000); - } - return null; - } catch { - return null; - } -} - -export class GoogleGenAIChatProvider implements ChatProvider { - readonly name: string = 'google_genai'; - - private readonly _model: string; - private readonly _client: GenAIClient | undefined; - private readonly _generationKwargs: GoogleGenAIGenerationKwargs; - private readonly _vertexai: boolean; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _project: string | undefined; - private readonly _location: string | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => GenAIClient) | undefined; - - constructor(options: GoogleGenAIOptions) { - this._model = options.model; - this._vertexai = options.vertexai ?? false; - this._stream = options.stream ?? true; - this._thinkingEffort = options.thinkingEffort; - this._generationKwargs = {}; - - const apiKey = options.apiKey ?? process.env['GOOGLE_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = - options.baseUrl === undefined || options.baseUrl.length === 0 ? undefined : options.baseUrl; - this._project = options.project; - this._location = options.location; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = - this._vertexai || this._apiKey !== undefined ? this._buildClient(this._apiKey) : undefined; - } - - private _buildClient(apiKey: string | undefined): GenAIClient { - const httpOptions: { headers?: Record; baseUrl?: string } = {}; - if (this._defaultHeaders !== undefined) { - httpOptions.headers = this._defaultHeaders; - } - if (this._baseUrl !== undefined) { - httpOptions.baseUrl = this._baseUrl; - } - return new GenAIClient({ - apiKey, - ...(this._vertexai - ? { - vertexai: true, - project: this._project, - location: this._location, - } - : {}), - httpOptions: Object.keys(httpOptions).length > 0 ? httpOptions : undefined, - }); - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.maxOutputTokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - if (options?.signal?.aborted === true) { - throw createAbortError(); - } - - const contents = messagesToGoogleGenAIContents(history); - - let kwargs: GoogleGenAIGenerationKwargs = { ...this._generationKwargs }; - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, topP: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - kwargs = { ...kwargs, thinkingConfig: this._encodeThinking(thinking.effort) }; - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - kwargs = { ...kwargs, maxOutputTokens: Math.max(1, cap) }; - } - - const config: Record = { - ...kwargs, - systemInstruction: systemPrompt, - ...(tools.length > 0 ? { tools: tools.map((t) => toolToGoogleGenAI(t)) } : {}), - }; - applyResponseFormat(config, options?.responseFormat); - - try { - const client = this._createClient(options?.auth); - const models = client.models as unknown as { - generateContent(params: Record): Promise; - generateContentStream(params: Record): Promise; - }; - - const params = { model: this._model, contents, config }; - - options?.onRequestSent?.(); - if (this._stream) { - const stream = await Promise.race([ - models.generateContentStream(params), - abortPromise(options?.signal), - ]); - return new GoogleGenAIStreamedMessage( - stream as AsyncIterable>, - true, - options?.signal, - ); - } - - const response = await Promise.race([ - models.generateContent(params), - abortPromise(options?.signal), - ]); - return new GoogleGenAIStreamedMessage( - response as Record, - false, - options?.signal, - ); - } catch (error: unknown) { - if (error instanceof DOMException && error.name === 'AbortError') { - throw error; - } - throw convertGoogleGenAIError(error); - } - } - - private _encodeThinking(effort: ThinkingEffort): ThinkingConfig { - const thinkingConfig: ThinkingConfig = { includeThoughts: true }; - - if (this._model.includes('gemini-3')) { - switch (effort) { - case 'off': - thinkingConfig.thinkingLevel = 'MINIMAL'; - thinkingConfig.includeThoughts = false; - break; - case 'low': - thinkingConfig.thinkingLevel = 'LOW'; - break; - case 'medium': - thinkingConfig.thinkingLevel = 'MEDIUM'; - break; - case 'high': - case 'xhigh': - case 'max': - thinkingConfig.thinkingLevel = 'HIGH'; - break; - } - } else { - switch (effort) { - case 'off': - thinkingConfig.thinkingBudget = 0; - thinkingConfig.includeThoughts = false; - break; - case 'low': - thinkingConfig.thinkingBudget = 1024; - thinkingConfig.includeThoughts = true; - break; - case 'medium': - thinkingConfig.thinkingBudget = 4096; - thinkingConfig.includeThoughts = true; - break; - case 'high': - case 'xhigh': - case 'max': - thinkingConfig.thinkingBudget = 32_000; - thinkingConfig.includeThoughts = true; - break; - } - } - - return thinkingConfig; - } - - private _createClient(auth: ProviderRequestAuth | undefined): GenAIClient { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => { - if (this._vertexai) return this._buildClient(this._apiKey); - return this._buildClient(requireProviderApiKey('GoogleGenAIChatProvider', a, this._apiKey)); - }, - ); - } -} - -const GEMINI_CATALOGUED_PREFIXES = [ - 'gemini-1.5-pro', - 'gemini-1.5-flash', - 'gemini-2.0-flash', - 'gemini-2.0-pro', - 'gemini-2.5-pro', - 'gemini-2.5-flash', -] as const; - -const GEMINI_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: true, - audio_in: true, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -const GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: true, - audio_in: true, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export function getGoogleGenAIModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (!normalized.startsWith('gemini-')) return undefined; - if (!GEMINI_CATALOGUED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) { - return undefined; - } - - if (normalized.startsWith('gemini-2.5-') || normalized.includes('thinking')) { - return GEMINI_THINKING_MULTIMODAL_TOOL_CAPABILITY; - } - return GEMINI_MULTIMODAL_TOOL_CAPABILITY; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts deleted file mode 100644 index 47f0cef594f..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/google-genai/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './google-genai.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts b/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts deleted file mode 100644 index 6a3fa58611a..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/merge-user-messages.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function mergeConsecutiveUserMessages( - messages: readonly T[], - mergePolicy: { - readonly isUser: (message: T) => boolean; - readonly isToolResultOnly: (message: T) => boolean; - readonly merge: (last: T, next: T) => T; - }, -): T[] { - const out: T[] = []; - for (const message of messages) { - const lastIndex = out.length - 1; - const last = lastIndex >= 0 ? out[lastIndex] : undefined; - if ( - last !== undefined && - mergePolicy.isUser(last) && - mergePolicy.isUser(message) && - (mergePolicy.isToolResultOnly(last) || !mergePolicy.isToolResultOnly(message)) - ) { - out[lastIndex] = mergePolicy.merge(last, message); - } else { - out.push(message); - } - } - return out; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts deleted file mode 100644 index 287c7f9c06a..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/chat-completions-stream.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { StreamedMessagePart, ToolCall } from '#/kosong/contract/message'; - -export interface ChatCompletionStreamToolFunctionDelta { - readonly name?: string; - readonly arguments?: string; -} - -export interface ChatCompletionStreamToolCallDelta { - readonly index?: number | string; - readonly id?: string; - readonly function?: ChatCompletionStreamToolFunctionDelta | null; -} - -export interface BufferedChatCompletionToolCall { - id?: string; - arguments: string; - emitted: boolean; -} - -export function convertChatCompletionStreamToolCall( - toolCall: ChatCompletionStreamToolCallDelta, - bufferedByIndex: Map, -): StreamedMessagePart[] { - if (toolCall.function === undefined || toolCall.function === null) { - return []; - } - - const streamIndex = toolCall.index; - const functionName = toolCall.function.name; - const functionArguments = toolCall.function.arguments; - const hasConcreteName = typeof functionName === 'string' && functionName.length > 0; - const hasArguments = typeof functionArguments === 'string' && functionArguments.length > 0; - - if (streamIndex === undefined) { - if (hasConcreteName) { - return [ - { - type: 'function', - id: toolCall.id ?? crypto.randomUUID(), - name: functionName, - arguments: functionArguments ?? null, - } satisfies ToolCall, - ]; - } - - if (hasArguments) { - return [ - { type: 'tool_call_part', argumentsPart: functionArguments } satisfies StreamedMessagePart, - ]; - } - - return []; - } - - const buffered = bufferedByIndex.get(streamIndex) ?? { arguments: '', emitted: false }; - if (toolCall.id !== undefined) { - buffered.id = toolCall.id; - } - - if (!buffered.emitted) { - if (!hasConcreteName) { - if (hasArguments) { - buffered.arguments += functionArguments; - } - bufferedByIndex.set(streamIndex, buffered); - return []; - } - - buffered.emitted = true; - const initialArguments = - buffered.arguments.length > 0 - ? buffered.arguments + (functionArguments ?? '') - : (functionArguments ?? null); - buffered.arguments = ''; - bufferedByIndex.set(streamIndex, buffered); - - const toolCallHeader: ToolCall = { - type: 'function', - id: buffered.id ?? toolCall.id ?? crypto.randomUUID(), - name: functionName, - arguments: initialArguments, - _streamIndex: streamIndex, - }; - return [toolCallHeader]; - } - - if (!hasArguments) { - return []; - } - - const part: StreamedMessagePart & { index: number | string } = { - type: 'tool_call_part', - argumentsPart: functionArguments, - index: streamIndex, - }; - return [part]; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts deleted file mode 100644 index 383da627da8..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -import './openai-legacy.contrib'; -import './openai-responses.contrib'; diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts deleted file mode 100644 index ea99d7fd7f5..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-common.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { - APIConnectionError as OpenAIConnectionError, - APIConnectionTimeoutError as OpenAITimeoutError, - APIError as OpenAIAPIError, - OpenAIError, -} from 'openai'; - -import { BugIndicatingError } from '#/_base/errors/errors'; -import { - APIConnectionError, - APIProviderQuotaExhaustedError, - APITimeoutError, - ChatProviderError, - classifyBaseApiError, - normalizeAPIStatusError, - parseRetryAfterMs, - parseTraceId, - throwIfAbortError, -} from '#/kosong/contract/errors'; -import { extractText } from '#/kosong/contract/message'; -import type { ContentPart, Message } from '#/kosong/contract/message'; -import type { FinishReason } from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -export interface OpenAIContentPart { - type: string; - text?: string | undefined; - image_url?: { url: string; id?: string | null } | undefined; - audio_url?: { url: string; id?: string | null } | undefined; - video_url?: { url: string; id?: string | null } | undefined; -} - -export function convertContentPart(part: ContentPart): OpenAIContentPart | null { - switch (part.type) { - case 'text': - return { type: 'text', text: part.text }; - case 'think': - return null; - case 'image_url': - return { - type: 'image_url', - image_url: - part.imageUrl.id === undefined - ? { url: part.imageUrl.url } - : { url: part.imageUrl.url, id: part.imageUrl.id }, - }; - case 'audio_url': - return { - type: 'audio_url', - audio_url: - part.audioUrl.id === undefined - ? { url: part.audioUrl.url } - : { url: part.audioUrl.url, id: part.audioUrl.id }, - }; - case 'video_url': - return { - type: 'video_url', - video_url: - part.videoUrl.id === undefined - ? { url: part.videoUrl.url } - : { url: part.videoUrl.url, id: part.videoUrl.id }, - }; - default: - throw new BugIndicatingError(`Unknown content part type: ${(part as ContentPart).type}`); - } -} - -export type OpenAIToolParam = { - type: string; - function: { - name: string; - description?: string; - parameters?: Record; - }; -}; - -export function toolToOpenAI(tool: Tool): OpenAIToolParam { - return { - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: tool.parameters, - }, - }; -} - -export function isOpenAIInsufficientQuotaCode(code: string | null | undefined): boolean { - return code === 'insufficient_quota'; -} - -function isOpenAIInsufficientQuotaError(error: OpenAIAPIError): boolean { - if (error.status !== 429) return false; - if (typeof error.code === 'string' && isOpenAIInsufficientQuotaCode(error.code)) return true; - if (typeof error.type === 'string' && isOpenAIInsufficientQuotaCode(error.type)) return true; - return error.message.toLowerCase().includes('insufficient_quota'); -} - -export function convertOpenAIError( - error: unknown, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - throwIfAbortError(error); - if (error instanceof ChatProviderError) { - return error; - } - const hooked = convertErrorHook?.(error); - if (hooked !== undefined) { - return hooked; - } - if (error instanceof OpenAITimeoutError) { - return new APITimeoutError(error.message); - } - if (error instanceof OpenAIConnectionError) { - return new APIConnectionError(error.message); - } - if (error instanceof OpenAIAPIError && typeof error.status === 'number') { - const reqId = error.requestID ?? null; - const retryAfterMs = parseRetryAfterMs(error.headers); - const traceId = parseTraceId(error.headers); - if (isOpenAIInsufficientQuotaError(error)) { - return new APIProviderQuotaExhaustedError(error.message, reqId, retryAfterMs, traceId); - } - return normalizeAPIStatusError(error.status, error.message, reqId, retryAfterMs, traceId); - } - if ( - error instanceof OpenAIAPIError && - error.constructor === OpenAIAPIError && - error.error === undefined - ) { - return classifyBaseApiError(error.message); - } - if (error instanceof OpenAIError) { - return new ChatProviderError(`Error: ${error.message}`); - } - if (error instanceof Error) { - return classifyBaseApiError(error.message); - } - return new ChatProviderError(`Error: ${String(error)}`); -} - -export interface FunctionToolCallShape { - type: 'function'; - id: string; - function: { name: string; arguments: string | null }; -} - -export function isFunctionToolCall( - tc: T, -): tc is T & FunctionToolCallShape { - return tc.type === 'function'; -} - -export function extractUsage(usage: unknown): TokenUsage | null { - if (usage === null || usage === undefined || typeof usage !== 'object') { - return null; - } - const u = usage as Record; - const promptTokens = typeof u['prompt_tokens'] === 'number' ? u['prompt_tokens'] : 0; - const completionTokens = typeof u['completion_tokens'] === 'number' ? u['completion_tokens'] : 0; - - let cached = 0; - if (typeof u['cached_tokens'] === 'number') { - cached = u['cached_tokens']; - } else if ( - typeof u['prompt_tokens_details'] === 'object' && - u['prompt_tokens_details'] !== null - ) { - const details = u['prompt_tokens_details'] as Record; - if (typeof details['cached_tokens'] === 'number') { - cached = details['cached_tokens']; - } - } - - return { - inputOther: promptTokens - cached, - output: completionTokens, - inputCacheRead: cached, - inputCacheCreation: 0, - }; -} - -export function normalizeOpenAIFinishReason(raw: string | null | undefined): { - finishReason: FinishReason | null; - rawFinishReason: string | null; -} { - if (raw === null || raw === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - switch (raw) { - case 'stop': - return { finishReason: 'completed', rawFinishReason: raw }; - case 'tool_calls': - case 'function_call': - return { finishReason: 'tool_calls', rawFinishReason: raw }; - case 'length': - return { finishReason: 'truncated', rawFinishReason: raw }; - case 'content_filter': - return { finishReason: 'filtered', rawFinishReason: raw }; - default: - return { finishReason: 'other', rawFinishReason: raw }; - } -} - -export type ToolMessageConversion = 'extract_text' | null; - -export const TOOL_RESULT_MEDIA_PROMPT = 'Attached media from tool result:'; -export const TOOL_RESULT_MEDIA_PLACEHOLDER = '(see attached media)'; - -export function isMediaPart(part: ContentPart): boolean { - return part.type !== 'text' && part.type !== 'think'; -} - -export function convertToolMessageContent( - message: Message, - conversion: ToolMessageConversion, -): string | OpenAIContentPart[] { - if (conversion === 'extract_text') { - return extractText(message); - } - return message.content - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); -} - -export const OPENAI_REASONING_CAPABILITY = Object.freeze({ - image_in: false, - video_in: false, - audio_in: false, - thinking: true, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_VISION_TOOL_CAPABILITY = Object.freeze({ - image_in: true, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_TEXT_TOOL_CAPABILITY = Object.freeze({ - image_in: false, - video_in: false, - audio_in: false, - thinking: false, - tool_use: true, - max_context_tokens: 0, -}); - -export const OPENAI_VISION_TOOL_PREFIXES = ['gpt-4o', 'gpt-4-turbo', 'gpt-4.1', 'gpt-4.5'] as const; - -export function isOpenAIReasoningModel(normalizedModelName: string): boolean { - return /^o\d/.test(normalizedModelName); -} - -export function hasModelPrefix(modelName: string, prefixes: readonly string[]): boolean { - return prefixes.some((prefix) => modelName.startsWith(prefix)); -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts deleted file mode 100644 index d89429a8ebd..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.contrib.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getOpenAILegacyModelCapability, OpenAILegacyChatProvider } from './openai-legacy'; -import { - compactObject, - composeOpenAIChatHooks, - firstProcessEnv, - traitEndpoint, - traitProvides, -} from './openaiHooks'; - -registerProtocolBase({ - id: 'openai', - capability: getOpenAILegacyModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new OpenAILegacyChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - maxTokens: config.providerOptions?.defaultMaxTokens, - reasoningKey: config.providerOptions?.reasoningKey, - offEffort: config.providerOptions?.offEffort, - hooks: composeOpenAIChatHooks(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts deleted file mode 100644 index f2b36111cab..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-legacy.ts +++ /dev/null @@ -1,764 +0,0 @@ -import OpenAI from 'openai'; - -import { parseTraceId, type ChatProviderError } from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, - VideoURLPart, -} from '#/kosong/contract/message'; -import { isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; - -import { - convertChatCompletionStreamToolCall, - type BufferedChatCompletionToolCall, -} from './chat-completions-stream'; -import { - convertContentPart, - convertOpenAIError, - convertToolMessageContent, - extractUsage, - hasModelPrefix, - isFunctionToolCall, - isOpenAIReasoningModel, - normalizeOpenAIFinishReason, - OPENAI_REASONING_CAPABILITY, - OPENAI_TEXT_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_PREFIXES, - type OpenAIContentPart, - TOOL_RESULT_MEDIA_PLACEHOLDER, - TOOL_RESULT_MEDIA_PROMPT, - type ToolMessageConversion, - toolToOpenAI, -} from './openai-common'; -import { ReasoningKeyDialect } from './reasoning-key'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeToolCallId } from '../tool-call-id'; - -const CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING = 128 * 1024; - -export const OPENAI_CHAT_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeToolCallId(id, 64), - maxLength: 64, -}; - -export interface OpenAIChatCompletionsHooks { - convertTool?: (tool: Tool) => Record | undefined; - convertError?: (error: unknown) => ChatProviderError | undefined; - convertMessage?: ( - message: Message, - converted: Record, - ) => Record | null; - mergeHistory?: ( - messages: readonly Record[], - ) => Record[] | undefined; - buildParams?: (params: Record) => Record | undefined; - toolCallIdPolicy?: () => ToolCallIdPolicy | undefined; - withThinking?: ( - effort: ThinkingEffort, - options: { readonly keep?: string }, - generationKwargs: OpenAILegacyGenerationKwargs, - ) => OpenAILegacyGenerationKwargs | undefined; - preserveThinking?: (generationKwargs: Record) => boolean | undefined; - withMaxCompletionTokens?: (maxCompletionTokens: number) => Record | undefined; - cacheKey?: (key: string) => Record | undefined; - extractUsage?: (chunk: Record) => Record | null | undefined; - reasoningKey?: () => string | undefined; - uploadVideo?: ( - input: string | VideoUploadInput, - options?: GenerateOptions, - ) => Promise; -} - -export interface OpenAILegacyOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - stream?: boolean | undefined; - maxTokens?: number | undefined; - reasoningKey?: string | undefined; - offEffort?: string | undefined; - thinkingEffort?: ThinkingEffort | undefined; - httpClient?: unknown; - defaultHeaders?: Record; - toolMessageConversion?: ToolMessageConversion | undefined; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; - hooks?: OpenAIChatCompletionsHooks | undefined; -} - -export interface OpenAILegacyGenerationKwargs { - max_tokens?: number | undefined; - max_completion_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - n?: number | undefined; - presence_penalty?: number | undefined; - frequency_penalty?: number | undefined; - stop?: string | string[] | undefined; - [key: string]: unknown; -} - -interface OpenAIMessage { - role: string; - content?: string | OpenAIContentPart[] | null | undefined; - tool_calls?: OpenAIToolCallOut[] | undefined; - tool_call_id?: string | undefined; - name?: string | undefined; - [key: string]: unknown; -} - -interface OpenAIToolCallOut { - type: string; - id: string; - function: { name: string; arguments: string | null }; -} - -function usesMaxCompletionTokens(model: string): boolean { - const normalized = model.toLowerCase(); - return /^o\d(?:$|[-.])/.test(normalized) || /^gpt-5(?:$|[-.])/.test(normalized); -} - -function completionTokenKwargs( - model: string, - maxCompletionTokens: number, -): OpenAILegacyGenerationKwargs { - return usesMaxCompletionTokens(model) - ? { max_completion_tokens: maxCompletionTokens } - : { max_tokens: maxCompletionTokens }; -} - -function normalizeGenerationKwargs( - model: string, - source: OpenAILegacyGenerationKwargs, -): OpenAILegacyGenerationKwargs { - const kwargs = { ...source }; - if (usesMaxCompletionTokens(model)) { - if (kwargs.max_completion_tokens === undefined && kwargs.max_tokens !== undefined) { - kwargs.max_completion_tokens = kwargs.max_tokens; - } - delete kwargs.max_tokens; - } - return kwargs; -} - -function responseFormatToOpenAI(format: ResponseFormat): Record { - if (format.type === 'json_object') { - return { type: 'json_object' }; - } - return { - type: 'json_schema', - json_schema: { - name: format.jsonSchema.name, - schema: format.jsonSchema.schema, - strict: format.jsonSchema.strict, - description: format.jsonSchema.description, - }, - }; -} - -function convertMessage( - message: Message, - reasoningKey: string, - toolMessageConversion: ToolMessageConversion, - preserveThinking: boolean, - allowToolResultExtraction: boolean, -): OpenAIMessage { - let reasoningContent = ''; - let hasReasoningPart = false; - const nonThinkParts: ContentPart[] = []; - - for (const part of message.content) { - if (part.type === 'think') { - hasReasoningPart = true; - reasoningContent += part.think; - } else { - nonThinkParts.push(part); - } - } - - const result: OpenAIMessage = { role: message.role }; - - if (message.role === 'tool') { - const hasNonTextPart = message.content.some((p) => p.type !== 'text' && p.type !== 'think'); - const effectiveConversion: ToolMessageConversion = - allowToolResultExtraction && hasNonTextPart ? 'extract_text' : toolMessageConversion; - - if (effectiveConversion !== null) { - result.content = convertToolMessageContentForChat(message, effectiveConversion); - } else { - const firstPart = nonThinkParts[0]; - if (nonThinkParts.length === 1 && firstPart?.type === 'text') { - result.content = firstPart.text; - } else if (nonThinkParts.length > 0) { - result.content = nonThinkParts - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); - } - } - } else { - const firstPart = nonThinkParts[0]; - if (nonThinkParts.length === 1 && firstPart?.type === 'text') { - result.content = firstPart.text; - } else if (nonThinkParts.length > 0) { - result.content = nonThinkParts - .map((p) => convertContentPart(p)) - .filter((p): p is OpenAIContentPart => p !== null); - } - } - - if (message.name !== undefined) { - result.name = message.name; - } - - if (message.toolCalls.length > 0) { - result.tool_calls = message.toolCalls.map((tc) => ({ - type: tc.type, - id: tc.id, - function: { name: tc.name, arguments: tc.arguments }, - })); - } - - if (message.toolCallId !== undefined) { - result.tool_call_id = message.toolCallId; - } - - if ( - message.role === 'assistant' && - hasReasoningPart && - result.content === undefined && - result.tool_calls === undefined - ) { - result.content = ''; - } - - if (message.role === 'assistant' && result.content === undefined) { - result.content = null; - } - - if (hasReasoningPart || (preserveThinking && message.role === 'assistant')) { - result[reasoningKey] = reasoningContent; - } - - return result; -} - -const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: not supported by this provider)'; -const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; - -function convertToolMessageContentForChat( - message: Message, - conversion: ToolMessageConversion, -): string | OpenAIContentPart[] { - const content = convertToolMessageContent(message, conversion); - if (typeof content !== 'string') { - return content; - } - const lines: string[] = content.length > 0 ? [content] : []; - if (message.content.some((part) => part.type === 'audio_url')) { - lines.push(OMITTED_AUDIO_PLACEHOLDER); - } - if (message.content.some((part) => part.type === 'video_url')) { - lines.push(OMITTED_VIDEO_PLACEHOLDER); - } - if (lines.length === 0 && message.content.some((part) => part.type === 'image_url')) { - return TOOL_RESULT_MEDIA_PLACEHOLDER; - } - return lines.join('\n'); -} - -function toolResultImageParts(message: Message): OpenAIContentPart[] { - const images: OpenAIContentPart[] = []; - for (const part of message.content) { - if (part.type !== 'image_url') continue; - const converted = convertContentPart(part); - if (converted !== null) { - images.push(converted); - } - } - return images; -} - -function appendToolResultMediaMessage( - messages: OpenAIMessage[], - pendingToolResultMedia: OpenAIContentPart[], -): void { - if (pendingToolResultMedia.length === 0) return; - messages.push({ - role: 'user', - content: [{ type: 'text', text: TOOL_RESULT_MEDIA_PROMPT }, ...pendingToolResultMedia], - }); - pendingToolResultMedia.length = 0; -} - -function convertHistoryMessages( - history: readonly Message[], - reasoningKey: string, - toolMessageConversion: ToolMessageConversion, - preserveThinking: boolean, -): OpenAIMessage[] { - const messages: OpenAIMessage[] = []; - const pendingToolResultMedia: OpenAIContentPart[] = []; - - for (const msg of history) { - if (isToolDeclarationOnlyMessage(msg)) continue; - if (msg.role !== 'tool') { - appendToolResultMediaMessage(messages, pendingToolResultMedia); - } - messages.push(convertMessage(msg, reasoningKey, toolMessageConversion, preserveThinking, true)); - if (msg.role === 'tool') { - pendingToolResultMedia.push(...toolResultImageParts(msg)); - } - } - - appendToolResultMediaMessage(messages, pendingToolResultMedia); - return messages; -} - -export class OpenAILegacyStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: OpenAI.Chat.ChatCompletion | AsyncIterable, - isStream: boolean, - reasoningKeyDialect: ReasoningKeyDialect, - private readonly _traceId: string | null, - private readonly _extractUsageHook?: - | ((chunk: Record) => Record | null | undefined) - | undefined, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse( - response as AsyncIterable, - reasoningKeyDialect, - ); - } else { - this._iter = this._convertNonStreamResponse( - response as OpenAI.Chat.ChatCompletion, - reasoningKeyDialect, - ); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - get traceId(): string | null { - return this._traceId; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReason(raw: string | null | undefined): void { - const normalized = normalizeOpenAIFinishReason(raw); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _captureUsage(raw: Record, fallback: unknown): void { - const hooked = this._extractUsageHook?.(raw); - const rawUsage = hooked !== undefined ? hooked : fallback; - if (rawUsage !== null && rawUsage !== undefined) { - this._usage = extractUsage(rawUsage) ?? null; - } - } - - private async *_convertNonStreamResponse( - response: OpenAI.Chat.ChatCompletion, - reasoningKeyDialect: ReasoningKeyDialect, - ): AsyncGenerator { - this._id = response.id; - this._captureUsage(response as unknown as Record, response.usage); - this._captureFinishReason(response.choices[0]?.finish_reason ?? null); - - const message = response.choices[0]?.message; - if (!message) return; - - const reasoning = reasoningKeyDialect.observe(message); - if (reasoning !== undefined) { - yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; - } - - if (message.content) { - yield { type: 'text', text: message.content } satisfies StreamedMessagePart; - } - - if (message.tool_calls) { - for (const toolCall of message.tool_calls) { - if (!isFunctionToolCall(toolCall)) continue; - yield { - type: 'function', - id: toolCall.id || crypto.randomUUID(), - name: toolCall.function.name, - arguments: toolCall.function.arguments, - } satisfies ToolCall; - } - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - reasoningKeyDialect: ReasoningKeyDialect, - ): AsyncGenerator { - const bufferedToolCalls = new Map(); - - try { - for await (const chunk of response) { - if (chunk.id) { - this._id = chunk.id; - } - - this._captureUsage(chunk as unknown as Record, chunk.usage); - - if (!chunk.choices || chunk.choices.length === 0) { - continue; - } - - const choice = chunk.choices[0]; - if (!choice) continue; - - if (choice.finish_reason !== null && choice.finish_reason !== undefined) { - this._captureFinishReason(choice.finish_reason); - } - - const delta = choice.delta; - - const reasoning = reasoningKeyDialect.observe(delta); - if (reasoning !== undefined) { - yield { type: 'think', think: reasoning } satisfies StreamedMessagePart; - } - - if (delta.content) { - yield { type: 'text', text: delta.content } satisfies StreamedMessagePart; - } - - for (const toolCall of delta.tool_calls ?? []) { - for (const part of convertChatCompletionStreamToolCall(toolCall, bufferedToolCalls)) { - yield part; - } - } - } - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } -} - -export class OpenAILegacyChatProvider implements ChatProvider { - readonly name: string = 'openai'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _reasoningKeyDialect: ReasoningKeyDialect; - private readonly _offEffort: string | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _generationKwargs: OpenAILegacyGenerationKwargs; - private readonly _toolMessageConversion: ToolMessageConversion; - private readonly _client: OpenAI | undefined; - private readonly _httpClient: unknown; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - private readonly _hooks: OpenAIChatCompletionsHooks | undefined; - - readonly uploadVideo?: ( - input: string | VideoUploadInput, - options?: GenerateOptions, - ) => Promise; - - constructor(options: OpenAILegacyOptions) { - const apiKey = options.apiKey ?? process.env['OPENAI_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; - this._defaultHeaders = options.defaultHeaders; - this._model = options.model; - this._stream = options.stream ?? true; - this._hooks = options.hooks; - const normalizedReasoningKey = options.reasoningKey?.trim(); - this._reasoningKeyDialect = new ReasoningKeyDialect( - normalizedReasoningKey !== undefined && normalizedReasoningKey.length > 0 - ? normalizedReasoningKey - : this._hooks?.reasoningKey?.(), - ); - this._thinkingEffort = options.thinkingEffort; - this._offEffort = options.offEffort; - this._generationKwargs = normalizeGenerationKwargs( - this._model, - options.maxTokens !== undefined ? completionTokenKwargs(this._model, options.maxTokens) : {}, - ); - this._toolMessageConversion = options.toolMessageConversion ?? null; - this._httpClient = options.httpClient; - this._clientFactory = options.clientFactory; - - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - - const uploadVideo = this._hooks?.uploadVideo; - if (uploadVideo !== undefined) { - this.uploadVideo = (input, generateOptions) => uploadVideo(input, generateOptions); - } - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_completion_tokens ?? this._generationKwargs.max_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const { kwargs, reasoningEffort } = this._resolveRequestKwargs(history, options); - - const preserveThinking = this._hooks?.preserveThinking?.(kwargs) ?? false; - const reasoningKey = this._reasoningKeyDialect.outboundKey(); - - const messages: Record[] = []; - if (systemPrompt) { - messages.push({ role: 'system', content: systemPrompt }); - } - - const policy = this._hooks?.toolCallIdPolicy?.() ?? OPENAI_CHAT_TOOL_CALL_ID_POLICY; - const normalizedHistory = normalizeToolCallIdsForProvider(history, policy); - - const convertMessageHook = this._hooks?.convertMessage; - if (convertMessageHook !== undefined) { - for (const msg of normalizedHistory) { - const converted = convertMessage(msg, reasoningKey, null, preserveThinking, false); - const shaped = convertMessageHook(msg, converted); - if (shaped !== null) { - messages.push(shaped); - } - } - } else { - messages.push( - ...convertHistoryMessages( - normalizedHistory, - reasoningKey, - this._toolMessageConversion, - preserveThinking, - ), - ); - } - - const merged = this._hooks?.mergeHistory?.(messages); - const finalMessages = merged ?? messages; - - const createParams: Record = { - model: this._model, - messages: finalMessages, - stream: this._stream, - ...kwargs, - }; - - if (tools.length > 0) { - const convertTool = this._hooks?.convertTool ?? ((tool: Tool) => toolToOpenAI(tool)); - createParams['tools'] = tools.map((tool) => convertTool(tool)); - } - if (options?.responseFormat !== undefined) { - createParams['response_format'] = responseFormatToOpenAI(options.responseFormat); - } - - if (this._stream) { - createParams['stream_options'] = { include_usage: true }; - } - - if (reasoningEffort !== undefined) { - createParams['reasoning_effort'] = reasoningEffort; - } - - const builtParams = this._hooks?.buildParams?.(createParams); - const finalParams = builtParams ?? createParams; - - try { - const client = this._createClient(options?.auth); - options?.onRequestSent?.(); - const { data, response } = await client.chat.completions - .create( - finalParams as unknown as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming, - options?.signal ? { signal: options.signal } : undefined, - ) - .withResponse(); - return new OpenAILegacyStreamedMessage( - data as unknown as - | OpenAI.Chat.ChatCompletion - | AsyncIterable, - this._stream, - this._reasoningKeyDialect, - parseTraceId(response.headers), - this._hooks?.extractUsage, - this._hooks?.convertError, - ); - } catch (error: unknown) { - throw convertOpenAIError(error, this._hooks?.convertError); - } - } - - private _resolveRequestKwargs( - history: readonly Message[], - options: GenerateOptions | undefined, - ): { kwargs: Record; reasoningEffort: string | undefined } { - let kwargs: Record = { ...this._generationKwargs }; - - if (options?.cacheKey !== undefined) { - const hooked = this._hooks?.cacheKey?.(options.cacheKey); - kwargs = { ...kwargs, ...(hooked ?? { prompt_cache_key: options.cacheKey }) }; - } - - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - let explicitThinkingEffort: ThinkingEffort | undefined; - if (thinking !== undefined) { - const hooked = this._hooks?.withThinking?.(thinking.effort, { keep: thinking.keep }, kwargs); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - explicitThinkingEffort = thinking.effort; - } - } - - let reasoningEffort: string | undefined = - explicitThinkingEffort === 'off' - ? this._offEffort - : explicitThinkingEffort === undefined || explicitThinkingEffort === 'on' - ? undefined - : explicitThinkingEffort; - - if ( - reasoningEffort === undefined && - explicitThinkingEffort !== 'off' && - kwargs['reasoning_effort'] === undefined && - this._hooks?.withThinking === undefined - ) { - const hasThinkPart = history.some((message) => - message.content.some((part) => part.type === 'think'), - ); - if (hasThinkPart) { - reasoningEffort = 'medium'; - } - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - cap = Math.max(1, cap); - const hooked = this._hooks?.withMaxCompletionTokens?.(cap); - if (hooked !== undefined) { - kwargs = { ...kwargs, ...hooked }; - } else { - const capped = Math.min(cap, CHAT_COMPLETIONS_MAX_OUTPUT_TOKENS_CEILING); - kwargs = { ...kwargs, ...completionTokenKwargs(this._model, Math.max(1, capped)) }; - } - } - - for (const key of Object.keys(kwargs)) { - if (kwargs[key] === undefined) { - delete kwargs[key]; - } - } - - return { kwargs, reasoningEffort }; - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => - this._buildClient(requireProviderApiKey('OpenAILegacyChatProvider', a, this._apiKey), a), - ); - } - - private _buildClient(apiKey: string, auth?: ProviderRequestAuth): OpenAI { - const clientOpts: Record = { - apiKey, - baseURL: this._baseUrl, - maxRetries: 0, - }; - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); - if (defaultHeaders !== undefined) { - clientOpts['defaultHeaders'] = defaultHeaders; - } - if (this._httpClient !== undefined) { - clientOpts['httpClient'] = this._httpClient; - } - return new OpenAI(clientOpts as ConstructorParameters[0]); - } -} - -export function getOpenAILegacyModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (isOpenAIReasoningModel(normalized)) { - return OPENAI_REASONING_CAPABILITY; - } - if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { - return OPENAI_VISION_TOOL_CAPABILITY; - } - if (normalized.startsWith('gpt-3.5-turbo')) { - return OPENAI_TEXT_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts deleted file mode 100644 index 6a12e180290..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProtocolBase } from '#/kosong/protocol/protocolBase'; -import { traitConvertError, traitDefaultHeaders } from '#/kosong/protocol/protocolTrait'; - -import { getOpenAIResponsesModelCapability, OpenAIResponsesChatProvider } from './openai-responses'; -import { compactObject, firstProcessEnv, traitEndpoint, traitProvides } from './openaiHooks'; - -registerProtocolBase({ - id: 'openai_responses', - capability: getOpenAIResponsesModelCapability, - createChatProvider({ config, traits }) { - const endpoint = traitEndpoint(traits); - return new OpenAIResponsesChatProvider({ - ...(traitProvides(traits) as Partial< - ConstructorParameters[0] - >), - model: config.modelName, - ...compactObject({ - apiKey: - config.apiKey ?? - firstProcessEnv(endpoint?.apiKeyEnv) ?? - (endpoint === undefined ? undefined : ''), - baseUrl: - config.baseUrl ?? firstProcessEnv(endpoint?.baseUrlEnv) ?? endpoint?.defaultBaseUrl, - defaultHeaders: traitDefaultHeaders(traits), - maxOutputTokens: config.providerOptions?.defaultMaxTokens, - offEffort: config.providerOptions?.offEffort, - convertError: traitConvertError(traits), - }), - }); - }, -}); diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts deleted file mode 100644 index 19808bc2f1e..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openai-responses.ts +++ /dev/null @@ -1,1211 +0,0 @@ -import OpenAI from 'openai'; - -import { Error2 } from '#/_base/errors/errors'; -import { - APIContextOverflowError, - APIProviderQuotaExhaustedError, - APIProviderRateLimitError, - ChatProviderError, - isContextOverflowErrorCode, -} from '#/kosong/contract/errors'; -import type { - ContentPart, - Message, - StreamedMessagePart, - ToolCall, -} from '#/kosong/contract/message'; -import { extractText, isToolDeclarationOnlyMessage } from '#/kosong/contract/message'; -import type { - ChatProvider, - FinishReason, - GenerateOptions, - ProviderRequestAuth, - ResponseFormat, - StreamedMessage, - ThinkingEffort, - ToolCallIdPolicy, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import { ProtocolErrors } from '#/kosong/protocol/errors'; - -import { - convertOpenAIError, - hasModelPrefix, - isMediaPart, - isOpenAIInsufficientQuotaCode, - isOpenAIReasoningModel, - OPENAI_REASONING_CAPABILITY, - OPENAI_VISION_TOOL_CAPABILITY, - OPENAI_VISION_TOOL_PREFIXES, - TOOL_RESULT_MEDIA_PLACEHOLDER, - TOOL_RESULT_MEDIA_PROMPT, - type ToolMessageConversion, -} from './openai-common'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../request-auth'; -import { normalizeToolCallIdsForProvider, sanitizeOpenAIResponsesCallId } from '../tool-call-id'; - -function normalizeResponsesFinishReason( - status: string | null | undefined, - incompleteReason: string | null | undefined, -): { finishReason: FinishReason | null; rawFinishReason: string | null } { - if (status === null || status === undefined) { - return { finishReason: null, rawFinishReason: null }; - } - if (status === 'completed') { - return { finishReason: 'completed', rawFinishReason: 'completed' }; - } - if (status === 'incomplete') { - if (incompleteReason === 'max_output_tokens') { - return { finishReason: 'truncated', rawFinishReason: 'max_output_tokens' }; - } - if (incompleteReason === 'content_filter') { - return { finishReason: 'filtered', rawFinishReason: 'content_filter' }; - } - return { - finishReason: 'other', - rawFinishReason: incompleteReason ?? 'incomplete', - }; - } - if (status === 'failed') { - return { finishReason: 'other', rawFinishReason: 'failed' }; - } - return { finishReason: null, rawFinishReason: null }; -} - -type RawObject = Record; -const OPENAI_RESPONSES_TOOL_CALL_ID_POLICY: ToolCallIdPolicy = { - normalize: (id) => sanitizeOpenAIResponsesCallId(id, 64), - maxLength: 64, -}; - -type ResponseOutputItemView = - | { - type: 'message'; - content: RawObject[]; - } - | { - type: 'function_call'; - itemId?: string; - callId?: string; - name?: string; - arguments?: string | null; - } - | { - type: 'reasoning'; - encryptedContent?: string; - summary: RawObject[]; - } - | { - type: 'other'; - }; - -function asRawObject(value: unknown): RawObject | null { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - return null; - } - return value as RawObject; -} - -function readStringField(object: RawObject, key: string): string | undefined { - const value = object[key]; - return typeof value === 'string' ? value : undefined; -} - -function hasOwn(object: RawObject, key: string): boolean { - return Object.prototype.hasOwnProperty.call(object, key); -} - -function readNullableStringField(object: RawObject, key: string): string | null | undefined { - const value = object[key]; - if (value === null) return null; - return typeof value === 'string' ? value : undefined; -} - -function readNumberField(object: RawObject, key: string): number | undefined { - const value = object[key]; - return typeof value === 'number' ? value : undefined; -} - -function readObjectField(object: RawObject, key: string): RawObject | undefined { - return asRawObject(object[key]) ?? undefined; -} - -function readObjectArrayField(object: RawObject, key: string): RawObject[] | undefined { - const value = object[key]; - if (!Array.isArray(value)) return undefined; - return value.flatMap((item) => { - const objectItem = asRawObject(item); - return objectItem === null ? [] : [objectItem]; - }); -} - -function failResponsesDecode(context: string, detail: string): never { - throw new ChatProviderError(`OpenAI Responses decode error: ${context} ${detail}`); -} - -function requireStringField(object: RawObject, key: string, context: string): string { - const value = readStringField(object, key); - if (value === undefined) { - failResponsesDecode(`${context}.${key}`, 'must be a string.'); - } - return value; -} - -function requireObjectField(object: RawObject, key: string, context: string): RawObject { - const value = readObjectField(object, key); - if (value === undefined) { - failResponsesDecode(`${context}.${key}`, 'must be an object.'); - } - return value; -} - -function readResponseOutputItem(value: unknown, context: string): ResponseOutputItemView { - const item = asRawObject(value); - if (item === null) { - failResponsesDecode(context, 'must be an object.'); - } - - const type = requireStringField(item, 'type', context); - - if (type === 'message') { - return { - type, - content: readObjectArrayField(item, 'content') ?? [], - }; - } - - if (type === 'function_call') { - return { - type, - itemId: readStringField(item, 'id'), - callId: readStringField(item, 'call_id'), - name: readStringField(item, 'name'), - arguments: readNullableStringField(item, 'arguments'), - }; - } - - if (type === 'reasoning') { - return { - type, - encryptedContent: readStringField(item, 'encrypted_content'), - summary: readObjectArrayField(item, 'summary') ?? [], - }; - } - - return { type: 'other' }; -} - -function responseStreamIndex( - itemId: string | undefined, - outputIndex: number | undefined, -): string | number | undefined { - return itemId ?? outputIndex; -} - -function formatResponseStreamIndex(streamIndex: string | number | undefined): string { - return streamIndex === undefined ? '' : String(streamIndex); -} - -function requireFunctionCallName(item: { name?: string }): string { - if (item.name === undefined) { - throw new ChatProviderError('OpenAI Responses function_call item is missing a name.'); - } - return item.name; -} - -function functionCallId(callId: string | undefined): string { - return callId === undefined || callId.length === 0 ? crypto.randomUUID() : callId; -} - -function formatResponsesErrorEvent( - code: string | null, - message: string, - param: string | null, -): string { - const codeText = code ?? 'unknown'; - const paramText = param === null ? '' : ` (param: ${param})`; - return `${codeText}: ${message}${paramText}`; -} - -const EMBEDDED_STATUS_CODE_RE = /\bstatus_code\s*[:=]\s*(\d{3})\b/; - -function readEmbeddedStatusCode(message: string): number | undefined { - const match = EMBEDDED_STATUS_CODE_RE.exec(message); - return match === null ? undefined : Number(match[1]); -} - -function errorFromOpenAIResponsesEvent( - prefix: string, - code: string | null, - message: string, - param: string | null, - options?: { - readonly rawEvent?: unknown; - readonly convertErrorHook?: (error: unknown) => ChatProviderError | undefined; - }, -): ChatProviderError { - const formatted = formatResponsesErrorEvent(code, message, param); - const fullMessage = `${prefix}: ${formatted}`; - const hooked = options?.convertErrorHook?.(options.rawEvent ?? { code, message, param }); - if (hooked !== undefined) { - return hooked; - } - if (isContextOverflowErrorCode(code)) { - return new APIContextOverflowError(400, fullMessage); - } - if (isOpenAIInsufficientQuotaCode(code)) { - return new APIProviderQuotaExhaustedError(fullMessage); - } - if (code === 'rate_limit_exceeded' || readEmbeddedStatusCode(message) === 429) { - return new APIProviderRateLimitError(fullMessage); - } - return new ChatProviderError(fullMessage); -} - -function parseNestedGatewayStreamError(message: string): - | { - code: string | null; - message: string; - param: string | null; - } - | undefined { - const marker = 'received error while streaming:'; - const markerIndex = message.indexOf(marker); - if (markerIndex === -1) return undefined; - - const jsonText = message.slice(markerIndex + marker.length).trim(); - if (jsonText.length === 0) return undefined; - - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch { - return undefined; - } - - const error = asRawObject(parsed); - if (error === null) return undefined; - - const nestedMessage = readStringField(error, 'message'); - if (nestedMessage === undefined) return undefined; - - return { - code: readNullableStringField(error, 'code') ?? null, - message: nestedMessage, - param: readNullableStringField(error, 'param') ?? null, - }; -} - -function malformedStreamErrorEvent( - message: string, - convertErrorHook?: (error: unknown) => ChatProviderError | undefined, -): ChatProviderError { - const nested = parseNestedGatewayStreamError(message); - if (nested !== undefined) { - return errorFromOpenAIResponsesEvent( - 'OpenAI Responses malformed stream error', - nested.code, - nested.message, - nested.param, - { convertErrorHook }, - ); - } - - return errorFromOpenAIResponsesEvent( - 'OpenAI Responses malformed stream error', - null, - message, - null, - { convertErrorHook }, - ); -} - -function readResponsesFailedResponseError(response: RawObject): - | { - code: string | null; - message: string; - } - | undefined { - const error = readObjectField(response, 'error'); - if (error !== undefined) { - const code = readNullableStringField(error, 'code') ?? 'unknown'; - const message = readStringField(error, 'message') ?? 'no message'; - return { code, message }; - } - return undefined; -} - -function formatResponsesFailedResponse(response: RawObject): string { - const error = readResponsesFailedResponseError(response); - if (error !== undefined) { - return formatResponsesErrorEvent(error.code, error.message, null); - } - - const incompleteDetails = readObjectField(response, 'incomplete_details'); - const reason = - incompleteDetails === undefined ? undefined : readStringField(incompleteDetails, 'reason'); - return reason === undefined - ? 'Unknown error (no error details in response)' - : `incomplete: ${reason}`; -} - -export interface OpenAIResponsesOptions { - apiKey?: string | undefined; - baseUrl?: string | undefined; - model: string; - maxOutputTokens?: number | undefined; - offEffort?: string | undefined; - thinkingEffort?: ThinkingEffort | undefined; - httpClient?: unknown; - defaultHeaders?: Record; - toolMessageConversion?: ToolMessageConversion | undefined; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; - convertError?: (error: unknown) => ChatProviderError | undefined; -} - -export interface OpenAIResponsesGenerationKwargs { - max_output_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - reasoning_effort?: string | undefined; - [key: string]: unknown; -} - -interface ResponseInputItem { - [key: string]: unknown; -} - -interface ResponseToolParam { - type: string; - name: string; - description: string; - parameters: Record; - strict: boolean; -} - -function responseFormatToResponsesText(format: ResponseFormat): Record { - if (format.type === 'json_object') { - return { format: { type: 'json_object' } }; - } - return { - format: { - type: 'json_schema', - name: format.jsonSchema.name, - schema: format.jsonSchema.schema, - strict: format.jsonSchema.strict, - description: format.jsonSchema.description, - }, - }; -} - -const OMITTED_AUDIO_PLACEHOLDER = '(audio omitted: unsupported audio format)'; -const OMITTED_VIDEO_PLACEHOLDER = '(video omitted: not supported by this provider)'; - -function contentPartsToInputItems(parts: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of parts) { - switch (part.type) { - case 'text': - if (part.text) { - items.push({ type: 'input_text', text: part.text }); - } - break; - case 'image_url': - items.push({ - type: 'input_image', - detail: 'auto', - image_url: part.imageUrl.url, - }); - break; - case 'audio_url': { - const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); - break; - } - case 'video_url': - items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); - break; - case 'think': - break; - } - } - return items; -} - -function contentPartsToOutputItems(parts: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of parts) { - if (part.type === 'text' && part.text) { - items.push({ type: 'output_text', text: part.text, annotations: [] }); - } - } - return items; -} - -function messageContentToFunctionOutputItems(content: ContentPart[]): unknown[] { - const items: unknown[] = []; - for (const part of content) { - switch (part.type) { - case 'text': - if (part.text) { - items.push({ type: 'input_text', text: part.text }); - } - break; - case 'image_url': - items.push({ type: 'input_image', image_url: part.imageUrl.url }); - break; - case 'audio_url': { - const mapped = mapAudioUrlToInputItem(part.audioUrl.url); - items.push(mapped ?? { type: 'input_text', text: OMITTED_AUDIO_PLACEHOLDER }); - break; - } - case 'video_url': - items.push({ type: 'input_text', text: OMITTED_VIDEO_PLACEHOLDER }); - break; - case 'think': - break; - } - } - return items; -} - -function mapAudioUrlToInputItem(url: string): unknown { - if (url.startsWith('data:audio/')) { - try { - const parts = url.split(',', 2); - if (parts.length !== 2 || parts[0] === undefined || parts[1] === undefined) return null; - const header = parts[0]; - const b64 = parts[1]; - const subtypePart = header.split('/')[1]; - if (subtypePart === undefined) return null; - const [subtypeHead = ''] = subtypePart.split(';'); - const subtype = subtypeHead.toLowerCase(); - const ext = - subtype === 'mp3' || subtype === 'mpeg' ? 'mp3' : subtype === 'wav' ? 'wav' : null; - if (ext === null) return null; - return { type: 'input_file', file_data: b64, filename: `inline.${ext}` }; - } catch { - return null; - } - } - if (url.startsWith('http://') || url.startsWith('https://')) { - return { type: 'input_file', file_url: url }; - } - return null; -} - -const OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS = new Set([ - 'gpt-4.1', - 'gpt-4.1-mini', - 'gpt-4.1-nano', - 'gpt-5-codex', - 'o1', - 'o1-mini', - 'o1-pro', - 'o3', - 'o3-mini', - 'o3-pro', - 'o4-mini', -]); - -export function usesOpenAIResponsesDeveloperRole(modelName: string): boolean { - const normalized = modelName.toLowerCase(); - if (OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS.has(normalized)) return true; - for (const cataloguedModel of OPENAI_RESPONSES_DEVELOPER_ROLE_MODELS) { - if (normalized.startsWith(cataloguedModel + '-')) return true; - } - return false; -} - -function convertMessage( - message: Message, - modelName: string, - toolMessageConversion: ToolMessageConversion, -): ResponseInputItem[] { - let role: string = message.role; - if (usesOpenAIResponsesDeveloperRole(modelName) && role === 'system') { - role = 'developer'; - } - - if (role === 'tool') { - const callId = message.toolCallId ?? ''; - let output: string | unknown[]; - if (toolMessageConversion === 'extract_text') { - const text = extractText(message); - output = - text.length === 0 && message.content.some(isMediaPart) - ? TOOL_RESULT_MEDIA_PLACEHOLDER - : text; - } else { - output = messageContentToFunctionOutputItems(message.content); - } - return [ - { - call_id: callId, - output, - type: 'function_call_output', - }, - ]; - } - - const result: ResponseInputItem[] = []; - - if (message.content.length > 0) { - const pendingParts: ContentPart[] = []; - - const flushPendingParts = (): void => { - if (pendingParts.length === 0) return; - if (role === 'assistant') { - result.push({ - content: contentPartsToOutputItems(pendingParts), - role, - type: 'message', - }); - } else { - result.push({ - content: contentPartsToInputItems(pendingParts), - role, - type: 'message', - }); - } - pendingParts.length = 0; - }; - - let i = 0; - const n = message.content.length; - while (i < n) { - const part = message.content[i]; - if (part === undefined) break; - if (part.type === 'think') { - flushPendingParts(); - const encryptedValue = part.encrypted; - const summaries: unknown[] = [{ type: 'summary_text', text: part.think }]; - i += 1; - while (i < n) { - const nextPart = message.content[i]; - if (nextPart === undefined) break; - if (nextPart.type !== 'think') break; - if (nextPart.encrypted !== encryptedValue) break; - summaries.push({ type: 'summary_text', text: nextPart.think }); - i += 1; - } - result.push({ - summary: summaries, - type: 'reasoning', - encrypted_content: encryptedValue, - }); - } else { - pendingParts.push(part); - i += 1; - } - } - - flushPendingParts(); - } - - for (const toolCall of message.toolCalls) { - result.push({ - arguments: toolCall.arguments ?? '{}', - call_id: toolCall.id, - name: toolCall.name, - type: 'function_call', - }); - } - - return result; -} - -function convertTool(tool: Tool): ResponseToolParam { - return { - type: 'function', - name: tool.name, - description: tool.description, - parameters: tool.parameters, - strict: false, - }; -} - -function convertHistoryMessages( - history: readonly Message[], - modelName: string, - toolMessageConversion: ToolMessageConversion, -): unknown[] { - const input: unknown[] = []; - const pendingToolResultMedia: unknown[] = []; - - const flushPendingMedia = (): void => { - if (pendingToolResultMedia.length === 0) return; - input.push({ - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: TOOL_RESULT_MEDIA_PROMPT }, ...pendingToolResultMedia], - }); - pendingToolResultMedia.length = 0; - }; - - for (const msg of history) { - if (isToolDeclarationOnlyMessage(msg)) continue; - if (msg.role !== 'tool') { - flushPendingMedia(); - } - input.push(...convertMessage(msg, modelName, toolMessageConversion)); - if (msg.role === 'tool' && toolMessageConversion === 'extract_text') { - pendingToolResultMedia.push( - ...messageContentToFunctionOutputItems(msg.content.filter(isMediaPart)), - ); - } - } - - flushPendingMedia(); - return input; -} - -export class OpenAIResponsesStreamedMessage implements StreamedMessage { - private _id: string | null = null; - private _usage: TokenUsage | null = null; - private _finishReason: FinishReason | null = null; - private _rawFinishReason: string | null = null; - private readonly _iter: AsyncGenerator; - - constructor( - response: unknown, - isStream: boolean, - private readonly _convertErrorHook?: - | ((error: unknown) => ChatProviderError | undefined) - | undefined, - ) { - if (isStream) { - this._iter = this._convertStreamResponse(response as AsyncIterable); - } else { - this._iter = this._convertNonStreamResponse(response as RawObject); - } - } - - get id(): string | null { - return this._id; - } - - get usage(): TokenUsage | null { - return this._usage; - } - - get finishReason(): FinishReason | null { - return this._finishReason; - } - - get rawFinishReason(): string | null { - return this._rawFinishReason; - } - - async *[Symbol.asyncIterator](): AsyncIterator { - yield* this._iter; - } - - private _captureFinishReasonFromResponse(response: RawObject): void { - const status = readNullableStringField(response, 'status'); - const incomplete = readObjectField(response, 'incomplete_details'); - const incompleteReason = incomplete ? readStringField(incomplete, 'reason') : null; - const normalized = normalizeResponsesFinishReason(status, incompleteReason); - this._finishReason = normalized.finishReason; - this._rawFinishReason = normalized.rawFinishReason; - } - - private _extractUsage(usage: RawObject): void { - const inputTokens = readNumberField(usage, 'input_tokens') ?? 0; - const outputTokens = readNumberField(usage, 'output_tokens') ?? 0; - const details = readObjectField(usage, 'input_tokens_details'); - const cached = details ? (readNumberField(details, 'cached_tokens') ?? 0) : 0; - this._usage = { - inputOther: inputTokens - cached, - output: outputTokens, - inputCacheRead: cached, - inputCacheCreation: 0, - }; - } - - private async *_convertNonStreamResponse( - response: RawObject, - ): AsyncGenerator { - this._id = readStringField(response, 'id') ?? null; - const usage = readObjectField(response, 'usage'); - if (usage !== undefined) { - this._extractUsage(usage); - } - this._captureFinishReasonFromResponse(response); - - const output = readObjectArrayField(response, 'output'); - if (output === undefined) return; - - for (const item of output) { - const outputItem = readResponseOutputItem(item, 'response.output item'); - - if (outputItem.type === 'message') { - for (const contentItem of outputItem.content) { - if (contentItem['type'] === 'output_text') { - const text = readStringField(contentItem, 'text'); - if (text !== undefined) { - yield { type: 'text', text }; - } - } - } - } else if (outputItem.type === 'function_call') { - yield { - type: 'function', - id: functionCallId(outputItem.callId), - name: requireFunctionCallName(outputItem), - arguments: outputItem.arguments ?? null, - } satisfies ToolCall; - } else if (outputItem.type === 'reasoning') { - let hasReasoningSummary = false; - for (const summary of outputItem.summary) { - const text = readStringField(summary, 'text'); - if (text === undefined) continue; - hasReasoningSummary = true; - const thinkPart: StreamedMessagePart = { - type: 'think', - think: text, - }; - if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; - } - yield thinkPart; - } - if (!hasReasoningSummary) { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; - if (outputItem.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = outputItem.encryptedContent; - } - yield thinkPart; - } - } - } - } - - private async *_convertStreamResponse( - response: AsyncIterable, - ): AsyncGenerator { - const functionCallArgumentsByIndex = new Map(); - let unindexedFunctionCallArguments: string | undefined; - - const hasFunctionCallArguments = (streamIndex: number | string | undefined): boolean => - streamIndex === undefined - ? unindexedFunctionCallArguments !== undefined - : functionCallArgumentsByIndex.has(streamIndex); - - const getFunctionCallArguments = (streamIndex: number | string | undefined): string => - streamIndex === undefined - ? (unindexedFunctionCallArguments as string) - : functionCallArgumentsByIndex.get(streamIndex)!; - - const setFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsValue: string, - ): void => { - if (streamIndex === undefined) { - unindexedFunctionCallArguments = argumentsValue; - } else { - functionCallArgumentsByIndex.set(streamIndex, argumentsValue); - } - }; - - const appendFunctionCallArguments = ( - streamIndex: number | string | undefined, - argumentsPart: string, - context: string, - ): void => { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - setFunctionCallArguments(streamIndex, getFunctionCallArguments(streamIndex) + argumentsPart); - }; - - const yieldFinalArgumentsSuffix = function* ( - streamIndex: number | string | undefined, - finalArguments: string, - context: string, - ): Generator { - if (!hasFunctionCallArguments(streamIndex)) { - failResponsesDecode( - context, - `received final function-call arguments for unknown stream index ${formatResponseStreamIndex(streamIndex)}.`, - ); - } - - const accumulatedArguments = getFunctionCallArguments(streamIndex); - if (finalArguments === accumulatedArguments) { - return; - } - - if (!finalArguments.startsWith(accumulatedArguments)) { - throw new ChatProviderError( - `OpenAI Responses final function-call arguments for stream index ${formatResponseStreamIndex( - streamIndex, - )} do not match the streamed argument deltas.`, - ); - } - - const suffix = finalArguments.slice(accumulatedArguments.length); - setFunctionCallArguments(streamIndex, finalArguments); - if (suffix.length === 0) { - return; - } - - const part: StreamedMessagePart = { - type: 'tool_call_part', - argumentsPart: suffix, - }; - if (streamIndex !== undefined) { - (part as { index: number | string }).index = streamIndex; - } - yield part; - }; - - try { - for await (const chunk of response) { - const type = readStringField(chunk, 'type'); - if (type === undefined) { - if (!hasOwn(chunk, 'type')) { - const message = readStringField(chunk, 'message'); - if (message !== undefined) { - throw malformedStreamErrorEvent(message, this._convertErrorHook); - } - } - failResponsesDecode('stream event.type', 'must be a string.'); - } - - switch (type) { - case 'response.output_text.delta': - yield { type: 'text', text: requireStringField(chunk, 'delta', type) }; - break; - case 'response.created': - case 'response.in_progress': { - const responseObject = requireObjectField(chunk, 'response', type); - const respId = readStringField(responseObject, 'id'); - if (respId !== undefined) { - this._id = respId; - } - break; - } - case 'response.output_item.added': { - const item = readResponseOutputItem(chunk['item'], `${type}.item`); - const outputIndex = readNumberField(chunk, 'output_index'); - if (item.type === 'function_call') { - const streamIndex = responseStreamIndex(item.itemId, outputIndex); - setFunctionCallArguments(streamIndex, item.arguments ?? ''); - const tc: ToolCall = { - type: 'function', - id: functionCallId(item.callId), - name: requireFunctionCallName(item), - arguments: item.arguments ?? null, - }; - if (streamIndex !== undefined) { - tc._streamIndex = streamIndex; - } - yield tc; - } - break; - } - case 'response.output_item.done': { - const item = readResponseOutputItem(chunk['item'], `${type}.item`); - const outputIndex = readNumberField(chunk, 'output_index'); - if (item.type === 'reasoning') { - const thinkPart: StreamedMessagePart = { type: 'think', think: '' }; - if (item.encryptedContent !== undefined) { - (thinkPart as { encrypted: string }).encrypted = item.encryptedContent; - } - yield thinkPart; - } else if (item.type === 'function_call' && typeof item.arguments === 'string') { - const streamIndex = responseStreamIndex(item.itemId, outputIndex); - yield* yieldFinalArgumentsSuffix(streamIndex, item.arguments, type); - } - break; - } - case 'response.function_call_arguments.delta': { - const streamIndex = responseStreamIndex( - readStringField(chunk, 'item_id'), - readNumberField(chunk, 'output_index'), - ); - const argumentsPart = requireStringField(chunk, 'delta', type); - const part: StreamedMessagePart = { - type: 'tool_call_part', - argumentsPart, - }; - appendFunctionCallArguments(streamIndex, argumentsPart, type); - if (streamIndex !== undefined) { - (part as { index: number | string }).index = streamIndex; - } - yield part; - break; - } - case 'response.function_call_arguments.done': { - const functionArguments = requireStringField(chunk, 'arguments', type); - const streamIndex = responseStreamIndex( - readStringField(chunk, 'item_id'), - readNumberField(chunk, 'output_index'), - ); - yield* yieldFinalArgumentsSuffix(streamIndex, functionArguments, type); - break; - } - case 'response.reasoning_summary_part.added': - yield { type: 'think', think: '' }; - break; - case 'response.reasoning_summary_text.delta': - yield { type: 'think', think: requireStringField(chunk, 'delta', type) }; - break; - case 'response.completed': - case 'response.incomplete': { - const responseObject = requireObjectField(chunk, 'response', type); - const respId = readStringField(responseObject, 'id'); - if (respId !== undefined) { - this._id = respId; - } - const usage = readObjectField(responseObject, 'usage'); - if (usage !== undefined) { - this._extractUsage(usage); - } - this._captureFinishReasonFromResponse(responseObject); - break; - } - case 'error': { - const message = requireStringField(chunk, 'message', type); - throw errorFromOpenAIResponsesEvent( - 'OpenAI Responses stream error', - readNullableStringField(chunk, 'code') ?? null, - message, - readNullableStringField(chunk, 'param') ?? null, - { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, - ); - } - case 'response.failed': { - const responseObject = requireObjectField(chunk, 'response', type); - const error = readResponsesFailedResponseError(responseObject); - if (error !== undefined) { - throw errorFromOpenAIResponsesEvent( - 'OpenAI Responses response.failed', - error.code, - error.message, - null, - { rawEvent: chunk, convertErrorHook: this._convertErrorHook }, - ); - } - throw new ChatProviderError( - `OpenAI Responses response.failed: ${formatResponsesFailedResponse(responseObject)}`, - ); - } - default: - break; - } - } - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } -} - -export class OpenAIResponsesChatProvider implements ChatProvider { - readonly name: string = 'openai-responses'; - - private readonly _model: string; - private readonly _stream: boolean; - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string | undefined; - private readonly _defaultHeaders: Record | undefined; - private readonly _thinkingEffort: ThinkingEffort | undefined; - private readonly _offEffort: string | undefined; - private readonly _generationKwargs: OpenAIResponsesGenerationKwargs; - private readonly _toolMessageConversion: ToolMessageConversion; - private readonly _client: OpenAI | undefined; - private readonly _httpClient: unknown; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - private readonly _convertErrorHook: ((error: unknown) => ChatProviderError | undefined) | undefined; - - constructor(options: OpenAIResponsesOptions) { - const apiKey = options.apiKey ?? process.env['OPENAI_API_KEY']; - this._apiKey = apiKey === undefined || apiKey.length === 0 ? undefined : apiKey; - this._baseUrl = options.baseUrl ?? 'https://api.openai.com/v1'; - this._defaultHeaders = options.defaultHeaders; - this._model = options.model; - this._stream = true; - this._thinkingEffort = options.thinkingEffort; - this._offEffort = options.offEffort; - this._generationKwargs = {}; - this._toolMessageConversion = options.toolMessageConversion ?? null; - this._httpClient = options.httpClient; - this._clientFactory = options.clientFactory; - this._convertErrorHook = options.convertError; - - if (options.maxOutputTokens !== undefined) { - this._generationKwargs.max_output_tokens = options.maxOutputTokens; - } - - this._client = this._apiKey === undefined ? undefined : this._buildClient(this._apiKey); - } - - get modelName(): string { - return this._model; - } - - get thinkingEffort(): ThinkingEffort | null { - return this._thinkingEffort ?? null; - } - - get maxCompletionTokens(): number | undefined { - return this._generationKwargs.max_output_tokens; - } - - async generate( - systemPrompt: string, - tools: Tool[], - history: Message[], - options?: GenerateOptions, - ): Promise { - const input: unknown[] = []; - - const normalizedHistory = normalizeToolCallIdsForProvider( - history, - OPENAI_RESPONSES_TOOL_CALL_ID_POLICY, - ); - input.push( - ...convertHistoryMessages(normalizedHistory, this._model, this._toolMessageConversion), - ); - - let kwargs: Record = { ...this._generationKwargs }; - - if (options?.cacheKey !== undefined) { - kwargs = { ...kwargs, prompt_cache_key: options.cacheKey }; - } - if (options?.sampling?.temperature !== undefined) { - kwargs = { ...kwargs, temperature: options.sampling.temperature }; - } - if (options?.sampling?.topP !== undefined) { - kwargs = { ...kwargs, top_p: options.sampling.topP }; - } - - const thinking = - options?.thinking ?? - (this._thinkingEffort !== undefined ? { effort: this._thinkingEffort } : undefined); - if (thinking !== undefined) { - const effort = - thinking.effort === 'off' - ? this._offEffort - : thinking.effort === 'on' - ? undefined - : thinking.effort; - kwargs = { ...kwargs, reasoning_effort: effort }; - } - - if (options?.maxCompletionTokens !== undefined) { - let cap = options.maxCompletionTokens; - if ( - options.usedContextTokens !== undefined && - options.maxContextTokens !== undefined && - options.maxContextTokens > 0 - ) { - cap = Math.min(cap, options.maxContextTokens - options.usedContextTokens); - } - kwargs = { ...kwargs, max_output_tokens: Math.max(1, cap) }; - } - - const reasoningEffort = kwargs['reasoning_effort'] as string | undefined; - delete kwargs['reasoning_effort']; - - if (reasoningEffort !== undefined) { - kwargs['reasoning'] = { - effort: reasoningEffort, - summary: 'auto', - }; - kwargs['include'] = ['reasoning.encrypted_content']; - } - - for (const key of Object.keys(kwargs)) { - if (kwargs[key] === undefined) { - delete kwargs[key]; - } - } - - try { - const client = this._createClient(options?.auth); - const createParams: Record = { - model: this._model, - input, - tools: tools.map((t) => convertTool(t)), - store: false, - stream: this._stream, - ...kwargs, - }; - if (systemPrompt) { - createParams['instructions'] = systemPrompt; - } - if (options?.responseFormat !== undefined) { - createParams['text'] = { - ...asRawObject(createParams['text']), - ...responseFormatToResponsesText(options.responseFormat), - }; - } - - if ( - !('responses' in client) || - typeof (client as { responses?: { create?: unknown } }).responses?.create !== 'function' - ) { - throw new Error2( - ProtocolErrors.codes.PROVIDER_API_ERROR, - 'OpenAI SDK version does not support Responses API. Upgrade to >=4.x with responses support.', - ); - } - - options?.onRequestSent?.(); - const response = await ( - client.responses as { - create(params: unknown, opts?: unknown): Promise; - } - ).create(createParams, options?.signal ? { signal: options.signal } : undefined); - return new OpenAIResponsesStreamedMessage(response, this._stream, this._convertErrorHook); - } catch (error: unknown) { - throw convertOpenAIError(error, this._convertErrorHook); - } - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => - this._buildClient(requireProviderApiKey('OpenAIResponsesChatProvider', a, this._apiKey), a), - ); - } - - private _buildClient(apiKey: string, auth?: ProviderRequestAuth): OpenAI { - const clientOpts: Record = { - apiKey, - baseURL: this._baseUrl, - maxRetries: 0, - }; - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, auth?.headers); - if (defaultHeaders !== undefined) { - clientOpts['defaultHeaders'] = defaultHeaders; - } - if (this._httpClient !== undefined) { - clientOpts['httpClient'] = this._httpClient; - } - return new OpenAI(clientOpts as ConstructorParameters[0]); - } -} - -export function getOpenAIResponsesModelCapability(modelName: string) { - const normalized = modelName.toLowerCase(); - if (isOpenAIReasoningModel(normalized)) { - return OPENAI_REASONING_CAPABILITY; - } - if (hasModelPrefix(normalized, OPENAI_VISION_TOOL_PREFIXES)) { - return OPENAI_VISION_TOOL_CAPABILITY; - } - return undefined; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts b/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts deleted file mode 100644 index a1a512491ff..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/openai/openaiHooks.ts +++ /dev/null @@ -1,141 +0,0 @@ -import type { GenerateOptions, VideoUploadInput } from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { ProtocolEndpoint, ResolvedTrait } from '#/kosong/protocol/protocolTrait'; - -import type { OpenAIChatCompletionsHooks } from './openai-legacy'; - -export function composeOpenAIChatHooks( - traits: readonly ResolvedTrait[], -): OpenAIChatCompletionsHooks | undefined { - const hooks: OpenAIChatCompletionsHooks = {}; - - const messageShapers = traits.filter(({ trait }) => trait.convertMessage !== undefined); - if (messageShapers.length > 0) { - hooks.convertMessage = (message, converted) => { - let current: Record | null = converted; - for (const { trait, context } of messageShapers) { - current = trait.convertMessage!(message, current, context); - if (current === null) return null; - } - return current; - }; - } - - const historyMergers = traits.filter(({ trait }) => trait.mergeHistory !== undefined); - if (historyMergers.length > 0) { - hooks.mergeHistory = (messages) => { - let current: readonly Record[] = messages; - for (const { trait, context } of historyMergers) { - const next = trait.mergeHistory!(current, context); - if (next !== undefined) current = next; - } - return [...current]; - }; - } - - const paramsBuilders = traits.filter(({ trait }) => trait.buildParams !== undefined); - if (paramsBuilders.length > 0) { - hooks.buildParams = (params) => { - let current = params; - for (const { trait, context } of paramsBuilders) { - const next = trait.buildParams!(current, context); - if (next !== undefined) current = next; - } - return current; - }; - } - - for (const { trait, context } of traits) { - if (trait.convertTool !== undefined) { - hooks.convertTool = (tool: Tool) => trait.convertTool!(tool, context); - } - if (trait.convertError !== undefined) { - hooks.convertError = (error: unknown) => trait.convertError!(error, context); - } - if (trait.toolCallIdPolicy !== undefined) { - hooks.toolCallIdPolicy = () => trait.toolCallIdPolicy!(context); - } - if (trait.withThinking !== undefined) { - hooks.withThinking = (effort, options, generationKwargs) => - trait.withThinking!(effort, options, generationKwargs, context); - } - if (trait.preserveThinking !== undefined) { - hooks.preserveThinking = (generationKwargs) => - trait.preserveThinking!(generationKwargs, context); - } - if (trait.withMaxCompletionTokens !== undefined) { - hooks.withMaxCompletionTokens = (maxCompletionTokens) => - trait.withMaxCompletionTokens!(maxCompletionTokens, context); - } - if (trait.cacheKey !== undefined) { - hooks.cacheKey = (key) => trait.cacheKey!(key, context); - } - if (trait.extractUsage !== undefined) { - hooks.extractUsage = (chunk) => trait.extractUsage!(chunk, context); - } - if (trait.reasoningKey !== undefined) { - hooks.reasoningKey = () => trait.reasoningKey!(context); - } - if (trait.uploadVideo !== undefined) { - hooks.uploadVideo = (input: string | VideoUploadInput, options?: GenerateOptions) => - trait.uploadVideo!(input, options, context); - } - } - - return Object.keys(hooks).length > 0 ? hooks : undefined; -} - -export interface AggregatedEndpoint { - readonly apiKeyEnv: readonly string[]; - readonly baseUrlEnv: readonly string[]; - readonly defaultBaseUrl?: string; -} - -export function traitEndpoint(traits: readonly ResolvedTrait[]): AggregatedEndpoint | undefined { - const apiKeyEnv: string[] = []; - const baseUrlEnv: string[] = []; - let defaultBaseUrl: string | undefined; - let declared = false; - for (const { trait, context } of traits) { - if (trait.endpoint === undefined) continue; - const endpoint: ProtocolEndpoint | undefined = trait.endpoint(context); - if (endpoint === undefined) continue; - declared = true; - if (endpoint.apiKeyEnv !== undefined) apiKeyEnv.push(endpoint.apiKeyEnv); - if (endpoint.baseUrlEnv !== undefined) baseUrlEnv.push(endpoint.baseUrlEnv); - if (endpoint.defaultBaseUrl !== undefined) defaultBaseUrl = endpoint.defaultBaseUrl; - } - return declared ? { apiKeyEnv, baseUrlEnv, defaultBaseUrl } : undefined; -} - -export function firstProcessEnv(names: readonly string[] | undefined): string | undefined { - if (names === undefined) return undefined; - for (const name of names) { - const value = process.env[name]; - if (value !== undefined && value.length > 0) return value; - } - return undefined; -} - -export function traitProvides( - traits: readonly ResolvedTrait[], -): Record | undefined { - let provides: Record | undefined; - for (const { trait, context } of traits) { - if (trait.provides === undefined) continue; - const declared = trait.provides(context); - if (declared === undefined) continue; - provides = { ...provides, ...declared }; - } - return provides; -} - -export function compactObject>(obj: T): Partial { - const out: Partial = {}; - for (const [key, value] of Object.entries(obj)) { - if (value !== undefined) { - (out as Record)[key] = value; - } - } - return out; -} diff --git a/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts b/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts deleted file mode 100644 index 5d3495c9871..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/bases/request-auth.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; - -export function requireProviderApiKey( - providerName: string, - auth: ProviderRequestAuth | undefined, - defaultApiKey?: string, -): string { - const apiKey = auth?.apiKey ?? defaultApiKey; - if (apiKey === undefined || apiKey.length === 0) { - throw new ChatProviderError( - `${providerName}: apiKey is required. Provide it via the constructor options, the provider's API-key environment variable, options.auth.apiKey on each request, or an OAuth login.`, - ); - } - return apiKey; -} - -export function mergeRequestHeaders( - defaultHeaders: Record | undefined, - requestHeaders: Record | undefined, -): Record | undefined { - const merged: Record = {}; - if (defaultHeaders !== undefined) { - Object.assign(merged, defaultHeaders); - } - if (requestHeaders !== undefined) { - Object.assign(merged, requestHeaders); - } - return Object.keys(merged).length > 0 ? merged : undefined; -} - -export function resolveAuthBackedClient( - state: { - readonly cachedClient: TClient | undefined; - readonly clientFactory: ((auth: ProviderRequestAuth) => TClient) | undefined; - }, - auth: ProviderRequestAuth | undefined, - build: (auth: ProviderRequestAuth | undefined) => TClient, -): TClient { - if (state.clientFactory !== undefined) { - return state.clientFactory(auth ?? {}); - } - if (auth === undefined && state.cachedClient !== undefined) { - return state.cachedClient; - } - return build(auth); -} diff --git a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts deleted file mode 100644 index ef5f601eaf6..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/protocolAdapterRegistry.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { LifecycleScope } from '#/app/scopes'; - -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { ChatProvider } from '#/kosong/contract/provider'; -import { - IProtocolAdapterRegistry, - type ExplainedCapability, - type Protocol, - type ProtocolAdapterConfig, -} from '#/kosong/protocol/protocol'; -import { - getProtocolBase, - listProtocolBases, - type ProtocolBaseId, - type ResolvedAdapterIdentity, -} from '#/kosong/protocol/protocolBase'; -import type { ProtocolTrait, ResolvedTrait, TraitContext } from '#/kosong/protocol/protocolTrait'; - -import { getProviderDefinition } from './providerDefinition'; - -const CONFIG_DEFAULT_HEADERS_TRAIT: ProtocolTrait = { - defaultHeaders: (ctx) => - ctx.config.defaultHeaders === undefined ? undefined : { ...ctx.config.defaultHeaders }, -}; - -export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { - declare readonly _serviceBrand: undefined; - - supportedProtocols(): readonly Protocol[] { - return listProtocolBases().map((base) => base.id); - } - - resolveAdapterIdentity(protocol: Protocol, providerType?: string): ResolvedAdapterIdentity { - const definition = - providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); - const baseId: ProtocolBaseId = protocol; - const traits: readonly ProtocolTrait[] = definition?.traits ?? []; - - const context: TraitContext = { - config: { protocol, providerType, modelName: '' }, - providerId: providerType, - }; - const resolved: ResolvedTrait[] = traits.map((trait) => ({ trait, context })); - resolved.push({ trait: CONFIG_DEFAULT_HEADERS_TRAIT, context }); - return { baseId, traits: resolved }; - } - - resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId { - const definition = - providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); - if (definition !== undefined) { - return definition.baseProtocol; - } - return protocol; - } - - resolveCapability(protocol: Protocol, modelName: string, providerType?: string): ModelCapability { - return this.explainCapability(protocol, modelName, providerType).capability; - } - - explainCapability( - protocol: Protocol, - modelName: string, - providerType?: string, - ): ExplainedCapability { - const identity = this.resolveAdapterIdentity(protocol, providerType); - let traitCapability: ModelCapability | undefined; - for (const { trait, context } of identity.traits) { - if (trait.capability === undefined) continue; - const capability = trait.capability(modelName, context); - if (capability !== undefined) { - traitCapability = capability; - } - } - if (traitCapability !== undefined) { - return { - capability: traitCapability, - source: { - kind: 'builtin', - detail: `trait capability hook (provider '${providerType ?? 'unregistered'}')`, - }, - }; - } - - const baseCapability = getProtocolBase(identity.baseId)?.capability?.(modelName); - if (baseCapability !== undefined) { - return { - capability: baseCapability, - source: { kind: 'builtin', detail: `protocol base '${identity.baseId}' catalog` }, - }; - } - return { - capability: UNKNOWN_CAPABILITY, - source: { kind: 'none', detail: 'no capability source knew this model' }, - }; - } - - createChatProvider(config: ProtocolAdapterConfig): ChatProvider { - const identity = this.resolveAdapterIdentity(config.protocol, config.providerType); - const traits: ResolvedTrait[] = identity.traits.map(({ trait }) => ({ - trait, - context: { config, providerId: config.providerType }, - })); - const base = getProtocolBase(identity.baseId); - if (base === undefined) { - throw new ChatProviderError( - `No protocol base registered for '${identity.baseId}'. Import the base's contrib module first.`, - ); - } - return base.createChatProvider({ config, traits }); - } -} - -registerScopedService( - LifecycleScope.App, - IProtocolAdapterRegistry, - ProtocolAdapterRegistry, - ScopeActivation.OnScopeCreated, - 'provider', -); diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts deleted file mode 100644 index 138068ee91c..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi-files.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { Blob, File } from 'node:buffer'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; - -import type OpenAI from 'openai'; -import OpenAIClient from 'openai'; - -import { ChatProviderError } from '#/kosong/contract/errors'; -import type { VideoURLPart } from '#/kosong/contract/message'; -import type { ProviderRequestAuth, VideoUploadInput } from '#/kosong/contract/provider'; - -import { convertOpenAIError } from '../../bases/openai/openai-common'; -import { - mergeRequestHeaders, - requireProviderApiKey, - resolveAuthBackedClient, -} from '../../bases/request-auth'; -import { classifyKimiQuotaError } from './kimi-errors'; - -export interface KimiUploadOptions { - auth?: ProviderRequestAuth; - signal?: AbortSignal; -} - -export interface KimiFilesOptions { - apiKey?: string; - baseUrl: string; - defaultHeaders?: Record; - clientFactory?: (auth: ProviderRequestAuth) => OpenAI; -} - -export class KimiFiles { - private readonly _apiKey: string | undefined; - private readonly _baseUrl: string; - private readonly _defaultHeaders: Record | undefined; - private readonly _client: OpenAI | undefined; - private readonly _clientFactory: ((auth: ProviderRequestAuth) => OpenAI) | undefined; - - constructor(options: KimiFilesOptions) { - this._apiKey = options.apiKey; - this._baseUrl = options.baseUrl; - this._defaultHeaders = options.defaultHeaders; - this._clientFactory = options.clientFactory; - this._client = - options.apiKey === undefined || options.apiKey.length === 0 - ? undefined - : new OpenAIClient({ - apiKey: options.apiKey, - baseURL: options.baseUrl, - defaultHeaders: options.defaultHeaders, - }); - } - - async uploadVideo( - input: string | VideoUploadInput, - options?: KimiUploadOptions, - ): Promise { - let file: unknown; - - if (typeof input === 'string') { - if (!fs.existsSync(input)) { - throw new ChatProviderError(`Video file not found: ${input}`); - } - const filename = path.basename(input); - const mimeType = guessMimeTypeFromExt(filename); - if (mimeType === undefined || !mimeType.startsWith('video/')) { - throw new ChatProviderError( - `KimiFiles.uploadVideo: file extension does not indicate a video type: ${filename}`, - ); - } - const data = await fs.promises.readFile(input); - const blob = new Blob([new Uint8Array(data)], { type: mimeType }); - file = new File([blob], filename, { type: mimeType }); - } else { - if (!input.mimeType.startsWith('video/')) { - throw new ChatProviderError(`Expected a video mime type, got ${input.mimeType}`); - } - const filename = input.filename ?? guessFilename(input.mimeType); - const bytes = input.data instanceof Uint8Array ? input.data : new Uint8Array(input.data); - const blob = new Blob([bytes], { type: input.mimeType }); - file = new File([blob], filename, { type: input.mimeType }); - } - - let uploaded: { id: string }; - try { - const client = this._createClient(options?.auth); - uploaded = (await client.files.create( - { - file: file as never, - purpose: 'video' as never, - }, - options?.signal ? { signal: options.signal } : undefined, - )) as unknown as { id: string }; - } catch (error: unknown) { - throw convertOpenAIError(error, classifyKimiQuotaError); - } - - return { - type: 'video_url', - videoUrl: { - url: `ms://${uploaded.id}`, - id: uploaded.id, - }, - }; - } - - private _createClient(auth: ProviderRequestAuth | undefined): OpenAI { - return resolveAuthBackedClient( - { cachedClient: this._client, clientFactory: this._clientFactory }, - auth, - (a) => { - const defaultHeaders = mergeRequestHeaders(this._defaultHeaders, a?.headers); - return new OpenAIClient({ - apiKey: requireProviderApiKey('KimiFiles.uploadVideo', a, this._apiKey), - baseURL: this._baseUrl, - defaultHeaders, - }); - }, - ); - } -} - -function guessFilename(mimeType: string): string { - const ext = MIME_TO_EXT[mimeType.toLowerCase()] ?? 'bin'; - return `upload.${ext}`; -} - -const MIME_TO_EXT: Record = { - 'video/mp4': 'mp4', - 'video/mpeg': 'mpeg', - 'video/quicktime': 'mov', - 'video/webm': 'webm', - 'video/x-matroska': 'mkv', - 'video/x-msvideo': 'avi', - 'video/x-flv': 'flv', - 'video/3gpp': '3gp', -}; - -const EXT_TO_MIME: Record = Object.fromEntries( - Object.entries(MIME_TO_EXT).map(([mime, ext]) => [ext, mime]), -); - -function guessMimeTypeFromExt(filename: string): string | undefined { - const dot = filename.lastIndexOf('.'); - if (dot < 0) return undefined; - const ext = filename.slice(dot + 1).toLowerCase(); - return EXT_TO_MIME[ext]; -} diff --git a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts deleted file mode 100644 index 03b8e116847..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/kimi/kimi.contrib.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { ContentPart } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import type { - ProtocolEndpoint, - ProtocolTrait, - TraitContext, -} from '#/kosong/protocol/protocolTrait'; - -import { type OpenAIToolParam, toolToOpenAI } from '../../bases/openai/openai-common'; -import { registerProviderDefinition } from '../../providerDefinition'; -import { classifyKimiQuotaError } from './kimi-errors'; -import { KimiFiles } from './kimi-files'; -import { normalizeKimiToolSchema } from './kimi-schema'; - -export const KIMI_API_KEY_ENV = 'KIMI_API_KEY'; -export const KIMI_BASE_URL_ENV = 'KIMI_BASE_URL'; -export const KIMI_DEFAULT_BASE_URL = 'https://api.moonshot.ai/v1'; - -const INTERLEAVED_THINKING_BETA = 'interleaved-thinking-2025-05-14'; - -export interface GenerationKwargs { - max_tokens?: number | undefined; - max_completion_tokens?: number | undefined; - temperature?: number | undefined; - top_p?: number | undefined; - n?: number | undefined; - presence_penalty?: number | undefined; - frequency_penalty?: number | undefined; - stop?: string | string[] | undefined; - prompt_cache_key?: string | undefined; - extra_body?: ExtraBody; -} - -export interface KimiThinkingConfig { - type?: 'enabled' | 'disabled'; - effort?: string; - keep?: unknown; - [key: string]: unknown; -} - -export interface ExtraBody { - thinking?: KimiThinkingConfig; - [key: string]: unknown; -} - -export function convertKimiTool(tool: Tool): OpenAIToolParam { - if (tool.name.startsWith('$')) { - return { - type: 'builtin_function', - function: { name: tool.name }, - }; - } - const converted = toolToOpenAI(tool); - return { - ...converted, - function: { - ...converted.function, - parameters: normalizeKimiToolSchema(tool.parameters), - }, - }; -} - -function isEffectivelyEmptyContent(parts: ContentPart[]): boolean { - for (const part of parts) { - if (part.type !== 'text') return false; - if (part.text.trim() !== '') return false; - } - return true; -} - -const filesByContext = new WeakMap(); - -function firstEnv(...names: readonly string[]): string | undefined { - for (const name of names) { - const value = process.env[name]; - if (value !== undefined && value.length > 0) return value; - } - return undefined; -} - -function resolveFiles(ctx: TraitContext): KimiFiles { - let files = filesByContext.get(ctx); - if (files === undefined) { - files = new KimiFiles({ - apiKey: ctx.config.apiKey ?? firstEnv(KIMI_API_KEY_ENV), - baseUrl: ctx.config.baseUrl ?? firstEnv(KIMI_BASE_URL_ENV) ?? KIMI_DEFAULT_BASE_URL, - defaultHeaders: - ctx.config.defaultHeaders === undefined ? undefined : { ...ctx.config.defaultHeaders }, - }); - filesByContext.set(ctx, files); - } - return files; -} - -export const kimiOpenAITrait: ProtocolTrait = { - strictThinkingValidation: true, - - endpoint: () => ({ - apiKeyEnv: KIMI_API_KEY_ENV, - baseUrlEnv: KIMI_BASE_URL_ENV, - defaultBaseUrl: KIMI_DEFAULT_BASE_URL, - }), - - convertError: (error) => classifyKimiQuotaError(error), - - cacheKey: (key) => ({ prompt_cache_key: key }), - - withThinking: (effort, options, generationKwargs) => { - const thinking: KimiThinkingConfig = - effort === 'off' - ? { type: 'disabled' } - : effort === 'on' - ? { type: 'enabled' } - : { type: 'enabled', effort }; - if (options.keep !== undefined) { - thinking.keep = options.keep; - } - const extraBody = generationKwargs['extra_body'] as ExtraBody | undefined; - return { extra_body: { ...extraBody, thinking } }; - }, - - preserveThinking: (generationKwargs) => { - const extraBody = generationKwargs['extra_body'] as ExtraBody | undefined; - const thinking = extraBody?.thinking; - if (thinking?.keep === 'all' && thinking.type !== 'disabled') { - return true; - } - return undefined; - }, - - withMaxCompletionTokens: (maxCompletionTokens) => ({ - max_completion_tokens: maxCompletionTokens, - }), - - buildParams: (params) => { - const { - extra_body: extraBody, - max_tokens: maxTokens, - max_completion_tokens: maxCompletionTokens, - ...rest - } = params; - const out: Record = { ...rest }; - const resolvedMaxCompletionTokens = maxCompletionTokens ?? maxTokens; - if (resolvedMaxCompletionTokens !== undefined) { - out['max_completion_tokens'] = resolvedMaxCompletionTokens; - } - if (extraBody !== undefined && extraBody !== null) { - Object.assign(out, extraBody); - } - return out; - }, - - convertTool: (tool) => convertKimiTool(tool), - - convertMessage: (message, converted) => { - if (message.role === 'assistant' && message.toolCalls.length > 0) { - const nonThinkParts = message.content.filter((part) => part.type !== 'think'); - if (isEffectivelyEmptyContent(nonThinkParts)) { - delete converted['content']; - } - } - - const convertedToolCalls = converted['tool_calls']; - if (Array.isArray(convertedToolCalls)) { - message.toolCalls.forEach((toolCall, index) => { - if (toolCall.extras === undefined) return; - const out = convertedToolCalls[index] as Record | undefined; - if (out !== undefined) { - out['extras'] = toolCall.extras; - } - }); - } - - if (message.tools !== undefined && message.tools.length > 0) { - converted['tools'] = message.tools.map((tool) => convertKimiTool(tool)); - } - - return converted; - }, - - extractUsage: (chunk) => { - const topLevel = chunk['usage']; - if (topLevel !== null && topLevel !== undefined && typeof topLevel === 'object') { - return topLevel as Record; - } - const choices = chunk['choices']; - if (!Array.isArray(choices) || choices.length === 0) { - return undefined; - } - const firstChoice = choices[0] as Record | undefined; - const choiceUsage = firstChoice?.['usage']; - if (choiceUsage !== null && choiceUsage !== undefined && typeof choiceUsage === 'object') { - return choiceUsage as Record; - } - return undefined; - }, - - uploadVideo: (input, options, ctx) => resolveFiles(ctx).uploadVideo(input, options), -}; - -export const kimiAnthropicTrait: ProtocolTrait = { - convertError: (error) => classifyKimiQuotaError(error), - - withThinking: (effort, _options, generationKwargs) => { - const seeded = generationKwargs['betaFeatures']; - const betaFeatures = (Array.isArray(seeded) ? (seeded as string[]) : []).filter( - (beta) => beta !== INTERLEAVED_THINKING_BETA, - ); - if (effort === 'off') { - return { - thinking: { type: 'disabled' }, - output_config: undefined, - betaFeatures, - }; - } - return { - thinking: { type: 'enabled' }, - output_config: effort === 'on' ? undefined : { effort }, - betaFeatures, - }; - }, -}; - -const kimiEndpoint: ProtocolEndpoint = { - apiKeyEnv: KIMI_API_KEY_ENV, - baseUrlEnv: KIMI_BASE_URL_ENV, - defaultBaseUrl: KIMI_DEFAULT_BASE_URL, -}; - -export const kimiResponsesTrait: ProtocolTrait = { - endpoint: () => kimiEndpoint, - - convertError: (error) => classifyKimiQuotaError(error), -}; - -registerProviderDefinition({ - id: 'kimi', - baseProtocol: 'openai', - traits: [kimiOpenAITrait], - endpoint: kimiEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); - -registerProviderDefinition({ - id: 'kimi', - baseProtocol: 'anthropic', - traits: [kimiAnthropicTrait], - endpoint: kimiEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); - -registerProviderDefinition({ - id: 'kimi', - baseProtocol: 'openai_responses', - traits: [kimiResponsesTrait], - endpoint: kimiEndpoint, - hostHeaders: 'full', - modelSource: 'oauth-catalog', -}); diff --git a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts b/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts deleted file mode 100644 index 6e075737fac..00000000000 --- a/packages/agent-core-v2/src/kosong/provider/providers/standard.contrib.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { registerProviderDefinition } from '../providerDefinition'; - -registerProviderDefinition({ - id: 'anthropic', - baseProtocol: 'anthropic', - traits: [], - endpoint: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'openai', - baseProtocol: 'openai', - traits: [], - endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'openai_responses', - baseProtocol: 'openai_responses', - traits: [], - endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, -}); - -registerProviderDefinition({ - id: 'google-genai', - baseProtocol: 'google-genai', - traits: [ - { endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }) }, - { endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }) }, - ], -}); diff --git a/packages/agent-core-v2/src/llm-adapter/contract/capability.ts b/packages/agent-core-v2/src/llm-adapter/contract/capability.ts new file mode 100644 index 00000000000..707f39e1f49 --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/contract/capability.ts @@ -0,0 +1,57 @@ +import type { ModelCapability as LlmModelCapability } from '#human/llm/capability'; + +export interface ModelCapability { + readonly image_in: boolean; + readonly video_in: boolean; + readonly audio_in: boolean; + readonly thinking: boolean; + readonly tool_use: boolean; + readonly max_context_tokens: number; + readonly max_input_tokens?: number; + readonly dynamically_loaded_tools?: boolean; +} + +const UNKNOWN_CAPABILITY_MARKER = Symbol.for('moonshot-ai.kosong.UNKNOWN_CAPABILITY'); + +export const UNKNOWN_CAPABILITY: ModelCapability = Object.freeze( + Object.defineProperty( + { + image_in: false, + video_in: false, + audio_in: false, + thinking: false, + tool_use: false, + max_context_tokens: 0, + dynamically_loaded_tools: false, + }, + UNKNOWN_CAPABILITY_MARKER, + { value: true }, + ), +); + +export function isUnknownCapability(capability: ModelCapability): boolean { + if (capability === UNKNOWN_CAPABILITY) return true; + const marked = + (capability as unknown as Record)[UNKNOWN_CAPABILITY_MARKER] === true; + if (marked) return true; + return ( + !capability.image_in && + !capability.video_in && + !capability.audio_in && + !capability.thinking && + !capability.tool_use && + capability.dynamically_loaded_tools !== true && + capability.max_context_tokens === 0 + ); +} + +export function toLlmCapability(capability: ModelCapability): LlmModelCapability { + return { + image_in: capability.image_in, + video_in: capability.video_in, + audio_in: capability.audio_in, + thinking: capability.thinking, + tool_use: capability.tool_use, + dynamically_loaded_tools: capability.dynamically_loaded_tools, + }; +} diff --git a/packages/agent-core-v2/src/kosong/contract/errors.ts b/packages/agent-core-v2/src/llm-adapter/contract/errors.ts similarity index 67% rename from packages/agent-core-v2/src/kosong/contract/errors.ts rename to packages/agent-core-v2/src/llm-adapter/contract/errors.ts index 3a6bafde1d6..8e7c8706c21 100644 --- a/packages/agent-core-v2/src/kosong/contract/errors.ts +++ b/packages/agent-core-v2/src/llm-adapter/contract/errors.ts @@ -1,5 +1,25 @@ import { Error2, type Error2Options } from '#/_base/errors/errors'; -import type { FinishReason } from './provider'; +import { + appendThinkingEffortConfigHint, + isAbortError, + isContextOverflowStatusError, + isImageFormatStatusError, + isProviderOverloadStatusError, + isRequestStructureStatusError, + isRequestTooLargeStatusError, + isToolExchangeAdjacencyStatusError, + llmStatusErrorMessage, + sanitizeStatusErrorMessage, + type LlmErrorMessage, + type LlmRemoteErrorMessage, +} from '#human/llm/errors'; +import type { FinishReason } from '#human/llm/finish-reason'; + +export { + isAbortError, + parseRetryAfterMs, + sanitizeStatusErrorMessage, +} from '#human/llm/errors'; export const CONFIG_INVALID_ERROR_CODE = 'config.invalid'; @@ -20,13 +40,6 @@ export type ProviderErrorCode = | typeof PROVIDER_OVERLOADED_ERROR_CODE | typeof CONTEXT_OVERFLOW_ERROR_CODE; -export function sanitizeStatusErrorMessage(message: string): string { - const titleMatch = /]*>([\s\S]*?)<\/title>/i.exec(message); - const extracted = titleMatch?.[1]?.trim(); - const normalized = extracted !== undefined && extracted.length > 0 ? extracted : message; - return normalized.replaceAll('\r', ''); -} - function codeForStatusError(statusCode: number): ProviderErrorCode { if (statusCode === 429) return PROVIDER_RATE_LIMIT_ERROR_CODE; if (statusCode === 401 || statusCode === 403) return PROVIDER_AUTH_ERROR_CODE; @@ -181,16 +194,6 @@ export function createAbortError(): DOMException { return new DOMException('The operation was aborted.', 'AbortError'); } -export function isAbortError(error: unknown): boolean { - if (error instanceof DOMException && error.name === 'AbortError') return true; - if (error instanceof Error && error.name === 'AbortError') return true; - return ( - typeof error === 'object' && - error !== null && - (error as object).constructor?.name === 'APIUserAbortError' - ); -} - export function throwIfAbortError(error: unknown): void { if (isAbortError(error)) { throw createAbortError(); @@ -202,27 +205,11 @@ const IMAGE_FORMAT_PROVIDER_MESSAGE_PATTERNS = [ /invalid data url for image/, ] as const; -const IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS = [ - /unsupported image (?:url|format|type)/, - /does not represent a valid image/, - /could not (?:process|decode) (?:the |input )?image/, - /unable to process (?:the |input )?image/, - /failed to decode (?:the )?image/, - /invalid image(?: data| type| format)?/, -] as const; - -const MEDIA_TYPE_FIELD_PATTERN = /(?:media|mime)_?type/; - export function isImageFormatError(error: unknown): boolean { if (error instanceof APIStatusError) { if (error instanceof APIContextOverflowError) return false; if (error instanceof APIRequestTooLargeError) return false; - if (error.statusCode !== 400) return false; - const lowerMessage = error.message.toLowerCase(); - return ( - IMAGE_FORMAT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)) || - (MEDIA_TYPE_FIELD_PATTERN.test(lowerMessage) && lowerMessage.includes('image')) - ); + return isImageFormatStatusError(error.statusCode, error.message); } if (error instanceof ChatProviderError) { const lowerMessage = error.message.toLowerCase(); @@ -263,17 +250,6 @@ export function classifyBaseApiError(message: string): ChatProviderError { return new ChatProviderError(`Error: ${message}`); } -const CONTEXT_OVERFLOW_MESSAGE_PATTERNS = [ - /context[ _-]?length/, - /(?:context[ _-]?window.*exceed|exceed.*context[ _-]?window)/, - /maximum context/, - /exceed(?:ed|s|ing)?\s+(?:the\s+)?max(?:imum)?\s+tokens?/, - /(?:too many tokens.*(?:prompt|input|context)|(?:prompt|input|context).*too many tokens)/, - /prompt is too long.*maximum/, - /input token count.*exceeds?.*maximum number of tokens/, - /request.*exceed(?:ed|s|ing)?.*model token limit/, -] as const; - const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /(?:apistatuserror.*429|429.*apistatuserror)/, /429.*too many requests/, @@ -284,41 +260,6 @@ const PROVIDER_RATE_LIMIT_MESSAGE_PATTERNS = [ /rate-limited/, ] as const; -const PROVIDER_OVERLOAD_MESSAGE_PATTERNS = [/overload/] as const; - -const REQUEST_TOO_LARGE_MESSAGE_PATTERNS = [ - /request exceeds the maximum size/, - /request entity too large/, - /request_too_large/, - /exceeds? the maximum allowed number of bytes/, - /payload too large/, - /content too large/, - /request (?:body )?too large/, -] as const; - -const THINKING_EFFORT_CONFIG_DOCS_URL = - 'https://moonshotai.github.io/kimi-code/en/configuration/config-files.html#thinking'; - -const THINKING_EFFORT_STATUS_MESSAGE_PATTERNS = [ - /reasoning[_ .-]?effort/, - /thinking[_ .-]?effort/, - /output_config[\s\S]*effort/, - /unsupported[\s\S]*effort/, - /invalid[\s\S]*effort/, -] as const; - -function appendThinkingEffortConfigHint(statusCode: number, message: string): string { - if (statusCode !== 400 && statusCode !== 422) return message; - const lowerMessage = message.toLowerCase(); - if (!THINKING_EFFORT_STATUS_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage))) { - return message; - } - if (message.includes(THINKING_EFFORT_CONFIG_DOCS_URL)) return message; - return `${message} - -The provider rejected the configured thinking effort. Non-Kimi providers receive effort strings without client-side mapping; choose an effort supported by the selected model. For Kimi models, check support_efforts and default_effort. See ${THINKING_EFFORT_CONFIG_DOCS_URL}`; -} - export function isContextOverflowErrorCode(code: string | null | undefined): boolean { return code === 'context_length_exceeded'; } @@ -351,19 +292,6 @@ export function normalizeAPIStatusError( ); } -export function parseRetryAfterMs(headers: unknown): number | null { - const raw = - headers !== null && - typeof headers === 'object' && - typeof (headers as { get?: unknown }).get === 'function' - ? (headers as { get(name: string): string | null }).get('retry-after') - : null; - if (raw === null || raw === undefined) return null; - const seconds = Number.parseInt(raw, 10); - if (!Number.isFinite(seconds) || seconds < 0) return null; - return seconds * 1000; -} - export function parseTraceId(headers: unknown): string | null { const raw = headers !== null && @@ -375,61 +303,24 @@ export function parseTraceId(headers: unknown): string | null { return raw; } -export function isContextOverflowStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 400 && statusCode !== 413 && statusCode !== 422) return false; - const lowerMessage = message.toLowerCase(); - return CONTEXT_OVERFLOW_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -export function isProviderOverloadStatusError(statusCode: number, message: string): boolean { - if (statusCode === 529) return true; - if (statusCode !== 500 && statusCode !== 503) return false; - const lowerMessage = message.toLowerCase(); - return PROVIDER_OVERLOAD_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -export function isRequestTooLargeStatusError(statusCode: number, message: string): boolean { - if (statusCode !== 413) return false; - const lowerMessage = message.toLowerCase(); - return REQUEST_TOO_LARGE_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); +export function traceIdFromHeadersRecord(headers: Record | null): string | null { + if (headers === null) return null; + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === 'x-trace-id' && value.length > 0) return value; + } + return null; } -const TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS = [ - /tool_use[\s\S]*tool_result/, - /tool_result[\s\S]*tool_use/, - /unexpected\s+`?tool_result/, - /tool_call_id[\s\S]*not found/, - /role\s+['"`]?tool['"`]?\s+must be a response to a preceding message/, - /assistant message with\s+['"`]?tool_calls['"`]?\s+must be followed by tool messages/, - /tool_call_ids? did not have response messages/, - /insufficient tool messages following/, -] as const; - export function isToolExchangeAdjacencyError(error: unknown): boolean { if (!(error instanceof APIStatusError)) return false; if (error instanceof APIContextOverflowError) return false; - if (error.statusCode !== 400 && error.statusCode !== 422) return false; - const lowerMessage = error.message.toLowerCase(); - return TOOL_EXCHANGE_ADJACENCY_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); -} - -const STRUCTURAL_REQUEST_MESSAGE_PATTERNS = [ - /text content blocks must be non-empty/, - /text content blocks must contain non-whitespace/, - /first message must use the .*user.* role/, - /roles must alternate/, - /multiple .*(?:user|assistant).* roles in a row/, - /tool_use[\s\S]*ids must be unique/, - /message at position \d+ with role ['"`]?[a-z]+['"`]? must not be empty/, -] as const; + return isToolExchangeAdjacencyStatusError(error.statusCode, error.message); +} export function isRecoverableRequestStructureError(error: unknown): boolean { - if (isToolExchangeAdjacencyError(error)) return true; if (!(error instanceof APIStatusError)) return false; if (error instanceof APIContextOverflowError) return false; - if (error.statusCode !== 400 && error.statusCode !== 422) return false; - const lowerMessage = error.message.toLowerCase(); - return STRUCTURAL_REQUEST_MESSAGE_PATTERNS.some((pattern) => pattern.test(lowerMessage)); + return isRequestStructureStatusError(error.statusCode, error.message); } export function isProviderRateLimitError(error: unknown): boolean { @@ -505,3 +396,169 @@ export function classifyApiError(error: unknown): ApiErrorClassification { if (error instanceof APIEmptyResponseError) return { kind: 'empty_response', statusCode }; return { kind: 'other', statusCode }; } + +export function isLlmErrorMessage(error: unknown): error is LlmErrorMessage { + return ( + typeof error === 'object' && + error !== null && + typeof (error as { kind?: unknown }).kind === 'string' && + typeof (error as { message?: unknown }).message === 'string' + ); +} + +export function isUnauthorizedLlmError(error: unknown): boolean { + return isLlmErrorMessage(error) && llmStatusErrorMessage(error)?.statusCode === 401; +} + +export function errorFromLlmMessage(error: LlmErrorMessage): Error { + switch (error.kind) { + case 'abort': + return createAbortError(); + case 'connection': + return new APIConnectionError(error.message); + case 'timeout': + return new APITimeoutError(error.message); + case 'rate_limit': + return new APIProviderRateLimitError( + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'quota_exhausted': + return new APIProviderQuotaExhaustedError( + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'overloaded': + return new APIProviderOverloadedError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'context_overflow': + return new APIContextOverflowError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'request_too_large': + return new APIRequestTooLargeError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'request_structure': + case 'image_format': + case 'status': + return new APIStatusError( + error.statusCode, + error.message, + error.requestId, + error.retryAfterMs, + traceIdFromHeadersRecord(error.headers), + ); + case 'empty_response': + return new APIEmptyResponseError(error.message, { + finishReason: error.finishReason, + rawFinishReason: error.rawFinishReason, + }); + case 'syntax': + case 'provider': + case 'unknown': + return new ChatProviderError(error.message); + } +} + +function traceHeadersOf(error: APIStatusError): Record | null { + return error.traceId === null ? null : { 'x-trace-id': error.traceId }; +} + +export function llmMessageFromError(error: unknown): LlmRemoteErrorMessage | undefined { + if (error instanceof APIProviderRateLimitError) { + return { + kind: 'rate_limit', + message: error.message, + statusCode: 429, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof APIProviderQuotaExhaustedError) { + return { + kind: 'quota_exhausted', + message: error.message, + statusCode: 429, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof APIContextOverflowError) { + return { + kind: 'context_overflow', + message: error.message, + statusCode: error.statusCode, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof APIRequestTooLargeError) { + return { + kind: 'request_too_large', + message: error.message, + statusCode: error.statusCode, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof APIProviderOverloadedError) { + return { + kind: 'overloaded', + message: error.message, + statusCode: error.statusCode, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof APIConnectionError) { + return { kind: 'connection', message: error.message }; + } + if (error instanceof APITimeoutError) { + return { kind: 'timeout', message: error.message }; + } + if (error instanceof APIEmptyResponseError) { + return { + kind: 'empty_response', + message: error.message, + finishReason: error.finishReason, + rawFinishReason: error.rawFinishReason, + }; + } + if (error instanceof APIStatusError) { + return { + kind: 'status', + message: error.message, + statusCode: error.statusCode, + requestId: error.requestId, + retryAfterMs: error.retryAfterMs, + headers: traceHeadersOf(error), + }; + } + if (error instanceof ChatProviderError) { + return { kind: 'provider', message: error.message }; + } + return undefined; +} diff --git a/packages/agent-core-v2/src/kosong/contract/inspection.ts b/packages/agent-core-v2/src/llm-adapter/contract/inspection.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/contract/inspection.ts rename to packages/agent-core-v2/src/llm-adapter/contract/inspection.ts diff --git a/packages/agent-core-v2/src/llm-adapter/contract/message.ts b/packages/agent-core-v2/src/llm-adapter/contract/message.ts new file mode 100644 index 00000000000..9bea1bada43 --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/contract/message.ts @@ -0,0 +1,121 @@ +import type { + AssistantMessage, + ContentPart, + Message as LlmMessage, + Role, + ToolCall, + ToolDescription, +} from '#human/llm/message'; + +export type { + AudioURLPart, + ContentPart, + ImageURLPart, + Role, + StreamedMessagePart, + TextPart, + ThinkPart, + ToolCall, + ToolCallPart, + VideoURLPart, +} from '#human/llm/message'; + +export type Tool = ToolDescription; + +export { + extractText, + getTextContent, + isContentPart, + isToolCall, + isToolCallPart, + mergeInPlace, +} from '#human/llm/message'; + +export interface Message { + readonly role: Role; + readonly name?: string; + readonly content: ContentPart[]; + readonly toolCalls: ToolCall[]; + readonly toolCallId?: string; + readonly partial?: boolean; + readonly tools?: readonly Tool[]; +} + +export function isToolDeclarationOnlyMessage(message: Message): boolean { + return ( + message.tools !== undefined && + message.tools.length > 0 && + message.content.length === 0 && + message.toolCalls.length === 0 + ); +} + +export function createUserMessage(content: string): Message { + return { + role: 'user', + content: [{ type: 'text', text: content }], + toolCalls: [], + }; +} + +export function createAssistantMessage(content: ContentPart[], toolCalls?: ToolCall[]): Message { + return { + role: 'assistant', + content, + toolCalls: toolCalls ?? [], + }; +} + +export function createToolMessage(toolCallId: string, output: string | ContentPart[]): Message { + const content: ContentPart[] = + typeof output === 'string' ? [{ type: 'text', text: output }] : output; + return { + role: 'tool', + content, + toolCalls: [], + toolCallId, + }; +} + +export function toLlmMessage(message: Message): LlmMessage { + switch (message.role) { + case 'system': + return { + role: 'system', + content: message.content, + tools: message.tools === undefined ? undefined : [...message.tools], + }; + case 'user': + return { role: 'user', content: message.content }; + case 'assistant': + return { role: 'assistant', content: message.content, toolCalls: message.toolCalls }; + case 'tool': + return { role: 'tool', content: message.content, toolCallId: message.toolCallId ?? '' }; + } +} + +export function fromLlmMessage(message: LlmMessage): Message { + switch (message.role) { + case 'system': + return { + role: 'system', + content: message.content, + toolCalls: [], + tools: message.tools, + }; + case 'user': + return { role: 'user', content: message.content, toolCalls: [] }; + case 'assistant': + return { role: 'assistant', content: message.content, toolCalls: message.toolCalls }; + case 'tool': + return { role: 'tool', content: message.content, toolCalls: [], toolCallId: message.toolCallId }; + } +} + +export function fromLlmAssistantMessage(message: AssistantMessage): Message { + return { + role: 'assistant', + content: message.content, + toolCalls: message.toolCalls, + }; +} diff --git a/packages/agent-core-v2/src/kosong/contract/requestTrace.ts b/packages/agent-core-v2/src/llm-adapter/contract/request-trace.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/contract/requestTrace.ts rename to packages/agent-core-v2/src/llm-adapter/contract/request-trace.ts diff --git a/packages/agent-core-v2/src/kosong/contract/tokens.ts b/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts similarity index 95% rename from packages/agent-core-v2/src/kosong/contract/tokens.ts rename to packages/agent-core-v2/src/llm-adapter/contract/tokens.ts index 046a91ee76e..79cad0ae7a2 100644 --- a/packages/agent-core-v2/src/kosong/contract/tokens.ts +++ b/packages/agent-core-v2/src/llm-adapter/contract/tokens.ts @@ -1,5 +1,4 @@ -import type { ContentPart, Message } from './message'; -import type { Tool } from './tool'; +import type { ContentPart, Message, Tool } from './message'; const messageTokenEstimateCache = new WeakMap(); diff --git a/packages/agent-core-v2/src/kosong/model/catalogService.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts similarity index 96% rename from packages/agent-core-v2/src/kosong/model/catalogService.ts rename to packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts index 903ebceea3d..038f49454f0 100644 --- a/packages/agent-core-v2/src/kosong/model/catalogService.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog-service.ts @@ -4,21 +4,20 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Error2 } from '#/_base/errors/errors'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import { - IProtocolAdapterRegistry, - type Protocol, - type ProtocolProviderOptions, -} from '#/kosong/protocol/protocol'; - -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; import { LATEST_OPUS_PROFILE, matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, -} from '../provider/bases/anthropic/anthropic-profile'; +} from '#human/llm/requester/bases/anthropic/profile'; + +import type { ModelCapability } from '../contract/capability'; +import { CONFIG_INVALID_ERROR_CODE } from '../contract/errors'; +import type { TokenUsage } from '#human/llm/usage'; +import { + IProtocolAdapterRegistry, + type Protocol, + type ProtocolProviderOptions, +} from '../protocol/protocol'; import { IProviderService, type ProviderConfig, @@ -26,7 +25,7 @@ import { import { getProviderDefinition, resolveProviderEndpoint, -} from '../provider/providerDefinition'; +} from '../provider/provider-definition'; import { type AuthProvider, @@ -36,6 +35,7 @@ import { type ModelPingResult, type ProviderCatalogItem, type ProviderCredentialState, + type ProviderRequestAuth, type SetDefaultModelResponse, StaticAuthProvider, toProtocolModel, @@ -43,7 +43,7 @@ import { toProtocolProvider, } from './catalog'; import { ModelCatalogErrors } from './errors'; -import { IHostRequestHeaders } from './hostRequestHeaders'; +import { IHostRequestHeaders } from './host-request-headers'; import { assembleModelInspection, attributeEffectiveFields, @@ -60,11 +60,11 @@ import { resolveEndpointBaseUrl, resolveModelAuthMaterial, resolveModelProtocol, -} from './modelAuth'; -import { IModelOAuthTokens } from './modelOAuth'; +} from './model-auth'; +import { IModelOAuthTokens } from './model-oauth'; import type { ResolvedModelAuthMaterial } from './model.types'; -import type { ModelRequester } from './modelRequester'; -import { ModelRequesterImpl } from './modelRequesterImpl'; +import type { ModelRequester } from './model-requester'; +import { ModelRequesterImpl } from './model-requester-impl'; import { drivesThinkingThroughTraits } from './thinking'; type MutableProtocolProviderOptions = { diff --git a/packages/agent-core-v2/src/kosong/model/catalog.ts b/packages/agent-core-v2/src/llm-adapter/model/catalog.ts similarity index 93% rename from packages/agent-core-v2/src/kosong/model/catalog.ts rename to packages/agent-core-v2/src/llm-adapter/model/catalog.ts index 1ebf394b93c..4d6539743da 100644 --- a/packages/agent-core-v2/src/kosong/model/catalog.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/catalog.ts @@ -1,17 +1,21 @@ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { ProviderRequestAuth } from '#/kosong/contract/provider'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import type { Protocol, ProtocolProviderOptions } from '#/kosong/protocol/protocol'; +import type { ModelCapability } from '../contract/capability'; +import type { TokenUsage } from '#human/llm/usage'; +import type { Protocol, ProtocolProviderOptions } from '../protocol/protocol'; import type { ProviderConfig } from '../provider/provider'; import type { ModelInspection } from './inspection'; import type { ModelRecord } from './model'; -import { effectiveModelConfig } from './modelAuth'; -import type { ModelRequester } from './modelRequester'; +import { effectiveModelConfig } from './model-auth'; +import type { ModelRequester } from './model-requester'; + +export interface ProviderRequestAuth { + apiKey?: string; + headers?: Record; +} export interface AuthProvider { readonly canRefresh?: boolean; diff --git a/packages/agent-core-v2/src/kosong/model/completionBudget.ts b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts similarity index 96% rename from packages/agent-core-v2/src/kosong/model/completionBudget.ts rename to packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts index 49bfd515c1f..b4fe8c01e5b 100644 --- a/packages/agent-core-v2/src/kosong/model/completionBudget.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/completion-budget.ts @@ -1,4 +1,4 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; +import type { ModelCapability } from '../contract/capability'; import type { CompletionBudgetConfig, CompletionBudgetParams } from './model.types'; diff --git a/packages/agent-core-v2/src/kosong/model/errors.ts b/packages/agent-core-v2/src/llm-adapter/model/errors.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/model/errors.ts rename to packages/agent-core-v2/src/llm-adapter/model/errors.ts diff --git a/packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts b/packages/agent-core-v2/src/llm-adapter/model/host-request-headers.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/model/hostRequestHeaders.ts rename to packages/agent-core-v2/src/llm-adapter/model/host-request-headers.ts diff --git a/packages/agent-core-v2/src/kosong/model/inspection.ts b/packages/agent-core-v2/src/llm-adapter/model/inspection.ts similarity index 97% rename from packages/agent-core-v2/src/kosong/model/inspection.ts rename to packages/agent-core-v2/src/llm-adapter/model/inspection.ts index aa426a420a7..964d2cf3a59 100644 --- a/packages/agent-core-v2/src/kosong/model/inspection.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/inspection.ts @@ -1,14 +1,13 @@ import { parseKimiCodeCustomHeaders } from '@moonshot-ai/kimi-code-oauth'; import { BugIndicatingError } from '#/_base/errors/errors'; +import type { AnthropicModelProfile } from '#human/llm/requester/bases/anthropic/profile'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; -import type { Protocol, ProtocolProviderOptions } from '#/kosong/protocol/protocol'; - -import type { AnthropicModelProfile } from '../provider/bases/anthropic/anthropic-profile'; +import type { ModelCapability } from '../contract/capability'; +import type { InspectionSource, ResolutionTrace } from '../contract/inspection'; +import type { Protocol, ProtocolProviderOptions } from '../protocol/protocol'; import type { ProviderConfig } from '../provider/provider'; -import { getProviderDefinition } from '../provider/providerDefinition'; +import { getProviderDefinition } from '../provider/provider-definition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; diff --git a/packages/agent-core-v2/src/kosong/model/modelAuth.ts b/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts similarity index 96% rename from packages/agent-core-v2/src/kosong/model/modelAuth.ts rename to packages/agent-core-v2/src/llm-adapter/model/model-auth.ts index 5fda0a9ee0d..9f6159e34ad 100644 --- a/packages/agent-core-v2/src/kosong/model/modelAuth.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-auth.ts @@ -1,15 +1,15 @@ import { Error2 } from '#/_base/errors/errors'; -import { CONFIG_INVALID_ERROR_CODE } from '#/kosong/contract/errors'; -import type { InspectionSource, ResolutionTrace } from '#/kosong/contract/inspection'; -import { ProtocolSchema, type Protocol } from '#/kosong/protocol/protocol'; - import { BUDGET_THINKING_EFFORTS, matchKnownAnthropicModelProfile, matchUnknownClaudeProfile, -} from '../provider/bases/anthropic/anthropic-profile'; +} from '#human/llm/requester/bases/anthropic/profile'; + +import { CONFIG_INVALID_ERROR_CODE } from '../contract/errors'; +import type { InspectionSource, ResolutionTrace } from '../contract/inspection'; +import { ProtocolSchema, type Protocol } from '../protocol/protocol'; import type { ProviderConfig } from '../provider/provider'; -import { explainProviderEndpoint, getProviderDefinition } from '../provider/providerDefinition'; +import { explainProviderEndpoint, getProviderDefinition } from '../provider/provider-definition'; import type { ModelRecord } from './model'; import type { ResolvedModelAuthMaterial } from './model.types'; diff --git a/packages/agent-core-v2/src/kosong/model/modelOAuth.ts b/packages/agent-core-v2/src/llm-adapter/model/model-oauth.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/model/modelOAuth.ts rename to packages/agent-core-v2/src/llm-adapter/model/model-oauth.ts diff --git a/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts new file mode 100644 index 00000000000..5d1dfe8483f --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester-impl.ts @@ -0,0 +1,363 @@ +import * as fs from 'node:fs'; +import * as nodePath from 'node:path'; + +import { AsyncEventQueue } from '#/_base/asyncEventQueue'; +import type { LlmErrorMessage } from '#human/llm/errors'; +import type { FinishInfo } from '#human/llm/finish-reason'; +import type { ProviderMediaContribution, VideoUploadInput } from '#human/llm/media/upload'; +import { createMessageAccumulator, type VideoURLPart } from '#human/llm/message'; +import type { LlmModel } from '#human/llm/model'; +import type { ProtocolName } from '#human/llm/protocol/base'; +import { withEmptyResponseGuard } from '#human/llm/requester/empty-response'; +import { + mergeRequestHeaders, + type ExtraParams, + type LlmRequestConfig, + type LlmRequestContent, + type LlmRequestEvent, + type LlmRequester, +} from '#human/llm/requester/requester'; +import type { TokenUsage } from '#human/llm/usage'; +import { + withAuth, + withAuthUpload, + type CredentialSource, +} from '#human/kimi-oauth/credential-source'; + +import { + ChatProviderError, + errorFromLlmMessage, + isAbortError, + isUnauthorizedLlmError, + llmMessageFromError, + traceIdFromHeadersRecord, + VideoUploadUnsupportedError, +} from '../contract/errors'; +import { fromLlmAssistantMessage, toLlmMessage, type Tool } from '../contract/message'; +import { mergeUsagePatch } from '#human/llm/usage'; + +import type { Model, ProviderRequestAuth } from './catalog'; +import type { + ModelRequestEvent, + ModelRequestInput, + ModelRequestParams, + ModelRequester, + ModelRequestTiming, + SamplingOptions, +} from './model-requester'; +import { translateProviderError } from '../protocol/errors'; + +export interface ResolvedLlmModel { + readonly requester: LlmRequester; + readonly protocol: ProtocolName; + readonly model: LlmModel; + readonly media?: ProviderMediaContribution; +} + +export interface ModelLlmGateway { + resolve(model: Model): ResolvedLlmModel; +} + +interface StreamDecodeStats { + readonly serverDecodeMs: number; + readonly clientConsumeMs: number; +} + +export class ModelRequesterImpl implements ModelRequester { + private cached: ResolvedLlmModel | undefined; + private cachedRequester: LlmRequester | undefined; + + constructor( + readonly model: Model, + private readonly gateway: ModelLlmGateway, + ) {} + + private resolve(): ResolvedLlmModel { + if (this.cached === undefined) { + this.cached = this.gateway.resolve(this.model); + } + return this.cached; + } + + private requesterFor(resolved: ResolvedLlmModel): LlmRequester { + if (this.cachedRequester === undefined) { + this.cachedRequester = withEmptyResponseGuard( + withAuth(throwToEvent(resolved.requester), this.credentialSource), + ); + } + return this.cachedRequester; + } + + private readonly credentialSource: CredentialSource = { + resolve: async (model, options) => { + const auth = await this.model.authProvider.getAuth({ force: options?.force }); + return applyAuth(model, auth); + }, + canRecover: (_model, error) => + this.model.authProvider.canRefresh === true && isUnauthorizedLlmError(error), + }; + + request( + input: ModelRequestInput, + signal?: AbortSignal, + params?: ModelRequestParams, + ): AsyncIterable { + const queue = new AsyncEventQueue(); + void this.runRequest(input, signal, queue, params).then( + () => queue.end(), + (error) => queue.fail(error), + ); + return queue; + } + + async uploadVideo( + input: string | VideoUploadInput, + options?: { readonly signal?: AbortSignal }, + ): Promise { + const resolved = this.resolve(); + const uploader = resolved.media?.uploadVideo; + if (uploader === undefined) { + throw new VideoUploadUnsupportedError( + `Model "${this.model.id}" (protocol=${this.model.protocol}) does not support video upload`, + ); + } + const video = typeof input === 'string' ? readVideoFile(input) : input; + const wrapped = withAuthUpload(uploader, this.credentialSource); + return wrapped(video, { model: resolved.model, signal: options?.signal }); + } + + private async runRequest( + input: ModelRequestInput, + signal: AbortSignal | undefined, + queue: AsyncEventQueue, + params?: ModelRequestParams, + ): Promise { + signal?.throwIfAborted(); + const resolved = this.resolve(); + const requester = this.requesterFor(resolved); + + let requestStartedAt = Date.now(); + let requestSentAt: number | undefined; + let firstChunkAt: number | undefined; + let streamEndedAt: number | undefined; + let serverDecodeMs = 0; + let clientConsumeMs = 0; + let lastResumeAt = 0; + + let accumulator = createMessageAccumulator(); + let usage: TokenUsage | undefined; + let finish: FinishInfo | undefined; + let messageId: string | undefined; + let traceId: string | null | undefined; + let failed: LlmErrorMessage | undefined; + + const config: LlmRequestConfig = { + model: resolved.model, + systemPrompt: input.systemPrompt, + tools: wireTools(input.tools), + cacheKey: params?.cacheKey, + thinking: + params?.thinkingEffort === undefined + ? undefined + : { effort: params.thinkingEffort, keep: params.thinkingKeep }, + responseFormat: input.responseFormat, + maxCompletionTokens: params?.maxCompletionTokens, + maxContextTokens: params?.maxContextTokens, + extraParams: samplingExtraParams(resolved.protocol, params?.sampling), + }; + const content: LlmRequestContent = { + messages: input.messages.map(toLlmMessage), + usedContextTokens: params?.usedContextTokens, + }; + + await requester.generate(config, content, { + signal: signal ?? new AbortController().signal, + onEvent: (event: LlmRequestEvent) => { + switch (event.type) { + case 'llm.sent': { + const now = Date.now(); + if (requestSentAt !== undefined) { + requestStartedAt = now; + accumulator = createMessageAccumulator(); + usage = undefined; + finish = undefined; + messageId = undefined; + } + requestSentAt = now; + return; + } + case 'llm.headers': { + traceId = traceIdFromHeadersRecord(event.headers); + params?.onTraceId?.(traceId); + return; + } + case 'llm.delta': { + const arrivedAt = Date.now(); + if (firstChunkAt === undefined) { + firstChunkAt = arrivedAt; + } else { + serverDecodeMs += arrivedAt - lastResumeAt; + } + accumulator.push(event.part); + queue.push({ type: 'part', part: event.part }); + lastResumeAt = Date.now(); + clientConsumeMs += lastResumeAt - arrivedAt; + return; + } + case 'llm.usage': { + usage = mergeUsagePatch(usage, event.usage); + return; + } + case 'llm.finish': { + finish = event.finish; + return; + } + case 'llm.message-id': { + messageId = event.messageId; + return; + } + case 'llm.failed.syntax': + case 'llm.failed.remote': { + failed = event.error; + return; + } + case 'llm.done': { + streamEndedAt = Date.now(); + if (firstChunkAt !== undefined) { + serverDecodeMs += streamEndedAt - lastResumeAt; + } + return; + } + } + }, + }); + + if (failed !== undefined) { + throw errorFromLlmMessage(failed); + } + + if (usage !== undefined) { + queue.push({ type: 'usage', usage, model: this.model.name }); + } + queue.push({ + type: 'finish', + message: fromLlmAssistantMessage(accumulator.finish()), + providerFinishReason: finish?.finishReason ?? undefined, + rawFinishReason: finish?.rawFinishReason ?? undefined, + id: messageId, + traceId: traceId ?? undefined, + }); + if (firstChunkAt !== undefined) { + queue.push({ + type: 'timing', + ...buildStreamTiming(requestStartedAt, requestSentAt, firstChunkAt, streamEndedAt, { + serverDecodeMs, + clientConsumeMs, + }), + }); + } + } +} + +function applyAuth(model: LlmModel, auth: ProviderRequestAuth | undefined): LlmModel { + if (auth === undefined) return model; + return { + ...model, + apiKey: auth.apiKey ?? model.apiKey, + defaultHeaders: mergeRequestHeaders(model.defaultHeaders, auth.headers), + }; +} + +function throwToEvent(inner: LlmRequester): LlmRequester { + return { + async generate(config, content, control) { + try { + await inner.generate(config, content, control); + } catch (error) { + if (isAbortError(error)) throw error; + const message = llmMessageFromError(error); + if (message === undefined) throw translateProviderError(error); + control.onEvent?.({ type: 'llm.failed.remote', error: message }); + } + }, + }; +} + +function wireTools(tools: readonly Tool[]): readonly Tool[] { + if (!tools.some((tool) => tool.deferred === true)) return tools; + return tools.filter((tool) => tool.deferred !== true); +} + +function samplingExtraParams( + protocol: ProtocolName, + sampling: SamplingOptions | undefined, +): ExtraParams | undefined { + if (sampling === undefined) return undefined; + const { temperature, topP } = sampling; + if (temperature === undefined && topP === undefined) return undefined; + switch (protocol) { + case 'openai': + return { openai: { temperature, top_p: topP } }; + case 'openai_responses': + return { responses: { temperature, top_p: topP } }; + case 'anthropic': + case 'anthropic_beta': + return { anthropic: { temperature, top_p: topP } }; + case 'google-genai': + case 'google-vertex': + return { googleGenai: { temperature, topP } }; + } +} + +const EXT_TO_MIME: Record = { + mp4: 'video/mp4', + mpeg: 'video/mpeg', + mov: 'video/quicktime', + webm: 'video/webm', + mkv: 'video/x-matroska', + avi: 'video/x-msvideo', + flv: 'video/x-flv', + '3gp': 'video/3gpp', +}; + +function readVideoFile(path: string): VideoUploadInput { + if (!fs.existsSync(path)) { + throw new ChatProviderError(`Video file not found: ${path}`); + } + const filename = nodePath.basename(path); + const ext = filename.includes('.') ? filename.split('.').pop()!.toLowerCase() : ''; + const mimeType = EXT_TO_MIME[ext]; + if (mimeType === undefined) { + throw new ChatProviderError( + `KimiFiles.uploadVideo: file extension does not indicate a video type: ${filename}`, + ); + } + const data = fs.readFileSync(path); + return { data: new Uint8Array(data), mimeType, filename }; +} + +type MutableModelRequestTiming = { -readonly [K in keyof ModelRequestTiming]: ModelRequestTiming[K] }; + +export function buildStreamTiming( + requestStartedAt: number, + requestSentAt: number | undefined, + firstChunkAt: number, + streamEndedAt: number | undefined, + decodeStats: StreamDecodeStats | undefined, +): ModelRequestTiming { + const outputEndedAt = streamEndedAt ?? Date.now(); + const timing: MutableModelRequestTiming = { + firstTokenLatencyMs: Math.max(0, firstChunkAt - requestStartedAt), + streamDurationMs: Math.max(0, outputEndedAt - firstChunkAt), + }; + if (requestSentAt !== undefined) { + const sentAt = Math.min(Math.max(requestSentAt, requestStartedAt), firstChunkAt); + timing.requestBuildMs = sentAt - requestStartedAt; + timing.serverFirstTokenMs = firstChunkAt - sentAt; + } + if (decodeStats !== undefined) { + timing.serverDecodeMs = Math.max(0, decodeStats.serverDecodeMs); + timing.clientConsumeMs = Math.max(0, decodeStats.clientConsumeMs); + } + return timing; +} diff --git a/packages/agent-core-v2/src/kosong/model/modelRequester.ts b/packages/agent-core-v2/src/llm-adapter/model/model-requester.ts similarity index 78% rename from packages/agent-core-v2/src/kosong/model/modelRequester.ts rename to packages/agent-core-v2/src/llm-adapter/model/model-requester.ts index c3cafa5710f..f77279af1de 100644 --- a/packages/agent-core-v2/src/kosong/model/modelRequester.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-requester.ts @@ -1,16 +1,18 @@ -import type { Message, StreamedMessagePart, VideoURLPart } from '#/kosong/contract/message'; -import type { - FinishReason, - ResponseFormat, - SamplingOptions, - ThinkingEffort, - VideoUploadInput, -} from '#/kosong/contract/provider'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { FinishReason } from '#human/llm/finish-reason'; +import type { VideoUploadInput } from '#human/llm/media/upload'; +import type { ResponseFormat } from '#human/llm/response-format'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { TokenUsage } from '#human/llm/usage'; + +import type { Message, StreamedMessagePart, Tool, VideoURLPart } from '../contract/message'; import type { Model } from './catalog'; +export interface SamplingOptions { + readonly temperature?: number; + readonly topP?: number; +} + export interface ModelRequestInput { readonly systemPrompt: string; readonly tools: readonly Tool[]; diff --git a/packages/agent-core-v2/src/kosong/model/modelService.ts b/packages/agent-core-v2/src/llm-adapter/model/model-service.ts similarity index 97% rename from packages/agent-core-v2/src/kosong/model/modelService.ts rename to packages/agent-core-v2/src/llm-adapter/model/model-service.ts index e7ff8b75cd4..75668dd745a 100644 --- a/packages/agent-core-v2/src/kosong/model/modelService.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model-service.ts @@ -3,7 +3,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; -import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; +import { deepEqual, diffRecords, isEmptyDiff } from '../record-diff'; import { type DefaultModelChangedEvent, diff --git a/packages/agent-core-v2/src/kosong/model/model.ts b/packages/agent-core-v2/src/llm-adapter/model/model.ts similarity index 97% rename from packages/agent-core-v2/src/kosong/model/model.ts rename to packages/agent-core-v2/src/llm-adapter/model/model.ts index f5cff914c95..c663eb208b7 100644 --- a/packages/agent-core-v2/src/kosong/model/model.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model.ts @@ -1,7 +1,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event, IWaitUntil } from '#/_base/event'; -import type { Protocol } from '#/kosong/protocol/protocol'; +import type { Protocol } from '../protocol/protocol'; import type { OAuthRef } from '../provider/provider'; export interface ModelOverride { diff --git a/packages/agent-core-v2/src/kosong/model/model.types.ts b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts similarity index 93% rename from packages/agent-core-v2/src/kosong/model/model.types.ts rename to packages/agent-core-v2/src/llm-adapter/model/model.types.ts index 816b59e1efc..0679247755c 100644 --- a/packages/agent-core-v2/src/kosong/model/model.types.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/model.types.ts @@ -1,5 +1,4 @@ -import type { ModelCapability } from '#/kosong/contract/capability'; - +import type { ModelCapability } from '../contract/capability'; import type { OAuthRef } from '../provider/provider'; export interface ModelOverrides { diff --git a/packages/agent-core-v2/src/kosong/model/thinking.ts b/packages/agent-core-v2/src/llm-adapter/model/thinking.ts similarity index 83% rename from packages/agent-core-v2/src/kosong/model/thinking.ts rename to packages/agent-core-v2/src/llm-adapter/model/thinking.ts index efbe1c57f17..c8531c0ef54 100644 --- a/packages/agent-core-v2/src/kosong/model/thinking.ts +++ b/packages/agent-core-v2/src/llm-adapter/model/thinking.ts @@ -1,7 +1,7 @@ -import type { ThinkingEffort } from '#/kosong/contract/provider'; -import type { IProtocolAdapterRegistry, Protocol } from '#/kosong/protocol/protocol'; +import type { ThinkingEffort } from '#human/llm/thinking'; -import { getProviderDefinitions } from '../provider/providerDefinition'; +import type { IProtocolAdapterRegistry, Protocol } from '../protocol/protocol'; +import { getProviderDefinitions } from '../provider/provider-definition'; import type { ModelThinkingMetadata, ThinkingDefaults } from './model.types'; @@ -12,6 +12,8 @@ export interface ThinkingConfig { keep?: string; } +export { resolveThinkingKeep } from '#human/llm/thinking'; + export function drivesThinkingThroughTraits(providerType: string | undefined): boolean { if (providerType === undefined) return false; return getProviderDefinitions(providerType).some((definition) => @@ -190,29 +192,3 @@ export function resolveThinkingEffortForModel( } return normalizeThinkingEffortForModel(effort, model, strictValidation); } - -const KEEP_OFF_VALUES = new Set(['0', 'false', 'no', 'off', 'none', 'null']); - -type KeepResolution = - | { readonly specified: false } - | { readonly specified: true; readonly value: string | undefined }; - -function parseKeepValue(raw: string | undefined): KeepResolution { - const trimmed = raw?.trim(); - if (trimmed === undefined || trimmed.length === 0) return { specified: false }; - if (KEEP_OFF_VALUES.has(trimmed.toLowerCase())) return { specified: true, value: undefined }; - return { specified: true, value: trimmed }; -} - -export function resolveThinkingKeep( - envKeep: string | undefined, - configKeep: string | undefined, - thinkingEffort: ThinkingEffort, -): string | undefined { - if (thinkingEffort === 'off') return undefined; - const fromEnv = parseKeepValue(envKeep); - if (fromEnv.specified) return fromEnv.value; - const fromConfig = parseKeepValue(configKeep); - if (fromConfig.specified) return fromConfig.value; - return 'all'; -} diff --git a/packages/agent-core-v2/src/kosong/protocol/errors.ts b/packages/agent-core-v2/src/llm-adapter/protocol/errors.ts similarity index 95% rename from packages/agent-core-v2/src/kosong/protocol/errors.ts rename to packages/agent-core-v2/src/llm-adapter/protocol/errors.ts index 65982dc804b..494f64756b8 100644 --- a/packages/agent-core-v2/src/kosong/protocol/errors.ts +++ b/packages/agent-core-v2/src/llm-adapter/protocol/errors.ts @@ -9,9 +9,9 @@ import { PROVIDER_OVERLOADED_ERROR_CODE, PROVIDER_RATE_LIMIT_ERROR_CODE, throwIfAbortError, -} from '#/kosong/contract/errors'; +} from '../contract/errors'; -export { sanitizeStatusErrorMessage } from '#/kosong/contract/errors'; +export { sanitizeStatusErrorMessage } from '../contract/errors'; export const ProtocolErrors = { codes: { diff --git a/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts new file mode 100644 index 00000000000..99fd1601d21 --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocol-base.ts @@ -0,0 +1,45 @@ +import type { ProtocolBase } from '#human/llm/protocol/base'; +import type { ProtocolTrait } from '#human/llm/protocol/trait'; +import { anthropicBase } from '#human/llm/requester/bases/anthropic/requester'; +import { googleGenAIBase } from '#human/llm/requester/bases/google-genai/requester'; +import { openAIBase } from '#human/llm/requester/bases/openai/requester'; +import { openAIResponsesBase } from '#human/llm/requester/bases/openai-responses/requester'; + +import type { Protocol, ProtocolAdapterConfig } from './protocol'; + +export type ProtocolBaseId = Protocol; + +export interface ProtocolBaseDefinition { + readonly id: ProtocolBaseId; + readonly base: ProtocolBase; +} + +export interface TraitContext { + readonly config: ProtocolAdapterConfig; + readonly providerId?: string; +} + +export interface ResolvedTrait { + readonly trait: ProtocolTrait; + readonly context: TraitContext; +} + +export interface ResolvedAdapterIdentity { + readonly baseId: ProtocolBaseId; + readonly traits: readonly ResolvedTrait[]; +} + +const PROTOCOL_BASES: readonly ProtocolBaseDefinition[] = [ + { id: 'openai', base: openAIBase }, + { id: 'openai_responses', base: openAIResponsesBase }, + { id: 'anthropic', base: anthropicBase }, + { id: 'google-genai', base: googleGenAIBase }, +]; + +export function getProtocolBase(id: ProtocolBaseId): ProtocolBaseDefinition | undefined { + return PROTOCOL_BASES.find((definition) => definition.id === id); +} + +export function listProtocolBases(): readonly ProtocolBaseDefinition[] { + return PROTOCOL_BASES; +} diff --git a/packages/agent-core-v2/src/kosong/protocol/protocol.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts similarity index 81% rename from packages/agent-core-v2/src/kosong/protocol/protocol.ts rename to packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts index b0c38f90ac8..a449100c661 100644 --- a/packages/agent-core-v2/src/kosong/protocol/protocol.ts +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocol.ts @@ -1,18 +1,15 @@ import { z } from 'zod'; import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import type { InspectionSource } from '#/kosong/contract/inspection'; -import type { ChatProvider } from '#/kosong/contract/provider'; -import type { ProtocolBaseId, ResolvedAdapterIdentity } from './protocolBase'; +import type { ModelCapability } from '../contract/capability'; +import type { InspectionSource } from '../contract/inspection'; +import type { Model } from '../model/catalog'; +import type { ResolvedLlmModel } from '../model/model-requester-impl'; -export const ProtocolSchema = z.enum([ - 'anthropic', - 'openai', - 'openai_responses', - 'google-genai', -]); +import type { ProtocolBaseId, ResolvedAdapterIdentity } from './protocol-base'; + +export const ProtocolSchema = z.enum(['anthropic', 'openai', 'openai_responses', 'google-genai']); export type Protocol = z.infer; @@ -65,7 +62,7 @@ export interface IProtocolAdapterRegistry { providerType?: string, ): ExplainedCapability; - createChatProvider(config: ProtocolAdapterConfig): ChatProvider; + resolve(model: Model): ResolvedLlmModel; } export const IProtocolAdapterRegistry: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts b/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts new file mode 100644 index 00000000000..ed189fa8dc7 --- /dev/null +++ b/packages/agent-core-v2/src/llm-adapter/protocol/protocolAdapterRegistry.ts @@ -0,0 +1,220 @@ +import { LifecycleScope } from '#/app/scopes'; + +import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { UNKNOWN_CAPABILITY, toLlmCapability, type ModelCapability } from '../contract/capability'; +import type { ModelThinkingMetadata } from '#human/llm/thinking'; +import type { ProviderMediaContribution } from '#human/llm/media/upload'; +import type { LlmModel } from '#human/llm/model'; +import type { ProtocolBase, ProtocolName } from '#human/llm/protocol/base'; +import type { ProtocolTrait } from '#human/llm/protocol/trait'; +import { anthropicBase, anthropicBetaBase } from '#human/llm/requester/bases/anthropic/requester'; +import { + createGoogleGenAIBase, + googleGenAIBase, +} from '#human/llm/requester/bases/google-genai/requester'; +import { openAIBase } from '#human/llm/requester/bases/openai/requester'; +import { openAIResponsesBase } from '#human/llm/requester/bases/openai-responses/requester'; +import { KimiFiles } from '#human/llm-kimi/files'; +import { KIMI_DEFAULT_BASE_URL } from '#human/llm-kimi/trait'; + +import type { Model } from '../model/catalog'; +import type { ResolvedLlmModel } from '../model/model-requester-impl'; +import { + anthropicEndpointTrait, + geminiEndpointTrait, + getProviderDefinition, + openAIEndpointTrait, + vertexEndpointTrait, +} from '../provider/provider-definition'; + +import { IProtocolAdapterRegistry, type ExplainedCapability, type Protocol } from './protocol'; +import { getProtocolBase, listProtocolBases, type ProtocolBaseId } from './protocol-base'; + +const vertexGenAIBase = createGoogleGenAIBase({ vertexai: true }); + +const kimiMedia: ProviderMediaContribution = { + uploadVideo: (video, { model, signal }) => + new KimiFiles({ + apiKey: model.apiKey, + baseUrl: model.baseUrl ?? KIMI_DEFAULT_BASE_URL, + defaultHeaders: model.defaultHeaders === undefined ? undefined : { ...model.defaultHeaders }, + }).uploadVideo(video, { signal }), +}; + +interface AdapterRoute { + readonly base: ProtocolBase; + readonly trait?: ProtocolTrait; + readonly providerId: string; + readonly media?: ProviderMediaContribution; +} + +function routeFor(model: Model): AdapterRoute { + const definition = + model.providerType === undefined + ? undefined + : getProviderDefinition(model.providerType, model.protocol); + const routeTrait = definition?.routeTrait; + const routeMedia = definition?.modelSource === 'oauth-catalog' ? kimiMedia : undefined; + switch (model.protocol) { + case 'openai': + return routeTrait !== undefined + ? { base: openAIBase, trait: routeTrait, providerId: 'openai', media: routeMedia } + : { + base: openAIBase, + trait: openAITraitFor(model), + providerId: 'openai', + }; + case 'openai_responses': + return routeTrait !== undefined + ? { base: openAIResponsesBase, trait: routeTrait, providerId: 'openai-responses', media: routeMedia } + : { + base: openAIResponsesBase, + trait: openAITraitFor(model), + providerId: 'openai-responses', + }; + case 'anthropic': { + const base = model.providerOptions?.betaApi === true ? anthropicBetaBase : anthropicBase; + return routeTrait !== undefined + ? { base, trait: routeTrait, providerId: 'anthropic', media: routeMedia } + : { base, trait: anthropicEndpointTrait, providerId: 'anthropic' }; + } + case 'google-genai': + return model.providerOptions?.vertexai === true + ? { + base: vertexGenAIBase, + trait: vertexEndpointTrait, + providerId: 'google_genai', + } + : { + base: googleGenAIBase, + trait: geminiEndpointTrait, + providerId: 'google_genai', + }; + } +} + +function openAITraitFor(model: Model): ProtocolTrait { + const reasoningKey = model.providerOptions?.reasoningKey ?? model.reasoningKey; + if (reasoningKey === undefined) return openAIEndpointTrait; + return { ...openAIEndpointTrait, reasoningKey: () => reasoningKey }; +} + +export class ProtocolAdapterRegistry implements IProtocolAdapterRegistry { + declare readonly _serviceBrand: undefined; + + supportedProtocols(): readonly Protocol[] { + return listProtocolBases().map((base) => base.id); + } + + resolveAdapterIdentity(protocol: Protocol, providerType?: string) { + const definition = + providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); + const baseId: ProtocolBaseId = definition?.baseProtocol ?? protocol; + const traits = definition?.traits ?? []; + const context = { + config: { protocol, providerType, modelName: '' }, + providerId: providerType, + }; + return { baseId, traits: traits.map((trait) => ({ trait, context })) }; + } + + resolveProviderBaseId(protocol: Protocol, providerType?: string): ProtocolBaseId { + const definition = + providerType === undefined ? undefined : getProviderDefinition(providerType, protocol); + return definition?.baseProtocol ?? protocol; + } + + resolveCapability(protocol: Protocol, modelName: string, providerType?: string): ModelCapability { + return this.explainCapability(protocol, modelName, providerType).capability; + } + + explainCapability( + protocol: Protocol, + modelName: string, + providerType?: string, + ): ExplainedCapability { + const identity = this.resolveAdapterIdentity(protocol, providerType); + let traitCapability: ModelCapability | undefined; + for (const { trait } of identity.traits) { + if (trait.capability === undefined) continue; + const capability = trait.capability(modelName); + if (capability !== undefined) { + traitCapability = toV2Capability(capability); + } + } + if (traitCapability !== undefined) { + return { + capability: traitCapability, + source: { + kind: 'builtin', + detail: `trait capability hook (provider '${providerType ?? 'unregistered'}')`, + }, + }; + } + + const baseCapability = getProtocolBase(identity.baseId)?.base.capability?.(modelName); + if (baseCapability !== undefined) { + return { + capability: toV2Capability(baseCapability), + source: { kind: 'builtin', detail: `protocol base '${identity.baseId}' catalog` }, + }; + } + return { + capability: UNKNOWN_CAPABILITY, + source: { kind: 'none', detail: 'no capability source knew this model' }, + }; + } + + resolve(model: Model): ResolvedLlmModel { + const route = routeFor(model); + const requester = route.base.createRequester(route.trait); + const llmModel: LlmModel & ModelThinkingMetadata = { + provider: route.providerId, + model: model.name, + capability: toLlmCapability(model.capabilities), + maxContextSize: model.maxContextSize > 0 ? model.maxContextSize : undefined, + maxInputSize: model.maxInputSize, + baseUrl: model.baseUrl, + defaultHeaders: Object.keys(model.headers).length > 0 ? { ...model.headers } : undefined, + supportEfforts: model.supportEfforts, + defaultEffort: model.defaultEffort, + offEffort: model.providerOptions?.offEffort, + alwaysThinking: model.alwaysThinking, + adaptiveThinking: model.providerOptions?.adaptiveThinking, + }; + return { requester, protocol: protocolNameFor(model, route), model: llmModel, media: route.media }; + } +} + +function protocolNameFor(model: Model, route: AdapterRoute): ProtocolName { + switch (model.protocol) { + case 'openai': + return 'openai'; + case 'openai_responses': + return 'openai_responses'; + case 'anthropic': + return route.base === anthropicBetaBase ? 'anthropic_beta' : 'anthropic'; + case 'google-genai': + return route.base === vertexGenAIBase ? 'google-vertex' : 'google-genai'; + } +} + +function toV2Capability(capability: import('#human/llm/capability').ModelCapability): ModelCapability { + return { + image_in: capability.image_in, + video_in: capability.video_in, + audio_in: capability.audio_in, + thinking: capability.thinking, + tool_use: capability.tool_use, + max_context_tokens: 0, + dynamically_loaded_tools: capability.dynamically_loaded_tools, + }; +} + +registerScopedService( + LifecycleScope.App, + IProtocolAdapterRegistry, + ProtocolAdapterRegistry, + ScopeActivation.OnScopeCreated, + 'provider', +); diff --git a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts similarity index 68% rename from packages/agent-core-v2/src/kosong/provider/providerDefinition.ts rename to packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts index 4ad5dac46f1..7e82634e812 100644 --- a/packages/agent-core-v2/src/kosong/provider/providerDefinition.ts +++ b/packages/agent-core-v2/src/llm-adapter/provider/provider-definition.ts @@ -1,17 +1,42 @@ import { BugIndicatingError } from '#/_base/errors/errors'; -import type { Protocol, ProtocolAdapterConfig } from '#/kosong/protocol/protocol'; -import type { - ProtocolEndpoint, - ProtocolTrait, - TraitContext, -} from '#/kosong/protocol/protocolTrait'; - +import type { ProtocolEndpoint, ProtocolTrait } from '#human/llm/protocol/trait'; +import { + kimiAnthropicTrait, + kimiOpenAITrait, + kimiResponsesTrait, + KIMI_DEFAULT_BASE_URL, +} from '#human/llm-kimi/trait'; + +import type { Protocol } from '../protocol/protocol'; import type { ModelSource } from './provider'; +export const openAIEndpointTrait: ProtocolTrait = { + endpoint: () => ({ apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }), +}; + +export const anthropicEndpointTrait: ProtocolTrait = { + endpoint: () => ({ apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }), +}; + +export const geminiEndpointTrait: ProtocolTrait = { + endpoint: () => ({ apiKeyEnv: 'GOOGLE_API_KEY', baseUrlEnv: 'GOOGLE_GEMINI_BASE_URL' }), +}; + +export const vertexEndpointTrait: ProtocolTrait = { + endpoint: () => ({ apiKeyEnv: 'VERTEXAI_API_KEY', baseUrlEnv: 'GOOGLE_VERTEX_BASE_URL' }), +}; + +export const kimiEndpoint: ProtocolEndpoint = { + apiKeyEnv: 'KIMI_API_KEY', + baseUrlEnv: 'KIMI_BASE_URL', + defaultBaseUrl: KIMI_DEFAULT_BASE_URL, +}; + export interface ProviderDefinition { readonly id: string; readonly baseProtocol: Protocol; readonly traits: readonly ProtocolTrait[]; + readonly routeTrait?: ProtocolTrait; readonly endpoint?: ProtocolEndpoint; readonly hostHeaders?: 'full' | 'user-agent'; readonly modelSource?: ModelSource; @@ -130,19 +155,13 @@ function normalizeEndpointDeclaration( function aggregateTraitEndpoints( definition: ProviderDefinition, ): AggregatedEndpointDeclaration | undefined { - const config: ProtocolAdapterConfig = { - protocol: definition.baseProtocol, - providerType: definition.id, - modelName: '', - }; - const context: TraitContext = { config, providerId: definition.id }; const apiKeyEnv: string[] = []; const baseUrlEnv: string[] = []; let defaultBaseUrl: string | undefined; let declared = false; for (const trait of definition.traits) { if (trait.endpoint === undefined) continue; - const endpoint = trait.endpoint(context); + const endpoint = trait.endpoint(); if (endpoint === undefined) continue; declared = true; if (endpoint.apiKeyEnv !== undefined) apiKeyEnv.push(endpoint.apiKeyEnv); @@ -162,3 +181,60 @@ function firstEnvHit( } return undefined; } + +registerProviderDefinition({ + id: 'anthropic', + baseProtocol: 'anthropic', + traits: [], + endpoint: { apiKeyEnv: 'ANTHROPIC_API_KEY', baseUrlEnv: 'ANTHROPIC_BASE_URL' }, +}); + +registerProviderDefinition({ + id: 'openai', + baseProtocol: 'openai', + traits: [], + endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, +}); + +registerProviderDefinition({ + id: 'openai_responses', + baseProtocol: 'openai_responses', + traits: [], + endpoint: { apiKeyEnv: 'OPENAI_API_KEY', baseUrlEnv: 'OPENAI_BASE_URL' }, +}); + +registerProviderDefinition({ + id: 'google-genai', + baseProtocol: 'google-genai', + traits: [vertexEndpointTrait, geminiEndpointTrait], +}); + +registerProviderDefinition({ + id: 'kimi', + baseProtocol: 'openai', + traits: [kimiOpenAITrait], + routeTrait: kimiOpenAITrait, + endpoint: kimiEndpoint, + hostHeaders: 'full', + modelSource: 'oauth-catalog', +}); + +registerProviderDefinition({ + id: 'kimi', + baseProtocol: 'anthropic', + traits: [kimiAnthropicTrait], + routeTrait: kimiAnthropicTrait, + endpoint: kimiEndpoint, + hostHeaders: 'full', + modelSource: 'oauth-catalog', +}); + +registerProviderDefinition({ + id: 'kimi', + baseProtocol: 'openai_responses', + traits: [kimiResponsesTrait], + routeTrait: kimiResponsesTrait, + endpoint: kimiEndpoint, + hostHeaders: 'full', + modelSource: 'oauth-catalog', +}); diff --git a/packages/agent-core-v2/src/kosong/provider/providerService.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider-service.ts similarity index 97% rename from packages/agent-core-v2/src/kosong/provider/providerService.ts rename to packages/agent-core-v2/src/llm-adapter/provider/provider-service.ts index 1bfd4b61d62..4ec7959c08f 100644 --- a/packages/agent-core-v2/src/kosong/provider/providerService.ts +++ b/packages/agent-core-v2/src/llm-adapter/provider/provider-service.ts @@ -3,7 +3,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { AsyncEmitter, type Event, type IWaitUntil } from '#/_base/event'; -import { deepEqual, diffRecords, isEmptyDiff } from '../recordDiff'; +import { deepEqual, diffRecords, isEmptyDiff } from '../record-diff'; import { type DefaultProviderChangedEvent, diff --git a/packages/agent-core-v2/src/kosong/provider/provider.ts b/packages/agent-core-v2/src/llm-adapter/provider/provider.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/provider/provider.ts rename to packages/agent-core-v2/src/llm-adapter/provider/provider.ts diff --git a/packages/agent-core-v2/src/kosong/recordDiff.ts b/packages/agent-core-v2/src/llm-adapter/record-diff.ts similarity index 100% rename from packages/agent-core-v2/src/kosong/recordDiff.ts rename to packages/agent-core-v2/src/llm-adapter/record-diff.ts diff --git a/packages/agent-core-v2/src/mcpCore/connection-manager.ts b/packages/agent-core-v2/src/mcpCore/connection-manager.ts index 72a94554c0c..8774de86f6f 100644 --- a/packages/agent-core-v2/src/mcpCore/connection-manager.ts +++ b/packages/agent-core-v2/src/mcpCore/connection-manager.ts @@ -1,7 +1,7 @@ import { ErrorCodes, Error2 } from '#/errors'; import type { McpServerConfig } from './config-schema'; import type { ILogger as Logger } from '#/_base/log/log'; -import type { Tool } from '#/kosong/contract/tool'; +import type { ToolDescription as Tool } from '#human/llm/message'; import { HostProcessError, HostProcessErrorCode } from '#/os/interface/hostProcess'; import { abortable } from '#/_base/utils/abort'; diff --git a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts index 2abd88f4c4c..2f722077397 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/agentTitlePromptSourceService.ts @@ -7,7 +7,7 @@ import { promptMetadataTextFromContentParts, promptMetadataTextFromText, } from '#/agent/prompt/promptMetadataText'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { IAgentTitlePromptSource, diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 3f195e7382b..4715e25d27b 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -14,9 +14,9 @@ import { ILogService } from '#/_base/log/log'; import { IOAuthService } from '#/app/auth/auth'; import { IEventService } from '#/app/event/event'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; -import { IHostRequestHeaders } from '#/kosong/model/hostRequestHeaders'; -import { IProviderService } from '#/kosong/provider/provider'; -import { isOAuthCatalogVendor } from '#/kosong/provider/providerDefinition'; +import { IHostRequestHeaders } from '#/llm-adapter/model/host-request-headers'; +import { IProviderService } from '#/llm-adapter/provider/provider'; +import { isOAuthCatalogVendor } from '#/llm-adapter/provider/provider-definition'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { SessionMetaUpdated } from '#/session/sessionMetadata/sessionMetaEvents'; diff --git a/packages/agent-core-v2/src/session/subagent/configSection.ts b/packages/agent-core-v2/src/session/subagent/configSection.ts index f71ed8e5155..302a7056145 100644 --- a/packages/agent-core-v2/src/session/subagent/configSection.ts +++ b/packages/agent-core-v2/src/session/subagent/configSection.ts @@ -11,11 +11,11 @@ import { } from '#/app/config/config'; import { registerConfigSection } from '#/app/config/configSectionContributions'; import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; -import type { IModelCatalog, Model } from '#/kosong/model/catalog'; +import type { IModelCatalog, Model } from '#/llm-adapter/model/catalog'; import { declaredDefaultEffortForModel, type ThinkingConfig, -} from '#/kosong/model/thinking'; +} from '#/llm-adapter/model/thinking'; import { SECONDARY_MODEL_FLAG_ID } from './flag'; diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 30d4acebaac..d3eabb9919d 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -4,8 +4,8 @@ import { userCancellationReason } from '#/_base/utils/abort'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentProfileService } from '#/agent/profile/profile'; import { tryAgentContextOf } from '#/agent/scopeContext/scopeContext'; -import { isProviderRateLimitError } from '#/kosong/contract/errors'; -import { type TokenUsage } from '#/kosong/contract/usage'; +import { isProviderRateLimitError } from '#/llm-adapter/contract/errors'; +import { type TokenUsage } from '#human/llm/usage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { SubagentCreatedEvent } from '#/app/telemetry/events'; import { Event2 } from '#/app/event/event2'; diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index 4bb64c5f14f..39dadbfe419 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -1,4 +1,4 @@ -import { APIProviderRateLimitError, isProviderRateLimitError } from '#/kosong/contract/errors'; +import { APIProviderRateLimitError, isProviderRateLimitError } from '#/llm-adapter/contract/errors'; import { linkAbortSignal, userCancellationReason } from '#/_base/utils/abort'; import type { IAgentScopeHandle } from '#/_base/di/scope'; diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index d07baf41eab..821c36390e1 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -1,6 +1,6 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { Turn } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; diff --git a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts index cd19ad03951..235a1daf017 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentModelsValidationService.ts @@ -2,7 +2,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog } from '#/kosong/model/catalog'; +import { IModelCatalog } from '#/llm-adapter/model/catalog'; import { assertValidSubagentModelConfig } from './configSection'; import { ISessionSubagentModelsValidationService } from './subagentModelsValidation'; diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index d4b48ef51ec..eaf730e2c1f 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -23,7 +23,7 @@ import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; import type { Runtime } from '#/runtime/runtime'; import { IConfigService } from '#/app/config/config'; import { IFlagService } from '#/app/flag/flag'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; import { ILogService } from '#/_base/log/log'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { RuntimeWorkspaceView } from '#/runtime/runtimeWorkspaceView'; diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts index 616fac52757..c2b9f7497cf 100644 --- a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts @@ -5,9 +5,9 @@ import type { TokenCountingRequest, TokenCountingStrategy, } from '#/agent/tokenCounting/tokenCounting'; -import type { Message } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; +import type { TokenUsage } from '#human/llm/usage'; export interface TokenCountingRebaseInput { readonly length: number; diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts index 2090a04b1e9..3e6f8bd2af2 100644 --- a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts @@ -13,15 +13,15 @@ import type { TokenCountingRequest, TokenCountingStrategy, } from '#/agent/tokenCounting/tokenCounting'; -import type { Message } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolDescription as Tool } from '#human/llm/message'; import { estimateTokens, estimateTokensForMessage, estimateTokensForMessages, estimateTokensForTools, -} from '#/kosong/contract/tokens'; -import type { TokenUsage } from '#/kosong/contract/usage'; +} from '#/llm-adapter/contract/tokens'; +import type { TokenUsage } from '#human/llm/usage'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { diff --git a/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts b/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts index 3460d9ace5b..2fd86122e45 100644 --- a/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts +++ b/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts @@ -14,9 +14,9 @@ import { type TokenCountingState, } from '#/agent/tokenCounting/tokenCountingOps'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import type { Message } from '#/kosong/contract/message'; -import { estimateTokensForMessages } from '#/kosong/contract/tokens'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { Message } from '#/llm-adapter/contract/message'; +import { estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; +import type { TokenUsage } from '#human/llm/usage'; import { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; import type { TokenCountingRebaseInput } from './sessionTokenCounting'; diff --git a/packages/agent-core-v2/src/session/usage/sessionUsage.ts b/packages/agent-core-v2/src/session/usage/sessionUsage.ts index 37a8931ec14..a8e33217e06 100644 --- a/packages/agent-core-v2/src/session/usage/sessionUsage.ts +++ b/packages/agent-core-v2/src/session/usage/sessionUsage.ts @@ -3,7 +3,7 @@ import type { Event } from '#/_base/event'; import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; import type { UsageRecordedContext, UsageStatus } from '#/agent/usage/usage'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; export interface ISessionUsageService { readonly _serviceBrand: undefined; diff --git a/packages/agent-core-v2/src/session/usage/sessionUsageService.ts b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts index 1f20331eace..426abf29cbd 100644 --- a/packages/agent-core-v2/src/session/usage/sessionUsageService.ts +++ b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts @@ -5,7 +5,7 @@ import { agentSpaceOf } from '#/agent/agentContext/agentSpace'; import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; import type { UsageRecordedContext, UsageStatus } from '#/agent/usage/usage'; import { copyUsage } from '#/agent/usage/usageOps'; -import type { TokenUsage } from '#/kosong/contract/usage'; +import type { TokenUsage } from '#human/llm/usage'; import { ISessionUsageService } from './sessionUsage'; import { UsageAgentModelDefinition } from './usageAgentModel'; diff --git a/packages/agent-core-v2/src/session/usage/usageAgentModel.ts b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts index 60924e96dd9..dd7dbdffb48 100644 --- a/packages/agent-core-v2/src/session/usage/usageAgentModel.ts +++ b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts @@ -9,7 +9,7 @@ import { type UsageModelState, type UsageRecordScope, } from '#/agent/usage/usageOps'; -import { addUsage, type TokenUsage } from '#/kosong/contract/usage'; +import { addUsage, type TokenUsage } from '#human/llm/usage'; import { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; export interface UsageRecordInput { diff --git a/packages/agent-core-v2/src/state/eventDispatcherService.ts b/packages/agent-core-v2/src/state/eventDispatcherService.ts index c7ba7918a81..969a51a1f72 100644 --- a/packages/agent-core-v2/src/state/eventDispatcherService.ts +++ b/packages/agent-core-v2/src/state/eventDispatcherService.ts @@ -18,7 +18,7 @@ import { type Event2Class, } from '#/app/event/event2'; import { IEventBus } from '#/app/event/eventBus'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { OrderedHookSlot } from '#/hooks'; import { IWireService } from '#/wire/wire'; import { WireError, WireErrors } from '#/wire/errors'; diff --git a/packages/agent-core-v2/src/tool/toolContract.ts b/packages/agent-core-v2/src/tool/toolContract.ts index 430ced66938..4cae00106c4 100644 --- a/packages/agent-core-v2/src/tool/toolContract.ts +++ b/packages/agent-core-v2/src/tool/toolContract.ts @@ -1,6 +1,5 @@ -import type { ContentPart, ToolCall } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; +import type { ContentPart, ToolCall, ToolDescription as Tool } from '#human/llm/message'; +import type { LLMRequestTrace } from '#/llm-adapter/contract/request-trace'; import type { ToolInputDisplay } from '@moonshot-ai/protocol'; export type ExecutableToolOutput = string | ContentPart[]; diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts index 215ad8b75d3..77fde920ea9 100644 --- a/packages/agent-core-v2/src/wire/wire.ts +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -9,6 +9,7 @@ export interface IWireService { appendRecord(record: WireRecord, dehydrate?: RecordDehydrator): void; readJournal(): AsyncIterable; flush(): Promise; + drainPersisted(): Promise; lineCount(): number; lastContextClearLine(): number | undefined; journalPath(): string | undefined; diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index fc30811360c..92588253eb3 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -6,7 +6,7 @@ import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { type AppendLogTruncation, IAppendLogStore, @@ -257,6 +257,10 @@ export class WireService extends Service implements IWireService { } } + async drainPersisted(): Promise { + await this.persistQueue; + } + async flush(): Promise { await this.persistQueue; const persistError = this.persistError; diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts index 0f145f12290..ad7b954438a 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts @@ -1,6 +1,6 @@ import { Error2, ErrorCodes } from '#/errors'; import { FILE_HISTORY_RECORD_PREFIX } from '#/features/fileHistory/fileHistoryOps'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { promptMetadataTextFromContentParts, promptMetadataTextFromText, diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 19167054602..5789bd9216c 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -75,8 +75,8 @@ import { type WireRecord, } from '#/wire/record'; import { repairWireJournal } from '#/wire/repair'; -import { IModelService } from '#/kosong/model/model'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IModelService } from '#/llm-adapter/model/model'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import { IWorkspaceContext } from '#/workspace/workspaceContext/workspaceContext'; import { IUserAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/userAgentProfileLoader'; import { IPluginAgentProfileLoader } from '#/workspace/workspaceAgentProfileLoader/pluginAgentProfileLoader'; diff --git a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts index 7407604438e..4537995107b 100644 --- a/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts +++ b/packages/agent-core-v2/src/workspace/workspaceInstance/workspaceInstanceManagerService.ts @@ -21,8 +21,8 @@ import { IAppStateService } from '#/app/state/appState'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { LifecycleScope } from '#/app/scopes'; import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; -import { IModelService } from '#/kosong/model/model'; -import { IProviderService } from '#/kosong/provider/provider'; +import { IModelService } from '#/llm-adapter/model/model'; +import { IProviderService } from '#/llm-adapter/provider/provider'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; diff --git a/packages/agent-core-v2/test/_base/utils/tokens.test.ts b/packages/agent-core-v2/test/_base/utils/tokens.test.ts index e9f4a882c18..3c755934c02 100644 --- a/packages/agent-core-v2/test/_base/utils/tokens.test.ts +++ b/packages/agent-core-v2/test/_base/utils/tokens.test.ts @@ -1,11 +1,11 @@ -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { describe, expect, it } from 'vitest'; import { estimateTokensForContentPart, estimateTokensForMessage, MEDIA_TOKEN_ESTIMATE, -} from '#/kosong/contract/tokens'; +} from '#/llm-adapter/contract/tokens'; describe('token estimates for media content parts', () => { const imagePart: ContentPart = { diff --git a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts index cbe017666d1..19098e41b3a 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -10,7 +10,7 @@ import { Emitter } from '#/_base/event'; import { IBashParserService } from '#/app/bashParser/bashParser'; import { BashParserService } from '#/app/bashParser/bashParserService'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import type { ToolCall } from '#/kosong/contract/message'; +import type { ToolCall } from '#human/llm/message'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem, type HostFileStat } from '#/os/interface/hostFileSystem'; diff --git a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts index 1b6187b41a8..0c3a3a6c6a1 100644 --- a/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts +++ b/packages/agent-core-v2/test/agent/blob/agentBlobService.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { type ServiceIdentifier } from '#/_base/di/instantiation'; import { LifecycleScope } from '#/app/scopes'; diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 9e586008153..53d0acde0c7 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -1,7 +1,8 @@ -import type { Message, ToolCall } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolCall } from '#human/llm/message'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { estimateTokens, estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { estimateTokens, estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; import { buildImageCompressionCaption } from '#/agent/media/image-compress'; import { buildContextCompactionShape, diff --git a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts index 0d9b036728c..9c3330ecd84 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/splice-replay.test.ts @@ -19,7 +19,7 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; -import type { ContentPart } from '#/kosong/contract/message'; +import type { ContentPart } from '#human/llm/message'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; diff --git a/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts b/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts index a111d3310cc..395feff4283 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/contextProjector.bench.ts @@ -8,7 +8,8 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentContextProjectorService } from '#/agent/contextProjector/contextProjector'; import { AgentContextProjectorService } from '#/agent/contextProjector/contextProjectorService'; import { ErrorCodes, Error2 } from '#/errors'; -import type { ContentPart, Message, TextPart, ToolCall } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ContentPart, TextPart, ToolCall } from '#human/llm/message'; const noopLogger: ILogger = { error: () => {}, diff --git a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts index fc208f4374c..f3e34dcb653 100644 --- a/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts +++ b/packages/agent-core-v2/test/agent/contextProjector/projector-tool-exchanges.test.ts @@ -10,7 +10,7 @@ import { AgentContextProjectorService } from '#/agent/contextProjector/contextPr import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; -import type { Message } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; diff --git a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts index e1587aa304a..23fef9c9ef5 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -2,16 +2,18 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'no import { tmpdir } from 'node:os'; import { join } from 'pathe'; -import { UNKNOWN_CAPABILITY } from '#/kosong/contract/capability'; +import { UNKNOWN_CAPABILITY } from '#/llm-adapter/contract/capability'; import { APIConnectionError, APIContextOverflowError, APIRequestTooLargeError, APIStatusError, -} from '#/kosong/contract/errors'; -import { type Message, type StreamedMessagePart, type ToolCall } from '#/kosong/contract/message'; -import { generate as runKosongGenerate } from '#/kosong/contract/generate'; -import type { ChatProvider, StreamedMessage } from '#/kosong/contract/provider'; +} from '#/llm-adapter/contract/errors'; +import { type Message } from '#/llm-adapter/contract/message'; +import { type StreamedMessagePart, type ToolCall } from '#human/llm/message'; +import type { FinishReason } from '#human/llm/finish-reason'; +import { fromLlmMessage } from '#/llm-adapter/contract/message'; +import type { TokenUsage } from '#human/llm/usage'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { @@ -21,10 +23,10 @@ import { COMPACTION_SUMMARY_PREFIX } from '#/agent/contextMemory/compactionHando import { makeHookRunner } from '../../features/externalHooks/runner-stub'; import type { IExternalHooksRunnerService } from '#/features/externalHooks/app/externalHooksRunner'; import { MASTER_ENV } from '#/app/flag/flagService'; -import { estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import type { TestAgentContext, TestAgentOptions, TestAgentServiceOverride } from '../../harness'; -import { agentService, appService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, sessionServices, testAgent as createTestAgent } from '../../harness'; +import { agentService, appService, appServices, createCommandRunner, execEnvServices, hostEnvironmentServices, requesterFromGenerateFn, sessionServices, testAgent as createTestAgent, type LegacyGenerateResult } from '../../harness'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; @@ -469,7 +471,7 @@ describe('FullCompaction', () => { tokenCalls.push(options?.force); return options?.force === true ? 'forced-refresh-token' : 'fresh-token'; }); - const generate: GenerateFn = async ( + const generate: GenerateFn = requesterFromGenerateFn(async ( _provider, _system, _tools, @@ -482,7 +484,7 @@ describe('FullCompaction', () => { throw new APIStatusError(401, 'Unauthorized', 'req-compact-401'); } return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent(oauthOptions.services, { initialConfig: oauthOptions.initialConfig, generate, @@ -637,13 +639,13 @@ describe('FullCompaction', () => { it('reports compaction retry_count after a retryable generation failure recovers', async () => { const records: TelemetryRecord[] = []; let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) { throw new APIConnectionError('socket hang up'); } return textResult('Recovered compacted summary.', 'trace-compact-1'); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -674,12 +676,12 @@ describe('FullCompaction', () => { it('retries any compaction request error indefinitely when KIMI_CODE_INFINITE_RETRY is set', async () => { vi.stubEnv('KIMI_CODE_INFINITE_RETRY', '1'); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) throw new APIStatusError(400, 'endpoint broken', null, 1); if (attempts === 2) throw new APIStatusError(404, 'model not found', null, 1); return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -701,11 +703,11 @@ describe('FullCompaction', () => { it('lets context overflow reach compaction shrink instead of retrying when KIMI_CODE_INFINITE_RETRY is set', async () => { vi.stubEnv('KIMI_CODE_INFINITE_RETRY', '1'); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) throw new APIContextOverflowError(400, 'context length exceeded'); return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -728,7 +730,7 @@ describe('FullCompaction', () => { let attempts = 0; let sawMedia = false; let sawStrippedResend = false; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { attempts += 1; const hasMedia = history.some((message) => message.content.some((part) => part.type === 'image_url' || part.type === 'video_url'), @@ -739,7 +741,7 @@ describe('FullCompaction', () => { } sawStrippedResend = true; return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -765,7 +767,7 @@ describe('FullCompaction', () => { let attempts = 0; let sawFullMedia = false; let sawDegradedResend = false; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { attempts += 1; const mediaCount = history.reduce( (count, message) => @@ -780,7 +782,7 @@ describe('FullCompaction', () => { } sawDegradedResend = true; return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -807,14 +809,14 @@ describe('FullCompaction', () => { vi.useFakeTimers(); const firstEmptySummary = deferred(); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts <= 2) { if (attempts === 1) firstEmptySummary.resolve(); return textResult(attempts === 1 ? '' : ' \n'); } return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -898,7 +900,7 @@ describe('FullCompaction', () => { const firstAttemptFailed = deferred(); let attempts = 0; const inputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { attempts += 1; inputs.push(inputHistorySnapshot(history)); if (attempts === 1) { @@ -906,7 +908,7 @@ describe('FullCompaction', () => { throw new APIStatusError(413, 'Request Entity Too Large', 'req-compact-plain-413'); } return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1014,13 +1016,13 @@ describe('FullCompaction', () => { it('fails the compaction instead of compacting an empty history when overflow shrink drops everything', async () => { let calls = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { calls += 1; if (calls === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-shrink-empty'); } return textResult('Groundless summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1045,14 +1047,14 @@ describe('FullCompaction', () => { vi.useFakeTimers(); const firstAttemptFailed = deferred(); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) { firstAttemptFailed.resolve(); throw new APIConnectionError('socket hang up'); } return textResult('Recovered compacted summary.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1081,13 +1083,13 @@ describe('FullCompaction', () => { const records: TelemetryRecord[] = []; const firstAttemptFailed = deferred(); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) { firstAttemptFailed.resolve(); } throw new APIStatusError(429, 'rate limited', null, null, 'trace-compact-retry'); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1128,9 +1130,9 @@ describe('FullCompaction', () => { it('cancels the compaction lifecycle when manual compaction generation fails', async () => { const records: TelemetryRecord[] = []; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { throw new Error('compaction exploded'); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1178,9 +1180,9 @@ describe('FullCompaction', () => { it('attaches the failed request trace id to compaction_failed', async () => { const records: TelemetryRecord[] = []; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { throw new APIStatusError(400, 'Bad request', null, null, 'trace-compact-fail'); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1244,10 +1246,10 @@ describe('FullCompaction', () => { it('fails a blocked turn when auto compaction generation fails', async () => { let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; throw new APIStatusError(400, 'Bad request'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1291,11 +1293,11 @@ describe('FullCompaction', () => { it('aborts an in-flight compaction when the agent is disposed', async () => { const started = deferred(); let signal: AbortSignal | undefined; - const generate: GenerateFn = async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { signal = options?.signal; started.resolve(); return new Promise(() => {}); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1316,7 +1318,7 @@ describe('FullCompaction', () => { vi.useFakeTimers(); const firstAttemptFinished = deferred(); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) { firstAttemptFinished.resolve(); @@ -1326,7 +1328,7 @@ describe('FullCompaction', () => { finishReason: 'truncated', rawFinishReason: 'length', }; - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1362,13 +1364,13 @@ describe('FullCompaction', () => { const records: TelemetryRecord[] = []; const firstAttemptFailed = deferred(); let attempts = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { attempts += 1; if (attempts === 1) { firstAttemptFailed.resolve(); } throw new APIConnectionError('socket hang up'); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1671,13 +1673,13 @@ describe('FullCompaction', () => { it('cancels when a droppable user-role tail is appended during the summary request', async () => { let ctx!: TestAgentContext; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { ctx.appendSystemReminder('RACE-NOTIFY-OUTPUT', { kind: 'injection', variant: 'race-notification', }); return textResult('Stale compacted summary.'); - }; + }); ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -1776,7 +1778,7 @@ describe('FullCompaction', () => { const records: TelemetryRecord[] = []; let ctx!: TestAgentContext; let llmCallCount = 0; - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { llmCallCount += 1; if (llmCallCount === 1) return textResult('Turn response.'); if (llmCallCount === 2) { @@ -1785,7 +1787,7 @@ describe('FullCompaction', () => { return textResult('Background compacted summary.'); } throw new Error(`Unexpected generate call ${String(llmCallCount)}`); - }; + }); ctx = testAgent({ generate, telemetry: recordingTelemetry(records), @@ -2219,7 +2221,7 @@ describe('FullCompaction', () => { it('compacts and retries when the provider reports context overflow', async () => { let callCount = 0; const inputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => { callCount += 1; inputs.push(inputHistorySnapshot(history)); if (callCount === 1) { @@ -2236,7 +2238,7 @@ describe('FullCompaction', () => { return textResult('Recovered after overflow compaction.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2300,7 +2302,7 @@ describe('FullCompaction', () => { it('recovers from compaction-request overflow under the measured token-counting strategy', async () => { let callCount = 0; const compactionInputLengths: number[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-measured-overflow'); @@ -2318,7 +2320,7 @@ describe('FullCompaction', () => { return textResult('Recovered under measured.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate, initialConfig: { @@ -2360,7 +2362,7 @@ describe('FullCompaction', () => { it('remembers the observed provider context window after overflow', async () => { let callCount = 0; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-observed-window'); @@ -2386,7 +2388,7 @@ describe('FullCompaction', () => { return textResult('Answered after observed-window precompaction.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2433,14 +2435,14 @@ describe('FullCompaction', () => { it('triggers preemptive compaction against the declared input cap, not the total window', async () => { let callCount = 0; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => { callCount += 1; if (callCount === 1) { return textResult('Preemptive summary under the input cap.'); } await callbacks?.onMessagePart?.({ type: 'text', text: 'Answered after input-cap compaction.' }); return textResult('Answered after input-cap compaction.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2465,7 +2467,7 @@ describe('FullCompaction', () => { it('honors the observed provider window over a declared input cap', async () => { let callCount = 0; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-observed-window'); @@ -2491,7 +2493,7 @@ describe('FullCompaction', () => { return textResult('Answered after observed-window precompaction.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2522,7 +2524,7 @@ describe('FullCompaction', () => { it('recovers from plain 413 when estimated request is over effective max', async () => { let callCount = 0; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => { callCount += 1; if (callCount === 1) { throw new APIStatusError(413, 'Request Entity Too Large', 'req-plain-413'); @@ -2535,7 +2537,7 @@ describe('FullCompaction', () => { text: 'Recovered after plain 413 compaction.', }); return textResult('Recovered after plain 413 compaction.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2577,9 +2579,9 @@ describe('FullCompaction', () => { }); it('does not compact plain 413 when estimated request is small', async () => { - const generate: GenerateFn = async () => { + const generate: GenerateFn = requesterFromGenerateFn(async () => { throw new APIStatusError(413, 'Request Entity Too Large', 'req-small-413'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2606,7 +2608,7 @@ describe('FullCompaction', () => { it('does not reset the step budget after provider context overflow compaction', async () => { let callCount = 0; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-budget-overflow'); @@ -2616,7 +2618,7 @@ describe('FullCompaction', () => { } await callbacks?.onMessagePart?.({ type: 'text', text: 'Should not run.' }); return textResult('Should not run.'); - }; + }); const ctx = testAgent({ generate, initialConfig: { @@ -2656,7 +2658,7 @@ describe('FullCompaction', () => { let callCount = 0; const records: TelemetryRecord[] = []; const thinkingEfforts: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; thinkingEfforts.push(options?.thinking?.effort); if (callCount === 1) { @@ -2677,7 +2679,7 @@ describe('FullCompaction', () => { return textResult('Recovered after thinking compaction.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate, telemetry: recordingTelemetry(records) }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2706,7 +2708,7 @@ describe('FullCompaction', () => { it('compacts provider overflow when model context size is unknown', async () => { let callCount = 0; const compactionMaxCompletionTokens: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-unknown-context'); @@ -2723,7 +2725,7 @@ describe('FullCompaction', () => { return textResult('Recovered with unknown context size.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2775,7 +2777,7 @@ describe('FullCompaction', () => { vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', '8192'); let callCount = 0; const compactionMaxCompletionTokens: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-hard-cap'); @@ -2789,7 +2791,7 @@ describe('FullCompaction', () => { text: 'Recovered with hard cap.', }); return textResult('Recovered with hard cap.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2811,7 +2813,7 @@ describe('FullCompaction', () => { vi.stubEnv('KIMI_MODEL_MAX_COMPLETION_TOKENS', maxCompletionTokens); let callCount = 0; const compactionMaxCompletionTokens: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-opt-out'); @@ -2825,7 +2827,7 @@ describe('FullCompaction', () => { text: 'Recovered with opt-out.', }); return textResult('Recovered with opt-out.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2845,7 +2847,7 @@ describe('FullCompaction', () => { it('honors maxOutputSize from model config during compaction', async () => { let callCount = 0; const compactionMaxCompletionTokens: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-max-output'); @@ -2859,7 +2861,7 @@ describe('FullCompaction', () => { text: 'Recovered with max output.', }); return textResult('Recovered with max output.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2884,7 +2886,7 @@ describe('FullCompaction', () => { it('uses default 128k hardCap when maxOutputSize is not configured', async () => { let callCount = 0; const compactionMaxCompletionTokens: unknown[] = []; - const generate: GenerateFn = async (_provider, _system, _tools, _history, callbacks, options) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, _history, callbacks, options) => { callCount += 1; if (callCount === 1) { throw new APIContextOverflowError(400, 'Context length exceeded', 'req-default-cap'); @@ -2898,7 +2900,7 @@ describe('FullCompaction', () => { text: 'Recovered with default cap.', }); return textResult('Recovered with default cap.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -2917,7 +2919,7 @@ describe('FullCompaction', () => { it('ignores filtered assistant placeholders when checking the retained overflow suffix', async () => { let callCount = 0; const inputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history, callbacks) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history, callbacks) => { callCount += 1; inputs.push(inputHistorySnapshot(history)); if (callCount === 1) { @@ -2938,7 +2940,7 @@ describe('FullCompaction', () => { return textResult('Recovered after ignoring the placeholder.'); } throw new Error(`Unexpected generate call ${String(callCount)}`); - }; + }); const ctx = testAgent({ generate, }); @@ -3415,7 +3417,7 @@ type MutableKimiConfig = { }; }; -function textResult(text: string, traceId: string | null = null): Awaited> { +function textResult(text: string, traceId: string | null = null): LegacyGenerateResult { return { id: 'mock-compaction-oauth-retry', message: { @@ -3435,18 +3437,23 @@ function textResult(text: string, traceId: string | null = null): Awaited; +} + function mockStreamedMessage( parts: readonly StreamedMessagePart[], traceId: string | null = null, - opts?: { finishReason?: StreamedMessage['finishReason']; rawFinishReason?: string | null }, -): StreamedMessage { + opts?: { finishReason?: FinishReason | null; rawFinishReason?: string | null }, +): ScriptedStream { return { - get id(): string | null { - return 'mock-stream'; - }, - get usage() { - return null; - }, + id: 'mock-stream', + usage: null, finishReason: opts?.finishReason ?? null, rawFinishReason: opts?.rawFinishReason ?? null, traceId, @@ -3459,19 +3466,38 @@ function mockStreamedMessage( } function realKosongGenerate( - script: (attempt: number, history: readonly Message[]) => StreamedMessage, + script: (attempt: number, history: readonly Message[]) => ScriptedStream, ): GenerateFn { let attempt = 0; - return (chat, systemPrompt, tools, history, callbacks, options) => { - attempt += 1; - const currentAttempt = attempt; - const provider: ChatProvider = { - name: 'mock-think-only', - modelName: chat.modelName, - thinkingEffort: chat.thinkingEffort, - generate: () => Promise.resolve(script(currentAttempt, history)), - }; - return runKosongGenerate(provider, systemPrompt, tools, history, callbacks, options); + return { + generate: async (config, content, control) => { + attempt += 1; + const streamed = script(attempt, content.messages.map(fromLlmMessage)); + const emit = control.onEvent; + emit?.({ type: 'llm.sent' }); + emit?.({ + type: 'llm.headers', + headers: streamed.traceId === null ? {} : { 'x-trace-id': streamed.traceId }, + }); + for await (const part of streamed) { + emit?.({ type: 'llm.delta', part }); + control.signal.throwIfAborted(); + } + if (streamed.usage !== null) { + emit?.({ type: 'llm.usage', usage: streamed.usage }); + } + emit?.({ + type: 'llm.finish', + finish: { + finishReason: streamed.finishReason, + rawFinishReason: streamed.rawFinishReason, + }, + }); + if (streamed.id !== null) { + emit?.({ type: 'llm.message-id', messageId: streamed.id }); + } + emit?.({ type: 'llm.done' }); + }, }; } @@ -3581,7 +3607,7 @@ describe('prompt deferral during full compaction', () => { const releaseCompaction = deferred(); let llmCallCount = 0; const llmInputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { llmCallCount += 1; llmInputs.push(history.map(messageText)); if (llmCallCount === 1) { @@ -3590,7 +3616,7 @@ describe('prompt deferral during full compaction', () => { return textResult('Compacted summary.'); } return textResult('Deferred turn reply.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -3631,7 +3657,7 @@ describe('prompt deferral during full compaction', () => { const releaseCompaction = deferred(); let llmCallCount = 0; const llmInputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { llmCallCount += 1; llmInputs.push(history.map(messageText)); if (llmCallCount === 1) { @@ -3640,7 +3666,7 @@ describe('prompt deferral during full compaction', () => { throw new Error('compaction exploded'); } return textResult('Recovered turn reply.'); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, @@ -3755,7 +3781,7 @@ describe('goal reminder re-injection after full compaction', () => { const releaseCompaction = deferred(); let llmCallCount = 0; const llmInputs: string[][] = []; - const generate: GenerateFn = async (_provider, _system, _tools, history) => { + const generate: GenerateFn = requesterFromGenerateFn(async (_provider, _system, _tools, history) => { llmCallCount += 1; llmInputs.push(history.map(messageText)); if (llmCallCount === 1) { @@ -3765,7 +3791,7 @@ describe('goal reminder re-injection after full compaction', () => { } if (llmCallCount === 2) return textResult('Deferred turn reply.'); throw new Error(`Unexpected generate call #${String(llmCallCount)}`); - }; + }); const ctx = testAgent({ generate }); ctx.configure({ provider: CATALOGUED_PROVIDER, diff --git a/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts index dd37ab68843..640be16679a 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/strategy.test.ts @@ -1,7 +1,7 @@ -import { type Message } from '#/kosong/contract/message'; +import { type Message } from '#/llm-adapter/contract/message'; import { describe, expect, it } from 'vitest'; -import { estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { estimateTokensForMessages } from '#/llm-adapter/contract/tokens'; import { DefaultCompactionStrategy } from '#/agent/fullCompaction/strategy'; describe('DefaultCompactionStrategy', () => { diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts index b7c17fbbbd3..0299b7fe338 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequester.test.ts @@ -1,8 +1,7 @@ -import { APIConnectionError, APIStatusError } from '#/kosong/contract/errors'; +import { APIConnectionError, APIStatusError } from '#/llm-adapter/contract/errors'; import { TOOL_SELECT_FLAG_ENV } from '#/agent/toolSelect/flag'; -import { type StreamedMessagePart } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import { emptyUsage } from '#/kosong/contract/usage'; +import type { StreamedMessagePart, ToolDescription as Tool } from '#human/llm/message'; +import { emptyUsage } from '#human/llm/usage'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { @@ -16,6 +15,7 @@ import { createTestAgent, llmGenerateServices, logServices, + requesterFromGenerateFn, telemetryServices, type TestAgentContext, } from '../../harness'; @@ -216,7 +216,7 @@ describe('LLMRequester service migration coverage', () => { await ctx.dispose(); let calls = 0; ctx = createTestAgent( - llmGenerateServices(async () => { + llmGenerateServices(requesterFromGenerateFn(async () => { calls += 1; if (calls === 1) { throw new APIStatusError(400, 'tool_use ids must be unique'); @@ -232,7 +232,7 @@ describe('LLMRequester service migration coverage', () => { finishReason: 'completed', rawFinishReason: 'stop', }; - }), + })), ); llmRequester = ctx.get(IAgentLLMRequesterService); @@ -338,9 +338,9 @@ describe('LLMRequester service migration coverage', () => { child: () => logger, }; ctx = createTestAgent( - llmGenerateServices(async () => { + llmGenerateServices(requesterFromGenerateFn(async () => { throw new Error('temporary provider failure'); - }), + })), logServices(logger), ); const llmRequester = ctx.get(IAgentLLMRequesterService); @@ -371,10 +371,10 @@ describe('LLMRequester service migration coverage', () => { it('fails a retryable provider error on the first attempt — retries are the loop\u2019s concern', async () => { let calls = 0; ctx = createTestAgent( - llmGenerateServices(async () => { + llmGenerateServices(requesterFromGenerateFn(async () => { calls += 1; throw new APIConnectionError('terminated'); - }), + })), ); const llmRequester = ctx.get(IAgentLLMRequesterService); @@ -387,9 +387,9 @@ describe('LLMRequester service migration coverage', () => { it('tracks api_error with the v1 wire shape (model id, alias, protocol, status code)', async () => { const records: TelemetryRecord[] = []; ctx = createTestAgent( - llmGenerateServices(async () => { + llmGenerateServices(requesterFromGenerateFn(async () => { throw new APIStatusError(429, 'rate limited'); - }), + })), telemetryServices(recordingTelemetry(records)), ); const llmRequester = ctx.get(IAgentLLMRequesterService); @@ -417,9 +417,9 @@ describe('LLMRequester service migration coverage', () => { it('tags api_error with turn_id and request_kind from the request source', async () => { const records: TelemetryRecord[] = []; ctx = createTestAgent( - llmGenerateServices(async () => { + llmGenerateServices(requesterFromGenerateFn(async () => { throw new APIConnectionError('terminated'); - }), + })), telemetryServices(recordingTelemetry(records)), ); const llmRequester = ctx.get(IAgentLLMRequesterService); @@ -464,11 +464,9 @@ describe('LLMRequester service migration coverage', () => { const { logger, entries } = captureLogs(); logEntries = entries; ctx = createTestAgent( - llmGenerateServices(async (_provider, _systemPrompt, _tools, _messages, callbacks, options) => { + llmGenerateServices(requesterFromGenerateFn(async (_provider, _systemPrompt, _tools, _messages, callbacks, options) => { requestMaxTokens = options?.maxCompletionTokens; - options?.onRequestStart?.(); await callbacks?.onMessagePart?.({ type: 'text', text: 'timed' }); - options?.onStreamEnd?.(); return { id: 'response-1', message: { @@ -480,7 +478,7 @@ describe('LLMRequester service migration coverage', () => { finishReason: 'completed', rawFinishReason: 'stop', }; - }), + })), configServices(() => ({ defaultModel: 'deepseek/deepseek-v4-flash', providers: { @@ -602,7 +600,7 @@ describe('LLMRequester service migration coverage', () => { beforeEach(() => { capturedCacheKey = undefined; ctx = createTestAgent( - llmGenerateServices(async (_provider, _systemPrompt, _tools, _messages, _callbacks, options) => { + llmGenerateServices(requesterFromGenerateFn(async (_provider, _systemPrompt, _tools, _messages, _callbacks, options) => { capturedCacheKey = options?.cacheKey; return { id: 'response-1', @@ -615,7 +613,7 @@ describe('LLMRequester service migration coverage', () => { finishReason: 'completed', rawFinishReason: 'stop', }; - }), + })), ); llmRequester = ctx.get(IAgentLLMRequesterService); }); diff --git a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts index b100cf18a7f..82e79524980 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/llmRequesterService.test.ts @@ -35,23 +35,19 @@ import { APIProviderRateLimitError, APIRequestTooLargeError, APIStatusError, -} from '#/kosong/contract/errors'; -import { emptyUsage, type TokenUsage } from '#/kosong/contract/usage'; -import { - isToolCall, - type Message, - type StreamedMessagePart, - type ToolCall, -} from '#/kosong/contract/message'; -import type { ThinkingEffort } from '#/kosong/contract/provider'; -import type { ModelCapability } from '#/kosong/contract/capability'; -import { IModelCatalog, type Model } from '#/kosong/model/catalog'; -import { IModelService } from '#/kosong/model/model'; +} from '#/llm-adapter/contract/errors'; +import { emptyUsage, type TokenUsage } from '#human/llm/usage'; +import { type Message } from '#/llm-adapter/contract/message'; +import { isToolCall, type StreamedMessagePart, type ToolCall } from '#human/llm/message'; +import type { ThinkingEffort } from '#human/llm/thinking'; +import type { ModelCapability } from '#/llm-adapter/contract/capability'; +import { IModelCatalog, type Model } from '#/llm-adapter/model/catalog'; +import { IModelService } from '#/llm-adapter/model/model'; import { type ModelRequestEvent, type ModelRequestInput, type ModelRequester, -} from '#/kosong/model/modelRequester'; +} from '#/llm-adapter/model/model-requester'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ILogService } from '#/_base/log/log'; import { Error2, ErrorCodes } from '#/errors'; diff --git a/packages/agent-core-v2/test/agent/llmRequester/toolCallIdNormalizer.test.ts b/packages/agent-core-v2/test/agent/llmRequester/toolCallIdNormalizer.test.ts index d3b33fcbcd1..baf0ff5b85c 100644 --- a/packages/agent-core-v2/test/agent/llmRequester/toolCallIdNormalizer.test.ts +++ b/packages/agent-core-v2/test/agent/llmRequester/toolCallIdNormalizer.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import type { Message, ToolCall } from '#/kosong/contract/message'; +import type { Message } from '#/llm-adapter/contract/message'; +import type { ToolCall } from '#human/llm/message'; import { ToolCallIdNormalizer } from '#/agent/llmRequester/toolCallIdNormalizer'; diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 320f1f54c49..4519f7fe2fd 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -1,17 +1,18 @@ import { getMaxListeners } from 'node:events'; -import { type ToolCall } from '#/kosong/contract/message'; -import { emptyUsage } from '#/kosong/contract/usage'; +import { type ToolCall } from '#human/llm/message'; +import { emptyUsage } from '#human/llm/usage'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import type { IDisposable } from '#/_base/di/lifecycle'; import { IAgentProfileService } from '#/index'; import { IAgentLLMRequesterService } from '#/agent/llmRequester/llmRequester'; -import type { ModelRequestTiming } from '#/kosong/model/modelRequester'; +import type { ModelRequestTiming } from '#/llm-adapter/model/model-requester'; +import { APIProviderRateLimitError } from '#/llm-adapter/contract/errors'; import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import { IAgentGoalService } from '#/features/goal/goalService'; import { IAgentLoopService, type Turn } from '#/agent/loop/loop'; -import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRequest'; import { AssistantDelta, ThinkingDelta, @@ -20,9 +21,9 @@ import { TurnStepStarted, } from '#/agent/loop/turnEvents'; import { TurnEnded } from '#/agent/loop/turnOps'; -import { RetryStepRequest } from '#/agent/prompt/promptStepRequests'; import type { ExecutableTool } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { userCancellationReason } from '#/_base/utils/abort'; @@ -31,6 +32,7 @@ import { agentService, createTestAgent, permissionModeServices, + requesterFromGenerateFn, type TestAgentContext, type TestAgentOptions, } from '../../harness'; @@ -83,12 +85,12 @@ describe('Agent loop', () => { [emit] turn.step.started { "time": "