diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index ce241fbbec1..70aa0ea1943 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -25,9 +25,7 @@ import { IAgentPlanService } from '@moonshot-ai/agent-core-v2/features/plan/plan import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; import { IAgentSwarmService } from '@moonshot-ai/agent-core-v2/features/swarm/agent/swarm'; import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; -import { IAgentTokenCountingService } from '@moonshot-ai/agent-core-v2/agent/tokenCounting/tokenCounting'; import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry'; -import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; 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'; @@ -171,18 +169,6 @@ export const AGENT_PANELS: readonly ServicePanelDef[] = [ { label: 'Refresh system prompt', run: (svc) => call(svc, 'refreshSystemPrompt') }, ], }, - { - id: String(IAgentUsageService), - label: 'AgentUsageService', - scope: 'agent', - fetch: (svc) => call(svc, 'status'), - }, - { - id: String(IAgentTokenCountingService), - label: 'AgentTokenCountingService', - scope: 'agent', - fetch: (svc) => call(svc, 'get'), - }, { id: String(IAgentPermissionModeService), label: 'AgentPermissionModeService', diff --git a/packages/acp-server/test/e2e-turn.test.ts b/packages/acp-server/test/e2e-turn.test.ts index 5aea5404249..f5e974d6fe8 100644 --- a/packages/acp-server/test/e2e-turn.test.ts +++ b/packages/acp-server/test/e2e-turn.test.ts @@ -506,11 +506,12 @@ describe('acp-server real prompt turn (scripted LLM)', () => { const wireId = (create.params as { update?: { toolCallId?: string } }).update?.toolCallId; const turnId = Number(wireId?.split(':')[0]); const session = getLiveSessionById(c.server.core.accessor, created.sessionId); - const agentHandle = session?.accessor.get(IAgentLifecycleService).get('main'); + const agentHandle = session?.accessor.get(IAgentLifecycleService).findAgentHandle('main'); const bus = agentHandle?.accessor.get(IEventBus); expect(bus).toBeDefined(); bus!.publish( new ToolProgress({ + agentId: 'main', turnId, toolCallId: 'call_1', update: { kind: 'stdout', text: 'raw-stdout-bytes' }, @@ -518,6 +519,7 @@ describe('acp-server real prompt turn (scripted LLM)', () => { ); bus!.publish( new ToolProgress({ + agentId: 'main', turnId, toolCallId: 'call_1', update: { kind: 'status', text: 'Still working…' }, diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index cde14a897fd..3ca35e540d7 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: 17 keys · Agent: 99 keys) +// Index (App: 0 keys · Workspace: 6 keys · Session: 17 keys · Agent: 94 keys) // App // Workspace // workspaceDirs.ephemeralDirs src/workspace/workspaceDirs/workspaceDirsService.ts @@ -135,8 +135,6 @@ // task.ghosts src/agent/task/taskService.ts // task.notificationDelivery src/agent/task/taskService.ts // task.scheduledNotificationKeys src/agent/task/taskService.ts -// todo src/session/todo/todoOps.ts -// tokenCounting src/agent/tokenCounting/tokenCountingOps.ts // toolDedupe.activeStep src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.activeTurnId src/agent/toolDedupe/toolDedupeService.ts // toolDedupe.callKeyByCallId src/agent/toolDedupe/toolDedupeService.ts @@ -150,9 +148,6 @@ // toolSelect.pendingLoaded src/agent/toolSelect/toolSelectService.ts // tower src/features/tower/towerOps.ts // turn src/agent/loop/turnOps.ts -// usage src/agent/usage/usageOps.ts -// usage.currentTurn src/agent/usage/usageService.ts -// usage.currentTurnId src/agent/usage/usageService.ts // userTool src/agent/userTool/userToolOps.ts /** App-scope keys registered into IAppStateService. */ @@ -1331,10 +1326,10 @@ export interface AgentStateSnapshot { // src/agent/media/mediaToolsRegistrar.ts 'media.registeredKey': string | undefined; // src/agent/permissionMode/injection/permissionModeInjection.ts - 'permissionMode.lastMode': 'manual' | 'yolo' | 'auto' | undefined; + 'permissionMode.lastMode': 'manual' | 'auto' | 'yolo' | undefined; // src/agent/permissionMode/permissionModeOps.ts // replayable · durable — folds: PermissionSetMode - 'permissionMode': /* PermissionMode — packages/agent-core-v2/src/agent/permissionPolicy/types.ts */ 'manual' | 'yolo' | 'auto'; + 'permissionMode': /* PermissionMode — packages/agent-core-v2/src/agent/permissionPolicy/types.ts */ 'manual' | 'auto' | 'yolo'; // replayable · durable — folds: PermissionSetMode 'permissionMode.configured': boolean; // src/agent/permissionRules/permissionRulesOps.ts @@ -1508,16 +1503,6 @@ export interface AgentStateSnapshot { // replayable · durable · undoable — folds: ContextAppendMessage, TaskWaitDelivered 'task.notificationDelivery': readonly string[]; 'task.scheduledNotificationKeys': Set; - // src/agent/tokenCounting/tokenCountingOps.ts - // replayable · durable — folds: TokenCountingMeasured, TokenCountingTruncated, TokenCountingRebased, TokenCountingTurnRecorded - 'tokenCounting': /* TokenCountingState — packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts */ { - readonly anchors: readonly /* TokenAnchor — packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts */ { - readonly length: number; - readonly tokens: number; - readonly measured: boolean; - }[]; - readonly tokens: number; - }; // src/agent/toolDedupe/toolDedupeService.ts 'toolDedupe.activeStep': number; 'toolDedupe.activeTurnId': number | undefined; @@ -1532,24 +1517,6 @@ export interface AgentStateSnapshot { 'toolExecutor.toolCallDupTypes': Map; // src/agent/toolSelect/toolSelectService.ts 'toolSelect.pendingLoaded': Set; - // src/agent/usage/usageOps.ts - // replayable · durable — folds: UsageRecord - 'usage': /* UsageModelState — packages/agent-core-v2/src/agent/usage/usageOps.ts */ { - readonly byModel: Record; - }; - // src/agent/usage/usageService.ts - 'usage.currentTurn': /* TokenUsage — packages/agent-core-v2/src/kosong/contract/usage.ts */ { - inputOther: number; - output: number; - inputCacheRead: number; - inputCacheCreation: number; - } | undefined; - 'usage.currentTurnId': number | undefined; // src/agent/userTool/userToolOps.ts // replayable · durable — folds: ToolsRegisterUserTool, ToolsUnregisterUserTool 'userTool': /* UserToolModelState — packages/agent-core-v2/src/agent/userTool/userToolOps.ts */ Map; - // src/session/todo/todoOps.ts - // replayable · durable · undoable — folds: ToolsUpdateStore - 'todo': readonly /* TodoItem — packages/agent-core-v2/src/session/todo/todoItem.ts */ { - readonly title: string; - readonly status: /* TodoStatus — packages/agent-core-v2/src/session/todo/todoItem.ts */ 'pending' | 'in_progress' | 'done'; - }[]; } export type AgentStateKey = keyof AgentStateSnapshot; diff --git a/packages/agent-core-v2/docs/wire-manifest.d.ts b/packages/agent-core-v2/docs/wire-manifest.d.ts index 518fe5433fd..f38531e07b5 100644 --- a/packages/agent-core-v2/docs/wire-manifest.d.ts +++ b/packages/agent-core-v2/docs/wire-manifest.d.ts @@ -25,61 +25,61 @@ // media to blob storage), owner (the source file declaring the class). // Index (55 record types) -// config.update profile src/agent/profile/profileOps.ts -// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts -// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.apply_compaction contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.clear contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// context.undo contextMemory, plan, task.notificationDelivery, todo src/agent/contextMemory/contextEvents.ts -// cron.add cron src/session/cron/cronOps.ts -// cron.cursor cron src/session/cron/cronOps.ts -// cron.delete cron src/session/cron/cronOps.ts -// forked goal, goalForkNotice src/agent/goal/goalOps.ts -// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts -// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts -// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts -// goal.clear goal, goalForkNotice src/agent/goal/goalOps.ts -// goal.create goal, goalForkNotice src/agent/goal/goalOps.ts -// goal.update goal src/agent/goal/goalOps.ts -// interaction.request interaction src/session/interaction/interactionOps.ts -// interaction.resolved interaction src/session/interaction/interactionOps.ts -// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts -// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts -// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts -// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts -// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts -// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts -// plan_mode.cancel plan src/features/plan/planOps.ts -// plan_mode.enter plan src/features/plan/planOps.ts -// plan_mode.exit plan src/features/plan/planOps.ts -// plan.revision plan src/features/plan/planOps.ts -// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts -// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts -// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts -// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts -// staleGuard.cleared staleGuard src/features/staleGuard/staleGuardOps.ts -// staleGuard.recorded staleGuard src/features/staleGuard/staleGuardOps.ts -// swarm_mode.enter swarm src/features/swarm/swarmOps.ts -// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts -// task.started task src/agent/task/taskOps.ts -// task.terminated task src/agent/task/taskOps.ts -// task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts -// token_counting.measured tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.rebased tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.truncated tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// token_counting.turn_recorded tokenCounting src/agent/tokenCounting/tokenCountingOps.ts -// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts -// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts -// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts -// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts -// tools.update_store todo src/session/todo/todoOps.ts -// tower_mode.enter tower src/features/tower/towerOps.ts -// tower_mode.exit tower src/features/tower/towerOps.ts -// turn.cancel turn src/agent/loop/turnOps.ts -// turn.ended turn src/agent/loop/turnOps.ts -// turn.prompt turn src/agent/loop/turnOps.ts -// turn.steer turn src/agent/loop/turnOps.ts -// usage.record usage src/agent/usage/usageOps.ts +// config.update profile src/agent/profile/profileOps.ts +// context.append_loop_event contextMemory, turn src/agent/contextMemory/contextEvents.ts +// context.append_message contextMemory, goalForkNotice, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts +// context.apply_compaction contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts +// context.clear contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts +// context.undo contextMemory, plan, task.notificationDelivery src/agent/contextMemory/contextEvents.ts +// cron.add cron src/session/cron/cronOps.ts +// cron.cursor cron src/session/cron/cronOps.ts +// cron.delete cron src/session/cron/cronOps.ts +// forked goal, goalForkNotice src/agent/goal/goalOps.ts +// full_compaction.begin fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.cancel fullCompaction src/agent/fullCompaction/compactionOps.ts +// full_compaction.complete fullCompaction src/agent/fullCompaction/compactionOps.ts +// goal.clear goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.create goal, goalForkNotice src/agent/goal/goalOps.ts +// goal.update goal src/agent/goal/goalOps.ts +// interaction.request interaction src/session/interaction/interactionOps.ts +// interaction.resolved interaction src/session/interaction/interactionOps.ts +// interruptionReminder.recorded interruptionReminder src/agent/interruptionReminder/interruptionReminderOps.ts +// llm.request llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// llm.tools_snapshot llm.requestTrace src/agent/llmRequester/llmRequestOps.ts +// mcp.tools_discovered mcp.discovery src/agent/mcp/mcpDiscoveryOps.ts +// permission.record_approval_result permissionRules src/agent/permissionRules/permissionRulesOps.ts +// permission.set_mode permissionMode, permissionMode.configured src/agent/permissionMode/permissionModeOps.ts +// plan_mode.cancel plan src/features/plan/planOps.ts +// plan_mode.enter plan src/features/plan/planOps.ts +// plan_mode.exit plan src/features/plan/planOps.ts +// plan.revision plan src/features/plan/planOps.ts +// plugin.session_start pluginSessionStartSnapshot src/agent/plugin/agentPluginOps.ts +// profile.bind profile, profile.activeTools src/agent/profile/profileOps.ts +// prompt.accepted promptAdmission src/agent/prompt/promptOps.ts +// runtime.set_binding runtimeBinding src/agent/runtimeBinding/runtimeBindingOps.ts +// staleGuard.cleared staleGuard src/features/staleGuard/staleGuardOps.ts +// staleGuard.recorded staleGuard src/features/staleGuard/staleGuardOps.ts +// swarm_mode.enter swarm src/features/swarm/swarmOps.ts +// swarm_mode.exit contextMemory, swarm src/features/swarm/swarmOps.ts +// task.started task src/agent/task/taskOps.ts +// task.terminated task src/agent/task/taskOps.ts +// task.waitDelivered task.notificationDelivery src/agent/task/taskOps.ts +// token_counting.measured (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.rebased (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.truncated (none) src/agent/tokenCounting/tokenCountingOps.ts +// token_counting.turn_recorded (none) src/agent/tokenCounting/tokenCountingOps.ts +// tools.register_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.reset_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.set_active_tools profile.activeTools src/agent/profile/profileOps.ts +// tools.unregister_user_tool userTool src/agent/userTool/userToolOps.ts +// tools.update_store (none) src/session/todo/todoOps.ts +// tower_mode.enter tower src/features/tower/towerOps.ts +// tower_mode.exit tower src/features/tower/towerOps.ts +// turn.cancel turn src/agent/loop/turnOps.ts +// turn.ended turn src/agent/loop/turnOps.ts +// turn.prompt turn src/agent/loop/turnOps.ts +// turn.steer turn src/agent/loop/turnOps.ts +// usage.record (none) src/agent/usage/usageOps.ts /** * states: profile @@ -87,6 +87,7 @@ */ interface ConfigUpdatePayload { _name: 'config.update'; + agentId: string; modelAlias?: string; profileName?: string; /** ThinkingEffort */ @@ -110,16 +111,18 @@ interface ConfigUpdatePayload { */ interface ContextAppendLoopEventPayload { _name: 'context.append_loop_event'; + agentId: string; /** LoopRecordedEvent */ event: 'step.begin' | 'step.end' | 'content.part' | 'tool.call' | 'tool.result'; } /** - * states: contextMemory, goalForkNotice, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, goalForkNotice, plan, task.notificationDelivery · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextAppendMessagePayload { _name: 'context.append_message'; + agentId: string; /** ContextMessage */ message: { role: 'system' | 'user' | 'assistant' | 'tool'; @@ -150,26 +153,28 @@ interface ContextAppendMessagePayload { } /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, plan, task.notificationDelivery · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts * shared base: ...contextCompactionBaseShape */ type ContextApplyCompactionPayload = { _name: 'context.apply_compaction'; } & ({ summary: string, compactedCount: number, contextSummary?: string } | { contextSummary: string, compactedCount: number, summary?: string } | { summary: ContextMessage, count: number, compactedCount?: number }); /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, plan, task.notificationDelivery · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextClearPayload { _name: 'context.clear'; + agentId: string; } /** - * states: contextMemory, plan, task.notificationDelivery, todo · blobs: contextMemory + * states: contextMemory, plan, task.notificationDelivery · blobs: contextMemory * owner: src/agent/contextMemory/contextEvents.ts */ interface ContextUndoPayload { _name: 'context.undo'; + agentId: string; count: number; } @@ -215,16 +220,18 @@ interface CronDeletePayload { */ interface ForkedPayload { _name: 'forked'; + agentId: string; } /** * states: fullCompaction * owner: src/agent/fullCompaction/compactionOps.ts - * payload type: CompactionBeginData */ interface FullCompactionBeginPayload { _name: 'full_compaction.begin'; + agentId: string; instruction?: string; + /** CompactionSource */ source: 'manual' | 'auto'; } @@ -234,6 +241,7 @@ interface FullCompactionBeginPayload { */ interface FullCompactionCancelPayload { _name: 'full_compaction.cancel'; + agentId: string; } /** @@ -242,6 +250,7 @@ interface FullCompactionCancelPayload { */ interface FullCompactionCompletePayload { _name: 'full_compaction.complete'; + agentId: string; } /** @@ -250,6 +259,7 @@ interface FullCompactionCompletePayload { */ interface GoalClearPayload { _name: 'goal.clear'; + agentId: string; } /** @@ -258,6 +268,7 @@ interface GoalClearPayload { */ interface GoalCreatePayload { _name: 'goal.create'; + agentId: string; goalId: string; objective: string; completionCriterion?: string; @@ -277,6 +288,7 @@ interface GoalCreatePayload { */ interface GoalUpdatePayload { _name: 'goal.update'; + agentId: string; goalId?: string; status?: 'active' | 'paused' | 'blocked' | 'complete'; reason?: string; @@ -298,10 +310,10 @@ interface GoalUpdatePayload { */ interface InteractionRequestPayload { _name: 'interaction.request'; + agentId: string; id: string; kind: 'approval' | 'question' | 'user_tool'; toolCallId?: string; - agentId?: string; request: any; } @@ -311,6 +323,7 @@ interface InteractionRequestPayload { */ interface InteractionResolvedPayload { _name: 'interaction.resolved'; + agentId: string; id: string; response: any; } @@ -321,6 +334,7 @@ interface InteractionResolvedPayload { */ interface InterruptionReminderRecordedPayload { _name: 'interruptionReminder.recorded'; + agentId: string; turnId: number; } @@ -330,6 +344,7 @@ interface InterruptionReminderRecordedPayload { */ interface LlmRequestPayload { _name: 'llm.request'; + agentId: string; kind: 'loop' | 'compaction'; provider: string; model: string; @@ -358,6 +373,7 @@ interface LlmRequestPayload { */ interface LlmToolsSnapshotPayload { _name: 'llm.tools_snapshot'; + agentId: string; hash: string; tools: { name: string; @@ -372,6 +388,7 @@ interface LlmToolsSnapshotPayload { */ interface McpToolsDiscoveredPayload { _name: 'mcp.tools_discovered'; + agentId: string; serverName: string; hash: string; tools: readonly MCPToolDefinition[]; @@ -386,16 +403,16 @@ interface McpToolsDiscoveredPayload { /** * states: permissionRules * owner: src/agent/permissionRules/permissionRulesOps.ts - * payload type: PermissionApprovalResultRecord */ interface PermissionRecordApprovalResultPayload { _name: 'permission.record_approval_result'; + agentId: string; turnId: number; toolCallId: string; toolName: string; action: string; sessionApprovalRule?: string; - result: ApprovalResponse; + result: PermissionApprovalResultRecord['result']; } /** @@ -404,6 +421,7 @@ interface PermissionRecordApprovalResultPayload { */ interface PermissionSetModePayload { _name: 'permission.set_mode'; + agentId: string; /** PermissionMode */ mode: 'manual' | 'yolo' | 'auto'; } @@ -414,6 +432,7 @@ interface PermissionSetModePayload { */ interface PlanModeCancelPayload { _name: 'plan_mode.cancel'; + agentId: string; id?: string; } @@ -423,6 +442,7 @@ interface PlanModeCancelPayload { */ interface PlanModeEnterPayload { _name: 'plan_mode.enter'; + agentId: string; id: string; } @@ -432,6 +452,7 @@ interface PlanModeEnterPayload { */ interface PlanModeExitPayload { _name: 'plan_mode.exit'; + agentId: string; id?: string; } @@ -441,6 +462,7 @@ interface PlanModeExitPayload { */ interface PlanRevisionPayload { _name: 'plan.revision'; + agentId: string; id: string; version: number; path: string; @@ -454,6 +476,7 @@ interface PlanRevisionPayload { */ interface PluginSessionStartPayload { _name: 'plugin.session_start'; + agentId: string; content: string | null; } @@ -463,6 +486,7 @@ interface PluginSessionStartPayload { */ interface ProfileBindPayload { _name: 'profile.bind'; + agentId: string; modelAlias?: string; profileName?: string; /** ThinkingEffort */ @@ -486,6 +510,7 @@ interface ProfileBindPayload { */ interface PromptAcceptedPayload { _name: 'prompt.accepted'; + agentId: string; promptId: string; } @@ -495,6 +520,7 @@ interface PromptAcceptedPayload { */ interface RuntimeSetBindingPayload { _name: 'runtime.set_binding'; + agentId: string; workspaceId: string; runtimeId: string; } @@ -523,6 +549,7 @@ interface StaleGuardRecordedPayload { */ interface SwarmModeEnterPayload { _name: 'swarm_mode.enter'; + agentId: string; /** SwarmModeTrigger */ trigger: 'manual' | 'task' | 'tool'; } @@ -533,6 +560,7 @@ interface SwarmModeEnterPayload { */ interface SwarmModeExitPayload { _name: 'swarm_mode.exit'; + agentId: string; } /** @@ -541,6 +569,7 @@ interface SwarmModeExitPayload { */ interface TaskStartedPayload { _name: 'task.started'; + agentId: string; /** AgentTaskInfo */ info: AgentTaskInfoByKind[AgentTaskKind]; } @@ -551,6 +580,7 @@ interface TaskStartedPayload { */ interface TaskTerminatedPayload { _name: 'task.terminated'; + agentId: string; /** AgentTaskInfo */ info: AgentTaskInfoByKind[AgentTaskKind]; outputTail?: string; @@ -562,46 +592,51 @@ interface TaskTerminatedPayload { */ interface TaskWaitDeliveredPayload { _name: 'task.waitDelivered'; + agentId: string; keys: string[]; } /** - * states: tokenCounting + * states: (none) * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingMeasuredPayload { _name: 'token_counting.measured'; + agentId: string; length: number; tokens: number; } /** - * states: tokenCounting + * states: (none) * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingRebasedPayload { _name: 'token_counting.rebased'; + agentId: string; length: number; tokens: number; measured: boolean; } /** - * states: tokenCounting + * states: (none) * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingTruncatedPayload { _name: 'token_counting.truncated'; + agentId: string; length: number; tokens: number; } /** - * states: tokenCounting + * states: (none) * owner: src/agent/tokenCounting/tokenCountingOps.ts */ interface TokenCountingTurnRecordedPayload { _name: 'token_counting.turn_recorded'; + agentId: string; length: number; tokens: number; turnId: number; @@ -610,14 +645,14 @@ interface TokenCountingTurnRecordedPayload { /** * states: userTool * owner: src/agent/userTool/userToolOps.ts - * payload type: UserToolRegistration */ interface ToolsRegisterUserToolPayload { _name: 'tools.register_user_tool'; + agentId: string; name: string; description: string; - parameters: Record; - disclosure?: 'inline' | 'deferred'; + parameters: UserToolRegistration['parameters']; + disclosure?: UserToolRegistration['disclosure']; } /** @@ -626,6 +661,7 @@ interface ToolsRegisterUserToolPayload { */ interface ToolsResetActiveToolsPayload { _name: 'tools.reset_active_tools'; + agentId: string; } /** @@ -634,6 +670,7 @@ interface ToolsResetActiveToolsPayload { */ interface ToolsSetActiveToolsPayload { _name: 'tools.set_active_tools'; + agentId: string; names: string[]; } @@ -643,15 +680,17 @@ interface ToolsSetActiveToolsPayload { */ interface ToolsUnregisterUserToolPayload { _name: 'tools.unregister_user_tool'; + agentId: string; name: string; } /** - * states: todo + * states: (none) * owner: src/session/todo/todoOps.ts */ interface ToolsUpdateStorePayload { _name: 'tools.update_store'; + agentId: string; key: string; value: any; } @@ -662,6 +701,7 @@ interface ToolsUpdateStorePayload { */ interface TowerModeEnterPayload { _name: 'tower_mode.enter'; + agentId: string; } /** @@ -670,6 +710,7 @@ interface TowerModeEnterPayload { */ interface TowerModeExitPayload { _name: 'tower_mode.exit'; + agentId: string; } /** @@ -678,6 +719,7 @@ interface TowerModeExitPayload { */ interface TurnCancelPayload { _name: 'turn.cancel'; + agentId: string; turnId?: number; target?: 'active' | 'queued'; reason?: 'user_cancelled' | 'aborted'; @@ -689,6 +731,7 @@ interface TurnCancelPayload { */ interface TurnEndedPayload { _name: 'turn.ended'; + agentId: string; turnId: number; reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; /** KimiErrorPayload */ @@ -744,6 +787,7 @@ interface TurnEndedPayload { */ interface TurnPromptPayload { _name: 'turn.prompt'; + agentId: string; input: readonly ContentPart[]; /** PromptOrigin */ origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; @@ -755,17 +799,19 @@ interface TurnPromptPayload { */ interface TurnSteerPayload { _name: 'turn.steer'; + agentId: string; input: readonly ContentPart[]; /** PromptOrigin */ origin: 'user' | 'skill_activation' | 'plugin_command' | 'injection' | 'shell_command' | 'compaction_summary' | 'system_trigger' | 'task' | 'cron_job' | 'cron_missed' | 'hook_result' | 'retry'; } /** - * states: usage + * states: (none) * owner: src/agent/usage/usageOps.ts */ interface UsageRecordPayload { _name: 'usage.record'; + agentId: string; model: string; /** TokenUsage */ usage: { diff --git a/packages/agent-core-v2/scripts/gen-wire-manifest.mts b/packages/agent-core-v2/scripts/gen-wire-manifest.mts index 1c65088e493..a51b0a7c11b 100644 --- a/packages/agent-core-v2/scripts/gen-wire-manifest.mts +++ b/packages/agent-core-v2/scripts/gen-wire-manifest.mts @@ -29,7 +29,7 @@ function walk(dir: string, out: string[] = []): string[] { const TYPE_DECL_RE = /static\s+override\s+readonly\s+type\s*=\s*'([^']+)'/g; const DURABLE_DECL_RE = /static\s+override\s+readonly\s+durable\s*=\s*true/; -const CLASS_DECL_RE = /class\s+(\w+)\s+extends\s+Event2/g; +const CLASS_DECL_RE = /class\s+(\w+)\s+extends\s+(?:AgentEvent2|Event2)/g; function scanEventDeclarations(): { owners: Map; diff --git a/packages/agent-core-v2/src/_base/di/collection.ts b/packages/agent-core-v2/src/_base/di/collection.ts index ef7ba369903..bfc23561b43 100644 --- a/packages/agent-core-v2/src/_base/di/collection.ts +++ b/packages/agent-core-v2/src/_base/di/collection.ts @@ -12,8 +12,29 @@ export interface CollectionToken { toString(): string; } +export interface DefinitionToken extends CollectionToken { + readonly __definition?: T; +} + +export interface DefinitionRecord { + readonly definition: T; + readonly owner: string; + readonly generation: number; +} + +export interface DefinitionChange { + readonly current: DefinitionRecord | undefined; + readonly previous: DefinitionRecord | undefined; +} + +export interface DefinitionView { + readonly current: DefinitionRecord | undefined; + readonly onDidChangeDefinition: Event>; +} + const _collectionTokens = new Map>(); const _collectionTokenSet = new WeakSet(); +const _definitionTokenSet = new WeakSet(); const _collectionValidators = new WeakMap< object, (value: unknown, existing: readonly unknown[]) => void @@ -53,10 +74,24 @@ export function collection( return token; } +export function definition(name: string): DefinitionToken { + const token = collection(name, { + validate: (_value, existing) => { + if (existing.length > 0) throw new Error(`Definition ${name} already has an active provider`); + }, + }) as DefinitionToken; + _definitionTokenSet.add(token); + return token; +} + export function isCollectionToken(thing: unknown): thing is CollectionToken { return typeof thing === 'function' && _collectionTokenSet.has(thing); } +export function isDefinitionToken(thing: unknown): thing is DefinitionToken { + return typeof thing === 'function' && _definitionTokenSet.has(thing); +} + export interface CollectionRecord { readonly value: T; readonly providerName: string; @@ -188,6 +223,19 @@ export class CollectionStore { return out; } + definitionFor(token: CollectionToken, consumer: object): DefinitionRecord | undefined { + const record = this.storedRecordsFor( + token as CollectionToken, + consumer, + )[0]; + if (record === undefined) return undefined; + return { + definition: record.value as T, + owner: `${record.providerName}@${record.scopePath}`, + generation: record.id, + }; + } + private _isRelated(consumer: object, provider: object): boolean { for (let c: object | undefined = consumer; c !== undefined; c = this._parentOf(c)) { if (c === provider) return true; @@ -199,9 +247,12 @@ export class CollectionStore { } } -export class CollectionViewImpl implements CollectionView { +export class CollectionViewImpl implements CollectionView, DefinitionView { private readonly _onDidChange = new Emitter>(); + private readonly _onDidChangeDefinition = new Emitter>(); readonly onDidChange: Event> = this._onDidChange.event; + readonly onDidChangeDefinition: Event> = + this._onDidChangeDefinition.event; constructor( private readonly _store: CollectionStore, @@ -217,17 +268,35 @@ export class CollectionViewImpl implements CollectionView { return this.records.map((record) => record.value); } + get current(): DefinitionRecord | undefined { + return this._store.definitionFor(this.token, this.consumer); + } + _fireDelta(kind: 'added' | 'removed', records: readonly StoredRecord[]): void { + const previous = kind === 'removed' ? this.definitionRecord(records[0]) : undefined; const values = records.map((record) => record.value as T); this._onDidChange.fire( kind === 'added' ? { added: values, removed: [] } : { added: [], removed: values }, ); + if (isDefinitionToken(this.token)) { + this._onDidChangeDefinition.fire({ current: this.current, previous }); + } } dispose(): void { this._store.dropView(this as unknown as CollectionViewImpl); this._onDidChange.dispose(); + this._onDidChangeDefinition.dispose(); + } + + private definitionRecord(record: StoredRecord | undefined): DefinitionRecord | undefined { + if (record === undefined) return undefined; + return { + definition: record.value as T, + owner: `${record.providerName}@${record.scopePath}`, + generation: record.id, + }; } } diff --git a/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts b/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts new file mode 100644 index 00000000000..14d79adf6b3 --- /dev/null +++ b/packages/agent-core-v2/src/_base/lifecycle/keyedResource.ts @@ -0,0 +1,150 @@ +export interface KeyedResourceGeneration { + readonly owner: string; + readonly generation: string | number; +} + +export interface KeyedResource { + dispose(): void | Promise; + abort?(reason?: unknown): void; +} + +export interface KeyedResourceLease { + readonly resource: Resource; + release(): void; +} + +interface ResourceEntry { + promise: Promise; + resource?: Resource; + leases: number; + draining: boolean; + abortOnDrain: boolean; + aborted: boolean; + disposed: boolean; + drainPromise?: Promise; + releaseDrain?: () => void; +} + +export class KeyedResourceLeasePool { + private readonly entries = new Map>(); + private withdrawn = false; + private withdrawal?: Promise; + + constructor( + readonly identity: KeyedResourceGeneration, + private readonly create: (key: Key) => Resource | Promise, + ) {} + + acquire(key: Key): Promise> { + if (this.withdrawn) return Promise.reject(this.unavailable()); + let entry = this.entries.get(key); + if (entry === undefined) { + entry = this.createEntry(key); + this.entries.set(key, entry); + } + if (entry.draining) return Promise.reject(this.unavailable()); + entry.leases += 1; + return entry.promise.then( + (resource) => { + let active = true; + return { + resource, + release: () => { + if (!active) return; + active = false; + entry.leases -= 1; + if (entry.leases === 0) entry.releaseDrain?.(); + }, + }; + }, + (error: unknown) => { + entry.leases -= 1; + if (entry.leases === 0) entry.releaseDrain?.(); + throw error; + }, + ); + } + + has(key: Key): boolean { + return this.entries.has(key); + } + + disposeKey(key: Key, reason?: unknown, abort = false): Promise { + const entry = this.entries.get(key); + if (entry === undefined) return Promise.resolve(); + this.entries.delete(key); + return this.drain(entry, reason, abort); + } + + withdraw(reason?: unknown): Promise { + if (this.withdrawal !== undefined) return this.withdrawal; + this.withdrawn = true; + const entries = [...this.entries.values()]; + this.entries.clear(); + this.withdrawal = Promise.all(entries.map((entry) => this.drain(entry, reason, false))).then( + () => undefined, + ); + return this.withdrawal; + } + + private createEntry(key: Key): ResourceEntry { + const entry: ResourceEntry = { + promise: undefined as unknown as Promise, + leases: 0, + draining: false, + abortOnDrain: false, + aborted: false, + disposed: false, + }; + entry.promise = Promise.resolve() + .then(() => this.create(key)) + .then( + (resource) => { + entry.resource = resource; + if (entry.abortOnDrain) this.abort(entry); + return resource; + }, + (error: unknown) => { + if (this.entries.get(key) === entry) this.entries.delete(key); + throw error; + }, + ); + return entry; + } + + private drain(entry: ResourceEntry, reason?: unknown, abort = false): Promise { + entry.abortOnDrain ||= abort; + entry.drainPromise ??= (async () => { + entry.draining = true; + try { + await entry.promise; + } catch { + return; + } + if (entry.abortOnDrain) this.abort(entry, reason); + if (entry.leases > 0) { + await new Promise((resolve) => { + entry.releaseDrain = resolve; + }); + } + if (entry.disposed) return; + entry.disposed = true; + await entry.resource!.dispose(); + })(); + return entry.drainPromise; + } + + private abort(entry: ResourceEntry, reason?: unknown): void { + if (entry.aborted || entry.resource?.abort === undefined) return; + entry.aborted = true; + try { + entry.resource.abort(reason); + } catch {} + } + + private unavailable(): Error { + return new Error( + `resource generation ${this.identity.owner}:${String(this.identity.generation)} is withdrawn`, + ); + } +} diff --git a/packages/agent-core-v2/src/agent/activityView/activityView.ts b/packages/agent-core-v2/src/agent/activityView/activityView.ts index 0e0ac912697..c05d56ba23f 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityView.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityView.ts @@ -2,7 +2,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { PromptOrigin } from '#/agent/contextMemory/types'; import type { TurnEndReason } from '#/agent/loop/turnEvents'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; export type TurnPhase = 'running' | 'streaming' | 'tool_call' | 'retrying'; @@ -72,8 +72,10 @@ export interface IAgentActivityView { export const IAgentActivityView: ServiceIdentifier = createDecorator('agentActivityView'); -export class AgentActivityUpdated extends Event2 { +export class AgentActivityUpdated extends AgentEvent2 { static override readonly type = 'agent.activity.updated'; static override readonly observable = true; } -export interface AgentActivityUpdated extends AgentActivityState {} +export interface AgentActivityUpdated extends AgentActivityState { + readonly agentId: string; +} diff --git a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts index 0ef3f99ae34..04b663ba51b 100644 --- a/packages/agent-core-v2/src/agent/activityView/activityViewService.ts +++ b/packages/agent-core-v2/src/agent/activityView/activityViewService.ts @@ -27,6 +27,7 @@ import { CompactionStarted, } from '#/agent/fullCompaction/compactionOps'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentTaskService } from '#/agent/task/task'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { USER_PROMPT_ORIGIN } from '#/agent/contextMemory/types'; @@ -81,6 +82,7 @@ export class AgentActivityView extends Disposable implements IAgentActivityView @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, @IAgentStateService private readonly states: IAgentStateService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, ) { super(); this.states.contributeState(activityViewLifecycleKey); @@ -366,7 +368,9 @@ export class AgentActivityView extends Disposable implements IAgentActivityView }; if (activityEqual(this.current, next)) return; this.current = next; - void this.dispatcher.dispatch(new AgentActivityUpdated(next)); + void this.dispatcher.dispatch( + new AgentActivityUpdated({ ...next, agentId: this.scopeContext.agentId }), + ); } } diff --git a/packages/agent-core-v2/src/agent/agentContext/agentContext.ts b/packages/agent-core-v2/src/agent/agentContext/agentContext.ts new file mode 100644 index 00000000000..4c667450f8b --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentContext/agentContext.ts @@ -0,0 +1,7 @@ +import type { AgentSpace } from './agentSpace'; + +export interface AgentContext { + readonly agentId: string; + readonly generation: number; + readonly space: AgentSpace; +} diff --git a/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts new file mode 100644 index 00000000000..09ec5e9c297 --- /dev/null +++ b/packages/agent-core-v2/src/agent/agentContext/agentSpace.ts @@ -0,0 +1,185 @@ +import { BugIndicatingError } from '#/_base/errors/errors'; +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { StateKey } from '#/_base/state/stateRegistry'; +import type { Event2 } from '#/app/event/event2'; +import { + type AgentModel, + type AgentModelBridge, + type AgentModelDefinition, +} from '#/state/agentModel'; + +import type { AgentContext } from './agentContext'; + +export type AgentModelInstanceOf = D extends AgentModelDefinition ? M : never; + +/** + * Per-agent store of materialized domain Model instances, minted by the + * agent lifecycle together with the `AgentContext`. `use` runs `run` against + * the definition's instance under a lease: synchronous when `run` is + * synchronous, otherwise the lease extends until the returned promise + * settles. Stale (disposed) spaces reject every call; contexts not issued by + * the lifecycle carry no space at all. + */ +export interface AgentSpace { + use, R>( + definition: D, + run: (model: AgentModelInstanceOf) => R, + ): R; +} + +export interface AgentSpaceHost { + isActiveModelDefinition(definition: AgentModelDefinition): boolean; + registerModel(definition: AgentModelDefinition, model: AgentModel): void; + dispatchModelEvent(event: Event2): Promise; + readLegacyState(key: StateKey): unknown; +} + +interface ModelEntry { + readonly definition: AgentModelDefinition; + readonly model: AgentModel; + leases: number; + retired: boolean; + disposed: boolean; +} + +export class AgentSpaceImpl implements AgentSpace { + private readonly instances = new Map, ModelEntry>(); + private host: AgentSpaceHost | undefined; + private context: AgentContext | undefined; + private dead = false; + + constructor(private readonly agentId: string) {} + + _bindContext(context: AgentContext): void { + this.context = context; + } + + _attachHost(host: AgentSpaceHost): void { + this.host = host; + } + + _detachHost(host: AgentSpaceHost): void { + if (this.host === host) this.host = undefined; + } + + use, R>( + definition: D, + run: (model: AgentModelInstanceOf) => R, + ): R { + const entry = this.ensureModel(definition); + entry.leases += 1; + let result: R; + try { + result = run(entry.model as AgentModelInstanceOf); + } catch (error) { + this.release(entry); + throw error; + } + if (result instanceof Promise) { + return result.finally(() => { + this.release(entry); + }) as R; + } + this.release(entry); + return result; + } + + ensureModel(definition: AgentModelDefinition): ModelEntry { + const existing = this.instances.get(definition); + if (existing !== undefined) return existing; + if (this.dead) { + throw new Error(`Agent ${this.agentId} space is disposed`); + } + const host = this.host; + if (host === undefined) { + throw new BugIndicatingError(`Agent ${this.agentId} space has no model host`); + } + if (!host.isActiveModelDefinition(definition)) { + throw new Error(`Model definition '${definition.id}' is unavailable`); + } + const context = this.context; + if (context === undefined) { + throw new BugIndicatingError(`Agent ${this.agentId} space is not bound to a context`); + } + const bridge: AgentModelBridge = { + dispatch: (event) => host.dispatchModelEvent(event), + readLegacy: (key) => host.readLegacyState(key), + initialState: () => Object.freeze(definition.state.initial()), + }; + const model = new definition.model({ agent: context, bridge }); + model._seal(); + validateApplierCoverage(definition, model); + const entry: ModelEntry = { definition, model, leases: 0, retired: false, disposed: false }; + this.instances.set(definition, entry); + host.registerModel(definition, model); + return entry; + } + + retireModel(definition: AgentModelDefinition): void { + const entry = this.instances.get(definition); + if (entry === undefined) return; + this.instances.delete(definition); + entry.retired = true; + if (entry.leases === 0) this.disposeEntry(entry); + } + + _kill(): void { + if (this.dead) return; + this.dead = true; + const entries = [...this.instances.values()]; + this.instances.clear(); + for (const entry of entries) { + entry.retired = true; + if (entry.leases === 0) this.disposeEntry(entry); + } + } + + private release(entry: ModelEntry): void { + entry.leases -= 1; + if (entry.leases === 0 && entry.retired) this.disposeEntry(entry); + } + + private disposeEntry(entry: ModelEntry): void { + if (entry.disposed) return; + entry.disposed = true; + try { + const result = entry.model.dispose(); + if (result instanceof Promise) { + result.catch((error: unknown) => onUnexpectedError(error)); + } + } catch (error) { + onUnexpectedError(error); + } + } +} + +function validateApplierCoverage( + definition: AgentModelDefinition, + model: AgentModel, +): void { + const registered = model._appliersTable(); + for (const cls of definition.events) { + if (!registered.has(cls)) { + throw new BugIndicatingError( + `Agent model '${definition.id}' does not apply declared event '${cls.type}'`, + ); + } + } + for (const cls of registered.keys()) { + if (!definition.events.includes(cls)) { + throw new BugIndicatingError( + `Agent model '${definition.id}' applies undeclared event '${cls.type}'`, + ); + } + } +} + +export function agentSpaceOf(agent: AgentContext): AgentSpace { + const space = (agent as { readonly space?: AgentSpace }).space; + if (space === undefined) { + throw new Error( + `Agent ${agent.agentId}:${String(agent.generation)} is not a lifecycle-issued context`, + ); + } + return space; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts index a41095190cc..2021c8bd766 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextEvents.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -9,37 +9,53 @@ import type { ContextMessage } from './types'; const contextMessageSchema = z.custom(); const loopRecordedEventSchema = z.custom(); -const contextAppendMessageSchema = z.object({ message: contextMessageSchema }); +const contextAppendMessageSchema = z.object({ + agentId: z.string(), + message: contextMessageSchema, +}); -export class ContextAppendMessage extends Event2> { +export class ContextAppendMessage extends AgentEvent2< + z.infer +> { static override readonly type = 'context.append_message'; static override readonly durable = true; static override readonly schema = contextAppendMessageSchema; } -export interface ContextAppendMessage extends z.infer {} +export interface ContextAppendMessage { + readonly agentId: string; + readonly message: ContextMessage; +} -const contextAppendLoopEventSchema = z.object({ event: loopRecordedEventSchema }); +const contextAppendLoopEventSchema = z.object({ + agentId: z.string(), + event: loopRecordedEventSchema, +}); -export class ContextAppendLoopEvent extends Event2< +export class ContextAppendLoopEvent extends AgentEvent2< z.infer > { static override readonly type = 'context.append_loop_event'; static override readonly durable = true; static override readonly schema = contextAppendLoopEventSchema; } -export interface ContextAppendLoopEvent - extends z.infer {} +export interface ContextAppendLoopEvent { + readonly agentId: string; + readonly event: LoopRecordedEvent; +} -const contextClearSchema = z.object({}); +const contextClearSchema = z.object({ agentId: z.string() }); -export class ContextClear extends Event2> { +export class ContextClear extends AgentEvent2> { static override readonly type = 'context.clear'; static override readonly durable = true; static override readonly schema = contextClearSchema; } -export interface ContextClear extends z.infer {} +export interface ContextClear { + readonly agentId: string; +} const contextCompactionBaseShape = { + agentId: z.string(), tokensBefore: z.number().optional(), tokensAfter: z.number().optional(), summaryOutputTokens: z.number().optional(), @@ -72,31 +88,36 @@ const contextApplyCompactionSchema = z.union([ export type ContextApplyCompactionPayload = z.infer; -export class ContextApplyCompaction extends Event2 { +export class ContextApplyCompaction extends AgentEvent2 { static override readonly type = 'context.apply_compaction'; static override readonly durable = true; static override readonly schema = contextApplyCompactionSchema; } const contextUndoSchema = z.object({ + agentId: z.string(), count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), }); -export class ContextUndo extends Event2> { +export class ContextUndo extends AgentEvent2> { static override readonly type = 'context.undo'; static override readonly durable = true; static override readonly schema = contextUndoSchema; } -export interface ContextUndo extends z.infer {} +export interface ContextUndo { + readonly agentId: string; + readonly count: number; +} export interface ContextSplicedPayload { + readonly agentId: string; start: number; deleteCount: number; messages: readonly ContextMessage[]; tokens?: number; } -export class ContextSpliced extends Event2 { +export class ContextSpliced extends AgentEvent2 { static override readonly type = 'context.spliced'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 6f39770b3fa..a2ccddc31b2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -1,13 +1,9 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { - TokenCountingRebased, - TokenCountingTruncated, - tokenCountingKey, -} from '#/agent/tokenCounting/tokenCountingOps'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { @@ -39,7 +35,8 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte constructor( @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, @IAgentStateService private readonly agentState: IAgentStateService, ) { super(); @@ -62,13 +59,17 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte if (messages.length === 0) return; const start = this.get().length; for (const message of messages) { - void this.dispatcher.dispatch(new ContextAppendMessage({ message })); + void this.dispatcher.dispatch( + new ContextAppendMessage({ agentId: this.scopeContext.agentId, message }), + ); } this.publishSplice({ start, deleteCount: 0, messages: [...messages] }); } appendLoopEvent(event: LoopRecordedEvent): void { - void this.dispatcher.dispatch(new ContextAppendLoopEvent({ event })); + void this.dispatcher.dispatch( + new ContextAppendLoopEvent({ agentId: this.scopeContext.agentId, event }), + ); } publishTrailingRemoval(previous: readonly ContextMessage[]): boolean { @@ -89,10 +90,12 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte clear(): void { const deleteCount = this.get().length; if (deleteCount === 0) return; - void this.dispatcher.dispatch(new ContextClear({})); - void this.dispatcher.dispatch( - new TokenCountingRebased({ length: 0, tokens: 0, measured: true }), - ); + void this.dispatcher.dispatch(new ContextClear({ agentId: this.scopeContext.agentId })); + this.tokenCounting.rebase(this.scopeContext.agentContext, { + length: 0, + tokens: 0, + measured: true, + }); this.publishSplice({ start: 0, deleteCount, messages: [] }); } @@ -100,7 +103,9 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte const history = this.get(); const cut = computeUndoCut(history, count); if (isFullyUndoable(cut, count)) { - void this.dispatcher.dispatch(new ContextUndo({ count })); + void this.dispatcher.dispatch( + new ContextUndo({ agentId: this.scopeContext.agentId, count }), + ); this.dispatchCutEvents(cut.cutIndex); this.publishSplice({ start: cut.cutIndex, @@ -116,6 +121,7 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte const result = buildContextCompactionShape(history, input, this.tokenEstimateFns); void this.dispatcher.dispatch( new ContextApplyCompaction({ + agentId: this.scopeContext.agentId, summary: result.summary, contextSummary: result.contextSummary, compactedCount: result.compactedCount, @@ -127,13 +133,11 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte droppedCount: result.droppedCount, }), ); - void this.dispatcher.dispatch( - new TokenCountingRebased({ - length: result.messages.length, - tokens: result.tokensAfter, - measured: false, - }), - ); + this.tokenCounting.rebase(this.scopeContext.agentContext, { + length: result.messages.length, + tokens: result.tokensAfter, + measured: false, + }); this.publishSplice({ start: 0, deleteCount: history.length, @@ -145,19 +149,14 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte return publicResult; } - private publishSplice(input: ContextSplicedPayload): void { - void this.dispatcher.dispatch(new ContextSpliced(input)); + private publishSplice(input: Omit): void { + void this.dispatcher.dispatch( + new ContextSpliced({ agentId: this.scopeContext.agentId, ...input }), + ); } private dispatchCutEvents(cutIndex: number): void { - const model = this.agentState.get(tokenCountingKey); - if (!model.anchors.some((anchor) => anchor.length > cutIndex)) return; - void this.dispatcher.dispatch( - new TokenCountingTruncated({ - length: cutIndex, - tokens: this.tokenCounting.get(0, cutIndex).size, - }), - ); + this.tokenCounting.recordTruncation(this.scopeContext.agentContext, cutIndex); } } diff --git a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts index 171b77dc3cf..c5816594515 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/compactionOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { CompactionBeginData, CompactionResult, CompactionSource } from './types'; @@ -12,64 +12,86 @@ export interface CompactionState { readonly phase: CompactionPhase; } -const fullCompactionBeginSchema = z.custom(); +const fullCompactionBeginSchema = z.object({ + agentId: z.string(), + instruction: z.string().optional(), + source: z.custom(), +}); -export class FullCompactionBegin extends Event2> { +export class FullCompactionBegin extends AgentEvent2< + z.infer +> { static override readonly type = 'full_compaction.begin'; static override readonly durable = true; static override readonly schema = fullCompactionBeginSchema; } -export interface FullCompactionBegin extends z.infer {} +export interface FullCompactionBegin extends CompactionBeginData { + readonly agentId: string; +} -const fullCompactionCancelSchema = z.object({}); +const fullCompactionCancelSchema = z.object({ agentId: z.string() }); -export class FullCompactionCancel extends Event2> { +export class FullCompactionCancel extends AgentEvent2< + z.infer +> { static override readonly type = 'full_compaction.cancel'; static override readonly durable = true; static override readonly schema = fullCompactionCancelSchema; } -export interface FullCompactionCancel extends z.infer {} +export interface FullCompactionCancel { + readonly agentId: string; +} -const fullCompactionCompleteSchema = z.object({}); +const fullCompactionCompleteSchema = z.object({ agentId: z.string() }); -export class FullCompactionComplete extends Event2> { +export class FullCompactionComplete extends AgentEvent2< + z.infer +> { static override readonly type = 'full_compaction.complete'; static override readonly durable = true; static override readonly schema = fullCompactionCompleteSchema; } -export interface FullCompactionComplete extends z.infer {} +export interface FullCompactionComplete { + readonly agentId: string; +} export interface CompactionStartedPayload { + readonly agentId: string; readonly trigger: CompactionSource; readonly instruction?: string; } -export class CompactionStarted extends Event2 { +export class CompactionStarted extends AgentEvent2 { static override readonly type = 'compaction.started'; static override readonly observable = true; } export interface CompactionStarted extends CompactionStartedPayload {} export interface CompactionBlockedPayload { + readonly agentId: string; readonly turnId?: number; } -export class CompactionBlocked extends Event2 { +export class CompactionBlocked extends AgentEvent2 { static override readonly type = 'compaction.blocked'; static override readonly observable = true; } export interface CompactionBlocked extends CompactionBlockedPayload {} -export class CompactionCancelled extends Event2> { +export class CompactionCancelled extends AgentEvent2 { static override readonly type = 'compaction.cancelled'; static override readonly observable = true; } +export interface CompactionCancelled { + readonly agentId: string; +} export interface CompactionCompletedPayload { + readonly agentId: string; readonly result: CompactionResult; } -export class CompactionCompleted extends Event2 { +export class CompactionCompleted extends AgentEvent2 { static override readonly type = 'compaction.completed'; static override readonly observable = true; } @@ -83,7 +105,13 @@ export const fullCompactionKey = defineState( if (s.phase !== 'running') { s.phase = 'running'; } - ctx.emit(new CompactionStarted({ trigger: e.source, instruction: e.instruction })); + ctx.emit( + new CompactionStarted({ + agentId: e.agentId, + trigger: e.source, + instruction: e.instruction, + }), + ); }) .on(FullCompactionCancel, (s) => { if (s.phase !== 'idle') { diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 61c147ddaa3..4fa80358a07 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -9,7 +9,7 @@ import { estimateTokensForMessage } from "#/kosong/contract/tokens"; import { buildCompactionSummaryText, isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentLLMRequesterService, type AgentLLMRequestFinish } from '#/agent/llmRequester/llmRequester'; import type { LLMRequestTrace } from '#/kosong/contract/requestTrace'; import { retryBackoffDelays, sleepForRetry } from '#/_base/utils/retry'; @@ -18,12 +18,16 @@ import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded } from '#/agent/loop/turnOps'; import { isAbortError } from '#/_base/utils/abort'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; +import { + agentContextOfScope, + IAgentScopeContext, +} from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { stripDynamicToolContext } from '#/agent/toolSelect/dynamicTools'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { ISessionTodoService } from '#/session/todo/sessionTodo'; -import { renderTodoList, type TodoItem } from '#/session/todo/todoItem'; +import { renderTodoList } from '#/session/todo/todoItem'; import { APIContextOverflowError, APIEmptyResponseError, @@ -135,12 +139,13 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, @IAgentLLMRequesterService private readonly llmRequester: IAgentLLMRequesterService, @IAgentProfileService private readonly profile: IAgentProfileService, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, @ISessionTodoService private readonly todo: ISessionTodoService, + @IAgentScopeContext private readonly agent: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @IEventBus private readonly eventBus: IEventBus, @@ -336,7 +341,9 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom ); } try { - void this.dispatcher.dispatch(new FullCompactionBegin(data)); + void this.dispatcher.dispatch( + new FullCompactionBegin({ ...data, agentId: this.agent.agentId }), + ); const active = this.createActiveCompaction( data.source, @@ -426,25 +433,25 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom private cancelActive(active: ActiveCompaction): boolean { if (this._compacting !== active) return false; - void this.dispatcher.dispatch(new FullCompactionCancel({})); + void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); this._compacting = null; if (!active.abortController.signal.aborted) { active.abortController.abort(); } - void this.dispatcher.dispatch(new CompactionCancelled({})); + void this.dispatcher.dispatch(new CompactionCancelled({ agentId: this.agent.agentId })); return true; } private markCompleted(active: ActiveCompaction): boolean { if (this._compacting !== active) return false; - void this.dispatcher.dispatch(new FullCompactionComplete({})); + void this.dispatcher.dispatch(new FullCompactionComplete({ agentId: this.agent.agentId })); this._compacting = null; return true; } private normalizeAfterReplay(): void { if (this.states.get(fullCompactionKey).phase !== 'running') return; - void this.dispatcher.dispatch(new FullCompactionCancel({})); + void this.dispatcher.dispatch(new FullCompactionCancel({ agentId: this.agent.agentId })); } private resetForTurn(): void { @@ -529,7 +536,9 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (active === null) return; active.blockedByTurn = true; this.propagateBlockingAbort(active, signal); - void this.dispatcher.dispatch(new CompactionBlocked({ turnId })); + void this.dispatcher.dispatch( + new CompactionBlocked({ agentId: this.agent.agentId, turnId }), + ); try { await active.promise; } catch (error) { @@ -577,7 +586,9 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } const { contextSummary: _contextSummary, ...eventResult } = result; void _contextSummary; - void this.dispatcher.dispatch(new CompactionCompleted({ result: eventResult })); + void this.dispatcher.dispatch( + new CompactionCompleted({ agentId: this.agent.agentId, result: eventResult }), + ); return result; } catch (error) { if (active.abortController.signal.aborted || isAbortError(error)) { @@ -591,7 +602,9 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom if (blockedByTurn) { throw error; } - void this.dispatcher.dispatch(new AgentErrorEvent(toKimiErrorPayload(error))); + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...toKimiErrorPayload(error), agentId: this.agent.agentId }), + ); throw error; } finally { try { @@ -728,7 +741,7 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom throw compactionCancelledReason(active); } - const summary = this.postProcessSummary(attempt.summary); + const summary = await this.postProcessSummary(attempt.summary); const result = this.context.applyCompaction({ summary, contextSummary: buildCompactionSummaryText(summary), @@ -780,20 +793,16 @@ export class AgentFullCompactionService extends Service implements IAgentFullCom } } - private postProcessSummary(summary: string): string { - const todos = this.currentTodos(); + private async postProcessSummary(summary: string): Promise { + const todos = await this.todo.getTodos(agentContextOfScope(this.agent)); if (todos.length === 0) { return summary; } return `${summary.trim()}\n\n${renderTodoList(todos, '## TODO List')}`; } - private currentTodos(): readonly TodoItem[] { - return this.todo.getTodos(); - } - private tokenCountWithPending(): number { - return this.tokenCounting.get().size; + return this.tokenCounting.get(agentContextOfScope(this.agent)).size; } } diff --git a/packages/agent-core-v2/src/agent/goal/goalOps.ts b/packages/agent-core-v2/src/agent/goal/goalOps.ts index 434ca98be1b..67cee561e7a 100644 --- a/packages/agent-core-v2/src/agent/goal/goalOps.ts +++ b/packages/agent-core-v2/src/agent/goal/goalOps.ts @@ -1,10 +1,11 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { + GoalActor, GoalBudgetLimits, GoalChange, GoalSnapshot, @@ -40,6 +41,7 @@ const GoalBudgetLimitsSchema = z const goalCreateSchema = z .object({ + agentId: z.string(), goalId: z.string(), objective: z.string(), completionCriterion: z.string().optional(), @@ -50,15 +52,25 @@ const goalCreateSchema = z }) .strip(); -export class GoalCreate extends Event2> { +export class GoalCreate extends AgentEvent2> { static override readonly type = 'goal.create'; static override readonly durable = true; static override readonly schema = goalCreateSchema; } -export interface GoalCreate extends z.infer {} +export interface GoalCreate { + readonly agentId: string; + readonly goalId: string; + readonly objective: string; + readonly completionCriterion?: string; + readonly wallClockResumedAt?: number; + readonly status?: GoalStatus; + readonly actor?: GoalActor; + readonly budgetLimits?: GoalBudgetLimits; +} const goalUpdateSchema = z .object({ + agentId: z.string(), goalId: z.string().optional(), status: GoalStatusSchema.optional(), reason: z.string().optional(), @@ -71,37 +83,53 @@ const goalUpdateSchema = z }) .strip(); -export class GoalUpdate extends Event2> { +export class GoalUpdate extends AgentEvent2> { static override readonly type = 'goal.update'; static override readonly durable = true; static override readonly schema = goalUpdateSchema; } -export interface GoalUpdate extends z.infer {} +export interface GoalUpdate { + readonly agentId: string; + readonly goalId?: string; + readonly status?: GoalStatus; + readonly reason?: string; + readonly turnsUsed?: number; + readonly tokensUsed?: number; + readonly wallClockMs?: number; + readonly wallClockResumedAt?: number; + readonly budgetLimits?: GoalBudgetLimits; + readonly actor?: GoalActor; +} -const goalClearSchema = z.object({}); +const goalClearSchema = z.object({ agentId: z.string() }); -export class GoalClear extends Event2> { +export class GoalClear extends AgentEvent2> { static override readonly type = 'goal.clear'; static override readonly durable = true; static override readonly schema = goalClearSchema; } -export interface GoalClear extends z.infer {} +export interface GoalClear { + readonly agentId: string; +} -const goalForkedSchema = z.object({}); +const goalForkedSchema = z.object({ agentId: z.string() }); -export class GoalForked extends Event2> { +export class GoalForked extends AgentEvent2> { static override readonly type = 'forked'; static override readonly durable = true; static override readonly schema = goalForkedSchema; } -export interface GoalForked extends z.infer {} +export interface GoalForked { + readonly agentId: string; +} export interface GoalUpdatedPayload { + readonly agentId: string; snapshot: GoalSnapshot | null; change?: GoalChange; } -export class GoalUpdated extends Event2 { +export class GoalUpdated extends AgentEvent2 { static override readonly type = 'goal.updated'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 3d336f1c654..c849e6644e6 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -34,7 +34,8 @@ import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import type { BeforeToolExecuteEvent } from '#/agent/toolExecutor/toolHooks'; -import { IAgentUsageService, type UsageRecordedContext } from '#/agent/usage/usage'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; +import { type UsageRecordedContext } from '#/agent/usage/usage'; import type { GoalBudgetProperties } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IConfigService } from '#/app/config/config'; @@ -271,7 +272,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, - @IAgentUsageService usageService: IAgentUsageService, + @ISessionUsageService usageService: ISessionUsageService, @IConfigService private readonly config: IConfigService, @IFlagService private readonly flags: IFlagService, @IGoalDeadlineScheduler private readonly deadlineScheduler: IGoalDeadlineScheduler, @@ -315,7 +316,10 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { }), ); this._register( - usageService.onDidRecord((ctx) => this.handleUsageRecorded(ctx)), + usageService.onDidRecord((ctx) => { + if (ctx.agent !== this.agentContext.agentContext) return; + this.handleUsageRecorded(ctx); + }), ); this._register( loopService.hooks.onWillBeginStep.register('goal-count-turn', async (ctx, next) => { @@ -485,6 +489,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const wallClockResumedAt = Date.now(); void this.dispatcher.dispatch( new GoalCreate({ + agentId: this.agentContext.agentId, goalId: randomUUID(), objective, completionCriterion: normalizeCompletionCriterion(input.completionCriterion), @@ -587,7 +592,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.assertSupportedAgent(); const state = this.requireState(); const budgetLimits = { ...state.budgetLimits, ...input.budgetLimits }; - void this.dispatcher.dispatch(new GoalUpdate({ budgetLimits })); + void this.dispatcher.dispatch(new GoalUpdate({ agentId: this.agentContext.agentId, budgetLimits })); const next = this.requireState(); this.emitGoalUpdated(this.toSnapshot(next)); this.telemetry.track2('goal_budget_set', { @@ -648,7 +653,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { private dispatchCompletion(state: GoalState, reason: string | undefined, actor: GoalActor): void { const wallClockMs = this.settleWallClock(state); - void this.dispatcher.dispatch(new GoalUpdate({ status: 'complete', reason, wallClockMs, actor })); + void this.dispatcher.dispatch( + new GoalUpdate({ agentId: this.agentContext.agentId, status: 'complete', reason, wallClockMs, actor }), + ); } private emitCompletion( @@ -680,7 +687,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const state = this.goalState; if (state === null || state.status !== 'active' || !matchesGoal(state, goalId)) return null; const tokensUsed = state.tokensUsed + Math.max(0, tokenDelta); - void this.dispatcher.dispatch(new GoalUpdate({ tokensUsed })); + void this.dispatcher.dispatch(new GoalUpdate({ agentId: this.agentContext.agentId, tokensUsed })); const next = this.requireState(); return this.blockIfBudgetReached(next) ?? this.toSnapshot(next); } @@ -694,7 +701,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const state = this.goalState; if (state === null || state.status !== 'active' || !matchesGoal(state, goalId)) return null; const turnsUsed = state.turnsUsed + 1; - void this.dispatcher.dispatch(new GoalUpdate({ turnsUsed })); + void this.dispatcher.dispatch(new GoalUpdate({ agentId: this.agentContext.agentId, turnsUsed })); const next = this.requireState(); this.emitGoalUpdated(this.toSnapshot(next)); this.telemetry.track2('goal_continued', { turns_used: next.turnsUsed }); @@ -1001,6 +1008,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { const reason = 'Paused after agent resume'; void this.dispatcher.dispatch( new GoalUpdate({ + agentId: this.agentContext.agentId, status: 'paused', reason, wallClockMs: this.settleWallClock(state), @@ -1027,7 +1035,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.cancelPendingContinuation(opts.preserveLiveContinuation === true); this.wallClockDeadline.clear(); this.liveWallClockStartedAt = undefined; - void this.dispatcher.dispatch(new GoalClear({})); + void this.dispatcher.dispatch(new GoalClear({ agentId: this.agentContext.agentId })); if (opts.emit !== false) this.emitGoalUpdated(null); if (opts.track !== false) this.telemetry.track2('goal_cleared', { actor }); } @@ -1056,7 +1064,7 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { this.liveWallClockStartedAt = undefined; } void this.dispatcher.dispatch( - new GoalUpdate({ status, reason, wallClockMs, wallClockResumedAt, actor }), + new GoalUpdate({ agentId: this.agentContext.agentId, status, reason, wallClockMs, wallClockResumedAt, actor }), ); const next = this.requireState(); if (status === 'active') this.adoptStarterTurn(actor); @@ -1086,7 +1094,9 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { } private emitGoalUpdated(snapshot: GoalSnapshot | null, change?: GoalChange): void { - void this.dispatcher.dispatch(new GoalUpdated({ snapshot, change })); + void this.dispatcher.dispatch( + new GoalUpdated({ agentId: this.agentContext.agentId, snapshot, change }), + ); } private settleWallClock(state: GoalState): number { diff --git a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts index d686c90ff55..2ff8efcb7e8 100644 --- a/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts +++ b/packages/agent-core-v2/src/agent/interruptionReminder/interruptionReminderOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; @@ -9,18 +9,21 @@ export const INTERRUPTION_REMINDER_VARIANT = 'interruption'; export type InterruptionReminderState = null; const interruptionReminderRecordedSchema = z.object({ + agentId: z.string(), turnId: z.number().int().nonnegative(), }); -export class InterruptionReminderRecorded extends Event2< +export class InterruptionReminderRecorded extends AgentEvent2< z.infer > { static override readonly type = 'interruptionReminder.recorded'; static override readonly durable = true; static override readonly schema = interruptionReminderRecordedSchema; } -export interface InterruptionReminderRecorded - extends z.infer {} +export interface InterruptionReminderRecorded { + readonly agentId: string; + readonly turnId: number; +} export const interruptionReminderKey = defineState( 'interruptionReminder', diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts index fc451fcd218..0f60cc00944 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequestOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { ThinkingEffort } from '#/kosong/contract/provider'; import { defineState } from '#/state/state'; @@ -22,18 +22,24 @@ const llmToolEntrySchema = z.object({ }); const llmToolsSnapshotSchema = z.object({ + agentId: z.string(), hash: z.string(), tools: z.array(llmToolEntrySchema).readonly(), }); -export class LlmToolsSnapshot extends Event2> { +export class LlmToolsSnapshot extends AgentEvent2> { static override readonly type = 'llm.tools_snapshot'; static override readonly durable = true; static override readonly schema = llmToolsSnapshotSchema; } -export interface LlmToolsSnapshot extends z.infer {} +export interface LlmToolsSnapshot { + readonly agentId: string; + readonly hash: string; + readonly tools: readonly LlmRequestToolSchema[]; +} const llmRequestSchema = z.object({ + agentId: z.string(), kind: z.enum(['loop', 'compaction']), provider: z.string(), model: z.string(), @@ -57,12 +63,38 @@ const llmRequestSchema = z.object({ export type LlmRequestPayload = z.infer; -export class LlmRequest extends Event2 { +export class LlmRequest extends AgentEvent2 { static override readonly type = 'llm.request'; static override readonly durable = true; static override readonly schema = llmRequestSchema; } -export interface LlmRequest extends LlmRequestPayload {} +export interface LlmRequest { + readonly agentId: string; + readonly kind: 'loop' | 'compaction'; + readonly provider: string; + readonly model: string; + readonly modelAlias?: string; + readonly thinkingEffort?: ThinkingEffort; + readonly thinkingKeep?: string; + readonly temperature?: number; + readonly topP?: number; + readonly maxTokens?: number; + readonly betaApi?: boolean; + readonly toolSelect: boolean; + readonly systemPromptHash: string; + readonly systemPrompt?: string; + readonly toolsHash: string; + readonly messageCount: number; + readonly turnStep?: string; + readonly attempt?: string; + readonly projection?: + | 'strict' + | 'media-degraded' + | 'media-stripped' + | 'strict-media-degraded' + | 'strict-media-stripped'; + readonly droppedCount?: number; +} export const llmRequestTraceKey = defineState( 'llm.requestTrace', diff --git a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts index 050b65e7497..45dc08dbe62 100644 --- a/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts +++ b/packages/agent-core-v2/src/agent/llmRequester/llmRequesterService.ts @@ -8,13 +8,13 @@ import { type MediaStripSnapshot, type ProjectionPolicy, } from '#/agent/contextProjector/contextProjector'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentProfileService, type ProfileModelContext } from '#/agent/profile/profile'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentToolSelectService } from '#/agent/toolSelect/toolSelect'; import { IAgentMediaResolverService } from '#/agent/media/mediaResolver'; -import { IAgentUsageService } from '#/agent/usage/usage'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import { IConfigService } from '#/app/config/config'; import { APIRequestTooLargeError, @@ -45,6 +45,7 @@ import { THINKING_SECTION } from '#/app/kosongConfig/configSection'; import type { Protocol } from '#/kosong/protocol/protocol'; import type { ApiErrorEvent } from '#/app/telemetry/events'; import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { WarningIssued } from '#/agent/profile/profileOps'; @@ -142,18 +143,19 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentContextProjectorService private readonly projector: IAgentContextProjectorService, - @IAgentTokenCountingService private readonly tokenCounting: IAgentTokenCountingService, + @ISessionTokenCountingService private readonly tokenCounting: ISessionTokenCountingService, @IAgentToolRegistryService private readonly tools: IAgentToolRegistryService, @IAgentToolSelectService private readonly toolSelect: IAgentToolSelectService, @IAgentMediaResolverService private readonly mediaResolver: IAgentMediaResolverService, @IAgentProfileService private readonly profile: IAgentProfileService, - @IAgentUsageService private readonly usage: IAgentUsageService, + @ISessionUsageService private readonly usage: ISessionUsageService, @IConfigService private readonly config: IConfigService, @IModelService private readonly modelService: IModelService, @IModelCatalog private readonly modelCatalog: IModelCatalog, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, ) { this.states.contributeState(llmRequestTraceKey); @@ -282,7 +284,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } const statusCode = apiStatusCode(error); if (statusCode !== undefined) properties['status_code'] = statusCode; - const currentTurn = this.usage.status().currentTurn; + const currentTurn = this.usage.status(this.scopeContext.agentContext).currentTurn; if (currentTurn !== undefined) properties['input_tokens'] = inputTotal(currentTurn); this.telemetry.track2('api_error', properties); return traceId; @@ -409,9 +411,14 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { throw error; } - this.usage.record(request.modelAlias, usage ?? emptyUsage(), request.source); + void this.usage.record( + this.scopeContext.agentContext, + request.modelAlias, + usage ?? emptyUsage(), + request.source, + ); if (usage !== undefined) { - this.tokenCounting.measured(request.messages, [message], usage); + this.tokenCounting.measured(this.scopeContext.agentContext, request.messages, [message], usage); } this.logResponse(request.logFields, usage ?? emptyUsage(), timing); @@ -535,7 +542,9 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { } catch { } try { - void this.dispatcher.dispatch(new WarningIssued({ code, message })); + void this.dispatcher.dispatch( + new WarningIssued({ agentId: this.scopeContext.agentId, code, message }), + ); } catch { } } @@ -585,7 +594,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { capability: resolved.modelCapabilities, usedContextTokens: overrides.messages === undefined - ? this.tokenCounting.get().measured + ? this.tokenCounting.get(this.scopeContext.agentContext).measured : undefined, }); const requester = this.modelCatalog.getRequester(resolved.modelAlias); @@ -659,7 +668,9 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const tools = toolSignature(wireTools); const toolsHash = fingerprint(JSON.stringify(tools)); if (!this.states.get(llmRequestTraceKey).seenToolsHashes.includes(toolsHash)) { - void this.dispatcher.dispatch(new LlmToolsSnapshot({ hash: toolsHash, tools })); + void this.dispatcher.dispatch( + new LlmToolsSnapshot({ agentId: this.scopeContext.agentId, hash: toolsHash, tools }), + ); } const systemPromptHash = fingerprint(input.systemPrompt); @@ -668,6 +679,7 @@ export class AgentLLMRequesterService implements IAgentLLMRequesterService { const modelConfig = input.modelAlias === undefined ? undefined : this.modelService.get(input.modelAlias); const payload: LlmRequestPayload = { + agentId: this.scopeContext.agentId, kind: requestKindForRecord(fields), provider: input.protocol, model: input.modelName, diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 74cece5d521..87e8c7f6854 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -21,6 +21,7 @@ import { OrderedHookSlot } from '#/hooks'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { isVacuousContentPart } from '#/agent/contextMemory/vacuousContent'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import type { @@ -105,6 +106,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { @IAgentToolExecutorService private readonly toolExecutor: IAgentToolExecutorService, @IConfigService private readonly config: IConfigService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITelemetryService private readonly telemetry: ITelemetryService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, @IAgentStateService private readonly states: IAgentStateService, @@ -276,7 +278,12 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (job === undefined || (turnId !== undefined && job.turn.id !== turnId)) return false; if (job.controller.signal.aborted) return true; void this.dispatcher.dispatch( - new TurnCancel({ turnId: job.turn.id, target: 'active', reason: cancelReasonFor(cancellation) }), + new TurnCancel({ + agentId: this.scopeContext.agentId, + turnId: job.turn.id, + target: 'active', + reason: cancelReasonFor(cancellation), + }), ); job.controller.abort(cancellation); return true; @@ -287,7 +294,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (index < 0) return false; const [job] = this.pendingTurns.splice(index, 1); if (job === undefined || job.turn.state !== 'queued') return false; - void this.dispatcher.dispatch(new TurnCancel({ turnId, target: 'queued', reason: cancelReasonFor(cancellation) })); + void this.dispatcher.dispatch( + new TurnCancel({ + agentId: this.scopeContext.agentId, + turnId, + target: 'queued', + reason: cancelReasonFor(cancellation), + }), + ); for (const step of job.steps.values()) step.cancel(cancellation); job.controller.abort(cancellation); job.turn.state = 'cancelled'; @@ -447,11 +461,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private startTurn(job: TurnJob): void { const origin = job.seed.origin; - void this.dispatcher.dispatch(new TurnPrompt({ input: job.seed.input, origin })); + void this.dispatcher.dispatch( + new TurnPrompt({ agentId: this.scopeContext.agentId, input: job.seed.input, origin }), + ); job.turn.state = 'running'; this.activeTurnJob = job; void this.dispatcher.dispatch( new TurnStarted({ + agentId: this.scopeContext.agentId, turnId: job.turn.id, origin, prompt: isDisplayablePromptOrigin(origin) ? turnPromptText(job.seed.input, origin) : undefined, @@ -504,9 +521,20 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { result.type === 'completed' ? undefined : interruptReasonFor(result); const durationMs = Date.now() - startedAt; void this.dispatcher.dispatch( - new TurnEnded({ turnId: turn.id, reason: result.type, error, durationMs, interruptReason }), + new TurnEnded({ + agentId: this.scopeContext.agentId, + turnId: turn.id, + reason: result.type, + error, + durationMs, + interruptReason, + }), ); - if (error !== undefined) void this.dispatcher.dispatch(new AgentErrorEvent(error)); + if (error !== undefined) { + void this.dispatcher.dispatch( + new AgentErrorEvent({ ...error, agentId: this.scopeContext.agentId }), + ); + } if (interruptReason !== undefined) { const interrupted: TurnInterruptedEvent = { turn_id: turn.id, @@ -865,7 +893,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onStarted: ((step: number) => void) | undefined, ): () => void { signal.throwIfAborted(); - void this.dispatcher.dispatch(new TurnStepStarted({ turnId, step: currentStep, stepId: stepUuid })); + void this.dispatcher.dispatch( + new TurnStepStarted({ + agentId: this.scopeContext.agentId, + turnId, + step: currentStep, + stepId: stepUuid, + }), + ); this.context.appendLoopEvent({ type: 'step.begin', uuid: stepUuid, @@ -1041,6 +1076,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ): void { void this.dispatcher.dispatch( new TurnStepCompleted({ + agentId: this.scopeContext.agentId, turnId, step, stepId, @@ -1067,6 +1103,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (activeStep === undefined) return; void this.dispatcher.dispatch( new TurnStepInterrupted({ + agentId: this.scopeContext.agentId, turnId, step: activeStep, reason, @@ -1095,12 +1132,16 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { case 'text': onResponseEvent(); accumulate(part); - void this.dispatcher.dispatch(new AssistantDelta({ turnId, delta: part.text })); + 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({ turnId, delta: part.think })); + void this.dispatcher.dispatch( + new ThinkingDelta({ agentId: this.scopeContext.agentId, turnId, delta: part.think }), + ); return; case 'image_url': case 'audio_url': @@ -1112,6 +1153,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { 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, @@ -1127,6 +1169,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { onResponseEvent(); void this.dispatcher.dispatch( new ToolCallDelta({ + agentId: this.scopeContext.agentId, turnId, toolCallId: toolCall.id, name: toolCall.name, diff --git a/packages/agent-core-v2/src/agent/loop/turnEvents.ts b/packages/agent-core-v2/src/agent/loop/turnEvents.ts index cfe340d5253..3612f9c0e0a 100644 --- a/packages/agent-core-v2/src/agent/loop/turnEvents.ts +++ b/packages/agent-core-v2/src/agent/loop/turnEvents.ts @@ -1,7 +1,7 @@ /* 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 { PromptOrigin } from '#/agent/contextMemory/types'; import { parseDaemonFileUrl } from '#/agent/media/mediaRef'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } 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'; @@ -17,13 +17,14 @@ export type TurnInterruptReason = | 'blocked'; export interface TurnStartedPayload { + readonly agentId: string; readonly turnId: number; readonly origin: PromptOrigin; readonly prompt?: string; readonly promptAttachments?: readonly { kind: 'image' | 'video' | 'audio'; fileId: string }[]; } -export class TurnStarted extends Event2 { +export class TurnStarted extends AgentEvent2 { static override readonly type = 'turn.started'; static override readonly observable = true; } @@ -77,18 +78,20 @@ export function isDisplayablePromptOrigin(origin: PromptOrigin): boolean { } export interface TurnStepStartedPayload { + readonly agentId: string; readonly turnId: number; readonly step: number; readonly stepId?: string; } -export class TurnStepStarted extends Event2 { +export class TurnStepStarted extends AgentEvent2 { static override readonly type = 'turn.step.started'; static override readonly observable = true; } export interface TurnStepStarted extends TurnStepStartedPayload {} export interface TurnStepCompletedPayload { + readonly agentId: string; readonly turnId: number; readonly step: number; readonly stepId?: string; @@ -104,13 +107,14 @@ export interface TurnStepCompletedPayload { readonly rawFinishReason?: string; } -export class TurnStepCompleted extends Event2 { +export class TurnStepCompleted extends AgentEvent2 { static override readonly type = 'turn.step.completed'; static override readonly observable = true; } export interface TurnStepCompleted extends TurnStepCompletedPayload {} export interface TurnStepInterruptedPayload { + readonly agentId: string; readonly turnId: number; readonly step: number; readonly stepId?: string; @@ -118,42 +122,45 @@ export interface TurnStepInterruptedPayload { readonly message?: string; } -export class TurnStepInterrupted extends Event2 { +export class TurnStepInterrupted extends AgentEvent2 { static override readonly type = 'turn.step.interrupted'; static override readonly observable = true; } export interface TurnStepInterrupted extends TurnStepInterruptedPayload {} export interface AssistantDeltaPayload { + readonly agentId: string; readonly turnId: number; readonly delta: string; } -export class AssistantDelta extends Event2 { +export class AssistantDelta extends AgentEvent2 { static override readonly type = 'assistant.delta'; static override readonly observable = true; } export interface AssistantDelta extends AssistantDeltaPayload {} export interface ThinkingDeltaPayload { + readonly agentId: string; readonly turnId: number; readonly delta: string; } -export class ThinkingDelta extends Event2 { +export class ThinkingDelta extends AgentEvent2 { static override readonly type = 'thinking.delta'; static override readonly observable = true; } export interface ThinkingDelta extends ThinkingDeltaPayload {} export interface ToolCallDeltaPayload { + readonly agentId: string; readonly turnId: number; readonly toolCallId: string; readonly name?: string; readonly argumentsPart?: string; } -export class ToolCallDelta extends Event2 { +export class ToolCallDelta extends AgentEvent2 { static override readonly type = 'tool.call.delta'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index f27ab3f04f2..c209e9356bd 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import type { KimiErrorPayload } from '#/_base/errors/serialize'; import { ContextAppendLoopEvent } from '#/agent/contextMemory/contextEvents'; import type { PromptOrigin } from '#/agent/contextMemory/types'; -import { Event2, type SerializedEvent2 } from '#/app/event/event2'; +import { AgentEvent2, type SerializedEvent2 } from '#/app/event/event2'; import type { ContentPart } from '#/kosong/contract/message'; import { defineState } from '#/state/state'; @@ -21,42 +21,58 @@ export interface TurnModelState { } const turnInputShape = { + agentId: z.string(), input: z.custom(), origin: z.custom(), }; const turnPromptSchema = z.object(turnInputShape); -export class TurnPrompt extends Event2> { +export class TurnPrompt extends AgentEvent2> { static override readonly type = 'turn.prompt'; static override readonly durable = true; static override readonly schema = turnPromptSchema; } -export interface TurnPrompt extends z.infer {} +export interface TurnPrompt { + readonly agentId: string; + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; +} const turnSteerSchema = z.object(turnInputShape); -export class TurnSteer extends Event2> { +export class TurnSteer extends AgentEvent2> { static override readonly type = 'turn.steer'; static override readonly durable = true; static override readonly schema = turnSteerSchema; } -export interface TurnSteer extends z.infer {} +export interface TurnSteer { + readonly agentId: string; + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; +} const turnCancelSchema = z.object({ + agentId: z.string(), turnId: z.number().optional(), target: z.enum(['active', 'queued']).optional(), reason: z.enum(['user_cancelled', 'aborted']).optional(), }); -export class TurnCancel extends Event2> { +export class TurnCancel extends AgentEvent2> { static override readonly type = 'turn.cancel'; static override readonly durable = true; static override readonly schema = turnCancelSchema; } -export interface TurnCancel extends z.infer {} +export interface TurnCancel { + readonly agentId: string; + readonly turnId?: number; + readonly target?: 'active' | 'queued'; + readonly reason?: 'user_cancelled' | 'aborted'; +} const turnEndedSchema = z.object({ + agentId: z.string(), turnId: z.number(), reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), error: z.custom().optional(), @@ -64,6 +80,7 @@ const turnEndedSchema = z.object({ }); export interface TurnEndedPayload { + readonly agentId: string; readonly turnId: number; readonly reason: 'completed' | 'cancelled' | 'failed' | 'blocked'; readonly error?: KimiErrorPayload; @@ -71,7 +88,7 @@ export interface TurnEndedPayload { readonly interruptReason?: TurnInterruptReason; } -export class TurnEnded extends Event2 { +export class TurnEnded extends AgentEvent2 { static override readonly type = 'turn.ended'; static override readonly durable = true; static override readonly observable = true; @@ -80,6 +97,7 @@ export class TurnEnded extends Event2 { override serialize(): SerializedEvent2 { const record: Record = { type: this.type, + agentId: this.agentId, turnId: this.turnId, reason: this.reason, }; diff --git a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts index 0a63e16a4e6..2a4458967c6 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpDiscoveryOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { MCPToolDefinition } from '#/mcpCore/types'; import { defineState } from '#/state/state'; @@ -27,6 +27,7 @@ const mcpToolCollisionSchema = z.object({ }); const mcpToolsDiscoveredSchema = z.object({ + agentId: z.string(), serverName: z.string(), hash: z.string(), tools: z.custom(), @@ -34,12 +35,19 @@ const mcpToolsDiscoveredSchema = z.object({ collisions: z.array(mcpToolCollisionSchema).readonly().optional(), }); -export class McpToolsDiscovered extends Event2> { +export class McpToolsDiscovered extends AgentEvent2> { static override readonly type = 'mcp.tools_discovered'; static override readonly durable = true; static override readonly schema = mcpToolsDiscoveredSchema; } -export interface McpToolsDiscovered extends z.infer {} +export interface McpToolsDiscovered { + readonly agentId: string; + readonly serverName: string; + readonly hash: string; + readonly tools: readonly MCPToolDefinition[]; + readonly enabledNames: readonly string[]; + readonly collisions?: readonly McpToolCollision[]; +} export const mcpDiscoveryKey = defineState('mcp.discovery', (): McpDiscoveryState => ({ seen: [] })) .replayable({ schema: z.custom() }) diff --git a/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts index 7515df7dbb1..b760531152b 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpEvents.ts @@ -1,6 +1,6 @@ /* 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 { KimiErrorPayload } from '#/_base/errors/serialize'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2, type AgentDomainTrait } from '#/app/event/event2'; export interface McpServerStatusPayload { readonly name: string; @@ -11,10 +11,11 @@ export interface McpServerStatusPayload { } export interface McpServerStatusEventPayload { + readonly agentId: string; readonly server: McpServerStatusPayload; } -export class McpServerStatus extends Event2 { +export class McpServerStatus extends AgentEvent2 { static override readonly type = 'mcp.server.status'; static override readonly observable = true; } @@ -23,18 +24,21 @@ export interface McpServerStatus extends McpServerStatusEventPayload {} export type ToolListUpdatedReason = 'mcp.connected' | 'mcp.disconnected' | 'mcp.failed'; export interface ToolListUpdatedPayload { + readonly agentId: string; readonly reason: ToolListUpdatedReason; readonly serverName: string; } -export class ToolListUpdated extends Event2 { +export class ToolListUpdated extends AgentEvent2 { static override readonly type = 'tool.list.updated'; static override readonly observable = true; } export interface ToolListUpdated extends ToolListUpdatedPayload {} -export class AgentErrorEvent extends Event2 { +export class AgentErrorEvent extends AgentEvent2 { static override readonly type = 'error'; static override readonly observable = true; } -export interface AgentErrorEvent extends KimiErrorPayload {} +export interface AgentErrorEvent extends KimiErrorPayload { + readonly agentId: string; +} diff --git a/packages/agent-core-v2/src/agent/mcp/mcpService.ts b/packages/agent-core-v2/src/agent/mcp/mcpService.ts index d3afabb0bcb..bded747b103 100644 --- a/packages/agent-core-v2/src/agent/mcp/mcpService.ts +++ b/packages/agent-core-v2/src/agent/mcp/mcpService.ts @@ -12,6 +12,7 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { sessionMediaOriginalsDir } from '#/agent/media/image-originals'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { IAgentLoopService } from '#/agent/loop/loop'; import { createMcpAuthTool } from '#/agent/mcp/tools/auth'; @@ -57,6 +58,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { @IAgentLoopService loop: IAgentLoopService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ITelemetryService private readonly telemetry: ITelemetryService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, ) { super(); @@ -166,6 +168,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { if (!this.mcpHandle.isBaselineServer(entry.name)) return; void this.dispatcher.dispatch( new McpServerStatus({ + agentId: this.scopeContext.agentId, server: { name: entry.name, transport: entry.transport, @@ -191,6 +194,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { if (removed) { void this.dispatcher.dispatch( new ToolListUpdated({ + agentId: this.scopeContext.agentId, reason: 'mcp.disconnected', serverName: entry.name, }), @@ -212,6 +216,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { this.recordDiscovery(entry.name, resolved.rawTools, resolved.enabledNames, result.collisions); void this.dispatcher.dispatch( new ToolListUpdated({ + agentId: this.scopeContext.agentId, reason: 'mcp.connected', serverName: entry.name, }), @@ -234,6 +239,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { this.mcpToolsByServer.set(entry.name, [tool.name]); void this.dispatcher.dispatch( new ToolListUpdated({ + agentId: this.scopeContext.agentId, reason: 'mcp.connected', serverName: entry.name, }), @@ -321,6 +327,7 @@ export class AgentMcpService extends Service implements IAgentMcpService { if (this.states.get(mcpDiscoveryKey).seen.includes(key)) return; void this.dispatcher.dispatch( new McpToolsDiscovered({ + agentId: this.scopeContext.agentId, serverName, hash, tools: rawTools, @@ -357,15 +364,16 @@ export class AgentMcpService extends Service implements IAgentMcpService { ) .join('; '); void this.dispatcher.dispatch( - new AgentErrorEvent( - makeErrorPayload( + new AgentErrorEvent({ + ...makeErrorPayload( ErrorCodes.MCP_TOOL_NAME_COLLISION, `MCP server "${serverName}" registered ${collisions.length} tool name` + `${collisions.length === 1 ? '' : 's'} ` + `that collide with existing qualified names; the losing tools were dropped: ${summary}`, { details: { serverName, collisions: collisions as readonly unknown[] } }, ), - ), + agentId: this.scopeContext.agentId, + }), ); } } diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts index b4207553fee..efceeb8fea8 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeOps.ts @@ -2,17 +2,23 @@ import { z } from 'zod'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; -const permissionSetModeSchema = z.object({ mode: z.custom() }); +const permissionSetModeSchema = z.object({ + agentId: z.string(), + mode: z.custom(), +}); -export class PermissionSetMode extends Event2> { +export class PermissionSetMode extends AgentEvent2> { static override readonly type = 'permission.set_mode'; static override readonly durable = true; static override readonly schema = permissionSetModeSchema; } -export interface PermissionSetMode extends z.infer {} +export interface PermissionSetMode { + readonly agentId: string; + readonly mode: PermissionMode; +} export const permissionModeKey = defineState('permissionMode', (): PermissionMode => 'manual') .replayable({ schema: z.custom() }) diff --git a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts index e8ce8991b41..35393395e1d 100644 --- a/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts +++ b/packages/agent-core-v2/src/agent/permissionMode/permissionModeService.ts @@ -48,7 +48,9 @@ export class AgentPermissionModeService extends Service implements IAgentPermiss const previousMode = this.mode; const changed = mode !== previousMode; if (!changed && this.agentState.get(permissionModeConfiguredKey)) return; - void this.dispatcher.dispatch(new PermissionSetMode({ mode })); + void this.dispatcher.dispatch( + new PermissionSetMode({ agentId: this.scopeContext.agentId, mode }), + ); if (changed) this._onDidChangeMode.fire({ mode, previousMode }); } diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts index 768878e8686..bf32582e78e 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { PermissionApprovalResultRecord, PermissionRule } from './permissionRules'; @@ -11,24 +11,39 @@ export interface PermissionRulesModelState { readonly sessionApprovalRulePatterns: readonly string[]; } -const permissionRulesAddSchema = z.object({ rules: z.custom() }); +const permissionRulesAddSchema = z.object({ + agentId: z.string(), + rules: z.custom(), +}); -export class PermissionRulesAdd extends Event2> { +export class PermissionRulesAdd extends AgentEvent2> { static override readonly type = 'permission.rules.add'; } -export interface PermissionRulesAdd extends z.infer {} +export interface PermissionRulesAdd { + readonly agentId: string; + readonly rules: readonly PermissionRule[]; +} -const permissionRecordApprovalResultSchema = z.custom(); +const permissionRecordApprovalResultSchema = z.object({ + agentId: z.string(), + turnId: z.number(), + toolCallId: z.string(), + toolName: z.string(), + action: z.string(), + sessionApprovalRule: z.string().optional(), + result: z.custom(), +}); -export class PermissionRecordApprovalResult extends Event2< +export class PermissionRecordApprovalResult extends AgentEvent2< z.infer > { static override readonly type = 'permission.record_approval_result'; static override readonly durable = true; static override readonly schema = permissionRecordApprovalResultSchema; } -export interface PermissionRecordApprovalResult - extends z.infer {} +export interface PermissionRecordApprovalResult extends PermissionApprovalResultRecord { + readonly agentId: string; +} export const permissionRulesKey = defineState( 'permissionRules', diff --git a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts index 57d1be97d67..046ed8c9674 100644 --- a/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts +++ b/packages/agent-core-v2/src/agent/permissionRules/permissionRulesService.ts @@ -2,6 +2,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { @@ -20,6 +21,7 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService constructor( @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly agentState: IAgentStateService, ) { this.agentState.contributeState(permissionRulesKey); @@ -35,11 +37,18 @@ export class AgentPermissionRulesService implements IAgentPermissionRulesService addRules(rules: readonly PermissionRule[]): void { if (rules.length === 0) return; - void this.dispatcher.dispatch(new PermissionRulesAdd({ rules: [...rules] })); + void this.dispatcher.dispatch( + new PermissionRulesAdd({ agentId: this.scopeContext.agentId, rules: [...rules] }), + ); } recordApprovalResult(record: PermissionApprovalResultRecord): void { - void this.dispatcher.dispatch(new PermissionRecordApprovalResult(record)); + void this.dispatcher.dispatch( + new PermissionRecordApprovalResult({ + ...record, + agentId: this.scopeContext.agentId, + }), + ); } } diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts index 538d5a5302c..c689731b40d 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; export interface PluginSessionStartSnapshotState { @@ -9,14 +9,22 @@ export interface PluginSessionStartSnapshotState { readonly content?: string; } -const pluginSessionStartSchema = z.object({ content: z.string().nullable() }); +const pluginSessionStartSchema = z.object({ + agentId: z.string(), + content: z.string().nullable(), +}); -export class PluginSessionStartEvent extends Event2> { +export class PluginSessionStartEvent extends AgentEvent2< + z.infer +> { static override readonly type = 'plugin.session_start'; static override readonly durable = true; static override readonly schema = pluginSessionStartSchema; } -export interface PluginSessionStartEvent extends z.infer {} +export interface PluginSessionStartEvent { + readonly agentId: string; + readonly content: string | null; +} export const pluginSessionStartSnapshotKey = defineState( 'pluginSessionStartSnapshot', diff --git a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts index a470d814401..5a9e07120cd 100644 --- a/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts +++ b/packages/agent-core-v2/src/agent/plugin/agentPluginService.ts @@ -188,7 +188,12 @@ export class AgentPluginService extends Service implements IAgentPluginService { } private recordSessionStartSnapshot(content: string | undefined): void { - void this.dispatcher.dispatch(new PluginSessionStartEvent({ content: content ?? null })); + void this.dispatcher.dispatch( + new PluginSessionStartEvent({ + agentId: this.scopeContext.agentId, + content: content ?? null, + }), + ); } } diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts index dcf6ea046e1..486cca667a8 100644 --- a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommand.ts @@ -1,6 +1,6 @@ /* 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 { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; export interface ActivatePluginCommandPayload { readonly pluginId: string; @@ -9,6 +9,7 @@ export interface ActivatePluginCommandPayload { } export interface PluginCommandActivatedPayload { + readonly agentId: string; readonly activationId: string; readonly pluginId: string; readonly commandName: string; @@ -16,7 +17,7 @@ export interface PluginCommandActivatedPayload { readonly trigger: 'user-slash'; } -export class PluginCommandActivated extends Event2 { +export class PluginCommandActivated extends AgentEvent2 { static override readonly type = 'plugin_command.activated'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts index 8ddbcef50fc..bae8680f8eb 100644 --- a/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts +++ b/packages/agent-core-v2/src/agent/pluginCommand/pluginCommandService.ts @@ -57,6 +57,7 @@ export class AgentPluginCommandService implements IAgentPluginCommandService { }; await this.dispatcher.dispatch( new PluginCommandActivated({ + agentId: this.scopeContext.agentId, activationId: origin.activationId, pluginId: origin.pluginId, commandName: origin.commandName, diff --git a/packages/agent-core-v2/src/agent/profile/profileOps.ts b/packages/agent-core-v2/src/agent/profile/profileOps.ts index 95be261c5ca..a03f90c3377 100644 --- a/packages/agent-core-v2/src/agent/profile/profileOps.ts +++ b/packages/agent-core-v2/src/agent/profile/profileOps.ts @@ -3,7 +3,7 @@ import { nothing, original } from 'immer'; import { z } from 'zod'; import type { EnvironmentDisclosureSnapshot } from '#/app/agentProfileCatalog/agentProfileCatalog'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { ThinkingEffort } from '#/kosong/contract/provider'; import { defineState } from '#/state/state'; @@ -22,6 +22,7 @@ export interface ProfileModelState { } const profileBindSchema = z.object({ + agentId: z.string(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom(), @@ -34,14 +35,27 @@ const profileBindSchema = z.object({ subagents: z.array(z.string()).readonly().optional(), }); -export class ProfileBind extends Event2> { +export class ProfileBind extends AgentEvent2> { static override readonly type = 'profile.bind'; static override readonly durable = true; static override readonly schema = profileBindSchema; } -export interface ProfileBind extends z.infer {} +export interface ProfileBind { + readonly agentId: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingEffort: ThinkingEffort; + readonly systemPrompt: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; + readonly agentsMdPaths?: readonly string[]; + readonly activeToolNames?: readonly string[]; + readonly disallowedTools: readonly string[]; + readonly subagents?: readonly string[]; +} const configUpdateSchema = z.object({ + agentId: z.string(), modelAlias: z.string().optional(), profileName: z.string().optional(), thinkingEffort: z.custom().optional(), @@ -55,37 +69,59 @@ const configUpdateSchema = z.object({ export type ConfigUpdatePayload = z.infer; -export class ConfigUpdate extends Event2 { +export class ConfigUpdate extends AgentEvent2 { static override readonly type = 'config.update'; static override readonly durable = true; static override readonly schema = configUpdateSchema; } -export interface ConfigUpdate extends ConfigUpdatePayload {} +export interface ConfigUpdate { + readonly agentId: string; + readonly modelAlias?: string; + readonly profileName?: string; + readonly thinkingEffort?: ThinkingEffort; + readonly thinkingLevel?: ThinkingEffort; + readonly systemPrompt?: string; + readonly environmentDisclosure?: EnvironmentDisclosureSnapshot; + readonly renderGeneration?: number; + readonly agentsMdPaths?: readonly string[]; + readonly disallowedTools?: readonly string[]; +} -const toolsSetActiveToolsSchema = z.object({ names: z.array(z.string()).readonly() }); +const toolsSetActiveToolsSchema = z.object({ + agentId: z.string(), + names: z.array(z.string()).readonly(), +}); -export class ToolsSetActiveTools extends Event2> { +export class ToolsSetActiveTools extends AgentEvent2> { static override readonly type = 'tools.set_active_tools'; static override readonly durable = true; static override readonly schema = toolsSetActiveToolsSchema; } -export interface ToolsSetActiveTools extends z.infer {} +export interface ToolsSetActiveTools { + readonly agentId: string; + readonly names: readonly string[]; +} -const toolsResetActiveToolsSchema = z.object({}); +const toolsResetActiveToolsSchema = z.object({ agentId: z.string() }); -export class ToolsResetActiveTools extends Event2> { +export class ToolsResetActiveTools extends AgentEvent2< + z.infer +> { static override readonly type = 'tools.reset_active_tools'; static override readonly durable = true; static override readonly schema = toolsResetActiveToolsSchema; } -export interface ToolsResetActiveTools extends z.infer {} +export interface ToolsResetActiveTools { + readonly agentId: string; +} export interface WarningIssuedPayload { + readonly agentId: string; readonly message: string; readonly code?: string; } -export class WarningIssued extends Event2 { +export class WarningIssued extends AgentEvent2 { static override readonly type = 'warning'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 7b6b129425d..2b504afa03d 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -40,6 +40,7 @@ import { ISessionToolPolicy } from '#/session/sessionToolPolicy/sessionToolPolic import { ISessionToolPolicyGate } from '#/session/sessionToolPolicyGate/sessionToolPolicyGate'; import { IPluginService } from '#/app/plugin/plugin'; import type { ResolvedAgentProfile, SystemPromptContext } from '#/agent/profile/profile'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; @@ -161,6 +162,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @ISessionToolPolicyGate private readonly toolPolicyGate: ISessionToolPolicyGate, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IBuiltinAgentProfileLoader private readonly builtinProfiles: IBuiltinAgentProfileLoader, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, @IPluginService private readonly plugins: IPluginService, @IAgentIdentity private readonly identity: IAgentIdentity, @@ -260,6 +262,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ snapshot.agentsMdPaths ?? extractAgentsMdPathsFromSystemPrompt(snapshot.systemPrompt); void this.dispatcher.dispatch( new ProfileBind({ + agentId: this.scopeContext.agentId, modelAlias: snapshot.modelAlias, profileName: snapshot.profileName, thinkingEffort: snapshot.thinkingLevel, @@ -328,6 +331,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.activeToolNamesOverlay = undefined; await this.dispatcher.dispatch(new ProfileBind({ + agentId: this.scopeContext.agentId, modelAlias: alias, profileName: profile.name, thinkingEffort: thinkingLevel, @@ -432,6 +436,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ } catch (error) { void this.dispatcher.dispatch( new WarningIssued({ + agentId: this.scopeContext.agentId, message: `System prompt refresh skipped: ${error instanceof Error ? error.message : String(error)}`, code: 'system-prompt-refresh-failed', }), @@ -564,7 +569,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private resolveConfigPayload( changed: Omit, ): ConfigUpdatePayload { - const payload: ConfigUpdatePayload = {}; + const payload: ConfigUpdatePayload = { agentId: this.scopeContext.agentId }; if (changed.modelAlias !== undefined) payload.modelAlias = changed.modelAlias; if (changed.profileName !== undefined) payload.profileName = changed.profileName; if (changed.thinkingLevel !== undefined || changed.modelAlias !== undefined) { @@ -623,7 +628,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const key = [code, model.id, model.name, effort, knownEfforts].join('\u0000'); if (this.emittedThinkingEffortWarnings.has(key)) return; this.emittedThinkingEffortWarnings.add(key); - void this.dispatcher.dispatch(new WarningIssued({ code, message })); + void this.dispatcher.dispatch(new WarningIssued({ agentId: this.scopeContext.agentId, code, message })); } catch { } } @@ -631,10 +636,12 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ private setActiveTools(names: readonly string[] | undefined): void { this.activeToolNamesOverlay = undefined; if (names === undefined) { - void this.dispatcher.dispatch(new ToolsResetActiveTools({})); + void this.dispatcher.dispatch(new ToolsResetActiveTools({ agentId: this.scopeContext.agentId })); return; } - void this.dispatcher.dispatch(new ToolsSetActiveTools({ names: [...names] })); + void this.dispatcher.dispatch( + new ToolsSetActiveTools({ agentId: this.scopeContext.agentId, names: [...names] }), + ); } private emitStatusUpdated(includeThinkingEffort = false): void { @@ -649,6 +656,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ const maxContextTokens = capabilities?.max_input_tokens ?? capabilities?.max_context_tokens; void this.dispatcher.dispatch( new AgentStatusUpdated({ + agentId: this.scopeContext.agentId, model: modelAlias, thinkingEffort: includeThinkingEffort ? this.getEffectiveThinkingLevel() @@ -778,6 +786,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ if (warning === undefined) return; void this.dispatcher.dispatch( new WarningIssued({ + agentId: this.scopeContext.agentId, message: warning, code: 'agents-md-oversized', }), @@ -824,6 +833,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ this.emittedToolPatternWarnings.add(key); void this.dispatcher.dispatch( new WarningIssued({ + agentId: this.scopeContext.agentId, code: 'tool-pattern-no-match', message: describeInactiveToolPattern(context, field, issue), }), @@ -940,6 +950,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ for (const id of newlySkipped) this.emittedPluginBudgetWarnings.add(id); void this.dispatcher.dispatch( new WarningIssued({ + agentId: this.scopeContext.agentId, message: `Plugin system-prompt contributions from ${newlySkipped.map((id) => `"${id}"`).join(', ')} ` + `were skipped: the aggregate ${PLUGIN_SECTIONS_MAX_BYTES / 1024} KB budget is exhausted.`, diff --git a/packages/agent-core-v2/src/agent/prompt/promptOps.ts b/packages/agent-core-v2/src/agent/prompt/promptOps.ts index b3e9a6cacc1..60342fcecb8 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptOps.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptOps.ts @@ -1,17 +1,23 @@ /* oxlint-disable typescript-eslint/no-unsafe-declaration-merging, eslint-plugin-import/namespace */ import { z } from 'zod'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; -const promptAcceptedSchema = z.object({ promptId: z.string().min(1) }); +const promptAcceptedSchema = z.object({ + agentId: z.string(), + promptId: z.string().min(1), +}); -export class PromptAccepted extends Event2> { +export class PromptAccepted extends AgentEvent2> { static override readonly type = 'prompt.accepted'; static override readonly durable = true; static override readonly schema = promptAcceptedSchema; } -export interface PromptAccepted extends z.infer {} +export interface PromptAccepted { + readonly agentId: string; + readonly promptId: string; +} export const promptAdmissionKey = defineState('promptAdmission', (): Map => new Map()) .replayable({ schema: z.map(z.string(), z.literal(true)) }) diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 9652d9d0840..e4c66d78da3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -20,7 +20,7 @@ import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IFileService } from '#/app/file/fileService'; import type { ContentPart } from '#/kosong/contract/message'; import { IEventService } from '#/app/event/event'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -54,48 +54,52 @@ import { materializePromptDaemonRefs } from '#/agent/media/promptMediaIntake'; import { ISessionMediaStore } from '#/agent/media/sessionMediaStore'; export interface PromptCompletedPayload { + readonly agentId: string; readonly promptId: string; readonly finishedAt: string; readonly reason: 'completed' | 'failed' | 'blocked'; } -export class PromptCompleted extends Event2 { +export class PromptCompleted extends AgentEvent2 { static override readonly type = 'prompt.completed'; static override readonly observable = true; } export interface PromptCompleted extends PromptCompletedPayload {} export interface PromptAbortedPayload { + readonly agentId: string; readonly promptId: string; readonly abortedAt: string; } -export class PromptAborted extends Event2 { +export class PromptAborted extends AgentEvent2 { static override readonly type = 'prompt.aborted'; static override readonly observable = true; } export interface PromptAborted extends PromptAbortedPayload {} export interface PromptSteeredPayload { + readonly agentId: string; readonly activePromptId: string; readonly promptIds: string[]; readonly content: ContentPart[]; readonly steeredAt: string; } -export class PromptSteered extends Event2 { +export class PromptSteered extends AgentEvent2 { static override readonly type = 'prompt.steered'; static override readonly observable = true; } export interface PromptSteered extends PromptSteeredPayload {} export interface PromptQueuedPayload { + readonly agentId: string; readonly promptId: string; readonly content: ContentPart[]; readonly queueLength: number; } -export class PromptQueued extends Event2 { +export class PromptQueued extends AgentEvent2 { static override readonly type = 'prompt.queued'; static override readonly observable = true; } @@ -195,7 +199,9 @@ export class AgentPromptService implements IAgentPromptService { if (submitted) throw new Error2(ErrorCodes.REQUEST_INVALID, 'prompt reservation already submitted'); submitted = true; this.reservedPromptIds.delete(id); - await this.dispatcher.dispatch(new PromptAccepted({ promptId: id })); + await this.dispatcher.dispatch( + new PromptAccepted({ agentId: this.scopeContext.agentId, promptId: id }), + ); return this.enqueue({ id, message }); }, dispose: () => { @@ -323,7 +329,11 @@ export class AgentPromptService implements IAgentPromptService { } const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { void this.dispatcher.dispatch( - new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }), + new TurnSteer({ + agentId: this.scopeContext.agentId, + input: materialized.content, + origin: materialized.origin ?? USER_PROMPT_ORIGIN, + }), ); }, () => {}); let turn: Turn | undefined; @@ -342,7 +352,7 @@ export class AgentPromptService implements IAgentPromptService { for (const item of selected) { item.state = 'steered'; item.launchedDeferred.resolve(turn); } this.steered.set(this.active.id, [...(this.steered.get(this.active.id) ?? []), ...selected]); void this.dispatcher.dispatch( - new PromptSteered({ activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: selected.flatMap((item) => stripBundledSkillBlocks(item.message)), steeredAt: new Date().toISOString() }), + new PromptSteered({ agentId: this.scopeContext.agentId, activePromptId: this.active.id, promptIds: selected.map((x) => x.id), content: selected.flatMap((item) => stripBundledSkillBlocks(item.message)), steeredAt: new Date().toISOString() }), ); return selected.map((item) => item.handle); } @@ -368,7 +378,11 @@ export class AgentPromptService implements IAgentPromptService { await this.materializeDaemonRefs(rerouted); const request = new SteerStepRequest(rerouted, captions, this.reminders, (materialized) => { void this.dispatcher.dispatch( - new TurnSteer({ input: materialized.content, origin: materialized.origin ?? USER_PROMPT_ORIGIN }), + new TurnSteer({ + agentId: this.scopeContext.agentId, + input: materialized.content, + origin: materialized.origin ?? USER_PROMPT_ORIGIN, + }), ); }, () => {}, 'activeOrNewTurn'); return (await this.loop.enqueue(request).assigned).turn; @@ -464,12 +478,12 @@ export class AgentPromptService implements IAgentPromptService { const { delivery: _delivery, ...rest } = ctx.result; ctx.result = rest as ExecutableToolResult; if (delivery.kind === 'steer') await this.inject(delivery.message as ContextMessage); } - private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { void this.dispatcher.dispatch(new PromptCompleted({ promptId, finishedAt: new Date().toISOString(), reason })); } + private publishCompleted(promptId: string, reason: 'completed' | 'failed' | 'blocked'): void { void this.dispatcher.dispatch(new PromptCompleted({ agentId: this.scopeContext.agentId, promptId, finishedAt: new Date().toISOString(), reason })); } private publishQueued(record: Record): void { if ((record.message.origin ?? USER_PROMPT_ORIGIN).kind !== 'user') return; - void this.dispatcher.dispatch(new PromptQueued({ promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length })); + void this.dispatcher.dispatch(new PromptQueued({ agentId: this.scopeContext.agentId, promptId: record.id, content: stripBundledSkillBlocks(record.message), queueLength: this.pending.length })); } - private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ promptId, abortedAt: new Date().toISOString() })); } + private publishAborted(promptId: string): void { void this.dispatcher.dispatch(new PromptAborted({ agentId: this.scopeContext.agentId, promptId, abortedAt: new Date().toISOString() })); } } function snapshot(item: Record): PromptSnapshot { return { id: item.id, userMessageId: item.userMessageId, createdAt: item.createdAt, state: item.state, message: item.message }; } diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts index 29b0074ce89..62245a875fe 100644 --- a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts +++ b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingOps.ts @@ -1,18 +1,26 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { RuntimeBinding } from '#/runtime/runtime'; import { defineState } from '#/state/state'; -const runtimeSetBindingSchema = z.object({ workspaceId: z.string(), runtimeId: z.string() }); +const runtimeSetBindingSchema = z.object({ + agentId: z.string(), + workspaceId: z.string(), + runtimeId: z.string(), +}); -export class RuntimeSetBinding extends Event2> { +export class RuntimeSetBinding extends AgentEvent2> { static override readonly type = 'runtime.set_binding'; static override readonly durable = true; static override readonly schema = runtimeSetBindingSchema; } -export interface RuntimeSetBinding extends z.infer {} +export interface RuntimeSetBinding { + readonly agentId: string; + readonly workspaceId: string; + readonly runtimeId: string; +} export const runtimeBindingKey = defineState( 'runtimeBinding', diff --git a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts index d0a57caabf0..ce369f01447 100644 --- a/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts +++ b/packages/agent-core-v2/src/agent/runtimeBinding/runtimeBindingService.ts @@ -3,6 +3,7 @@ import { defineState } from '#/state/state'; import type { IDisposable } from '#/_base/di/lifecycle'; import { Emitter } from '#/_base/event'; import { LifecycleScope } from '#/app/scopes'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import type { RuntimeBinding } from '#/runtime/runtime'; import { RuntimeError } from '#/runtime/runtimeRegistry'; @@ -22,6 +23,7 @@ export class AgentRuntimeBindingService implements IAgentRuntimeBindingService { private readonly restoreHook: IDisposable; constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly state: IAgentStateService, @IAgentRuntimeBindingSeed seed: IAgentRuntimeBindingSeed, @ISessionContext private readonly session: ISessionContext, @@ -36,7 +38,9 @@ export class AgentRuntimeBindingService implements IAgentRuntimeBindingService { this.restoreHook = dispatcher.hooks.onDidRestore.register('agent-runtime-binding', async (_ctx, next) => { const replayed = this.state.get(runtimeBindingKey); if (replayed === undefined) { - void this.dispatcher.dispatch(new RuntimeSetBinding(this.current)); + void this.dispatcher.dispatch( + new RuntimeSetBinding({ ...this.current, agentId: this.scopeContext.agentId }), + ); } else { this.assertSessionWorkspace(replayed); this.state.set(agentRuntimeBindingKey, replayed); @@ -70,7 +74,9 @@ export class AgentRuntimeBindingService implements IAgentRuntimeBindingService { return this.current; } const next = { workspaceId: binding.workspaceId, runtimeId: binding.runtimeId }; - void this.dispatcher.dispatch(new RuntimeSetBinding(next)); + void this.dispatcher.dispatch( + new RuntimeSetBinding({ ...next, agentId: this.scopeContext.agentId }), + ); this.state.set(agentRuntimeBindingKey, next); this.changeEmitter.fire(next); return next; diff --git a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts index 0a3d7b52791..edffee0b0f4 100644 --- a/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts +++ b/packages/agent-core-v2/src/agent/scopeContext/scopeContext.ts @@ -1,9 +1,13 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import type { IAgentScopeHandle } from '#/_base/di/scope'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { AgentSpaceImpl } from '#/agent/agentContext/agentSpace'; export interface IAgentScopeContext { readonly _serviceBrand: undefined; readonly agentId: string; + readonly agentContext: AgentContext; scope(subKey?: string): string; } @@ -13,11 +17,20 @@ export const IAgentScopeContext: ServiceIdentifier = export function makeAgentScopeContext(input: { readonly agentId: string; readonly agentScope: string; + readonly generation?: number; }): IAgentScopeContext { const { agentScope } = input; + const space = new AgentSpaceImpl(input.agentId); + const agentContext: AgentContext = Object.freeze({ + agentId: input.agentId, + generation: input.generation ?? 0, + space, + }); + space._bindContext(agentContext); return { _serviceBrand: undefined, agentId: input.agentId, + agentContext, scope: (subKey?: string): string => { if (subKey === undefined || subKey === '') return agentScope; if (agentScope === '') return subKey; @@ -25,3 +38,15 @@ export function makeAgentScopeContext(input: { }, }; } + +export function agentContextOfScope(scope: IAgentScopeContext): AgentContext { + return scope.agentContext; +} + +export function agentContextOf(handle: IAgentScopeHandle): AgentContext { + return agentContextOfScope(handle.accessor.get(IAgentScopeContext)); +} + +export function tryAgentContextOf(handle: IAgentScopeHandle): AgentContext | undefined { + return handle.accessor.get(IAgentScopeContext)?.agentContext; +} diff --git a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts index f29ae8b2a79..2d7837f3621 100644 --- a/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts +++ b/packages/agent-core-v2/src/agent/shellCommand/shellCommandService.ts @@ -7,10 +7,11 @@ import { userCancellationReason } from '#/_base/utils/abort'; import { escapeXml } from '#/_base/utils/xml-escape'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import type { ToolUpdate } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { Error2, ErrorCodes } from '#/errors'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -21,35 +22,38 @@ import { } from './shellCommand'; export interface ShellOutputPayload { + readonly agentId: string; readonly commandId: string; readonly update: ToolUpdate; readonly taskId?: string; } -export class ShellOutput extends Event2 { +export class ShellOutput extends AgentEvent2 { static override readonly type = 'shell.output'; static override readonly observable = true; } export interface ShellOutput extends ShellOutputPayload {} export interface ShellStartedPayload { + readonly agentId: string; readonly commandId: string; readonly taskId: string; } -export class ShellStarted extends Event2 { +export class ShellStarted extends AgentEvent2 { static override readonly type = 'shell.started'; static override readonly observable = true; } export interface ShellStarted extends ShellStartedPayload {} export interface ShellCompletedPayload { + readonly agentId: string; readonly commandId: string; readonly isError: boolean; readonly taskId?: string; } -export class ShellCompleted extends Event2 { +export class ShellCompleted extends AgentEvent2 { static override readonly type = 'shell.completed'; static override readonly observable = true; } @@ -71,6 +75,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentPromptService private readonly promptService: IAgentPromptService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentStateService private readonly states: IAgentStateService, ) { this.states.contributeState(shellCommandTasksKey); @@ -113,6 +118,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { if (input.commandId !== undefined) { void this.dispatcher.dispatch( new ShellOutput({ + agentId: this.scopeContext.agentId, commandId: input.commandId, update, taskId: this.shellCommandTasks.get(input.commandId), @@ -124,7 +130,11 @@ export class AgentShellCommandService implements IAgentShellCommandService { if (input.commandId !== undefined) { this.shellCommandTasks.set(input.commandId, taskId); void this.dispatcher.dispatch( - new ShellStarted({ commandId: input.commandId, taskId }), + new ShellStarted({ + agentId: this.scopeContext.agentId, + commandId: input.commandId, + taskId, + }), ); } }, @@ -140,6 +150,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { if (input.commandId !== undefined && stderr.length > 0) { void this.dispatcher.dispatch( new ShellOutput({ + agentId: this.scopeContext.agentId, commandId: input.commandId, update: { kind: 'stderr', text: stderr }, taskId: this.shellCommandTasks.get(input.commandId), @@ -150,6 +161,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { if (input.commandId !== undefined) { void this.dispatcher.dispatch( new ShellCompleted({ + agentId: this.scopeContext.agentId, commandId: input.commandId, isError, taskId: this.shellCommandTasks.get(input.commandId), @@ -165,6 +177,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { if (message.length > 0) { void this.dispatcher.dispatch( new ShellOutput({ + agentId: this.scopeContext.agentId, commandId: input.commandId, update: { kind: 'stderr', text: message }, taskId: this.shellCommandTasks.get(input.commandId), @@ -173,6 +186,7 @@ export class AgentShellCommandService implements IAgentShellCommandService { } void this.dispatcher.dispatch( new ShellCompleted({ + agentId: this.scopeContext.agentId, commandId: input.commandId, isError: true, taskId: this.shellCommandTasks.get(input.commandId), diff --git a/packages/agent-core-v2/src/agent/skill/skillOps.ts b/packages/agent-core-v2/src/agent/skill/skillOps.ts index 083634de0a3..5db6d4bb2bc 100644 --- a/packages/agent-core-v2/src/agent/skill/skillOps.ts +++ b/packages/agent-core-v2/src/agent/skill/skillOps.ts @@ -2,19 +2,21 @@ import { z } from 'zod'; import type { SkillActivationOrigin, SkillSource } from '#/agent/contextMemory/types'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; export interface SkillActivatePayload { + readonly agentId: string; readonly origin: SkillActivationOrigin; } -export class SkillActivate extends Event2 { +export class SkillActivate extends AgentEvent2 { static override readonly type = 'skill.activate'; } export interface SkillActivate extends SkillActivatePayload {} export interface SkillActivatedPayload { + readonly agentId: string; readonly activationId: string; readonly skillName: string; readonly trigger: string; @@ -23,7 +25,7 @@ export interface SkillActivatedPayload { readonly skillSource?: SkillSource; } -export class SkillActivated extends Event2 { +export class SkillActivated extends AgentEvent2 { static override readonly type = 'skill.activated'; static override readonly observable = true; } @@ -34,6 +36,7 @@ export const skillKey = defineState('skill', (): null => null) .on(SkillActivate, (_s, e, ctx) => { ctx.emit( new SkillActivated({ + agentId: e.agentId, activationId: e.origin.activationId, skillName: e.origin.skillName, trigger: e.origin.trigger, diff --git a/packages/agent-core-v2/src/agent/skill/skillService.ts b/packages/agent-core-v2/src/agent/skill/skillService.ts index aa25fe55c86..d63f18613c6 100644 --- a/packages/agent-core-v2/src/agent/skill/skillService.ts +++ b/packages/agent-core-v2/src/agent/skill/skillService.ts @@ -228,7 +228,9 @@ export class AgentSkillService extends Service implements IAgentSkillService { origin: SkillActivationOrigin, input?: readonly ContentPart[], ): Promise { - await this.dispatcher.dispatch(new SkillActivate({ origin })); + await this.dispatcher.dispatch( + new SkillActivate({ agentId: this.scopeContext.agentId, origin }), + ); this.publishActivation(origin); if (input === undefined) return undefined; diff --git a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts index 8ab985ddfe8..3c2eac8a070 100644 --- a/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts +++ b/packages/agent-core-v2/src/agent/stepRetry/stepRetryService.ts @@ -13,7 +13,7 @@ import { import { isRetryableGenerateError } from '#/kosong/contract/errors'; import { IConfigService } from '#/app/config/config'; import { IEventBus } from '#/app/event/eventBus'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { unwrapErrorCause } from '#/errors'; import { IAgentLoopService, @@ -21,12 +21,14 @@ import { } 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; @@ -39,7 +41,7 @@ export interface TurnStepRetryingPayload { readonly statusCode?: number; } -export class TurnStepRetrying extends Event2 { +export class TurnStepRetrying extends AgentEvent2 { static override readonly type = 'turn.step.retrying'; static override readonly observable = true; } @@ -62,6 +64,7 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry @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(); @@ -129,6 +132,7 @@ export class AgentStepRetryService extends Disposable implements IAgentStepRetry 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, diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 61e6c008c21..631f417afc6 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -57,6 +57,7 @@ export interface IAgentTaskEntry { } export interface AgentTaskNotificationContext { + readonly agentId: string; readonly notificationType: string; readonly title: string; readonly body: string; diff --git a/packages/agent-core-v2/src/agent/task/taskOps.ts b/packages/agent-core-v2/src/agent/task/taskOps.ts index 5e1c45141b7..cc6aebc15ac 100644 --- a/packages/agent-core-v2/src/agent/task/taskOps.ts +++ b/packages/agent-core-v2/src/agent/task/taskOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { AgentTaskNotificationContext } from './task'; @@ -9,52 +9,70 @@ import type { AgentTaskInfo } from './types'; export type TaskModelState = Map; -const taskStartedSchema = z.object({ info: z.custom() }); +const taskStartedSchema = z.object({ + agentId: z.string(), + info: z.custom(), +}); -export class TaskStarted extends Event2> { +export class TaskStarted extends AgentEvent2> { static override readonly type = 'task.started'; static override readonly durable = true; static override readonly observable = true; static override readonly schema = taskStartedSchema; } -export interface TaskStarted extends z.infer {} +export interface TaskStarted { + readonly agentId: string; + readonly info: AgentTaskInfo; +} const taskTerminatedSchema = z.object({ + agentId: z.string(), info: z.custom(), outputTail: z.string().optional(), }); -export class TaskTerminated extends Event2> { +export class TaskTerminated extends AgentEvent2> { static override readonly type = 'task.terminated'; static override readonly durable = true; static override readonly schema = taskTerminatedSchema; } -export interface TaskTerminated extends z.infer {} +export interface TaskTerminated { + readonly agentId: string; + readonly info: AgentTaskInfo; + readonly outputTail?: string; +} export interface TaskTerminatedNoticePayload { + readonly agentId: string; readonly info: AgentTaskInfo; } -export class TaskTerminatedNotice extends Event2 { +export class TaskTerminatedNotice extends AgentEvent2 { static override readonly type = 'task.terminated'; static override readonly observable = true; } export interface TaskTerminatedNotice extends TaskTerminatedNoticePayload {} -export class TaskNotified extends Event2 { +export class TaskNotified extends AgentEvent2 { static override readonly type = 'task.notified'; static override readonly observable = true; } export interface TaskNotified extends AgentTaskNotificationContext {} -const taskWaitDeliveredSchema = z.object({ keys: z.array(z.string()) }); +const taskWaitDeliveredSchema = z.object({ + agentId: z.string(), + keys: z.array(z.string()), +}); -export class TaskWaitDelivered extends Event2> { +export class TaskWaitDelivered extends AgentEvent2> { static override readonly type = 'task.waitDelivered'; static override readonly durable = true; static override readonly schema = taskWaitDeliveredSchema; } -export interface TaskWaitDelivered extends z.infer {} +export interface TaskWaitDelivered { + readonly agentId: string; + readonly keys: string[]; +} export const taskKey = defineState('task', (): TaskModelState => new Map()).replayable({ schema: z.custom(), @@ -65,6 +83,6 @@ export const taskKey = defineState('task', (): TaskModelState => new Map()).repl .on(TaskTerminated, (s, e, ctx) => { s.set(e.info.taskId, e.info); if (e instanceof TaskTerminated) { - ctx.emit(new TaskTerminatedNotice({ info: e.info })); + ctx.emit(new TaskTerminatedNotice({ agentId: e.agentId, info: e.info })); } }); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index d64d6999858..66eba1857e4 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -234,7 +234,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IAtomicDocumentStore atomicDocs: IAtomicDocumentStore, @IFileSystemStorageService byteStore: IFileSystemStorageService, @ISessionContext session: ISessionContext, - @IAgentScopeContext scopeContext: IAgentScopeContext, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @ITaskService private readonly taskService: ITaskService, @IEventBus private readonly eventBus: IEventBus, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @@ -253,12 +253,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.states.contributeState(taskDeliveredNotificationKeysKey); this.states.contributeState(taskActiveTaskReminderPendingKey); const fallbackRoot = - scopeContext.agentId === 'main' + this.scopeContext.agentId === 'main' ? { dir: session.sessionDir, scope: session.scope() } : undefined; this.persistence = new AgentTaskPersistence( - join(session.sessionDir, 'agents', scopeContext.agentId), - scopeContext.scope(), + join(session.sessionDir, 'agents', this.scopeContext.agentId), + this.scopeContext.scope(), atomicDocs, byteStore, fallbackRoot, @@ -627,7 +627,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.markDeliveredNotification(origin); keys.push(key); } - void this.dispatcher.dispatch(new TaskWaitDelivered({ keys })); + void this.dispatcher.dispatch( + new TaskWaitDelivered({ agentId: this.scopeContext.agentId, keys }), + ); } detach(taskId: string): AgentTaskInfo | undefined { @@ -1068,7 +1070,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private recordTaskStarted(info: AgentTaskInfo): void { - void this.dispatcher.dispatch(new TaskStarted({ info })); + void this.dispatcher.dispatch( + new TaskStarted({ agentId: this.scopeContext.agentId, info }), + ); this.telemetry.track2('background_task_created', { task_id: info.taskId, kind: info.kind === 'process' ? 'bash' : info.kind, @@ -1076,7 +1080,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private recordTaskTerminated(info: AgentTaskInfo, outputTail?: string): void { - void this.dispatcher.dispatch(new TaskTerminated({ info, outputTail })); + void this.dispatcher.dispatch( + new TaskTerminated({ agentId: this.scopeContext.agentId, info, outputTail }), + ); this.telemetry.track2('background_task_completed', { task_id: info.taskId, kind: info.kind, @@ -1205,6 +1211,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private fireNotificationHook(notification: AgentTaskNotification): void { void this.dispatcher.dispatch( new TaskNotified({ + agentId: this.scopeContext.agentId, notificationType: notification.type, title: notification.title, body: notification.body, diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts index cebe271b2e2..05659efaf0b 100644 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCounting.ts @@ -1,7 +1,5 @@ -import { createDecorator } from '#/_base/di/instantiation'; import type { Message } from '#/kosong/contract/message'; import type { Tool } from '#/kosong/contract/tool'; -import type { TokenUsage } from '#/kosong/contract/usage'; export type TokenCountingStrategy = 'measured+estimated' | 'measured' | 'estimated'; @@ -16,31 +14,3 @@ export interface TokenCountingRequest { readonly tools: readonly Tool[]; readonly messages: readonly Message[]; } - -export interface IAgentTokenCountingService { - readonly _serviceBrand: undefined; - - readonly strategy: TokenCountingStrategy; - - get(start?: number, end?: number): ContextSize; - measured(input: readonly Message[], output: readonly Message[], usage: TokenUsage): void; - /** Tokens of the most recent measured anchor (0 when none) — a real reading - * that stays valid across transient uncascaded context rewrites. */ - latestMeasured(): number; - /** The externally reported context size — the ONLY reading the - * `[token_counting]` strategy selects: `measured` reports the latest - * measured anchor alone, `estimated` reports a pure estimate with anchors - * ignored, and the default reports the live size floored by the last - * measured total. Internal logic (triggers, budgets, overflow backoff) - * must use `get()` / the estimate primitives, never this method. */ - statusSize(): number; - requestSize(request: TokenCountingRequest): number; - - estimateText(text: string): number; - estimateMessage(message: Message): number; - estimateMessages(messages: readonly Message[]): number; - estimateTools(tools: readonly Tool[]): number; -} - -export const IAgentTokenCountingService = - createDecorator('agentTokenCountingService'); diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts index 1f1706944ea..06dbf7aa7c3 100644 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts +++ b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingOps.ts @@ -1,9 +1,7 @@ /* 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 { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import { Event2 } from '#/app/event/event2'; -import { defineState } from '#/state/state'; +import { AgentEvent2 } from '#/app/event/event2'; export interface TokenAnchor { readonly length: number; @@ -16,94 +14,67 @@ export interface TokenCountingState { readonly tokens: number; } -const sizeSchema = z.object({ length: z.number(), tokens: z.number() }); +const sizeSchema = z.object({ + agentId: z.string(), + length: z.number(), + tokens: z.number(), +}); -export class TokenCountingMeasured extends Event2> { +export class TokenCountingMeasured extends AgentEvent2> { static override readonly type = 'token_counting.measured'; static override readonly durable = true; static override readonly schema = sizeSchema; } -export interface TokenCountingMeasured extends z.infer {} +export interface TokenCountingMeasured { + readonly agentId: string; + readonly length: number; + readonly tokens: number; +} -export class TokenCountingTruncated extends Event2> { +export class TokenCountingTruncated extends AgentEvent2> { static override readonly type = 'token_counting.truncated'; static override readonly durable = true; static override readonly schema = sizeSchema; } -export interface TokenCountingTruncated extends z.infer {} +export interface TokenCountingTruncated { + readonly agentId: string; + readonly length: number; + readonly tokens: number; +} const rebaseSchema = sizeSchema.extend({ measured: z.boolean() }); -export class TokenCountingRebased extends Event2> { +export class TokenCountingRebased extends AgentEvent2> { static override readonly type = 'token_counting.rebased'; static override readonly durable = true; static override readonly schema = rebaseSchema; } -export interface TokenCountingRebased extends z.infer {} +export interface TokenCountingRebased { + readonly agentId: string; + readonly length: number; + readonly tokens: number; + readonly measured: boolean; +} const turnRecordedSchema = sizeSchema.extend({ turnId: z.number() }); -export class TokenCountingTurnRecorded extends Event2> { +export class TokenCountingTurnRecorded extends AgentEvent2> { static override readonly type = 'token_counting.turn_recorded'; static override readonly durable = true; static override readonly schema = turnRecordedSchema; } -export interface TokenCountingTurnRecorded extends z.infer {} +export interface TokenCountingTurnRecorded { + readonly agentId: string; + readonly length: number; + readonly tokens: number; + readonly turnId: number; +} -function anchorsEqual(a: readonly TokenAnchor[], b: readonly TokenAnchor[]): boolean { +export function anchorsEqual(a: readonly TokenAnchor[], b: readonly TokenAnchor[]): boolean { return a.length === b.length && a.every((anchor, i) => anchor === b[i]); } -export const tokenCountingKey = defineState( - 'tokenCounting', - (): TokenCountingState => ({ anchors: [], tokens: 0 }), -).replayable({ schema: z.custom() }) - .on(TokenCountingMeasured, (s, e, ctx) => { - const length = normalizeAnchorLength(e.length); - const tokens = Math.max(0, e.tokens); - const anchor: TokenAnchor = { length, tokens, measured: true }; - const anchors = [...s.anchors.filter((a) => a.length < length), anchor]; - if (!(s.tokens === tokens && anchorsEqual(s.anchors, anchors))) { - s.anchors = anchors; - s.tokens = tokens; - } - ctx.emit(new AgentStatusUpdated({ contextTokens: s.tokens })); - }) - .on(TokenCountingTruncated, (s, e, ctx) => { - const length = normalizeAnchorLength(e.length); - const tokens = Math.max(0, e.tokens); - const anchors = s.anchors.filter((a) => a.length <= length); - if (!(s.tokens === tokens && anchorsEqual(s.anchors, anchors))) { - s.anchors = anchors; - s.tokens = tokens; - } - ctx.emit(new AgentStatusUpdated({ contextTokens: s.tokens })); - }) - .on(TokenCountingRebased, (s, e, ctx) => { - const length = normalizeAnchorLength(e.length); - const tokens = Math.max(0, e.tokens); - const anchors: TokenAnchor[] = [{ length, tokens, measured: e.measured }]; - if (!(s.tokens === tokens && anchorsEqual(s.anchors, anchors))) { - s.anchors = anchors; - s.tokens = tokens; - } - ctx.emit(new AgentStatusUpdated({ contextTokens: s.tokens })); - }) - .on(TokenCountingTurnRecorded, (s, e, ctx) => { - const length = normalizeAnchorLength(e.length); - const tokens = Math.max(0, e.tokens); - const pinned = s.anchors.some((a) => a.length === length); - const anchors = pinned - ? s.anchors - : [...s.anchors.filter((a) => a.length < length), { length, tokens, measured: false }]; - if (!(s.tokens === tokens && anchorsEqual(s.anchors, anchors))) { - s.anchors = anchors; - s.tokens = tokens; - } - ctx.emit(new AgentStatusUpdated({ contextTokens: s.tokens })); - }); - -function normalizeAnchorLength(length: number): number { +export function normalizeAnchorLength(length: number): number { if (!Number.isFinite(length)) return 0; return Math.max(0, Math.floor(length)); } diff --git a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts b/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts deleted file mode 100644 index 8221c2918fd..00000000000 --- a/packages/agent-core-v2/src/agent/tokenCounting/tokenCountingService.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Disposable } from '#/_base/di/lifecycle'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; -import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { TurnEnded } from '#/agent/loop/turnOps'; -import type { Message } from '#/kosong/contract/message'; -import type { Tool } from '#/kosong/contract/tool'; -import { - estimateTokens, - estimateTokensForMessage, - estimateTokensForMessages, - estimateTokensForTools, -} from '#/kosong/contract/tokens'; -import type { TokenUsage } from '#/kosong/contract/usage'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IEventDispatcher } from '#/state/eventDispatcher'; - -import { TOKEN_COUNTING_SECTION, type TokenCountingConfig } from './configSection'; -import { - IAgentTokenCountingService, - type ContextSize, - type TokenCountingRequest, - type TokenCountingStrategy, -} from './tokenCounting'; -import { - TokenCountingMeasured, - TokenCountingTurnRecorded, - tokenCountingKey, - type TokenAnchor, -} from './tokenCountingOps'; - -const ZERO_ANCHOR: TokenAnchor = { length: 0, tokens: 0, measured: true }; - -export class AgentTokenCountingService extends Disposable implements IAgentTokenCountingService { - declare readonly _serviceBrand: undefined; - - constructor( - @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IConfigService private readonly config: IConfigService, - @IAgentStateService private readonly agentState: IAgentStateService, - @IEventBus private readonly eventBus: IEventBus, - ) { - super(); - this.agentState.contributeState(tokenCountingKey); - this._register( - this.eventBus.subscribe(TurnEnded, (e) => { - void this.dispatcher.dispatch( - new TokenCountingTurnRecorded({ - turnId: e.turnId, - length: this.context().length, - tokens: this.statusSize(), - }), - ); - }), - ); - } - - get strategy(): TokenCountingStrategy { - return ( - this.config.get(TOKEN_COUNTING_SECTION)?.strategy ?? - 'measured+estimated' - ); - } - - get(start?: number, end?: number): ContextSize { - const context = this.context(); - const from = normalizeSliceIndex(start ?? 0, context.length); - const to = normalizeSliceIndex(end ?? context.length, context.length); - const anchor = this.latestAnchor(context.length); - const measuredEnd = Math.min(to, anchor.length); - const estimatedStart = Math.max(from, anchor.length); - const measured = - from === 0 && measuredEnd === anchor.length - ? anchor.tokens - : this.estimateMessages(context.slice(from, measuredEnd)); - const estimated = this.estimateMessages(context.slice(estimatedStart, to)); - return { size: measured + estimated, measured, estimated }; - } - - measured(input: readonly Message[], _output: readonly Message[], usage: TokenUsage): void { - const context = this.context(); - if (!matchesContext(input, context)) return; - const length = context.length; - const tokens = tokenUsageTotal(usage); - void this.dispatcher.dispatch(new TokenCountingMeasured({ length, tokens })); - } - - latestMeasured(): number { - const anchors = this.agentState.get(tokenCountingKey).anchors; - for (let i = anchors.length - 1; i >= 0; i--) { - if (anchors[i]!.measured) return anchors[i]!.tokens; - } - return 0; - } - - statusSize(): number { - if (this.strategy === 'measured') return this.latestMeasured(); - if (this.strategy === 'estimated') return this.estimateMessages(this.context()); - return Math.max(this.get().size, this.latestMeasured()); - } - - requestSize(request: TokenCountingRequest): number { - return ( - this.estimateText(request.systemPrompt) + - this.estimateTools(request.tools) + - this.estimateMessages(request.messages) - ); - } - - estimateText(text: string): number { - return estimateTokens(text); - } - - estimateMessage(message: Message): number { - return estimateTokensForMessage(message); - } - - estimateMessages(messages: readonly Message[]): number { - return estimateTokensForMessages(messages); - } - - estimateTools(tools: readonly Tool[]): number { - return estimateTokensForTools(tools); - } - - private context(): readonly ContextMessage[] { - return this.agentState.get(contextMemoryKey) as readonly ContextMessage[]; - } - - /** Latest anchor still valid for the live context: anchors beyond it are - * stale (a rewrite that did not cascade) and skipped. An anchor longer - * than the queried range still certifies the range as measured — the - * caller clamps with `min(to, anchor.length)`. */ - private latestAnchor(contextLength: number): TokenAnchor { - const anchors = this.agentState.get(tokenCountingKey).anchors; - for (let i = anchors.length - 1; i >= 0; i--) { - const anchor = anchors[i]!; - if (anchor.length <= contextLength) return anchor; - } - return ZERO_ANCHOR; - } -} - -function matchesContext(input: readonly Message[], context: readonly ContextMessage[]): boolean { - if (input.length !== context.length) return false; - for (let index = 0; index < input.length; index += 1) { - if (input[index] !== context[index]) return false; - } - return true; -} - -function tokenUsageTotal(usage: TokenUsage): number { - return usage.inputCacheRead + usage.inputCacheCreation + usage.inputOther + usage.output; -} - -function normalizeSliceIndex(index: number, length: number): number { - if (index < 0) return Math.max(length + index, 0); - return Math.min(index, length); -} - -registerScopedService( - LifecycleScope.Agent, - IAgentTokenCountingService, - AgentTokenCountingService, - ScopeActivation.OnScopeCreated, - 'tokenCounting', -); diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts index 1e4884ed763..3270517c51a 100644 --- a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -19,7 +19,7 @@ import type { BeforeExecuteDecision, ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ISessionApprovalService } from '#/session/approval/approval'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -31,7 +31,7 @@ import { IAgentToolApprovalService } from './toolApproval'; export interface PermissionApprovalRequestedPayload { readonly id?: string; readonly sessionId?: string; - readonly agentId?: string; + readonly agentId: string; readonly turnId: number; readonly toolCallId: string; readonly toolName: string; @@ -40,7 +40,7 @@ export interface PermissionApprovalRequestedPayload { readonly toolInput: unknown; } -export class PermissionApprovalRequested extends Event2 { +export class PermissionApprovalRequested extends AgentEvent2 { static override readonly type = 'permission.approval.requested'; static override readonly observable = true; } @@ -54,7 +54,7 @@ export interface PermissionApprovalResolvedPayload extends PermissionApprovalReq readonly error?: string; } -export class PermissionApprovalResolved extends Event2 { +export class PermissionApprovalResolved extends AgentEvent2 { static override readonly type = 'permission.approval.resolved'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts index 80245461f31..b72c15825ce 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorEvents.ts @@ -1,9 +1,10 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { ToolUpdate } from '#/tool/toolContract'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; export interface ToolCallStartedPayload { + readonly agentId: string; readonly turnId: number; readonly toolCallId: string; readonly name: string; @@ -12,25 +13,27 @@ export interface ToolCallStartedPayload { readonly display?: ToolInputDisplay; } -export class ToolCallStarted extends Event2 { +export class ToolCallStarted extends AgentEvent2 { static override readonly type = 'tool.call.started'; static override readonly observable = true; } export interface ToolCallStarted extends ToolCallStartedPayload {} export interface ToolProgressPayload { + readonly agentId: string; readonly turnId: number; readonly toolCallId: string; readonly update: ToolUpdate; } -export class ToolProgress extends Event2 { +export class ToolProgress extends AgentEvent2 { static override readonly type = 'tool.progress'; static override readonly observable = true; } export interface ToolProgress extends ToolProgressPayload {} export interface ToolResultEventPayload { + readonly agentId: string; readonly turnId: number; readonly toolCallId: string; readonly output: unknown; @@ -38,7 +41,7 @@ export interface ToolResultEventPayload { readonly synthetic?: boolean; } -export class ToolResultEvent extends Event2 { +export class ToolResultEvent extends AgentEvent2 { static override readonly type = 'tool.result'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts index 45f4a74115c..cd6534193ce 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts @@ -33,6 +33,7 @@ import type { WillExecuteToolEvent, } from '#/agent/toolExecutor/toolHooks'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { ILogService } from '#/_base/log/log'; import type { ToolCallEvent } from '#/app/telemetry/events'; @@ -148,6 +149,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { } constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentToolRegistryService private readonly toolRegistry: IAgentToolRegistryService, @IEventDispatcher private readonly dispatcher: IEventDispatcher, @ITelemetryService private readonly telemetry: ITelemetryService, @@ -572,6 +574,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { ): void { void this.dispatcher.dispatch( new ToolCallStarted({ + agentId: this.scopeContext.agentId, turnId: options.turnId, toolCallId: call.toolCall.id, name: call.toolName, @@ -594,6 +597,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { ): void { void this.dispatcher.dispatch( new ToolResultEvent({ + agentId: this.scopeContext.agentId, turnId: options.turnId, toolCallId: call.toolCall.id, output: result.output, @@ -609,6 +613,7 @@ export class AgentToolExecutorService implements IAgentToolExecutorService { ): void { void this.dispatcher.dispatch( new ToolProgress({ + agentId: this.scopeContext.agentId, turnId: options.turnId, toolCallId: call.toolCall.id, update, diff --git a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts index 29f78a374e0..d294993c70c 100644 --- a/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts +++ b/packages/agent-core-v2/src/agent/tools/agent/agentTool.ts @@ -19,6 +19,7 @@ import { } from '#/agent/toolPolicy/evaluate'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentUserToolService } from '#/agent/userTool/userTool'; @@ -96,6 +97,7 @@ export class SubagentTool implements ISubagentTool { } private readonly callerAgentId: string; + private readonly callerAgent: AgentContext; private readonly canRunInBackground: () => boolean; private catalogReady = false; private frozenCatalogProfiles: readonly AgentProfile[] | undefined; @@ -120,6 +122,7 @@ export class SubagentTool implements ISubagentTool { @AgentToolContribution private readonly contributions: CollectionView, ) { this.callerAgentId = scopeContext.agentId; + this.callerAgent = scopeContext.agentContext; this.canRunInBackground = () => this.toolPolicy.isToolActive('TaskList') && this.toolPolicy.isToolActive('TaskOutput') && @@ -242,7 +245,7 @@ export class SubagentTool implements ISubagentTool { } private resumeProfileName(agentId: string): string | undefined { - const target = this.lifecycle.get(agentId); + const target = this.lifecycle.findAgentHandle(agentId); if (target === undefined) return undefined; return target.accessor.get(IAgentProfileService).data().profileName; } @@ -253,7 +256,7 @@ export class SubagentTool implements ISubagentTool { controller: AbortController, runtime: Runtime, ): Promise { - const requester = this.lifecycle.get(this.callerAgentId); + const requester = this.lifecycle.get(this.callerAgent); if (requester === undefined) { throw new Error2( ErrorCodes.AGENT_NOT_FOUND, @@ -270,7 +273,7 @@ export class SubagentTool implements ISubagentTool { let displayModel: string | undefined; let promptText = args.prompt; if (isResume) { - const target = this.lifecycle.get(resumeAgentId); + const target = this.lifecycle.findAgentHandle(resumeAgentId); if (target === undefined) { throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent instance "${resumeAgentId}" does not exist`, { details: { agentId: resumeAgentId }, @@ -341,8 +344,19 @@ export class SubagentTool implements ISubagentTool { }); } + const runInBackground = args.run_in_background === true; + emitAgentRunSpawned(requester, agentId, { + profileName, + parentToolCallId: toolCallId, + description: args.description, + runInBackground, + model: displayModel, + }); + + const target = this.lifecycle.findAgentHandle(agentId); + if (target === undefined) throw new Error(`Agent "${agentId}" does not exist`); const run = await this.subagents.run( - agentId, + target.accessor.get(IAgentScopeContext).agentContext, { kind: 'prompt', prompt: promptText }, { signal: controller.signal }, ); @@ -360,8 +374,7 @@ export class SubagentTool implements ISubagentTool { profileName, parentToolCallId: toolCallId, model: displayModel, - thinkingEffort: this.lifecycle - .get(agentId) + thinkingEffort: this.lifecycle.findAgentHandle(agentId) ?.accessor.get(IAgentProfileService) .getEffectiveThinkingLevel(), completion: mirrored.then((r) => ({ result: r.summary, usage: r.usage })), @@ -474,7 +487,7 @@ export class SubagentTool implements ISubagentTool { }; } - const requester = this.lifecycle.get(this.callerAgentId); + const requester = this.lifecycle.get(this.callerAgent); if (requester !== undefined) { emitAgentRunSpawned(requester, handle.agentId, { profileName: handle.profileName, diff --git a/packages/agent-core-v2/src/agent/tools/todo-list/todoListTool.ts b/packages/agent-core-v2/src/agent/tools/todo-list/todoListTool.ts index 92678f18321..e12ea48d391 100644 --- a/packages/agent-core-v2/src/agent/tools/todo-list/todoListTool.ts +++ b/packages/agent-core-v2/src/agent/tools/todo-list/todoListTool.ts @@ -1,7 +1,10 @@ import type { ToolExecution } from '#/tool/toolContract'; -import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; import { toInputJsonSchema } from '#/tool/input-schema'; +import { + agentContextOfScope, + IAgentScopeContext, +} from '#/agent/scopeContext/scopeContext'; import { ISessionTodoService } from '#/session/todo/sessionTodo'; import { TODO_LIST_TOOL_NAME, @@ -23,7 +26,10 @@ export class TodoListTool implements ITodoListTool { readonly description: string = DESCRIPTION; readonly parameters: Record = toInputJsonSchema(TodoListInputSchema); - constructor(@ISessionTodoService private readonly todo: ISessionTodoService) {} + constructor( + @ISessionTodoService private readonly todo: ISessionTodoService, + @IAgentScopeContext private readonly agent: IAgentScopeContext, + ) {} resolveExecution(args: TodoListInput): ToolExecution { const description = @@ -36,16 +42,18 @@ export class TodoListTool implements ITodoListTool { description, approvalRule: this.name, execute: async () => { + const agent = agentContextOfScope(this.agent); if (args.todos === undefined) { - return { isError: false, output: renderTodoList(this.todo.getTodos()) }; + const todos = await this.todo.getTodos(agent); + return { isError: false, output: renderTodoList(todos) }; } const next: readonly TodoItem[] = args.todos.map((todo) => ({ title: todo.title, status: todo.status, })); - this.todo.setTodos(next); - const stored = this.todo.getTodos(); + await this.todo.setTodos(agent, next); + const stored = await this.todo.getTodos(agent); const output = stored.length === 0 ? 'Todo list cleared.' @@ -55,5 +63,3 @@ export class TodoListTool implements ITodoListTool { }; } } - -registerAgentToolService(ITodoListTool, TodoListTool, { name: 'TodoList', domain: 'todo' }); diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts index c9e84103f97..11c870c0f63 100644 --- a/packages/agent-core-v2/src/agent/undo/undoService.ts +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -22,7 +22,7 @@ import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadat import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventService } from '#/app/event/event'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2 } from '#/errors'; import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; @@ -34,11 +34,12 @@ import { keepsUndoCheckpoints } from '#/state/state'; import { IAgentConversationUndoService, type UndoAvailability } from './undo'; -export class ContextUndone extends Event2<{ readonly turns: number }> { +export class ContextUndone extends AgentEvent2<{ readonly agentId: string; readonly turns: number }> { static override readonly type = 'context.undone'; static override readonly observable = true; } export interface ContextUndone { + readonly agentId: string; readonly turns: number; } @@ -111,7 +112,9 @@ export class AgentConversationUndoService await this.flushAfterCommit('state reconciliation'); await this.reconcileLastPromptSafely(); this.telemetry.track2('conversation_undo', { count: turns }); - await this.dispatcher.dispatch(new ContextUndone({ turns })); + await this.dispatcher.dispatch( + new ContextUndone({ agentId: this.agentCtx.agentId, turns }), + ); return turns; } finally { quiescence?.dispose(); @@ -129,6 +132,12 @@ export class AgentConversationUndoService model = key.name; } } + for (const entry of this.dispatcher.modelCheckpointDepths()) { + if (entry.depth < depth) { + depth = entry.depth; + model = entry.id; + } + } return { depth, model }; } diff --git a/packages/agent-core-v2/src/agent/usage/usage.ts b/packages/agent-core-v2/src/agent/usage/usage.ts index fc397f23e0e..d1923978650 100644 --- a/packages/agent-core-v2/src/agent/usage/usage.ts +++ b/packages/agent-core-v2/src/agent/usage/usage.ts @@ -1,9 +1,8 @@ +import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; import type { TokenUsage } from '#/kosong/contract/usage'; -import { createDecorator } from '#/_base/di/instantiation'; -import type { Event } from '#/_base/event'; -import type { ErrorCode } from '#/errors'; +import { type ErrorCode } from '#/errors'; import { Error2 } from '#/_base/errors/errors'; import { UsageErrors } from './errors'; @@ -26,18 +25,8 @@ export interface UsageStatus { } export interface UsageRecordedContext { + readonly agent: AgentContext; readonly model: string; readonly usage: Readonly; readonly source?: AgentLLMRequestSource; } - -export interface IAgentUsageService { - readonly _serviceBrand: undefined; - - record(model: string, usage: TokenUsage, source?: AgentLLMRequestSource): void; - status(): UsageStatus; - - readonly onDidRecord: Event; -} - -export const IAgentUsageService = createDecorator('agentUsageService'); diff --git a/packages/agent-core-v2/src/agent/usage/usageEvents.ts b/packages/agent-core-v2/src/agent/usage/usageEvents.ts index df070e87790..b608a58b5d1 100644 --- a/packages/agent-core-v2/src/agent/usage/usageEvents.ts +++ b/packages/agent-core-v2/src/agent/usage/usageEvents.ts @@ -1,9 +1,10 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { UsageStatus } from './usage'; export interface AgentStatusUpdatedPayload { + readonly agentId: string; usage?: UsageStatus; swarmMode?: boolean; towerMode?: boolean; @@ -14,7 +15,7 @@ export interface AgentStatusUpdatedPayload { contextTokens?: number; } -export class AgentStatusUpdated extends Event2 { +export class AgentStatusUpdated extends AgentEvent2 { static override readonly type = 'agent.status.updated'; static override readonly observable = true; } diff --git a/packages/agent-core-v2/src/agent/usage/usageOps.ts b/packages/agent-core-v2/src/agent/usage/usageOps.ts index 50914c6722b..d44ca844a1e 100644 --- a/packages/agent-core-v2/src/agent/usage/usageOps.ts +++ b/packages/agent-core-v2/src/agent/usage/usageOps.ts @@ -1,11 +1,8 @@ /* 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 { Event2 } from '#/app/event/event2'; -import { addUsage, type TokenUsage } from '#/kosong/contract/usage'; -import { defineState } from '#/state/state'; - -import type { UsageStatus } from './usage'; +import { AgentEvent2 } from '#/app/event/event2'; +import { type TokenUsage } from '#/kosong/contract/usage'; export type UsageRecordScope = 'session' | 'turn'; @@ -14,52 +11,24 @@ export interface UsageModelState { } const usageRecordSchema = z.object({ + agentId: z.string(), model: z.string(), usage: z.custom(), usageScope: z.custom().optional(), }); -export class UsageRecord extends Event2> { +export class UsageRecord extends AgentEvent2> { static override readonly type = 'usage.record'; static override readonly durable = true; static override readonly schema = usageRecordSchema; } -export interface UsageRecord extends z.infer {} - -export const usageKey = defineState('usage', (): UsageModelState => ({ byModel: {} })) - .replayable({ schema: z.custom() }) - .on(UsageRecord, (s, e) => { - const current = s.byModel[e.model]; - s.byModel[e.model] = current === undefined ? copyUsage(e.usage) : addUsage(current, e.usage); -}); +export interface UsageRecord { + readonly agentId: string; + readonly model: string; + readonly usage: TokenUsage; + readonly usageScope?: UsageRecordScope; +} export function copyUsage(usage: TokenUsage): TokenUsage { return { ...usage }; } - -export function usageStatusFromState( - model: UsageModelState, - currentTurn?: TokenUsage, -): UsageStatus { - const byModel = byModelSnapshot(model.byModel); - const hasByModel = Object.keys(byModel).length > 0; - return { - byModel: hasByModel ? byModel : undefined, - total: hasByModel ? totalUsage(byModel) : undefined, - currentTurn: currentTurn === undefined ? undefined : copyUsage(currentTurn), - }; -} - -function byModelSnapshot(byModel: Record): Record { - return Object.fromEntries( - Object.entries(byModel).map(([model, usage]) => [model, copyUsage(usage)]), - ); -} - -function totalUsage(byModel: Record): TokenUsage | undefined { - let total: TokenUsage | undefined; - for (const usage of Object.values(byModel)) { - total = total === undefined ? copyUsage(usage) : addUsage(total, usage); - } - return total; -} diff --git a/packages/agent-core-v2/src/agent/usage/usageService.ts b/packages/agent-core-v2/src/agent/usage/usageService.ts deleted file mode 100644 index 297a32ef6e2..00000000000 --- a/packages/agent-core-v2/src/agent/usage/usageService.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { addUsage, type TokenUsage } from '#/kosong/contract/usage'; -import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; -import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; -import { Emitter, type Event } from '#/_base/event'; -import { defineState } from '#/state/state'; - -import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; -import { IAgentStateService } from '#/agent/state/agentState'; -import { IEventDispatcher } from '#/state/eventDispatcher'; - -import type { UsageRecordedContext, UsageStatus } from './usage'; -import { IAgentUsageService } from './usage'; -import { AgentStatusUpdated } from './usageEvents'; -import { - copyUsage, - usageKey, - UsageRecord, - usageStatusFromState, - type UsageRecordScope, -} from './usageOps'; - -export const usageCurrentTurnIdKey = defineState( - 'usage.currentTurnId', - () => undefined as number | undefined, -); -export const usageCurrentTurnKey = defineState( - 'usage.currentTurn', - () => undefined as TokenUsage | undefined, -); - -export class AgentUsageService extends Service implements IAgentUsageService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidRecord = this._register(new Emitter()); - readonly onDidRecord: Event = this._onDidRecord.event; - - constructor( - @IEventDispatcher private readonly dispatcher: IEventDispatcher, - @IAgentStateService private readonly states: IAgentStateService, - ) { - super(); - this.states.contributeState(usageKey); - this.states.contributeState(usageCurrentTurnIdKey); - this.states.contributeState(usageCurrentTurnKey); - } - - private get currentTurnId(): number | undefined { - return this.states.get(usageCurrentTurnIdKey); - } - - private set currentTurnId(value: number | undefined) { - this.states.set(usageCurrentTurnIdKey, value); - } - - private get currentTurn(): TokenUsage | undefined { - return this.states.get(usageCurrentTurnKey); - } - - private set currentTurn(value: TokenUsage | undefined) { - this.states.set(usageCurrentTurnKey, value); - } - - record(model: string, usage: TokenUsage, source?: AgentLLMRequestSource): void { - const usageScope: UsageRecordScope = source?.type === 'turn' ? 'turn' : 'session'; - void this.dispatcher.dispatch(new UsageRecord({ model, usage, usageScope })); - - const turnId = source?.type === 'turn' ? source.turnId : undefined; - if (turnId !== undefined) { - if (this.currentTurnId !== turnId) { - this.currentTurnId = turnId; - this.currentTurn = copyUsage(usage); - } else { - this.currentTurn = - this.currentTurn === undefined ? copyUsage(usage) : addUsage(this.currentTurn, usage); - } - } - - void this.dispatcher.dispatch(new AgentStatusUpdated({ usage: this.status() })); - this._onDidRecord.fire({ model, usage: copyUsage(usage), source }); - } - - status(): UsageStatus { - return usageStatusFromState(this.states.get(usageKey), this.currentTurn); - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentUsageService, - AgentUsageService, - ScopeActivation.OnScopeCreated, - 'usage', -); diff --git a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts index fef55d0da69..7fb6e991bd5 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolOps.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolOps.ts @@ -2,33 +2,48 @@ import { original } from 'immer'; import { z } from 'zod'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { UserToolRegistration } from './userTool'; export type UserToolModelState = Map; -const toolsRegisterUserToolSchema = z.custom(); +const toolsRegisterUserToolSchema = z.object({ + agentId: z.string(), + name: z.string(), + description: z.string(), + parameters: z.custom(), + disclosure: z.custom().optional(), +}); -export class ToolsRegisterUserTool extends Event2> { +export class ToolsRegisterUserTool extends AgentEvent2< + z.infer +> { static override readonly type = 'tools.register_user_tool'; static override readonly durable = true; static override readonly schema = toolsRegisterUserToolSchema; } -export interface ToolsRegisterUserTool extends z.infer {} +export interface ToolsRegisterUserTool extends UserToolRegistration { + readonly agentId: string; +} -const toolsUnregisterUserToolSchema = z.object({ name: z.string() }); +const toolsUnregisterUserToolSchema = z.object({ + agentId: z.string(), + name: z.string(), +}); -export class ToolsUnregisterUserTool extends Event2< +export class ToolsUnregisterUserTool extends AgentEvent2< z.infer > { static override readonly type = 'tools.unregister_user_tool'; static override readonly durable = true; static override readonly schema = toolsUnregisterUserToolSchema; } -export interface ToolsUnregisterUserTool - extends z.infer {} +export interface ToolsUnregisterUserTool { + readonly agentId: string; + readonly name: string; +} function equalRegistration(a: UserToolRegistration, b: UserToolRegistration): boolean { return ( diff --git a/packages/agent-core-v2/src/agent/userTool/userToolService.ts b/packages/agent-core-v2/src/agent/userTool/userToolService.ts index e2b309800bf..54e2ec4dc07 100644 --- a/packages/agent-core-v2/src/agent/userTool/userToolService.ts +++ b/packages/agent-core-v2/src/agent/userTool/userToolService.ts @@ -14,6 +14,7 @@ import type { import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { ISessionInteractionService } from '#/session/interaction/interaction'; import { IAgentStateService } from '#/agent/state/agentState'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { IAgentUserToolService, type UserToolRegistration } from './userTool'; @@ -36,6 +37,7 @@ export class AgentUserToolService extends Service implements IAgentUserToolServi private readonly registrations = new Map(); constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentToolRegistryService private readonly registry: IAgentToolRegistryService, @IAgentProfileService private readonly profile: IAgentProfileService, @ISessionInteractionService private readonly interaction: ISessionInteractionService, @@ -63,12 +65,16 @@ export class AgentUserToolService extends Service implements IAgentUserToolServi } register(input: UserToolRegistration): void { - void this.dispatcher.dispatch(new ToolsRegisterUserTool(input)); + void this.dispatcher.dispatch( + new ToolsRegisterUserTool({ ...input, agentId: this.scopeContext.agentId }), + ); this.applyRegister(input); } unregister(name: string): void { - void this.dispatcher.dispatch(new ToolsUnregisterUserTool({ name })); + void this.dispatcher.dispatch( + new ToolsUnregisterUserTool({ agentId: this.scopeContext.agentId, name }), + ); this.applyUnregister(name); } diff --git a/packages/agent-core-v2/src/app/event/event2.ts b/packages/agent-core-v2/src/app/event/event2.ts index ef6f9a0175b..62dcfb099ff 100644 --- a/packages/agent-core-v2/src/app/event/event2.ts +++ b/packages/agent-core-v2/src/app/event/event2.ts @@ -23,6 +23,7 @@ export abstract class Event2

> { declare static readonly type: string; static readonly durable: boolean = false; static readonly observable: boolean = false; + static readonly agentDomain: boolean = false; declare static readonly schema: z.ZodType | undefined; readonly type: string; @@ -45,11 +46,22 @@ export abstract class Event2

> { } } +export interface AgentDomainTrait { + readonly agentId: string; +} + +export abstract class AgentEvent2

extends Event2

{ + static override readonly agentDomain = true; + + declare readonly agentId: string; +} + export interface Event2Class

= Event2

> { new (payload: P, time?: number): E; readonly type: string; readonly durable: boolean; readonly observable: boolean; + readonly agentDomain: boolean; readonly schema: z.ZodType

| undefined; } diff --git a/packages/agent-core-v2/src/app/event/eventBus.ts b/packages/agent-core-v2/src/app/event/eventBus.ts index 1ad446c23ea..99fdadd12f9 100644 --- a/packages/agent-core-v2/src/app/event/eventBus.ts +++ b/packages/agent-core-v2/src/app/event/eventBus.ts @@ -1,15 +1,35 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import { type IDisposable } from '#/_base/di/lifecycle'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; -import type { Event2, Event2Class } from './event2'; +import type { AgentDomainTrait, Event2, Event2Class } from './event2'; export interface IEventBus { readonly _serviceBrand: undefined; - publish(event: Event2): void; + publish(event: Event2, agent?: AgentContext): void; subscribe(handler: (event: Event2) => void): IDisposable; subscribe>(cls: Event2Class, handler: (event: E) => void): IDisposable; subscribe(type: string, handler: (event: Event2) => void): IDisposable; } export const IEventBus: ServiceIdentifier = createDecorator('eventBus'); + +export interface ISessionEventBus extends IEventBus { + activateAgent(agent: AgentContext): void; + deactivateAgent(agent: AgentContext): void; + sourceOf(event: Event2): AgentContext | undefined; + onAgent

>( + agent: AgentContext, + cls: Event2Class, + handler: (event: E) => void, + ): IDisposable; + onAgent( + agent: AgentContext, + type: string, + handler: (event: Event2 & AgentDomainTrait) => void, + ): IDisposable; +} + +export const ISessionEventBus: ServiceIdentifier = + createDecorator('sessionEventBus'); diff --git a/packages/agent-core-v2/src/app/event/eventBusService.ts b/packages/agent-core-v2/src/app/event/eventBusService.ts index 370b9dff673..340bdb16f30 100644 --- a/packages/agent-core-v2/src/app/event/eventBusService.ts +++ b/packages/agent-core-v2/src/app/event/eventBusService.ts @@ -3,21 +3,78 @@ import { Service } from '#/_base/di/service'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import type { Event2, Event2Class } from './event2'; -import { IEventBus } from './eventBus'; +import type { AgentDomainTrait, Event2, Event2Class } from './event2'; +import { IEventBus, ISessionEventBus } from './eventBus'; -export class EventBusService extends Service implements IEventBus { +export class EventBusService extends Service implements ISessionEventBus { declare readonly _serviceBrand: undefined; private readonly allEmitter = this._register(new Emitter>('*')); private readonly perType = new Map>>(); + private readonly agents = new Map(); + private readonly sources = new WeakMap, AgentContext>(); - publish(event: Event2): void { + activateAgent(agent: AgentContext): void { + this.agents.set(agent.agentId, agent); + } + + deactivateAgent(agent: AgentContext): void { + if (this.agents.get(agent.agentId) === agent) this.agents.delete(agent.agentId); + } + + publish(event: Event2, agent?: AgentContext): void { + const cls = event.constructor as Event2Class; + if (cls.agentDomain) { + if ( + agent === undefined || + this.agents.get(agent.agentId) !== agent || + (event as Event2 & AgentDomainTrait).agentId !== agent.agentId + ) { + throw new Error(`Agent event '${event.type}' has no active lifecycle context`); + } + } + if (agent !== undefined) this.sources.set(event, agent); this.allEmitter.fire(event); this.perType.get(event.type)?.fire(event); } + sourceOf(event: Event2): AgentContext | undefined { + return this.sources.get(event); + } + + onAgent

>( + agent: AgentContext, + cls: Event2Class, + handler: (event: E) => void, + ): IDisposable; + onAgent( + agent: AgentContext, + type: string, + handler: (event: Event2 & AgentDomainTrait) => void, + ): IDisposable; + onAgent( + agent: AgentContext, + typeOrClass: string | Event2Class, + handler: (event: any) => void, + ): IDisposable { + if (this.agents.get(agent.agentId) !== agent) { + throw new Error( + `Agent ${agent.agentId}:${String(agent.generation)} is not the active lifecycle context`, + ); + } + return this.subscribe(typeOrClass as string, (event) => { + if ( + this.agents.get(agent.agentId) === agent && + (event as Event2 & AgentDomainTrait).agentId === agent.agentId + ) { + handler(event); + } + }); + } + listenerCounts(): { all: number; perType: Record } { const perType: Record = {}; for (const [type, emitter] of this.perType) { @@ -49,10 +106,91 @@ export class EventBusService extends Service implements IEventBus { } } +export class AgentEventBusView extends Service implements IEventBus { + declare readonly _serviceBrand: undefined; + private readonly agent: AgentContext; + + constructor( + @ISessionEventBus private readonly bus: ISessionEventBus, + @IAgentScopeContext scope: IAgentScopeContext, + ) { + super(); + this.agent = scope.agentContext; + } + + activateAgent(agent: AgentContext): void { + this.bus.activateAgent(agent); + } + + deactivateAgent(agent: AgentContext): void { + this.bus.deactivateAgent(agent); + } + + publish(event: Event2, agent: AgentContext = this.agent): void { + if (agent !== this.agent) throw new Error('Agent event bus view received a foreign context'); + this.bus.publish(event, this.agent); + } + + onAgent

>( + agent: AgentContext, + cls: Event2Class, + handler: (event: E) => void, + ): IDisposable; + onAgent( + agent: AgentContext, + type: string, + handler: (event: Event2 & AgentDomainTrait) => void, + ): IDisposable; + onAgent( + agent: AgentContext, + typeOrClass: string | Event2Class, + handler: (event: Event2 & AgentDomainTrait) => void, + ): IDisposable { + if (agent !== this.agent) throw new Error('Agent event bus view received a foreign context'); + return this.bus.onAgent(agent, typeOrClass as string, handler); + } + + subscribe(handler: (event: Event2) => void): IDisposable; + subscribe>( + cls: Event2Class, + handler: (event: E) => void, + ): IDisposable; + subscribe(type: string, handler: (event: Event2) => void): IDisposable; + subscribe( + typeOrHandler: string | Event2Class | ((event: Event2) => void), + handler?: (event: Event2) => void, + ): IDisposable { + if ((this.bus as unknown) === undefined) return { dispose: () => {} }; + const matches = (event: Event2): boolean => { + const cls = event.constructor as Event2Class; + if (cls.agentDomain) { + return (event as Event2 & AgentDomainTrait).agentId === this.agent.agentId; + } + return this.bus.sourceOf(event) === this.agent; + }; + if (typeof typeOrHandler === 'function' && !('type' in typeOrHandler)) { + return this.bus.subscribe((event) => { + if (matches(event)) typeOrHandler(event); + }); + } + return this.bus.subscribe(typeOrHandler as string, (event) => { + if (matches(event)) handler!(event); + }); + } +} + registerScopedService( - LifecycleScope.Agent, - IEventBus, + LifecycleScope.Session, + ISessionEventBus, EventBusService, ScopeActivation.OnScopeCreated, 'event', ); + +registerScopedService( + LifecycleScope.Agent, + IEventBus, + AgentEventBusView, + ScopeActivation.OnDemand, + 'eventView', +); diff --git a/packages/agent-core-v2/src/app/gateway/gatewayService.ts b/packages/agent-core-v2/src/app/gateway/gatewayService.ts index 8640ad02e44..4a7092d23a6 100644 --- a/packages/agent-core-v2/src/app/gateway/gatewayService.ts +++ b/packages/agent-core-v2/src/app/gateway/gatewayService.ts @@ -30,7 +30,7 @@ export class RestGateway implements IRestGateway { }); } const agents = session.accessor.get(IAgentLifecycleService); - const agent = agents.get(agentId); + const agent = agents.list().find((handle) => handle.id === agentId); if (agent === undefined) { throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `unknown agent '${agentId}'`, { details: { agentId, sessionId }, diff --git a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts index bb526b56789..1eb60123ac4 100644 --- a/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts +++ b/packages/agent-core-v2/src/app/sessionLegacy/sessionLegacyService.ts @@ -12,12 +12,13 @@ import { IInstantiationService, type ServicesAccessor, } from '#/_base/di/instantiation'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPlanService } from '#/features/plan/plan'; import { IAgentProfileService } from '#/agent/profile/profile'; import { IAgentSwarmService } from '#/features/swarm/agent/swarm'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { getLiveSessionById, resumeSessionById, @@ -64,7 +65,7 @@ export class SessionLegacyService implements ISessionLegacyService { agent: IAgentScopeHandle, ): Promise { const profile = agent.accessor.get(IAgentProfileService); - const tokenCounting = agent.accessor.get(IAgentTokenCountingService); + const tokenCounting = agent.accessor.get(ISessionTokenCountingService); const permission = agent.accessor.get(IAgentPermissionModeService); const plan = agent.accessor.get(IAgentPlanService); const swarm = agent.accessor.get(IAgentSwarmService); @@ -75,7 +76,7 @@ export class SessionLegacyService implements ISessionLegacyService { if (maxTokens === 0 && model === '') { maxTokens = resolveDefaultModelContextTokens(agent) ?? 0; } - const tokens = tokenCounting.statusSize(); + const tokens = tokenCounting.statusSize(agentContextOf(agent)); const planData = await plan.status(); return { diff --git a/packages/agent-core-v2/src/features/btw/btwService.ts b/packages/agent-core-v2/src/features/btw/btwService.ts index 2e85c45c68f..9c1e2f3ffc2 100644 --- a/packages/agent-core-v2/src/features/btw/btwService.ts +++ b/packages/agent-core-v2/src/features/btw/btwService.ts @@ -2,7 +2,9 @@ import { IAgentSystemReminderService } from '#/agent/systemReminder/systemRemind import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { ErrorCodes, Error2 } from '#/errors'; +import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER, TOOL_CALL_DISABLED_MESSAGE } from './btw'; @@ -14,7 +16,11 @@ export class SessionBtwService implements ISessionBtwService { ) {} async start(): Promise { - const child = await this.lifecycle.fork('main'); + const main = this.lifecycle.findAgentHandle(MAIN_AGENT_ID); + if (main === undefined) { + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); + } + const child = await this.lifecycle.fork(main.accessor.get(IAgentScopeContext).agentContext); child.accessor .get(IAgentSystemReminderService) ?.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER, { 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 e7866de5c74..81d25799160 100644 --- a/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts +++ b/packages/agent-core-v2/src/features/externalHooks/agent/agentExternalHooksService.ts @@ -3,6 +3,7 @@ import { IInstantiationService } from '#/_base/di/instantiation'; import { Service } from '#/_base/di/service'; import { defineState } from '#/state/state'; import { isPlainRecord } from '#/_base/utils/canonical-args'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IAgentTaskService, type AgentTaskInfo, type AgentTaskNotificationContext } from '#/agent/task/task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -27,7 +28,7 @@ import { PermissionApprovalResolved, } from '#/agent/toolApproval/toolApprovalService'; import { IEventBus } from '#/app/event/eventBus'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ResolvedToolExecutionHookContext, ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; @@ -46,13 +47,14 @@ import { } from '../internal/userPrompt'; export interface HookResultPayload { + readonly agentId: string; readonly turnId?: number; readonly hookEvent: string; readonly content: string; readonly blocked?: boolean; } -export class HookResult extends Event2 { +export class HookResult extends AgentEvent2 { static override readonly type = 'hook.result'; static override readonly observable = true; } @@ -74,6 +76,7 @@ export class AgentExternalHooksService extends Service implements IAgentExternal @ISessionContext private readonly sessionContext: ISessionContext, @ISessionMetadata private readonly sessionMetadata: ISessionMetadata, @IAgentStateService private readonly states: IAgentStateService, + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IEventDispatcher private readonly dispatcher: IEventDispatcher, ) { super(); @@ -361,6 +364,7 @@ export class AgentExternalHooksService extends Service implements IAgentExternal }); void this.dispatcher.dispatch( new HookResult({ + agentId: this.scopeContext.agentId, hookEvent: block.event, content: block.message, blocked: true, @@ -379,6 +383,7 @@ export class AgentExternalHooksService extends Service implements IAgentExternal }); void this.dispatcher.dispatch( new HookResult({ + agentId: this.scopeContext.agentId, hookEvent: append.event, content: append.message, }), diff --git a/packages/agent-core-v2/src/features/feature.ts b/packages/agent-core-v2/src/features/feature.ts index 6b50eee632a..e3233f1580b 100644 --- a/packages/agent-core-v2/src/features/feature.ts +++ b/packages/agent-core-v2/src/features/feature.ts @@ -28,12 +28,41 @@ import { type AgentToolCtor, type AnyAgentTool, } from '#/agent/toolRegistry/toolContribution'; +import type { + AgentEffectDefinition, + SessionEffectDefinition, +} from '#/state/agentEffect'; +import { AgentEffectContribution, SessionEffectContribution } from '#/state/agentEffect'; +import type { + AgentModel, + AgentModelDefinition, + SessionModelDefinition, +} from '#/state/agentModel'; +import { AgentModelContribution, SessionModelContribution } from '#/state/agentModel'; export abstract class Feature extends Service { contribute(token: CollectionToken, value: T): FiberHandle { return this.provide(token, value); } + contributeSessionModel(definition: SessionModelDefinition): FiberHandle { + return this.provide(SessionModelContribution, definition as SessionModelDefinition); + } + + contributeAgentModel>( + definition: AgentModelDefinition, + ): FiberHandle { + return this.provide(AgentModelContribution, definition as AgentModelDefinition); + } + + contributeSessionEffect(definition: SessionEffectDefinition): FiberHandle { + return this.provide(SessionEffectContribution, definition); + } + + contributeAgentEffect(definition: AgentEffectDefinition): FiberHandle { + return this.provide(AgentEffectContribution, definition); + } + contributeConfig( domain: string, schema: ConfigSchema, diff --git a/packages/agent-core-v2/src/features/plan/planOps.ts b/packages/agent-core-v2/src/features/plan/planOps.ts index ff951806109..3cdda71afd4 100644 --- a/packages/agent-core-v2/src/features/plan/planOps.ts +++ b/packages/agent-core-v2/src/features/plan/planOps.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import '#/agent/contextMemory/conversationTime'; @@ -13,34 +13,50 @@ export interface PlanState { readonly revisionCount?: Readonly>; } -const planModeEnterSchema = z.object({ id: z.string() }); +const planModeEnterSchema = z.object({ agentId: z.string(), id: z.string() }); -export class PlanModeEnter extends Event2> { +export class PlanModeEnter extends AgentEvent2> { static override readonly type = 'plan_mode.enter'; static override readonly durable = true; static override readonly schema = planModeEnterSchema; } -export interface PlanModeEnter extends z.infer {} +export interface PlanModeEnter { + readonly agentId: string; + readonly id: string; +} -const planModeCancelSchema = z.object({ id: z.string().optional() }); +const planModeCancelSchema = z.object({ + agentId: z.string(), + id: z.string().optional(), +}); -export class PlanModeCancel extends Event2> { +export class PlanModeCancel extends AgentEvent2> { static override readonly type = 'plan_mode.cancel'; static override readonly durable = true; static override readonly schema = planModeCancelSchema; } -export interface PlanModeCancel extends z.infer {} +export interface PlanModeCancel { + readonly agentId: string; + readonly id?: string; +} -const planModeExitSchema = z.object({ id: z.string().optional() }); +const planModeExitSchema = z.object({ + agentId: z.string(), + id: z.string().optional(), +}); -export class PlanModeExit extends Event2> { +export class PlanModeExit extends AgentEvent2> { static override readonly type = 'plan_mode.exit'; static override readonly durable = true; static override readonly schema = planModeExitSchema; } -export interface PlanModeExit extends z.infer {} +export interface PlanModeExit { + readonly agentId: string; + readonly id?: string; +} export interface PlanRevisionRecordedEvent { + readonly agentId: string; readonly id: string; readonly version: number; readonly path: string; @@ -49,6 +65,7 @@ export interface PlanRevisionRecordedEvent { } const planRevisionSchema = z.object({ + agentId: z.string(), id: z.string(), version: z.number(), path: z.string(), @@ -56,7 +73,7 @@ const planRevisionSchema = z.object({ bytes: z.number(), }); -export class PlanRevision extends Event2 { +export class PlanRevision extends AgentEvent2 { static override readonly type = 'plan.revision'; static override readonly durable = true; static override readonly observable = true; @@ -72,21 +89,21 @@ export const planKey = defineState('plan', (): PlanState => ({ active: false })) s.active = true; s.id = e.id; } - ctx.emit(new AgentStatusUpdated({ planMode: true })); + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, planMode: true })); }) .on(PlanModeCancel, (s, e, ctx) => { if (s.active) { s.active = false; delete s.id; } - ctx.emit(new AgentStatusUpdated({ planMode: false })); + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, planMode: false })); }) .on(PlanModeExit, (s, e, ctx) => { if (s.active) { s.active = false; delete s.id; } - ctx.emit(new AgentStatusUpdated({ planMode: false })); + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, planMode: false })); }) .on(PlanRevision, (s, e) => { s.revisionCount = { ...s.revisionCount, [e.id]: e.version }; diff --git a/packages/agent-core-v2/src/features/plan/planService.ts b/packages/agent-core-v2/src/features/plan/planService.ts index 203ea4d9dc3..4f5f5fbbf77 100644 --- a/packages/agent-core-v2/src/features/plan/planService.ts +++ b/packages/agent-core-v2/src/features/plan/planService.ts @@ -79,7 +79,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { eventBus.subscribe(ContextUndone, () => { this.restoreTelemetryMode(); void this.dispatcher.dispatch( - new AgentStatusUpdated({ planMode: this.isActive }), + new AgentStatusUpdated({ agentId: this.agentCtx.agentId, planMode: this.isActive }), ); }), ); @@ -168,7 +168,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { let enterRecorded = false; try { await this.ensurePlanDirectory(planFilePath); - await this.dispatcher.dispatch(new PlanModeEnter({ id })); + await this.dispatcher.dispatch(new PlanModeEnter({ agentId: this.agentCtx.agentId, id })); this.telemetryContext.set({ mode: 'plan' }); enterRecorded = true; if (createFile) { @@ -183,7 +183,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { } cancel(id?: string): void { - void this.dispatcher.dispatch(new PlanModeCancel({ id })); + void this.dispatcher.dispatch(new PlanModeCancel({ agentId: this.agentCtx.agentId, id })); this.telemetryContext.set({ mode: 'agent' }); } @@ -194,7 +194,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { } exit(id?: string): void { - void this.dispatcher.dispatch(new PlanModeExit({ id })); + void this.dispatcher.dispatch(new PlanModeExit({ agentId: this.agentCtx.agentId, id })); this.telemetryContext.set({ mode: 'agent' }); } @@ -210,6 +210,7 @@ export class AgentPlanService extends Service implements IAgentPlanService { await this.blobs.put(scope, key, bytes); await this.dispatcher.dispatch( new PlanRevision({ + agentId: this.agentCtx.agentId, id, version, path: `${scope}/${key}`, diff --git a/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts index c4d73956263..1422a1b5a67 100644 --- a/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts +++ b/packages/agent-core-v2/src/features/sessionInit/sessionInitService.ts @@ -6,6 +6,7 @@ import { IAgentProfileService } from '#/agent/profile/profile'; import { loadAgentsMdDetailed } from '#/agent/profile/context'; import { IAgentAgentsMdReminderService } from '#/agent/agentsMdReminder/agentsMdReminder'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IEventDispatcher } from '#/state/eventDispatcher'; import { ErrorCodes, Error2 } from '#/errors'; @@ -40,7 +41,7 @@ export class SessionInitService implements ISessionInitService { } async generateAgentsMd(): Promise { - const main = this.lifecycle.get(MAIN_AGENT_ID); + const main = this.lifecycle.list().find((handle) => handle.id === MAIN_AGENT_ID); if (main === undefined) { throw new Error2(ErrorCodes.AGENT_NOT_FOUND, 'Main agent was not found'); } @@ -72,7 +73,7 @@ export class SessionInitService implements ISessionInitService { }); const run = await this.subagents.run( - child.id, + agentContextOf(child), { kind: 'prompt', prompt: DEFAULT_INIT_PROMPT }, { signal: controller.signal }, ); diff --git a/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts index 73d313d706d..b984b64f8ee 100644 --- a/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/agent/swarmService.ts @@ -6,6 +6,7 @@ import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { denyToolExecution } from '#/agent/toolExecutor/beforeToolExecuteEvent'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { IEventDispatcher } from '#/state/eventDispatcher'; @@ -23,6 +24,7 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, @IAgentStateService private readonly agentState: IAgentStateService, ) { super(); @@ -62,13 +64,13 @@ export class AgentSwarmService extends Service implements IAgentSwarmService { enter(trigger: SwarmModeTrigger): void { if (this.agentState.get(swarmKey) !== null) return; - void this.dispatcher.dispatch(new SwarmModeEnter({ trigger })); + void this.dispatcher.dispatch(new SwarmModeEnter({ agentId: this.agentCtx.agentId, trigger })); } exit(): void { if (this.agentState.get(swarmKey) === null) return; const history = this.context.get(); - void this.dispatcher.dispatch(new SwarmModeExit({})); + void this.dispatcher.dispatch(new SwarmModeExit({ agentId: this.agentCtx.agentId })); this.context.publishTrailingRemoval(history); } 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 a3338bfc051..4f13f03f57a 100644 --- a/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts +++ b/packages/agent-core-v2/src/features/swarm/session/sessionSwarmService.ts @@ -11,6 +11,7 @@ import { IAgentUserToolService } from '#/agent/userTool/userTool'; import { Event2 } from '#/app/event/event2'; import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalog/sessionAgentProfileCatalog'; import { applyProfilePromptPrefix } from '#/app/agentProfileCatalog/promptPrefix'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { isSubagentMeta, @@ -97,7 +98,7 @@ export class SessionSwarmService implements ISessionSwarmService { resume: (agentId, options) => this.resumeAttempt(callerAgentId, agentId, options, false), retry: (agentId, options) => this.resumeAttempt(callerAgentId, agentId, options, true), suspended: (event) => { - const caller = this.lifecycle.get(callerAgentId); + const caller = this.lifecycle.findAgentHandle(callerAgentId); void caller?.accessor.get(IEventDispatcher)?.dispatch( new SubagentSuspended({ subagentId: event.agentId, @@ -185,7 +186,7 @@ export class SessionSwarmService implements ISessionSwarmService { } finally { lease.dispose(); } - return this.observe(caller, child.id, options.profileName, { + return this.observe(caller, child, options.profileName, { kind: 'prompt', prompt: promptText, }, options); @@ -219,17 +220,18 @@ export class SessionSwarmService implements ISessionSwarmService { const request = retryTurn ? ({ kind: 'retry' } as const) : ({ kind: 'prompt', prompt: options.prompt } as const); - return this.observe(caller, child.id, profileName, request, options); + return this.observe(caller, child, profileName, request, options); } private async observe( caller: IAgentScopeHandle, - agentId: string, + child: IAgentScopeHandle, profileName: string, request: { kind: 'prompt'; prompt: string } | { kind: 'retry' }, options: AgentRunAttemptOptions, ): Promise { - const run = await this.subagents.run(agentId, request, { + const agentId = child.id; + const run = await this.subagents.run(agentContextOf(child), request, { signal: options.signal, onReady: options.onReady, }); @@ -247,7 +249,7 @@ export class SessionSwarmService implements ISessionSwarmService { } private requireHandle(agentId: string, label: string): IAgentScopeHandle { - const handle = this.lifecycle.get(agentId); + const handle = this.lifecycle.findAgentHandle(agentId); if (handle === undefined) { throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `${label} "${agentId}" does not exist`, { details: { agentId }, diff --git a/packages/agent-core-v2/src/features/swarm/swarmOps.ts b/packages/agent-core-v2/src/features/swarm/swarmOps.ts index 1e2127dcc6c..46102c1ddc6 100644 --- a/packages/agent-core-v2/src/features/swarm/swarmOps.ts +++ b/packages/agent-core-v2/src/features/swarm/swarmOps.ts @@ -3,38 +3,46 @@ import { z } from 'zod'; import { contextMemoryKey, popSwarmModeReminder } from '#/agent/contextMemory/contextOps'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { SwarmModeTrigger } from './agent/swarm'; -const swarmModeEnterSchema = z.object({ trigger: z.custom() }); +const swarmModeEnterSchema = z.object({ + agentId: z.string(), + trigger: z.custom(), +}); -export class SwarmModeEnter extends Event2> { +export class SwarmModeEnter extends AgentEvent2> { static override readonly type = 'swarm_mode.enter'; static override readonly durable = true; static override readonly schema = swarmModeEnterSchema; } -export interface SwarmModeEnter extends z.infer {} +export interface SwarmModeEnter { + readonly agentId: string; + readonly trigger: SwarmModeTrigger; +} -const swarmModeExitSchema = z.object({}); +const swarmModeExitSchema = z.object({ agentId: z.string() }); -export class SwarmModeExit extends Event2> { +export class SwarmModeExit extends AgentEvent2> { static override readonly type = 'swarm_mode.exit'; static override readonly durable = true; static override readonly schema = swarmModeExitSchema; } -export interface SwarmModeExit extends z.infer {} +export interface SwarmModeExit { + readonly agentId: string; +} export const swarmKey = defineState('swarm', (): SwarmModeTrigger | null => null).replayable({ schema: z.custom(), }) .on(SwarmModeEnter, (_s, e, ctx) => { - ctx.emit(new AgentStatusUpdated({ swarmMode: true })); + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, swarmMode: true })); return e.trigger; }) - .on(SwarmModeExit, (_s, _e, ctx) => { - ctx.emit(new AgentStatusUpdated({ swarmMode: false })); + .on(SwarmModeExit, (_s, e, ctx) => { + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, swarmMode: false })); return null; }); diff --git a/packages/agent-core-v2/src/features/todo/todoFeature.ts b/packages/agent-core-v2/src/features/todo/todoFeature.ts new file mode 100644 index 00000000000..0e26e9bb6a9 --- /dev/null +++ b/packages/agent-core-v2/src/features/todo/todoFeature.ts @@ -0,0 +1,23 @@ +import { LifecycleScope } from '#/app/scopes'; +import { ITodoListTool } from '#/agent/tools/todo-list/todo-list'; +import { TodoListTool } from '#/agent/tools/todo-list/todoListTool'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; +import { ISessionTodoService } from '#/session/todo/sessionTodo'; +import { SessionTodoService } from '#/session/todo/sessionTodoService'; +import { TodoAgentEffectDefinition } from '#/session/todo/todoAgentEffect'; +import { TodoAgentModelDefinition } from '#/session/todo/todoAgentModel'; + +export class TodoFeature extends Feature { + static override readonly name = 'todo'; + + constructor() { + super(); + this.contributeAgentModel(TodoAgentModelDefinition); + this.contributeAgentEffect(TodoAgentEffectDefinition); + this.contributeService(LifecycleScope.Session, ISessionTodoService, SessionTodoService); + this.contributeTool(ITodoListTool, TodoListTool, { name: 'TodoList', domain: 'todo' }); + } +} + +registerFeature(TodoFeature); diff --git a/packages/agent-core-v2/src/features/tokenCounting/tokenCountingFeature.ts b/packages/agent-core-v2/src/features/tokenCounting/tokenCountingFeature.ts new file mode 100644 index 00000000000..bd88d6effd8 --- /dev/null +++ b/packages/agent-core-v2/src/features/tokenCounting/tokenCountingFeature.ts @@ -0,0 +1,22 @@ +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; +import { SessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCountingService'; +import { TokenCountingAgentModelDefinition } from '#/session/tokenCounting/tokenCountingAgentModel'; + +export class TokenCountingFeature extends Feature { + static override readonly name = 'tokenCounting'; + + constructor() { + super(); + this.contributeAgentModel(TokenCountingAgentModelDefinition); + this.contributeService( + LifecycleScope.Session, + ISessionTokenCountingService, + SessionTokenCountingService, + ); + } +} + +registerFeature(TokenCountingFeature); 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 9325c7dc4c8..bdba38ea424 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 @@ -29,6 +29,7 @@ import { type ExecutableToolResult, type ToolExecution, } from '#/tool/toolContract'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { subagentLabels } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -265,7 +266,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { controller: AbortController, binding: SubagentBinding | undefined, ): Promise { - const requester = this.lifecycle.get(this.callerAgentId); + const requester = this.lifecycle.findAgentHandle(this.callerAgentId); if (requester === undefined) { throw new Error(`Caller agent "${this.callerAgentId}" does not exist`); } @@ -297,7 +298,7 @@ export class TowerSpawnTool implements ITowerSpawnTool { }); const run = await this.subagents.run( - agentId, + agentContextOf(created), { kind: 'prompt', prompt }, { signal: controller.signal }, ); diff --git a/packages/agent-core-v2/src/features/tower/towerOps.ts b/packages/agent-core-v2/src/features/tower/towerOps.ts index ef719f0dc91..540b9a7f888 100644 --- a/packages/agent-core-v2/src/features/tower/towerOps.ts +++ b/packages/agent-core-v2/src/features/tower/towerOps.ts @@ -2,35 +2,39 @@ import { z } from 'zod'; import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; -import { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; -const towerModeEnterSchema = z.object({}); +const towerModeEnterSchema = z.object({ agentId: z.string() }); -export class TowerModeEnter extends Event2> { +export class TowerModeEnter extends AgentEvent2> { static override readonly type = 'tower_mode.enter'; static override readonly durable = true; static override readonly schema = towerModeEnterSchema; } -export interface TowerModeEnter extends z.infer {} +export interface TowerModeEnter { + readonly agentId: string; +} -const towerModeExitSchema = z.object({}); +const towerModeExitSchema = z.object({ agentId: z.string() }); -export class TowerModeExit extends Event2> { +export class TowerModeExit extends AgentEvent2> { static override readonly type = 'tower_mode.exit'; static override readonly durable = true; static override readonly schema = towerModeExitSchema; } -export interface TowerModeExit extends z.infer {} +export interface TowerModeExit { + readonly agentId: string; +} export const towerKey = defineState('tower', () => false).replayable({ schema: z.boolean(), }) - .on(TowerModeEnter, (_s, _e, ctx) => { - ctx.emit(new AgentStatusUpdated({ towerMode: true })); + .on(TowerModeEnter, (_s, e, ctx) => { + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, towerMode: true })); return true; }) - .on(TowerModeExit, (_s, _e, ctx) => { - ctx.emit(new AgentStatusUpdated({ towerMode: false })); + .on(TowerModeExit, (_s, e, ctx) => { + ctx.emit(new AgentStatusUpdated({ agentId: e.agentId, towerMode: false })); return false; }); diff --git a/packages/agent-core-v2/src/features/tower/towerService.ts b/packages/agent-core-v2/src/features/tower/towerService.ts index 79d5b39c5f8..984902baea5 100644 --- a/packages/agent-core-v2/src/features/tower/towerService.ts +++ b/packages/agent-core-v2/src/features/tower/towerService.ts @@ -94,12 +94,12 @@ export class AgentTowerService extends Disposable implements IAgentTowerService enter(): void { if (!this.flags.enabled(TOWER_FLAG_ID)) return; if (this.isActive) return; - void this.dispatcher.dispatch(new TowerModeEnter({})); + void this.dispatcher.dispatch(new TowerModeEnter({ agentId: this.agentCtx.agentId })); } exit(): void { if (!this.isActive) return; - void this.dispatcher.dispatch(new TowerModeExit({})); + void this.dispatcher.dispatch(new TowerModeExit({ agentId: this.agentCtx.agentId })); } get isActive(): boolean { diff --git a/packages/agent-core-v2/src/features/usage/usageFeature.ts b/packages/agent-core-v2/src/features/usage/usageFeature.ts new file mode 100644 index 00000000000..d22046eacb3 --- /dev/null +++ b/packages/agent-core-v2/src/features/usage/usageFeature.ts @@ -0,0 +1,18 @@ +import { LifecycleScope } from '#/app/scopes'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; +import { SessionUsageService } from '#/session/usage/sessionUsageService'; +import { UsageAgentModelDefinition } from '#/session/usage/usageAgentModel'; + +export class UsageFeature extends Feature { + static override readonly name = 'usage'; + + constructor() { + super(); + this.contributeAgentModel(UsageAgentModelDefinition); + this.contributeService(LifecycleScope.Session, ISessionUsageService, SessionUsageService); + } +} + +registerFeature(UsageFeature); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index b4320a000d6..95311fd11e7 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -108,6 +108,8 @@ export * from '#/app/event/event2'; export * from '#/state/errors'; export * from '#/state/state'; export * from '#/state/stateContribution'; +export * from '#/state/agentModel'; +export * from '#/state/agentEffect'; export * from '#/state/eventDispatcher'; import '#/state/eventDispatcherService'; export * from '#/_base/state/stateRegistry'; @@ -372,7 +374,10 @@ export * from '#/features/tower/tools/status/status'; export * from '#/features/tower/skill/skill'; import '#/features/tower/towerFeature'; export * from '#/agent/usage/usage'; -export * from '#/agent/usage/usageService'; +export * from '#/session/usage/sessionUsage'; +export * from '#/session/usage/usageAgentModel'; +export * from '#/session/usage/sessionUsageService'; +import '#/features/usage/usageFeature'; export * from '#/agent/toolDedupe/toolDedupe'; export * from '#/agent/toolDedupe/toolDedupeService'; export * from '#/agent/agentsMdReminder/agentsMdReminder'; @@ -608,7 +613,10 @@ export * from '#/agent/contextProjector/contextProjectorService'; export * from '#/agent/contextProjector/mediaProjection'; export * from '#/agent/tokenCounting/tokenCounting'; export * from '#/agent/tokenCounting/tokenCountingOps'; -export * from '#/agent/tokenCounting/tokenCountingService'; +export * from '#/session/tokenCounting/sessionTokenCounting'; +export * from '#/session/tokenCounting/tokenCountingAgentModel'; +export * from '#/session/tokenCounting/sessionTokenCountingService'; +import '#/features/tokenCounting/tokenCountingFeature'; export * from '#/agent/contextInjector/contextInjector'; export * from '#/agent/contextInjector/contextInjectorService'; export * from '#/agent/plugin/agentPlugin'; @@ -682,6 +690,8 @@ export * from '#/agent/undo/undo'; export * from '#/agent/undo/undoService'; export * from '#/agent/shellCommand/shellCommand'; 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'; @@ -692,9 +702,11 @@ import '#/features/sessionInit/sessionInitFeature'; export * from '#/session/todo/todoItem'; export * from '#/session/todo/todoListReminder'; export * from '#/session/todo/sessionTodo'; +export * from '#/session/todo/todoAgentModel'; +export * from '#/session/todo/todoAgentEffect'; export * from '#/session/todo/sessionTodoService'; export * from '#/agent/tools/todo-list/todo-list'; -import '#/agent/tools/todo-list/todoListTool'; +import '#/features/todo/todoFeature'; export * from '#/tool/toolContract'; export * from '#/agent/toolExecutor/toolHooks'; export * from '#/agent/toolExecutor/toolExecutor'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts index 004baef4d41..fb3376ce27b 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycle.ts @@ -1,9 +1,15 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; import type { IAgentScopeHandle } from '#/_base/di/scope'; import type { Event } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import type { BindAgentInput } from '#/agent/profile/profile'; +export interface AgentScopeCreatedEvent { + readonly context: AgentContext; + readonly handle: IAgentScopeHandle; +} + export const MAIN_AGENT_ID = 'main'; export interface CreateAgentOptions { @@ -26,17 +32,19 @@ export interface AgentListFilter { export interface IAgentLifecycleService { readonly _serviceBrand: undefined; - readonly onDidCreate: Event; - readonly onDidDispose: Event; + readonly onDidCreate: Event; + readonly onDidCreateScope: Event; + readonly onDidDispose: Event; create(opts?: CreateAgentOptions): Promise; - fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise; + fork(source: AgentContext, opts?: ForkAgentOptions): Promise; - get(agentId: string): IAgentScopeHandle | undefined; + get(context: AgentContext): IAgentScopeHandle | undefined; + findAgentHandle(agentId: string): IAgentScopeHandle | undefined; list(filter?: AgentListFilter): readonly IAgentScopeHandle[]; broadcastPermissionMode(mode: PermissionMode): void; - remove(agentId: string): Promise; + remove(context: AgentContext): Promise; } export const IAgentLifecycleService: ServiceIdentifier = diff --git a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts index d2fae4f31d6..87d463ffecb 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts @@ -12,7 +12,7 @@ import { } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; -import { IEventBus } from '#/app/event/eventBus'; +import { IEventBus, ISessionEventBus } from '#/app/event/eventBus'; import { DEFAULT_PERMISSION_MODE_SECTION } from '#/agent/permissionMode/configSection'; import { permissionModeConfiguredKey } from '#/agent/permissionMode/permissionModeOps'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; @@ -21,7 +21,11 @@ import { TOWER_WORKER_PROFILE } from '#/features/tower/tower'; import { IAgentTaskService } from '#/agent/task/task'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { + agentContextOf, + IAgentScopeContext, + makeAgentScopeContext, +} from '#/agent/scopeContext/scopeContext'; import { IAgentLoopService } from '#/agent/loop/loop'; import { TurnEnded } from '#/agent/loop/turnOps'; import { IAgentProfileService } from '#/agent/profile/profile'; @@ -41,24 +45,31 @@ import { IEventDispatcher } from '#/state/eventDispatcher'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { type AgentListFilter, + type AgentScopeCreatedEvent, type CreateAgentOptions, type ForkAgentOptions, IAgentLifecycleService, } from './agentLifecycle'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; let nextAgentId = 0; export class AgentLifecycleService extends Disposable implements IAgentLifecycleService { declare readonly _serviceBrand: undefined; private readonly handles = new Map(); - private readonly onDidCreateEmitter = this._register(new Emitter()); - private readonly onDidDisposeEmitter = this._register(new Emitter()); + private readonly onDidCreateEmitter = this._register(new Emitter()); + private readonly onDidCreateScopeEmitter = this._register(new Emitter()); + private readonly onDidDisposeEmitter = this._register(new Emitter()); private readonly interactionBusDisposables = new Map(); private readonly creating = new Map>(); + private nextLifecycleGeneration = 0; get onDidCreate() { return this.onDidCreateEmitter.event; } + get onDidCreateScope() { + return this.onDidCreateScopeEmitter.event; + } get onDidDispose() { return this.onDidDisposeEmitter.event; } @@ -73,18 +84,20 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle @ITelemetryService private readonly telemetry: ITelemetryService, ) { super(); - this._register(this.onDidCreate((handle) => this.subscribeInteractionBus(handle))); this._register( - this.onDidCreate((handle) => { + this.onDidCreateScope(({ handle }) => this.subscribeInteractionBus(handle)), + ); + this._register( + this.onDidCreateScope(({ handle }) => { handle.accessor.get(IAgentStateService).contributeState(interactionKey); }), ); this._register( - this.onDidDispose((agentId) => { - const d = this.interactionBusDisposables.get(agentId); + this.onDidDispose((agent) => { + const d = this.interactionBusDisposables.get(agent.agentId); if (d !== undefined) { d.dispose(); - this.interactionBusDisposables.delete(agentId); + this.interactionBusDisposables.delete(agent.agentId); } }), ); @@ -138,13 +151,20 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle private async doCreate(agentId: string, opts: CreateAgentOptions): Promise { const agentScope = this.ctx.scope(`agents/${agentId}`); const agentHomedir = join(this.bootstrap.homeDir, agentScope); + const generation = ++this.nextLifecycleGeneration; + const scopeContext = makeAgentScopeContext({ agentId, agentScope, generation }); + const agent = scopeContext.agentContext; + const eventBus = this.instantiation.invokeFunction((accessor) => + accessor.get(ISessionEventBus) as ISessionEventBus | undefined, + ); + eventBus?.activateAgent(agent); const handle = createScopedChildHandle( this.instantiation, LifecycleScope.Agent, agentId, { seeds: [ - [IAgentScopeContext, makeAgentScopeContext({ agentId, agentScope })], + [IAgentScopeContext, scopeContext], [ITelemetryService, this.telemetry.withContext({ agent_id: agentId })], [IAgentRuntimeBindingSeed, { _serviceBrand: undefined, @@ -164,17 +184,19 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle forkedFrom: opts.forkedFrom, labels: opts.labels, }); - this.onDidCreateEmitter.fire(handle); + this.onDidCreateEmitter.fire(agent); + this.onDidCreateScopeEmitter.fire({ context: agent, handle }); await handle.accessor.get(IEventDispatcher).restore(); await this.bindBootstrap(handle, opts); await handle.accessor.get(IAgentToolActivationService).activate(); return handle; } catch (error) { if (this.handles.get(agentId) === handle) this.handles.delete(agentId); + eventBus?.deactivateAgent(agent); try { handle.dispose(); } catch { } - this.onDidDisposeEmitter.fire(agentId); + this.onDidDisposeEmitter.fire(agent); throw error; } } @@ -195,12 +217,14 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } } - async fork(sourceAgentId: string, opts?: ForkAgentOptions): Promise { - const source = this.handles.get(sourceAgentId); + async fork(sourceContext: AgentContext, opts?: ForkAgentOptions): Promise { + const source = this.get(sourceContext); if (source === undefined) { - throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Source agent "${sourceAgentId}" does not exist`, { - details: { agentId: sourceAgentId }, - }); + throw new Error2( + ErrorCodes.AGENT_NOT_FOUND, + `Source agent "${sourceContext.agentId}" does not exist`, + { details: { agentId: sourceContext.agentId } }, + ); } if (opts?.agentId !== undefined && this.handles.has(opts.agentId)) { throw new Error2(ErrorCodes.AGENT_ALREADY_EXISTS, `Agent "${opts.agentId}" already exists`, { @@ -235,7 +259,13 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle return child; } - get(agentId: string): IAgentScopeHandle | undefined { + get(context: AgentContext): IAgentScopeHandle | undefined { + const handle = this.handles.get(context.agentId); + if (handle === undefined) return undefined; + return agentContextOf(handle) === context ? handle : undefined; + } + + findAgentHandle(agentId: string): IAgentScopeHandle | undefined { return this.handles.get(agentId); } @@ -258,9 +288,10 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle } } - async remove(agentId: string): Promise { - const handle = this.handles.get(agentId); + async remove(context: AgentContext): Promise { + const handle = this.get(context); if (handle === undefined) return; + const agentId = context.agentId; this.handles.delete(agentId); await handle.accessor.get(IAgentTaskService).stopAllOnExit('Session closed'); const loop = handle.accessor.get(IAgentLoopService); @@ -276,8 +307,12 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle compaction.abortController.abort(reason); } await Promise.all([loop.settled(), compactionSettled, prompt.drain(reason)]); + const agent = agentContextOf(handle); handle.dispose(); - this.onDidDisposeEmitter.fire(agentId); + this.instantiation.invokeFunction((accessor) => + (accessor.get(ISessionEventBus) as ISessionEventBus | undefined)?.deactivateAgent(agent), + ); + this.onDidDisposeEmitter.fire(agent); } } diff --git a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts index 41b7a3558ef..fdf5dc7d8c0 100644 --- a/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts +++ b/packages/agent-core-v2/src/session/cron/sessionCronServiceImpl.ts @@ -80,9 +80,9 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe this.states.contributeState(cronStartedKey); this._register( - this.agentLifecycle.onDidCreate((handle) => { + this.agentLifecycle.onDidCreateScope(({ context, handle }) => { handle.accessor.get(IAgentStateService).contributeState(cronKey); - if (handle.id !== 'main') return; + if (context.agentId !== 'main') return; this.bindMainAgent(handle); }), ); @@ -90,7 +90,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe for (const handle of this.agentLifecycle.list()) { handle.accessor.get(IAgentStateService).contributeState(cronKey); } - const existingMain = this.agentLifecycle.get('main'); + const existingMain = this.agentLifecycle.findAgentHandle('main'); if (existingMain) { this.bindMainAgent(existingMain); } @@ -103,7 +103,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } private get tasks(): ReadonlyMap { - const main = this.agentLifecycle.get('main'); + const main = this.agentLifecycle.findAgentHandle('main'); if (main === undefined) return new Map(); return main.accessor.get(IAgentStateService).get(cronKey); } @@ -250,7 +250,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe if (this.getCronConfig().disabled) return; if (this.tasks.size === 0) return; - const mainHandle = this.agentLifecycle.get('main'); + const mainHandle = this.agentLifecycle.findAgentHandle('main'); if (!mainHandle) return; const loop = mainHandle.accessor.get(IAgentLoopService); @@ -340,7 +340,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe ): Turn | undefined { if (tasks.length === 0) return undefined; - const mainHandle = this.agentLifecycle.get('main'); + const mainHandle = this.agentLifecycle.findAgentHandle('main'); if (!mainHandle) return undefined; const promptService = mainHandle.accessor.get(IAgentPromptService); @@ -388,7 +388,7 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe task: CronTask, ctx: { readonly coalescedCount: number; readonly firedAt: number }, ): Promise { - const mainHandle = this.agentLifecycle.get('main'); + const mainHandle = this.agentLifecycle.findAgentHandle('main'); if (!mainHandle) return Promise.resolve(false); const promptService = mainHandle.accessor.get(IAgentPromptService); @@ -454,13 +454,13 @@ export class SessionCronServiceImpl extends Disposable implements ISessionCronSe } private dispatchCron(event: CronAdd | CronDelete | CronCursor): void { - const mainHandle = this.agentLifecycle.get('main'); + const mainHandle = this.agentLifecycle.findAgentHandle('main'); if (!mainHandle) return; void mainHandle.accessor.get(IEventDispatcher).dispatch(event); } private signalCron(event: CronFired): void { - const mainHandle = this.agentLifecycle.get('main'); + const mainHandle = this.agentLifecycle.findAgentHandle('main'); if (!mainHandle) return; void mainHandle.accessor.get(IEventDispatcher).dispatch(event); } diff --git a/packages/agent-core-v2/src/session/interaction/interactionOps.ts b/packages/agent-core-v2/src/session/interaction/interactionOps.ts index 012c6652979..2ce6572f56c 100644 --- a/packages/agent-core-v2/src/session/interaction/interactionOps.ts +++ b/packages/agent-core-v2/src/session/interaction/interactionOps.ts @@ -1,7 +1,7 @@ /* 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 { Event2 } from '#/app/event/event2'; +import { AgentEvent2 } from '#/app/event/event2'; import { defineState } from '#/state/state'; import type { InteractionKind } from './interaction'; @@ -10,7 +10,7 @@ export interface InteractionRecord { readonly id: string; readonly kind: InteractionKind; readonly toolCallId?: string; - readonly agentId?: string; + readonly agentId: string; readonly request: unknown; readonly resolved: boolean; readonly response?: unknown; @@ -19,31 +19,46 @@ export interface InteractionRecord { export type InteractionModelState = Map; const interactionRequestSchema = z.object({ + agentId: z.string(), id: z.string(), kind: z.enum(['approval', 'question', 'user_tool']), toolCallId: z.string().optional(), - agentId: z.string().optional(), request: z.unknown(), }); -export class InteractionRequestEvent extends Event2> { +export class InteractionRequestEvent extends AgentEvent2< + z.infer +> { static override readonly type = 'interaction.request'; static override readonly durable = true; static override readonly schema = interactionRequestSchema; } -export interface InteractionRequestEvent extends z.infer {} +export interface InteractionRequestEvent { + readonly agentId: string; + readonly id: string; + readonly kind: InteractionKind; + readonly toolCallId?: string; + readonly request: unknown; +} const interactionResolvedSchema = z.object({ + agentId: z.string(), id: z.string(), response: z.unknown(), }); -export class InteractionResolvedEvent extends Event2> { +export class InteractionResolvedEvent extends AgentEvent2< + z.infer +> { static override readonly type = 'interaction.resolved'; static override readonly durable = true; static override readonly schema = interactionResolvedSchema; } -export interface InteractionResolvedEvent extends z.infer {} +export interface InteractionResolvedEvent { + readonly agentId: string; + readonly id: string; + readonly response: unknown; +} export const interactionKey = defineState( 'interaction', diff --git a/packages/agent-core-v2/src/session/interaction/interactionService.ts b/packages/agent-core-v2/src/session/interaction/interactionService.ts index 3ea0954d465..be31d577f69 100644 --- a/packages/agent-core-v2/src/session/interaction/interactionService.ts +++ b/packages/agent-core-v2/src/session/interaction/interactionService.ts @@ -153,10 +153,10 @@ export class SessionInteractionService extends Service implements ISessionIntera if (dispatcher === undefined) return; void dispatcher.dispatch( new InteractionRequestEvent({ + agentId: interaction.origin.agentId ?? MAIN_AGENT_ID, id: interaction.id, kind: interaction.kind, toolCallId: readPayloadToolCallId(interaction.payload), - agentId: interaction.origin.agentId, request: interaction.payload, }), ); @@ -165,17 +165,24 @@ export class SessionInteractionService extends Service implements ISessionIntera private recordResolved(id: string, response: unknown, origin: InteractionOrigin): void { const dispatcher = this.originDispatcher(origin); if (dispatcher === undefined) return; - void dispatcher.dispatch(new InteractionResolvedEvent({ id, response })); + void dispatcher.dispatch( + new InteractionResolvedEvent({ + agentId: origin.agentId ?? MAIN_AGENT_ID, + id, + response, + }), + ); } private originDispatcher(origin: InteractionOrigin): IEventDispatcher | undefined { if (this.instantiation === undefined) return undefined; const agentId = origin.agentId ?? MAIN_AGENT_ID; try { - return this.instantiation.invokeFunction( - (accessor) => - accessor.get(IAgentLifecycleService).get(agentId)?.accessor.get(IEventDispatcher), - ); + return this.instantiation.invokeFunction((accessor) => { + const lifecycle = accessor.get(IAgentLifecycleService); + const handle = lifecycle.findAgentHandle(agentId); + return handle?.accessor.get(IEventDispatcher); + }); } catch { return undefined; } diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts index b14b5981642..3f1ec82968a 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionActivityService.ts @@ -63,16 +63,16 @@ export class SessionActivityView extends Disposable implements ISessionActivityV for (const handle of this.agents.list()) this.attachAgent(handle); this.current = this.aggregate(); this._register( - this.agents.onDidCreate((handle) => { + this.agents.onDidCreateScope(({ handle }) => { this.attachAgent(handle); this.recompute('agent_lifecycle'); }), ); this._register( - this.agents.onDidDispose((agentId) => { - this.agentSubscriptions.get(agentId)?.dispose(); - this.agentSubscriptions.delete(agentId); - if (this.folds.delete(agentId)) this.recompute('agent_lifecycle'); + this.agents.onDidDispose((agent) => { + this.agentSubscriptions.get(agent.agentId)?.dispose(); + this.agentSubscriptions.delete(agent.agentId); + if (this.folds.delete(agent.agentId)) this.recompute('agent_lifecycle'); }), ); this._register(this.interactions.onDidChangePending(() => this.recompute('interaction'))); diff --git a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts index c0233d3b163..279afc1efbf 100644 --- a/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts +++ b/packages/agent-core-v2/src/session/sessionActivity/sessionOutcomeMirrorService.ts @@ -34,11 +34,11 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM }) .catch(() => {}); this.attachMain(); - this._register(this.agents.onDidCreate((handle) => { - if (handle.id === MAIN_AGENT_ID) this.attachMain(); + this._register(this.agents.onDidCreate((agent) => { + if (agent.agentId === MAIN_AGENT_ID) this.attachMain(); })); - this._register(this.agents.onDidDispose((agentId) => { - if (agentId !== MAIN_AGENT_ID) return; + this._register(this.agents.onDidDispose((agent) => { + if (agent.agentId !== MAIN_AGENT_ID) return; this.mainSubscription?.dispose(); this.mainSubscription = undefined; })); @@ -52,7 +52,9 @@ export class SessionOutcomeMirror extends Disposable implements ISessionOutcomeM private attachMain(): void { if (this.mainSubscription !== undefined) return; - const bus = this.agents.get(MAIN_AGENT_ID)?.accessor.get(IEventBus) as IEventBus | undefined; + const bus = this.agents.findAgentHandle(MAIN_AGENT_ID)?.accessor.get(IEventBus) as + | IEventBus + | undefined; if (bus === undefined) return; const subscription = new DisposableStore(); this.mainSubscription = subscription; diff --git a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts index 31cf63e800b..d8b4832b075 100644 --- a/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts +++ b/packages/agent-core-v2/src/session/sessionTitle/sessionTitleService.ts @@ -83,7 +83,7 @@ export class SessionTitleService implements ISessionTitleService { if (current.titleKind === 'custom') return undefined; if (current.titleKind === 'generated') return undefined; } - const main = this.agentLifecycle.get(MAIN_AGENT_ID); + const main = this.agentLifecycle.findAgentHandle(MAIN_AGENT_ID); if (main === undefined) return undefined; const promptSource = main.accessor.get(IAgentTitlePromptSource); const input = await composeTitleInput(promptSource, source); diff --git a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts index 66aff73eee0..99ed8444141 100644 --- a/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts +++ b/packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts @@ -1,8 +1,9 @@ /* 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 { IAgentScopeHandle } from '#/_base/di/scope'; import { userCancellationReason } from '#/_base/utils/abort'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +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 { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -96,7 +97,7 @@ export function emitAgentRunSpawned( ): void { const childProfile = requester.accessor .get(IAgentLifecycleService) - ?.get(targetAgentId) + .findAgentHandle(targetAgentId) ?.accessor.get(IAgentProfileService); void requester.accessor.get(IEventDispatcher)?.dispatch( new SubagentSpawned({ @@ -199,6 +200,9 @@ function childContextTokens( agentLifecycle: IAgentLifecycleService, agentId: string, ): number | undefined { - const child = agentLifecycle.get(agentId); - return child?.accessor.get(IAgentTokenCountingService)?.statusSize(); + const child = agentLifecycle.findAgentHandle(agentId); + if (child === undefined) return undefined; + const context = tryAgentContextOf(child); + if (context === undefined) return undefined; + return child.accessor.get(ISessionTokenCountingService)?.statusSize(context); } diff --git a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts index a21d88a52fa..bbd42b5339a 100644 --- a/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts +++ b/packages/agent-core-v2/src/session/subagent/runAgentTurn.ts @@ -8,7 +8,8 @@ import type { ContextMessage, PromptOrigin } from '#/agent/contextMemory/types'; import { Error2, ErrorCodes, toKimiErrorPayload, type KimiErrorPayload } from '#/errors'; import { IAgentPromptService } from '#/agent/prompt/prompt'; import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; -import { IAgentUsageService } from '#/agent/usage/usage'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { ISessionUsageService } from '#/session/usage/sessionUsage'; import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; import type { AgentRunHandle, AgentRunRequest } from './subagent'; @@ -77,7 +78,7 @@ async function awaitRun( }, cancelTurn, ); - const usage = target.accessor.get(IAgentUsageService)?.status().total; + const usage = target.accessor.get(ISessionUsageService)?.status(agentContextOf(target)).total; return { summary, usage }; } finally { unlink(); diff --git a/packages/agent-core-v2/src/session/subagent/subagent.ts b/packages/agent-core-v2/src/session/subagent/subagent.ts index 23df459162b..a59856d6514 100644 --- a/packages/agent-core-v2/src/session/subagent/subagent.ts +++ b/packages/agent-core-v2/src/session/subagent/subagent.ts @@ -2,6 +2,7 @@ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiatio import type { Event } from '#/_base/event'; import type { TokenUsage } from '#/kosong/contract/usage'; import type { AgentProfileSummaryPolicy } from '#/app/agentProfileCatalog/agentProfileCatalog'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { Turn } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; @@ -43,7 +44,7 @@ export interface ISessionSubagentService { readonly onDidStopAgentTask: Event; - run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise; + run(agent: AgentContext, request: AgentRunRequest, opts: RunAgentOptions): Promise; notifyAgentTaskStopped(context: AgentTaskStopHookContext): void; } diff --git a/packages/agent-core-v2/src/session/subagent/subagentService.ts b/packages/agent-core-v2/src/session/subagent/subagentService.ts index 4b9234cc9af..4f597e0dd11 100644 --- a/packages/agent-core-v2/src/session/subagent/subagentService.ts +++ b/packages/agent-core-v2/src/session/subagent/subagentService.ts @@ -1,4 +1,5 @@ import { Service } from '#/_base/di/service'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import { Error2, ErrorCodes } from '#/errors'; import { LifecycleScope } from '#/app/scopes'; import { @@ -42,11 +43,11 @@ export class SessionSubagentService extends Service implements ISessionSubagentS super(); } - run(agentId: string, request: AgentRunRequest, opts: RunAgentOptions): Promise { - const handle = this.agentLifecycle.get(agentId); + run(agent: AgentContext, request: AgentRunRequest, opts: RunAgentOptions): Promise { + const handle = this.agentLifecycle.get(agent); if (handle === undefined) { - throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent "${agentId}" does not exist`, { - details: { agentId }, + throw new Error2(ErrorCodes.AGENT_NOT_FOUND, `Agent "${agent.agentId}" does not exist`, { + details: { agentId: agent.agentId }, }); } return runAgentTurn(handle, request, { diff --git a/packages/agent-core-v2/src/session/todo/sessionTodo.ts b/packages/agent-core-v2/src/session/todo/sessionTodo.ts index 047a85802be..c6f3cdfc960 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodo.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodo.ts @@ -1,15 +1,21 @@ import { createDecorator } from '#/_base/di/instantiation'; import type { Event } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; import type { TodoItem } from './todoItem'; +export interface TodoChange { + readonly agent: AgentContext; + readonly todos: readonly TodoItem[]; +} + export interface ISessionTodoService { readonly _serviceBrand: undefined; - getTodos(): readonly TodoItem[]; - setTodos(todos: readonly TodoItem[]): void; - clear(): void; - readonly onDidChange: Event; + getTodos(agent: AgentContext): Promise; + setTodos(agent: AgentContext, todos: readonly TodoItem[]): Promise; + clear(agent: AgentContext): Promise; + readonly onDidChange: Event; } export const ISessionTodoService = createDecorator('sessionTodoService'); diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 9996f069bce..f01d5bc3d67 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -1,155 +1,144 @@ -import { toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import type { CollectionView } from '#/_base/di/collection'; +import { toDisposable } from '#/_base/di/lifecycle'; import { Service } from '#/_base/di/service'; -import { LifecycleScope } from '#/app/scopes'; -import { - type IAgentScopeHandle, - ScopeActivation, - registerScopedService, -} from '#/_base/di/scope'; import { Emitter } from '#/_base/event'; - +import { onUnexpectedError } from '#/_base/errors/unexpectedError'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { agentSpaceOf } from '#/agent/agentContext/agentSpace'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { ContextUndone } from '#/agent/undo/undoService'; -import { IAgentStateService } from '#/agent/state/agentState'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { IEventDispatcher } from '#/state/eventDispatcher'; - -import { ISessionTodoService } from './sessionTodo'; -import { todoKey, ToolsUpdateStore } from './todoOps'; +import { + AgentEffectContribution, + type AgentEffectDefinition, +} from '#/state/agentEffect'; +import { + AgentModelContribution, + type AgentModelDefinition, + type DomainResourceRuntime, +} from '#/state/agentModel'; + +import { ISessionTodoService, type TodoChange } from './sessionTodo'; +import { TodoAgentEffectDefinition } from './todoAgentEffect'; +import { TodoAgentModelDefinition } from './todoAgentModel'; import { TODO_LIST_TOOL_NAME, type TodoItem } from './todoItem'; -import { TODO_LIST_REMINDER_VARIANT, todoListStaleReminder } from './todoListReminder'; - -const MAIN_AGENT_ID = 'main'; +import { TODO_LIST_REMINDER_VARIANT } from './todoListReminder'; export class SessionTodoService extends Service implements ISessionTodoService { declare readonly _serviceBrand: undefined; - private readonly onDidChangeEmitter = this._register(new Emitter()); + private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange = this.onDidChangeEmitter.event; - - private readonly agentBindings = new Map(); - private lastKnownTodos: readonly TodoItem[] = []; + private readonly effects = new Map(); constructor( + @AgentModelContribution + private readonly models: CollectionView>, + @AgentEffectContribution + private readonly effectDefinitions: CollectionView>, @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, ) { super(); - this._register( - this.agentLifecycle.onDidCreate((handle) => { - this.bindAgent(handle); + this.effectDefinitions.onDidChange(({ removed }) => { + if (!removed.includes(TodoAgentEffectDefinition as AgentEffectDefinition)) return; + for (const agentId of this.effects.keys()) this.disposeEffect(agentId); }), ); this._register( - this.agentLifecycle.onDidDispose((agentId) => this.disposeAgentBindings(agentId)), + this.agentLifecycle.onDidDispose((agent) => { + this.disposeEffect(agent.agentId); + }), ); - - for (const handle of this.agentLifecycle.list()) { - this.bindAgent(handle); - } - this._register( toDisposable(() => { - for (const agentId of Array.from(this.agentBindings.keys())) { - this.disposeAgentBindings(agentId); - } + for (const agentId of this.effects.keys()) this.disposeEffect(agentId); }), ); } - getTodos(): readonly TodoItem[] { - const main = this.agentLifecycle.get(MAIN_AGENT_ID); - if (main === undefined) return []; - return main.accessor.get(IAgentStateService).get(todoKey); + async getTodos(agent: AgentContext): Promise { + this.requireDefinitions(); + const space = agentSpaceOf(agent); + this.ensureEffect(agent); + return space.use(TodoAgentModelDefinition, (model) => model.items()); } - setTodos(todos: readonly TodoItem[]): void { + async setTodos(agent: AgentContext, todos: readonly TodoItem[]): Promise { + this.requireDefinitions(); const next: readonly TodoItem[] = todos.map((todo) => ({ title: todo.title, status: todo.status, })); - this.dispatchTodoSet(next); + const space = agentSpaceOf(agent); + this.ensureEffect(agent); + await space.use(TodoAgentModelDefinition, (model) => model.replaceAll(next)); + this.fireChange(agent, space.use(TodoAgentModelDefinition, (model) => model.items())); } - clear(): void { - this.setTodos([]); + clear(agent: AgentContext): Promise { + return this.setTodos(agent, []); } - private dispatchTodoSet(todos: readonly TodoItem[]): void { - const main = this.agentLifecycle.get(MAIN_AGENT_ID); - if (main === undefined) return; - const dispatcher = main.accessor.get(IEventDispatcher); - void dispatcher.dispatch(new ToolsUpdateStore({ key: 'todo', value: todos })); - const current = main.accessor.get(IAgentStateService).get(todoKey); - this.lastKnownTodos = current; - this.onDidChangeEmitter.fire(current); + private requireDefinitions(): void { + if (!this.models.items.includes(TodoAgentModelDefinition as AgentModelDefinition)) { + throw new Error('resource definition is unavailable'); + } + if ( + !this.effectDefinitions.items.includes( + TodoAgentEffectDefinition as AgentEffectDefinition, + ) + ) { + throw new Error('resource definition is unavailable'); + } } - private bindAgent(handle: IAgentScopeHandle): void { - handle.accessor.get(IAgentStateService).contributeState(todoKey); + private ensureEffect(agent: AgentContext): void { + if (this.effects.has(agent.agentId)) return; + const handle = this.agentLifecycle.get(agent); + if (handle === undefined) { + throw new Error(`Agent ${agent.agentId}:${String(agent.generation)} is stale`); + } + const eventBus = handle.accessor.get(IEventBus); const injector = handle.accessor.get(IAgentContextInjectorService); - this.trackAgentBinding( - handle.id, - injector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder(handle)), - ); - if (handle.id !== MAIN_AGENT_ID) return; - - this.lastKnownTodos = handle.accessor.get(IAgentStateService).get(todoKey); - this.trackAgentBinding( - handle.id, - handle.accessor.get(IEventBus).subscribe(ContextUndone, () => { - const current = handle.accessor.get(IAgentStateService).get(todoKey); - if (todoItemsEqual(current, this.lastKnownTodos)) return; - this.lastKnownTodos = current; - this.onDidChangeEmitter.fire(current); - }), - ); - } - - private staleReminder(handle: IAgentScopeHandle): string | undefined { const memory = handle.accessor.get(IAgentContextMemoryService); const toolPolicy = handle.accessor.get(IAgentToolPolicyService); - return todoListStaleReminder({ - active: toolPolicy.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'), - history: memory.get(), - todos: this.getTodos(), + const runtime = TodoAgentEffectDefinition.create({ + agent, + getTodos: () => agentSpaceOf(agent).use(TodoAgentModelDefinition, (model) => model.items()), + getHistory: () => memory.get(), + isToolActive: () => toolPolicy.isToolActive(TODO_LIST_TOOL_NAME, 'builtin'), + registerReminder: (provider) => injector.register(TODO_LIST_REMINDER_VARIANT, provider), + subscribeChange: (listener) => + this.onDidChange((change) => { + if (change.agent === agent) listener(change.todos); + }), + subscribeUndo: (listener) => eventBus.subscribe(ContextUndone, listener), + onChange: (todos) => { + this.fireChange(agent, todos); + }, }); + this.effects.set(agent.agentId, runtime); } - private trackAgentBinding(agentId: string, disposable: IDisposable): void { - const list = this.agentBindings.get(agentId); - if (list === undefined) { - this.agentBindings.set(agentId, [disposable]); - } else { - list.push(disposable); + private disposeEffect(agentId: string): void { + const effect = this.effects.get(agentId); + if (effect === undefined) return; + this.effects.delete(agentId); + try { + const result = effect.dispose(); + if (result instanceof Promise) { + result.catch((error: unknown) => onUnexpectedError(error)); + } + } catch (error) { + onUnexpectedError(error); } } - private disposeAgentBindings(agentId: string): void { - const bindings = this.agentBindings.get(agentId); - if (bindings === undefined) return; - for (const disposable of bindings) { - disposable.dispose(); - } - this.agentBindings.delete(agentId); - if (agentId === MAIN_AGENT_ID) this.lastKnownTodos = []; + private fireChange(agent: AgentContext, todos: readonly TodoItem[]): void { + this.onDidChangeEmitter.fire({ agent, todos }); } } - -function todoItemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean { - return ( - a.length === b.length && - a.every((item, index) => item.title === b[index]?.title && item.status === b[index]?.status) - ); -} - -registerScopedService( - LifecycleScope.Session, - ISessionTodoService, - SessionTodoService, - ScopeActivation.OnScopeCreated, - 'todo', -); diff --git a/packages/agent-core-v2/src/session/todo/todoAgentEffect.ts b/packages/agent-core-v2/src/session/todo/todoAgentEffect.ts new file mode 100644 index 00000000000..66686a42d4c --- /dev/null +++ b/packages/agent-core-v2/src/session/todo/todoAgentEffect.ts @@ -0,0 +1,52 @@ +import type { IDisposable } from '#/_base/di/lifecycle'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { defineAgentEffect, type AgentEffectContext } from '#/state/agentEffect'; + +import type { TodoItem } from './todoItem'; +import { todoListStaleReminder } from './todoListReminder'; + +export interface TodoAgentEffectContext extends AgentEffectContext { + getTodos(): readonly TodoItem[]; + getHistory(): readonly ContextMessage[]; + isToolActive(): boolean; + registerReminder(provider: () => string | undefined): IDisposable; + subscribeChange(listener: (todos: readonly TodoItem[]) => void): IDisposable; + subscribeUndo(listener: () => void): IDisposable; + onChange(todos: readonly TodoItem[]): void; +} + +export const TodoAgentEffectDefinition = defineAgentEffect({ + id: 'todo.reminder', + create: (context: TodoAgentEffectContext) => { + let lastKnown = context.getTodos(); + const reminder = context.registerReminder(() => + todoListStaleReminder({ + active: context.isToolActive(), + history: context.getHistory(), + todos: context.getTodos(), + }), + ); + const change = context.subscribeChange((todos) => { + lastKnown = todos; + }); + const undo = context.subscribeUndo(() => { + const current = context.getTodos(); + if (todoItemsEqual(current, lastKnown)) return; + context.onChange(current); + }); + return { + dispose: () => { + undo.dispose(); + change.dispose(); + reminder.dispose(); + }, + }; + }, +}); + +function todoItemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean { + return ( + a.length === b.length && + a.every((item, index) => item.title === b[index]?.title && item.status === b[index]?.status) + ); +} diff --git a/packages/agent-core-v2/src/session/todo/todoAgentModel.ts b/packages/agent-core-v2/src/session/todo/todoAgentModel.ts new file mode 100644 index 00000000000..fffb7d68044 --- /dev/null +++ b/packages/agent-core-v2/src/session/todo/todoAgentModel.ts @@ -0,0 +1,39 @@ +import { z } from 'zod'; + +import { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; + +import '#/agent/contextMemory/conversationTime'; + +import { readTodoItems, type TodoItem } from './todoItem'; +import { ToolsUpdateStore, type TodoState } from './todoOps'; + +export class TodoAgentModel extends AgentModel { + constructor(context: AgentModelContext) { + super(context); + this.on(ToolsUpdateStore, (event) => { + if (event.key !== 'todo') return; + this.state = readTodoItems(event.value); + }); + } + + items(): readonly TodoItem[] { + return this.state; + } + + replaceAll(todos: readonly TodoItem[]): Promise { + return this.emit( + new ToolsUpdateStore({ agentId: this.agent.agentId, key: 'todo', value: todos }), + ); + } +} + +export const TodoAgentModelDefinition = defineAgentModel({ + id: 'todo', + model: TodoAgentModel, + state: { + initial: (): TodoState => [], + schema: z.custom(), + }, + events: [ToolsUpdateStore], + undoable: true, +}); diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index a4cf821cf11..db8f746e0ea 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -1,28 +1,25 @@ /* 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 { Event2 } from '#/app/event/event2'; -import { defineState } from '#/state/state'; +import { AgentEvent2 } from '#/app/event/event2'; -import '#/agent/contextMemory/conversationTime'; - -import { readTodoItems, type TodoItem } from './todoItem'; +import type { TodoItem } from './todoItem'; export type TodoState = readonly TodoItem[]; -const toolsUpdateStoreSchema = z.object({ key: z.string(), value: z.unknown() }); +const toolsUpdateStoreSchema = z.object({ + agentId: z.string(), + key: z.string(), + value: z.unknown(), +}); -export class ToolsUpdateStore extends Event2> { +export class ToolsUpdateStore extends AgentEvent2> { static override readonly type = 'tools.update_store'; static override readonly durable = true; static override readonly schema = toolsUpdateStoreSchema; } -export interface ToolsUpdateStore extends z.infer {} - -export const todoKey = defineState('todo', (): TodoState => []) - .replayable({ schema: z.custom() }) - .undoable() - .on(ToolsUpdateStore, (s, e) => { - if (e.key !== 'todo') return; - return readTodoItems(e.value); - }); +export interface ToolsUpdateStore { + readonly agentId: string; + readonly key: string; + readonly value: unknown; +} diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts new file mode 100644 index 00000000000..42dba732dee --- /dev/null +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCounting.ts @@ -0,0 +1,51 @@ +import { createDecorator } from '#/_base/di/instantiation'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import type { + ContextSize, + 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'; + +export interface TokenCountingRebaseInput { + readonly length: number; + readonly tokens: number; + readonly measured: boolean; +} + +export interface ISessionTokenCountingService { + readonly _serviceBrand: undefined; + + readonly strategy: TokenCountingStrategy; + + get(agent: AgentContext, start?: number, end?: number): ContextSize; + measured( + agent: AgentContext, + input: readonly Message[], + output: readonly Message[], + usage: TokenUsage, + ): void; + /** Tokens of the most recent measured anchor (0 when none) — a real reading + * that stays valid across transient uncascaded context rewrites. */ + latestMeasured(agent: AgentContext): number; + /** The externally reported context size — the ONLY reading the + * `[token_counting]` strategy selects: `measured` reports the latest + * measured anchor alone, `estimated` reports a pure estimate with anchors + * ignored, and the default reports the live size floored by the last + * measured total. Internal logic (triggers, budgets, overflow backoff) + * must use `get()` / the estimate primitives, never this method. */ + statusSize(agent: AgentContext): number; + recordTruncation(agent: AgentContext, cutIndex: number): void; + rebase(agent: AgentContext, input: TokenCountingRebaseInput): void; + requestSize(request: TokenCountingRequest): number; + + estimateText(text: string): number; + estimateMessage(message: Message): number; + estimateMessages(messages: readonly Message[]): number; + estimateTools(tools: readonly Tool[]): number; +} + +export const ISessionTokenCountingService = + createDecorator('sessionTokenCountingService'); diff --git a/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts new file mode 100644 index 00000000000..f07ad113b61 --- /dev/null +++ b/packages/agent-core-v2/src/session/tokenCounting/sessionTokenCountingService.ts @@ -0,0 +1,126 @@ +import { Disposable } from '#/_base/di/lifecycle'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { agentSpaceOf } from '#/agent/agentContext/agentSpace'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { TurnEnded } from '#/agent/loop/turnOps'; +import { IConfigService } from '#/app/config/config'; +import { ISessionEventBus } from '#/app/event/eventBus'; +import { + TOKEN_COUNTING_SECTION, + type TokenCountingConfig, +} from '#/agent/tokenCounting/configSection'; +import type { + ContextSize, + TokenCountingRequest, + TokenCountingStrategy, +} from '#/agent/tokenCounting/tokenCounting'; +import type { Message } from '#/kosong/contract/message'; +import type { Tool } from '#/kosong/contract/tool'; +import { + estimateTokens, + estimateTokensForMessage, + estimateTokensForMessages, + estimateTokensForTools, +} from '#/kosong/contract/tokens'; +import type { TokenUsage } from '#/kosong/contract/usage'; +import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; + +import { + ISessionTokenCountingService, + type TokenCountingRebaseInput, +} from './sessionTokenCounting'; +import { TokenCountingAgentModelDefinition } from './tokenCountingAgentModel'; + +export class SessionTokenCountingService extends Disposable implements ISessionTokenCountingService { + declare readonly _serviceBrand: undefined; + + constructor( + @IConfigService private readonly config: IConfigService, + @ISessionEventBus eventBus: ISessionEventBus, + @IAgentLifecycleService lifecycle: IAgentLifecycleService, + ) { + super(); + this._register( + eventBus.subscribe(TurnEnded, (event) => { + const handle = lifecycle.findAgentHandle(event.agentId); + if (handle === undefined) return; + void agentSpaceOf(agentContextOf(handle)).use( + TokenCountingAgentModelDefinition, + (model) => model.recordTurn(event.turnId, this.strategy), + ); + }), + ); + } + + get strategy(): TokenCountingStrategy { + return ( + this.config.get(TOKEN_COUNTING_SECTION)?.strategy ?? + 'measured+estimated' + ); + } + + get(agent: AgentContext, start?: number, end?: number): ContextSize { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.get(start, end), + ); + } + + measured( + agent: AgentContext, + input: readonly Message[], + output: readonly Message[], + usage: TokenUsage, + ): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.measured(input, output, usage), + ); + } + + latestMeasured(agent: AgentContext): number { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.latestMeasured(), + ); + } + + statusSize(agent: AgentContext): number { + return agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.statusSize(this.strategy), + ); + } + + recordTruncation(agent: AgentContext, cutIndex: number): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.recordTruncation(cutIndex), + ); + } + + rebase(agent: AgentContext, input: TokenCountingRebaseInput): void { + void agentSpaceOf(agent).use(TokenCountingAgentModelDefinition, (model) => + model.rebase(input), + ); + } + + requestSize(request: TokenCountingRequest): number { + return ( + this.estimateText(request.systemPrompt) + + this.estimateTools(request.tools) + + this.estimateMessages(request.messages) + ); + } + + estimateText(text: string): number { + return estimateTokens(text); + } + + estimateMessage(message: Message): number { + return estimateTokensForMessage(message); + } + + estimateMessages(messages: readonly Message[]): number { + return estimateTokensForMessages(messages); + } + + estimateTools(tools: readonly Tool[]): number { + return estimateTokensForTools(tools); + } +} diff --git a/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts b/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts new file mode 100644 index 00000000000..3460d9ace5b --- /dev/null +++ b/packages/agent-core-v2/src/session/tokenCounting/tokenCountingAgentModel.ts @@ -0,0 +1,210 @@ +import { z } from 'zod'; + +import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import type { ContextSize, TokenCountingStrategy } from '#/agent/tokenCounting/tokenCounting'; +import { + anchorsEqual, + normalizeAnchorLength, + TokenCountingMeasured, + TokenCountingRebased, + TokenCountingTruncated, + TokenCountingTurnRecorded, + type TokenAnchor, + 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 { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; + +import type { TokenCountingRebaseInput } from './sessionTokenCounting'; + +const ZERO_ANCHOR: TokenAnchor = { length: 0, tokens: 0, measured: true }; + +export class TokenCountingAgentModel extends AgentModel { + constructor(context: AgentModelContext) { + super(context); + this.on(TokenCountingMeasured, (event) => { + const length = normalizeAnchorLength(event.length); + const tokens = Math.max(0, event.tokens); + const anchor: TokenAnchor = { length, tokens, measured: true }; + const anchors = [...this.state.anchors.filter((a) => a.length < length), anchor]; + if (!(this.state.tokens === tokens && anchorsEqual(this.state.anchors, anchors))) { + this.state.anchors = anchors; + this.state.tokens = tokens; + } + void this.emit( + new AgentStatusUpdated({ agentId: event.agentId, contextTokens: this.state.tokens }), + ); + }); + this.on(TokenCountingTruncated, (event) => { + const length = normalizeAnchorLength(event.length); + const tokens = Math.max(0, event.tokens); + const anchors = this.state.anchors.filter((a) => a.length <= length); + if (!(this.state.tokens === tokens && anchorsEqual(this.state.anchors, anchors))) { + this.state.anchors = anchors; + this.state.tokens = tokens; + } + void this.emit( + new AgentStatusUpdated({ agentId: event.agentId, contextTokens: this.state.tokens }), + ); + }); + this.on(TokenCountingRebased, (event) => { + const length = normalizeAnchorLength(event.length); + const tokens = Math.max(0, event.tokens); + const anchors: TokenAnchor[] = [{ length, tokens, measured: event.measured }]; + if (!(this.state.tokens === tokens && anchorsEqual(this.state.anchors, anchors))) { + this.state.anchors = anchors; + this.state.tokens = tokens; + } + void this.emit( + new AgentStatusUpdated({ agentId: event.agentId, contextTokens: this.state.tokens }), + ); + }); + this.on(TokenCountingTurnRecorded, (event) => { + const length = normalizeAnchorLength(event.length); + const tokens = Math.max(0, event.tokens); + const pinned = this.state.anchors.some((anchor) => anchor.length === length); + const anchors = pinned + ? this.state.anchors + : [ + ...this.state.anchors.filter((anchor) => anchor.length < length), + { length, tokens, measured: false }, + ]; + if (!(this.state.tokens === tokens && anchorsEqual(this.state.anchors, anchors))) { + this.state.anchors = anchors; + this.state.tokens = tokens; + } + void this.emit( + new AgentStatusUpdated({ agentId: event.agentId, contextTokens: this.state.tokens }), + ); + }); + } + + get(start?: number, end?: number): ContextSize { + const context = this.context(); + const from = normalizeSliceIndex(start ?? 0, context.length); + const to = normalizeSliceIndex(end ?? context.length, context.length); + const anchor = this.latestAnchor(context.length); + const measuredEnd = Math.min(to, anchor.length); + const estimatedStart = Math.max(from, anchor.length); + const measured = + from === 0 && measuredEnd === anchor.length + ? anchor.tokens + : estimateTokensForMessages(context.slice(from, measuredEnd)); + const estimated = estimateTokensForMessages(context.slice(estimatedStart, to)); + return { size: measured + estimated, measured, estimated }; + } + + measured( + input: readonly Message[], + _output: readonly Message[], + usage: TokenUsage, + ): Promise { + const context = this.context(); + if (!matchesContext(input, context)) return Promise.resolve(); + return this.emit( + new TokenCountingMeasured({ + agentId: this.agent.agentId, + length: context.length, + tokens: tokenUsageTotal(usage), + }), + ); + } + + latestMeasured(): number { + const anchors = this.state.anchors; + for (let i = anchors.length - 1; i >= 0; i--) { + if (anchors[i]!.measured) return anchors[i]!.tokens; + } + return 0; + } + + statusSize(strategy: TokenCountingStrategy): number { + if (strategy === 'measured') return this.latestMeasured(); + if (strategy === 'estimated') return estimateTokensForMessages(this.context()); + return Math.max(this.get().size, this.latestMeasured()); + } + + recordTruncation(cutIndex: number): Promise { + if (!this.state.anchors.some((anchor) => anchor.length > cutIndex)) { + return Promise.resolve(); + } + return this.emit( + new TokenCountingTruncated({ + agentId: this.agent.agentId, + length: cutIndex, + tokens: this.get(0, cutIndex).size, + }), + ); + } + + rebase(input: TokenCountingRebaseInput): Promise { + return this.emit( + new TokenCountingRebased({ + agentId: this.agent.agentId, + length: input.length, + tokens: input.tokens, + measured: input.measured, + }), + ); + } + + recordTurn(turnId: number, strategy: TokenCountingStrategy): Promise { + return this.emit( + new TokenCountingTurnRecorded({ + agentId: this.agent.agentId, + turnId, + length: this.context().length, + tokens: this.statusSize(strategy), + }), + ); + } + + private context(): readonly ContextMessage[] { + return this.readLegacy(contextMemoryKey) as readonly ContextMessage[]; + } + + private latestAnchor(contextLength: number): TokenAnchor { + const anchors = this.state.anchors; + for (let i = anchors.length - 1; i >= 0; i--) { + const anchor = anchors[i]!; + if (anchor.length <= contextLength) return anchor; + } + return ZERO_ANCHOR; + } +} + +export const TokenCountingAgentModelDefinition = defineAgentModel({ + id: 'tokenCounting', + model: TokenCountingAgentModel, + state: { + initial: (): TokenCountingState => ({ anchors: [], tokens: 0 }), + schema: z.custom(), + }, + events: [ + TokenCountingMeasured, + TokenCountingTruncated, + TokenCountingRebased, + TokenCountingTurnRecorded, + ], +}); + +function matchesContext(input: readonly Message[], context: readonly ContextMessage[]): boolean { + if (input.length !== context.length) return false; + for (let index = 0; index < input.length; index += 1) { + if (input[index] !== context[index]) return false; + } + return true; +} + +function tokenUsageTotal(usage: TokenUsage): number { + return usage.inputCacheRead + usage.inputCacheCreation + usage.inputOther + usage.output; +} + +function normalizeSliceIndex(index: number, length: number): number { + if (index < 0) return Math.max(length + index, 0); + return Math.min(index, length); +} diff --git a/packages/agent-core-v2/src/session/usage/sessionUsage.ts b/packages/agent-core-v2/src/session/usage/sessionUsage.ts new file mode 100644 index 00000000000..37a8931ec14 --- /dev/null +++ b/packages/agent-core-v2/src/session/usage/sessionUsage.ts @@ -0,0 +1,22 @@ +import { createDecorator } from '#/_base/di/instantiation'; +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'; + +export interface ISessionUsageService { + readonly _serviceBrand: undefined; + + record( + agent: AgentContext, + model: string, + usage: TokenUsage, + source?: AgentLLMRequestSource, + ): Promise; + status(agent: AgentContext): UsageStatus; + + readonly onDidRecord: Event; +} + +export const ISessionUsageService = createDecorator('sessionUsageService'); diff --git a/packages/agent-core-v2/src/session/usage/sessionUsageService.ts b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts new file mode 100644 index 00000000000..e025e6bafaf --- /dev/null +++ b/packages/agent-core-v2/src/session/usage/sessionUsageService.ts @@ -0,0 +1,34 @@ +import { Service } from '#/_base/di/service'; +import { Emitter, type Event } from '#/_base/event'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +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 { ISessionUsageService } from './sessionUsage'; +import { UsageAgentModelDefinition } from './usageAgentModel'; + +export class SessionUsageService extends Service implements ISessionUsageService { + declare readonly _serviceBrand: undefined; + + private readonly onDidRecordEmitter = this._register(new Emitter()); + readonly onDidRecord: Event = this.onDidRecordEmitter.event; + + async record( + agent: AgentContext, + model: string, + usage: TokenUsage, + source?: AgentLLMRequestSource, + ): Promise { + await agentSpaceOf(agent).use(UsageAgentModelDefinition, (m) => + m.record({ model, usage, source }), + ); + this.onDidRecordEmitter.fire({ agent, model, usage: copyUsage(usage), source }); + } + + status(agent: AgentContext): UsageStatus { + return agentSpaceOf(agent).use(UsageAgentModelDefinition, (m) => m.status()); + } +} diff --git a/packages/agent-core-v2/src/session/usage/usageAgentModel.ts b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts new file mode 100644 index 00000000000..1e94639970f --- /dev/null +++ b/packages/agent-core-v2/src/session/usage/usageAgentModel.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +import type { AgentLLMRequestSource } from '#/agent/llmRequester/llmRequester'; +import type { UsageStatus } from '#/agent/usage/usage'; +import { AgentStatusUpdated } from '#/agent/usage/usageEvents'; +import { + copyUsage, + UsageRecord, + type UsageModelState, + type UsageRecordScope, +} from '#/agent/usage/usageOps'; +import { addUsage, type TokenUsage } from '#/kosong/contract/usage'; +import { AgentModel, defineAgentModel, type AgentModelContext } from '#/state/agentModel'; + +export interface UsageRecordInput { + readonly model: string; + readonly usage: TokenUsage; + readonly source?: AgentLLMRequestSource; +} + +export class UsageAgentModel extends AgentModel { + private currentTurnId: number | undefined; + private currentTurn: TokenUsage | undefined; + + constructor(context: AgentModelContext) { + super(context); + this.on(UsageRecord, (event) => { + const current = this.state.byModel[event.model]; + this.state.byModel[event.model] = + current === undefined ? copyUsage(event.usage) : addUsage(current, event.usage); + }); + } + + record(input: UsageRecordInput): Promise { + const usageScope: UsageRecordScope = input.source?.type === 'turn' ? 'turn' : 'session'; + const recorded = this.emit( + new UsageRecord({ + agentId: this.agent.agentId, + model: input.model, + usage: input.usage, + usageScope, + }), + ); + const turnId = input.source?.type === 'turn' ? input.source.turnId : undefined; + if (turnId !== undefined) { + if (this.currentTurnId !== turnId) { + this.currentTurnId = turnId; + this.currentTurn = copyUsage(input.usage); + } else { + this.currentTurn = + this.currentTurn === undefined + ? copyUsage(input.usage) + : addUsage(this.currentTurn, input.usage); + } + } + const notified = this.emit( + new AgentStatusUpdated({ agentId: this.agent.agentId, usage: this.status() }), + ); + return recorded.then(() => notified).then(() => undefined); + } + + status(): UsageStatus { + const byModel = Object.fromEntries( + Object.entries(this.state.byModel).map(([model, usage]) => [model, copyUsage(usage)]), + ); + const hasByModel = Object.keys(byModel).length > 0; + let total: TokenUsage | undefined; + if (hasByModel) { + for (const usage of Object.values(byModel)) { + total = total === undefined ? copyUsage(usage) : addUsage(total, usage); + } + } + return { + byModel: hasByModel ? byModel : undefined, + total, + currentTurn: this.currentTurn === undefined ? undefined : copyUsage(this.currentTurn), + }; + } +} + +export const UsageAgentModelDefinition = defineAgentModel({ + id: 'usage', + model: UsageAgentModel, + state: { + initial: (): UsageModelState => ({ byModel: {} }), + schema: z.custom(), + }, + events: [UsageRecord], +}); diff --git a/packages/agent-core-v2/src/state/agentEffect.ts b/packages/agent-core-v2/src/state/agentEffect.ts new file mode 100644 index 00000000000..5ac63a5dd32 --- /dev/null +++ b/packages/agent-core-v2/src/state/agentEffect.ts @@ -0,0 +1,55 @@ +import { collection } from '#/_base/di/collection'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; + +import type { DomainResourceRuntime } from './agentModel'; + +export interface AgentEffectContext { + readonly agent: AgentContext; +} + +export interface AgentEffectDefinition< + Context extends AgentEffectContext = AgentEffectContext, + Runtime extends DomainResourceRuntime = DomainResourceRuntime, +> { + readonly id: string; + readonly create: (context: Context) => Runtime; +} + +export function defineAgentEffect< + Context extends AgentEffectContext, + Runtime extends DomainResourceRuntime, +>( + definition: AgentEffectDefinition, +): AgentEffectDefinition { + return Object.freeze(definition); +} + +export const AgentEffectContribution = collection>( + 'agent-effect', + { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Agent effect '${value.id}' already has an active provider`); + } + }, + }, +); + +export interface SessionEffectContext { + readonly sessionId: string; +} + +export interface SessionEffectDefinition< + Runtime extends DomainResourceRuntime = DomainResourceRuntime, +> { + readonly id: string; + readonly create: (context: SessionEffectContext) => Runtime; +} + +export const SessionEffectContribution = collection('session-effect', { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Session effect '${value.id}' already has an active provider`); + } + }, +}); diff --git a/packages/agent-core-v2/src/state/agentModel.ts b/packages/agent-core-v2/src/state/agentModel.ts new file mode 100644 index 00000000000..ebdbdde6543 --- /dev/null +++ b/packages/agent-core-v2/src/state/agentModel.ts @@ -0,0 +1,209 @@ +import type { z } from 'zod'; +import type { Draft } from 'immer'; + +import { collection } from '#/_base/di/collection'; +import { BugIndicatingError } from '#/_base/errors/errors'; +import type { StateKey } from '#/_base/state/stateRegistry'; +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { registerEvent2Class, type Event2, type Event2Class } from '#/app/event/event2'; + +import type { FoldContext } from './state'; + +export interface DomainResourceRuntime { + dispose(): void | Promise; + abort?(reason?: unknown): void; +} + +export interface AgentModelBridge { + dispatch(event: Event2): Promise; + readLegacy(key: StateKey): unknown; + initialState(): unknown; +} + +export interface AgentModelContext { + readonly agent: AgentContext; + readonly bridge: AgentModelBridge; +} + +interface ModelWindow { + readonly draft: unknown; + readonly ctx: FoldContext; + replaced: boolean; + replacement: unknown; +} + +/** + * Base class of an agent-granular domain Model — the container of one + * domain's replayable state. Subclasses register appliers in the constructor + * via `this.on(EventClass, applier)`; the host runs each applier inside an + * infra-controlled immer window where `this.state` is the mutable draft. + * Outside the window `this.state` is the last committed frozen snapshot. + */ +export abstract class AgentModel implements DomainResourceRuntime { + private committedState: S; + private window: ModelWindow | undefined; + private readonly appliers = new Map, (event: any) => void>(); + private sealed = false; + + readonly agent: AgentContext; + private readonly bridge: AgentModelBridge; + + constructor(context: AgentModelContext) { + this.agent = context.agent; + this.bridge = context.bridge; + this.committedState = context.bridge.initialState() as S; + } + + protected get state(): Draft { + const window = this.window; + return (window !== undefined ? window.draft : this.committedState) as Draft; + } + + protected set state(value: S) { + if (this.window === undefined) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' can only replace state inside an applier`, + ); + } + this.window.replaced = true; + this.window.replacement = value; + } + + protected on>(cls: Event2Class, applier: (event: E) => void): void { + if (this.sealed) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' cannot register appliers after construction`, + ); + } + if (this.appliers.has(cls)) { + throw new BugIndicatingError( + `Model '${this.constructor.name}' already applies event '${cls.type}'`, + ); + } + this.appliers.set(cls, applier as (event: any) => void); + } + + protected emit(event: Event2): Promise { + const window = this.window; + if (window !== undefined) { + window.ctx.emit(event); + return Promise.resolve(); + } + return this.bridge.dispatch(event); + } + + protected readLegacy(key: StateKey): T { + return this.bridge.readLegacy(key) as T; + } + + onUndo?(count: number): void; + + dispose(): void | Promise {} + + _seal(): void { + this.sealed = true; + } + + _appliersTable(): ReadonlyMap, (event: any) => void> { + return this.appliers; + } + + _state(): S { + return this.committedState; + } + + _commitState(next: S): void { + this.committedState = next; + } + + _enterWindow(draft: S, ctx: FoldContext): void { + this.window = { draft, ctx, replaced: false, replacement: undefined }; + } + + _exitWindow(): { readonly replaced: boolean; readonly replacement: unknown } { + const window = this.window; + this.window = undefined; + return { replaced: window?.replaced ?? false, replacement: window?.replacement }; + } +} + +export interface AgentModelStateSpec { + readonly initial: () => S; + readonly schema: z.ZodType; +} + +export interface AgentModelDefinition = AgentModel> { + readonly id: string; + readonly model: new (context: AgentModelContext) => M; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable: boolean; +} + +export interface AgentModelDefinitionInput> { + readonly id: string; + readonly model: new (context: AgentModelContext) => M; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable?: boolean; +} + +const AGENT_MODEL_DEFINITIONS = new Map>(); + +/** + * Declares one domain's agent-granular Model: the Model class, its state + * spec, and the static durable-event vocabulary its appliers cover. The + * returned definition is the token used with `AgentContext.space.use(...)` + * and `Feature.contributeAgentModel(...)`. + */ +export function defineAgentModel>( + input: AgentModelDefinitionInput, +): AgentModelDefinition { + if (AGENT_MODEL_DEFINITIONS.has(input.id)) { + throw new BugIndicatingError(`Agent model '${input.id}' is already defined`); + } + for (const cls of input.events) { + if (!cls.durable) { + throw new BugIndicatingError( + `Agent model '${input.id}' cannot apply non-durable event '${cls.type}'`, + ); + } + registerEvent2Class(cls); + } + const definition: AgentModelDefinition = Object.freeze({ + id: input.id, + model: input.model, + state: input.state, + events: Object.freeze([...input.events]), + undoable: input.undoable ?? false, + }); + AGENT_MODEL_DEFINITIONS.set(definition.id, definition); + return definition; +} + +export function agentModelDefinitions(): readonly AgentModelDefinition[] { + return [...AGENT_MODEL_DEFINITIONS.values()]; +} + +export const AgentModelContribution = collection>('agent-model', { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Agent model '${value.id}' already has an active provider`); + } + }, +}); + +export interface SessionModelDefinition { + readonly id: string; + readonly state: AgentModelStateSpec; + readonly events: readonly Event2Class[]; + readonly undoable: boolean; +} + +export const SessionModelContribution = collection('session-model', { + validate: (value, existing) => { + if (existing.some((definition) => definition.id === value.id)) { + throw new Error(`Session model '${value.id}' already has an active provider`); + } + }, +}); diff --git a/packages/agent-core-v2/src/state/eventDispatcher.ts b/packages/agent-core-v2/src/state/eventDispatcher.ts index 22add002eac..3dfa76e20f7 100644 --- a/packages/agent-core-v2/src/state/eventDispatcher.ts +++ b/packages/agent-core-v2/src/state/eventDispatcher.ts @@ -8,6 +8,11 @@ export type EventDispatcherHooks = { readonly onDidRestore: Record; }; +export interface ModelCheckpointDepth { + readonly id: string; + readonly depth: number; +} + export interface IEventDispatcher { readonly _serviceBrand: undefined; @@ -16,6 +21,7 @@ export interface IEventDispatcher { dispatch(event: Event2): Promise; history(key: ReplayableStateKey): readonly PatchEntry[]; checkpointDepth(key: ReplayableStateKey): number; + modelCheckpointDepths(): readonly ModelCheckpointDepth[]; undo(key: ReplayableStateKey, patchId: number): void; restore(): Promise; flush(): Promise; diff --git a/packages/agent-core-v2/src/state/eventDispatcherService.ts b/packages/agent-core-v2/src/state/eventDispatcherService.ts index 12209f83e4b..09a648942c0 100644 --- a/packages/agent-core-v2/src/state/eventDispatcherService.ts +++ b/packages/agent-core-v2/src/state/eventDispatcherService.ts @@ -6,9 +6,16 @@ import { Service } from '#/_base/di/service'; import { type CollectionView } from '#/_base/di/collection'; import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { AgentSpaceImpl, type AgentSpaceHost } from '#/agent/agentContext/agentSpace'; import { IAgentBlobService } from '#/agent/blob/agentBlobService'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; -import { event2FromRecord, type Event2, type Event2Class } from '#/app/event/event2'; +import { + event2FromRecord, + type AgentDomainTrait, + type Event2, + type Event2Class, +} from '#/app/event/event2'; import { IEventBus } from '#/app/event/eventBus'; import type { ContentPart } from '#/kosong/contract/message'; import { OrderedHookSlot } from '#/hooks'; @@ -16,10 +23,18 @@ import { IWireService } from '#/wire/wire'; import { WireError, WireErrors } from '#/wire/errors'; import type { PartsTransformer } from '#/wire/record'; -import { IEventDispatcher } from './eventDispatcher'; +import { + AgentModelContribution, + agentModelDefinitions, + type AgentModel, + type AgentModelDefinition, +} from './agentModel'; +import { IEventDispatcher, type ModelCheckpointDepth } from './eventDispatcher'; import { StateError, StateErrors } from './errors'; import { + expandedModelAppliers, keepsUndoCheckpoints, + type EventApplier, type FoldContext, type PatchEntry, type ReplayableStateKey, @@ -66,6 +81,22 @@ interface PreparedFold { readonly inversePatches: PatchEntry['inversePatches']; } +interface ModelAttachment { + readonly definition: AgentModelDefinition; + readonly model: AgentModel; + readonly appliers: ReadonlyMap, EventApplier>; + readonly meta: StateMeta; + readonly keepsCheckpoints: boolean; +} + +interface PreparedModel { + readonly attachment: ModelAttachment; + readonly ctx: FoldContextImpl; + readonly next: any; + readonly patches: PatchEntry['patches']; + readonly inversePatches: PatchEntry['inversePatches']; +} + type RestorePhase = 'new' | 'restoring' | 'ready' | 'failed'; class FoldContextImpl implements FoldContext { @@ -96,6 +127,17 @@ class FoldContextImpl implements FoldContext { } } +function sanitizePendingUndo(ctx: FoldContextImpl, meta: StateMeta): void { + if ( + ctx.pendingUndo !== undefined && + (!Number.isSafeInteger(ctx.pendingUndo) || + ctx.pendingUndo <= 0 || + meta.checkpoints.length < ctx.pendingUndo) + ) { + ctx.pendingUndo = undefined; + } +} + export class EventDispatcherService extends Service implements IEventDispatcher { declare readonly _serviceBrand: undefined; @@ -106,17 +148,33 @@ export class EventDispatcherService extends Service implements IEventDispatcher private readonly metas = new Map, StateMeta>(); private folded: FoldedEventStateRegistry; + private activeModelDefs = new Map>(); + private readonly withdrawnModelIds = new Set(); + private modelTargets = new Map[]>(); + private readonly attachments = new Map, ModelAttachment>(); + + private readonly spaceHost: AgentSpaceHost = { + isActiveModelDefinition: (definition) => + this.activeModelDefs.get(definition.id) === definition, + registerModel: (definition, model) => this.registerModel(definition, model), + dispatchModelEvent: (event) => this.dispatch(event), + readLegacyState: (key) => this.agentState.get(key), + }; + private restorePhase: RestorePhase = 'new'; private dispatching = false; + private disposed = false; private queue: QueuedEvent[] = []; private drainDepth = 0; constructor( @IWireService private readonly wire: IWireService, @IEventBus private readonly eventBus: IEventBus, + @IAgentScopeContext private readonly agentScope: IAgentScopeContext | undefined, @IAgentBlobService private readonly blobService: IAgentBlobService, @IAgentStateService private readonly agentState: IAgentStateService, @EventStateContribution view: CollectionView, + @AgentModelContribution modelView: CollectionView>, ) { super(); this.folded = this.foldContributions(view); @@ -141,6 +199,31 @@ export class EventDispatcherService extends Service implements IEventDispatcher this.folded = this.foldContributions(view); }), ); + this.refoldModels(modelView.items); + this._register( + modelView.onDidChange(({ added, removed }) => { + for (const definition of removed) { + this.withdrawnModelIds.add(definition.id); + const attachment = this.attachments.get(definition); + if (attachment !== undefined) { + this.attachments.delete(definition); + this.space()?.retireModel(definition); + } + } + for (const definition of added) { + this.withdrawnModelIds.delete(definition.id); + } + this.refoldModels(modelView.items); + this.materializeUndoableModels(); + }), + ); + this.space()?._attachHost(this.spaceHost); + this.materializeUndoableModels(); + } + + private space(): AgentSpaceImpl | undefined { + const space = this.agentScope?.agentContext.space; + return space instanceof AgentSpaceImpl ? space : undefined; } private foldContributions( @@ -149,6 +232,97 @@ export class EventDispatcherService extends Service implements IEventDispatcher return foldEventStateContributions(view.items, this.agentState.replayableKeys()); } + private refoldModels(records: readonly AgentModelDefinition[]): void { + const defs = new Map>(); + for (const definition of agentModelDefinitions()) { + if (!this.withdrawnModelIds.has(definition.id)) defs.set(definition.id, definition); + } + for (const definition of records) defs.set(definition.id, definition); + this.activeModelDefs = defs; + this.rebuildModelTargets(); + } + + private rebuildModelTargets(): void { + const targets = new Map[]>(); + const add = (type: string, definition: AgentModelDefinition): void => { + const list = targets.get(type); + if (list === undefined) { + targets.set(type, [definition]); + return; + } + if (!list.includes(definition)) list.push(definition); + }; + const domainOwners = new Map>(); + for (const definition of this.activeModelDefs.values()) { + for (const cls of definition.events) { + const owner = domainOwners.get(cls.type); + if (owner !== undefined && owner !== definition) { + throw new BugIndicatingError( + `Event '${cls.type}' is applied by both agent models '${owner.id}' and '${definition.id}'`, + ); + } + domainOwners.set(cls.type, definition); + add(cls.type, definition); + } + } + for (const [definition, attachment] of this.attachments) { + if (this.activeModelDefs.get(definition.id) !== definition) continue; + for (const cls of attachment.appliers.keys()) add(cls.type, definition); + } + this.modelTargets = targets; + } + + private materializeUndoableModels(): void { + const space = this.space(); + if (space === undefined) return; + for (const definition of this.activeModelDefs.values()) { + if (!definition.undoable || this.attachments.has(definition)) continue; + space.ensureModel(definition); + } + } + + private registerModel( + definition: AgentModelDefinition, + model: AgentModel, + ): void { + if (this.attachments.has(definition)) return; + const domainAppliers = new Map, EventApplier>(); + for (const [cls, applier] of model._appliersTable()) { + domainAppliers.set(cls, (event) => applier.call(model, event)); + } + const customUndo = + model.onUndo === undefined ? undefined : (count: number): void => model.onUndo!(count); + const expanded = expandedModelAppliers( + definition.id, + definition.undoable, + domainAppliers, + customUndo, + ); + this.attachments.set(definition, { + definition, + model, + appliers: expanded, + meta: { history: [], checkpoints: [], nextPatchId: 1 }, + keepsCheckpoints: definition.undoable && customUndo === undefined, + }); + this.rebuildModelTargets(); + } + + private materializeModel(definition: AgentModelDefinition): ModelAttachment { + const space = this.space(); + if (space === undefined) { + throw new BugIndicatingError( + `Agent model '${definition.id}' cannot materialize without an agent space`, + ); + } + space.ensureModel(definition); + const attachment = this.attachments.get(definition); + if (attachment === undefined) { + throw new BugIndicatingError(`Agent model '${definition.id}' failed to attach`); + } + return attachment; + } + history(key: ReplayableStateKey): readonly PatchEntry[] { return this.ensureMeta(key).history; } @@ -158,6 +332,15 @@ export class EventDispatcherService extends Service implements IEventDispatcher return meta?.checkpoints.length ?? 0; } + modelCheckpointDepths(): readonly ModelCheckpointDepth[] { + const depths: ModelCheckpointDepth[] = []; + for (const attachment of this.attachments.values()) { + if (!attachment.keepsCheckpoints) continue; + depths.push({ id: attachment.definition.id, depth: attachment.meta.checkpoints.length }); + } + return depths; + } + undo(key: ReplayableStateKey, patchId: number): void { const meta = this.ensureMeta(key); const head = meta.history.at(-1); @@ -177,6 +360,16 @@ export class EventDispatcherService extends Service implements IEventDispatcher } dispatch(event: Event2): Promise { + const cls = event.constructor as Event2Class; + if ( + cls.agentDomain && + (this.agentScope === undefined || + (event as Event2 & AgentDomainTrait).agentId !== this.agentScope.agentId) + ) { + return Promise.reject( + new Error(`Agent event '${event.type}' does not match dispatcher lifecycle context`), + ); + } if (this.dispatching) { return new Promise((resolve, reject) => { this.queue.push({ event, resolve, reject }); @@ -242,20 +435,46 @@ export class EventDispatcherService extends Service implements IEventDispatcher `Fold of event '${event.type}' on state '${key.name}' both mutates and undoes to a checkpoint`, ); } - if ( - ctx.pendingUndo !== undefined && - (!Number.isSafeInteger(ctx.pendingUndo) || - ctx.pendingUndo <= 0 || - meta.checkpoints.length < ctx.pendingUndo) - ) { - ctx.pendingUndo = undefined; - } + sanitizePendingUndo(ctx, meta); prepared.push({ key, meta, ctx, next, patches, inversePatches }); } } + const modelTargets = this.modelTargets.get(event.type); + const preparedModels: PreparedModel[] = []; + if (modelTargets !== undefined) { + for (const definition of modelTargets) { + const attachment = this.attachments.get(definition) ?? this.materializeModel(definition); + const applier = attachment.appliers.get(event.constructor as Event2Class); + if (applier === undefined) continue; + const ctx = new FoldContextImpl(this, silent); + const [next, patches, inversePatches] = produceWithPatches( + attachment.model._state(), + (draft: any) => { + attachment.model._enterWindow(draft, ctx); + let windowResult: ReturnType['_exitWindow']>; + try { + applier(event, ctx); + } finally { + windowResult = attachment.model._exitWindow(); + } + return windowResult.replaced ? windowResult.replacement : undefined; + }, + ); + if (ctx.pendingUndo !== undefined && patches.length > 0) { + throw new BugIndicatingError( + `Applier of event '${event.type}' on model '${definition.id}' both mutates and undoes to a checkpoint`, + ); + } + sanitizePendingUndo(ctx, attachment.meta); + preparedModels.push({ attachment, ctx, next, patches, inversePatches }); + } + } for (const p of prepared) { this.commit(p.key, p.meta, p.ctx, event, p.next, p.patches, p.inversePatches); } + for (const p of preparedModels) { + this.commitModel(p.attachment, p.ctx, event, p.next, p.patches, p.inversePatches); + } if (silent) return; const cls = event.constructor as Event2Class; if (cls.durable) { @@ -263,9 +482,19 @@ export class EventDispatcherService extends Service implements IEventDispatcher .replayable.blobs?.dehydrate; this.wire.appendRecord(event.serialize(), dehydrator); } - if (cls.observable) { - this.eventBus.publish(event); + if (cls.observable && !this.disposed) { + this.eventBus.publish(event, this.agentScope?.agentContext); + } + } + + override dispose(): void { + this.disposed = true; + const space = this.space(); + if (space !== undefined) { + space._detachHost(this.spaceHost); + space._kill(); } + super.dispose(); } private commit( @@ -306,6 +535,44 @@ export class EventDispatcherService extends Service implements IEventDispatcher this.trimHistory(key, meta); } + private commitModel( + attachment: ModelAttachment, + ctx: FoldContextImpl, + event: Event2, + next: any, + patches: PatchEntry['patches'], + inversePatches: PatchEntry['inversePatches'], + ): void { + const meta = attachment.meta; + if (ctx.pendingUndo !== undefined) { + const targetIndex = meta.checkpoints.length - ctx.pendingUndo; + const targetId = meta.checkpoints[targetIndex]!; + this.rollbackModel(attachment, targetId); + meta.checkpoints = meta.checkpoints.slice(0, targetIndex); + return; + } + attachment.model._commitState(next); + if (ctx.pendingClear) { + meta.history = []; + meta.checkpoints = []; + } + let markerId = meta.history.at(-1)?.id ?? 0; + if (patches.length > 0 || inversePatches.length > 0) { + const entry: PatchEntry = { + id: meta.nextPatchId++, + eventType: event.type, + patches, + inversePatches, + }; + meta.history.push(entry); + markerId = entry.id; + } + if (ctx.pendingCheckpoint) { + meta.checkpoints.push(markerId); + } + this.trimModelHistory(attachment); + } + private rollback(key: ReplayableStateKey, meta: StateMeta, targetEntryId: number): void { let i = meta.history.length - 1; let current = this.agentState.get(key); @@ -317,6 +584,18 @@ export class EventDispatcherService extends Service implements IEventDispatcher meta.history = meta.history.slice(0, i + 1); } + private rollbackModel(attachment: ModelAttachment, targetEntryId: number): void { + const meta = attachment.meta; + let i = meta.history.length - 1; + let current = attachment.model._state(); + while (i >= 0 && meta.history[i]!.id > targetEntryId) { + current = applyPatches(current, [...meta.history[i]!.inversePatches]); + i--; + } + attachment.model._commitState(current); + meta.history = meta.history.slice(0, i + 1); + } + private trimHistory(key: ReplayableStateKey, meta: StateMeta): void { const oldest = meta.checkpoints[0]; if (oldest !== undefined) { @@ -331,6 +610,21 @@ export class EventDispatcherService extends Service implements IEventDispatcher } } + private trimModelHistory(attachment: ModelAttachment): void { + const meta = attachment.meta; + const oldest = meta.checkpoints[0]; + if (oldest !== undefined) { + const firstRetained = meta.history.findIndex((entry) => entry.id >= oldest); + if (firstRetained > 0) { + meta.history.splice(0, firstRetained); + } + return; + } + if (!attachment.keepsCheckpoints && meta.history.length > HISTORY_TAIL) { + meta.history.splice(0, meta.history.length - HISTORY_TAIL); + } + } + private ensureMeta(key: ReplayableStateKey): StateMeta { let meta = this.metas.get(key); if (meta === undefined) { @@ -357,7 +651,23 @@ export class EventDispatcherService extends Service implements IEventDispatcher recordIndex++; continue; } - const event = event2FromRecord(cls, record); + let eventRecord = record; + if (cls.agentDomain) { + if (this.agentScope === undefined) { + this.reportSkippedRecord(record.type, recordIndex, true); + recordIndex++; + continue; + } + const recordAgentId = record['agentId']; + if (recordAgentId === undefined) { + eventRecord = { ...record, agentId: this.agentScope.agentId }; + } else if (recordAgentId !== this.agentScope.agentId) { + this.reportSkippedRecord(record.type, recordIndex, true); + recordIndex++; + continue; + } + } + const event = event2FromRecord(cls, eventRecord); if (event === undefined) { this.reportSkippedRecord(record.type, recordIndex, true); recordIndex++; diff --git a/packages/agent-core-v2/src/state/state.ts b/packages/agent-core-v2/src/state/state.ts index bc649008479..4c4f6b1fe3a 100644 --- a/packages/agent-core-v2/src/state/state.ts +++ b/packages/agent-core-v2/src/state/state.ts @@ -220,6 +220,56 @@ export function expandedStateFolds( return folds; } +export type EventApplier = (event: any, ctx: FoldContext) => void; + +export function expandedModelAppliers( + owner: string, + undoable: boolean, + appliers: ReadonlyMap, EventApplier>, + onUndo: ((count: number) => void) | undefined, +): ReadonlyMap, EventApplier> { + if (!undoable) return appliers; + if (undoableProtocol === undefined) { + throw new BugIndicatingError( + `Agent model '${owner}' is undoable but no undoable protocol is registered ` + + '(the contextMemory domain registers it at import time)', + ); + } + const protocol = undoableProtocol; + if (appliers.has(protocol.events.undo)) { + throw new BugIndicatingError( + `Undoable agent model '${owner}' must not apply the undo event itself; ` + + 'override onUndo on the model to customize the rollback', + ); + } + const custom = onUndo !== undefined; + const expanded = new Map, EventApplier>(appliers); + const domainAppend = expanded.get(protocol.events.appendMessage); + expanded.set(protocol.events.appendMessage, (event, ctx) => { + if (!custom && protocol.isUndoAnchor(event.message)) { + ctx.checkpoint(); + return; + } + domainAppend?.(event, ctx); + }); + for (const cls of [protocol.events.applyCompaction, protocol.events.clear]) { + const domain = expanded.get(cls); + expanded.set(cls, (event, ctx) => { + ctx.clearCheckpoints(); + domain?.(event, ctx); + }); + } + expanded.set(protocol.events.undo, (event, ctx) => { + if (!protocol.isValidUndoCount(event.count)) return; + if (onUndo !== undefined) { + onUndo(event.count); + return; + } + ctx.undoToCheckpoint(event.count); + }); + return expanded; +} + export type DeepReadonly = T extends (...args: infer A) => infer R ? (...args: A) => R : T extends ReadonlyMap diff --git a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts index 229facc0621..a6bacc72341 100644 --- a/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts @@ -28,7 +28,11 @@ import { ErrorCodes, Error2, isError2 } from '#/errors'; import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { agentContextOf } from '#/agent/scopeContext/scopeContext'; +import { + IAgentLifecycleService, + MAIN_AGENT_ID, +} from '#/session/agentLifecycle/agentLifecycle'; import { ensureMainAgent } from '#/session/agentLifecycle/mainAgent'; import { labelsFromAgentMeta } from '#/session/agentLifecycle/subagentMetadata'; import { ISessionContext, sessionContextSeed } from '#/session/sessionContext/sessionContext'; @@ -349,7 +353,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec }); try { const agents = handle.accessor.get(IAgentLifecycleService); - if (agents.get(MAIN_AGENT_ID) === undefined) { + if (agents.findAgentHandle(MAIN_AGENT_ID) === undefined) { await agents.create({ agentId: MAIN_AGENT_ID }); } await this.announceCreated({ sessionId, handle, source: 'resume' }); @@ -433,7 +437,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async drainAgents(handle: ISessionScopeHandle): Promise { const agentLifecycle = handle.accessor.get(IAgentLifecycleService); for (const agent of agentLifecycle.list()) { - await agentLifecycle.remove(agent.id); + await agentLifecycle.remove(agentContextOf(agent)); } } @@ -621,7 +625,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } else if (records[0]?.type !== 'metadata') { records.unshift(createWireMetadataRecord()); } - records.push(forkedRecord()); + records.push(forkedRecord(args.agentId)); await this.appendLogStore.rewrite( agentScopeOf(sessionScopeOf(this.handlerScope, args.targetSessionId), args.agentId), @@ -636,7 +640,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec agentId: string, ): Promise { if (sourceHandle !== undefined) { - const agentHandle = sourceHandle.accessor.get(IAgentLifecycleService).get(agentId); + const agentHandle = sourceHandle.accessor + .get(IAgentLifecycleService) + .findAgentHandle(agentId); if (agentHandle !== undefined) { await agentHandle.accessor.get(IEventDispatcher).flush(); } @@ -733,8 +739,8 @@ function createSessionId(): string { return `session_${randomUUID()}`; } -function forkedRecord(): WireRecord { - return { type: 'forked', time: Date.now() }; +function forkedRecord(agentId: string): WireRecord { + return { type: 'forked', agentId, time: Date.now() }; } function forkCustomMetadata( diff --git a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts index 495507bebf5..ff95a03c5e3 100644 --- a/packages/agent-core-v2/test/agent/activityView/activityView.test.ts +++ b/packages/agent-core-v2/test/agent/activityView/activityView.test.ts @@ -8,6 +8,7 @@ import type { Event2, Event2Class } from '#/app/event/event2'; import { IAgentLoopService } from '#/agent/loop/loop'; import { TurnStarted } from '#/agent/loop/turnEvents'; import { TurnEnded, turnKey, type TurnModelState } from '#/agent/loop/turnOps'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentTaskService } from '#/agent/task/task'; @@ -27,6 +28,7 @@ import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompacti import type { FullCompactionTask } from '#/agent/fullCompaction/fullCompaction'; import { OrderedHookSlot } from '#/hooks'; import { IEventDispatcher } from '#/state/eventDispatcher'; +import { stubAgentContext } from '../agentContext/stubs'; class FakeBus { private readonly byType = new Map void>>(); @@ -106,6 +108,12 @@ function harness( agentState.contributeState(turnKey); agentState.set(turnKey, { nextTurnId: 1, cancelledTurnIds: [], lastEnded }); ix.set(IAgentStateService, agentState); + ix.stub(IAgentScopeContext, { + _serviceBrand: undefined, + agentId: 'main', + agentContext: stubAgentContext('main', 1), + scope: (subKey?: string) => subKey ?? '', + }); ix.stub(IAgentFullCompactionService, { _serviceBrand: undefined, compacting, @@ -136,11 +144,11 @@ describe('AgentActivityView', () => { it('folds task.started / task.terminated into the background slice', () => { const { bus, view, updates } = harness(); - bus.publish(new TaskStarted({ info: makeTaskInfo('bash-1') })); + bus.publish(new TaskStarted({ agentId: 'main', info: makeTaskInfo('bash-1') })); expect(view.state().background).toEqual([{ kind: 'process', id: 'bash-1', since: 100 }]); expect(updates().at(-1)?.background).toHaveLength(1); - bus.publish(new TaskTerminatedNotice({ info: makeTaskInfo('bash-1') })); + bus.publish(new TaskTerminatedNotice({ agentId: 'main', info: makeTaskInfo('bash-1') })); expect(view.state().background).toEqual([]); expect(updates().at(-1)?.background).toHaveLength(0); }); @@ -164,7 +172,7 @@ describe('AgentActivityView', () => { it('does not overwrite a live lastTurn when the restore hook runs', async () => { const { bus, view, restore } = harness([], null, { turnId: 7, reason: 'failed' }); - bus.publish(new TurnEnded({ turnId: 9, reason: 'completed' })); + bus.publish(new TurnEnded({ agentId: 'main', turnId: 9, reason: 'completed' })); await restore({ turnId: 7, reason: 'failed' }); expect(view.state().lastTurn).toMatchObject({ turnId: 9, reason: 'completed' }); }); @@ -177,12 +185,12 @@ describe('AgentActivityView', () => { it('folds full compaction into the background slice', () => { const { bus, view } = harness(); - bus.publish(new CompactionStarted({ trigger: 'manual' })); + bus.publish(new CompactionStarted({ agentId: 'main', trigger: 'manual' })); expect(view.state().background).toEqual([ expect.objectContaining({ kind: 'compaction', id: 'full-compaction' }), ]); - bus.publish(new CompactionCancelled({})); + bus.publish(new CompactionCancelled({ agentId: 'main' })); expect(view.state().background).toEqual([]); }); @@ -204,10 +212,10 @@ describe('AgentActivityView', () => { it('folds turn boundaries into turn / lastTurn', () => { const { bus, view } = harness(); - bus.publish(new TurnStarted({ turnId: 1, origin: { kind: 'user' } })); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); expect(view.state().turn?.turnId).toBe(1); - bus.publish(new TurnEnded({ turnId: 1, reason: 'completed' })); + bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'completed' })); expect(view.state().turn).toBeUndefined(); expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'completed' }); }); @@ -215,26 +223,25 @@ describe('AgentActivityView', () => { it('clears the previous outcome when a new turn starts', () => { const { bus, view } = harness(); - bus.publish(new TurnStarted({ turnId: 1, origin: { kind: 'user' } })); - bus.publish(new TurnEnded({ turnId: 1, reason: 'cancelled' })); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); + bus.publish(new TurnEnded({ agentId: 'main', turnId: 1, reason: 'cancelled' })); expect(view.state().lastTurn).toMatchObject({ turnId: 1, reason: 'cancelled' }); - bus.publish(new TurnStarted({ turnId: 2, origin: { kind: 'user' } })); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 2, origin: { kind: 'user' } })); expect(view.state().lastTurn).toBeUndefined(); - bus.publish(new TurnEnded({ turnId: 2, reason: 'completed' })); + bus.publish(new TurnEnded({ agentId: 'main', turnId: 2, reason: 'completed' })); expect(view.state().lastTurn).toMatchObject({ turnId: 2, reason: 'completed' }); }); it('exposes the engine-minted interaction id as the approval id', () => { const { bus, view } = harness(); - bus.publish(new TurnStarted({ turnId: 1, origin: { kind: 'user' } })); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); bus.publish( - new PermissionApprovalRequested({ + new PermissionApprovalRequested({ agentId: 'main', id: 'approval_1', sessionId: 's', - agentId: 'main', turnId: 1, toolCallId: 'tc-1', toolName: 'Bash', @@ -248,10 +255,9 @@ describe('AgentActivityView', () => { ]); bus.publish( - new PermissionApprovalResolved({ + new PermissionApprovalResolved({ agentId: 'main', id: 'approval_1', sessionId: 's', - agentId: 'main', turnId: 1, toolCallId: 'tc-1', toolName: 'Bash', @@ -267,11 +273,10 @@ describe('AgentActivityView', () => { it('falls back to the tool call id when the approval event carries no interaction id', () => { const { bus, view } = harness(); - bus.publish(new TurnStarted({ turnId: 1, origin: { kind: 'user' } })); + bus.publish(new TurnStarted({ agentId: 'main', turnId: 1, origin: { kind: 'user' } })); bus.publish( - new PermissionApprovalRequested({ + new PermissionApprovalRequested({ agentId: 'main', sessionId: 's', - agentId: 'main', turnId: 1, toolCallId: 'tc-1', toolName: 'Bash', diff --git a/packages/agent-core-v2/test/agent/agentContext/stubs.ts b/packages/agent-core-v2/test/agent/agentContext/stubs.ts new file mode 100644 index 00000000000..35cfa1f2d95 --- /dev/null +++ b/packages/agent-core-v2/test/agent/agentContext/stubs.ts @@ -0,0 +1,10 @@ +import type { AgentContext } from '#/agent/agentContext/agentContext'; +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; + +export function stubAgentContext(agentId: string, generation = 1): AgentContext { + return makeAgentScopeContext({ + agentId, + agentScope: `agents/${agentId}`, + generation, + }).agentContext; +} 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 333096741e6..a842848e6a7 100644 --- a/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts +++ b/packages/agent-core-v2/test/agent/agentsMdReminder/agentsMdReminder.test.ts @@ -54,6 +54,7 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st import { stubToolExecutorEvents, type ToolExecutorEventStubs } from '../toolExecutor/stubs'; import { stubLoopWithHooks } from '../loop/stubs'; import { registerLogServices } from '../../_base/log/stubs'; +import { stubAgentContext } from '../agentContext/stubs'; let disposables: DisposableStore; let homeDir: string; @@ -116,6 +117,7 @@ function createHarness( reg.defineInstance(IAgentScopeContext, { _serviceBrand: undefined, agentId: 'main', + agentContext: stubAgentContext('main', 0), scope: (sub?: string): string => (sub ? `agents/main/${sub}` : 'agents/main'), } satisfies IAgentScopeContext); reg.definePartialInstance(IFileSystemStorageService, { diff --git a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts index efe74f2e483..7e202723e37 100644 --- a/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts +++ b/packages/agent-core-v2/test/agent/contextInjector/contextInjector.test.ts @@ -19,6 +19,7 @@ import { AgentStateService } from '#/agent/state/agentStateService'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { AgentSystemReminderService } from '#/agent/systemReminder/systemReminderService'; import { IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; import { IWireService } from '#/wire/wire'; import { registerLogServices } from '../../_base/log/stubs'; import { registerContextMemoryServices, type StubContextMemory } from '../contextMemory/stubs'; @@ -28,6 +29,7 @@ import { stubLoopWithHooks, stubWire, } from '../loop/stubs'; +import { stubAgentContext } from '../agentContext/stubs'; function injector(ix: TestInstantiationService): IAgentContextInjectorService { return ix.get(IAgentContextInjectorService); @@ -95,12 +97,17 @@ describe('AgentContextInjectorService', () => { ): void { const backing = (context as StubContextMemory).messages as ContextMessage[]; backing.splice(start, deleteCount, ...inserted); - ix.get(IEventBus).publish( + const eventBus = ix.get(IEventBus); + const agentContext = stubAgentContext('main', 1); + (eventBus as EventBusService).activateAgent(agentContext); + eventBus.publish( new ContextSpliced({ + agentId: 'main', start, deleteCount, messages: [...inserted], }), + agentContext, ); } 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 dbf97dd0fe7..56f545685af 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -14,7 +14,6 @@ import type { ContextMessage } from '#/agent/contextMemory/types'; import { IWireService } from '#/wire/wire'; import { IAgentContextMemoryService, - IAgentTokenCountingService, IAgentProfileService, } from '#/index'; @@ -23,14 +22,14 @@ import { createTestAgent, type TestAgentContext } from '../../harness'; describe('Agent context', () => { let ctx: TestAgentContext; let context: IAgentContextMemoryService; - let tokenCounting: IAgentTokenCountingService; + let tokenCounting: TestAgentContext['tokenCounting']; let profile: IAgentProfileService; let wire: IWireService; beforeEach(() => { ctx = createTestAgent(); context = ctx.get(IAgentContextMemoryService); - tokenCounting = ctx.get(IAgentTokenCountingService); + tokenCounting = ctx.tokenCounting; profile = ctx.get(IAgentProfileService); wire = ctx.get(IWireService); }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts index 0090a47c468..53f652e4d46 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/message-history.test.ts @@ -6,7 +6,7 @@ import { TestInstantiationService } from '#/_base/di/test'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { AgentContextMemoryService } from '#/agent/contextMemory/contextMemoryService'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +import { ISessionTokenCountingService } from '#/session/tokenCounting/sessionTokenCounting'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; @@ -26,13 +26,15 @@ function textOf(message: ContextMessage): string { .join(''); } -const noopTokenCounting: IAgentTokenCountingService = { +const noopTokenCounting: ISessionTokenCountingService = { _serviceBrand: undefined, strategy: 'measured+estimated', get: () => ({ size: 0, measured: 0, estimated: 0 }), measured: () => {}, latestMeasured: () => 0, statusSize: () => 0, + recordTruncation: () => {}, + rebase: () => {}, requestSize: () => 0, estimateText: () => 0, estimateMessage: () => 0, @@ -50,7 +52,7 @@ describe('message history (IAgentContextMemoryService)', () => { ix = disposables.add(new TestInstantiationService()); ix.set(IEventBus, new SyncDescriptor(EventBusService)); registerTestAgentWire(ix, 'wire/message-history', { eventBus: ix.get(IEventBus) }); - ix.set(IAgentTokenCountingService, noopTokenCounting); + ix.set(ISessionTokenCountingService, noopTokenCounting); registerTestEventDispatcher(ix); ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); }); 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 f6b68621cb6..0d9b036728c 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 @@ -16,7 +16,7 @@ import { } from '#/agent/contextMemory/contextEvents'; import { contextMemoryKey } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; +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'; @@ -151,13 +151,15 @@ interface Host { eventBus: IEventBus; } -const noopTokenCounting: IAgentTokenCountingService = { +const noopTokenCounting: ISessionTokenCountingService = { _serviceBrand: undefined, strategy: 'measured+estimated', get: () => ({ size: 0, measured: 0, estimated: 0 }), measured: () => {}, latestMeasured: () => 0, statusSize: () => 0, + recordTruncation: () => {}, + rebase: () => {}, requestSize: () => 0, estimateText: () => 0, estimateMessage: () => 0, @@ -171,7 +173,7 @@ function buildHost(key: string): Host { ix.set(IAppendLogStore, new SyncDescriptor(AppendLogStore)); ix.stub(IAgentBlobService, blob); ix.set(IEventBus, new SyncDescriptor(EventBusService)); - ix.set(IAgentTokenCountingService, noopTokenCounting); + ix.set(ISessionTokenCountingService, noopTokenCounting); ix.set(IAgentContextMemoryService, new SyncDescriptor(AgentContextMemoryService)); const wire = registerTestAgentWire(ix, testWireScope(SCOPE, key), { log: ix.get(IAppendLogStore), @@ -209,23 +211,23 @@ describe('AgentContextMemoryService (wire-backed)', () => { const host = buildHost(KEY); const model = () => host.agentState.get(contextMemoryKey); - await host.dispatcher.dispatch(new ContextAppendMessage({ message: userMessage('a') })); - await host.dispatcher.dispatch(new ContextAppendMessage({ message: userMessage('b') })); + await host.dispatcher.dispatch(new ContextAppendMessage({ agentId: 'test-agent', message: userMessage('a') })); + await host.dispatcher.dispatch(new ContextAppendMessage({ agentId: 'test-agent', message: userMessage('b') })); expect(model()).toHaveLength(2); let prev = model(); - await host.dispatcher.dispatch(new ContextAppendMessage({ message: userMessage('c') })); + await host.dispatcher.dispatch(new ContextAppendMessage({ agentId: 'test-agent', message: userMessage('c') })); expect(model()).not.toBe(prev); expect(model()).toHaveLength(3); prev = model(); - await host.dispatcher.dispatch(new ContextUndo({ count: 1 })); + await host.dispatcher.dispatch(new ContextUndo({ agentId: 'test-agent', count: 1 })); expect(model()).not.toBe(prev); expect(model()).toHaveLength(2); prev = model(); await host.dispatcher.dispatch( - new ContextApplyCompaction({ summary: 'sum', compactedCount: 1, tokensBefore: 0, tokensAfter: 0 }), + new ContextApplyCompaction({ agentId: 'test-agent', summary: 'sum', compactedCount: 1, tokensBefore: 0, tokensAfter: 0 }), ); expect(model()).not.toBe(prev); expect(model()).toHaveLength(2); @@ -236,7 +238,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { }); prev = model(); - await host.dispatcher.dispatch(new ContextClear({})); + await host.dispatcher.dispatch(new ContextClear({ agentId: 'test-agent' })); expect(model()).not.toBe(prev); expect(model()).toHaveLength(0); @@ -447,7 +449,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { const big = 'A'.repeat(200); const dataUri = `data:image/png;base64,${big}`; - await host.dispatcher.dispatch(new ContextAppendMessage({ message: imageMessage(big) })); + await host.dispatcher.dispatch(new ContextAppendMessage({ agentId: 'test-agent', message: imageMessage(big) })); await host.dispatcher.flush(); const live = host.agentState.get(contextMemoryKey); @@ -480,9 +482,14 @@ describe('AgentContextMemoryService (wire-backed)', () => { const host = buildHost(KEY); const big = 'A'.repeat(200); - await host.dispatcher.dispatch(new ContextAppendMessage({ message: imageMessage(big) })); await host.dispatcher.dispatch( - new ContextAppendLoopEvent({ event: { type: 'step.begin', uuid: 'interrupted' } }), + new ContextAppendMessage({ agentId: 'test-agent', message: imageMessage(big) }), + ); + await host.dispatcher.dispatch( + new ContextAppendLoopEvent({ + agentId: 'test-agent', + event: { type: 'step.begin', uuid: 'interrupted' }, + }), ); await host.dispatcher.flush(); const records = await readRecords(host.log); @@ -496,12 +503,18 @@ describe('AgentContextMemoryService (wire-backed)', () => { ); expect(blob.loadCalls).toBeGreaterThanOrEqual(1); - await replay.dispatcher.dispatch(new ContextAppendMessage({ message: userMessage('retry') })); await replay.dispatcher.dispatch( - new ContextAppendLoopEvent({ event: { type: 'step.begin', uuid: 'recovered' } }), + new ContextAppendMessage({ agentId: 'test-agent', message: userMessage('retry') }), + ); + await replay.dispatcher.dispatch( + new ContextAppendLoopEvent({ + agentId: 'test-agent', + event: { type: 'step.begin', uuid: 'recovered' }, + }), ); await replay.dispatcher.dispatch( new ContextAppendLoopEvent({ + agentId: 'test-agent', event: { type: 'content.part', stepUuid: 'recovered', @@ -510,7 +523,10 @@ describe('AgentContextMemoryService (wire-backed)', () => { }), ); await replay.dispatcher.dispatch( - new ContextAppendLoopEvent({ event: { type: 'step.end', uuid: 'recovered' } }), + new ContextAppendLoopEvent({ + agentId: 'test-agent', + event: { type: 'step.end', uuid: 'recovered' }, + }), ); const rebuilt = replay.agentState.get(contextMemoryKey); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 7237ef83107..b24a6339d7e 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -9,11 +9,12 @@ import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps'; import { ContextSpliced } from '#/agent/contextMemory/contextEvents'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IEventBus } from '#/app/event/eventBus'; +import { IEventBus, type ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { IWireService } from '#/wire/wire'; import { stubAgentWire } from '../../wire/stubs'; +import { stubAgentContext } from '../agentContext/stubs'; export interface StubContextMemory extends IAgentContextMemoryService { readonly messages: readonly ContextMessage[]; @@ -28,7 +29,15 @@ function publishSplice( tokens?: number; }, ): void { - eventBus?.publish(new ContextSpliced(input)); + if (eventBus === undefined) return; + const sessionBus = eventBus as Partial; + if (typeof sessionBus.activateAgent === 'function') { + const context = stubAgentContext('main', 1); + sessionBus.activateAgent(context); + sessionBus.publish?.(new ContextSpliced({ agentId: 'main', ...input }), context); + return; + } + eventBus.publish(new ContextSpliced({ agentId: 'main', ...input })); } export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { diff --git a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts index 6e7fcbe6a4b..6c4af9c5908 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -112,7 +112,7 @@ describe('contextUndo op', () => { function applyContextUndo(state: ContextMessage[], count: number): ContextMessage[] { const fold = expandedStateFolds(contextMemoryKey).get(ContextUndo)!; - const result = fold(castDraft(state), new ContextUndo({ count }), foldContext); + const result = fold(castDraft(state), new ContextUndo({ agentId: 'main', count }), foldContext); return result === undefined ? state : result; } diff --git a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts index 5f0afd5d87c..73e2a3992bd 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/compactionOps.test.ts @@ -81,15 +81,15 @@ describe('fullCompaction ops (wire-backed)', () => { it('begin/complete/cancel drive the phase and persist flat records', async () => { expect(agentState.get(fullCompactionKey).phase).toBe('idle'); - void dispatcher.dispatch(new FullCompactionBegin({ source: 'manual', instruction: 'keep facts' })); + void dispatcher.dispatch(new FullCompactionBegin({ agentId: 'test-agent', source: 'manual', instruction: 'keep facts' })); expect(agentState.get(fullCompactionKey).phase).toBe('running'); - void dispatcher.dispatch(new FullCompactionComplete({})); + void dispatcher.dispatch(new FullCompactionComplete({ agentId: 'test-agent' })); expect(agentState.get(fullCompactionKey).phase).toBe('idle'); - void dispatcher.dispatch(new FullCompactionBegin({ source: 'auto' })); + void dispatcher.dispatch(new FullCompactionBegin({ agentId: 'test-agent', source: 'auto' })); expect(agentState.get(fullCompactionKey).phase).toBe('running'); - void dispatcher.dispatch(new FullCompactionCancel({})); + void dispatcher.dispatch(new FullCompactionCancel({ agentId: 'test-agent' })); expect(agentState.get(fullCompactionKey).phase).toBe('idle'); const records = await readRecords(); @@ -107,24 +107,28 @@ describe('fullCompaction ops (wire-backed)', () => { instruction: 'keep facts', }), ); - expect(records[1]).toEqual({ type: 'full_compaction.complete', time: expect.any(Number) }); + expect(records[1]).toEqual({ + type: 'full_compaction.complete', + agentId: 'test-agent', + time: expect.any(Number), + }); }); it('fold keeps the same reference on a no-op (state stays quiet)', () => { - void dispatcher.dispatch(new FullCompactionCancel({})); + void dispatcher.dispatch(new FullCompactionCancel({ agentId: 'test-agent' })); const idle = agentState.get(fullCompactionKey); - void dispatcher.dispatch(new FullCompactionCancel({})); + void dispatcher.dispatch(new FullCompactionCancel({ agentId: 'test-agent' })); expect(agentState.get(fullCompactionKey)).toBe(idle); - void dispatcher.dispatch(new FullCompactionBegin({ source: 'manual' })); + void dispatcher.dispatch(new FullCompactionBegin({ agentId: 'test-agent', source: 'manual' })); const running = agentState.get(fullCompactionKey); - void dispatcher.dispatch(new FullCompactionBegin({ source: 'auto' })); + void dispatcher.dispatch(new FullCompactionBegin({ agentId: 'test-agent', source: 'auto' })); expect(agentState.get(fullCompactionKey)).toBe(running); }); it('replay rebuilds the phase silently', async () => { - void dispatcher.dispatch(new FullCompactionBegin({ source: 'manual' })); - void dispatcher.dispatch(new FullCompactionComplete({})); + void dispatcher.dispatch(new FullCompactionBegin({ agentId: 'test-agent', source: 'manual' })); + void dispatcher.dispatch(new FullCompactionComplete({ agentId: 'test-agent' })); const records = await readRecords(); const host = buildHost('full-compaction-replay'); 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 60b0800c156..2f47e0248e0 100644 --- a/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts +++ b/packages/agent-core-v2/test/agent/fullCompaction/fullCompaction.test.ts @@ -39,7 +39,6 @@ import { type ToolExecution, } from '#/index'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { IAgentTokenCountingService } from '#/agent/tokenCounting/tokenCounting'; import { IAgentGoalService } from '#/agent/goal/goal'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; @@ -260,7 +259,7 @@ describe('FullCompaction', () => { const candidate = event as { type?: unknown; event?: unknown }; return candidate.type === '[wire]' && candidate.event === 'full_compaction.complete'; }); - expect(completeEvent?.args).toEqual({ time: '