From 4242821a81bdea5492c7688ceb8ecd12d462413b Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 1 Nov 2026 21:09:05 +0100 Subject: [PATCH] Offer human_tasks_list and human_task_cancel in delegated runs --- packages/core/src/mcp/tools.ts | 17 ++++++++++++-- packages/core/test/human-tasks.test.ts | 32 +++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/core/src/mcp/tools.ts b/packages/core/src/mcp/tools.ts index 86375ca9..627c022f 100644 --- a/packages/core/src/mcp/tools.ts +++ b/packages/core/src/mcp/tools.ts @@ -920,6 +920,19 @@ function canAsk(_agent: Agent, ctx: RunContext): boolean { } } +/** + * The run may look at and take back tasks for the human: that asks nothing, so also in delegated runs (an agent drops + * its own, a lead its team's). Not in condition checks (they only observe). + */ +function canWithdraw(_agent: Agent, ctx: RunContext): boolean { + try { + const r = getRun(ctx.runId); + return r.trigger !== "check" && r.trigger !== "dream"; + } catch { + return false; + } +} + /** The run is a dream (background memory consolidation): it gets `memory_dream_report` and nothing else. */ function isDreamRun(ctx: RunContext): boolean { try { @@ -1785,7 +1798,7 @@ const TOOLS: ToolDef[] = [ description: "Tasks you gave the human (human_task_create) and whether they are still open, parked in their backlog (status backlog: they will get to it later, it still counts as open), being worked on, done or declined, with their note. Also lists a decision (kind: decision) Godmode found open in one of your final answers and put on their list.", schema: z.object({ status: z.enum(["active", "closed", "all"]).optional().describe("active (default) = open, in their backlog or being worked on") }), - when: (agent, ctx) => canAsk(agent, ctx) && followupsAllowed(ctx.conversationId), + when: canWithdraw, run: ({ status }, { agent }) => { const tasks = listHumanTasks({ status: status ?? "active", agentId: isManager(agent) ? undefined : agent.id }).filter((t) => !t.questionId); if (!tasks.length) return status === "active" || !status ? "No open tasks for the human." : "None."; @@ -1814,7 +1827,7 @@ const TOOLS: ToolDef[] = [ id: z.string().min(1).max(100).describe('The task\'s id or its ref, e.g. "H-12"'), reason: z.string().max(500).optional().describe("Why it isn't needed anymore; required for a task of your team, the human sees it on the task"), }), - when: (agent, ctx) => canAsk(agent, ctx) && followupsAllowed(ctx.conversationId), + when: canWithdraw, run: ({ id, reason }, { agent }) => { const t = withdrawHumanTask(findHumanTask(id).id, reason ?? "", { agentId: agent.id }); return `${humanTaskRef(t)} is off the list.`; diff --git a/packages/core/test/human-tasks.test.ts b/packages/core/test/human-tasks.test.ts index aa09e694..647ef196 100644 --- a/packages/core/test/human-tasks.test.ts +++ b/packages/core/test/human-tasks.test.ts @@ -2,10 +2,10 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes import type { Agent, MessageBlock, Task } from "@godmode/shared"; import { taskEventText } from "@godmode/shared"; import { invocations, makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness"; -import { callTool } from "../src/mcp/tools"; +import { callTool, listToolsFor } from "../src/mcp/tools"; import { deleteAgent, ensureDefaultAgent, updateAgent } from "../src/agents/service"; import { getAccessToken } from "../src/server/auth"; -import { deleteConversation, getConversation, sendMessage, startChat } from "../src/services/conversations"; +import { createConversation, deleteConversation, getConversation, sendMessage, startChat } from "../src/services/conversations"; import { listAttention } from "../src/services/attention"; import { listAudit } from "../src/services/audit"; import { listNotifications } from "../src/services/notifications"; @@ -15,7 +15,7 @@ import { run as sql } from "../src/db"; import { answerQuestion, getQuestion, openQuestionOf, setRemoteAnswerer, setRemoteQuestionFetcher } from "../src/services/questions"; import { adoptQuestion } from "../src/remote/mirror"; import { stopPauses } from "../src/services/pauses"; -import { cancelRun, getRun, listRuns, waitForRun } from "../src/runner/runner"; +import { cancelRun, getRun, listRuns, startRun, waitForRun } from "../src/runner/runner"; import { createTask, getTask, listTaskEvents, startTasks, stopTasks } from "../src/tasks/service"; let env: TestEnv; @@ -177,6 +177,32 @@ describe("an agent gives the human a task", () => { } }, 30_000); + test("a delegated run can't give tasks but takes back its own and its team's", async () => { + const godmode = await ensureDefaultAgent(); + const first = await chatWithTask(); + const delegated = async (a: Agent) => { + const conv = createConversation({ agentId: a.id, origin: "delegation" }); + const r = await startRun({ agentId: a.id, conversationId: conv.id, prompt: "Withdraw the duplicate", trigger: "delegation", parentRunId: first.run.id, depth: 1 }); + await waitForRun(r.id, 10_000); + return { runId: r.id, agentId: a.id, conversationId: conv.id, workspaceId: null, depth: 1 }; + }; + const lead = await delegated(godmode); + const tools = listToolsFor(godmode, lead).map((t) => t.name); + expect(tools).toContain("human_task_cancel"); + expect(tools).toContain("human_tasks_list"); + expect(tools).not.toContain("human_task_create"); + expect((await callTool(lead, "human_tasks_list", {})).content[0]!.text).toContain(`"ref": "H-${first.task.number}"`); + const back = await callTool(lead, "human_task_cancel", { id: `H-${first.task.number}`, reason: "Duplicate" }); + expect(back.content[0]!.text).toBe(`H-${first.task.number} is off the list.`); + expect(getHumanTask(first.task.id)).toMatchObject({ status: "withdrawn", closedReason: "Godmode: Duplicate" }); + + const second = await chatWithTask(); + const own = await delegated(agent); + expect((await callTool(own, "human_task_create", { title: "Another one" })).isError).toBe(true); + expect((await callTool(own, "human_task_cancel", { id: second.task.id })).isError).toBeUndefined(); + expect(getHumanTask(second.task.id).status).toBe("withdrawn"); + }, 30_000); + test("at most five open tasks per chat; deleting the chat withdraws them", async () => { const { conversationId, run } = await chatWithTask(); const ctx = { runId: run.id, agentId: agent.id, conversationId, workspaceId: null, depth: 0 };