| 1 | /** |
| 2 | * An agent's own computer, from the agents service's side |
| 3 | * (docs.g1t.sh/guides/agents/, "Its computer"). The computer itself is the |
| 4 | * runner's `AgentComputer` object (services/runner computer.ts), reached |
| 5 | * through the RUNNER binding; here is what a session's tools call, and what |
| 6 | * the Computer tab reads and presses. |
| 7 | * |
| 8 | * Sessions only: a chat reply never gets these ports, so a quick answer |
| 9 | * never wakes a machine. Each session works in its own directory under the |
| 10 | * home, `/home/agent/sessions/<id>`, and shares the home with every other |
| 11 | * session of the agent. Every command is a `command` entry in the session's |
| 12 | * transcript (transcript.ts), and is kept on the computer's own page. |
| 13 | */ |
| 14 | import type { AbilitySection, AgentComputerCommand, AgentComputerView, Result, ServiceBinding, User } from "@g1t/contracts"; |
| 15 | |
| 16 | // Relative, with extensions, so Node runs the tests on this file as it is. |
| 17 | import { runnerComputerClient } from "../../../packages/contracts/src/clients.ts"; |
| 18 | import { fail, ok } from "../../../packages/contracts/src/result.ts"; |
| 19 | import { canChange, canSeeAgent } from "./access.ts"; |
| 20 | import type { Row } from "./store.ts"; |
| 21 | import type { ComputerPorts } from "./tools.ts"; |
| 22 | |
| 23 | export type ComputerEnv = { RUNNER?: ServiceBinding }; |
| 24 | |
| 25 | /** The home on the computer, and where a session's work goes by default. */ |
| 26 | export const COMPUTER_HOME = "/home/agent"; |
| 27 | export function sessionCwd(sessionId: string): string { |
| 28 | return `${COMPUTER_HOME}/sessions/${sessionId}`; |
| 29 | } |
| 30 | |
| 31 | /** Whether the agent's abilities give it a computer this turn: shell or files, ready and not Never. */ |
| 32 | export function hasComputer(sections: AbilitySection[]): boolean { |
| 33 | return sections.some((section) => section.group === "computer" && section.sources.some((source) => source.abilities.some((ability) => ability.status === "ready" && ability.level !== "never"))); |
| 34 | } |
| 35 | |
| 36 | const NOT_HERE = "Agents' computers aren't available on this installation."; |
| 37 | |
| 38 | /** |
| 39 | * The ports a session's tool box calls (tools.ts `useComputer`): each |
| 40 | * command names the agent, its workspace and who asked, so the runner |
| 41 | * meters the machine time to them; `onCommand` is told of every command |
| 42 | * that ran, for the transcript. |
| 43 | */ |
| 44 | export function computerPorts( |
| 45 | env: ComputerEnv, |
| 46 | input: { agent: Row; workspace: string; session: { id: string }; asker: { username: string | null }; onCommand: (command: AgentComputerCommand) => void }, |
| 47 | ): ComputerPorts | null { |
| 48 | if (!env.RUNNER) return null; |
| 49 | const runner = runnerComputerClient(env.RUNNER); |
| 50 | const who = { agent_id: input.agent.id, workspace: input.workspace, agent_handle: input.agent.handle, asked_by: input.asker.username }; |
| 51 | const cwd = sessionCwd(input.session.id); |
| 52 | const outcome = <T, U>(result: Result<T>, map: (value: T) => U) => (result.ok ? { ok: true as const, value: map(result.value) } : { ok: false as const, code: result.error.code, message: result.error.message }); |
| 53 | return { |
| 54 | cwd, |
| 55 | async exec(cmd, dir, timeoutSeconds) { |
| 56 | const ran = await runner.computerExec({ ...who, cmd, cwd: dir ?? cwd, timeout_seconds: timeoutSeconds, session_id: input.session.id }); |
| 57 | if (ran.ok) input.onCommand(ran.value.command); |
| 58 | return outcome(ran, (value) => value.command); |
| 59 | }, |
| 60 | async readFile(path) { |
| 61 | return outcome(await runner.computerReadFile({ ...who, path, session_id: input.session.id }), (value) => value); |
| 62 | }, |
| 63 | async writeFile(path, text) { |
| 64 | return outcome(await runner.computerWriteFile({ ...who, path, text, session_id: input.session.id }), (value) => value); |
| 65 | }, |
| 66 | }; |
| 67 | } |
| 68 | |
| 69 | // ── The Computer tab ────────────────────────────────────────────────────── |
| 70 | |
| 71 | /** What the views need: the workspace, the viewer, and the agents' database. */ |
| 72 | export type ComputerCtx = { env: ComputerEnv; db: D1Database; slug: string; workspaceId: string; viewer: User }; |
| 73 | |
| 74 | /** The agent by handle, if the viewer may see it: a personal agent only its member and the owners. */ |
| 75 | async function agentRow(ctx: ComputerCtx, handle: unknown): Promise<Row | null> { |
| 76 | const row = await ctx.db |
| 77 | .prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL") |
| 78 | .bind(ctx.workspaceId, String(handle ?? "").trim().replace(/^@/, "").toLowerCase()) |
| 79 | .first<Row>(); |
| 80 | return row && canSeeAgent(ctx.viewer, ctx.slug, row) ? row : null; |
| 81 | } |
| 82 | |
| 83 | const NO_AGENT = (handle: unknown) => fail("not_found", `There is no agent called @${String(handle ?? "")}.`); |
| 84 | |
| 85 | async function view(ctx: ComputerCtx, row: Row): Promise<Result<AgentComputerView>> { |
| 86 | if (!ctx.env.RUNNER) return fail("unavailable", NOT_HERE); |
| 87 | const runner = runnerComputerClient(ctx.env.RUNNER); |
| 88 | const [status, commands] = await Promise.all([runner.computerStatus(row.id), runner.computerCommands(row.id, null)]); |
| 89 | if (!status.ok) return status; |
| 90 | return ok({ status: status.value, commands: commands.ok ? commands.value : [], can_manage: canChange(ctx.viewer, ctx.slug, row) }); |
| 91 | } |
| 92 | |
| 93 | /** The agent's computer: its state, disk and recent commands, for anyone who may see the agent. */ |
| 94 | export async function computerView(ctx: ComputerCtx, handle: unknown): Promise<Result<AgentComputerView>> { |
| 95 | const row = await agentRow(ctx, handle); |
| 96 | if (!row) return NO_AGENT(handle); |
| 97 | return view(ctx, row); |
| 98 | } |
| 99 | |
| 100 | /** The agent, if the viewer may act on its computer: owners a workspace agent's, its member a personal one's. */ |
| 101 | async function managed(ctx: ComputerCtx, handle: unknown): Promise<Result<Row>> { |
| 102 | const row = await agentRow(ctx, handle); |
| 103 | if (!row) return NO_AGENT(handle); |
| 104 | if (!canChange(ctx.viewer, ctx.slug, row)) return fail("forbidden", row.scope === "personal" ? "Only the person whose personal agent this is can manage its computer." : "Only the workspace's owners wake, sleep or reset an agent's computer."); |
| 105 | if (!ctx.env.RUNNER) return fail("unavailable", NOT_HERE); |
| 106 | return ok(row); |
| 107 | } |
| 108 | |
| 109 | export async function wakeComputer(ctx: ComputerCtx, handle: unknown): Promise<Result<AgentComputerView>> { |
| 110 | const row = await managed(ctx, handle); |
| 111 | if (!row.ok) return row; |
| 112 | const woke = await runnerComputerClient(ctx.env.RUNNER!).computerWake({ agent_id: row.value.id, workspace: ctx.slug, agent_handle: row.value.handle, asked_by: ctx.viewer.username }); |
| 113 | if (!woke.ok) return woke; |
| 114 | return view(ctx, row.value); |
| 115 | } |
| 116 | |
| 117 | export async function sleepComputer(ctx: ComputerCtx, handle: unknown): Promise<Result<AgentComputerView>> { |
| 118 | const row = await managed(ctx, handle); |
| 119 | if (!row.ok) return row; |
| 120 | const slept = await runnerComputerClient(ctx.env.RUNNER!).computerSleep(row.value.id); |
| 121 | if (!slept.ok) return slept; |
| 122 | return view(ctx, row.value); |
| 123 | } |
| 124 | |
| 125 | export async function resetComputer(ctx: ComputerCtx, handle: unknown): Promise<Result<AgentComputerView>> { |
| 126 | const row = await managed(ctx, handle); |
| 127 | if (!row.ok) return row; |
| 128 | const reset = await runnerComputerClient(ctx.env.RUNNER!).computerReset(row.value.id); |
| 129 | if (!reset.ok) return reset; |
| 130 | return view(ctx, row.value); |
| 131 | } |
| 132 | |
| 133 | /** The computer's recent commands, newest first; `sessionId` narrows them to one session's. */ |
| 134 | export async function computerCommands(ctx: ComputerCtx, handle: unknown, sessionId: unknown): Promise<Result<AgentComputerCommand[]>> { |
| 135 | const row = await agentRow(ctx, handle); |
| 136 | if (!row) return NO_AGENT(handle); |
| 137 | if (!ctx.env.RUNNER) return fail("unavailable", NOT_HERE); |
| 138 | return runnerComputerClient(ctx.env.RUNNER).computerCommands(row.id, typeof sessionId === "string" && sessionId ? sessionId : null); |
| 139 | } |
| 140 | |
| 141 | /** An archived agent's computer: its disk is kept 30 days, then deleted. Never fails the archive. */ |
| 142 | export async function forgetComputer(env: ComputerEnv, agentId: string): Promise<void> { |
| 143 | if (!env.RUNNER) return; |
| 144 | await runnerComputerClient(env.RUNNER) |
| 145 | .computerForget(agentId) |
| 146 | .catch((error: unknown) => console.error("agents: an archived agent's computer was not told", agentId, String(error))); |
| 147 | } |