| 1 | /** |
| 2 | * The pure parts of an agent's computer (computer.ts): how a command's |
| 3 | * streamed lines become one transcript, how its end is said in a session's |
| 4 | * transcript, and how the container's NDJSON is read. No Workers imports, |
| 5 | * so Node runs the tests on the file as it is. |
| 6 | */ |
| 7 | import type { AgentComputerCommand } from "@g1t/contracts"; |
| 8 | |
| 9 | /** How much of one command's output a kept transcript holds: `COMPUTER_TRANSCRIPT_BYTES` (@g1t/contracts runner.ts), which computer.ts passes. */ |
| 10 | const DEFAULT_TRANSCRIPT_BYTES = 64 * 1024; |
| 11 | |
| 12 | /** The object key a computer's saved home is kept under. */ |
| 13 | export function homeKey(agentId: string): string { |
| 14 | return `homes/${agentId}.tar.zst`; |
| 15 | } |
| 16 | |
| 17 | /** A command's lines gathered into one transcript, in order, cut at the cap. */ |
| 18 | export function gather(lines: { stream: string; line: string }[], cap = DEFAULT_TRANSCRIPT_BYTES): { output: string; truncated: boolean } { |
| 19 | let output = ""; |
| 20 | let truncated = false; |
| 21 | for (const entry of lines) { |
| 22 | const next = output ? `${output}\n${entry.line}` : entry.line; |
| 23 | if (next.length > cap) { |
| 24 | truncated = true; |
| 25 | break; |
| 26 | } |
| 27 | output = next; |
| 28 | } |
| 29 | return { output, truncated }; |
| 30 | } |
| 31 | |
| 32 | /** `outcome` for a session's transcript: `exit 0 · 1.2 s`, with what went wrong. */ |
| 33 | export function outcomeLine(command: Pick<AgentComputerCommand, "exit_code" | "duration_ms" | "timed_out" | "truncated">): string { |
| 34 | const seconds = command.duration_ms / 1000; |
| 35 | const took = seconds < 10 ? `${seconds.toFixed(1)} s` : seconds < 90 ? `${Math.round(seconds)} s` : `${Math.round(seconds / 60)} min`; |
| 36 | return [`exit ${command.exit_code}`, took, command.timed_out ? "timed out" : null, command.truncated ? "output cut" : null].filter(Boolean).join(" · "); |
| 37 | } |
| 38 | |
| 39 | /** Byte parts as one array. */ |
| 40 | export function concat(parts: Uint8Array[], size: number): Uint8Array { |
| 41 | const out = new Uint8Array(size); |
| 42 | let at = 0; |
| 43 | for (const part of parts) { |
| 44 | out.set(part, at); |
| 45 | at += part.byteLength; |
| 46 | } |
| 47 | return out; |
| 48 | } |
| 49 | |
| 50 | /** The lines of an NDJSON body as they arrive, each parsed; lines that aren't JSON objects are skipped. */ |
| 51 | export async function* ndjson(body: ReadableStream<Uint8Array>): AsyncGenerator<Record<string, unknown>> { |
| 52 | const reader = body.getReader(); |
| 53 | const decoder = new TextDecoder(); |
| 54 | let buffer = ""; |
| 55 | const parse = (line: string): Record<string, unknown> | null => { |
| 56 | if (!line.trim()) return null; |
| 57 | try { |
| 58 | const value = JSON.parse(line) as unknown; |
| 59 | return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null; |
| 60 | } catch { |
| 61 | return null; |
| 62 | } |
| 63 | }; |
| 64 | for (;;) { |
| 65 | const { done, value } = await reader.read(); |
| 66 | if (done) break; |
| 67 | buffer += decoder.decode(value, { stream: true }); |
| 68 | let at: number; |
| 69 | while ((at = buffer.indexOf("\n")) >= 0) { |
| 70 | const parsed = parse(buffer.slice(0, at)); |
| 71 | buffer = buffer.slice(at + 1); |
| 72 | if (parsed) yield parsed; |
| 73 | } |
| 74 | } |
| 75 | const last = parse(buffer + decoder.decode()); |
| 76 | if (last) yield last; |
| 77 | } |
| 78 | |
| 79 | /** |
| 80 | * What a command's streamed lines and closing line become, as kept: the |
| 81 | * output with the closing note on its last line, or a line saying the |
| 82 | * computer stopped answering when there was no closing line. |
| 83 | */ |
| 84 | export function transcriptOf( |
| 85 | lines: { stream: string; line: string }[], |
| 86 | closing: { exit_code?: number; duration_ms?: number; truncated?: boolean; timed_out?: boolean; note?: string } | null, |
| 87 | problem: string | null, |
| 88 | cap = DEFAULT_TRANSCRIPT_BYTES, |
| 89 | ): Pick<AgentComputerCommand, "output" | "truncated" | "timed_out" | "exit_code"> & { duration_ms: number | null } { |
| 90 | const { output, truncated } = gather(lines, cap); |
| 91 | const tail = closing ? (closing.note ? `[${closing.note}]` : null) : `[The computer stopped answering${problem ? `: ${problem}` : ""}.]`; |
| 92 | return { |
| 93 | output: tail ? `${output}${output ? "\n" : ""}${tail}` : output, |
| 94 | truncated: truncated || Boolean(closing?.truncated), |
| 95 | timed_out: Boolean(closing?.timed_out), |
| 96 | exit_code: typeof closing?.exit_code === "number" ? closing.exit_code : -1, |
| 97 | duration_ms: typeof closing?.duration_ms === "number" ? closing.duration_ms : null, |
| 98 | }; |
| 99 | } |