mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 07:33:18 +00:00
5885 lines
193 KiB
TypeScript
5885 lines
193 KiB
TypeScript
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
|
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
|
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
|
import { projectTimelineRows } from "./timeline-projection.js";
|
|
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
|
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
|
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
|
import { randomUUID } from "node:crypto";
|
|
import { basename, resolve } from "node:path";
|
|
import { stat } from "node:fs/promises";
|
|
import {
|
|
AGENT_LIFECYCLE_STATUSES,
|
|
type AgentLifecycleStatus,
|
|
} from "@getpaseo/protocol/agent-lifecycle";
|
|
import {
|
|
getParentAgentIdFromLabels,
|
|
hasOpenAgentTab,
|
|
isDelegatedAgent,
|
|
isOpenAgentTabLabel,
|
|
PARENT_AGENT_ID_LABEL,
|
|
} from "@getpaseo/protocol/agent-labels";
|
|
import type { Logger } from "pino";
|
|
import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
|
|
import type { ProviderPaseoToolsPolicy } from "@getpaseo/protocol/provider-config";
|
|
import { z } from "zod";
|
|
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
|
|
|
import {
|
|
getAgentStreamEventTurnId,
|
|
type AgentCapabilityFlags,
|
|
type AgentClient,
|
|
type AgentCreateSessionOptions,
|
|
type AgentResumePurpose,
|
|
type AgentResumeSessionOptions,
|
|
type AgentOptionControl,
|
|
type AgentLaunchContext,
|
|
type AgentSlashCommand,
|
|
type AgentMode,
|
|
type AgentPermissionRequest,
|
|
type AgentPermissionResponse,
|
|
type AgentPermissionResult,
|
|
type AgentPersistenceHandle,
|
|
type AgentProviderNotice,
|
|
type AgentPromptInput,
|
|
type AgentProvider,
|
|
type AgentRunOptions,
|
|
type AgentSteerOptions,
|
|
type AgentRunResult,
|
|
type AgentSession,
|
|
type AgentSessionConfig,
|
|
type SteerResult,
|
|
type AgentStreamEvent,
|
|
type AgentTimelineItem,
|
|
type AgentUsage,
|
|
type AgentRuntimeInfo,
|
|
type ImportedTimelineEntry,
|
|
type ImportableProviderSession,
|
|
type ListImportableSessionsOptions,
|
|
} from "./agent-sdk-types.js";
|
|
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
|
import { toStoredAgentRecord } from "./agent-projections.js";
|
|
import {
|
|
applyProviderOptionsPatch,
|
|
exposeFeatureValuesToHook,
|
|
foldHookFeatureValues,
|
|
type ProviderOptionsPatch,
|
|
} from "./option-controls.js";
|
|
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
|
import type { AgentOwner } from "./agent-owner.js";
|
|
import {
|
|
InMemoryAgentTimelineStore,
|
|
type SeedAgentTimelineOptions,
|
|
} from "./agent-timeline-store.js";
|
|
import type {
|
|
AgentTimelineFetchOptions,
|
|
AgentTimelineFetchResult,
|
|
AgentTimelineRow,
|
|
AgentTimelineStore,
|
|
} from "./agent-timeline-store-types.js";
|
|
import {
|
|
AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
|
AgentStreamCoalescer,
|
|
} from "./agent-stream-coalescer.js";
|
|
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
|
import {
|
|
AgentRunState,
|
|
type ForegroundTurnWaiter,
|
|
type PendingForegroundRun,
|
|
} from "./agent-run-state.js";
|
|
import { invokeRewindCapability, type RewindMode } from "./rewind/rewind.js";
|
|
import { parseAgentMessage, projectAgentMessages } from "./agent-messages/index.js";
|
|
import { isStaleProviderSessionError } from "./stale-provider-session-error.js";
|
|
import {
|
|
PASEO_MCP_SERVER_NAME,
|
|
stripInternalPaseoMcpServer,
|
|
withRuntimePaseoMcpServer,
|
|
} from "./runtime-mcp-config.js";
|
|
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
|
import type { PaseoToolCatalogFactory } from "./tools/types.js";
|
|
import { isPaseoToolPolicyEnabled } from "./paseo-tool-policy.js";
|
|
import {
|
|
ProviderSubagentStore,
|
|
type ProviderSubagentDescriptor,
|
|
type ProviderSubagentStoreEvent,
|
|
} from "./provider-subagents/store.js";
|
|
import { withTimeout } from "../../utils/promise-timeout.js";
|
|
import { extractAttention } from "../persistence-hooks.js";
|
|
|
|
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
|
|
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
|
|
const IMPORTABLE_SESSION_LIST_TIMEOUT_MS = 90_000;
|
|
const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: true,
|
|
supportsRewindConversation: false,
|
|
supportsRewindFiles: false,
|
|
supportsRewindBoth: false,
|
|
};
|
|
|
|
type TimeoutResult = "completed" | "timed_out";
|
|
|
|
function submittedPromptText(prompt: AgentPromptInput): string {
|
|
if (typeof prompt === "string") {
|
|
return prompt;
|
|
}
|
|
return prompt
|
|
.flatMap((block) => (block.type === "text" && !("mimeType" in block) ? [block.text] : []))
|
|
.join("\n")
|
|
.trim();
|
|
}
|
|
|
|
export class AgentManagerShuttingDownError extends Error {
|
|
constructor() {
|
|
super("Agent manager is shutting down");
|
|
this.name = "AgentManagerShuttingDownError";
|
|
}
|
|
}
|
|
|
|
export class AgentRunCancellationError extends Error {
|
|
constructor(agentId: string, action: "reload" | "replace" | "rewind" | "stop") {
|
|
super(
|
|
`Cannot ${action} agent ${agentId} because its active run cancellation was not acknowledged`,
|
|
);
|
|
this.name = "AgentRunCancellationError";
|
|
}
|
|
}
|
|
|
|
export type AgentRunCancellationResult =
|
|
| { status: "not_running" }
|
|
| { status: "settled" }
|
|
| { status: "refused" };
|
|
|
|
/** A session that will run in a directory needs that directory to be there. */
|
|
async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
|
|
try {
|
|
const stats = await stat(cwd);
|
|
if (!stats.isDirectory()) {
|
|
throw new Error(`Working directory is not a directory: ${cwd}`);
|
|
}
|
|
} catch (error) {
|
|
if (
|
|
error instanceof Error &&
|
|
"code" in error &&
|
|
(error as NodeJS.ErrnoException).code === "ENOENT"
|
|
) {
|
|
throw new Error(`Working directory does not exist: ${cwd}`, { cause: error });
|
|
}
|
|
if (error instanceof Error) {
|
|
throw error;
|
|
}
|
|
throw new Error(`Failed to access working directory: ${cwd}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
interface PreparedSessionConfig {
|
|
storedConfig: AgentSessionConfig;
|
|
launchConfig: AgentSessionConfig;
|
|
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined;
|
|
}
|
|
|
|
interface NormalizeConfigOptions {
|
|
resolveDefaultModel?: boolean;
|
|
env?: Record<string, string>;
|
|
/** Defaults to interactive. A history load reads persisted state and runs nothing. */
|
|
purpose?: AgentResumePurpose;
|
|
}
|
|
|
|
interface TimeoutOptions {
|
|
operation: Promise<void>;
|
|
timeoutMs: number;
|
|
onLateError?: (error: unknown) => void;
|
|
}
|
|
|
|
function formatProviderList(providers: readonly string[]): string {
|
|
return providers.length > 0 ? providers.join(", ") : "none";
|
|
}
|
|
|
|
function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
|
const config: AgentSessionConfig = {
|
|
provider: record.provider,
|
|
cwd: record.cwd,
|
|
};
|
|
// lastModeId is the last live mode — it also covers provider-side switches
|
|
// that never reach record.config.modeId.
|
|
const modeId = record.lastModeId ?? record.config?.modeId;
|
|
if (modeId != null) config.modeId = modeId;
|
|
if (!record.config) {
|
|
return config;
|
|
}
|
|
if (record.config.model != null) config.model = record.config.model;
|
|
if (record.config.thinkingOptionId != null) {
|
|
config.thinkingOptionId = record.config.thinkingOptionId;
|
|
}
|
|
if (record.config.providerOptions != null) {
|
|
config.providerOptions = record.config.providerOptions;
|
|
}
|
|
if (record.config.toolPolicy != null) config.toolPolicy = record.config.toolPolicy;
|
|
if (record.config.systemPrompt != null) {
|
|
config.systemPrompt = record.config.systemPrompt;
|
|
}
|
|
if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers;
|
|
return stripInternalPaseoMcpServer(config);
|
|
}
|
|
|
|
export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus };
|
|
export type {
|
|
AgentTimelineCursor,
|
|
AgentTimelineFetchDirection,
|
|
AgentTimelineFetchOptions,
|
|
AgentTimelineFetchResult,
|
|
AgentTimelineRow,
|
|
AgentTimelineWindow,
|
|
} from "./agent-timeline-store-types.js";
|
|
|
|
export type AgentManagerEvent =
|
|
| { type: "agent_state"; agent: ManagedAgent }
|
|
| { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean }
|
|
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
|
|
| { type: "timeline_replacement"; agentId: string; epoch: string }
|
|
| {
|
|
type: "agent_stream";
|
|
agentId: string;
|
|
event: AgentStreamEvent;
|
|
seq?: number;
|
|
epoch?: string;
|
|
timestamp?: string;
|
|
};
|
|
|
|
export type AgentSubscriber = (event: AgentManagerEvent) => void;
|
|
|
|
export interface SubscribeOptions {
|
|
agentId?: string;
|
|
replayState?: boolean;
|
|
/** Receive events for internal agents too. Off by default for global subscribers. */
|
|
includeInternal?: boolean;
|
|
}
|
|
|
|
interface HydrateTimelineOptions {
|
|
force?: boolean;
|
|
broadcast?: boolean | (() => boolean);
|
|
broadcastTimeline?: boolean;
|
|
}
|
|
|
|
export type ImportablePersistedAgentQueryOptions = ListImportableSessionsOptions & {
|
|
/**
|
|
* When set, only providers in this set are scanned, in addition to the
|
|
* built-in importable allowlist + enabled + non-derived rules.
|
|
*/
|
|
providerFilter?: Set<string>;
|
|
};
|
|
|
|
export interface ManagedImportableProviderSession extends ImportableProviderSession {
|
|
provider: AgentProvider;
|
|
}
|
|
|
|
export interface ImportableSessionProviderError {
|
|
provider: AgentProvider;
|
|
message: string;
|
|
}
|
|
|
|
export interface ManagedImportableSessionsResult {
|
|
sessions: ManagedImportableProviderSession[];
|
|
providerErrors: ImportableSessionProviderError[];
|
|
}
|
|
|
|
export type AgentAttentionCallback = (params: {
|
|
agentId: string;
|
|
provider: AgentProvider;
|
|
reason: "finished" | "error" | "permission";
|
|
}) => void;
|
|
|
|
export type AgentArchivedCallback = (agentId: string) => Promise<void> | void;
|
|
|
|
export interface ProviderAvailability {
|
|
provider: AgentProvider;
|
|
available: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
interface AgentManagerRescueTimeouts {
|
|
reloadSessionCloseMs?: number;
|
|
interruptSessionMs?: number;
|
|
}
|
|
|
|
interface ProviderEnabledFlag {
|
|
enabled: boolean;
|
|
derivedFromProviderId?: string | null;
|
|
applyToolPolicy?: (
|
|
config: AgentSessionConfig,
|
|
toolPolicy: ToolPolicy | undefined,
|
|
) => AgentSessionConfig;
|
|
}
|
|
type ProviderEnabledMap = Partial<Record<AgentProvider, ProviderEnabledFlag>>;
|
|
type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
|
|
|
export interface CreateAgentOptions {
|
|
labels?: Record<string, string>;
|
|
initialPrompt?: string;
|
|
env?: Record<string, string>;
|
|
persistSession?: boolean;
|
|
initialTitle?: string | null;
|
|
// undefined is an explicit decision: the agent never appears in the sidebar.
|
|
workspaceId: string | undefined;
|
|
owner?: AgentOwner;
|
|
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
|
|
background?: boolean;
|
|
}
|
|
|
|
export interface AgentManagerOptions {
|
|
pluginLifecycle?: PluginLifecycle;
|
|
/** An internal workspace makes every agent created inside it internal. */
|
|
clients?: ProviderClientMap;
|
|
providerDefinitions?: ProviderEnabledMap;
|
|
idFactory?: () => string;
|
|
registry?: AgentStorage;
|
|
onAgentAttention?: AgentAttentionCallback;
|
|
onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
|
timelineStore?: InMemoryAgentTimelineStore;
|
|
durableTimelineStore?: AgentTimelineStore;
|
|
terminalManager?: TerminalManager | null;
|
|
mcpBaseUrl?: string;
|
|
mcpAuthToken?: string;
|
|
pluginAgentTools?: PluginAgentToolsRuntime;
|
|
paseoToolsEnabled?: boolean;
|
|
paseoToolCatalogFactory?: PaseoToolCatalogFactory;
|
|
resolvePaseoToolPolicy?: (provider: AgentProvider) => ProviderPaseoToolsPolicy | undefined;
|
|
appendSystemPrompt?: string;
|
|
agentStreamCoalesceWindowMs?: number;
|
|
rescueTimeouts?: AgentManagerRescueTimeouts;
|
|
beforeSteerUnavailableFallback?: (input: {
|
|
agentId: string;
|
|
expectedTurnId: string;
|
|
}) => Promise<void>;
|
|
logger: Logger;
|
|
}
|
|
|
|
export type ActiveTurnSteerDispatchResult =
|
|
| { status: "inactive" | "steered" }
|
|
| { status: "replaced"; iterator: AsyncGenerator<AgentStreamEvent> };
|
|
|
|
function stripSteerOptions(options?: AgentSteerOptions): AgentRunOptions | undefined {
|
|
if (!options) return undefined;
|
|
const { clearPendingPermissions: _, ...runOptions } = options;
|
|
return runOptions;
|
|
}
|
|
|
|
export interface WaitForAgentOptions {
|
|
signal?: AbortSignal;
|
|
waitForActive?: boolean;
|
|
}
|
|
|
|
export interface WaitForAgentResult {
|
|
status: AgentLifecycleStatus;
|
|
permission: AgentPermissionRequest | null;
|
|
lastMessage: string | null;
|
|
}
|
|
|
|
export interface WaitForAgentStartOptions {
|
|
signal?: AbortSignal;
|
|
}
|
|
|
|
export type AttentionState =
|
|
| { requiresAttention: false }
|
|
| {
|
|
requiresAttention: true;
|
|
attentionReason: "finished" | "error" | "permission";
|
|
attentionTimestamp: Date;
|
|
};
|
|
|
|
/** Private helpers and background agents report to their creator, never to the user. */
|
|
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
|
|
return !agent.internal && !agent.background;
|
|
}
|
|
|
|
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
|
if (input == null || !input.requiresAttention) {
|
|
return { requiresAttention: false };
|
|
}
|
|
return {
|
|
requiresAttention: true,
|
|
attentionReason: input.attentionReason,
|
|
attentionTimestamp: new Date(input.attentionTimestamp),
|
|
};
|
|
}
|
|
|
|
interface StreamEventFlags {
|
|
shouldDispatchEvent: boolean;
|
|
shouldNotifyWaiters: boolean;
|
|
}
|
|
|
|
type ActiveTurnTerminalDisposition = "closed_current" | "stale" | "untracked";
|
|
|
|
interface HandleStreamEventOptions {
|
|
fromHistory?: boolean;
|
|
}
|
|
|
|
interface ManagedAgentBase {
|
|
id: string;
|
|
provider: AgentProvider;
|
|
cwd: string;
|
|
/**
|
|
* Workspace this agent belongs to, stamped at creation. Independent of cwd:
|
|
* cwd answers "where does it run", workspaceId answers "which workspace owns it".
|
|
* Null/undefined for legacy agents created before ownership stamping.
|
|
*/
|
|
workspaceId?: string;
|
|
owner?: AgentOwner;
|
|
capabilities: AgentCapabilityFlags;
|
|
config: AgentSessionConfig;
|
|
/** Explicit launch overrides, before per-opening hooks and daemon-owned variables. */
|
|
launchEnv?: Record<string, string>;
|
|
runtimeInfo?: AgentRuntimeInfo;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
availableModes: AgentMode[];
|
|
controls?: AgentOptionControl[];
|
|
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
|
effectiveOptions?: ProviderOptions;
|
|
currentModeId: string | null;
|
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
|
bufferedPermissionResolutions: Map<
|
|
string,
|
|
Extract<AgentStreamEvent, { type: "permission_resolved" }>
|
|
>;
|
|
inFlightPermissionResponses: Set<string>;
|
|
pendingReplacement: boolean;
|
|
persistence: AgentPersistenceHandle | null;
|
|
historyPrimed: boolean;
|
|
lastUserMessageAt: Date | null;
|
|
activeTurnId: string | null;
|
|
activeTurnStartedAt: Date | null;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string;
|
|
attention: AttentionState;
|
|
foregroundTurnWaiters: Set<ForegroundTurnWaiter>;
|
|
finalizedForegroundTurnIds: Set<string>;
|
|
unsubscribeSession: (() => void) | null;
|
|
/**
|
|
* Internal agents are hidden from listings and don't trigger notifications.
|
|
*/
|
|
internal?: boolean;
|
|
/**
|
|
* Background agents are durable but hidden from default discovery and never raise attention.
|
|
* Fixed at creation.
|
|
*/
|
|
background?: boolean;
|
|
/**
|
|
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
|
*/
|
|
labels: Record<string, string>;
|
|
}
|
|
|
|
type ManagedAgentWithSession = ManagedAgentBase & {
|
|
session: AgentSession;
|
|
};
|
|
|
|
type ManagedAgentInitializing = ManagedAgentWithSession & {
|
|
lifecycle: "initializing";
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
type ManagedAgentIdle = ManagedAgentWithSession & {
|
|
lifecycle: "idle";
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
type ManagedAgentRunning = ManagedAgentWithSession & {
|
|
lifecycle: "running";
|
|
activeForegroundTurnId: string | null;
|
|
};
|
|
|
|
type ManagedAgentError = ManagedAgentWithSession & {
|
|
lifecycle: "error";
|
|
activeForegroundTurnId: null;
|
|
lastError: string;
|
|
};
|
|
|
|
type ManagedAgentClosed = ManagedAgentBase & {
|
|
lifecycle: "closed";
|
|
session: null;
|
|
activeForegroundTurnId: null;
|
|
};
|
|
|
|
export type ManagedAgent =
|
|
| ManagedAgentInitializing
|
|
| ManagedAgentIdle
|
|
| ManagedAgentRunning
|
|
| ManagedAgentError
|
|
| ManagedAgentClosed;
|
|
|
|
export interface AgentMetricsSnapshot {
|
|
total: number;
|
|
subscriptionCount: number;
|
|
byLifecycle: Record<string, number>;
|
|
withActiveForegroundTurn: number;
|
|
timelineStats: {
|
|
totalItems: number;
|
|
maxItemsPerAgent: number;
|
|
};
|
|
}
|
|
|
|
type ActiveManagedAgent =
|
|
| ManagedAgentInitializing
|
|
| ManagedAgentIdle
|
|
| ManagedAgentRunning
|
|
| ManagedAgentError;
|
|
|
|
type LiveManagedAgent = ActiveManagedAgent;
|
|
type AgentLabelPatch = Record<string, string | null>;
|
|
|
|
function attachManagedTurnIdentity(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
fromHistory: boolean,
|
|
): { event: AgentStreamEvent; turnId: string | undefined } {
|
|
const existingTurnId = getAgentStreamEventTurnId(event);
|
|
if (fromHistory || existingTurnId !== undefined) {
|
|
return { event, turnId: existingTurnId };
|
|
}
|
|
switch (event.type) {
|
|
case "turn_started": {
|
|
const turnId =
|
|
agent.activeForegroundTurnId ?? agent.activeTurnId ?? `autonomous-${randomUUID()}`;
|
|
return { event: { ...event, turnId }, turnId };
|
|
}
|
|
case "turn_completed":
|
|
case "turn_failed":
|
|
case "turn_canceled": {
|
|
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
|
|
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
|
|
}
|
|
case "timeline": {
|
|
// Live provider items belong to the foreground turn that owns their dispatch.
|
|
// Provider history deliberately keeps absent IDs because it has no daemon turn identity.
|
|
const turnId = agent.activeForegroundTurnId ?? agent.activeTurnId ?? undefined;
|
|
return turnId ? { event: { ...event, turnId }, turnId } : { event, turnId };
|
|
}
|
|
default:
|
|
return { event, turnId: undefined };
|
|
}
|
|
}
|
|
|
|
function limitAgentStreamEventContent(event: AgentStreamEvent): AgentStreamEvent {
|
|
return event.type === "timeline"
|
|
? { ...event, item: limitAgentTimelineItemContent(event.item) }
|
|
: event;
|
|
}
|
|
|
|
interface WriteLabelsResult {
|
|
record: StoredAgentRecord | null;
|
|
live: boolean;
|
|
}
|
|
|
|
interface AgentMetadataPatch {
|
|
title?: string;
|
|
labels?: AgentLabelPatch;
|
|
}
|
|
|
|
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
|
|
|
function normalizePersistenceForConfig(
|
|
handle: AgentPersistenceHandle | null,
|
|
config: AgentSessionConfig,
|
|
): AgentPersistenceHandle | null {
|
|
if (!handle) {
|
|
return null;
|
|
}
|
|
const { mcpServers: _launchMcpServers, ...metadata } = handle.metadata ?? {};
|
|
return {
|
|
...handle,
|
|
metadata: {
|
|
...metadata,
|
|
cwd: config.cwd,
|
|
...(config.mcpServers ? { mcpServers: config.mcpServers } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
interface SubscriptionRecord {
|
|
callback: AgentSubscriber;
|
|
agentId: string | null;
|
|
includeInternal: boolean;
|
|
}
|
|
|
|
interface SteerEventBarrier {
|
|
events: AgentStreamEvent[];
|
|
}
|
|
|
|
const BUSY_STATUSES: Set<AgentLifecycleStatus> = new Set(["initializing", "running"]);
|
|
const AgentIdSchema = z.guid();
|
|
|
|
function isAgentBusy(status: AgentLifecycleStatus): boolean {
|
|
return BUSY_STATUSES.has(status);
|
|
}
|
|
|
|
function isTurnTerminalEvent(event: AgentStreamEvent): boolean {
|
|
return (
|
|
event.type === "turn_completed" ||
|
|
event.type === "turn_failed" ||
|
|
event.type === "turn_canceled"
|
|
);
|
|
}
|
|
|
|
function abortMessage(reason: unknown, fallbackMessage: string): string {
|
|
if (typeof reason === "string") return reason;
|
|
if (reason instanceof Error) return reason.message;
|
|
return fallbackMessage;
|
|
}
|
|
|
|
function createAbortError(signal: AbortSignal | undefined, fallbackMessage: string): Error {
|
|
const message = abortMessage(signal?.reason, fallbackMessage);
|
|
return Object.assign(new Error(message), { name: "AbortError" });
|
|
}
|
|
|
|
function validateAgentId(agentId: string, source: string): string {
|
|
const result = AgentIdSchema.safeParse(agentId);
|
|
if (!result.success) {
|
|
throw new Error(`${source}: agentId must be a UUID`);
|
|
}
|
|
return result.data;
|
|
}
|
|
|
|
function applyLabelPatch(
|
|
labels: Record<string, string>,
|
|
patch: AgentLabelPatch,
|
|
): Record<string, string> {
|
|
const nextLabels = { ...labels };
|
|
for (const [key, value] of Object.entries(patch)) {
|
|
if (value === null) {
|
|
delete nextLabels[key];
|
|
} else {
|
|
nextLabels[key] = value;
|
|
}
|
|
}
|
|
return nextLabels;
|
|
}
|
|
|
|
function buildExplicitTimelineSeedForRegister(
|
|
now: Date,
|
|
options:
|
|
| {
|
|
timeline?: AgentTimelineItem[];
|
|
timelineRows?: AgentTimelineRow[];
|
|
timelineNextSeq?: number;
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
}
|
|
| undefined,
|
|
): SeedAgentTimelineOptions | null {
|
|
const hasTimeline = Boolean(options?.timeline?.length);
|
|
const hasTimelineRows = Boolean(options?.timelineRows?.length);
|
|
const hasTimelineNextSeq = options?.timelineNextSeq !== undefined;
|
|
if (!hasTimeline && !hasTimelineRows && !hasTimelineNextSeq) {
|
|
return null;
|
|
}
|
|
return {
|
|
items: options?.timeline,
|
|
rows: options?.timelineRows,
|
|
nextSeq: options?.timelineNextSeq,
|
|
timestamp: (options?.updatedAt ?? options?.createdAt ?? now).toISOString(),
|
|
};
|
|
}
|
|
|
|
function buildImportedTimelineRows(entries: readonly ImportedTimelineEntry[]): AgentTimelineRow[] {
|
|
const rows: AgentTimelineRow[] = [];
|
|
for (const entry of entries) {
|
|
for (const item of projectAgentMessages(entry.item)) {
|
|
rows.push({
|
|
seq: rows.length + 1,
|
|
timestamp: entry.timestamp ?? new Date().toISOString(),
|
|
item: limitAgentTimelineItemContent(item),
|
|
});
|
|
}
|
|
}
|
|
return rows;
|
|
}
|
|
|
|
function resolveImportedAgentTitle(
|
|
config: AgentSessionConfig,
|
|
timelineRows: readonly AgentTimelineRow[],
|
|
): string | null {
|
|
const initialPrompt = getFirstUserMessageTextFromRows(timelineRows);
|
|
if (!initialPrompt) {
|
|
return null;
|
|
}
|
|
const { explicitTitle, provisionalTitle } = resolveCreateAgentTitles({
|
|
configTitle: config.title,
|
|
initialPrompt,
|
|
});
|
|
return explicitTitle ?? provisionalTitle ?? null;
|
|
}
|
|
|
|
function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): string | null {
|
|
for (const row of rows) {
|
|
const item = row.item;
|
|
if (item.type !== "user_message") {
|
|
continue;
|
|
}
|
|
const text = item.text.trim();
|
|
if (text) {
|
|
return text;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shouldDetachFromArchivedParent(
|
|
parent: Pick<StoredAgentRecord, "workspaceId">,
|
|
child: StoredAgentRecord,
|
|
): boolean {
|
|
const isCrossWorkspace =
|
|
parent.workspaceId !== undefined &&
|
|
child.workspaceId !== undefined &&
|
|
parent.workspaceId !== child.workspaceId;
|
|
return isCrossWorkspace || hasOpenAgentTab(child.labels);
|
|
}
|
|
|
|
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
|
|
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
|
|
for (const label of Object.keys(labels)) {
|
|
if (isOpenAgentTabLabel(label)) {
|
|
patch[label] = null;
|
|
}
|
|
}
|
|
return patch;
|
|
}
|
|
|
|
/**
|
|
* How long an archived internal agent stays readable by id. Storage plays this
|
|
* role for public agents; internal agents never reach it, and a one-shot helper
|
|
* can finish and auto-archive before its creator's `waitForFinish` arrives.
|
|
*/
|
|
const RETIRED_INTERNAL_AGENT_TTL_MS = 10 * 60 * 1000;
|
|
const RETIRED_INTERNAL_AGENT_LIMIT = 500;
|
|
|
|
export interface RetiredInternalAgent {
|
|
record: ArchivedStoredAgentRecord;
|
|
lastMessage: string | null;
|
|
}
|
|
|
|
export class AgentManager {
|
|
private readonly pluginLifecycle: PluginLifecycle | undefined;
|
|
private readonly retiredInternalAgents = new Map<
|
|
string,
|
|
RetiredInternalAgent & { expiresAt: number }
|
|
>();
|
|
private readonly clients = new Map<AgentProvider, AgentClient>();
|
|
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
|
private readonly providerDefinitions = new Map<AgentProvider, ProviderEnabledFlag>();
|
|
private readonly agents = new Map<string, LiveManagedAgent>();
|
|
private readonly timelineStore: InMemoryAgentTimelineStore;
|
|
private readonly providerSubagents = new ProviderSubagentStore();
|
|
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
|
|
private readonly sessionEventTails = new Map<string, Promise<void>>();
|
|
private readonly steerEventBarriers = new Map<string, SteerEventBarrier>();
|
|
private readonly foregroundMutationTails = new Map<string, Promise<void>>();
|
|
private readonly runs = new AgentRunState();
|
|
private readonly subscribers = new Set<SubscriptionRecord>();
|
|
private readonly idFactory: () => string;
|
|
private readonly registry?: AgentStorage;
|
|
private readonly durableTimelineStore?: AgentTimelineStore;
|
|
private readonly previousStatuses = new Map<string, AgentLifecycleStatus>();
|
|
private readonly backgroundTasks = new Set<Promise<void>>();
|
|
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
|
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
|
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
|
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
|
private readonly collectingRuntimes = new Set<string>();
|
|
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
|
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
|
private mcpBaseUrl: string | null;
|
|
private readonly mcpAuthToken: string | null;
|
|
private sessionOpenGeneration = 0;
|
|
private readonly sessionOpenGenerations = new Map<string, number>();
|
|
private readonly attachedPluginTools = new Map<string, AttachedAgentTool[]>();
|
|
private readonly pluginAgentTools: PluginAgentToolsRuntime | undefined;
|
|
private paseoToolsEnabled = true;
|
|
private paseoToolCatalogFactory: PaseoToolCatalogFactory | null = null;
|
|
private readonly paseoToolPolicies = new Map<string, ProviderPaseoToolsPolicy | undefined>();
|
|
private readonly resolvePaseoToolPolicy: (
|
|
provider: AgentProvider,
|
|
) => ProviderPaseoToolsPolicy | undefined;
|
|
private appendSystemPrompt: string;
|
|
private onAgentAttention?: AgentAttentionCallback;
|
|
private onAgentArchived?: AgentArchivedCallback;
|
|
private onWorkspaceStateMayHaveChanged?: (params: { cwd: string }) => void;
|
|
private logger: Logger;
|
|
private readonly rescueTimeouts: Required<AgentManagerRescueTimeouts>;
|
|
private readonly beforeSteerUnavailableFallback?: AgentManagerOptions["beforeSteerUnavailableFallback"];
|
|
private acceptingAgentRegistrations = true;
|
|
|
|
constructor(options: AgentManagerOptions) {
|
|
this.pluginLifecycle = options.pluginLifecycle;
|
|
this.pluginAgentTools = options.pluginAgentTools;
|
|
this.timelineStore = options.timelineStore ?? new InMemoryAgentTimelineStore();
|
|
this.idFactory = options.idFactory ?? (() => randomUUID());
|
|
this.registry = options?.registry;
|
|
this.durableTimelineStore = options?.durableTimelineStore;
|
|
this.onAgentAttention = options?.onAgentAttention;
|
|
this.onWorkspaceStateMayHaveChanged = options?.onWorkspaceStateMayHaveChanged;
|
|
this.mcpBaseUrl = options?.mcpBaseUrl ?? null;
|
|
this.mcpAuthToken = options?.mcpAuthToken ?? null;
|
|
this.configurePaseoTools(options);
|
|
this.resolvePaseoToolPolicy = options.resolvePaseoToolPolicy ?? (() => undefined);
|
|
this.appendSystemPrompt = options.appendSystemPrompt ?? "";
|
|
this.logger = options.logger.child({ module: "agent", component: "agent-manager" });
|
|
this.rescueTimeouts = {
|
|
reloadSessionCloseMs:
|
|
options.rescueTimeouts?.reloadSessionCloseMs ?? RELOAD_SESSION_CLOSE_TIMEOUT_MS,
|
|
interruptSessionMs:
|
|
options.rescueTimeouts?.interruptSessionMs ?? INTERRUPT_SESSION_TIMEOUT_MS,
|
|
};
|
|
this.beforeSteerUnavailableFallback = options.beforeSteerUnavailableFallback;
|
|
this.agentStreamCoalescer = new AgentStreamCoalescer({
|
|
windowMs: options.agentStreamCoalesceWindowMs ?? AGENT_STREAM_COALESCE_DEFAULT_WINDOW_MS,
|
|
timers: { setTimeout, clearTimeout },
|
|
onFlush: ({ agentId, item, provider, turnId }) => {
|
|
const event = this.recordAndDispatchTimelineItem(agentId, item, provider, turnId);
|
|
this.notifyForegroundTurnWaiters(agentId, event);
|
|
},
|
|
});
|
|
this.updateProviderRegistry({
|
|
providerDefinitions: options.providerDefinitions ?? {},
|
|
clients: options.clients ?? {},
|
|
});
|
|
}
|
|
|
|
private configurePaseoTools(options: AgentManagerOptions): void {
|
|
this.paseoToolsEnabled = options.paseoToolsEnabled ?? true;
|
|
this.paseoToolCatalogFactory = options.paseoToolCatalogFactory ?? null;
|
|
}
|
|
|
|
registerClient(provider: AgentProvider, client: AgentClient): void {
|
|
this.clients.set(provider, client);
|
|
}
|
|
|
|
updateProviderRegistry(input: {
|
|
providerDefinitions: ProviderEnabledMap;
|
|
clients: ProviderClientMap;
|
|
retiredProviders?: readonly AgentProvider[];
|
|
}): void {
|
|
this.providerEnabled.clear();
|
|
this.providerDefinitions.clear();
|
|
for (const [provider, definition] of Object.entries(input.providerDefinitions)) {
|
|
if (definition) {
|
|
this.providerEnabled.set(provider, definition.enabled);
|
|
this.providerDefinitions.set(provider, definition);
|
|
}
|
|
}
|
|
|
|
this.clients.clear();
|
|
for (const [provider, client] of Object.entries(input.clients)) {
|
|
if (client) {
|
|
this.clients.set(provider, client);
|
|
}
|
|
}
|
|
|
|
for (const provider of input.retiredProviders ?? []) {
|
|
for (const agent of this.agents.values()) {
|
|
if (agent.provider !== provider) continue;
|
|
void this.closeAgent(agent.id).catch((error) => {
|
|
this.logger.warn(
|
|
{ err: error, agentId: agent.id, provider },
|
|
"Failed to close agent after provider retirement",
|
|
);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
getRegisteredProviderIds(): AgentProvider[] {
|
|
return Array.from(this.clients.keys());
|
|
}
|
|
|
|
setAgentAttentionCallback(callback: AgentAttentionCallback): void {
|
|
this.onAgentAttention = callback;
|
|
}
|
|
|
|
setAgentArchivedCallback(callback: AgentArchivedCallback): void {
|
|
this.onAgentArchived = callback;
|
|
}
|
|
|
|
setMcpBaseUrl(url: string | null): void {
|
|
this.mcpBaseUrl = url;
|
|
}
|
|
|
|
prepareForShutdown(): void {
|
|
this.acceptingAgentRegistrations = false;
|
|
}
|
|
|
|
setPaseoToolsEnabled(enabled: boolean): void {
|
|
this.paseoToolsEnabled = enabled;
|
|
}
|
|
|
|
setPaseoToolCatalogFactory(factory: PaseoToolCatalogFactory | null): void {
|
|
this.paseoToolCatalogFactory = factory;
|
|
}
|
|
|
|
invalidatePluginSessions(): void {
|
|
this.sessionOpenGeneration++;
|
|
}
|
|
|
|
async reloadStalePluginSession(agentId: string): Promise<void> {
|
|
if (!this.needsPluginSessionReload(agentId)) return;
|
|
await this.trackAgentRegistrationOperation(
|
|
this.runLifecycleMutation(agentId, async () => {
|
|
if (!this.needsPluginSessionReload(agentId)) return;
|
|
await this.reloadAgentSessionInternal(agentId);
|
|
}),
|
|
);
|
|
}
|
|
|
|
private needsPluginSessionReload(agentId: string): boolean {
|
|
const agent = this.getAgent(agentId);
|
|
return (
|
|
agent !== null &&
|
|
agent.lifecycle !== "closed" &&
|
|
this.sessionOpenGenerations.get(agentId) !== this.sessionOpenGeneration &&
|
|
!this.hasInFlightRun(agentId)
|
|
);
|
|
}
|
|
|
|
getPluginTools(agentId: string): readonly AttachedAgentTool[] {
|
|
return (this.attachedPluginTools.get(agentId) ?? []).filter((tool) =>
|
|
this.pluginAgentTools?.isToolPluginAvailable(tool.pluginId, tool.pluginInstanceId),
|
|
);
|
|
}
|
|
|
|
async executePluginTool(
|
|
tool: AttachedAgentTool,
|
|
agentId: string,
|
|
input: unknown,
|
|
signal?: AbortSignal,
|
|
): Promise<AgentToolResult> {
|
|
const agent = this.getAgent(agentId);
|
|
if (!agent || agent.lifecycle === "closed") throw new Error(`Agent is not live: ${agentId}`);
|
|
if (!this.pluginAgentTools) throw new Error("Plugin tools are unavailable");
|
|
const record = await this.registry?.get(agentId);
|
|
return this.pluginAgentTools.callTool(
|
|
tool.pluginId,
|
|
tool.pluginInstanceId,
|
|
describeHookAgent({ ...agent, title: record?.title ?? agent.config.title }),
|
|
tool.name,
|
|
input,
|
|
signal,
|
|
);
|
|
}
|
|
|
|
private detachPluginTools(agentId: string): void {
|
|
this.attachedPluginTools.delete(agentId);
|
|
this.sessionOpenGenerations.delete(agentId);
|
|
this.pluginAgentTools?.detachTools(agentId);
|
|
}
|
|
|
|
getPaseoToolPolicy(agentId: string): ProviderPaseoToolsPolicy | undefined {
|
|
return this.paseoToolPolicies.get(agentId);
|
|
}
|
|
|
|
/**
|
|
* Capability token the daemon's own MCP clients must present to the Agent MCP
|
|
* endpoint when a daemon password is configured. Read by the per-client
|
|
* session to authenticate its own MCP connection. Stays in the daemon — never
|
|
* sent to remote clients.
|
|
*/
|
|
getMcpAuthToken(): string | null {
|
|
return this.mcpAuthToken;
|
|
}
|
|
|
|
setAppendSystemPrompt(prompt: string | null | undefined): void {
|
|
this.appendSystemPrompt = prompt ?? "";
|
|
}
|
|
|
|
public getMetricsSnapshot(): AgentMetricsSnapshot {
|
|
const byLifecycle: Record<string, number> = {};
|
|
let withActiveForegroundTurn = 0;
|
|
let totalItems = 0;
|
|
let maxItemsPerAgent = 0;
|
|
|
|
for (const agent of this.agents.values()) {
|
|
byLifecycle[agent.lifecycle] = (byLifecycle[agent.lifecycle] ?? 0) + 1;
|
|
|
|
if (agent.activeForegroundTurnId !== null) {
|
|
withActiveForegroundTurn++;
|
|
}
|
|
|
|
if (!this.timelineStore.has(agent.id)) {
|
|
continue;
|
|
}
|
|
|
|
const len = this.timelineStore.getItems(agent.id).length;
|
|
totalItems += len;
|
|
if (len > maxItemsPerAgent) {
|
|
maxItemsPerAgent = len;
|
|
}
|
|
}
|
|
|
|
return {
|
|
total: this.agents.size,
|
|
subscriptionCount: this.subscribers.size,
|
|
byLifecycle,
|
|
withActiveForegroundTurn,
|
|
timelineStats: {
|
|
totalItems,
|
|
maxItemsPerAgent,
|
|
},
|
|
};
|
|
}
|
|
|
|
private touchUpdatedAt(agent: ManagedAgent): Date {
|
|
const nowMs = Date.now();
|
|
const previousMs = agent.updatedAt.getTime();
|
|
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
|
const next = new Date(nextMs);
|
|
agent.updatedAt = next;
|
|
return next;
|
|
}
|
|
|
|
private nextStoredUpdatedAt(record: StoredAgentRecord): string {
|
|
const previousMs = Date.parse(record.updatedAt);
|
|
const nowMs = Date.now();
|
|
const nextMs = nowMs > previousMs ? nowMs : previousMs + 1;
|
|
return new Date(nextMs).toISOString();
|
|
}
|
|
|
|
hasInFlightRun(agentId: string): boolean {
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent) {
|
|
return false;
|
|
}
|
|
|
|
return (
|
|
agent.lifecycle === "running" ||
|
|
Boolean(agent.activeForegroundTurnId) ||
|
|
this.runs.hasRun(agentId)
|
|
);
|
|
}
|
|
|
|
subscribe(callback: AgentSubscriber, options?: SubscribeOptions): () => void {
|
|
const targetAgentId =
|
|
options?.agentId == null ? null : validateAgentId(options.agentId, "subscribe");
|
|
const record: SubscriptionRecord = {
|
|
callback,
|
|
agentId: targetAgentId,
|
|
includeInternal: options?.includeInternal === true,
|
|
};
|
|
this.subscribers.add(record);
|
|
|
|
if (options?.replayState !== false) {
|
|
if (record.agentId) {
|
|
const agent = this.agents.get(record.agentId);
|
|
if (agent) {
|
|
callback({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
} else {
|
|
// Global subscribers skip internal agents unless they opted in.
|
|
for (const agent of this.agents.values()) {
|
|
if (agent.internal && !record.includeInternal) {
|
|
continue;
|
|
}
|
|
callback({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
this.subscribers.delete(record);
|
|
};
|
|
}
|
|
|
|
subscriptionCount(): number {
|
|
return this.subscribers.size;
|
|
}
|
|
|
|
listAgents(options?: { includeInternal?: boolean }): ManagedAgent[] {
|
|
return Array.from(this.agents.values())
|
|
.filter((agent) => options?.includeInternal === true || !agent.internal)
|
|
.map((agent) => Object.assign({}, agent));
|
|
}
|
|
|
|
/** Archived internal agents still readable by id; see getRetiredInternalAgent. */
|
|
listRetiredInternalAgents(): RetiredInternalAgent[] {
|
|
const now = Date.now();
|
|
const retired: RetiredInternalAgent[] = [];
|
|
for (const [agentId, entry] of this.retiredInternalAgents) {
|
|
if (entry.expiresAt <= now) {
|
|
this.retiredInternalAgents.delete(agentId);
|
|
continue;
|
|
}
|
|
retired.push({ record: entry.record, lastMessage: entry.lastMessage });
|
|
}
|
|
return retired;
|
|
}
|
|
|
|
async listImportableSessions(
|
|
options?: ImportablePersistedAgentQueryOptions,
|
|
): Promise<ManagedImportableSessionsResult> {
|
|
const providerEntries = Array.from(this.clients.entries()).filter(
|
|
([provider, client]) =>
|
|
client.capabilities.supportsSessionListing &&
|
|
!!client.listImportableSessions &&
|
|
this.isProviderImportable(provider, options?.providerFilter),
|
|
);
|
|
const providerResults = await Promise.all(
|
|
providerEntries.map(async ([provider, client]) => {
|
|
try {
|
|
const sessions = await withTimeout(
|
|
client.listImportableSessions!({
|
|
limit: options?.limit,
|
|
query: options?.query,
|
|
scanLimit: options?.scanLimit,
|
|
cwd: options?.cwd,
|
|
}),
|
|
IMPORTABLE_SESSION_LIST_TIMEOUT_MS,
|
|
`Timed out listing importable sessions for provider '${provider}' after ${IMPORTABLE_SESSION_LIST_TIMEOUT_MS}ms`,
|
|
);
|
|
return {
|
|
sessions: sessions
|
|
.filter((session) => matchesImportableSessionQuery(session, options?.query))
|
|
.map((session) => Object.assign(session, { provider })),
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, provider },
|
|
"Failed to list importable sessions for provider",
|
|
);
|
|
return {
|
|
sessions: [],
|
|
error: {
|
|
provider,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
},
|
|
};
|
|
}
|
|
}),
|
|
);
|
|
const sessions = providerResults.flatMap((result) => result.sessions);
|
|
|
|
const limit = options?.limit ?? 20;
|
|
return {
|
|
sessions: sessions
|
|
.sort((a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime())
|
|
.slice(0, limit),
|
|
providerErrors: providerResults.flatMap((result) => (result.error ? [result.error] : [])),
|
|
};
|
|
}
|
|
|
|
private isProviderImportable(
|
|
provider: AgentProvider,
|
|
providerFilter: Set<string> | undefined,
|
|
): boolean {
|
|
if (this.providerEnabled.get(provider) === false) {
|
|
return false;
|
|
}
|
|
if (providerFilter && !providerFilter.has(provider)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async listProviderAvailability(): Promise<ProviderAvailability[]> {
|
|
return Promise.all(
|
|
Array.from(this.clients.keys()).map((provider) => this.getProviderAvailability(provider)),
|
|
);
|
|
}
|
|
|
|
async getProviderAvailability(provider: AgentProvider): Promise<ProviderAvailability> {
|
|
const client = this.clients.get(provider);
|
|
if (!client) {
|
|
return {
|
|
provider,
|
|
available: false,
|
|
error: `No client registered for provider '${provider}'`,
|
|
};
|
|
}
|
|
|
|
try {
|
|
const available = await client.isAvailable();
|
|
return {
|
|
provider,
|
|
available,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
this.logger.warn({ err: error, provider }, "Failed to check provider availability");
|
|
return {
|
|
provider,
|
|
available: false,
|
|
error: message,
|
|
};
|
|
}
|
|
}
|
|
|
|
async listDraftCommands(config: AgentSessionConfig): Promise<AgentSlashCommand[]> {
|
|
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
|
const client = this.requireClient(normalizedConfig.provider);
|
|
if (!normalizedConfig.model) {
|
|
return [];
|
|
}
|
|
const available = await client.isAvailable();
|
|
if (!available) {
|
|
throw new Error(
|
|
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
|
);
|
|
}
|
|
|
|
if (client.listCommands) {
|
|
return await client.listCommands(normalizedConfig);
|
|
}
|
|
|
|
const session = await client.createSession(normalizedConfig);
|
|
try {
|
|
if (!session.listCommands) {
|
|
throw new Error(
|
|
`Provider '${normalizedConfig.provider}' does not support listing commands`,
|
|
);
|
|
}
|
|
return await session.listCommands();
|
|
} finally {
|
|
try {
|
|
await session.close();
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, provider: normalizedConfig.provider },
|
|
"Failed to close draft command listing session",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
async listDraftControls(
|
|
config: AgentSessionConfig,
|
|
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
|
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
|
const client = this.requireClient(normalizedConfig.provider);
|
|
const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
|
|
if (!normalizedConfig.model && !client.listControls) {
|
|
return { controls: [], options };
|
|
}
|
|
const available = await client.isAvailable();
|
|
if (!available) {
|
|
throw new Error(
|
|
`Provider '${normalizedConfig.provider}' is not available. Please ensure the CLI is installed.`,
|
|
);
|
|
}
|
|
|
|
if (client.listControls) {
|
|
return { controls: await client.listControls(normalizedConfig), options };
|
|
}
|
|
|
|
const session = await client.createSession(normalizedConfig);
|
|
try {
|
|
return { controls: session.controls ?? [], options };
|
|
} finally {
|
|
try {
|
|
await session.close();
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ err: error, provider: normalizedConfig.provider },
|
|
"Failed to close draft feature listing session",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
usageSession(id: string) {
|
|
const agent = this.agents.get(id);
|
|
return agent?.session?.usageSession?.() ?? null;
|
|
}
|
|
|
|
getAgent(id: string): ManagedAgent | null {
|
|
const agent = this.agents.get(id);
|
|
return agent ? { ...agent } : null;
|
|
}
|
|
|
|
async waitForAgentClose(agentId: string): Promise<void> {
|
|
// Loading during reload must wait for the replacement, not resume another writer.
|
|
await this.lifecycleMutationTails.get(agentId);
|
|
await this.inFlightAgentCloses?.get(agentId)?.catch(() => undefined);
|
|
}
|
|
|
|
getTimeline(id: string): AgentTimelineItem[] {
|
|
this.requireAgent(id);
|
|
return this.timelineStore.getItems(id);
|
|
}
|
|
|
|
async getTimelineRows(id: string): Promise<AgentTimelineRow[]> {
|
|
this.requireAgent(id);
|
|
if (this.durableTimelineStore) {
|
|
return projectTimelineRows({
|
|
rows: await this.durableTimelineStore.getCommittedRows(id),
|
|
mode: "projected",
|
|
}).map((entry) => Object.assign({ seq: entry.seqEnd }, entry));
|
|
}
|
|
return this.timelineStore.getRows(id);
|
|
}
|
|
|
|
fetchTimeline(id: string, options?: AgentTimelineFetchOptions): AgentTimelineFetchResult {
|
|
this.requireAgent(id);
|
|
return this.timelineStore.fetch(id, options);
|
|
}
|
|
|
|
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] {
|
|
this.requirePublicAgent(parentAgentId);
|
|
return this.providerSubagents.list(parentAgentId);
|
|
}
|
|
|
|
listProviderSubagentActivity(): ProviderSubagentDescriptor[] {
|
|
const publicParentIds = new Set(
|
|
Array.from(this.agents.values())
|
|
.filter((agent) => !agent.internal)
|
|
.map((agent) => agent.id),
|
|
);
|
|
return this.providerSubagents
|
|
.listAll()
|
|
.filter((subagent) => publicParentIds.has(subagent.parentAgentId));
|
|
}
|
|
|
|
getProviderSubagent(
|
|
parentAgentId: string,
|
|
subagentId: string,
|
|
): ProviderSubagentDescriptor | null {
|
|
this.requirePublicAgent(parentAgentId);
|
|
return this.providerSubagents.get(parentAgentId, subagentId);
|
|
}
|
|
|
|
fetchProviderSubagentTimeline(
|
|
parentAgentId: string,
|
|
subagentId: string,
|
|
options?: AgentTimelineFetchOptions,
|
|
): AgentTimelineFetchResult {
|
|
this.requirePublicAgent(parentAgentId);
|
|
return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options);
|
|
}
|
|
|
|
createAgent(
|
|
config: AgentSessionConfig,
|
|
agentId: string | undefined,
|
|
options: CreateAgentOptions,
|
|
): Promise<ManagedAgent> {
|
|
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
|
|
}
|
|
|
|
private async createAgentInternal(
|
|
config: AgentSessionConfig,
|
|
agentId: string | undefined,
|
|
options: CreateAgentOptions,
|
|
): Promise<ManagedAgent> {
|
|
this.assertAcceptingAgentRegistrations();
|
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
|
if (this.pluginLifecycle && !config.internal) {
|
|
const sent = exposeFeatureValuesToHook(config);
|
|
const request = await this.pluginLifecycle.before("agent.create.before", {
|
|
config: sent,
|
|
env: options.env,
|
|
});
|
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
|
// against the previous SDK edits `config.featureValues`.
|
|
config = {
|
|
...foldHookFeatureValues({ returned: request.config, sent }),
|
|
internal: config.internal,
|
|
};
|
|
options = { ...options, env: request.env };
|
|
}
|
|
await this.deleteAgentState(resolvedAgentId);
|
|
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
|
config,
|
|
resolvedAgentId,
|
|
{ env: options?.env },
|
|
);
|
|
this.requireEnabledProvider(storedConfig.provider);
|
|
const client = await this.requireAvailableClient({
|
|
provider: storedConfig.provider,
|
|
});
|
|
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
|
|
const launchContext = await this.buildLaunchContext(
|
|
resolvedAgentId,
|
|
client,
|
|
storedConfig.cwd,
|
|
paseoToolPolicy,
|
|
options?.env,
|
|
{
|
|
reason: "create",
|
|
purpose: "interactive",
|
|
workspaceId: options.workspaceId ?? null,
|
|
title: options.initialTitle ?? storedConfig.title ?? null,
|
|
labels: options.labels ?? {},
|
|
},
|
|
);
|
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
|
const createOptions = this.buildCreateSessionOptions(options);
|
|
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
|
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
|
labels: options.labels,
|
|
initialTitle: options.initialTitle,
|
|
workspaceId: options.workspaceId,
|
|
owner: options.owner,
|
|
background: options.background,
|
|
launchEnv: options.env ? { ...options.env } : undefined,
|
|
historyPrimed: true,
|
|
});
|
|
if (!agent.internal) {
|
|
this.pluginLifecycle?.emit("agent.create.after", {
|
|
agent: describeHookAgent({ ...agent, title: agent.config.title }),
|
|
});
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
private buildCreateSessionOptions(options?: {
|
|
persistSession?: boolean;
|
|
}): AgentCreateSessionOptions | undefined {
|
|
return options?.persistSession === undefined
|
|
? undefined
|
|
: { persistSession: options.persistSession };
|
|
}
|
|
|
|
// Reconstruct an agent from provider persistence. Callers should explicitly
|
|
// hydrate timeline history after resume.
|
|
resumeAgentFromPersistence(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
agentId?: string,
|
|
options?: {
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
workspaceId?: string;
|
|
owner?: AgentOwner;
|
|
background?: boolean;
|
|
attention?: AttentionState;
|
|
},
|
|
resumeOptions?: AgentResumeSessionOptions,
|
|
): Promise<ManagedAgent> {
|
|
const resolvedAgentId = validateAgentId(
|
|
agentId ?? this.idFactory(),
|
|
"resumeAgentFromPersistence",
|
|
);
|
|
return this.trackAgentRegistrationOperation(
|
|
this.runLifecycleMutation(resolvedAgentId, () =>
|
|
this.resumeAgentFromPersistenceInternal(
|
|
handle,
|
|
overrides,
|
|
resolvedAgentId,
|
|
options,
|
|
resumeOptions,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
private async resumeAgentFromPersistenceInternal(
|
|
handle: AgentPersistenceHandle,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
agentId?: string,
|
|
options?: {
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
workspaceId?: string;
|
|
owner?: AgentOwner;
|
|
background?: boolean;
|
|
attention?: AttentionState;
|
|
},
|
|
resumeOptions?: AgentResumeSessionOptions,
|
|
): Promise<ManagedAgent> {
|
|
this.assertAcceptingAgentRegistrations();
|
|
const resolvedAgentId = validateAgentId(
|
|
agentId ?? this.idFactory(),
|
|
"resumeAgentFromPersistence",
|
|
);
|
|
const metadata = (handle.metadata ?? {}) as Partial<AgentSessionConfig>;
|
|
const mergedConfig = {
|
|
...metadata,
|
|
...overrides,
|
|
provider: handle.provider,
|
|
} as AgentSessionConfig;
|
|
// Decide residency from durable state inside the lifecycle lane. A loader may
|
|
// have read the record before a queued archive or restore completed. Residency is
|
|
// settled before the config is prepared, because a history load reads an archived
|
|
// agent whose working directory may be gone.
|
|
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
|
|
const currentResumeOptions = record
|
|
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
|
|
: resumeOptions;
|
|
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
|
|
|
const launchEnv = record?.launchEnv;
|
|
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
|
mergedConfig,
|
|
resolvedAgentId,
|
|
{ purpose, env: launchEnv },
|
|
);
|
|
const client = this.requireClient(handle.provider);
|
|
const available = await client.isAvailable();
|
|
if (!available) {
|
|
throw new Error(
|
|
`Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`,
|
|
);
|
|
}
|
|
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
|
|
const launchContext = await this.buildLaunchContext(
|
|
resolvedAgentId,
|
|
client,
|
|
storedConfig.cwd,
|
|
paseoToolPolicy,
|
|
launchEnv,
|
|
{
|
|
reason: "resume",
|
|
purpose,
|
|
workspaceId: options?.workspaceId ?? null,
|
|
title: record?.title ?? mergedConfig.title ?? null,
|
|
labels: record?.labels ?? options?.labels ?? {},
|
|
},
|
|
);
|
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
|
const session = await client.resumeSession(
|
|
handle,
|
|
providerLaunchConfig,
|
|
launchContext,
|
|
currentResumeOptions,
|
|
);
|
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
|
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
|
...options,
|
|
launchEnv,
|
|
persistence: handle,
|
|
restoring: true,
|
|
});
|
|
}
|
|
|
|
importProviderSession(input: {
|
|
provider: AgentProvider;
|
|
providerHandleId: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
labels?: Record<string, string>;
|
|
}): Promise<ManagedAgent> {
|
|
return this.trackAgentRegistrationOperation(this.importProviderSessionInternal(input));
|
|
}
|
|
|
|
private async importProviderSessionInternal(input: {
|
|
provider: AgentProvider;
|
|
providerHandleId: string;
|
|
cwd: string;
|
|
workspaceId: string;
|
|
labels?: Record<string, string>;
|
|
}): Promise<ManagedAgent> {
|
|
this.assertAcceptingAgentRegistrations();
|
|
const resolvedAgentId = validateAgentId(this.idFactory(), "importProviderSession");
|
|
this.requireEnabledProvider(input.provider);
|
|
|
|
const client = await this.requireAvailableClient({ provider: input.provider });
|
|
if (!client.importSession) {
|
|
throw new Error(`Provider '${input.provider}' does not support importing sessions`);
|
|
}
|
|
|
|
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
|
{
|
|
provider: input.provider,
|
|
cwd: input.cwd,
|
|
},
|
|
resolvedAgentId,
|
|
);
|
|
this.paseoToolPolicies.set(resolvedAgentId, paseoToolPolicy);
|
|
const launchContext = await this.buildLaunchContext(
|
|
resolvedAgentId,
|
|
client,
|
|
storedConfig.cwd,
|
|
paseoToolPolicy,
|
|
undefined,
|
|
{
|
|
reason: "import",
|
|
purpose: "interactive",
|
|
workspaceId: input.workspaceId,
|
|
labels: input.labels ?? {},
|
|
},
|
|
);
|
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
|
const imported = await client.importSession(
|
|
{
|
|
providerHandleId: input.providerHandleId,
|
|
cwd: input.cwd,
|
|
},
|
|
{ config: providerLaunchConfig, storedConfig, launchContext },
|
|
);
|
|
await this.requireExternalMcpSupport(imported.session, providerLaunchConfig, launchContext);
|
|
let handedToRegistration = false;
|
|
try {
|
|
const importedConfig = await this.normalizeConfig(
|
|
stripInternalPaseoMcpServer(imported.config),
|
|
);
|
|
const timelineRows = buildImportedTimelineRows(imported.timeline);
|
|
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
|
|
|
|
handedToRegistration = true;
|
|
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
|
labels: input.labels,
|
|
workspaceId: input.workspaceId,
|
|
timelineRows,
|
|
timelineNextSeq: timelineRows.length + 1,
|
|
persistence: imported.persistence,
|
|
historyPrimed: true,
|
|
initialTitle,
|
|
publishWhenReady: true,
|
|
});
|
|
for (const event of imported.providerSubagentEvents ?? []) {
|
|
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
|
this.dispatch({ type: "provider_subagent", event: update });
|
|
}
|
|
return agent;
|
|
} finally {
|
|
if (!handedToRegistration) {
|
|
await this.closeUnregisteredSession(imported.session);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Hot-reload an active agent session with config overrides. By default the
|
|
// in-memory timeline is preserved (used for voice-mode toggles and similar
|
|
// config swaps). When `rehydrateFromDisk` is set, the timeline is wiped so a
|
|
// new epoch is minted and provider history is re-streamed — this is what the
|
|
// user-facing "Reload agent" action wants when the on-disk session was
|
|
// mutated outside Paseo.
|
|
reloadAgentSession(
|
|
agentId: string,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
options?: { rehydrateFromDisk?: boolean },
|
|
): Promise<ManagedAgent> {
|
|
return this.trackAgentRegistrationOperation(
|
|
this.runLifecycleMutation(agentId, () =>
|
|
this.reloadAgentSessionInternal(agentId, overrides, options),
|
|
),
|
|
);
|
|
}
|
|
|
|
private async reloadAgentSessionInternal(
|
|
agentId: string,
|
|
overrides?: Partial<AgentSessionConfig>,
|
|
options?: { rehydrateFromDisk?: boolean },
|
|
): Promise<ManagedAgent> {
|
|
this.assertAcceptingAgentRegistrations();
|
|
let existing = this.requireSessionAgent(agentId);
|
|
if (this.hasInFlightRun(agentId)) {
|
|
await this.cancelAgentRunBefore(agentId, "reload");
|
|
existing = this.requireSessionAgent(agentId);
|
|
}
|
|
const rehydrateFromDisk = options?.rehydrateFromDisk ?? false;
|
|
const preservedHistoryPrimed = existing.historyPrimed;
|
|
const preservedLastUsage = existing.lastUsage;
|
|
const preservedLastError = existing.lastError;
|
|
const preservedAttention = existing.attention;
|
|
const handle = existing.persistence;
|
|
const provider = handle?.provider ?? existing.provider;
|
|
const client = this.requireClient(provider);
|
|
const refreshConfig = {
|
|
...existing.config,
|
|
...overrides,
|
|
provider,
|
|
} as AgentSessionConfig;
|
|
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
|
refreshConfig,
|
|
agentId,
|
|
{ env: existing.launchEnv },
|
|
);
|
|
const hadPreviousPaseoToolPolicy = this.paseoToolPolicies.has(agentId);
|
|
const previousPaseoToolPolicy = this.paseoToolPolicies.get(agentId);
|
|
const launchContext = await this.buildLaunchContext(
|
|
agentId,
|
|
client,
|
|
storedConfig.cwd,
|
|
paseoToolPolicy,
|
|
existing.launchEnv,
|
|
{
|
|
reason: "refresh",
|
|
purpose: "interactive",
|
|
workspaceId: existing.workspaceId,
|
|
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
|
labels: existing.labels,
|
|
},
|
|
);
|
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
|
if (
|
|
Object.keys(stripInternalPaseoMcpServer(providerLaunchConfig).mcpServers ?? {}).length > 0 &&
|
|
existing.session.capabilities.supportsMcpServers !== true
|
|
) {
|
|
throw this.unsupportedMcpError(providerLaunchConfig, launchContext);
|
|
}
|
|
|
|
let session: AgentSession | undefined;
|
|
let closedExisting: ManagedAgentClosed | undefined;
|
|
let handedToRegistration = false;
|
|
try {
|
|
// A persisted thread can have only one writer, even when its turn is idle.
|
|
await this.closeReloadedSession(existing.session, agentId);
|
|
await this.drainSessionEvents(agentId);
|
|
this.cancelRunningProviderSubagents(agentId);
|
|
closedExisting = this.prepareAgentForClosure(existing, "agent reloaded");
|
|
await this.persistSnapshot(closedExisting);
|
|
this.assertAcceptingAgentRegistrations();
|
|
|
|
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
|
session = handle
|
|
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
|
: await client.createSession(providerLaunchConfig, launchContext);
|
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
|
this.assertAcceptingAgentRegistrations();
|
|
|
|
if (rehydrateFromDisk) {
|
|
// Wipe the in-memory timeline so registerSession mints a new epoch and
|
|
// hydrateTimelineFromProvider re-streams the freshly read provider history.
|
|
this.timelineStore.delete(agentId);
|
|
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
|
this.dispatch({ type: "provider_subagent", event });
|
|
}
|
|
}
|
|
|
|
// Preserve existing labels and timeline during reload.
|
|
handedToRegistration = true;
|
|
return this.registerSession(session, storedConfig, agentId, {
|
|
labels: existing.labels,
|
|
workspaceId: existing.workspaceId,
|
|
owner: existing.owner,
|
|
background: existing.background,
|
|
launchEnv: existing.launchEnv,
|
|
createdAt: existing.createdAt,
|
|
updatedAt: existing.updatedAt,
|
|
lastUserMessageAt: existing.lastUserMessageAt,
|
|
historyPrimed: rehydrateFromDisk ? false : preservedHistoryPrimed,
|
|
lastUsage: preservedLastUsage,
|
|
lastError: preservedLastError,
|
|
attention: preservedAttention,
|
|
restoring: true,
|
|
});
|
|
} catch (error) {
|
|
if (closedExisting) {
|
|
this.emitClosedAgent(closedExisting, { persist: false });
|
|
} else if (this.agents.get(agentId) === existing) {
|
|
existing.lifecycle = "error";
|
|
existing.lastError = error instanceof Error ? error.message : String(error);
|
|
this.emitState(existing);
|
|
}
|
|
throw error;
|
|
} finally {
|
|
if (!handedToRegistration) {
|
|
this.detachPluginTools(agentId);
|
|
if (hadPreviousPaseoToolPolicy) {
|
|
this.paseoToolPolicies.set(agentId, previousPaseoToolPolicy);
|
|
} else {
|
|
this.paseoToolPolicies.delete(agentId);
|
|
}
|
|
if (session) {
|
|
await this.closeUnregisteredSession(session);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async closeReloadedSession(session: AgentSession, agentId: string): Promise<void> {
|
|
let operation = this.reloadedSessionCloses.get(session);
|
|
if (!operation) {
|
|
operation = session.close();
|
|
this.reloadedSessionCloses.set(session, operation);
|
|
// Keep pending closes across request timeouts; a retry must await the same release.
|
|
void operation.catch(() => this.reloadedSessionCloses.delete(session));
|
|
}
|
|
const result = await this.waitWithTimeout({
|
|
operation,
|
|
timeoutMs: this.rescueTimeouts.reloadSessionCloseMs,
|
|
onLateError: (error) => {
|
|
this.logger.warn(
|
|
{ err: error, agentId },
|
|
"Previous session close failed after refresh timeout",
|
|
);
|
|
},
|
|
});
|
|
if (result === "timed_out") {
|
|
throw new Error("Timed out closing previous session during refresh");
|
|
}
|
|
}
|
|
|
|
private async waitWithTimeout(options: TimeoutOptions): Promise<TimeoutResult> {
|
|
let didTimeOut = false;
|
|
let timer: NodeJS.Timeout | null = null;
|
|
const operation = options.operation
|
|
.then((): TimeoutResult => "completed")
|
|
.catch((error) => {
|
|
if (didTimeOut) {
|
|
options.onLateError?.(error);
|
|
return "timed_out" as const;
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
try {
|
|
return await Promise.race([
|
|
operation,
|
|
new Promise<TimeoutResult>((resolvePromise) => {
|
|
timer = setTimeout(() => {
|
|
didTimeOut = true;
|
|
resolvePromise("timed_out");
|
|
}, options.timeoutMs);
|
|
}),
|
|
]);
|
|
} finally {
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The archived snapshot of an internal agent, for a while after archive.
|
|
* Null once it has expired or was never an internal agent.
|
|
*/
|
|
getRetiredInternalAgent(agentId: string): RetiredInternalAgent | null {
|
|
const entry = this.retiredInternalAgents.get(agentId);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
if (entry.expiresAt <= Date.now()) {
|
|
this.retiredInternalAgents.delete(agentId);
|
|
return null;
|
|
}
|
|
return { record: entry.record, lastMessage: entry.lastMessage };
|
|
}
|
|
|
|
private retireInternalAgent(entry: RetiredInternalAgent): void {
|
|
const now = Date.now();
|
|
for (const [agentId, retired] of this.retiredInternalAgents) {
|
|
if (retired.expiresAt <= now) {
|
|
this.retiredInternalAgents.delete(agentId);
|
|
}
|
|
}
|
|
while (this.retiredInternalAgents.size >= RETIRED_INTERNAL_AGENT_LIMIT) {
|
|
const oldest = this.retiredInternalAgents.keys().next().value;
|
|
if (oldest === undefined) {
|
|
break;
|
|
}
|
|
this.retiredInternalAgents.delete(oldest);
|
|
}
|
|
this.retiredInternalAgents.set(entry.record.id, {
|
|
...entry,
|
|
expiresAt: now + RETIRED_INTERNAL_AGENT_TTL_MS,
|
|
});
|
|
}
|
|
|
|
/** Capture runtime capabilities without handing lifecycle ownership to the collector. */
|
|
getRuntimeCleanupCandidates(): RuntimeCleanupCandidate[] {
|
|
if (!this.registry) return [];
|
|
const candidates: RuntimeCleanupCandidate[] = [];
|
|
for (const agent of this.agents.values()) {
|
|
const updatedAt = agent.updatedAt;
|
|
let identity = this.runtimeCleanupIdentities.get(agent.session);
|
|
if (!identity) {
|
|
identity = Object.freeze({});
|
|
this.runtimeCleanupIdentities.set(agent.session, identity);
|
|
}
|
|
const isEligible = () =>
|
|
this.agents.get(agent.id) === agent &&
|
|
agent.updatedAt === updatedAt &&
|
|
this.runtimeHasNoPendingWork(agent);
|
|
candidates.push({
|
|
identity,
|
|
agentId: agent.id,
|
|
parentAgentId: getParentAgentIdFromLabels(agent.labels) ?? undefined,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? null,
|
|
lastActivityAt: updatedAt.getTime(),
|
|
isEligible,
|
|
canStopRuntime: agent.session.canStopRuntime?.bind(agent.session),
|
|
closeIfEligible: async (revalidate) => {
|
|
if (!agent.persistence?.sessionId) return false;
|
|
// Block prompt admission before entering the close lane. A skipped close also
|
|
// releases this barrier, so the waiting prompt keeps the existing runtime.
|
|
this.collectingRuntimes.add(agent.id);
|
|
try {
|
|
return await this.runLifecycleMutation(agent.id, () =>
|
|
this.closeAgentRuntime(agent.id, () => isEligible() && revalidate()),
|
|
);
|
|
} finally {
|
|
this.collectingRuntimes.delete(agent.id);
|
|
}
|
|
},
|
|
});
|
|
}
|
|
return candidates;
|
|
}
|
|
|
|
private runtimeHasNoPendingWork(agent: LiveManagedAgent): boolean {
|
|
return (
|
|
agent.lifecycle === "idle" &&
|
|
!agent.internal &&
|
|
agent.activeForegroundTurnId === null &&
|
|
!this.runs.hasRun(agent.id) &&
|
|
!agent.pendingReplacement &&
|
|
agent.pendingPermissions.size === 0 &&
|
|
agent.inFlightPermissionResponses.size === 0 &&
|
|
(this.collectingRuntimes.has(agent.id) || !this.lifecycleMutationTails.has(agent.id)) &&
|
|
!this.foregroundMutationTails.has(agent.id) &&
|
|
!this.sessionEventTails.has(agent.id) &&
|
|
!this.providerSubagents.list(agent.id).some((child) => child.status === "running")
|
|
);
|
|
}
|
|
|
|
closeAgent(agentId: string): Promise<void> {
|
|
const existing = this.inFlightAgentCloses.get(agentId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
const close = this.runLifecycleMutation(agentId, async () => {
|
|
// A preceding reload or archive may already have closed the durable agent.
|
|
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
|
|
});
|
|
this.inFlightAgentCloses.set(agentId, close);
|
|
const clearClose = () => {
|
|
if (this.inFlightAgentCloses.get(agentId) === close) {
|
|
this.inFlightAgentCloses.delete(agentId);
|
|
}
|
|
};
|
|
void close.then(clearClose, clearClose);
|
|
return close;
|
|
}
|
|
|
|
private async closeAgentRuntime(agentId: string, canClose?: () => boolean): Promise<boolean> {
|
|
const agent = this.requireAgent(agentId);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: agent.activeForegroundTurnId ?? undefined,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
pendingPermissions: agent.pendingPermissions.size,
|
|
},
|
|
"agent.manager.close.start",
|
|
);
|
|
await this.drainSessionEvents(agentId);
|
|
if (canClose && !canClose()) return false;
|
|
// Retain ownership until shutdown succeeds. A failed close may still own a
|
|
// native writer, so publishing a resumable closed snapshot would orphan it.
|
|
await agent.session.close();
|
|
await this.drainSessionEvents(agentId);
|
|
this.cancelRunningProviderSubagents(agentId);
|
|
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
|
|
|
|
let persistError: unknown;
|
|
try {
|
|
await this.persistSnapshot(closedAgent);
|
|
} catch (error) {
|
|
persistError = error;
|
|
}
|
|
this.detachPluginTools(agentId);
|
|
this.emitClosedAgent(closedAgent, { persist: false });
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: closedAgent.provider,
|
|
sessionId: closedAgent.persistence?.sessionId ?? undefined,
|
|
},
|
|
"agent.manager.close.complete",
|
|
);
|
|
|
|
if (persistError !== undefined) {
|
|
throw persistError;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private cancelRunningProviderSubagents(parentAgentId: string): void {
|
|
for (const subagent of this.providerSubagents.list(parentAgentId)) {
|
|
if (subagent.status !== "running") {
|
|
continue;
|
|
}
|
|
const event = this.providerSubagents.apply(parentAgentId, subagent.provider, {
|
|
type: "upsert",
|
|
id: subagent.id,
|
|
status: "canceled",
|
|
});
|
|
this.dispatch({ type: "provider_subagent", event });
|
|
}
|
|
}
|
|
|
|
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
|
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
|
|
}
|
|
|
|
private async archiveAgentUnlocked(
|
|
agentId: string,
|
|
requestedArchivedAt?: string,
|
|
): Promise<{ archivedAt: string }> {
|
|
const agent = this.requireAgent(agentId);
|
|
if (agent.internal) {
|
|
// Nothing about an internal agent is on disk, and archiving must not put
|
|
// it there. Its final snapshot is kept in memory for a while (see
|
|
// getRetiredInternalAgent); the runtime is closed and the committed
|
|
// timeline dropped, so `getAgent` returns null from here on.
|
|
const archivedAt = requestedArchivedAt ?? new Date().toISOString();
|
|
const lastMessage = await this.getLastAssistantMessage(agentId);
|
|
const record = buildArchivedAgentRecord(
|
|
toStoredAgentRecord(agent, { title: agent.config.title ?? null, internal: true }),
|
|
{ archivedAt, updatedAt: archivedAt },
|
|
);
|
|
await this.closeAgentRuntime(agentId);
|
|
await this.deleteAgentState(agentId);
|
|
this.retireInternalAgent({ record, lastMessage });
|
|
await this.cascadeArchiveChildren(record);
|
|
return { archivedAt };
|
|
}
|
|
if (!this.registry) {
|
|
throw new Error("Agent storage is not configured");
|
|
}
|
|
|
|
await this.registry.applySnapshot(agent, {
|
|
internal: agent.internal,
|
|
});
|
|
const stored = await this.registry.get(agentId);
|
|
if (!stored) {
|
|
throw new Error(`Agent ${agentId} not found in storage after snapshot`);
|
|
}
|
|
|
|
const { archivedAt } = await this.markRecordArchived(stored, requestedArchivedAt);
|
|
agent.updatedAt = new Date(archivedAt);
|
|
await this.closeAgentRuntime(agentId);
|
|
await this.syncNativeArchiveState(stored.provider, stored.persistence, "archive");
|
|
this.discardRetainedAgentState(agentId);
|
|
|
|
await this.cascadeArchiveChildren(stored);
|
|
|
|
return { archivedAt };
|
|
}
|
|
|
|
// Children created via the MCP `create_agent` tool carry the parent-agent-id
|
|
// label pointing back at the caller. Archiving the parent cascades to those
|
|
// children so subagent fleets don't outlive their orchestrator. Detached
|
|
// handoff agents omit this label, so they stand outside the cascade.
|
|
private async cascadeArchiveChildren(
|
|
parent: Pick<StoredAgentRecord, "id" | "workspaceId">,
|
|
): Promise<void> {
|
|
const registry = this.registry;
|
|
if (!registry) {
|
|
return;
|
|
}
|
|
const parentAgentId = parent.id;
|
|
const records = await registry.list();
|
|
for (const record of records) {
|
|
if (record.archivedAt) {
|
|
continue;
|
|
}
|
|
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
|
continue;
|
|
}
|
|
const child = await registry.get(record.id);
|
|
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
|
continue;
|
|
}
|
|
await this.runLifecycleMutation(child.id, async () => {
|
|
const currentChild = await registry.get(child.id);
|
|
if (
|
|
!currentChild ||
|
|
currentChild.archivedAt ||
|
|
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
|
|
) {
|
|
return;
|
|
}
|
|
if (shouldDetachFromArchivedParent(parent, currentChild)) {
|
|
await this.detachAgentUnlocked(currentChild.id);
|
|
} else if (this.agents.has(currentChild.id)) {
|
|
await this.archiveAgentUnlocked(currentChild.id);
|
|
} else {
|
|
await this.archiveSnapshotUnlocked(currentChild.id, new Date().toISOString());
|
|
}
|
|
});
|
|
}
|
|
// Internal children never reach storage, so the scan above cannot see them.
|
|
// They have no tab to keep open and no listing to detach into, so they
|
|
// always archive with the parent.
|
|
for (const child of Array.from(this.agents.values())) {
|
|
if (!child.internal || getParentAgentIdFromLabels(child.labels) !== parentAgentId) {
|
|
continue;
|
|
}
|
|
await this.runLifecycleMutation(child.id, async () => {
|
|
const current = this.agents.get(child.id);
|
|
if (!current || getParentAgentIdFromLabels(current.labels) !== parentAgentId) {
|
|
return;
|
|
}
|
|
await this.archiveAgentUnlocked(current.id);
|
|
});
|
|
}
|
|
}
|
|
|
|
private async markRecordArchived(
|
|
record: StoredAgentRecord,
|
|
archivedAt = new Date().toISOString(),
|
|
): Promise<ArchivedStoredAgentRecord> {
|
|
const archivedRecord = await this.persistArchivedRecord(record, {
|
|
archivedAt,
|
|
updatedAt: archivedAt,
|
|
});
|
|
|
|
if (this.agents.has(record.id)) {
|
|
this.notifyAgentState(record.id);
|
|
} else if (!archivedRecord.internal) {
|
|
this.dispatchStoredAgentState(archivedRecord);
|
|
}
|
|
|
|
await this.fireAgentArchived(record.id);
|
|
|
|
return archivedRecord;
|
|
}
|
|
|
|
private async persistArchivedRecord(
|
|
record: StoredAgentRecord,
|
|
options: { archivedAt: string; updatedAt?: string },
|
|
): Promise<ArchivedStoredAgentRecord> {
|
|
const archivedRecord = buildArchivedAgentRecord(record, options);
|
|
await this.requireRegistry().upsert(archivedRecord);
|
|
if (!record.archivedAt && !record.internal) {
|
|
this.pluginLifecycle?.emit("agent.archive.after", {
|
|
agent: describeHookAgent(archivedRecord),
|
|
archivedAt: archivedRecord.archivedAt,
|
|
});
|
|
}
|
|
return archivedRecord;
|
|
}
|
|
|
|
private async fireAgentArchived(agentId: string): Promise<void> {
|
|
const callback = this.onAgentArchived;
|
|
if (!callback) {
|
|
return;
|
|
}
|
|
try {
|
|
await callback(agentId);
|
|
} catch (error) {
|
|
this.logger.warn({ err: error, agentId }, "onAgentArchived callback failed");
|
|
}
|
|
}
|
|
|
|
private dispatchStoredAgentState(record: StoredAgentRecord): void {
|
|
const updatedAt = new Date(record.updatedAt);
|
|
const attention = extractAttention(record);
|
|
this.dispatch({
|
|
type: "agent_state",
|
|
agent: {
|
|
id: record.id,
|
|
provider: record.provider,
|
|
cwd: record.cwd,
|
|
workspaceId: record.workspaceId,
|
|
owner: record.owner,
|
|
session: null,
|
|
capabilities: STORED_AGENT_CAPABILITIES,
|
|
config: buildStoredAgentConfig(record),
|
|
runtimeInfo: undefined,
|
|
lifecycle: "closed",
|
|
createdAt: new Date(record.createdAt),
|
|
updatedAt,
|
|
availableModes: [],
|
|
controls: record.controls,
|
|
effectiveOptions: this.effectiveOptionsFor(
|
|
record.provider,
|
|
record.config?.providerOptions ?? undefined,
|
|
),
|
|
currentModeId: record.lastModeId ?? null,
|
|
pendingPermissions: new Map(),
|
|
bufferedPermissionResolutions: new Map(),
|
|
inFlightPermissionResponses: new Set(),
|
|
pendingReplacement: false,
|
|
activeForegroundTurnId: null,
|
|
activeTurnId: null,
|
|
activeTurnStartedAt: null,
|
|
foregroundTurnWaiters: new Set(),
|
|
finalizedForegroundTurnIds: new Set(),
|
|
unsubscribeSession: null,
|
|
persistence: record.persistence ?? null,
|
|
historyPrimed: true,
|
|
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
|
|
lastUsage: undefined,
|
|
lastError: record.lastError ?? undefined,
|
|
attention,
|
|
internal: record.internal,
|
|
background: record.background,
|
|
labels: record.labels,
|
|
},
|
|
});
|
|
}
|
|
|
|
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const notice = (await agent.session.setMode(modeId)) ?? null;
|
|
await this.drainSessionEvents(agentId);
|
|
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
|
agent.config.modeId = currentMode ?? undefined;
|
|
agent.currentModeId = currentMode;
|
|
// Update runtimeInfo to reflect the new mode
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
return notice;
|
|
}
|
|
|
|
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const normalizedModelId =
|
|
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
|
|
|
if (agent.session.setModel) {
|
|
await agent.session.setModel(normalizedModelId);
|
|
}
|
|
await this.drainSessionEvents(agentId);
|
|
|
|
agent.config.model = normalizedModelId ?? undefined;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = { ...agent.runtimeInfo, model: normalizedModelId };
|
|
}
|
|
this.refreshSessionPersistence(agent);
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async setAgentThinkingOption(
|
|
agentId: string,
|
|
thinkingOptionId: string | null,
|
|
): Promise<AgentProviderNotice | null> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const normalizedThinkingOptionId =
|
|
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
|
? thinkingOptionId
|
|
: null;
|
|
|
|
let notice: AgentProviderNotice | null = null;
|
|
if (agent.session.setThinkingOption) {
|
|
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
|
|
}
|
|
await this.drainSessionEvents(agentId);
|
|
|
|
let effectiveThinkingOptionId = normalizedThinkingOptionId;
|
|
const runtimeInfo = await agent.session.getRuntimeInfo();
|
|
if (runtimeInfo.thinkingOptionId !== undefined) {
|
|
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
|
|
}
|
|
|
|
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = {
|
|
...agent.runtimeInfo,
|
|
thinkingOptionId: effectiveThinkingOptionId,
|
|
};
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
return notice;
|
|
}
|
|
|
|
/**
|
|
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
|
|
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
|
|
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
|
|
* one step once the provider accepts, over the options as they are at that moment, so concurrent
|
|
* writes to one agent resolve by arrival order.
|
|
*/
|
|
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
let ended: ProviderOptionsPatch = {};
|
|
if (agent.session.applyOptions && Object.keys(patch).length > 0) {
|
|
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
|
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
|
ended =
|
|
(await agent.session.applyOptions(
|
|
Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null])),
|
|
)) ?? {};
|
|
// Changes the provider made on its own while applying are older than its final answer.
|
|
await this.drainSessionEvents(agentId);
|
|
}
|
|
this.storeOptionsPatch(agent, { ...patch, ...ended });
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
/** The config as the opened session left it: its own difference from the options it was given. */
|
|
private withOpenedOptions(config: AgentSessionConfig, session: AgentSession): AgentSessionConfig {
|
|
const opened = session.openOptionsPatch;
|
|
if (!opened || Object.keys(opened).length === 0) return config;
|
|
const own = applyProviderOptionsPatch(config.providerOptions, opened);
|
|
return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined };
|
|
}
|
|
|
|
private storeOptionsPatch(agent: ManagedAgent, patch: ProviderOptionsPatch): void {
|
|
const own = applyProviderOptionsPatch(agent.config.providerOptions, patch);
|
|
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
|
|
}
|
|
|
|
private effectiveOptionsFor(
|
|
provider: AgentProvider,
|
|
own: ProviderOptions | undefined,
|
|
): ProviderOptions {
|
|
return this.clients.get(provider)?.effectiveOptions?.(own) ?? own ?? {};
|
|
}
|
|
|
|
async setTitle(agentId: string, title: string): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
const normalizedTitle = title.trim();
|
|
if (!normalizedTitle) {
|
|
return;
|
|
}
|
|
if (
|
|
this.agentsAwaitingInitialSnapshotPersist.has(agent.id) &&
|
|
this.registry &&
|
|
(await this.registry.get(agent.id)) === null
|
|
) {
|
|
return;
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
await this.persistSnapshot(agent, { title: normalizedTitle });
|
|
this.emitState(agent, { persist: false });
|
|
}
|
|
|
|
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
|
await this.runLifecycleMutation(agentId, async () => {
|
|
const agent = this.requireAgent(agentId);
|
|
await this.writeLabels(agent.id, labels);
|
|
});
|
|
}
|
|
|
|
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
|
const liveAgent = this.agents.get(agentId);
|
|
if (liveAgent) {
|
|
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
|
this.touchUpdatedAt(liveAgent);
|
|
await this.persistSnapshot(liveAgent);
|
|
this.emitState(liveAgent, { persist: false });
|
|
const record = this.registry ? await this.registry.get(agentId) : null;
|
|
return { record, live: true };
|
|
}
|
|
|
|
const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
|
|
return { record: nextRecord, live: false };
|
|
}
|
|
|
|
private async writeStoredMetadata(
|
|
agentId: string,
|
|
patch: AgentMetadataPatch,
|
|
): Promise<StoredAgentRecord> {
|
|
const registry = this.requireRegistry();
|
|
const record = await registry.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent not found: ${agentId}`);
|
|
}
|
|
|
|
const nextRecord = {
|
|
...record,
|
|
...(patch.title ? { title: patch.title } : {}),
|
|
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
|
updatedAt: this.nextStoredUpdatedAt(record),
|
|
};
|
|
await registry.upsert(nextRecord);
|
|
return nextRecord;
|
|
}
|
|
|
|
async detachAgent(agentId: string): Promise<{
|
|
record: StoredAgentRecord;
|
|
live: boolean;
|
|
previousParentAgentId: string | null;
|
|
}> {
|
|
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
|
|
}
|
|
|
|
private async detachAgentUnlocked(agentId: string): Promise<{
|
|
record: StoredAgentRecord;
|
|
live: boolean;
|
|
previousParentAgentId: string | null;
|
|
}> {
|
|
const registry = this.requireRegistry();
|
|
const liveAgent = this.agents.get(agentId);
|
|
if (liveAgent) {
|
|
const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels);
|
|
if (!previousParentAgentId) {
|
|
await this.persistSnapshot(liveAgent);
|
|
const record = await registry.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
|
}
|
|
return { record, live: true, previousParentAgentId: null };
|
|
}
|
|
|
|
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
|
|
if (!record) {
|
|
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
|
}
|
|
return { record, live: true, previousParentAgentId };
|
|
}
|
|
|
|
const record = await registry.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent not found: ${agentId}`);
|
|
}
|
|
const previousParentAgentId = getParentAgentIdFromLabels(record.labels);
|
|
if (!previousParentAgentId) {
|
|
return { record, live: false, previousParentAgentId: null };
|
|
}
|
|
|
|
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
|
|
if (!result.record) {
|
|
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
|
}
|
|
return { record: result.record, live: false, previousParentAgentId };
|
|
}
|
|
|
|
notifyAgentState(agentId: string): void {
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent || agent.internal) {
|
|
return;
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async clearAgentAttention(agentId: string): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
if (agent.attention.requiresAttention) {
|
|
agent.attention = { requiresAttention: false };
|
|
await this.persistSnapshot(agent);
|
|
this.emitState(agent, { persist: false });
|
|
}
|
|
}
|
|
|
|
async markAgentUnread(agentId: string): Promise<void> {
|
|
const liveAgent = this.agents.get(agentId);
|
|
if (liveAgent) {
|
|
const isFinished = liveAgent.lifecycle === "idle";
|
|
const hasPendingPermissions = liveAgent.pendingPermissions.size > 0;
|
|
const canMarkUnread =
|
|
isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions;
|
|
if (!canMarkUnread) {
|
|
throw new Error(`Agent is no longer finished and read: ${agentId}`);
|
|
}
|
|
liveAgent.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: new Date(),
|
|
};
|
|
await this.persistSnapshot(liveAgent);
|
|
this.emitState(liveAgent, { persist: false });
|
|
return;
|
|
}
|
|
|
|
const registry = this.requireRegistry();
|
|
const record = await registry.get(agentId);
|
|
const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed";
|
|
const canMarkUnread =
|
|
record && !record.internal && !record.archivedAt && !record.requiresAttention;
|
|
if (!canMarkUnread || !hasFinishedStatus) {
|
|
throw new Error(`Agent is no longer finished and read: ${agentId}`);
|
|
}
|
|
const updatedAt = this.nextStoredUpdatedAt(record);
|
|
const nextRecord: StoredAgentRecord = {
|
|
...record,
|
|
updatedAt,
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: updatedAt,
|
|
};
|
|
await registry.upsert(nextRecord);
|
|
this.dispatchStoredAgentState(nextRecord);
|
|
}
|
|
|
|
async archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord> {
|
|
return this.runLifecycleMutation(agentId, () =>
|
|
this.archiveSnapshotUnlocked(agentId, archivedAt),
|
|
);
|
|
}
|
|
|
|
private async archiveSnapshotUnlocked(
|
|
agentId: string,
|
|
archivedAt: string,
|
|
): Promise<StoredAgentRecord> {
|
|
const registry = this.requireRegistry();
|
|
// A stored-only archive can have waited behind a persisted resume. Reuse the
|
|
// live archive transition so its newly acquired runtime is closed as well.
|
|
if (this.agents.has(agentId)) {
|
|
await this.archiveAgentUnlocked(agentId, archivedAt);
|
|
const archivedRecord = await registry.get(agentId);
|
|
if (!archivedRecord) throw new Error(`Agent not found: ${agentId}`);
|
|
return archivedRecord;
|
|
}
|
|
|
|
const record = await registry.get(agentId);
|
|
if (!record) {
|
|
throw new Error(`Agent not found: ${agentId}`);
|
|
}
|
|
|
|
const nextRecord = await this.persistArchivedRecord(record, { archivedAt });
|
|
|
|
await this.syncNativeArchiveState(record.provider, record.persistence, "archive");
|
|
|
|
this.discardRetainedAgentState(agentId);
|
|
if (!nextRecord.internal) this.dispatchStoredAgentState(nextRecord);
|
|
|
|
await this.fireAgentArchived(agentId);
|
|
await this.cascadeArchiveChildren(record);
|
|
|
|
return nextRecord;
|
|
}
|
|
|
|
async unarchiveSnapshot(
|
|
agentId: string,
|
|
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
|
|
): Promise<boolean> {
|
|
return this.runLifecycleMutation(agentId, () =>
|
|
this.unarchiveSnapshotUnlocked(agentId, updates),
|
|
);
|
|
}
|
|
|
|
private async unarchiveSnapshotUnlocked(
|
|
agentId: string,
|
|
updates?: { workspaceId?: string; labels?: AgentLabelPatch },
|
|
): Promise<boolean> {
|
|
const registry = this.requireRegistry();
|
|
const record = await registry.get(agentId);
|
|
if (!record || !record.archivedAt) {
|
|
return false;
|
|
}
|
|
|
|
// Close and native restore share the lifecycle lane with persisted resume.
|
|
// No new history or interactive runtime can acquire the writer between them.
|
|
if (this.agents.has(agentId)) await this.closeAgentRuntime(agentId);
|
|
await this.syncNativeArchiveState(record.provider, record.persistence, "restore");
|
|
|
|
await registry.upsert({
|
|
...record,
|
|
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
|
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
|
archivedAt: null,
|
|
updatedAt: new Date().toISOString(),
|
|
});
|
|
|
|
if (this.getAgent(agentId)) {
|
|
this.notifyAgentState(agentId);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
|
const registry = this.requireRegistry();
|
|
const records = await registry.list();
|
|
const matched = records.find(
|
|
(record) =>
|
|
record.persistence?.provider === handle.provider &&
|
|
record.persistence?.sessionId === handle.sessionId,
|
|
);
|
|
if (!matched) {
|
|
return;
|
|
}
|
|
|
|
await this.unarchiveSnapshot(matched.id);
|
|
}
|
|
|
|
async updateAgentMetadata(
|
|
agentId: string,
|
|
updates: {
|
|
title?: string;
|
|
labels?: Record<string, string>;
|
|
},
|
|
): Promise<void> {
|
|
await this.runLifecycleMutation(agentId, () =>
|
|
this.updateAgentMetadataUnlocked(agentId, updates),
|
|
);
|
|
}
|
|
|
|
private async updateAgentMetadataUnlocked(
|
|
agentId: string,
|
|
updates: {
|
|
title?: string;
|
|
labels?: Record<string, string>;
|
|
},
|
|
): Promise<void> {
|
|
const liveAgent = this.getAgent(agentId);
|
|
if (liveAgent) {
|
|
if (updates.title) {
|
|
await this.setTitle(agentId, updates.title);
|
|
}
|
|
if (updates.labels) {
|
|
await this.writeLabels(agentId, updates.labels);
|
|
}
|
|
return;
|
|
}
|
|
|
|
await this.writeStoredMetadata(agentId, updates);
|
|
}
|
|
|
|
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
|
|
// Parent cascade classifies a child inside the same lane used by open-tab
|
|
// label writes, so a received ownership update cannot be overtaken.
|
|
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
|
|
const result = previous.catch(() => undefined).then(mutation);
|
|
const tail = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
this.lifecycleMutationTails.set(agentId, tail);
|
|
void tail.finally(() => {
|
|
if (this.lifecycleMutationTails.get(agentId) === tail) {
|
|
this.lifecycleMutationTails.delete(agentId);
|
|
}
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async runAgent(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<AgentRunResult> {
|
|
const events = this.streamAgent(agentId, prompt, options);
|
|
const timeline: AgentTimelineItem[] = [];
|
|
let finalText = "";
|
|
let usage: AgentUsage | undefined;
|
|
let canceled = false;
|
|
|
|
for await (const event of events) {
|
|
if (event.type === "timeline") {
|
|
timeline.push(event.item);
|
|
} else if (event.type === "turn_completed") {
|
|
usage = event.usage;
|
|
} else if (event.type === "turn_failed") {
|
|
throw new Error(this.formatTurnFailedMessage(event));
|
|
} else if (event.type === "turn_canceled") {
|
|
canceled = true;
|
|
}
|
|
}
|
|
|
|
finalText = this.getLastAssistantMessageFromTimeline(timeline) ?? "";
|
|
|
|
const agent = this.requireAgent(agentId);
|
|
const sessionId = agent.persistence?.sessionId;
|
|
if (!sessionId) {
|
|
throw new Error(`Agent ${agentId} has no persistence.sessionId after run completed`);
|
|
}
|
|
return {
|
|
sessionId,
|
|
finalText,
|
|
usage,
|
|
timeline,
|
|
canceled,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Try to run a prompt out-of-band — i.e. without allocating a foreground turn
|
|
* and without canceling any active turn. Returns true when the session
|
|
* accepted the prompt as a side-effect command (e.g. /goal pause). Events
|
|
* emitted by the handler flow through dispatchStream so they persist and
|
|
* broadcast like normal timeline events.
|
|
*/
|
|
async tryRunOutOfBand(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<boolean> {
|
|
if (this.collectingRuntimes.has(agentId) || !this.agents.has(agentId)) {
|
|
await this.waitForAgentClose(agentId);
|
|
await ensureAgentLoaded(agentId, {
|
|
agentManager: this,
|
|
agentStorage: this.requireRegistry(),
|
|
logger: this.logger,
|
|
});
|
|
}
|
|
const agent = this.requireSessionAgent(agentId);
|
|
// Providers parse slash commands from the raw text, so a command another agent sent
|
|
// must reach them without its sender envelope.
|
|
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
|
|
const handler = agent.session.tryHandleOutOfBand?.(
|
|
agentMessage?.source ? agentMessage.text : prompt,
|
|
);
|
|
if (!handler) {
|
|
return false;
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
if (options?.clientMessageId) {
|
|
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted");
|
|
this.emitState(agent);
|
|
}
|
|
const dispatch = (event: AgentStreamEvent): void => {
|
|
// Persist timeline items so they show up in fetchAgentTimeline; broadcast
|
|
// for live subscribers. Other event types are broadcast only.
|
|
if (event.type === "timeline") {
|
|
this.touchUpdatedAt(agent);
|
|
const row = this.recordTimeline(agent.id, event.item);
|
|
this.dispatchStream(agent.id, event, {
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agent.id),
|
|
timestamp: row.timestamp,
|
|
});
|
|
return;
|
|
}
|
|
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
|
};
|
|
void this.runForegroundMutation(agentId, async () => {
|
|
try {
|
|
await handler.run({ emit: dispatch });
|
|
} catch (error) {
|
|
const text = error instanceof Error ? error.message : "Out-of-band command failed";
|
|
dispatch({
|
|
type: "timeline",
|
|
provider: agent.provider,
|
|
item: { type: "assistant_message", text: `[Error] ${text}` },
|
|
});
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
async appendTimelineItem(
|
|
agentId: string,
|
|
item: AgentTimelineItem,
|
|
): Promise<{ seq: number; epoch: string }> {
|
|
const agent = this.requireAgent(agentId);
|
|
item = limitAgentTimelineItemContent(item);
|
|
this.touchUpdatedAt(agent);
|
|
const row = this.recordTimeline(agentId, item);
|
|
this.dispatchStream(
|
|
agentId,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider: agent.provider,
|
|
},
|
|
{
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agentId),
|
|
timestamp: row.timestamp,
|
|
},
|
|
);
|
|
await this.persistSnapshot(agent);
|
|
return { seq: row.seq, epoch: this.timelineStore.getEpoch(agentId) };
|
|
}
|
|
|
|
async emitLiveTimelineItem(agentId: string, item: AgentTimelineItem): Promise<void> {
|
|
const agent = this.requireAgent(agentId);
|
|
this.touchUpdatedAt(agent);
|
|
this.dispatchStream(agentId, {
|
|
type: "timeline",
|
|
item,
|
|
provider: agent.provider,
|
|
});
|
|
}
|
|
|
|
private async startPendingForegroundTurn(params: {
|
|
agent: ActiveManagedAgent;
|
|
agentId: string;
|
|
pendingRun: PendingForegroundRun;
|
|
prompt: AgentPromptInput;
|
|
options?: AgentRunOptions;
|
|
}): Promise<string> {
|
|
const { agent, agentId, pendingRun, prompt, options } = params;
|
|
try {
|
|
const result = await agent.session.startTurn(prompt, options);
|
|
if (pendingRun.settled) {
|
|
throw new Error(`Agent ${agentId} run was canceled before its turn started`);
|
|
}
|
|
return result.turnId;
|
|
} catch (error) {
|
|
if (pendingRun.settled) {
|
|
throw error;
|
|
}
|
|
if (isStaleProviderSessionError(error)) {
|
|
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
|
agent.pendingReplacement = false;
|
|
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
|
throw error;
|
|
}
|
|
agent.pendingReplacement = false;
|
|
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
|
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
|
try {
|
|
// A terminal rejection belongs after the submitted prompt even though no provider turn exists.
|
|
if (options?.clientMessageId) {
|
|
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "rejected", {
|
|
messageId: options.clientMessageId,
|
|
});
|
|
}
|
|
await this.handleStreamEvent(agent, {
|
|
type: "turn_failed",
|
|
provider: agent.provider,
|
|
error: errorMsg,
|
|
});
|
|
this.finalizeForegroundTurn(agent);
|
|
} finally {
|
|
// A steer waiting on this start is released only once the run is cleared.
|
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async *streamAfterRuntimeCollection(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): AsyncGenerator<AgentStreamEvent> {
|
|
await this.waitForAgentClose(agentId);
|
|
await ensureAgentLoaded(agentId, {
|
|
agentManager: this,
|
|
agentStorage: this.requireRegistry(),
|
|
logger: this.logger,
|
|
});
|
|
yield* this.streamAgent(agentId, prompt, options);
|
|
}
|
|
|
|
streamAgent(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): AsyncGenerator<AgentStreamEvent> {
|
|
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
|
|
return this.streamAfterRuntimeCollection(agentId, prompt, options);
|
|
const existingAgent = this.requireSessionAgent(agentId);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: existingAgent.provider,
|
|
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
|
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
|
lifecycle: existingAgent.lifecycle,
|
|
activeForegroundTurnId: existingAgent.activeForegroundTurnId,
|
|
hasTrackedRun: this.runs.hasRun(agentId),
|
|
promptType: typeof prompt === "string" ? "string" : "structured",
|
|
hasRunOptions: Boolean(options),
|
|
},
|
|
"agent.manager.stream.request",
|
|
);
|
|
if (existingAgent.activeForegroundTurnId || this.runs.hasRun(agentId)) {
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: existingAgent.provider,
|
|
sessionId: existingAgent.persistence?.sessionId ?? undefined,
|
|
turnId: existingAgent.activeForegroundTurnId ?? undefined,
|
|
lifecycle: existingAgent.lifecycle,
|
|
hasTrackedRun: this.runs.hasRun(agentId),
|
|
},
|
|
"agent.manager.stream.reject",
|
|
);
|
|
throw new Error(`Agent ${agentId} already has an active run`);
|
|
}
|
|
|
|
const agent = existingAgent;
|
|
const isReplacement = agent.pendingReplacement;
|
|
agent.lastError = undefined;
|
|
|
|
const pendingRun = this.runs.createPendingRun(agentId);
|
|
|
|
const streamForwarder = async function* streamForwarder(this: AgentManager) {
|
|
let turnId: string;
|
|
let turnStream: ReturnType<AgentRunState["createTurnStream"]> | null = null;
|
|
turnId = await this.startPendingForegroundTurn({
|
|
agent,
|
|
agentId,
|
|
pendingRun,
|
|
prompt,
|
|
options,
|
|
});
|
|
|
|
if (isReplacement) {
|
|
agent.pendingReplacement = false;
|
|
}
|
|
const turnStartedAt = new Date();
|
|
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
|
agent.activeForegroundTurnId = turnId;
|
|
this.openActiveTurn(agent, turnId, turnStartedAt);
|
|
agent.attention = { requiresAttention: false };
|
|
agent.lifecycle = "running";
|
|
this.touchUpdatedAt(agent);
|
|
// AgentManager owns the accepted-turn boundary. Publish liveness before the canonical
|
|
// prompt so clients can retire optimistic activity without painting an idle frame.
|
|
// The provider's duplicate start for this turn is suppressed at the ingestion boundary.
|
|
this.dispatchStream(
|
|
agent.id,
|
|
{ type: "turn_started", provider: agent.provider, turnId },
|
|
{ timestamp: turnStartedAt.toISOString() },
|
|
);
|
|
const stagedSubmittedPromptEcho = options?.clientMessageId
|
|
? pendingRun.stagedEvents.find(
|
|
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
|
|
event.type === "timeline" &&
|
|
event.item.type === "user_message" &&
|
|
event.item.clientMessageId === options.clientMessageId,
|
|
)
|
|
: undefined;
|
|
if (options?.clientMessageId) {
|
|
this.recordSubmittedPrompt(agent, prompt, options.clientMessageId, "accepted", {
|
|
messageId: options.clientMessageId,
|
|
turnId,
|
|
providerMessageId:
|
|
stagedSubmittedPromptEcho?.item.type === "user_message"
|
|
? stagedSubmittedPromptEcho.item.messageId
|
|
: undefined,
|
|
});
|
|
}
|
|
for (const stagedEvent of pendingRun.stagedEvents.splice(0)) {
|
|
const isAcceptedTurnStart =
|
|
stagedEvent.type === "turn_started" && getAgentStreamEventTurnId(stagedEvent) === turnId;
|
|
if (isAcceptedTurnStart || stagedEvent === stagedSubmittedPromptEcho) {
|
|
continue;
|
|
}
|
|
this.enqueueSessionEvent(agent.id, stagedEvent);
|
|
}
|
|
this.emitState(agent);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
},
|
|
"agent.manager.stream.start",
|
|
);
|
|
|
|
turnStream = this.runs.createTurnStream(turnId);
|
|
this.runs.addWaiter(agent, turnStream.waiter);
|
|
|
|
try {
|
|
const acceptedTurnStartedEvent: AgentStreamEvent = {
|
|
type: "turn_started",
|
|
provider: agent.provider,
|
|
turnId,
|
|
};
|
|
yield acceptedTurnStartedEvent;
|
|
for await (const event of turnStream.events(isTurnTerminalEvent)) {
|
|
yield event;
|
|
}
|
|
} finally {
|
|
if (turnStream) {
|
|
this.runs.deleteWaiter(agent, turnStream.waiter);
|
|
}
|
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
|
if (!agent.activeForegroundTurnId) {
|
|
await this.refreshRuntimeInfo(agent);
|
|
}
|
|
}
|
|
}.call(this);
|
|
|
|
return streamForwarder;
|
|
}
|
|
|
|
private finalizeForegroundTurn(agent: ActiveManagedAgent, turnId?: string): void {
|
|
const mutableAgent = agent;
|
|
if (turnId) {
|
|
this.runs.rememberFinalizedTurn(mutableAgent, turnId);
|
|
}
|
|
mutableAgent.activeForegroundTurnId = null;
|
|
this.applyActiveTurnTerminal(mutableAgent, turnId);
|
|
const terminalError = mutableAgent.lastError;
|
|
const shouldHoldBusyForReplacement = mutableAgent.pendingReplacement && !terminalError;
|
|
let nextLifecycle: "running" | "error" | "idle";
|
|
if (shouldHoldBusyForReplacement) {
|
|
nextLifecycle = "running";
|
|
} else if (terminalError) {
|
|
nextLifecycle = "error";
|
|
} else {
|
|
nextLifecycle = "idle";
|
|
}
|
|
mutableAgent.lifecycle = nextLifecycle;
|
|
const persistenceHandle =
|
|
mutableAgent.session.describePersistence() ??
|
|
(mutableAgent.runtimeInfo?.sessionId
|
|
? { provider: mutableAgent.provider, sessionId: mutableAgent.runtimeInfo.sessionId }
|
|
: null);
|
|
if (persistenceHandle) {
|
|
mutableAgent.persistence = normalizePersistenceForConfig(
|
|
persistenceHandle,
|
|
mutableAgent.config,
|
|
);
|
|
}
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: mutableAgent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
lifecycle: mutableAgent.lifecycle,
|
|
terminalError,
|
|
pendingReplacement: mutableAgent.pendingReplacement,
|
|
},
|
|
"agent.manager.finalize",
|
|
);
|
|
if (!shouldHoldBusyForReplacement) {
|
|
this.touchUpdatedAt(mutableAgent);
|
|
this.emitState(mutableAgent);
|
|
}
|
|
}
|
|
|
|
private openActiveTurn(agent: ActiveManagedAgent, turnId: string, startedAt: Date): void {
|
|
agent.activeTurnId = turnId;
|
|
agent.activeTurnStartedAt = startedAt;
|
|
}
|
|
|
|
private applyActiveTurnTerminal(
|
|
agent: ActiveManagedAgent,
|
|
turnId?: string,
|
|
fromHistory = false,
|
|
): ActiveTurnTerminalDisposition {
|
|
if (fromHistory) return "stale";
|
|
if (!agent.activeTurnId) return "untracked";
|
|
if (turnId && agent.activeTurnId !== turnId) return "stale";
|
|
agent.activeTurnId = null;
|
|
agent.activeTurnStartedAt = null;
|
|
return "closed_current";
|
|
}
|
|
|
|
async replaceAgentRun(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
|
const snapshot = this.requireAgent(agentId);
|
|
if (
|
|
snapshot.lifecycle !== "running" &&
|
|
!snapshot.activeForegroundTurnId &&
|
|
!this.runs.hasRun(agentId)
|
|
) {
|
|
return this.streamAgent(agentId, prompt, options);
|
|
}
|
|
|
|
const agent = this.requireSessionAgent(agentId);
|
|
agent.pendingReplacement = true;
|
|
agent.lifecycle = "running";
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
|
|
try {
|
|
await this.cancelAgentRunBefore(agentId, "replace");
|
|
return this.streamAgent(agentId, prompt, options);
|
|
} catch (error) {
|
|
const latest = this.agents.get(agentId);
|
|
if (latest) {
|
|
latest.pendingReplacement = false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async steerAgentRun(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentSteerOptions,
|
|
): Promise<SteerResult> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
|
if (!expectedTurnId || !agent.session.steerActiveTurn) {
|
|
return { status: "unavailable" };
|
|
}
|
|
const result = await this.runSteerAdmission(agent, expectedTurnId, async () => {
|
|
const admission = await agent.session.steerActiveTurn!(prompt, {
|
|
...options,
|
|
expectedTurnId,
|
|
});
|
|
if (admission.status === "accepted") {
|
|
await this.recordAcceptedSteer(
|
|
agent,
|
|
prompt,
|
|
options?.clientMessageId,
|
|
expectedTurnId,
|
|
admission.providerMessageId,
|
|
);
|
|
}
|
|
return admission;
|
|
});
|
|
// An unavailable answer is only safe to fall back from while this admission
|
|
// still owns the active turn. Never let an A admission replace a later B.
|
|
if (result.status === "unavailable" && agent.activeTurnId !== expectedTurnId) {
|
|
throw new Error("Active turn changed before steering could be delivered");
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async steerOrReplaceActiveTurn(
|
|
agentId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentSteerOptions,
|
|
): Promise<ActiveTurnSteerDispatchResult> {
|
|
// A turn that is still starting has no id to steer yet. Replacing it would drop its
|
|
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
|
|
await this.runs.waitForForegroundStart(agentId);
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
|
if (!expectedTurnId) {
|
|
return { status: "inactive" };
|
|
}
|
|
|
|
const result = agent.session.steerActiveTurn
|
|
? await this.runSteerAdmission(agent, expectedTurnId, async () => {
|
|
const admission = await agent.session.steerActiveTurn!(prompt, {
|
|
...options,
|
|
expectedTurnId,
|
|
});
|
|
if (admission.status === "accepted") {
|
|
await this.recordAcceptedSteer(
|
|
agent,
|
|
prompt,
|
|
options?.clientMessageId,
|
|
expectedTurnId,
|
|
admission.providerMessageId,
|
|
);
|
|
}
|
|
return admission;
|
|
})
|
|
: { status: "unavailable" as const };
|
|
if (result.status === "accepted") {
|
|
return { status: "steered" };
|
|
}
|
|
|
|
// Providers without autonomous steering keep their existing dispatch behavior. The shared
|
|
// admission may recognize the turn, but only an accepted steer can own it without replacement.
|
|
if (agent.activeForegroundTurnId === null && agent.activeTurnId === expectedTurnId) {
|
|
return { status: "inactive" };
|
|
}
|
|
|
|
await this.beforeSteerUnavailableFallback?.({ agentId, expectedTurnId });
|
|
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
|
return {
|
|
status: "replaced",
|
|
iterator: await this.replaceAdmittedForegroundTurn(
|
|
agent,
|
|
expectedTurnId,
|
|
prompt,
|
|
stripSteerOptions(options),
|
|
),
|
|
};
|
|
}
|
|
|
|
private assertSteerAdmissionOwnsTurn(agent: ActiveManagedAgent, expectedTurnId: string): void {
|
|
if (agent.activeTurnId !== expectedTurnId) {
|
|
throw new Error("Active turn changed before steering could be delivered");
|
|
}
|
|
}
|
|
|
|
private async runSteerAdmission<T>(
|
|
agent: ActiveManagedAgent,
|
|
expectedTurnId: string,
|
|
operation: () => Promise<T>,
|
|
): Promise<T> {
|
|
return this.runForegroundMutation(agent.id, async () => {
|
|
await this.drainSessionEvents(agent.id);
|
|
this.agentStreamCoalescer.flushFor(agent.id);
|
|
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
|
const barrier: SteerEventBarrier = { events: [] };
|
|
this.steerEventBarriers.set(agent.id, barrier);
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
if (this.steerEventBarriers.get(agent.id) === barrier) {
|
|
this.steerEventBarriers.delete(agent.id);
|
|
}
|
|
for (const event of barrier.events) {
|
|
this.enqueueSessionEvent(agent.id, event);
|
|
}
|
|
await this.drainSessionEvents(agent.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async runForegroundMutation<T>(agentId: string, operation: () => Promise<T>): Promise<T> {
|
|
const previous = this.foregroundMutationTails.get(agentId) ?? Promise.resolve();
|
|
const run = previous.catch(() => undefined).then(operation);
|
|
const tail = run.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
this.foregroundMutationTails.set(agentId, tail);
|
|
try {
|
|
return await run;
|
|
} finally {
|
|
if (this.foregroundMutationTails.get(agentId) === tail) {
|
|
this.foregroundMutationTails.delete(agentId);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async replaceAdmittedForegroundTurn(
|
|
agent: ActiveManagedAgent,
|
|
expectedTurnId: string,
|
|
prompt: AgentPromptInput,
|
|
options?: AgentRunOptions,
|
|
): Promise<AsyncGenerator<AgentStreamEvent>> {
|
|
this.assertSteerAdmissionOwnsTurn(agent, expectedTurnId);
|
|
agent.pendingReplacement = true;
|
|
agent.lifecycle = "running";
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
|
|
try {
|
|
await this.cancelAgentRunBefore(agent.id, "replace");
|
|
return this.streamAgent(agent.id, prompt, options);
|
|
} catch (error) {
|
|
const latest = this.agents.get(agent.id);
|
|
if (latest) {
|
|
latest.pendingReplacement = false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async recordAcceptedSteer(
|
|
agent: ActiveManagedAgent,
|
|
prompt: AgentPromptInput,
|
|
clientMessageId: string | undefined,
|
|
expectedTurnId: string,
|
|
providerMessageId: string | undefined,
|
|
): Promise<void> {
|
|
if (!clientMessageId) {
|
|
return;
|
|
}
|
|
this.recordSubmittedPrompt(agent, prompt, clientMessageId, "accepted", {
|
|
messageId: clientMessageId,
|
|
turnId: expectedTurnId,
|
|
...(providerMessageId ? { providerMessageId } : {}),
|
|
});
|
|
this.emitState(agent);
|
|
}
|
|
|
|
async waitForAgentRunStart(agentId: string, options?: WaitForAgentStartOptions): Promise<void> {
|
|
const snapshot = this.getAgent(agentId);
|
|
if (!snapshot) {
|
|
throw new Error(`Agent ${agentId} not found`);
|
|
}
|
|
|
|
const pendingRun = this.runs.getPendingRun(agentId);
|
|
if (
|
|
(snapshot.lifecycle === "running" || pendingRun?.start.status === "started") &&
|
|
!snapshot.pendingReplacement
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (!snapshot.activeForegroundTurnId && !pendingRun && !snapshot.pendingReplacement) {
|
|
throw new Error(`Agent ${agentId} has no pending run`);
|
|
}
|
|
|
|
if (options?.signal?.aborted) {
|
|
throw createAbortError(options.signal, "wait_for_agent_start aborted");
|
|
}
|
|
|
|
await new Promise<void>((resolvePromise, reject) => {
|
|
if (options?.signal?.aborted) {
|
|
reject(createAbortError(options.signal, "wait_for_agent_start aborted"));
|
|
return;
|
|
}
|
|
|
|
let unsubscribe: (() => void) | null = null;
|
|
let abortHandler: (() => void) | null = null;
|
|
|
|
const cleanup = () => {
|
|
if (unsubscribe) {
|
|
try {
|
|
unsubscribe();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
unsubscribe = null;
|
|
}
|
|
if (abortHandler && options?.signal) {
|
|
try {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
abortHandler = null;
|
|
}
|
|
};
|
|
|
|
const finishOk = () => {
|
|
cleanup();
|
|
resolvePromise();
|
|
};
|
|
|
|
const finishErr = (error: unknown) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
if (options?.signal) {
|
|
abortHandler = () =>
|
|
finishErr(createAbortError(options.signal, "wait_for_agent_start aborted"));
|
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
const checkCurrentState = () => {
|
|
const current = this.getAgent(agentId);
|
|
if (!current) {
|
|
finishErr(new Error(`Agent ${agentId} not found`));
|
|
return true;
|
|
}
|
|
|
|
const currentPendingRun = this.runs.getPendingRun(agentId);
|
|
if (
|
|
(current.lifecycle === "running" || currentPendingRun?.start.status === "started") &&
|
|
!current.pendingReplacement
|
|
) {
|
|
finishOk();
|
|
return true;
|
|
}
|
|
|
|
if (currentPendingRun?.start.status === "failed") {
|
|
finishErr(new Error(currentPendingRun.start.error));
|
|
return true;
|
|
}
|
|
|
|
if (current.lifecycle === "error" && !currentPendingRun) {
|
|
finishErr(new Error(current.lastError ?? `Agent ${agentId} failed to start`));
|
|
return true;
|
|
}
|
|
|
|
if (!currentPendingRun && !current.activeForegroundTurnId && !current.pendingReplacement) {
|
|
finishErr(new Error(`Agent ${agentId} run finished before starting`));
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
unsubscribe = this.subscribe(
|
|
(event) => {
|
|
if (event.type !== "agent_state" || event.agent.id !== agentId) {
|
|
return;
|
|
}
|
|
checkCurrentState();
|
|
},
|
|
{ agentId, replayState: false },
|
|
);
|
|
|
|
checkCurrentState();
|
|
});
|
|
}
|
|
|
|
async respondToPermission(
|
|
agentId: string,
|
|
requestId: string,
|
|
response: AgentPermissionResponse,
|
|
): Promise<AgentPermissionResult | void> {
|
|
const agent = this.requireAgent(agentId);
|
|
if (agent.inFlightPermissionResponses.has(requestId)) {
|
|
throw new Error("A response to this permission request is already being submitted");
|
|
}
|
|
agent.inFlightPermissionResponses.add(requestId);
|
|
|
|
try {
|
|
const result = await agent.session.respondToPermission(requestId, response);
|
|
agent.pendingPermissions.delete(requestId);
|
|
|
|
try {
|
|
await this.refreshSessionState(agent);
|
|
} catch {
|
|
// Ignore refresh errors - state sync after permission approval is best effort.
|
|
}
|
|
|
|
this.touchUpdatedAt(agent);
|
|
await this.persistSnapshot(agent);
|
|
this.emitState(agent);
|
|
|
|
const bufferedResolution = agent.bufferedPermissionResolutions.get(requestId);
|
|
if (bufferedResolution) {
|
|
agent.bufferedPermissionResolutions.delete(requestId);
|
|
this.dispatchStream(agent.id, bufferedResolution, { timestamp: new Date().toISOString() });
|
|
}
|
|
|
|
return result;
|
|
} finally {
|
|
agent.inFlightPermissionResponses.delete(requestId);
|
|
agent.bufferedPermissionResolutions.delete(requestId);
|
|
}
|
|
}
|
|
|
|
async cancelAgentRun(agentId: string): Promise<AgentRunCancellationResult> {
|
|
return this.runForegroundMutation(agentId, () => this.cancelAgentRunNow(agentId));
|
|
}
|
|
|
|
private async cancelAgentRunNow(agentId: string): Promise<AgentRunCancellationResult> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const run =
|
|
this.runs.getRun(agentId) ??
|
|
(agent.lifecycle === "running" ? this.runs.trackAutonomousRun(agentId, null) : null);
|
|
if (!run) {
|
|
return { status: "not_running" };
|
|
}
|
|
|
|
const interruptAcknowledged = await this.interruptSession(agent.session, agentId);
|
|
const settlement = await this.waitWithTimeout({
|
|
operation: run.settledPromise,
|
|
timeoutMs: interruptAcknowledged
|
|
? INTERRUPT_SESSION_TIMEOUT_MS
|
|
: this.rescueTimeouts.interruptSessionMs,
|
|
});
|
|
|
|
if (!interruptAcknowledged) {
|
|
return { status: settlement === "completed" ? "settled" : "refused" };
|
|
}
|
|
|
|
const runTurnId = this.runs.getTurnId(agentId);
|
|
if (settlement === "timed_out" && runTurnId) {
|
|
this.logger.warn(
|
|
{ agentId, turnId: runTurnId, kind: run.kind },
|
|
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
|
|
);
|
|
await this.dispatchSessionEvent(agent, {
|
|
type: "turn_canceled",
|
|
provider: agent.provider,
|
|
reason: "interrupted",
|
|
turnId: runTurnId,
|
|
});
|
|
await run.settledPromise;
|
|
} else if (settlement === "timed_out" && run.kind === "foreground") {
|
|
this.logger.warn(
|
|
{ agentId, kind: run.kind },
|
|
"cancelAgentRun: acknowledged pending turn still active after timeout, clearing it",
|
|
);
|
|
this.runs.settleForegroundRun(agentId, run.token);
|
|
if (!agent.pendingReplacement) {
|
|
agent.lifecycle = "idle";
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
} else if (settlement === "timed_out" && run.kind === "autonomous") {
|
|
this.logger.warn(
|
|
{ agentId, kind: run.kind },
|
|
"cancelAgentRun: acknowledged turn still active after timeout, force-canceling",
|
|
);
|
|
await this.dispatchSessionEvent(agent, {
|
|
type: "turn_canceled",
|
|
provider: agent.provider,
|
|
reason: "interrupted",
|
|
});
|
|
}
|
|
|
|
if (agent.pendingPermissions.size > 0) {
|
|
this.resolvePendingPermissionsForAgent(agent, agent.provider, undefined, "Interrupted");
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
return { status: "settled" };
|
|
}
|
|
|
|
private async cancelAgentRunBefore(
|
|
agentId: string,
|
|
action: "reload" | "replace" | "rewind",
|
|
): Promise<void> {
|
|
const result = await this.cancelAgentRun(agentId);
|
|
if (result.status === "refused") {
|
|
throw new AgentRunCancellationError(agentId, action);
|
|
}
|
|
}
|
|
|
|
private async interruptSession(session: AgentSession, agentId: string): Promise<boolean> {
|
|
try {
|
|
const result = await this.waitWithTimeout({
|
|
operation: session.interrupt(),
|
|
timeoutMs: this.rescueTimeouts.interruptSessionMs,
|
|
onLateError: (error) => {
|
|
this.logger.warn(
|
|
{ err: error, agentId },
|
|
"Session interrupt failed after timeout during cancel",
|
|
);
|
|
},
|
|
});
|
|
|
|
if (result === "timed_out") {
|
|
this.logger.warn(
|
|
{ agentId, timeoutMs: this.rescueTimeouts.interruptSessionMs },
|
|
"Timed out interrupting session during cancel",
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (error) {
|
|
this.logger.error({ err: error, agentId }, "Failed to interrupt session");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
getPendingPermissions(agentId: string): AgentPermissionRequest[] {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
return Array.from(agent.pendingPermissions.values());
|
|
}
|
|
|
|
private peekPendingPermission(agent: ManagedAgent): AgentPermissionRequest | null {
|
|
const iterator = agent.pendingPermissions.values().next();
|
|
return iterator.done ? null : iterator.value;
|
|
}
|
|
|
|
/**
|
|
* Hydrates the runtime timeline from provider history. No-ops if already hydrated.
|
|
*/
|
|
async hydrateTimelineFromProvider(
|
|
agentId: string,
|
|
options?: HydrateTimelineOptions,
|
|
): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
await this.hydrateTimelineFromLegacyProviderHistory(agent, options);
|
|
}
|
|
|
|
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
|
|
const agent = this.requireSessionAgent(agentId);
|
|
const submittedRow = this.timelineStore
|
|
.getRows(agentId)
|
|
.find(
|
|
(row) =>
|
|
row.item.type === "user_message" &&
|
|
row.item.messageId === messageId &&
|
|
row.item.clientMessageId === messageId,
|
|
);
|
|
if (submittedRow && !submittedRow.providerMessageId) {
|
|
throw new Error("Cannot rewind before the provider acknowledges the submitted prompt");
|
|
}
|
|
const providerMessageId = submittedRow?.providerMessageId ?? messageId;
|
|
|
|
if (this.hasInFlightRun(agentId)) {
|
|
await this.cancelAgentRunBefore(agentId, "rewind");
|
|
}
|
|
|
|
const lock = this.runs.createPendingRun(agentId);
|
|
try {
|
|
this.logger.info(
|
|
{ agentId, provider: agent.provider, messageId, mode },
|
|
"agent.rewind.start",
|
|
);
|
|
await invokeRewindCapability(agent.session, { messageId: providerMessageId, mode });
|
|
if (mode !== "files") {
|
|
await this.hydrateTimelineFromProvider(agentId, {
|
|
force: true,
|
|
broadcast: true,
|
|
broadcastTimeline: false,
|
|
});
|
|
this.dispatch({
|
|
type: "timeline_replacement",
|
|
agentId,
|
|
epoch: this.timelineStore.getEpoch(agentId),
|
|
});
|
|
}
|
|
// Rewind stages provider events under the run lock; publish its final state directly.
|
|
this.refreshSessionPersistence(agent);
|
|
await this.refreshSessionState(agent, { emit: false });
|
|
await this.persistSnapshot(agent);
|
|
this.emitState(agent, { persist: false });
|
|
this.logger.info(
|
|
{ agentId, provider: agent.provider, messageId, mode },
|
|
"agent.rewind.complete",
|
|
);
|
|
} catch (error) {
|
|
// Native rewind can commit a fork before history refresh fails. Recovery
|
|
// must resume that latest handle rather than the abandoned conversation.
|
|
this.refreshSessionPersistence(agent);
|
|
await this.persistSnapshot(agent);
|
|
this.logger.warn(
|
|
{ err: error, agentId, provider: agent.provider, messageId, mode },
|
|
"agent.rewind.failed",
|
|
);
|
|
throw error;
|
|
} finally {
|
|
this.runs.settleForegroundRun(agentId, lock.token);
|
|
}
|
|
}
|
|
|
|
async deleteAgentState(agentId: string): Promise<void> {
|
|
this.discardRetainedAgentState(agentId);
|
|
await this.deleteCommittedTimeline(agentId);
|
|
}
|
|
|
|
notifyAgentRemoved(agent: Pick<StoredAgentRecord, "id" | "workspaceId" | "internal">): void {
|
|
this.dispatch({
|
|
type: "agent_removed",
|
|
agentId: agent.id,
|
|
workspaceId: agent.workspaceId,
|
|
internal: agent.internal === true,
|
|
});
|
|
}
|
|
|
|
async deleteCommittedTimeline(agentId: string): Promise<void> {
|
|
await this.durableTimelineStore?.deleteAgent(agentId);
|
|
}
|
|
|
|
async getLastAssistantMessage(agentId: string): Promise<string | null> {
|
|
if (!this.timelineStore.has(agentId)) {
|
|
return (await this.durableTimelineStore?.getLastAssistantMessage(agentId)) ?? null;
|
|
}
|
|
return await this.getLastAssistantMessageFromStores(agentId);
|
|
}
|
|
|
|
private getLastAssistantMessageFromTimeline(
|
|
timeline: readonly AgentTimelineItem[],
|
|
): string | null {
|
|
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
|
|
}
|
|
|
|
private getLastAssistantMessageSegmentFromTimeline(
|
|
timeline: readonly AgentTimelineItem[],
|
|
): { text: string; startsAtBeginning: boolean } | null {
|
|
// Collect the last contiguous assistant messages (Claude streams chunks)
|
|
const chunks: string[] = [];
|
|
let startsAtBeginning = false;
|
|
for (let i = timeline.length - 1; i >= 0; i--) {
|
|
const item = timeline[i];
|
|
if (item.type !== "assistant_message") {
|
|
if (chunks.length) {
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
chunks.push(item.text);
|
|
startsAtBeginning = i === 0;
|
|
}
|
|
|
|
if (!chunks.length) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
text: chunks.toReversed().join(""),
|
|
startsAtBeginning,
|
|
};
|
|
}
|
|
|
|
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
|
|
const liveTimeline = this.timelineStore.getItems(agentId);
|
|
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
|
|
if (!this.durableTimelineStore) {
|
|
return liveSegment?.text ?? null;
|
|
}
|
|
if (!liveSegment) {
|
|
return await this.durableTimelineStore.getLastAssistantMessage(agentId);
|
|
}
|
|
if (!liveSegment.startsAtBeginning) {
|
|
return liveSegment.text;
|
|
}
|
|
const lastDurableItem = await this.durableTimelineStore.getLastItem(agentId);
|
|
if (lastDurableItem?.type !== "assistant_message") {
|
|
return liveSegment.text;
|
|
}
|
|
const durableMessage = await this.durableTimelineStore.getLastAssistantMessage(agentId);
|
|
return durableMessage ? `${durableMessage}${liveSegment.text}` : liveSegment.text;
|
|
}
|
|
|
|
private async getLastItemFromStores(agentId: string): Promise<AgentTimelineItem | null> {
|
|
const lastLiveItem = this.timelineStore.getLastItem(agentId);
|
|
return lastLiveItem ?? (await this.durableTimelineStore?.getLastItem(agentId)) ?? null;
|
|
}
|
|
|
|
async waitForAgentEvent(
|
|
agentId: string,
|
|
options?: WaitForAgentOptions,
|
|
): Promise<WaitForAgentResult> {
|
|
const snapshot = this.getAgent(agentId);
|
|
if (!snapshot) {
|
|
throw new Error(`Agent ${agentId} not found`);
|
|
}
|
|
|
|
const pendingForegroundRun = this.runs.getPendingRun(agentId);
|
|
const hasForegroundTurn =
|
|
Boolean(snapshot.activeForegroundTurnId) || Boolean(pendingForegroundRun);
|
|
|
|
const immediatePermission = this.peekPendingPermission(snapshot);
|
|
if (immediatePermission) {
|
|
return {
|
|
status: snapshot.lifecycle,
|
|
permission: immediatePermission,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
|
|
const initialStatus = snapshot.lifecycle;
|
|
const initialBusy = isAgentBusy(initialStatus) || hasForegroundTurn;
|
|
const waitForActive = options?.waitForActive ?? false;
|
|
if (!waitForActive && !initialBusy) {
|
|
return {
|
|
status: initialStatus,
|
|
permission: null,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
if (waitForActive && !initialBusy && !hasForegroundTurn) {
|
|
return {
|
|
status: initialStatus,
|
|
permission: null,
|
|
lastMessage: await this.getLastAssistantMessage(agentId),
|
|
};
|
|
}
|
|
|
|
if (options?.signal?.aborted) {
|
|
throw createAbortError(options.signal, "wait_for_agent aborted");
|
|
}
|
|
|
|
return await new Promise<WaitForAgentResult>((resolvePromise, reject) => {
|
|
// Bug #1 Fix: Check abort signal AGAIN inside Promise constructor
|
|
// to avoid race condition between pre-Promise check and abort listener registration
|
|
if (options?.signal?.aborted) {
|
|
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
|
return;
|
|
}
|
|
|
|
let currentStatus: AgentLifecycleStatus = initialStatus;
|
|
let hasStarted =
|
|
isAgentBusy(initialStatus) ||
|
|
Boolean(snapshot.activeForegroundTurnId) ||
|
|
pendingForegroundRun?.start.status === "started";
|
|
let terminalStatusOverride: AgentLifecycleStatus | null = null;
|
|
let finished = false;
|
|
|
|
// Bug #3 Fix: Declare unsubscribe and abortHandler upfront so cleanup can reference them
|
|
let unsubscribe: (() => void) | null = null;
|
|
let abortHandler: (() => void) | null = null;
|
|
|
|
const cleanup = () => {
|
|
// Clean up subscription
|
|
if (unsubscribe) {
|
|
try {
|
|
unsubscribe();
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
unsubscribe = null;
|
|
}
|
|
|
|
// Clean up abort listener
|
|
if (abortHandler && options?.signal) {
|
|
try {
|
|
options.signal.removeEventListener("abort", abortHandler);
|
|
} catch {
|
|
// ignore cleanup errors
|
|
}
|
|
abortHandler = null;
|
|
}
|
|
};
|
|
|
|
const finish = (permission: AgentPermissionRequest | null) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
finished = true;
|
|
cleanup();
|
|
void this.getLastAssistantMessage(agentId)
|
|
.then((lastMessage) => {
|
|
resolvePromise({
|
|
status: currentStatus,
|
|
permission,
|
|
lastMessage,
|
|
});
|
|
return;
|
|
})
|
|
.catch(reject);
|
|
};
|
|
|
|
// Bug #3 Fix: Set up abort handler BEFORE subscription
|
|
// to ensure cleanup handlers exist before callback can fire
|
|
if (options?.signal) {
|
|
abortHandler = () => {
|
|
cleanup();
|
|
reject(createAbortError(options.signal, "wait_for_agent aborted"));
|
|
};
|
|
options.signal.addEventListener("abort", abortHandler, { once: true });
|
|
}
|
|
|
|
// Bug #3 Fix: Now subscribe with cleanup handlers already in place
|
|
// This prevents race condition if callback fires synchronously with replayState: true
|
|
unsubscribe = this.subscribe(
|
|
(event) => {
|
|
if (event.type === "agent_state") {
|
|
currentStatus = event.agent.lifecycle;
|
|
const pending = this.peekPendingPermission(event.agent);
|
|
if (pending) {
|
|
finish(pending);
|
|
return;
|
|
}
|
|
if (isAgentBusy(event.agent.lifecycle)) {
|
|
hasStarted = true;
|
|
return;
|
|
}
|
|
if (!waitForActive || hasStarted) {
|
|
if (terminalStatusOverride) {
|
|
currentStatus = terminalStatusOverride;
|
|
}
|
|
finish(null);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (event.type === "agent_stream") {
|
|
if (event.event.type === "permission_requested") {
|
|
finish(event.event.request);
|
|
return;
|
|
}
|
|
if (event.event.type === "turn_failed") {
|
|
hasStarted = true;
|
|
terminalStatusOverride = "error";
|
|
return;
|
|
}
|
|
if (event.event.type === "turn_completed") {
|
|
hasStarted = true;
|
|
}
|
|
if (event.event.type === "turn_canceled") {
|
|
hasStarted = true;
|
|
}
|
|
}
|
|
},
|
|
{ agentId, replayState: true },
|
|
);
|
|
});
|
|
}
|
|
|
|
private async registerSession(
|
|
session: AgentSession,
|
|
openedConfig: AgentSessionConfig,
|
|
agentId: string,
|
|
options?: {
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
timeline?: AgentTimelineItem[];
|
|
timelineRows?: AgentTimelineRow[];
|
|
timelineNextSeq?: number;
|
|
persistence?: AgentPersistenceHandle;
|
|
historyPrimed?: boolean;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string;
|
|
attention?: AttentionState;
|
|
/**
|
|
* Bringing a known agent back, rather than starting a new one. Its timestamps and
|
|
* attention come from what was already recorded, and installing the session is not
|
|
* activity in it.
|
|
*/
|
|
restoring?: boolean;
|
|
initialTitle?: string | null;
|
|
publishWhenReady?: boolean;
|
|
workspaceId?: string;
|
|
owner?: AgentOwner;
|
|
background?: boolean;
|
|
launchEnv?: Record<string, string>;
|
|
},
|
|
): Promise<ManagedAgent> {
|
|
let registered = false;
|
|
try {
|
|
this.assertAcceptingAgentRegistrations();
|
|
const config = this.withOpenedOptions(openedConfig, session);
|
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
|
if (this.agents.has(resolvedAgentId)) {
|
|
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
|
}
|
|
const initialPersistedTitle = await this.resolveInitialPersistedTitle(
|
|
resolvedAgentId,
|
|
config,
|
|
options?.initialTitle ?? null,
|
|
);
|
|
|
|
const now = new Date();
|
|
const { durableTimelineHasRows } = await this.initializeAgentTimelineForRegister({
|
|
agentId: resolvedAgentId,
|
|
now,
|
|
options,
|
|
});
|
|
|
|
const managed = this.buildManagedAgentForRegister({
|
|
resolvedAgentId,
|
|
session,
|
|
config,
|
|
now,
|
|
durableTimelineHasRows,
|
|
background: options?.background === true,
|
|
options,
|
|
});
|
|
|
|
// Read history before publishing the agent: a provider failure must leave the
|
|
// session unregistered so the registration catch closes it.
|
|
const startupHistory: AgentStreamEvent[] = [];
|
|
if (session.initialTimeline?.length && !managed.historyPrimed) {
|
|
for await (const event of session.streamHistory()) {
|
|
startupHistory.push(limitAgentStreamEventContent(event));
|
|
}
|
|
}
|
|
|
|
this.assertAcceptingAgentRegistrations();
|
|
this.agents.set(resolvedAgentId, managed);
|
|
registered = true;
|
|
// Initialize previousStatus to track transitions
|
|
this.previousStatuses.set(resolvedAgentId, managed.lifecycle);
|
|
if (session.initialTimeline?.length) {
|
|
if (!managed.historyPrimed) {
|
|
// Legacy/imported chats need their existing history before startup rows.
|
|
await this.primeTimelineFromLegacyProviderHistory(managed, false, startupHistory);
|
|
} else {
|
|
for (const entry of buildImportedTimelineRows(session.initialTimeline)) {
|
|
this.recordTimeline(managed.id, entry.item, { timestamp: entry.timestamp });
|
|
}
|
|
}
|
|
this.refreshSessionPersistence(managed);
|
|
}
|
|
await this.refreshRuntimeInfo(managed, { emit: false });
|
|
this.assertAgentRegistrationActive(managed);
|
|
await this.persistSnapshot(managed, {
|
|
title: initialPersistedTitle,
|
|
});
|
|
this.assertAgentRegistrationActive(managed);
|
|
if (!options?.publishWhenReady) {
|
|
this.emitState(managed, { persist: false });
|
|
}
|
|
|
|
await this.refreshSessionState(managed, { emit: false });
|
|
this.assertAgentRegistrationActive(managed);
|
|
managed.lifecycle = "idle";
|
|
// Stamping now over a restored timestamp rewrote the workspace's "last used" in the
|
|
// sidebar every time a chat was reopened, because workspace `statusEnteredAt` is
|
|
// re-derived from persisted agent `updatedAt` on every daemon start.
|
|
if (!options?.restoring) {
|
|
this.touchUpdatedAt(managed);
|
|
}
|
|
await this.persistSnapshot(managed);
|
|
this.assertAgentRegistrationActive(managed);
|
|
this.emitState(managed, { persist: false });
|
|
this.subscribeToSession(managed);
|
|
await this.drainSessionEvents(managed.id);
|
|
this.assertAgentRegistrationActive(managed);
|
|
return { ...managed };
|
|
} catch (error) {
|
|
if (!registered) {
|
|
await this.closeUnregisteredSession(session);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private assertAcceptingAgentRegistrations(): void {
|
|
if (!this.acceptingAgentRegistrations) {
|
|
throw new AgentManagerShuttingDownError();
|
|
}
|
|
}
|
|
|
|
private assertAgentRegistrationActive(agent: ActiveManagedAgent): void {
|
|
if (!this.acceptingAgentRegistrations || this.agents.get(agent.id) !== agent) {
|
|
throw new AgentManagerShuttingDownError();
|
|
}
|
|
}
|
|
|
|
private async closeUnregisteredSession(session: AgentSession): Promise<void> {
|
|
try {
|
|
await session.close();
|
|
} catch (error) {
|
|
this.logger.warn({ err: error }, "Failed to close unregistered agent session");
|
|
}
|
|
}
|
|
|
|
private async requireExternalMcpSupport(
|
|
session: AgentSession,
|
|
launchConfig: AgentSessionConfig,
|
|
launchContext: AgentLaunchContext,
|
|
): Promise<void> {
|
|
if (
|
|
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}).length === 0 ||
|
|
session.capabilities.supportsMcpServers === true
|
|
) {
|
|
return;
|
|
}
|
|
await this.closeUnregisteredSession(session);
|
|
throw this.unsupportedMcpError(launchConfig, launchContext);
|
|
}
|
|
|
|
private unsupportedMcpError(config: AgentSessionConfig, context: AgentLaunchContext): Error {
|
|
const owners = [...new Set(Object.values(context.mcpServerOwners ?? {}))];
|
|
const source = owners.length ? ` (plugin ${owners.join(", ")})` : "";
|
|
return new Error(`Provider '${config.provider}' does not support MCP servers${source}`);
|
|
}
|
|
|
|
private async initializeAgentTimelineForRegister(params: {
|
|
agentId: string;
|
|
now: Date;
|
|
options:
|
|
| {
|
|
timeline?: AgentTimelineItem[];
|
|
timelineRows?: AgentTimelineRow[];
|
|
timelineNextSeq?: number;
|
|
persistence?: AgentPersistenceHandle;
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
}
|
|
| undefined;
|
|
}): Promise<{ durableTimelineHasRows: boolean }> {
|
|
const { agentId, now, options } = params;
|
|
const timelineAlreadyPrimed = this.timelineStore.has(agentId);
|
|
const explicitTimelineSeed = buildExplicitTimelineSeedForRegister(now, options);
|
|
const shouldSeedFromDurable =
|
|
!explicitTimelineSeed && !this.timelineStore.has(agentId) && this.durableTimelineStore;
|
|
const durableTimelineSeed = shouldSeedFromDurable
|
|
? await this.loadCommittedTimelineSeed(agentId, now)
|
|
: null;
|
|
const durableTimelineHasRows =
|
|
timelineAlreadyPrimed ||
|
|
(durableTimelineSeed != null && (durableTimelineSeed.nextSeq ?? 1) > 1);
|
|
const timelineSeed = explicitTimelineSeed ?? durableTimelineSeed;
|
|
if (timelineSeed || !this.timelineStore.has(agentId)) {
|
|
this.timelineStore.initialize(agentId, timelineSeed ?? { timestamp: now.toISOString() });
|
|
}
|
|
if (options?.timelineRows?.length) {
|
|
this.enqueueDurableTimelineBulkInsert(agentId, options.timelineRows);
|
|
}
|
|
return { durableTimelineHasRows };
|
|
}
|
|
|
|
private buildManagedAgentForRegister(params: {
|
|
resolvedAgentId: string;
|
|
session: AgentSession;
|
|
config: AgentSessionConfig;
|
|
now: Date;
|
|
durableTimelineHasRows: boolean;
|
|
background: boolean;
|
|
options:
|
|
| {
|
|
createdAt?: Date;
|
|
updatedAt?: Date;
|
|
lastUserMessageAt?: Date | null;
|
|
labels?: Record<string, string>;
|
|
historyPrimed?: boolean;
|
|
lastUsage?: AgentUsage;
|
|
lastError?: string;
|
|
attention?: AttentionState;
|
|
persistence?: AgentPersistenceHandle;
|
|
workspaceId?: string;
|
|
owner?: AgentOwner;
|
|
launchEnv?: Record<string, string>;
|
|
}
|
|
| undefined;
|
|
}): ActiveManagedAgent {
|
|
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
|
|
return {
|
|
id: resolvedAgentId,
|
|
provider: config.provider,
|
|
cwd: config.cwd,
|
|
workspaceId: options?.workspaceId,
|
|
owner: options?.owner,
|
|
launchEnv: options?.launchEnv,
|
|
session,
|
|
capabilities: session.capabilities,
|
|
config,
|
|
runtimeInfo: undefined,
|
|
lifecycle: "initializing",
|
|
createdAt: options?.createdAt ?? now,
|
|
updatedAt: options?.updatedAt ?? now,
|
|
availableModes: [],
|
|
currentModeId: null,
|
|
pendingPermissions: new Map<string, AgentPermissionRequest>(),
|
|
bufferedPermissionResolutions: new Map(),
|
|
inFlightPermissionResponses: new Set(),
|
|
pendingReplacement: false,
|
|
activeForegroundTurnId: null,
|
|
activeTurnId: null,
|
|
activeTurnStartedAt: null,
|
|
foregroundTurnWaiters: new Set<ForegroundTurnWaiter>(),
|
|
finalizedForegroundTurnIds: new Set<string>(),
|
|
unsubscribeSession: null,
|
|
persistence: normalizePersistenceForConfig(
|
|
options?.persistence ?? session.describePersistence(),
|
|
config,
|
|
),
|
|
historyPrimed: options?.historyPrimed ?? durableTimelineHasRows,
|
|
lastUserMessageAt: options?.lastUserMessageAt ?? null,
|
|
lastUsage: options?.lastUsage,
|
|
lastError: options?.lastError,
|
|
attention: resolveInitialAttention(options?.attention),
|
|
internal: config.internal ?? false,
|
|
background: params.background,
|
|
labels: options?.labels ?? {},
|
|
} as ActiveManagedAgent;
|
|
}
|
|
|
|
private async loadCommittedTimelineSeed(
|
|
agentId: string,
|
|
now: Date,
|
|
): Promise<SeedAgentTimelineOptions> {
|
|
if (!this.durableTimelineStore) {
|
|
return { timestamp: now.toISOString() };
|
|
}
|
|
return {
|
|
nextSeq: (await this.durableTimelineStore.getLatestCommittedSeq(agentId)) + 1,
|
|
timestamp: now.toISOString(),
|
|
};
|
|
}
|
|
|
|
private prepareAgentForClosure(
|
|
agent: LiveManagedAgent,
|
|
cancelReason: string,
|
|
): ManagedAgentClosed {
|
|
this.agentStreamCoalescer.flushAndDiscard(agent.id);
|
|
this.agents.delete(agent.id);
|
|
this.previousStatuses.delete(agent.id);
|
|
if (agent.unsubscribeSession) {
|
|
agent.unsubscribeSession();
|
|
agent.unsubscribeSession = null;
|
|
}
|
|
this.runs.cancelWaiters(agent, (turnId) => ({
|
|
type: "turn_canceled",
|
|
provider: agent.provider,
|
|
reason: cancelReason,
|
|
turnId,
|
|
}));
|
|
this.runs.clearAgentRun(agent.id);
|
|
return {
|
|
...agent,
|
|
lifecycle: "closed",
|
|
session: null,
|
|
activeForegroundTurnId: null,
|
|
activeTurnId: null,
|
|
activeTurnStartedAt: null,
|
|
pendingPermissions: new Map(),
|
|
bufferedPermissionResolutions: new Map(),
|
|
inFlightPermissionResponses: new Set(),
|
|
pendingReplacement: false,
|
|
foregroundTurnWaiters: new Set(),
|
|
finalizedForegroundTurnIds: new Set(),
|
|
unsubscribeSession: null,
|
|
};
|
|
}
|
|
|
|
private discardRetainedAgentState(agentId: string): void {
|
|
this.timelineStore.delete(agentId);
|
|
this.paseoToolPolicies.delete(agentId);
|
|
this.detachPluginTools(agentId);
|
|
for (const event of this.providerSubagents.deleteParent(agentId)) {
|
|
this.dispatch({ type: "provider_subagent", event });
|
|
}
|
|
}
|
|
|
|
private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void {
|
|
this.emitState(agent, options);
|
|
if (!agent.internal) {
|
|
this.pluginLifecycle?.emit("agent.close.after", {
|
|
agent: describeHookAgent({ ...agent, title: agent.config.title }),
|
|
});
|
|
}
|
|
}
|
|
private subscribeToSession(agent: ActiveManagedAgent): void {
|
|
if (agent.unsubscribeSession) {
|
|
return;
|
|
}
|
|
const agentId = agent.id;
|
|
const unsubscribe = agent.session.subscribe((event: AgentStreamEvent) => {
|
|
this.enqueueSessionEvent(agentId, event);
|
|
});
|
|
agent.unsubscribeSession = unsubscribe;
|
|
}
|
|
|
|
private enqueueSessionEvent(agentId: string, event: AgentStreamEvent): void {
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: event.provider,
|
|
sessionId: this.agents.get(agentId)?.persistence?.sessionId ?? undefined,
|
|
turnId: getAgentStreamEventTurnId(event),
|
|
event,
|
|
},
|
|
"agent.manager.enqueue",
|
|
);
|
|
const steerBarrier = this.steerEventBarriers.get(agentId);
|
|
if (steerBarrier) {
|
|
steerBarrier.events.push(event);
|
|
return;
|
|
}
|
|
const pendingRun = this.runs.getPendingRun(agentId);
|
|
if (pendingRun?.start.status === "pending") {
|
|
pendingRun.stagedEvents.push(event);
|
|
return;
|
|
}
|
|
const previous = this.sessionEventTails.get(agentId) ?? Promise.resolve();
|
|
const next = previous
|
|
.catch(() => undefined)
|
|
.then(async () => {
|
|
const current = this.agents.get(agentId);
|
|
if (!current) {
|
|
return;
|
|
}
|
|
if (current.session == null) {
|
|
return;
|
|
}
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: event.provider,
|
|
sessionId: current.persistence?.sessionId ?? undefined,
|
|
turnId: getAgentStreamEventTurnId(event),
|
|
event,
|
|
},
|
|
"agent.manager.dequeue",
|
|
);
|
|
await this.dispatchSessionEvent(current, event);
|
|
return;
|
|
})
|
|
.catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, eventType: event.type },
|
|
"Failed to process session event",
|
|
);
|
|
});
|
|
|
|
this.sessionEventTails.set(agentId, next);
|
|
this.trackBackgroundTask(next);
|
|
void next.finally(() => {
|
|
if (this.sessionEventTails.get(agentId) === next) {
|
|
this.sessionEventTails.delete(agentId);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Provider mutations may synchronously emit config events that are processed through the
|
|
* asynchronous session queue. Apply those events before committing the mutation's explicit
|
|
* manager state so call order remains authoritative.
|
|
*/
|
|
private async drainSessionEvents(agentId: string): Promise<void> {
|
|
while (true) {
|
|
const tail = this.sessionEventTails.get(agentId);
|
|
if (!tail) {
|
|
return;
|
|
}
|
|
await tail;
|
|
if (this.sessionEventTails.get(agentId) === tail) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private async dispatchSessionEvent(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
): Promise<void> {
|
|
if (event.type === "provider_subagent") {
|
|
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
|
this.dispatch({ type: "provider_subagent", event: update });
|
|
return;
|
|
}
|
|
const turnId = getAgentStreamEventTurnId(event);
|
|
const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId);
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
matchingWaiterCount: matchingWaiters.length,
|
|
event,
|
|
},
|
|
"agent.manager.dispatch_session_event",
|
|
);
|
|
|
|
const shouldNotifyWaiters = await this.handleStreamEvent(agent, event);
|
|
|
|
if (!shouldNotifyWaiters) {
|
|
return;
|
|
}
|
|
|
|
this.runs.notifyWaiters(matchingWaiters, event, {
|
|
terminal: isTurnTerminalEvent(event),
|
|
});
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
notifiedWaiterCount: matchingWaiters.length,
|
|
terminal: isTurnTerminalEvent(event),
|
|
event,
|
|
},
|
|
"agent.manager.notify_waiters",
|
|
);
|
|
}
|
|
|
|
private async resolveInitialPersistedTitle(
|
|
agentId: string,
|
|
config: AgentSessionConfig,
|
|
fallbackTitle: string | null,
|
|
): Promise<string | null> {
|
|
const existing = await this.registry?.get(agentId);
|
|
if (existing) {
|
|
return existing.title ?? null;
|
|
}
|
|
const explicitTitle =
|
|
typeof config.title === "string" && config.title.trim().length > 0
|
|
? config.title.trim()
|
|
: null;
|
|
return explicitTitle ?? fallbackTitle;
|
|
}
|
|
|
|
private async persistSnapshot(
|
|
agent: ManagedAgent,
|
|
options?: { title?: string | null; internal?: boolean },
|
|
): Promise<void> {
|
|
if (!this.registry) {
|
|
return;
|
|
}
|
|
// Don't persist internal agents - they're ephemeral system tasks
|
|
if (agent.internal) {
|
|
return;
|
|
}
|
|
await this.registry.applySnapshot(agent, options);
|
|
}
|
|
|
|
private requireRegistry(): AgentStorage {
|
|
if (!this.registry) {
|
|
throw new Error("Agent storage unavailable");
|
|
}
|
|
return this.registry;
|
|
}
|
|
|
|
/**
|
|
* Provider-side mode switches (ACP current_mode_update, in-session
|
|
* commands, permission-driven transitions) must land in config.modeId too —
|
|
* reloadAgentSession and the persisted record derive the resumed session's
|
|
* mode from it, so leaving it stale silently downgrades the mode on resume.
|
|
*/
|
|
private applyObservedMode(agent: ActiveManagedAgent, modeId: string | null): void {
|
|
agent.currentModeId = modeId;
|
|
if (modeId != null) {
|
|
agent.config.modeId = modeId;
|
|
}
|
|
}
|
|
|
|
private async refreshSessionState(
|
|
agent: ActiveManagedAgent,
|
|
options?: { emit?: boolean },
|
|
): Promise<void> {
|
|
try {
|
|
const modes = await agent.session.getAvailableModes();
|
|
agent.availableModes = modes;
|
|
} catch {
|
|
agent.availableModes = [];
|
|
}
|
|
|
|
try {
|
|
this.applyObservedMode(agent, await agent.session.getCurrentMode());
|
|
} catch {
|
|
agent.currentModeId = null;
|
|
}
|
|
|
|
try {
|
|
const pending = agent.session.getPendingPermissions();
|
|
agent.pendingPermissions = new Map(pending.map((request) => [request.id, request]));
|
|
} catch {
|
|
agent.pendingPermissions.clear();
|
|
}
|
|
|
|
this.syncControls(agent);
|
|
await this.refreshRuntimeInfo(agent, options);
|
|
}
|
|
|
|
private async refreshRuntimeInfo(
|
|
agent: ActiveManagedAgent,
|
|
options?: { emit?: boolean },
|
|
): Promise<void> {
|
|
try {
|
|
const newInfo = await agent.session.getRuntimeInfo();
|
|
const changed =
|
|
newInfo.model !== agent.runtimeInfo?.model ||
|
|
newInfo.thinkingOptionId !== agent.runtimeInfo?.thinkingOptionId ||
|
|
newInfo.sessionId !== agent.runtimeInfo?.sessionId ||
|
|
newInfo.modeId !== agent.runtimeInfo?.modeId;
|
|
agent.runtimeInfo = newInfo;
|
|
if (!agent.persistence && newInfo.sessionId) {
|
|
agent.persistence = normalizePersistenceForConfig(
|
|
{ provider: agent.provider, sessionId: newInfo.sessionId },
|
|
agent.config,
|
|
);
|
|
}
|
|
// Emit state if runtimeInfo changed so clients get the updated model
|
|
if (changed && options?.emit !== false) {
|
|
this.emitState(agent);
|
|
}
|
|
} catch {
|
|
// Keep existing runtimeInfo if refresh fails.
|
|
}
|
|
}
|
|
|
|
private async hydrateTimelineFromLegacyProviderHistory(
|
|
agent: ActiveManagedAgent,
|
|
options?: HydrateTimelineOptions,
|
|
): Promise<void> {
|
|
if (agent.historyPrimed && !options?.force) {
|
|
return;
|
|
}
|
|
|
|
const broadcast = options?.broadcast ?? false;
|
|
const broadcastTimeline = options?.broadcastTimeline ?? broadcast;
|
|
|
|
if (options?.force) {
|
|
await this.forceHydrateTimelineFromLegacyProviderHistory(
|
|
agent,
|
|
typeof broadcast === "function" ? broadcast() : broadcast,
|
|
typeof broadcastTimeline === "function" ? broadcastTimeline() : broadcastTimeline,
|
|
);
|
|
return;
|
|
}
|
|
|
|
await this.primeTimelineFromLegacyProviderHistory(agent, broadcast);
|
|
}
|
|
|
|
private async forceHydrateTimelineFromLegacyProviderHistory(
|
|
agent: ActiveManagedAgent,
|
|
broadcast: boolean,
|
|
broadcastTimeline: boolean,
|
|
): Promise<void> {
|
|
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
|
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
|
|
for await (const rawEvent of agent.session.streamHistory()) {
|
|
const event = limitAgentStreamEventContent(rawEvent);
|
|
if (event.type === "timeline") {
|
|
for (const item of projectAgentMessages(event.item)) {
|
|
historyEvents.push({ ...event, item });
|
|
}
|
|
} else if (event.type === "provider_subagent") {
|
|
providerSubagentEvents.push(event);
|
|
}
|
|
}
|
|
|
|
this.agentStreamCoalescer.flushAndDiscard(agent.id);
|
|
await this.deleteCommittedTimeline(agent.id);
|
|
this.timelineStore.delete(agent.id);
|
|
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
|
|
agent.historyPrimed = true;
|
|
|
|
for (const event of this.providerSubagents.deleteParent(agent.id)) {
|
|
if (broadcast) {
|
|
this.dispatch({ type: "provider_subagent", event });
|
|
}
|
|
}
|
|
for (const event of providerSubagentEvents) {
|
|
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
|
if (broadcast) {
|
|
this.dispatch({ type: "provider_subagent", event: update });
|
|
}
|
|
}
|
|
for (const event of historyEvents) {
|
|
const row = this.recordTimeline(
|
|
agent.id,
|
|
event.item,
|
|
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
|
);
|
|
if (broadcastTimeline) {
|
|
this.dispatchStream(agent.id, event, {
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agent.id),
|
|
timestamp: row.timestamp,
|
|
});
|
|
}
|
|
}
|
|
this.touchUpdatedAt(agent);
|
|
this.emitState(agent);
|
|
}
|
|
|
|
private async primeTimelineFromLegacyProviderHistory(
|
|
agent: ActiveManagedAgent,
|
|
broadcast: boolean | (() => boolean),
|
|
history:
|
|
| AsyncIterable<AgentStreamEvent>
|
|
| Iterable<AgentStreamEvent> = agent.session.streamHistory(),
|
|
): Promise<void> {
|
|
const deferredBroadcast = typeof broadcast === "function";
|
|
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
|
|
const historySubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
|
|
agent.historyPrimed = false;
|
|
try {
|
|
// Collect the whole replay before touching either store. A stream that fails
|
|
// halfway then leaves the committed timeline as it was, instead of a partial
|
|
// copy the next attempt would append to.
|
|
for await (const rawEvent of history) {
|
|
const event = limitAgentStreamEventContent(rawEvent);
|
|
if (event.type === "provider_subagent") {
|
|
historySubagentEvents.push(event);
|
|
continue;
|
|
}
|
|
if (event.type !== "timeline") {
|
|
continue;
|
|
}
|
|
for (const item of projectAgentMessages(event.item)) {
|
|
historyEvents.push({ ...event, item });
|
|
}
|
|
}
|
|
} catch (error) {
|
|
this.logger.warn({ err: error, agentId: agent.id }, "Failed to hydrate provider history");
|
|
throw error;
|
|
}
|
|
|
|
// The replay is the timeline, so drop the rows a previous hydration committed.
|
|
// Keeping them would leave getTimelineRows reading one copy per hydration.
|
|
await this.deleteCommittedTimeline(agent.id);
|
|
|
|
const timelineEvents: Array<{
|
|
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
|
row: AgentTimelineRow;
|
|
}> = [];
|
|
const providerSubagentEvents: AgentManagerEvent[] = [];
|
|
for (const event of historySubagentEvents) {
|
|
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
|
|
const managerEvent: AgentManagerEvent = { type: "provider_subagent", event: update };
|
|
if (deferredBroadcast) {
|
|
providerSubagentEvents.push(managerEvent);
|
|
} else if (broadcast) {
|
|
this.dispatch(managerEvent);
|
|
}
|
|
}
|
|
for (const event of historyEvents) {
|
|
const row = this.recordTimeline(
|
|
agent.id,
|
|
event.item,
|
|
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
|
);
|
|
if (deferredBroadcast) {
|
|
timelineEvents.push({ event, row });
|
|
} else if (broadcast) {
|
|
this.dispatchStream(agent.id, event, {
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agent.id),
|
|
timestamp: row.timestamp,
|
|
});
|
|
}
|
|
}
|
|
agent.historyPrimed = true;
|
|
|
|
if (typeof broadcast !== "function" || !broadcast()) {
|
|
return;
|
|
}
|
|
for (const event of providerSubagentEvents) {
|
|
this.dispatch(event);
|
|
}
|
|
for (const { event, row } of timelineEvents) {
|
|
this.dispatchStream(agent.id, event, {
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agent.id),
|
|
timestamp: row.timestamp,
|
|
});
|
|
}
|
|
}
|
|
|
|
private notifyForegroundTurnWaiters(agentId: string, event: AgentStreamEvent): void {
|
|
const turnId = getAgentStreamEventTurnId(event);
|
|
if (turnId == null) {
|
|
return;
|
|
}
|
|
|
|
const agent = this.agents.get(agentId);
|
|
if (!agent) {
|
|
return;
|
|
}
|
|
|
|
this.runs.notifyAgentWaiters(agent, event);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
event,
|
|
},
|
|
"agent.manager.notify_waiters.coalesced",
|
|
);
|
|
}
|
|
|
|
private async handleStreamEvent(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
options?: HandleStreamEventOptions,
|
|
): Promise<boolean> {
|
|
event = limitAgentStreamEventContent(event);
|
|
const identified = attachManagedTurnIdentity(agent, event, options?.fromHistory === true);
|
|
event = identified.event;
|
|
const eventTurnId = identified.turnId;
|
|
const isForegroundEvent = agent.activeForegroundTurnId === eventTurnId;
|
|
this.traceHandleStreamEventStart(agent, event, eventTurnId, isForegroundEvent);
|
|
if (
|
|
eventTurnId &&
|
|
isTurnTerminalEvent(event) &&
|
|
this.runs.hasFinalizedTurn(agent, eventTurnId)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Only update timestamp for live events, not history replay
|
|
if (!options?.fromHistory) {
|
|
this.touchUpdatedAt(agent);
|
|
if (this.agentStreamCoalescer.handle(agent.id, event)) {
|
|
this.traceCoalescerBuffered(agent, event, eventTurnId);
|
|
return false;
|
|
}
|
|
this.agentStreamCoalescer.flushFor(agent.id);
|
|
}
|
|
|
|
let terminalDisposition: ActiveTurnTerminalDisposition = "untracked";
|
|
if (isTurnTerminalEvent(event)) {
|
|
terminalDisposition = this.applyActiveTurnTerminal(
|
|
agent,
|
|
eventTurnId,
|
|
options?.fromHistory === true,
|
|
);
|
|
}
|
|
|
|
const flags: StreamEventFlags = { shouldDispatchEvent: true, shouldNotifyWaiters: true };
|
|
|
|
const dispatchPromise = this.dispatchStreamEventByType({
|
|
agent,
|
|
event,
|
|
options,
|
|
isForegroundEvent,
|
|
eventTurnId,
|
|
terminalDisposition,
|
|
flags,
|
|
});
|
|
if (dispatchPromise) {
|
|
await dispatchPromise;
|
|
}
|
|
|
|
if (!options?.fromHistory) {
|
|
if (isTurnTerminalEvent(event)) {
|
|
this.runs.settleTerminalRun(agent.id, eventTurnId);
|
|
if (isForegroundEvent) {
|
|
this.finalizeForegroundTurn(agent, eventTurnId);
|
|
}
|
|
}
|
|
|
|
if (flags.shouldDispatchEvent) {
|
|
this.dispatchStream(agent.id, event, { timestamp: new Date().toISOString() });
|
|
}
|
|
}
|
|
|
|
this.traceHandleStreamEventEnd(agent, event, eventTurnId, flags);
|
|
|
|
return flags.shouldNotifyWaiters;
|
|
}
|
|
|
|
private traceHandleStreamEventStart(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
turnId: string | undefined,
|
|
isForegroundEvent: boolean,
|
|
): void {
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
isForegroundEvent,
|
|
event,
|
|
},
|
|
"agent.manager.handle_stream_event.start",
|
|
);
|
|
}
|
|
|
|
private traceCoalescerBuffered(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
turnId: string | undefined,
|
|
): void {
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
event,
|
|
},
|
|
"agent.manager.coalescer.buffer",
|
|
);
|
|
}
|
|
|
|
private traceHandleStreamEventEnd(
|
|
agent: ActiveManagedAgent,
|
|
event: AgentStreamEvent,
|
|
turnId: string | undefined,
|
|
flags: StreamEventFlags,
|
|
): void {
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: event.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
shouldDispatchEvent: flags.shouldDispatchEvent,
|
|
shouldNotifyWaiters: flags.shouldNotifyWaiters,
|
|
event,
|
|
},
|
|
"agent.manager.handle_stream_event.end",
|
|
);
|
|
}
|
|
|
|
private onStreamOptionsChanged(params: {
|
|
agent: ActiveManagedAgent;
|
|
patch: ProviderOptionsPatch;
|
|
flags: StreamEventFlags;
|
|
}): void {
|
|
const { agent, patch, flags } = params;
|
|
this.storeOptionsPatch(agent, patch);
|
|
flags.shouldDispatchEvent = false;
|
|
this.emitState(agent);
|
|
}
|
|
|
|
private dispatchStreamEventByType(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: AgentStreamEvent;
|
|
options: HandleStreamEventOptions | undefined;
|
|
isForegroundEvent: boolean;
|
|
eventTurnId: string | undefined;
|
|
terminalDisposition: ActiveTurnTerminalDisposition;
|
|
flags: StreamEventFlags;
|
|
}): Promise<void> | undefined {
|
|
const { agent, event, options, isForegroundEvent, eventTurnId, terminalDisposition, flags } =
|
|
params;
|
|
switch (event.type) {
|
|
case "thread_started":
|
|
this.onStreamThreadStarted(agent);
|
|
return undefined;
|
|
case "usage_updated":
|
|
agent.lastUsage = event.usage;
|
|
this.emitState(agent);
|
|
return undefined;
|
|
case "mode_changed":
|
|
this.applyObservedMode(agent, event.currentModeId);
|
|
agent.availableModes = event.availableModes;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: event.currentModeId };
|
|
}
|
|
flags.shouldDispatchEvent = false;
|
|
this.emitState(agent);
|
|
return undefined;
|
|
case "model_changed":
|
|
agent.runtimeInfo = event.runtimeInfo;
|
|
if (!agent.persistence && event.runtimeInfo.sessionId) {
|
|
agent.persistence = normalizePersistenceForConfig(
|
|
{ provider: agent.provider, sessionId: event.runtimeInfo.sessionId },
|
|
agent.config,
|
|
);
|
|
}
|
|
this.applyObservedMode(agent, event.runtimeInfo.modeId ?? agent.currentModeId);
|
|
flags.shouldDispatchEvent = false;
|
|
this.emitState(agent);
|
|
return undefined;
|
|
case "thinking_option_changed":
|
|
agent.config.thinkingOptionId = event.thinkingOptionId ?? undefined;
|
|
if (agent.runtimeInfo) {
|
|
agent.runtimeInfo = {
|
|
...agent.runtimeInfo,
|
|
thinkingOptionId: event.thinkingOptionId,
|
|
};
|
|
}
|
|
flags.shouldDispatchEvent = false;
|
|
this.emitState(agent);
|
|
return undefined;
|
|
case "options_changed":
|
|
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
|
return undefined;
|
|
case "timeline":
|
|
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
|
case "turn_completed":
|
|
this.onStreamTurnCompleted({
|
|
agent,
|
|
event,
|
|
eventTurnId,
|
|
isForegroundEvent,
|
|
terminalDisposition,
|
|
});
|
|
return undefined;
|
|
case "turn_failed":
|
|
return this.onStreamTurnFailed({
|
|
agent,
|
|
event,
|
|
eventTurnId,
|
|
isForegroundEvent,
|
|
terminalDisposition,
|
|
options,
|
|
});
|
|
case "turn_canceled":
|
|
this.onStreamTurnCanceled({
|
|
agent,
|
|
event,
|
|
eventTurnId,
|
|
isForegroundEvent,
|
|
terminalDisposition,
|
|
options,
|
|
});
|
|
return undefined;
|
|
case "turn_started":
|
|
this.onStreamTurnStarted({ agent, eventTurnId, isForegroundEvent, flags });
|
|
return undefined;
|
|
case "permission_requested":
|
|
this.onStreamPermissionRequested(agent, event);
|
|
return undefined;
|
|
case "permission_resolved":
|
|
this.onStreamPermissionResolved({ agent, event, options, flags });
|
|
return undefined;
|
|
default:
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private onStreamThreadStarted(agent: ActiveManagedAgent): void {
|
|
const previousSessionId = agent.persistence?.sessionId ?? null;
|
|
this.refreshSessionPersistence(agent);
|
|
if (agent.persistence?.sessionId !== previousSessionId) {
|
|
this.emitState(agent);
|
|
}
|
|
void this.refreshRuntimeInfo(agent);
|
|
}
|
|
|
|
private refreshSessionPersistence(agent: ActiveManagedAgent): void {
|
|
const handle = agent.session.describePersistence();
|
|
if (handle) {
|
|
agent.persistence = normalizePersistenceForConfig(handle, agent.config);
|
|
}
|
|
}
|
|
|
|
private async onStreamTimelineEvent(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: Extract<AgentStreamEvent, { type: "timeline" }>;
|
|
options: { fromHistory?: boolean } | undefined;
|
|
flags: StreamEventFlags;
|
|
}): Promise<void> {
|
|
const { agent, event, options, flags } = params;
|
|
|
|
flags.shouldDispatchEvent = false;
|
|
flags.shouldNotifyWaiters = false;
|
|
for (const item of projectAgentMessages(event.item)) {
|
|
if (
|
|
item.type === "user_message" &&
|
|
item.clientMessageId &&
|
|
this.reconcileSubmittedPromptEcho(agent, item, event.turnId)
|
|
)
|
|
continue;
|
|
|
|
if (options?.fromHistory) {
|
|
this.recordTimeline(
|
|
agent.id,
|
|
item,
|
|
event.timestamp ? { timestamp: event.timestamp } : undefined,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
this.recordAndDispatchTimelineItem(agent.id, item, event.provider, event.turnId);
|
|
if (item.type === "user_message") {
|
|
agent.lastUserMessageAt = new Date();
|
|
this.emitState(agent);
|
|
}
|
|
flags.shouldNotifyWaiters = true;
|
|
}
|
|
}
|
|
|
|
private onStreamTurnCompleted(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: Extract<AgentStreamEvent, { type: "turn_completed" }>;
|
|
eventTurnId: string | undefined;
|
|
isForegroundEvent: boolean;
|
|
terminalDisposition: ActiveTurnTerminalDisposition;
|
|
}): void {
|
|
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition } = params;
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: eventTurnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
},
|
|
"agent.manager.turn.completed",
|
|
);
|
|
if (terminalDisposition === "stale") return;
|
|
if (event.usage) {
|
|
agent.lastUsage = { ...agent.lastUsage, ...event.usage };
|
|
}
|
|
// If no usage on turn_completed, keep lastUsage as-is so context window
|
|
// data accumulated during streaming isn't lost when the provider omits
|
|
// it from the completion event.
|
|
agent.lastError = undefined;
|
|
if (
|
|
!isForegroundEvent &&
|
|
!agent.activeForegroundTurnId &&
|
|
agent.lifecycle !== "idle" &&
|
|
!agent.pendingReplacement
|
|
) {
|
|
(agent as ActiveManagedAgent).lifecycle = "idle";
|
|
this.emitState(agent);
|
|
}
|
|
void this.refreshRuntimeInfo(agent);
|
|
}
|
|
|
|
private async onStreamTurnFailed(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: Extract<AgentStreamEvent, { type: "turn_failed" }>;
|
|
eventTurnId: string | undefined;
|
|
isForegroundEvent: boolean;
|
|
terminalDisposition: ActiveTurnTerminalDisposition;
|
|
options: { fromHistory?: boolean } | undefined;
|
|
}): Promise<void> {
|
|
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
|
|
this.logger.warn(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: eventTurnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
error: event.error,
|
|
code: event.code,
|
|
diagnostic: event.diagnostic,
|
|
},
|
|
"handleStreamEvent: turn_failed",
|
|
);
|
|
if (terminalDisposition === "stale") return;
|
|
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
|
agent.lifecycle = "error";
|
|
}
|
|
agent.lastError = event.error;
|
|
await this.appendSystemErrorTimelineMessage(
|
|
agent,
|
|
event.provider,
|
|
this.formatTurnFailedMessage(event),
|
|
options,
|
|
);
|
|
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Turn failed");
|
|
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
|
this.emitState(agent);
|
|
}
|
|
}
|
|
|
|
private onStreamTurnCanceled(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: Extract<AgentStreamEvent, { type: "turn_canceled" }>;
|
|
eventTurnId: string | undefined;
|
|
isForegroundEvent: boolean;
|
|
terminalDisposition: ActiveTurnTerminalDisposition;
|
|
options:
|
|
| {
|
|
fromHistory?: boolean;
|
|
}
|
|
| undefined;
|
|
}): void {
|
|
const { agent, event, eventTurnId, isForegroundEvent, terminalDisposition, options } = params;
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: eventTurnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
eventTurnId,
|
|
},
|
|
"agent.manager.turn.canceled",
|
|
);
|
|
if (terminalDisposition === "stale") return;
|
|
if (!isForegroundEvent && !agent.activeForegroundTurnId && !agent.pendingReplacement) {
|
|
agent.lifecycle = "idle";
|
|
}
|
|
agent.lastError = undefined;
|
|
this.resolvePendingPermissionsForAgent(agent, event.provider, options, "Interrupted");
|
|
if (!isForegroundEvent && !agent.activeForegroundTurnId) {
|
|
this.emitState(agent);
|
|
}
|
|
}
|
|
|
|
private onStreamTurnStarted(params: {
|
|
agent: ActiveManagedAgent;
|
|
eventTurnId: string | undefined;
|
|
isForegroundEvent: boolean;
|
|
flags: StreamEventFlags;
|
|
}): void {
|
|
const { agent, eventTurnId, isForegroundEvent, flags } = params;
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: eventTurnId,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
},
|
|
"agent.manager.turn.started",
|
|
);
|
|
if (isForegroundEvent) {
|
|
flags.shouldDispatchEvent = false;
|
|
flags.shouldNotifyWaiters = false;
|
|
return;
|
|
}
|
|
if (agent.activeForegroundTurnId) {
|
|
flags.shouldDispatchEvent = false;
|
|
flags.shouldNotifyWaiters = false;
|
|
return;
|
|
}
|
|
this.runs.trackAutonomousRun(agent.id, eventTurnId ?? null);
|
|
if (eventTurnId) {
|
|
this.openActiveTurn(agent, eventTurnId, new Date());
|
|
}
|
|
agent.lastError = undefined;
|
|
agent.attention = { requiresAttention: false };
|
|
agent.lifecycle = "running";
|
|
this.emitState(agent);
|
|
}
|
|
|
|
private onStreamPermissionRequested(
|
|
agent: ActiveManagedAgent,
|
|
event: Extract<AgentStreamEvent, { type: "permission_requested" }>,
|
|
): void {
|
|
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
|
agent.pendingPermissions.set(event.request.id, event.request);
|
|
this.refreshSessionPersistence(agent);
|
|
if (!hadPendingPermissions && raisesAttention(agent)) {
|
|
this.broadcastAgentAttention(agent, "permission");
|
|
}
|
|
this.emitState(agent);
|
|
}
|
|
|
|
private onStreamPermissionResolved(params: {
|
|
agent: ActiveManagedAgent;
|
|
event: Extract<AgentStreamEvent, { type: "permission_resolved" }>;
|
|
options: { fromHistory?: boolean } | undefined;
|
|
flags: StreamEventFlags;
|
|
}): void {
|
|
const { agent, event, options, flags } = params;
|
|
agent.pendingPermissions.delete(event.requestId);
|
|
this.refreshSessionPersistence(agent);
|
|
if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) {
|
|
agent.bufferedPermissionResolutions.set(event.requestId, event);
|
|
flags.shouldDispatchEvent = false;
|
|
return;
|
|
}
|
|
this.emitState(agent);
|
|
}
|
|
|
|
private resolvePendingPermissionsForAgent(
|
|
agent: ActiveManagedAgent,
|
|
provider: AgentProvider,
|
|
options: { fromHistory?: boolean } | undefined,
|
|
message: string,
|
|
): void {
|
|
for (const [requestId] of agent.pendingPermissions) {
|
|
agent.pendingPermissions.delete(requestId);
|
|
if (!options?.fromHistory) {
|
|
this.dispatchStream(agent.id, {
|
|
type: "permission_resolved",
|
|
provider,
|
|
requestId,
|
|
resolution: { behavior: "deny", message },
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
private recordAndDispatchTimelineItem(
|
|
agentId: string,
|
|
item: AgentTimelineItem,
|
|
provider: AgentProvider,
|
|
turnId?: string,
|
|
options?: { providerMessageId?: string },
|
|
): AgentStreamEvent {
|
|
const row = this.recordTimeline(agentId, item, { ...options, turnId });
|
|
const event: AgentStreamEvent = {
|
|
type: "timeline",
|
|
item,
|
|
provider,
|
|
...(turnId !== undefined ? { turnId } : {}),
|
|
};
|
|
this.dispatchStream(agentId, event, {
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agentId),
|
|
timestamp: row.timestamp,
|
|
});
|
|
|
|
if (
|
|
item.type === "tool_call" &&
|
|
item.status === "completed" &&
|
|
item.detail?.type === "shell" &&
|
|
commandMayHaveChangedExternalState(item.detail.command)
|
|
) {
|
|
const agent = this.agents.get(agentId);
|
|
if (agent) {
|
|
this.onWorkspaceStateMayHaveChanged?.({ cwd: agent.cwd });
|
|
}
|
|
}
|
|
|
|
return event;
|
|
}
|
|
|
|
private recordSubmittedPrompt(
|
|
agent: ActiveManagedAgent,
|
|
prompt: AgentPromptInput,
|
|
clientMessageId: string,
|
|
outcome: "accepted" | "rejected",
|
|
options?: { messageId?: string; providerMessageId?: string; turnId?: string },
|
|
): void {
|
|
const items = projectAgentMessages({
|
|
type: "user_message",
|
|
text: submittedPromptText(prompt),
|
|
clientMessageId,
|
|
...(options?.messageId ? { messageId: options.messageId } : {}),
|
|
});
|
|
for (const item of items) {
|
|
// Human attempts stay in history on rejection; delivery notifications require acceptance.
|
|
if (outcome === "rejected" && item.type !== "user_message") continue;
|
|
this.recordSubmittedPromptItem(agent, item, options);
|
|
}
|
|
}
|
|
|
|
private recordSubmittedPromptItem(
|
|
agent: ActiveManagedAgent,
|
|
item: AgentTimelineItem,
|
|
options?: { providerMessageId?: string; turnId?: string },
|
|
): void {
|
|
if (
|
|
item.type === "user_message" &&
|
|
item.clientMessageId &&
|
|
this.timelineStore.getSubmittedUserMessage(agent.id, item.clientMessageId)
|
|
)
|
|
return;
|
|
this.touchUpdatedAt(agent);
|
|
if (item.type === "user_message") agent.lastUserMessageAt = new Date();
|
|
this.recordAndDispatchTimelineItem(agent.id, item, agent.provider, options?.turnId, options);
|
|
}
|
|
|
|
private reconcileSubmittedPromptEcho(
|
|
agent: ActiveManagedAgent,
|
|
item: Extract<AgentTimelineItem, { type: "user_message" }>,
|
|
turnId?: string,
|
|
): AgentTimelineRow | null {
|
|
const { clientMessageId, messageId } = item;
|
|
if (!clientMessageId) return null;
|
|
let existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
|
if (!existing) {
|
|
this.recordSubmittedPromptItem(
|
|
agent,
|
|
{ ...item, messageId: clientMessageId },
|
|
{
|
|
...(messageId ? { providerMessageId: messageId } : {}),
|
|
...(turnId ? { turnId } : {}),
|
|
},
|
|
);
|
|
existing = this.timelineStore.getSubmittedUserMessage(agent.id, clientMessageId);
|
|
}
|
|
if (!existing || existing.item.type !== "user_message") return null;
|
|
if (messageId) {
|
|
const enriched = this.timelineStore.enrichSubmittedUserMessage(
|
|
agent.id,
|
|
clientMessageId,
|
|
messageId,
|
|
);
|
|
if (enriched) this.enqueueDurableTimelineUpdate(agent.id, enriched);
|
|
}
|
|
return existing;
|
|
}
|
|
|
|
private async appendSystemErrorTimelineMessage(
|
|
agent: ActiveManagedAgent,
|
|
provider: AgentProvider,
|
|
message: string,
|
|
options?: { fromHistory?: boolean },
|
|
): Promise<void> {
|
|
if (options?.fromHistory) {
|
|
return;
|
|
}
|
|
|
|
const normalized = message.trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
const text = `${SYSTEM_ERROR_PREFIX} ${normalized}`;
|
|
const lastItem = await this.getLastItemFromStores(agent.id);
|
|
if (lastItem?.type === "assistant_message" && lastItem.text === text) {
|
|
return;
|
|
}
|
|
|
|
const item: AgentTimelineItem = { type: "assistant_message", text };
|
|
const row = this.recordTimeline(agent.id, item);
|
|
this.dispatchStream(
|
|
agent.id,
|
|
{
|
|
type: "timeline",
|
|
item,
|
|
provider,
|
|
},
|
|
{
|
|
seq: row.seq,
|
|
epoch: this.timelineStore.getEpoch(agent.id),
|
|
timestamp: row.timestamp,
|
|
},
|
|
);
|
|
}
|
|
|
|
private formatTurnFailedMessage(
|
|
event: Extract<AgentStreamEvent, { type: "turn_failed" }>,
|
|
): string {
|
|
const base = event.error.trim();
|
|
const parts = [base.length > 0 ? base : "Provider run failed"];
|
|
const code = event.code?.trim();
|
|
if (code) {
|
|
parts.push(`code: ${code}`);
|
|
}
|
|
const diagnostic = event.diagnostic?.trim();
|
|
if (diagnostic && diagnostic !== base) {
|
|
parts.push(diagnostic);
|
|
}
|
|
return parts.join("\n\n");
|
|
}
|
|
|
|
private recordTimeline(
|
|
agentId: string,
|
|
item: AgentTimelineItem,
|
|
options?: {
|
|
timestamp?: string;
|
|
providerMessageId?: string;
|
|
turnId?: string;
|
|
},
|
|
): AgentTimelineRow {
|
|
item = limitAgentTimelineItemContent(item);
|
|
const row = this.timelineStore.append(agentId, item, options);
|
|
this.enqueueDurableTimelineAppend(agentId, row);
|
|
return row;
|
|
}
|
|
|
|
private emitState(agent: ManagedAgent, options?: { persist?: boolean }): void {
|
|
// Keep attention as an edge-triggered unread signal, not a level signal.
|
|
this.checkAndSetAttention(agent);
|
|
if (options?.persist !== false) {
|
|
this.enqueueBackgroundPersist(agent);
|
|
}
|
|
|
|
this.syncControls(agent);
|
|
|
|
this.logger.trace(
|
|
{
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
sessionId: agent.persistence?.sessionId ?? undefined,
|
|
turnId: agent.activeForegroundTurnId ?? undefined,
|
|
lifecycle: agent.lifecycle,
|
|
activeForegroundTurnId: agent.activeForegroundTurnId,
|
|
pendingPermissions: agent.pendingPermissions.size,
|
|
persist: options?.persist !== false,
|
|
},
|
|
"agent.manager.emit_state",
|
|
);
|
|
|
|
this.dispatch({
|
|
type: "agent_state",
|
|
agent: { ...agent },
|
|
});
|
|
}
|
|
|
|
private syncControls(agent: ManagedAgent): void {
|
|
if ("session" in agent && agent.session?.controls) {
|
|
agent.controls = agent.session.controls;
|
|
}
|
|
agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
|
|
}
|
|
|
|
private checkAndSetAttention(agent: ManagedAgent): void {
|
|
const previousStatus = this.previousStatuses.get(agent.id);
|
|
const currentStatus = agent.lifecycle;
|
|
|
|
// Track the new status
|
|
this.previousStatuses.set(agent.id, currentStatus);
|
|
|
|
if (!raisesAttention(agent)) {
|
|
return;
|
|
}
|
|
|
|
// Skip if already requires attention
|
|
if (agent.attention.requiresAttention) {
|
|
return;
|
|
}
|
|
|
|
// Check if agent transitioned from running to idle (finished)
|
|
if (previousStatus === "running" && currentStatus === "idle") {
|
|
agent.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "finished",
|
|
attentionTimestamp: new Date(),
|
|
};
|
|
this.broadcastAgentAttention(agent, "finished");
|
|
return;
|
|
}
|
|
|
|
// Check if agent entered error state
|
|
if (previousStatus !== "error" && currentStatus === "error") {
|
|
agent.attention = {
|
|
requiresAttention: true,
|
|
attentionReason: "error",
|
|
attentionTimestamp: new Date(),
|
|
};
|
|
this.broadcastAgentAttention(agent, "error");
|
|
return;
|
|
}
|
|
}
|
|
|
|
private enqueueBackgroundPersist(agent: ManagedAgent): void {
|
|
const task = this.persistSnapshot(agent).catch((err) => {
|
|
this.logger.error({ err, agentId: agent.id }, "Failed to persist agent snapshot");
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private enqueueDurableTimelineAppend(agentId: string, row: AgentTimelineRow): void {
|
|
if (!this.durableTimelineStore) {
|
|
return;
|
|
}
|
|
const task = this.durableTimelineStore.bulkInsert(agentId, [row]).catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
|
"Failed to append timeline row to durable store",
|
|
);
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private enqueueDurableTimelineBulkInsert(
|
|
agentId: string,
|
|
rows: readonly AgentTimelineRow[],
|
|
): void {
|
|
if (!this.durableTimelineStore || rows.length === 0) {
|
|
return;
|
|
}
|
|
const task = this.durableTimelineStore.bulkInsert(agentId, rows).catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, rowCount: rows.length },
|
|
"Failed to seed durable timeline store",
|
|
);
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private enqueueDurableTimelineUpdate(agentId: string, row: AgentTimelineRow): void {
|
|
if (!this.durableTimelineStore) return;
|
|
const task = this.durableTimelineStore.updateCommittedRow(agentId, row).catch((err) => {
|
|
this.logger.error(
|
|
{ err, agentId, seq: row.seq, itemType: row.item.type },
|
|
"Failed to enrich durable timeline row",
|
|
);
|
|
});
|
|
this.trackBackgroundTask(task);
|
|
}
|
|
|
|
private trackBackgroundTask(task: Promise<void>): void {
|
|
this.backgroundTasks.add(task);
|
|
void task.finally(() => {
|
|
this.backgroundTasks.delete(task);
|
|
});
|
|
}
|
|
|
|
private trackAgentRegistrationOperation<T>(result: Promise<T>): Promise<T> {
|
|
const settled = result.then(
|
|
() => undefined,
|
|
() => undefined,
|
|
);
|
|
this.agentRegistrationTasks.add(settled);
|
|
void settled.then(() => {
|
|
this.agentRegistrationTasks.delete(settled);
|
|
return undefined;
|
|
});
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Flush any background persistence work (best-effort).
|
|
*/
|
|
async flush(): Promise<void> {
|
|
await this.flushTasks({ includeAgentRegistrations: false });
|
|
}
|
|
|
|
/**
|
|
* Flush persistence and agent registrations that crossed the synchronous
|
|
* shutdown barrier. Those registrations own provider sessions until they
|
|
* either install them or close them.
|
|
*/
|
|
async flushForShutdown(): Promise<void> {
|
|
await this.flushTasks({ includeAgentRegistrations: true });
|
|
}
|
|
|
|
private async flushTasks(options: { includeAgentRegistrations: boolean }): Promise<void> {
|
|
this.agentStreamCoalescer.flushAll();
|
|
// Drain tasks, including tasks spawned while awaiting.
|
|
while (
|
|
this.backgroundTasks.size > 0 ||
|
|
(options.includeAgentRegistrations && this.agentRegistrationTasks.size > 0)
|
|
) {
|
|
const pending = options.includeAgentRegistrations
|
|
? [...this.backgroundTasks, ...this.agentRegistrationTasks]
|
|
: [...this.backgroundTasks];
|
|
await Promise.allSettled(pending);
|
|
}
|
|
}
|
|
|
|
private broadcastAgentAttention(
|
|
agent: ManagedAgent,
|
|
reason: "finished" | "error" | "permission",
|
|
): void {
|
|
if (isDelegatedAgent(agent)) {
|
|
return;
|
|
}
|
|
|
|
this.onAgentAttention?.({
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
private dispatchStream(
|
|
agentId: string,
|
|
event: AgentStreamEvent,
|
|
metadata?: {
|
|
seq?: number;
|
|
epoch?: string;
|
|
timestamp?: string;
|
|
},
|
|
): void {
|
|
if (event.type === "timeline") {
|
|
event = {
|
|
...event,
|
|
item: limitAgentTimelineItemContent(event.item),
|
|
};
|
|
}
|
|
const agent = this.agents.get(agentId);
|
|
this.logger.trace(
|
|
{
|
|
agentId,
|
|
provider: event.provider,
|
|
sessionId: agent?.persistence?.sessionId ?? undefined,
|
|
turnId: getAgentStreamEventTurnId(event),
|
|
metadata,
|
|
event,
|
|
},
|
|
"agent.manager.dispatch_stream",
|
|
);
|
|
this.dispatch({ type: "agent_stream", agentId, event, ...metadata });
|
|
if (this.pluginLifecycle && agent && !agent.internal && event.type !== "timeline") {
|
|
publishAgentStream(
|
|
this.pluginLifecycle,
|
|
describeHookAgent({ ...agent, title: agent.config.title }),
|
|
event,
|
|
);
|
|
}
|
|
}
|
|
|
|
private dispatch(event: AgentManagerEvent): void {
|
|
for (const subscriber of this.subscribers) {
|
|
if (
|
|
subscriber.agentId &&
|
|
(event.type === "agent_stream" || event.type === "agent_removed") &&
|
|
subscriber.agentId !== event.agentId
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
subscriber.agentId &&
|
|
event.type === "agent_state" &&
|
|
subscriber.agentId !== event.agent.id
|
|
) {
|
|
continue;
|
|
}
|
|
if (
|
|
subscriber.agentId &&
|
|
event.type === "provider_subagent" &&
|
|
subscriber.agentId !==
|
|
(event.event.type === "upsert"
|
|
? event.event.subagent.parentAgentId
|
|
: event.event.parentAgentId)
|
|
) {
|
|
continue;
|
|
}
|
|
// Global subscribers (no agentId) skip internal agents unless they opted in.
|
|
if (
|
|
!subscriber.agentId &&
|
|
!subscriber.includeInternal &&
|
|
this.eventBelongsToInternalAgent(event)
|
|
) {
|
|
continue;
|
|
}
|
|
subscriber.callback(event);
|
|
}
|
|
}
|
|
|
|
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
|
|
if (event.type === "agent_state") return event.agent.internal === true;
|
|
if (event.type === "agent_removed") return event.internal;
|
|
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
|
|
if (event.type !== "provider_subagent") return false;
|
|
const parentAgentId =
|
|
event.event.type === "upsert"
|
|
? event.event.subagent.parentAgentId
|
|
: event.event.parentAgentId;
|
|
return this.agents.get(parentAgentId)?.internal === true;
|
|
}
|
|
|
|
private async normalizeConfig(
|
|
config: AgentSessionConfig,
|
|
options: NormalizeConfigOptions = {},
|
|
): Promise<AgentSessionConfig> {
|
|
const normalized: AgentSessionConfig = { ...config };
|
|
|
|
// Always resolve cwd to absolute path for consistent history file lookup
|
|
if (normalized.cwd) {
|
|
normalized.cwd = resolve(normalized.cwd);
|
|
// Only a session that will run in the directory needs it to still be there. Reading
|
|
// an archived agent's history runs nothing, and must survive the worktree it ran in
|
|
// being removed when its workspace was archived.
|
|
if (options.purpose !== "history") {
|
|
await assertUsableWorkingDirectory(normalized.cwd);
|
|
}
|
|
}
|
|
|
|
if (typeof normalized.model === "string") {
|
|
const trimmed = normalized.model.trim();
|
|
normalized.model = trimmed.length > 0 && trimmed !== "default" ? trimmed : undefined;
|
|
}
|
|
|
|
const shouldResolveDefaultModel = options.resolveDefaultModel ?? true;
|
|
if (shouldResolveDefaultModel && !normalized.model) {
|
|
const defaultModelId = await this.resolveDefaultModelId(normalized);
|
|
if (defaultModelId) {
|
|
normalized.model = defaultModelId;
|
|
}
|
|
}
|
|
|
|
return this.applyProviderConfiguration(normalized);
|
|
}
|
|
|
|
private applyProviderConfiguration(config: AgentSessionConfig): AgentSessionConfig {
|
|
const definition = this.providerDefinitions.get(config.provider);
|
|
this.validateToolPolicyServers(config);
|
|
if (config.toolPolicy && !definition?.applyToolPolicy) {
|
|
throw new Error(
|
|
`Provider '${config.provider}' cannot preapprove exact MCP tools for unattended execution`,
|
|
);
|
|
}
|
|
return definition?.applyToolPolicy
|
|
? definition.applyToolPolicy(config, config.toolPolicy)
|
|
: config;
|
|
}
|
|
|
|
private validateToolPolicyServers(config: AgentSessionConfig): void {
|
|
if (!config.toolPolicy) return;
|
|
const serverNames = new Set(Object.keys(config.mcpServers ?? {}));
|
|
for (const grant of config.toolPolicy.preapproved) {
|
|
if (!serverNames.has(grant.server)) {
|
|
throw new Error(
|
|
`toolPolicy preapproval '${grant.server}.${grant.tool}' requires MCP server '${grant.server}' in the same agent request`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async resolveDefaultModelId(config: AgentSessionConfig): Promise<string | undefined> {
|
|
const client = this.clients.get(config.provider);
|
|
if (!client) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const catalog = await client.fetchCatalog({
|
|
scope: "workspace",
|
|
cwd: config.cwd,
|
|
force: false,
|
|
});
|
|
return (catalog.models.find((model) => model.isDefault) ?? catalog.models[0])?.id;
|
|
} catch {
|
|
// Provider may not support model listing — leave model undefined.
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
private async prepareSessionConfig(
|
|
config: AgentSessionConfig,
|
|
agentId: string,
|
|
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
|
|
): Promise<PreparedSessionConfig> {
|
|
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
|
|
env: options.env,
|
|
purpose: options.purpose,
|
|
});
|
|
const paseoToolPolicy = this.paseoToolsEnabled
|
|
? this.resolvePaseoToolPolicy(storedConfig.provider)
|
|
: { enabled: false };
|
|
const launchConfig = this.applyDaemonAppendSystemPrompt(
|
|
withRuntimePaseoMcpServer({
|
|
config: storedConfig,
|
|
agentId,
|
|
mcpBaseUrl:
|
|
this.paseoToolsEnabled && isPaseoToolPolicyEnabled(paseoToolPolicy)
|
|
? this.mcpBaseUrl
|
|
: null,
|
|
mcpAuthToken: this.mcpAuthToken,
|
|
}),
|
|
);
|
|
return { storedConfig, launchConfig, paseoToolPolicy };
|
|
}
|
|
|
|
private applyDaemonAppendSystemPrompt(config: AgentSessionConfig): AgentSessionConfig {
|
|
const daemonAppendSystemPrompt = this.appendSystemPrompt.trim();
|
|
const next = { ...config };
|
|
delete next.daemonAppendSystemPrompt;
|
|
|
|
return daemonAppendSystemPrompt
|
|
? {
|
|
...next,
|
|
daemonAppendSystemPrompt,
|
|
}
|
|
: next;
|
|
}
|
|
|
|
private async buildLaunchContext(
|
|
agentId: string,
|
|
client: AgentClient,
|
|
cwd: string,
|
|
paseoToolPolicy: ProviderPaseoToolsPolicy | undefined,
|
|
env?: Record<string, string>,
|
|
opening?: {
|
|
reason: PluginSessionOpenRequest["reason"];
|
|
purpose: PluginSessionOpenRequest["purpose"];
|
|
workspaceId?: string | null;
|
|
title?: string | null;
|
|
labels?: Record<string, string>;
|
|
},
|
|
): Promise<AgentLaunchContext> {
|
|
// A plugin changing during this open must still make the resulting session stale.
|
|
const generation = this.sessionOpenGeneration;
|
|
this.detachPluginTools(agentId);
|
|
let mcpServers: AgentSessionConfig["mcpServers"];
|
|
let mcpServerOwners: Record<string, string> | undefined;
|
|
if (this.pluginLifecycle) {
|
|
const request = {
|
|
agentId,
|
|
provider: client.provider,
|
|
cwd,
|
|
workspaceId: opening?.workspaceId ?? null,
|
|
reason: opening?.reason ?? "resume",
|
|
purpose: opening?.purpose ?? "interactive",
|
|
env: { ...env },
|
|
title: opening?.title ?? null,
|
|
labels: opening?.labels ?? {},
|
|
tools: [],
|
|
mcpServers: {},
|
|
};
|
|
const transformed = await this.pluginLifecycle.before("agent.open.before", request);
|
|
env = transformed.env;
|
|
if (request.purpose === "interactive") {
|
|
this.attachedPluginTools.set(agentId, transformed.tools as AttachedAgentTool[]);
|
|
mcpServers = transformed.mcpServers;
|
|
mcpServerOwners = transformed.mcpServerOwners;
|
|
}
|
|
}
|
|
const context: AgentLaunchContext = {
|
|
agentId,
|
|
mcpServers,
|
|
mcpServerOwners,
|
|
env: {
|
|
...env,
|
|
PASEO_AGENT_ID: agentId,
|
|
PASEO_AGENT_CWD: cwd,
|
|
},
|
|
};
|
|
const useNativeTools =
|
|
this.paseoToolsEnabled &&
|
|
isPaseoToolPolicyEnabled(paseoToolPolicy) &&
|
|
client.capabilities.supportsNativePaseoTools;
|
|
if (
|
|
this.paseoToolCatalogFactory &&
|
|
(useNativeTools || this.getPluginTools(agentId).length > 0)
|
|
) {
|
|
try {
|
|
const catalog = await this.paseoToolCatalogFactory({
|
|
callerAgentId: agentId,
|
|
paseoToolPolicy,
|
|
});
|
|
if (useNativeTools) context.paseoTools = catalog;
|
|
} catch (error) {
|
|
this.detachPluginTools(agentId);
|
|
throw error;
|
|
}
|
|
}
|
|
this.sessionOpenGenerations.set(agentId, generation);
|
|
return context;
|
|
}
|
|
|
|
private resolveProviderLaunchConfig(
|
|
launchConfig: AgentSessionConfig,
|
|
launchContext: AgentLaunchContext,
|
|
): AgentSessionConfig {
|
|
const savedServerNames = new Set(
|
|
Object.keys(stripInternalPaseoMcpServer(launchConfig).mcpServers ?? {}),
|
|
);
|
|
for (const name of Object.keys(launchContext.mcpServers ?? {})) {
|
|
const owner = launchContext.mcpServerOwners?.[name];
|
|
if (name === PASEO_MCP_SERVER_NAME || savedServerNames.has(name)) {
|
|
const existingOwner = savedServerNames.has(name)
|
|
? "agent saved configuration"
|
|
: "Paseo internal MCP server";
|
|
throw new Error(`MCP server '${name}' from plugin ${owner} collides with ${existingOwner}`);
|
|
}
|
|
}
|
|
const merged =
|
|
Object.keys(launchContext.mcpServers ?? {}).length > 0
|
|
? {
|
|
...launchConfig,
|
|
mcpServers: { ...launchConfig.mcpServers, ...launchContext.mcpServers },
|
|
}
|
|
: launchConfig;
|
|
return launchContext.paseoTools ? stripInternalPaseoMcpServer(merged) : merged;
|
|
}
|
|
|
|
private async requireAvailableClient(options: { provider: AgentProvider }): Promise<AgentClient> {
|
|
const client = this.clients.get(options.provider);
|
|
if (!client) {
|
|
const configuredProviders = this.getConfiguredProviderIds();
|
|
throw new Error(
|
|
`Unknown provider '${options.provider}'. Configured providers: ${formatProviderList(
|
|
configuredProviders,
|
|
)}.`,
|
|
);
|
|
}
|
|
|
|
let unavailableReason: string | null = null;
|
|
try {
|
|
const available = await client.isAvailable();
|
|
if (available) {
|
|
return client;
|
|
}
|
|
} catch (error) {
|
|
unavailableReason = error instanceof Error ? error.message : String(error);
|
|
}
|
|
|
|
const availableProviders = (await this.listProviderAvailability())
|
|
.filter((entry) => entry.available)
|
|
.map((entry) => entry.provider);
|
|
const providerList = formatProviderList(availableProviders);
|
|
const reason = unavailableReason ? ` Reason: ${unavailableReason}.` : "";
|
|
throw new Error(
|
|
`Provider '${options.provider}' is not available.${reason} Available providers: ${providerList}. Use one of those providers, or install/configure '${options.provider}'.`,
|
|
);
|
|
}
|
|
|
|
private requireEnabledProvider(provider: AgentProvider): void {
|
|
if (this.providerEnabled.get(provider) === false) {
|
|
throw new Error(`Provider '${provider}' is disabled`);
|
|
}
|
|
}
|
|
|
|
private getConfiguredProviderIds(): AgentProvider[] {
|
|
return Array.from(new Set([...this.providerEnabled.keys(), ...this.clients.keys()]));
|
|
}
|
|
|
|
private requireClient(provider: AgentProvider): AgentClient {
|
|
const client = this.clients.get(provider);
|
|
if (!client) {
|
|
throw new Error(`No client registered for provider '${provider}'`);
|
|
}
|
|
return client;
|
|
}
|
|
|
|
private async syncNativeArchiveState(
|
|
provider: AgentProvider,
|
|
persistence: AgentPersistenceHandle | null | undefined,
|
|
state: "archive" | "restore",
|
|
): Promise<void> {
|
|
if (!persistence) return;
|
|
const client = this.clients.get(provider);
|
|
const sync =
|
|
state === "archive" ? client?.archiveNativeSession : client?.unarchiveNativeSession;
|
|
if (!sync) return;
|
|
if (state === "restore") {
|
|
await sync.call(client, persistence);
|
|
return;
|
|
}
|
|
try {
|
|
await sync.call(client, persistence);
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
{ error, provider, sessionId: persistence.sessionId },
|
|
"Failed to archive native session (best-effort)",
|
|
);
|
|
}
|
|
}
|
|
|
|
private requireAgent(id: string): LiveManagedAgent {
|
|
const normalizedId = validateAgentId(id, "requireAgent");
|
|
const agent = this.agents.get(normalizedId);
|
|
if (!agent) {
|
|
throw new Error(`Unknown agent '${normalizedId}'`);
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
private requireSessionAgent(id: string): ActiveManagedAgent {
|
|
const agent = this.requireAgent(id);
|
|
if (agent.session === null) {
|
|
throw new Error(`Agent '${agent.id}' has no managed session`);
|
|
}
|
|
return agent;
|
|
}
|
|
|
|
private requirePublicAgent(id: string): LiveManagedAgent {
|
|
const agent = this.requireAgent(id);
|
|
if (agent.internal) {
|
|
throw new Error(`Unknown agent '${agent.id}'`);
|
|
}
|
|
return agent;
|
|
}
|
|
}
|
|
|
|
function matchesImportableSessionQuery(
|
|
session: ImportableProviderSession,
|
|
rawQuery: string | undefined,
|
|
): boolean {
|
|
const query = rawQuery?.trim().toLowerCase();
|
|
if (!query) return true;
|
|
const cwdBasename = basename(session.cwd.replaceAll("\\", "/"));
|
|
return [session.title, session.firstPromptPreview, session.lastPromptPreview, cwdBasename].some(
|
|
(value) => value?.toLowerCase().includes(query),
|
|
);
|
|
}
|
|
|
|
export function commandMayHaveChangedExternalState(command: string): boolean {
|
|
const normalized = command.toLowerCase();
|
|
// Commands that operate on remote state and do NOT trigger local file
|
|
// watchers. Local git mutations (commit, checkout, merge, rebase, reset,
|
|
// pull) are already caught by watchers on .git/HEAD and refs/heads/.
|
|
return (
|
|
// GitHub PR operations (merge, close, create, edit, comment, review)
|
|
/\bgh\s+pr\s+(merge|close|create|edit|comment|review)\b/.test(normalized) ||
|
|
// Pushes to remote — local refs unchanged, but remote state (PR checks,
|
|
// mergeable status) may shift immediately after.
|
|
/\bgit\s+push\b/.test(normalized) ||
|
|
// Fetches update refs/remotes/ which our watchers do not watch, so
|
|
// ahead/behind counts can drift stale until the next refresh.
|
|
/\bgit\s+fetch\b/.test(normalized)
|
|
);
|
|
}
|