From 39feada295bbb1b83fec04033558c66a3ef8db17 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Fri, 23 Oct 2026 05:49:27 +0200 Subject: [PATCH] Add run_cancel tool so managers can stop runs themselves Paused runs (usage limit, budget, a question) could only be stopped from the UI. run_cancel stops a queued, running or paused run for good, also when its chat works on another computer of the group (forwarded over the link). It refuses the caller's own run and the runs above it. --- apps/desktop/src/components/chat/tool-meta.ts | 6 ++ docs/ARCHITECTURE.md | 4 +- packages/core/src/mcp/tools.ts | 45 +++++++++- packages/core/src/remote/deviceTools.ts | 1 + packages/core/src/runner/prompt.ts | 2 +- packages/core/test/run-cancel.test.ts | 88 +++++++++++++++++++ packages/shared/src/activity.ts | 2 + 7 files changed, 143 insertions(+), 5 deletions(-) create mode 100644 packages/core/test/run-cancel.test.ts diff --git a/apps/desktop/src/components/chat/tool-meta.ts b/apps/desktop/src/components/chat/tool-meta.ts index b6ed68fa..21a3c4ef 100644 --- a/apps/desktop/src/components/chat/tool-meta.ts +++ b/apps/desktop/src/components/chat/tool-meta.ts @@ -11,6 +11,7 @@ import { BotMessageSquare, Box, Camera, + CircleStop, Code2, Eye, FileDown, @@ -406,6 +407,7 @@ const GODMODE_TOOLS = new Set([ "automation_check_result", "runs_list", "run_continue", + "run_cancel", "workspaces_list", "workspace_create", "workspace_update", @@ -547,6 +549,8 @@ function godmodeMeta(tool: string, input: Input, ctx: ToolContext): Omit (), your lead. Your final answer goes back to .]` | -| `agent_create`, `templates_list`, `team_install`, `agent_update`, `agent_delete`, `routine_list`, `routine_create`, `routine_update`, `routine_run`, `routine_delete`, `automation_triggers_list`, `automation_events_list`, `runs_list`, `run_continue`, `workspaces_list`, `workspace_create`, `workspace_update`, `project_create`, `project_update`, `tasks_list`, `task_get`, `task_create`, `task_update`, `task_message` | Management tools — only for agents with `canManageAgents` (the built-in *Godmode* agent). `agent_create` / `agent_update` also set `role` and `reportsTo`; `agent_create({ template })` starts from a role template (unset fields come from it, its schedule comes back as `templateSchedule`), `templates_list` shows the roles and structures (seats with their paths for `skip`), and `team_install` hires a structure as the calling agent (no secret access or management rights for the new agents; refused in a run kept in a VM). `workspace_update` / `project_update` only add sources (`addSources`); removing sources, deleting, VM, browser profile and auto-merge stay human-only. A run kept in a VM sets up no workspaces or projects (their instructions reach agents on the computer), and changing a workspace's context follows the `agent_update` rules for every agent in it (reveal mode, computer use); changing a project's, for every agent working on it (its own agents, and those with a chat or ticket in it) — a new project has none. What a caller without raw secrets or computer use writes into a project is marked (`projects.fill_only` / `off_computer`): runs that get it go fill-only, agents that control the computer get it without description and instructions, until the human saves its instructions (lifting `fill_only` takes the vault passphrase) | +| `agent_create`, `templates_list`, `team_install`, `agent_update`, `agent_delete`, `routine_list`, `routine_create`, `routine_update`, `routine_run`, `routine_delete`, `automation_triggers_list`, `automation_events_list`, `runs_list`, `run_continue`, `run_cancel`, `workspaces_list`, `workspace_create`, `workspace_update`, `project_create`, `project_update`, `tasks_list`, `task_get`, `task_create`, `task_update`, `task_message` | Management tools — only for agents with `canManageAgents` (the built-in *Godmode* agent). `agent_create` / `agent_update` also set `role` and `reportsTo`; `agent_create({ template })` starts from a role template (unset fields come from it, its schedule comes back as `templateSchedule`), `templates_list` shows the roles and structures (seats with their paths for `skip`), and `team_install` hires a structure as the calling agent (no secret access or management rights for the new agents; refused in a run kept in a VM). `workspace_update` / `project_update` only add sources (`addSources`); removing sources, deleting, VM, browser profile and auto-merge stay human-only. A run kept in a VM sets up no workspaces or projects (their instructions reach agents on the computer), and changing a workspace's context follows the `agent_update` rules for every agent in it (reveal mode, computer use); changing a project's, for every agent working on it (its own agents, and those with a chat or ticket in it) — a new project has none. What a caller without raw secrets or computer use writes into a project is marked (`projects.fill_only` / `off_computer`): runs that get it go fill-only, agents that control the computer get it without description and instructions, until the human saves its instructions (lifting `fill_only` takes the vault passphrase) | | `automation_check_result({ met, observation, summary })` | Only in condition-check runs: report whether an automation's condition holds (see Automations) | | `task_note({ text, taskId? })` | A progress note on the ticket the run works on (managers: any ticket); on its timeline, nobody is notified | | `ask_human({ question, context?, options? })`, `request_approval({ action, reason, affects })` | Ask the human a question or for an OK and stand still until the answer; the run continues with it (see Questions and approvals). Not in condition checks, dreams or delegated runs | diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 7e6f9839..65d2a1b9 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -132,7 +132,7 @@ import { callApiTool, METHODS, type ApiCallResult, type CallPlaces } from "../in import { chatSources, createProject, getProject, projectOfChat, updateProject } from "../services/projects"; import { all, get } from "../db"; import { config } from "../config"; -import { fixRunner, runnerExec, runnerHealth } from "../remote/runners"; +import { fixRunner, link, runnerExec, runnerHealth } from "../remote/runners"; import { loginFillScope } from "../browser/fill"; import { chatFillOnly, createConversation, maskSecretInChat, sendMessage } from "../services/conversations"; import { assignVm, createVm, getVm, listVms, sharedDirOf, startVm, stopVm, suspendVm, vmInUse, vmOfRun, vmStatus } from "../vm/service"; @@ -142,7 +142,7 @@ import { getSettings } from "../services/settings"; import { agentSettingsRefusal, applyAgentSettings, humanOnlySettings, settingsSections } from "../services/agentSettings"; import { spendReport } from "../services/spend"; import { budgetOverview, budgetSentence, exhaustedBudget } from "../services/budgets"; -import { getRun, listRuns, markMissingLoginReported, runBrowserProfile, runChatBrowserProfile, waitForRun, runExempt } from "../runner/runner"; +import { cancelRun, getRun, listRuns, markMissingLoginReported, runBrowserProfile, runChatBrowserProfile, waitForRun, runExempt } from "../runner/runner"; import { listGoals } from "../tasks/goals"; import { addTaskNote, createTask, findTask, getTask, listTaskEvents, listTasks, reportBlocked, sendTaskMessage, taskForConversation, updateTask } from "../tasks/service"; import { describeNow } from "../runner/prompt"; @@ -402,6 +402,16 @@ function reachableAgents(agent: Agent): Agent[] { return peersFor(agent); } +/** The run and the runs that handed it its work, up to the top. */ +function runsAbove(runId: string): string[] { + const chain: string[] = []; + for (let id: string | null = runId; id && !chain.includes(id); ) { + chain.push(id); + id = get<{ parent_run_id: string | null }>("SELECT parent_run_id FROM runs WHERE id = ?", id)?.parent_run_id ?? null; + } + return chain; +} + function requireReachable(agent: Agent, targetId: string): Agent { const target = reachableAgents(agent).find((a) => a.id === targetId); if (!target) throw new HttpError(403, `Agent ${targetId} is not one of your peers (use agents_list to see who you can work with).`); @@ -2899,6 +2909,37 @@ const TOOLS: ToolDef[] = [ }, }), + defineTool({ + name: "run_cancel", + description: + "Stop a run for good: one that works, waits in the queue or stands still (paused on a usage limit, a budget or a question). The work it handed to other agents stops too, and a paused run never resumes by itself. Runs of chats that work on another computer of the group are stopped there. Use it for runs that would repeat work already done or are no longer wanted; never for your own run or the ones that handed you your task.", + schema: z.object({ + runId: z.string(), + reason: z.string().trim().min(1).max(200).optional().describe("Why, shown in its chat"), + }), + when: managesSetup, + run: async ({ runId, reason }, { agent, ctx }) => { + const run = getRun(runId); + if (run.status === "succeeded" || run.status === "failed" || run.status === "cancelled") return `Run ${runId} already ended (${run.status}).`; + if (runsAbove(ctx.runId).includes(runId)) return fail("That run is yours or handed you your task: stopping it would stop you too. End your turn instead."); + const target = run.agentId === agent.id ? agent : requireReachable(agent, run.agentId); + const refusal = offHostRefusal(ctx, target, "stop its work") ?? revealTargetRefusal(agent, ctx, target, "stop its work"); + if (refusal) return fail(refusal); + const text = `Stopped by ${agent.name}${reason ? `: ${reason}` : ""}`; + const runnerId = get<{ runner_id: string | null }>("SELECT c.runner_id FROM runs r JOIN conversations c ON c.id = r.conversation_id WHERE r.id = ?", runId)?.runner_id ?? null; + if (runnerId) { + const l = link(runnerId); + l.assertOnline(); + const path = encodeURIComponent(runId); + await l.json("POST", `/api/link/runs/${path}/stop`, { reason: text }).catch(() => l.json("POST", `/api/runs/${path}/cancel`, {})); + } else { + await cancelRun(runId, text); + } + audit(`agent:${agent.id}`, "run.cancel", runId, { agentId: run.agentId, status: run.status, ...(runnerId ? { runnerId } : {}), ...(reason ? { reason } : {}) }); + return `Stopped run ${runId} of ${target.name} (it was ${run.status}${runnerId ? ", on another computer" : ""}).`; + }, + }), + defineTool({ name: "workspaces_list", description: diff --git a/packages/core/src/remote/deviceTools.ts b/packages/core/src/remote/deviceTools.ts index 1de08b25..72b69496 100644 --- a/packages/core/src/remote/deviceTools.ts +++ b/packages/core/src/remote/deviceTools.ts @@ -51,6 +51,7 @@ export const SHARED_TOOLS: ReadonlySet = new Set([ "task_note", "spend_overview", "runs_list", + "run_cancel", "workspaces_list", "workspace_create", "workspace_update", diff --git a/packages/core/src/runner/prompt.ts b/packages/core/src/runner/prompt.ts index b0b80fd1..51d6b915 100644 --- a/packages/core/src/runner/prompt.ts +++ b/packages/core/src/runner/prompt.ts @@ -347,7 +347,7 @@ Not in this chat: its task came from another agent that may not read raw secrets if (perms.canManageAgents) { out.push(`### Managing agents -You are the orchestrator. You can create, update and delete agents (\`agent_create\`, \`agent_update\`, \`agent_delete\`), manage their automations (\`routine_list\`, \`routine_create\`, \`routine_update\`, \`routine_run\`, \`routine_delete\`, \`automation_events_list\`), inspect recent work with \`runs_list\` (results and errors of every agent) and pick up a run a restart of Godmode cut off with \`run_continue\`, manage and supervise the task board (\`tasks_list\`, \`task_get\`, \`task_create\`, \`task_update\`, \`task_message\`, \`task_note\` — tickets agents work on; coding tasks end in a pull request; read a ticket's result and timeline with \`task_get\`, and send feedback into it with \`task_message\` instead of filing a new task for the same work), set up workspaces and their projects (\`workspaces_list\`, \`workspace_create\`, \`workspace_update\`, \`project_create\`, \`project_update\` — a workspace groups the agents, logins and repositories of one business or client, a project is one product or initiative inside it; when the work clearly belongs to something new, create it instead of piling everything into an existing one, and put its agents and tasks there), and review \`logins_overview\` and \`missing_logins_list\`. When asked to "check on all agents", use \`runs_list\` and \`missing_logins_list\` and summarize what succeeded, what failed and what the human must do (e.g. add a login in the vault). For a common job or a whole team, start from the ready-made roles and org charts (\`templates_list\`): \`agent_create({ template })\` hires one role, \`team_install\` a whole team or company with its reporting lines (one all-in-one agent per department when the business is small, specialists where the work is big enough). When you create an agent, give it a clear description, concrete standing instructions, a character and personality that fit the job, and an automation when the job is recurring. An agent that needs a browser of its own (separate logins) gets a new, empty profile: \`browser_profile_create\`, then \`agent_update({ browser: { profileId } })\`; existing profiles (\`browser_profiles_list\`) hold ${human}'s logins and stay theirs. Give every agent a \`role\` — its job title in two or three words — and, when the team has leads, say who it reports to (\`reportsTo\`); an agent without one reports to you. Never delete an agent unless ${human} explicitly asked for it.${ +You are the orchestrator. You can create, update and delete agents (\`agent_create\`, \`agent_update\`, \`agent_delete\`), manage their automations (\`routine_list\`, \`routine_create\`, \`routine_update\`, \`routine_run\`, \`routine_delete\`, \`automation_events_list\`), inspect recent work with \`runs_list\` (results and errors of every agent), pick up a run a restart of Godmode cut off with \`run_continue\` and stop one for good with \`run_cancel\` (a paused run that would repeat work already done, a run nobody needs any more), manage and supervise the task board (\`tasks_list\`, \`task_get\`, \`task_create\`, \`task_update\`, \`task_message\`, \`task_note\` — tickets agents work on; coding tasks end in a pull request; read a ticket's result and timeline with \`task_get\`, and send feedback into it with \`task_message\` instead of filing a new task for the same work), set up workspaces and their projects (\`workspaces_list\`, \`workspace_create\`, \`workspace_update\`, \`project_create\`, \`project_update\` — a workspace groups the agents, logins and repositories of one business or client, a project is one product or initiative inside it; when the work clearly belongs to something new, create it instead of piling everything into an existing one, and put its agents and tasks there), and review \`logins_overview\` and \`missing_logins_list\`. When asked to "check on all agents", use \`runs_list\` and \`missing_logins_list\` and summarize what succeeded, what failed and what the human must do (e.g. add a login in the vault). For a common job or a whole team, start from the ready-made roles and org charts (\`templates_list\`): \`agent_create({ template })\` hires one role, \`team_install\` a whole team or company with its reporting lines (one all-in-one agent per department when the business is small, specialists where the work is big enough). When you create an agent, give it a clear description, concrete standing instructions, a character and personality that fit the job, and an automation when the job is recurring. An agent that needs a browser of its own (separate logins) gets a new, empty profile: \`browser_profile_create\`, then \`agent_update({ browser: { profileId } })\`; existing profiles (\`browser_profiles_list\`) hold ${human}'s logins and stay theirs. Give every agent a \`role\` — its job title in two or three words — and, when the team has leads, say who it reports to (\`reportsTo\`); an agent without one reports to you. Never delete an agent unless ${human} explicitly asked for it.${ ctx.settingsTools ? `\n\nGodmode's own settings are yours to read and change when ${human} asks for it or a task needs it: \`settings_get\` and \`settings_update\` (default and fallback model, reasoning effort, run limits, how the browser and computer control behave, virtual machines, memory and dreaming, voice, upkeep, logs). A change applies to every agent at once, so say what you changed. What guards secrets, access from outside, what agents may do on this computer, what every agent is told or their limits stays with ${human} (\`humanOnly\` in \`settings_get\`); never hand them a task for a setting you can change yourself.` : "" diff --git a/packages/core/test/run-cancel.test.ts b/packages/core/test/run-cancel.test.ts new file mode 100644 index 00000000..ee2fbc04 --- /dev/null +++ b/packages/core/test/run-cancel.test.ts @@ -0,0 +1,88 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import type { Agent, Run } from "@godmode/shared"; +import { makeAgent, setupEnv, type TestEnv } from "./fixtures/runner-harness"; +import { get, insert } from "../src/db"; +import { ensureDefaultAgent } from "../src/agents/service"; +import { callTool } from "../src/mcp/tools"; +import { getRun } from "../src/runner/runner"; +import { newId, now } from "../src/util"; + +let env: TestEnv; +let worker: Agent; +let godmode: Agent; + +beforeAll(async () => { + env = await setupEnv("godmode-run-cancel-"); + worker = await makeAgent({ name: "Worker" }); + godmode = await ensureDefaultAgent(); +}); + +afterAll(async () => { + await env.close(); +}); + +function chat(owner: Agent, runnerId: string | null = null): string { + const id = newId("cnv"); + insert("conversations", { id, agent_id: owner.id, title: "Work", origin: "chat", runner_id: runnerId, created_at: now(), updated_at: now() }); + return id; +} + +function addRun(owner: Agent, status: Run["status"], opts: { conversationId?: string; parent?: string } = {}): string { + const id = newId("run"); + const conversationId = opts.conversationId ?? chat(owner); + insert("runs", { id, agent_id: owner.id, conversation_id: conversationId, trigger: "chat", status, prompt: "Post the video", parent_run_id: opts.parent ?? null, created_at: now(), started_at: now() }); + return id; +} + +function ctxOf(owner: Agent, runId = addRun(owner, "running")) { + return { runId, agentId: owner.id, conversationId: getRun(runId).conversationId, workspaceId: owner.workspaceId, depth: 0 }; +} + +describe("run_cancel", () => { + test("a manager stops a paused run of a teammate for good", async () => { + const paused = addRun(worker, "paused"); + const res = await callTool(ctxOf(godmode), "run_cancel", { runId: paused, reason: "the work was redone elsewhere" }); + expect(res.isError).toBeFalsy(); + expect(res.content[0]!.text).toContain("Stopped run"); + const run = getRun(paused); + expect(run.status).toBe("cancelled"); + expect(run.error).toBe("Stopped by Godmode: the work was redone elsewhere"); + }); + + test("a queued run without a live job is closed too", async () => { + const queued = addRun(worker, "queued"); + const res = await callTool(ctxOf(godmode), "run_cancel", { runId: queued }); + expect(res.isError).toBeFalsy(); + expect(getRun(queued).status).toBe("cancelled"); + }); + + test("a run that already ended is left as it is", async () => { + const done = addRun(worker, "succeeded"); + const res = await callTool(ctxOf(godmode), "run_cancel", { runId: done }); + expect(res.content[0]!.text).toContain("already ended"); + expect(getRun(done).status).toBe("succeeded"); + }); + + test("it refuses the caller's own run and the run that handed it its task", async () => { + const parent = addRun(godmode, "running"); + const own = addRun(godmode, "running", { parent }); + const ctx = ctxOf(godmode, own); + for (const runId of [own, parent]) { + const res = await callTool(ctx, "run_cancel", { runId }); + expect(res.isError).toBe(true); + expect(getRun(runId).status).toBe("running"); + } + }); + + test("a run of a chat on an unknown computer is not closed here", async () => { + const remote = addRun(worker, "paused", { conversationId: chat(worker, "rnr_gone") }); + const res = await callTool(ctxOf(godmode), "run_cancel", { runId: remote }); + expect(res.isError).toBe(true); + expect(get<{ status: string }>("SELECT status FROM runs WHERE id = ?", remote)?.status).toBe("paused"); + }); + + test("agents that manage nobody don't get the tool", async () => { + const res = await callTool(ctxOf(worker), "run_cancel", { runId: addRun(godmode, "paused") }); + expect(res.isError).toBe(true); + }); +}); diff --git a/packages/shared/src/activity.ts b/packages/shared/src/activity.ts index f47f4b77..66efaf12 100644 --- a/packages/shared/src/activity.ts +++ b/packages/shared/src/activity.ts @@ -103,6 +103,7 @@ export const GATEWAY_ACTIVITY: Record "Writing down what it consolidated", runs_list: () => "Checking recent runs", run_continue: () => "Continuing work a restart cut off", + run_cancel: () => "Stopping a run", workspaces_list: () => "Looking at workspaces", workspace_create: (i) => (short(i.name) ? `Creating the workspace ${short(i.name)}` : "Creating a workspace"), workspace_update: () => "Updating a workspace", @@ -324,6 +325,7 @@ export const GATEWAY_DONE: Record = { memory_dream_report: "Wrote down what it consolidated", runs_list: "Checked recent runs", run_continue: "Continued work a restart cut off", + run_cancel: "Stopped a run", workspaces_list: "Looked at workspaces", workspace_create: "Created a workspace", workspace_update: "Updated a workspace",