blob: 72e8b9766bd3480b81cf10d0655b0d5edfd43bd8 [file]
import type {
PtyShellOutput,
ShellRunCompactResult,
ShellOutput,
ShellRunOperation,
ShellRunRecord,
ShellRunSnapshotResult,
ShellRunStateResult,
ShellRunStatus,
ShellRunUpdate,
ToolResultContent,
} from '@maka/core';
import { encodedTerminalInputActionsByteLength, isActiveShellRunStatus } from '@maka/core';
import { shellRunResourceRef, type ShellRunWriteInput } from './shell-run-contract.js';
import { truncateToolOutput } from './tool-output.js';
import { isLikelySandboxDenial } from './sandbox/detect.js';
export const PTY_MODEL_TEXT_BUDGET_BYTES = 50 * 1024;
const TRUNCATED_MARKER = '[terminal snapshot truncated to fit the output limit]';
export type TerminalToolResult = Extract<ToolResultContent, { kind: 'terminal' }>;
export type ShellRunToolResult = Extract<ToolResultContent, { kind: 'shell_run' }>;
export function shellRunUpdate(record: ShellRunRecord): ShellRunUpdate {
return {
sessionId: record.sessionId,
ownership: { kind: 'local' },
sourceTurnId: record.sourceTurnId,
sourceToolCallId: record.sourceToolCallId,
result: shellRunSnapshotContent(record),
};
}
export function terminalContent(record: ShellRunRecord): TerminalToolResult {
if (isActiveShellRunStatus(record.status) || record.status === 'orphaned') {
throw new Error(`ShellRun status ${record.status} cannot be returned as a terminal result`);
}
return {
kind: 'terminal',
cwd: record.cwd,
cmd: record.command,
status: terminalResultStatus(record.status),
...(record.exitCode !== undefined ? { exitCode: record.exitCode } : {}),
...(record.failureMessage !== undefined ? { failureMessage: record.failureMessage } : {}),
output: projectShellOutputForModel(record.output),
...(sandboxDenialForRecord(record) ? { sandboxDenial: sandboxDenialForRecord(record) } : {}),
};
}
export function shellRunContent(
record: ShellRunRecord,
operation?: ShellRunOperation,
): ShellRunToolResult {
const state = shellRunSnapshotContent(record);
if (!operation) return state;
if (operation.kind === 'stop') return { ...state, operation };
if (state.mode !== 'pty') {
throw new Error('PTY control operation requires PTY ShellRun state');
}
return { ...state, operation };
}
export function compactShellRunContent(record: ShellRunRecord): ShellRunToolResult {
return shellRunStateContent(record);
}
export function ptyControlOperation(
input: ShellRunWriteInput,
outcome: {
inputQueued: boolean;
resizeApplied: boolean;
resizeChanged: boolean;
failed?: boolean;
},
): Extract<ShellRunOperation, { kind: 'pty_control' }> {
const terminalInput =
input.input !== undefined
? Buffer.byteLength(input.input, 'utf8')
: input.actions
? encodedTerminalInputActionsByteLength(input.actions)
: undefined;
return {
kind: 'pty_control',
failed: outcome.failed === true,
...(terminalInput !== undefined
? { input: { bytes: terminalInput, queued: outcome.inputQueued } }
: {}),
...(input.size
? {
resize: {
cols: input.size.cols,
rows: input.size.rows,
applied: outcome.resizeApplied,
changed: outcome.resizeChanged,
},
}
: {}),
};
}
function terminalResultStatus(status: ShellRunStatus): TerminalToolResult['status'] {
switch (status) {
case 'completed':
case 'failed':
case 'timed_out':
case 'cancelled':
return status;
case 'starting':
case 'running':
case 'orphaned':
throw new Error(`ShellRun status ${status} cannot be returned as a terminal result`);
}
}
function shellRunStateContent(record: ShellRunRecord): ShellRunCompactResult {
const state = {
kind: 'shell_run',
ref: shellRunResourceRef(record.shellRunId),
status: record.status,
cwd: record.cwd,
cmd: record.command,
startedAt: record.startedAt,
updatedAt: record.updatedAt,
...(record.completedAt !== undefined ? { completedAt: record.completedAt } : {}),
...(record.timeoutMs !== undefined ? { timeoutMs: record.timeoutMs } : {}),
...(record.exitCode !== undefined ? { exitCode: record.exitCode } : {}),
...(record.failureMessage !== undefined ? { failureMessage: record.failureMessage } : {}),
revision: record.revision,
} as const;
return record.output.mode === 'pipes' ? { ...state, mode: 'pipes' } : { ...state, mode: 'pty' };
}
function shellRunSnapshotContent(record: ShellRunRecord): ShellRunSnapshotResult {
const state = shellRunStateContent(record);
const output = projectShellOutputForModel(record.output);
return output.mode === 'pipes'
? {
...state,
mode: 'pipes',
output,
...(sandboxDenialForRecord(record)
? { sandboxDenial: sandboxDenialForRecord(record) }
: {}),
}
: {
...state,
mode: 'pty',
output,
...(sandboxDenialForRecord(record)
? { sandboxDenial: sandboxDenialForRecord(record) }
: {}),
};
}
function sandboxDenialForRecord(record: ShellRunRecord):
| {
likely: true;
backend?: 'macos-seatbelt' | 'linux';
}
| undefined {
if (record.status !== 'failed' || record.sandboxExecution?.enforced !== true) return undefined;
const flat = flattenSandboxDenialText(record.output);
if (!isLikelySandboxDenial({ ...flat, sandboxed: true })) return undefined;
const backend = record.sandboxExecution.type;
return {
likely: true,
...(backend === 'macos-seatbelt' || backend === 'linux' ? { backend } : {}),
};
}
function flattenSandboxDenialText(output: ShellOutput): {
stdout: string;
stderr: string;
} {
if (output.mode === 'pipes') return { stdout: output.stdout, stderr: output.stderr };
return {
stdout: `${output.scrollback}\n${output.screen}\n${output.lastAlternateScreen ?? ''}`,
stderr: '',
};
}
function projectShellOutputForModel(output: ShellOutput): ShellOutput {
if (output.mode === 'pty') return projectPtyOutputForModel(output);
const stdout = truncateToolOutput(output.stdout, { direction: 'tail' });
const stderr = truncateToolOutput(output.stderr, { direction: 'tail' });
return {
...output,
stdout: stdout.content,
stderr: stderr.content,
stdoutTruncated: output.stdoutTruncated || stdout.truncated,
stderrTruncated: output.stderrTruncated || stderr.truncated,
};
}
export function projectPtyOutputForModel(
output: PtyShellOutput,
maxBytes = PTY_MODEL_TEXT_BUDGET_BYTES,
): PtyShellOutput {
let remaining = Math.max(0, Math.trunc(maxBytes));
let truncated = output.truncated;
const screen = takePrioritizedText(output.screen, remaining);
remaining = screen.truncated ? 0 : remaining - Buffer.byteLength(screen.text, 'utf8');
truncated ||= screen.truncated;
const alternate =
output.lastAlternateScreen === undefined
? undefined
: takePrioritizedText(output.lastAlternateScreen, remaining);
if (alternate) {
remaining = alternate.truncated ? 0 : remaining - Buffer.byteLength(alternate.text, 'utf8');
truncated ||= alternate.truncated;
}
const scrollback = takeTailText(output.scrollback, remaining);
truncated ||= scrollback.truncated;
const { lastAlternateScreen: _lastAlternateScreen, ...base } = output;
return {
...base,
screen: screen.text,
scrollback: scrollback.text,
...(alternate?.text ? { lastAlternateScreen: alternate.text } : {}),
truncated,
};
}
function takePrioritizedText(text: string, budget: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text, 'utf8') <= budget) return { text, truncated: false };
return takeTailText(text, budget);
}
function takeTailText(text: string, budget: number): { text: string; truncated: boolean } {
if (Buffer.byteLength(text, 'utf8') <= budget) return { text, truncated: false };
if (budget <= 0) return { text: '', truncated: text.length > 0 };
const markerBytes = Buffer.byteLength(TRUNCATED_MARKER, 'utf8');
if (budget <= markerBytes) return { text: '', truncated: true };
const tail = sliceUtf8Tail(text, budget - markerBytes - 1);
return { text: `${TRUNCATED_MARKER}\n${tail}`, truncated: true };
}
function sliceUtf8Tail(text: string, budget: number): string {
const characters = Array.from(text);
let result = '';
let bytes = 0;
for (let index = characters.length - 1; index >= 0; index -= 1) {
const character = characters[index];
const size = Buffer.byteLength(character, 'utf8');
if (bytes + size > budget) break;
result = character + result;
bytes += size;
}
return result;
}