Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.";
Expand Down Expand Up @@ -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.`;
Expand Down
32 changes: 29 additions & 3 deletions packages/core/test/human-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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 };
Expand Down
Loading