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
8 changes: 6 additions & 2 deletions packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ import { NOTE_MAX, cancelFollowup, followupsAllowed, getFollowup, inWords, parse
import { autoRetryOf, continueCutOffRun, latestContinuation } from "../services/resume";
import { askQuestion, listQuestions } from "../services/questions";
import { callOwnerWhenAsked, callsEnabled, getCall, listCalls, ownerCallRefusal, placeCall } from "../calls/calls";
import { MAX_OPEN_PER_CHAT, createAgentHumanTask, findHumanTask, listHumanTasks, withdrawHumanTask } from "../services/humanTasks";
import { MAX_OPEN_PER_CHAT, createAgentHumanTask, duplicateAsk, findHumanTask, listHumanTasks, withdrawHumanTask } from "../services/humanTasks";
import { createMod, findMod, installMod, listMods, updateMod } from "../mods/service";
import { noteConnectorCall } from "../connect/connectors";
import { selfRepairCheck } from "../diagnostics/selfRepair";
Expand Down Expand Up @@ -1545,6 +1545,8 @@ const TOOLS: ToolDef[] = [
}),
when: canAsk,
run: ({ question, context, options, call }, { ctx }) => {
const twice = duplicateAsk(ctx.conversationId, question);
if (twice) return fail(twice);
const out = askQuestion(ctx, { kind: "question", question, context, options });
if (!out.ok) return fail(out.text);
if (!call || !out.id) return out.text;
Expand Down Expand Up @@ -1574,14 +1576,16 @@ const TOOLS: ToolDef[] = [
}),
when: canAsk,
run: ({ action, reason, affects }, { ctx }) => {
const twice = duplicateAsk(ctx.conversationId, action);
if (twice) return fail(twice);
const out = askQuestion(ctx, { kind: "approval", action, reason, affects });
return out.ok ? out.text : fail(out.text);
},
}),

defineTool({
name: "human_task_create",
description: `Give the human a task: something you can't do yourself and they have to — solve a CAPTCHA, log in where only they can, confirm on their phone, sign or upload a document, pay, call someone, change a setting only they reach, give you access. It goes on their task list ("My tasks") with a notification. Write \`title\` as an imperative ("Confirm the Google Ads sign-in on your phone"), \`instructions\` as exact steps plus what you need back, and link the page where they do it. Finish what doesn't depend on it, then end your turn with a short summary of what you're waiting for: when they mark it done (or say they can't), this chat continues by itself with their note — don't poll and don't schedule a follow-up for it. On a board task the ticket waits in Blocked until then. One task per thing to do (at most ${MAX_OPEN_PER_CHAT} open per chat). For a decision or an OK use ask_human / request_approval instead.`,
description: `Give the human a task: something you can't do yourself and they have to — solve a CAPTCHA, log in where only they can, confirm on their phone, sign or upload a document, pay, call someone, change a setting only they reach, give you access. It goes on their task list ("My tasks") with a notification. Write \`title\` as an imperative ("Confirm the Google Ads sign-in on your phone"), \`instructions\` as exact steps plus what you need back, and link the page where they do it. Finish what doesn't depend on it, then end your turn with a short summary of what you're waiting for: when they mark it done (or say they can't), this chat continues by itself with their note — don't poll and don't schedule a follow-up for it. On a board task the ticket waits in Blocked until then. One task per thing to do (at most ${MAX_OPEN_PER_CHAT} open per chat). Godmode refuses a task the human's list already holds (from any agent or chat) and names it: then work with that one instead of rephrasing it into a new one. For a decision or an OK use ask_human / request_approval instead.`,
schema: z.object({
title: z.string().min(1).max(200).describe("What to do, as an imperative, self-contained — also shown in a notification"),
instructions: z.string().max(6000).optional().describe("How: exact steps, where, what you found, and what you need back. Markdown, short."),
Expand Down
79 changes: 78 additions & 1 deletion packages/core/src/services/humanTasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const MAX_OPEN_PER_CHAT = 5;
/** Closed tasks the board lists (newest first). */
const CLOSED_LIMIT = 60;
const POSITION_STEP = 1024;
/** How long an answered decision keeps a new one that asks the same off the list. */
const DECISION_MEMORY_MS = 3 * 24 * 60 * 60 * 1000;

interface Row {
id: string;
Expand Down Expand Up @@ -294,11 +296,78 @@ function ticketOf(conversationId: string): { id: string; workspace_id: string |
return get<{ id: string; workspace_id: string | null }>("SELECT id, workspace_id FROM tasks WHERE conversation_id = ?", conversationId);
}

/* ------------------------------------------------------------------ */
/* The same thing twice */
/* ------------------------------------------------------------------ */

const FILLER = new Set(
(
"a an the and or but if then than so to of in on at by for from with without into onto about as is are was were be been being it its this that these those " +
"i me my we our us you your he she they them their his her do does did done can could should shall may might must will would please again still now " +
"just also not no yes there here what which who when where how why up down out over one ein eine einen einem einer der die das den dem des und oder aber " +
"wenn dann zu von im in am an auf mit für fuer ohne ist sind war bitte noch jetzt schon auch nicht ja nein ich du sie wir ihr er es mir dir uns euch"
).split(" "),
);

function words(text: string): Set<string> {
const plain = text
.toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/(\d)[.,'](?=\d{3}\b)/g, "$1")
.replace(/['’]s\b/g, "");
return new Set((plain.match(/[\p{L}\p{N}]+/gu) ?? []).filter((w) => !FILLER.has(w) && (w.length > 1 || /\d/.test(w))));
}

/** Two titles that ask for the same thing: (nearly) all words of the shorter one are in the longer one. */
export function sameAsk(a: string, b: string): boolean {
const x = words(a);
const y = words(b);
const [small, big] = x.size <= y.size ? [x, y] : [y, x];
if (!small.size) return false;
let shared = 0;
for (const w of small) if (big.has(w)) shared++;
if (shared === small.size && small.size === big.size) return true;
return shared >= 3 && shared / small.size >= 0.8;
}

/** "H-12" mentions in a text that are tasks on the list. */
function mentionedTasks(text: string): Row[] {
const numbers = [...new Set([...text.matchAll(/\bH-(\d{1,6})\b/g)].map((m) => Number(m[1])))];
return numbers.map((n) => get<Row>(`${SELECT} WHERE h.number = ?`, n)).filter((r): r is Row => !!r);
}

/** A task on the human's list that already asks for this: open (or in the backlog, or being done), or answered since `since`. */
export function findSameHumanTask(title: string, opts: { since?: string; except?: string[] } = {}): HumanTask | null {
const skip = new Set(opts.except ?? []);
const rows = all<Row>(
`${SELECT} WHERE h.status IN ${ACTIVE_SQL}${opts.since ? " OR (h.status IN ('done', 'declined') AND h.closed_at >= ?)" : ""} ORDER BY CASE WHEN h.status IN ${ACTIVE_SQL} THEN 0 ELSE 1 END, h.number DESC`,
...(opts.since ? [opts.since] : []),
);
const hit = rows.find((r) => !skip.has(r.id) && sameAsk(title, r.title));
return hit ? toHumanTask(hit) : null;
}

/** Why an agent in this chat shouldn't put this on the list again, or null. A decision its own answer left open here doesn't count: asking it properly takes its place. */
export function duplicateAsk(conversationId: string, title: string): string | null {
const same = findSameHumanTask(clean(title, 300), { except: openFromAnswer(conversationId).map((r) => r.id) });
if (!same) return null;
const ref = humanTaskRef(same);
return `${alreadyThere(same)}. Don't add it a second time: work with ${ref}, and if your work waits for it, say so in your answer and mention ${ref}.`;
}

function alreadyThere(t: HumanTask): string {
const from = [t.agentName, t.conversationTitle ? `chat "${t.conversationTitle}"` : ""].filter(Boolean).join(", ");
return `${humanTaskRef(t)} “${t.title}” is already on ${humanName()}'s list${from ? ` (${from})` : ""}`;
}

/** The gateway's `human_task_create`. */
export function createAgentHumanTask(origin: HumanTaskOrigin, input: HumanTaskInput): HumanTask {
const agent = getAgent(origin.agentId);
const title = clean(input.title, 200);
if (!title) throw badRequest("Say what to do (title).");
const twice = duplicateAsk(origin.conversationId, title);
if (twice) throw conflict(twice);
const open =
get<{ n: number }>(
`SELECT COUNT(*) AS n FROM human_tasks WHERE conversation_id = ? AND question_id IS NULL AND from_answer = 0 AND status IN ${ACTIVE_SQL}`,
Expand Down Expand Up @@ -381,12 +450,20 @@ export function noteOpenDecision(run: FinishedRun, answer: string): HumanTask |
}
return null;
}
const same = earlier.find((e) => e.title === title);
const same = earlier.find((e) => sameAsk(e.title, title));
for (const e of earlier) if (e !== same) withdraw(e, "A later answer took its place.");
if (same) {
if (earlier.length > 1) changed();
return getHumanTask(same.id);
}
// A report that lists what is already open ("H-12 and H-14 are still open"), or a decision the list already holds
// or the human answered lately: no second ticket for it.
const mentioned = mentionedTasks(title).length > 0;
const since = new Date(Date.now() - DECISION_MEMORY_MS).toISOString();
if (mentioned || findSameHumanTask(title, { since })) {
if (earlier.length) changed();
return null;
}
const agent = getAgent(run.agentId);
const ticket = ticketOf(run.conversationId);
const id = add({
Expand Down
56 changes: 54 additions & 2 deletions packages/core/test/human-tasks.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
import type { Agent, MessageBlock, Task } from "@godmode/shared";
import { taskEventText } from "@godmode/shared";
import { invocations, makeAgent, setupEnv, until, type TestEnv } from "./fixtures/runner-harness";
Expand All @@ -9,7 +9,8 @@ import { deleteConversation, getConversation, sendMessage, startChat } from "../
import { listAttention } from "../src/services/attention";
import { listNotifications } from "../src/services/notifications";
import { updateSettings } from "../src/services/settings";
import { closeHumanTask, getHumanTask, listHumanTasks, startHumanTasks, stopHumanTasks } from "../src/services/humanTasks";
import { closeHumanTask, getHumanTask, listHumanTasks, noteOpenDecision, sameAsk, startHumanTasks, stopHumanTasks } from "../src/services/humanTasks";
import { run as sql } from "../src/db";
import { answerQuestion, getQuestion, openQuestionOf } from "../src/services/questions";
import { stopPauses } from "../src/services/pauses";
import { cancelRun, getRun, listRuns, waitForRun } from "../src/runner/runner";
Expand Down Expand Up @@ -224,6 +225,11 @@ describe("the human's own tasks", () => {
});

describe("a decision an answer leaves open", () => {
// Every test leaves the same decision: an earlier one would keep it off the list.
beforeEach(() => {
sql("DELETE FROM human_tasks WHERE from_answer = 1");
});

/** A chat whose agent finished with a "Decision for you" in its answer. */
async function chatWithDecision(content = "LEAVE_DECISION set up the store") {
const { conversation, run } = await startChat({ agentId: agent.id, content });
Expand Down Expand Up @@ -355,6 +361,52 @@ describe("a decision an answer leaves open", () => {
}, 40_000);
});

describe("the same thing twice", () => {
beforeEach(() => {
sql("UPDATE human_tasks SET status = 'withdrawn' WHERE status IN ('backlog', 'open', 'doing') AND question_id IS NULL");
});

test("titles that ask for the same thing match, different ones don't", () => {
expect(
sameAsk(
"If an order comes in before CJ sources it, may we buy that one unit on AliExpress (WINOMO item 3256813118244875, about USD 18) and ship it straight to the customer?",
"if a hoop order comes in before CJ finds a supplier, may we buy that one unit on AliExpress (WINOMO item, about USD 18) and ship it straight to the customer?",
),
).toBe(true);
expect(sameAsk("Easyclean claim of 8,139 EUR: sue or write off?", "the Easyclean claim (8139 EUR): sue or write off")).toBe(true);
expect(sameAsk("Log in to Higgsfield in the Godmode browser", "Log in to Twilio in the Godmode browser")).toBe(false);
expect(sameAsk('Type the Scalable CAPTCHA and click "Anmelden"', 'financeAds: type the CAPTCHA and click "Jetzt kostenlos registrieren!"')).toBe(false);
expect(sameAsk("Confirm on your phone", "Confirm the Google Ads sign-in on your phone")).toBe(false);
});

test("an agent can't put a task on the list that is already there, from whichever chat", async () => {
const first = await chatWithTask("HUMAN_TASK passkey once");
const second = await startChat({ agentId: other.id, content: "hello" });
await waitForRun(second.run.id, 10_000);
const ctx = { runId: second.run.id, agentId: other.id, conversationId: second.conversation.id, workspaceId: null, depth: 0 };
const twice = await callTool(ctx, "human_task_create", { title: "Please create the passkey" });
expect(twice.isError).toBe(true);
expect(twice.content[0]!.text).toContain(`H-${first.task.number} “Create the passkey” is already on Dana's list`);
expect(listHumanTasks({ status: "active", conversationId: second.conversation.id })).toHaveLength(0);
expect((await callTool(ctx, "human_task_create", { title: "Scan the Google QR code for the YouTube channel" })).isError).toBeUndefined();

// Once it is done, it may be asked again.
await closeHumanTask(first.task.id, { outcome: "done" });
expect((await callTool(ctx, "human_task_create", { title: "Create the passkey" })).isError).toBeUndefined();
for (const t of listHumanTasks({ status: "active", conversationId: second.conversation.id })) await closeHumanTask(t.id, { outcome: "declined" });
}, 30_000);

test("an answer that only lists what is open, or asks what the list holds, adds nothing", async () => {
const { conversationId, run, task } = await chatWithTask("HUMAN_TASK roll-up");
const finished = { agentId: agent.id, conversationId, runId: run.id, workspaceId: null, attended: false };
const rollUp = `Ops pulse done.\n\n## Decisions for you\n\nH-${task.number} (create the passkey) is still open.`;
expect(noteOpenDecision(finished, rollUp)).toBeNull();
expect(noteOpenDecision(finished, "I set it up.\n\nShould I create the passkey now?")).toBeNull();
expect(listHumanTasks({ status: "active", conversationId }).map((t) => t.id)).toEqual([task.id]);
await closeHumanTask(task.id, { outcome: "declined" });
}, 30_000);
});

describe("questions and approvals have a ticket", () => {
async function asking(content = "ASK_HUMAN which color") {
const started = await startChat({ agentId: agent.id, content });
Expand Down
Loading