blob: 337d8eac14f70aff94a0c35adc0fb04609526f8d [file]
import { type SessionEvent, type StoredMessage } from '@maka/core';
import type { CreateSessionInput, UserMessageInput } from '@maka/core';
import type { ExecutionBoundaryReadModel } from '@maka/core';
import type { SessionSummary } from '@maka/core';
import {
readRuntimeHostSessions,
RuntimeHostOperationError,
type RuntimeHostConnection,
} from '@maka/runtime-host/client';
import type { InteractionPendingSnapshot, SessionCatalogItem } from '@maka/runtime-host/protocol';
import {
runMakaTextCliCore,
type MakaRunContext,
type MakaRunContextInput,
type MakaRunDeps,
type MakaRunEnvironmentDeps,
type MakaRunOutcome,
type MakaRunRuntime,
} from './run-command-core.js';
import {
connectRuntimeHostCli,
resolveRuntimeHostCliTarget,
type RuntimeHostCliConnectionContext,
} from './runtime-host-cli-context.js';
import {
createRuntimeHostMakaSessionDriver,
runtimeHostSessionSummary,
type RuntimeHostMakaSessionDriver,
} from './runtime-host-session-driver.js';
import type { MakaPreparedSessionTurn } from './session-driver.js';
import {
formatRuntimeHostCliTaskBlockers,
isRuntimeHostCliTaskBlocked,
readRuntimeHostCliTaskReadiness,
} from './runtime-host-task-readiness.js';
const GRAPH_POLL_INTERVAL_MS = 25;
export interface RuntimeHostRunCommandDeps {
connect(rootPath: string): Promise<RuntimeHostCliConnectionContext>;
createContext(
connection: RuntimeHostConnection,
catalog: RuntimeHostCliConnectionContext['catalog'],
input: Parameters<MakaRunDeps['createContext']>[0],
): MakaRunContext | Promise<MakaRunContext>;
run: typeof runMakaTextCliCore;
}
export interface RuntimeHostRunContextDeps {
createDriver(
input: Parameters<typeof createRuntimeHostMakaSessionDriver>[0],
): RuntimeHostMakaSessionDriver;
}
export async function runRuntimeHostTextCli(
argv: readonly string[],
overrides: Partial<MakaRunEnvironmentDeps> = {},
commandOverrides: Partial<RuntimeHostRunCommandDeps> = {},
): Promise<number> {
const commandDeps = { ...defaultRuntimeHostRunCommandDeps(), ...commandOverrides };
let connected: RuntimeHostCliConnectionContext | undefined;
const connect = async (rootPath: string): Promise<RuntimeHostCliConnectionContext> => {
connected ??= await commandDeps.connect(rootPath);
return connected;
};
try {
return await commandDeps.run(
argv,
{
listSessions: async (rootPath) =>
runtimeHostSessionSummaries(
await readRuntimeHostSessions((await connect(rootPath)).connection),
),
createContext: async (input) => {
const context = await connect(input.workspaceRoot);
await assertRuntimeHostRunReady(context.connection, context.catalog, input);
return commandDeps.createContext(context.connection, context.catalog, input);
},
},
overrides,
);
} finally {
await connected?.close().catch(() => undefined);
}
}
function defaultRuntimeHostRunCommandDeps(): RuntimeHostRunCommandDeps {
return {
connect: (rootPath) => connectRuntimeHostCli({ rootPath, surface: 'run' }),
createContext: createRuntimeHostRunContext,
run: runMakaTextCliCore,
};
}
export function createRuntimeHostRunContext(
connection: RuntimeHostConnection,
catalog: RuntimeHostCliConnectionContext['catalog'],
input: Parameters<MakaRunDeps['createContext']>[0],
overrides: Partial<RuntimeHostRunContextDeps> = {},
): MakaRunContext {
const target = resolveRuntimeHostCliTarget(catalog, {
...(input.requestedConnectionSlug ? { connectionSlug: input.requestedConnectionSlug } : {}),
...(input.requestedModel ? { model: input.requestedModel } : {}),
});
const contextDeps = {
createDriver: createRuntimeHostMakaSessionDriver,
...overrides,
};
const driver = contextDeps.createDriver({
connection,
cwd: input.cwd,
llmConnectionSlug: target.connection.slug,
model: target.model,
permissionMode: 'ask',
});
const runtime = new RuntimeHostRunRuntime(
connection,
driver,
input.runOutcomeObserver,
input.enableAgentGraph === true,
input.sessionCwdOverride,
input.maxSteps,
);
return {
runtime,
target: { connection: { slug: target.connection.slug }, model: target.model },
...(input.enableAgentGraph
? {
agentGraph: {
reserveActivity: () => ({ release: () => {} }),
waitForCompletion: (sessionId: string) => runtime.waitForGraphCompletion(sessionId),
},
}
: {}),
close: async () => runtime.close(),
};
}
async function assertRuntimeHostRunReady(
connection: RuntimeHostConnection,
catalog: RuntimeHostCliConnectionContext['catalog'],
input: Parameters<MakaRunDeps['createContext']>[0],
): Promise<void> {
const snapshot = await readRuntimeHostCliTaskReadiness({
connection,
catalog,
cwd: input.cwd,
...(input.requestedConnectionSlug ? { connectionSlug: input.requestedConnectionSlug } : {}),
...(input.requestedModel ? { model: input.requestedModel } : {}),
});
if (isRuntimeHostCliTaskBlocked(snapshot)) {
throw new Error(`Task is not ready:\n${formatRuntimeHostCliTaskBlockers(snapshot)}`);
}
}
class RuntimeHostRunRuntime implements MakaRunRuntime {
readonly #connection: RuntimeHostConnection;
readonly #driver: RuntimeHostMakaSessionDriver;
readonly #observer: ((outcome: MakaRunOutcome) => void | Promise<void>) | undefined;
readonly #graphEnabled: boolean;
readonly #sessionCwdOverride: MakaRunContextInput['sessionCwdOverride'];
readonly #maxSteps: number | undefined;
readonly #unsubscribeTranscriptReplacements: () => void;
#sessionId: string | undefined;
#activeTurn: { sessionId: string; turnId: string; runId: string } | undefined;
#stopRequested = false;
#closed = false;
readonly #interactions: NonInteractiveInteractionController;
#graphAdmissionTurnIds = new Set<string>();
#latestTranscriptReplacement: StoredMessage[] | undefined;
readonly #graphTerminalWaiters = new Map<
string,
Set<{
resolve(messages: StoredMessage[]): void;
reject(error: Error): void;
timer: ReturnType<typeof setTimeout>;
}>
>();
constructor(
connection: RuntimeHostConnection,
driver: RuntimeHostMakaSessionDriver,
observer: ((outcome: MakaRunOutcome) => void | Promise<void>) | undefined,
graphEnabled: boolean,
sessionCwdOverride: MakaRunContextInput['sessionCwdOverride'],
maxSteps: number | undefined,
) {
this.#connection = connection;
this.#driver = driver;
this.#observer = observer;
this.#graphEnabled = graphEnabled;
this.#sessionCwdOverride = sessionCwdOverride;
this.#maxSteps = maxSteps;
this.#interactions = new NonInteractiveInteractionController(driver, (pending) =>
this.#stopForInteraction(pending),
);
this.#unsubscribeTranscriptReplacements = driver.subscribeTranscriptReplacements(
(_sessionId, _turnId, messages) => this.#acceptGraphTranscript(messages),
);
}
async createSession(input: CreateSessionInput): Promise<SessionSummary> {
const created = await this.#driver.createSession(input);
this.#sessionId = created.id;
return created;
}
async readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel> {
await this.#attach(sessionId);
return this.#connection.request('session.execution_boundary.query', { sessionId });
}
async *sendMessage(sessionId: string, input: UserMessageInput): AsyncIterable<SessionEvent> {
await this.#attach(sessionId);
if (this.#stopRequested) throw new Error('Turn was cancelled before start');
if (input.turnOrchestration?.mode === 'graph') {
this.#graphAdmissionTurnIds = graphSupervisorTurnIds(await this.#driver.readMessages());
}
const maxSteps = input.maxSteps ?? this.#maxSteps;
const turn = await this.#driver.preparePrompt(input.text, {
turnId: input.turnId,
...(input.turnOrchestration ? { turnOrchestration: input.turnOrchestration } : {}),
...(maxSteps !== undefined ? { maxSteps } : {}),
});
if (!turn.runId) throw new Error('Runtime Host did not return a Run identity');
const activeTurn = { sessionId: turn.sessionId, turnId: turn.turnId, runId: turn.runId };
this.#activeTurn = activeTurn;
if (this.#stopRequested) {
await this.#stopTurn(activeTurn);
if (input.turnOrchestration?.mode === 'graph') await this.#stopGraph(sessionId);
}
try {
yield* this.#observeTurn(turn);
} finally {
if (this.#activeTurn === activeTurn) this.#activeTurn = undefined;
}
}
async respondToSandboxBoundary(
sessionId: string,
response: { requestId: string; decision: 'deny' },
): Promise<void> {
await this.#attach(sessionId);
await this.#driver.respondToSandboxBoundary(response);
}
async resumeLatest(sessionId: string): Promise<AsyncIterable<SessionEvent> | null> {
await this.#attach(sessionId);
const plan = await this.#connection.request('turn.resume.query', { sessionId });
return plan.disposition === 'ready' ? this.#driver.resumeLatest() : null;
}
async stopSession(sessionId: string): Promise<void> {
this.#stopRequested = true;
await this.#attach(sessionId);
const stops: Promise<unknown>[] = [
this.#activeTurn ? this.#stopTurn(this.#activeTurn) : this.#driver.stop(),
];
if (this.#graphEnabled) {
stops.push(this.#stopGraph(sessionId));
}
const settled = await Promise.allSettled(stops);
const failure = settled.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
this.#cancelGraphTerminalWaiters(new Error('Agent Graph wait was cancelled'));
if (failure) throw failure.reason;
}
async setExecutionBoundaryKind(sessionId: string, kind: 'managed' | 'bypass'): Promise<void> {
await this.#attach(sessionId);
await this.#driver.setPermissionMode(kind === 'bypass' ? 'bypass' : 'ask');
}
async waitForGraphCompletion(sessionId: string): Promise<void> {
let terminalStatus: 'completed' | 'failed' | 'stopped' | undefined;
for (;;) {
await this.#interactions.settle();
if (this.#stopRequested) throw new Error('Agent Graph wait was cancelled');
try {
const graph = await this.#connection.request('agent.graph.query', {
rootSessionId: sessionId,
});
await this.#interactions.settle();
if (this.#stopRequested) throw new Error('Agent Graph wait was cancelled');
if (graph.status === 'empty' || graph.status === 'completed') {
terminalStatus = 'completed';
break;
}
if (graph.status === 'failed' || graph.status === 'stopped') {
terminalStatus = graph.status;
break;
}
} catch (error) {
if (error instanceof RuntimeHostOperationError && error.code === 'not_found') return;
throw error;
}
await this.#interactions.race(delay(GRAPH_POLL_INTERVAL_MS));
}
if (terminalStatus !== 'completed') {
throw new Error(`Agent Graph ${terminalStatus}`);
}
await this.#interactions.settle();
let messages = await this.#driver.readMessages();
let graphTurnId = lastNewGraphSupervisorTurnId(messages, this.#graphAdmissionTurnIds);
let outcome = graphTurnId ? outcomeFromStoredTurn(messages, graphTurnId) : undefined;
if (graphTurnId && !outcome) {
messages = await this.#waitForGraphTurnTerminal(graphTurnId);
graphTurnId = lastNewGraphSupervisorTurnId(messages, this.#graphAdmissionTurnIds);
outcome = graphTurnId ? outcomeFromStoredTurn(messages, graphTurnId) : undefined;
if (!outcome)
throw new Error('Agent Graph final Turn did not reach a durable terminal boundary');
}
if (outcome) await this.#observer?.(outcome);
}
close(): Promise<void> {
this.#closed = true;
this.#interactions.close();
this.#unsubscribeTranscriptReplacements();
this.#cancelGraphTerminalWaiters(new Error('Runtime Host run context closed'));
return Promise.resolve();
}
async #attach(sessionId: string): Promise<void> {
if (this.#sessionId === sessionId) return;
const switched = await this.#driver.switchSession(sessionId);
if (
this.#sessionCwdOverride?.sessionId === sessionId &&
switched.summary.cwd !== this.#sessionCwdOverride.cwd
) {
const moved = await this.#driver.moveSession(this.#sessionCwdOverride.cwd);
if (moved.cwd !== this.#sessionCwdOverride.cwd) {
throw new Error(
`Runtime Host cannot resume Session ${sessionId}: its working directory could not be canonicalized`,
);
}
}
this.#sessionId = sessionId;
}
async *#observeTurn(turn: MakaPreparedSessionTurn): AsyncIterable<SessionEvent> {
const accumulator = new TurnOutcomeAccumulator(turn.runId ?? turn.turnId);
const events = turn.events[Symbol.asyncIterator]();
for (;;) {
const next = await this.#interactions.race(events.next());
if (next.done) break;
const event = next.value;
if (event.type === 'user_question_request' || event.type === 'sandbox_boundary_request') {
continue;
}
accumulator.accept(event);
yield event;
}
await this.#interactions.settle();
await this.#observer?.(accumulator.finish());
}
async #stopTurn(turn: { sessionId: string; turnId: string; runId: string }): Promise<void> {
await this.#connection.request('turn.stop', turn);
}
async #stopGraph(sessionId: string): Promise<void> {
try {
await this.#connection.request('agent.graph.stop', { rootSessionId: sessionId });
} catch (error) {
if (!(error instanceof RuntimeHostOperationError) || error.code !== 'not_found') throw error;
}
}
#acceptGraphTranscript(messages: StoredMessage[]): void {
const replacement = messages.map((message) => structuredClone(message));
this.#latestTranscriptReplacement = replacement;
for (const [turnId, waiters] of this.#graphTerminalWaiters) {
if (!outcomeFromStoredTurn(replacement, turnId)) continue;
this.#graphTerminalWaiters.delete(turnId);
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.resolve(replacement);
}
}
}
#waitForGraphTurnTerminal(turnId: string): Promise<StoredMessage[]> {
if (this.#closed) return Promise.reject(new Error('Runtime Host run context closed'));
if (this.#stopRequested) return Promise.reject(new Error('Agent Graph wait was cancelled'));
const latest = this.#latestTranscriptReplacement;
if (latest && outcomeFromStoredTurn(latest, turnId)) return Promise.resolve(latest);
return new Promise<StoredMessage[]>((resolve, reject) => {
let waiters = this.#graphTerminalWaiters.get(turnId);
if (!waiters) {
waiters = new Set();
this.#graphTerminalWaiters.set(turnId, waiters);
}
const waiter = {
resolve,
reject,
timer: setTimeout(() => {
waiters?.delete(waiter);
if (waiters?.size === 0) this.#graphTerminalWaiters.delete(turnId);
reject(new Error('Agent Graph final Turn did not reach a durable terminal boundary'));
}, 45_000),
};
waiters.add(waiter);
});
}
async #stopForInteraction(pending: InteractionPendingSnapshot): Promise<void> {
this.#stopRequested = true;
const stops: Promise<unknown>[] = [
this.#stopTurn({
sessionId: pending.sessionId,
turnId: pending.turnId,
runId: pending.runId,
}),
];
if (this.#graphEnabled) stops.push(this.#stopGraph(pending.sessionId));
const settled = await Promise.allSettled(stops);
const failure = settled.find(
(result): result is PromiseRejectedResult => result.status === 'rejected',
);
if (failure) throw failure.reason;
}
#cancelGraphTerminalWaiters(error: Error): void {
for (const waiters of this.#graphTerminalWaiters.values()) {
for (const waiter of waiters) {
clearTimeout(waiter.timer);
waiter.reject(error);
}
}
this.#graphTerminalWaiters.clear();
}
}
function runtimeHostSessionSummaries(items: readonly SessionCatalogItem[]): SessionSummary[] {
return items.flatMap((item) => ('kind' in item ? [] : [runtimeHostSessionSummary(item)]));
}
class TurnOutcomeAccumulator {
readonly #outcomeId: string;
#finalOutput: string | undefined;
#failure: { class: string; message: string } | undefined;
#completed = false;
#unresolvedBoundary = false;
#recoveredBoundary = false;
constructor(outcomeId: string) {
this.#outcomeId = outcomeId;
}
accept(event: SessionEvent): void {
if (event.type === 'text_complete' && event.text.trim().length > 0) {
this.#finalOutput = event.text;
} else if (event.type === 'error') {
this.#failure = { class: event.reason ?? 'runtime_error', message: event.message };
} else if (event.type === 'abort') {
this.#failure = { class: 'aborted', message: 'Turn was cancelled' };
} else if (event.type === 'complete') {
this.#completed = true;
}
if (event.type !== 'tool_result') return;
if (event.isError && event.content.kind === 'text' && event.content.sandboxFailure) {
this.#unresolvedBoundary = true;
return;
}
if (!event.isError && this.#unresolvedBoundary) {
this.#unresolvedBoundary = false;
this.#recoveredBoundary = true;
}
}
finish(): MakaRunOutcome {
const completed = this.#completed && !this.#failure;
return {
outcomeId: this.#outcomeId,
status: completed ? 'completed' : 'failed',
...(completed && this.#finalOutput !== undefined ? { finalOutput: this.#finalOutput } : {}),
...(!completed
? {
failure: this.#failure ?? {
class: 'missing_terminal_event',
message: 'Turn ended unexpectedly',
},
}
: {}),
sandboxBoundary: this.#unresolvedBoundary
? 'unresolved'
: this.#recoveredBoundary
? 'recovered'
: 'none',
};
}
}
function graphSupervisorTurnIds(messages: readonly StoredMessage[]): Set<string> {
return new Set(
messages.flatMap((message) =>
message.type === 'user' && message.origin?.kind === 'agent_graph' ? [message.turnId] : [],
),
);
}
function lastNewGraphSupervisorTurnId(
messages: readonly StoredMessage[],
admissionTurnIds: ReadonlySet<string>,
): string | undefined {
return [...messages]
.reverse()
.find(
(message) =>
message.type === 'user' &&
message.origin?.kind === 'agent_graph' &&
!admissionTurnIds.has(message.turnId),
)?.turnId;
}
function outcomeFromStoredTurn(
messages: readonly StoredMessage[],
turnId: string,
): MakaRunOutcome | undefined {
const turnMessages = messages.filter((message) => message.turnId === turnId);
const finalOutput = [...turnMessages]
.reverse()
.find(
(message): message is Extract<StoredMessage, { type: 'assistant' }> =>
message.type === 'assistant' && message.text.trim().length > 0,
)?.text;
const storedTerminal = [...turnMessages]
.reverse()
.find(
(message): message is Extract<StoredMessage, { type: 'turn_state' }> =>
message.type === 'turn_state' && message.status !== 'running',
);
const status = storedTerminal?.status;
if (!status) return undefined;
const completed = status === 'completed';
return {
outcomeId: turnId,
status: completed ? 'completed' : 'failed',
...(completed && finalOutput !== undefined ? { finalOutput } : {}),
...(!completed
? {
failure: {
class: storedTerminal?.errorClass ?? storedTerminal?.abortSource ?? status,
message: status === 'aborted' ? 'Turn was cancelled' : 'Agent Graph final Turn failed',
},
}
: {}),
sandboxBoundary: storedSandboxBoundaryOutcome(turnMessages),
};
}
class NonInteractiveInteractionController {
readonly #driver: RuntimeHostMakaSessionDriver;
readonly #stop: (pending: InteractionPendingSnapshot) => Promise<void>;
readonly #handled = new Set<string>();
readonly #tasks = new Set<Promise<void>>();
readonly #unsubscribe: () => void;
#failure: Error | undefined;
readonly #failureSignal: Promise<Error>;
#publishFailure!: (error: Error) => void;
constructor(
driver: RuntimeHostMakaSessionDriver,
stop: (pending: InteractionPendingSnapshot) => Promise<void>,
) {
this.#driver = driver;
this.#stop = stop;
this.#failureSignal = new Promise((resolve) => {
this.#publishFailure = resolve;
});
this.#unsubscribe = driver.subscribePendingInteractions((pending) => this.#accept(pending));
}
race<T>(operation: Promise<T>): Promise<T> {
this.throwIfFailed();
return Promise.race([operation, this.#failureSignal.then((error) => Promise.reject(error))]);
}
async settle(): Promise<void> {
await Promise.all([...this.#tasks]);
this.throwIfFailed();
}
throwIfFailed(): void {
if (this.#failure) throw this.#failure;
}
close(): void {
this.#unsubscribe();
}
#accept(pending: InteractionPendingSnapshot): void {
if (this.#handled.has(pending.interactionId)) return;
this.#handled.add(pending.interactionId);
const task = this.#handle(pending).catch((error) => {
this.#fail(error instanceof Error ? error : new Error(String(error)));
});
this.#tasks.add(task);
void task.finally(() => this.#tasks.delete(task));
}
async #handle(pending: InteractionPendingSnapshot): Promise<void> {
if (pending.request.kind === 'sandbox_boundary') {
await this.#driver.respondToSandboxBoundary({
requestId: pending.interactionId,
decision: 'deny',
});
return;
}
await this.#stop(pending);
throw new Error(
pending.request.kind === 'question'
? 'interactive user questions are unavailable in non-interactive mode'
: 'interactive permission requests are unavailable in non-interactive mode',
);
}
#fail(error: Error): void {
if (this.#failure) return;
this.#failure = error;
this.#publishFailure(error);
}
}
function storedSandboxBoundaryOutcome(
messages: readonly StoredMessage[],
): MakaRunOutcome['sandboxBoundary'] {
let unresolved = false;
let recovered = false;
for (const message of messages) {
if (
message.type === 'tool_result' &&
message.isError &&
message.content.kind === 'text' &&
message.content.sandboxFailure
) {
unresolved = true;
} else if (message.type === 'tool_result' && !message.isError && unresolved) {
unresolved = false;
recovered = true;
}
}
return unresolved ? 'unresolved' : recovered ? 'recovered' : 'none';
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}