Skip to content
147 linesCodeBlameRaw
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 */
14import 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.
17import { runnerComputerClient } from "../../../packages/contracts/src/clients.ts";
18import { fail, ok } from "../../../packages/contracts/src/result.ts";
19import { canChange, canSeeAgent } from "./access.ts";
20import type { Row } from "./store.ts";
21import type { ComputerPorts } from "./tools.ts";
22
23export type ComputerEnv = { RUNNER?: ServiceBinding };
24
25/** The home on the computer, and where a session's work goes by default. */
26export const COMPUTER_HOME = "/home/agent";
27export 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. */
32export 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
36const 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 */
44export 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. */
72export 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. */
75async 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
83const NO_AGENT = (handle: unknown) => fail("not_found", `There is no agent called @${String(handle ?? "")}.`);
84
85async 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. */
94export 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. */
101async 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
109export 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
117export 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
125export 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. */
134export 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. */
142export 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}