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
10 changes: 10 additions & 0 deletions packages/core/src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1596,6 +1596,16 @@ SELECT 'aud_' || lower(hex(randomblob(8))), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'
FROM settings WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5';
UPDATE settings SET value = json_set(value, '$.fallbackModel', 'claude-opus-5')
WHERE key = 'runner' AND json_valid(value) AND json_extract(value, '$.fallbackModel') = 'claude-sonnet-5';
`,
},
{
id: 98,
name: "dismiss_unknown_missing_logins",
sql: /* sql */ `
-- Older builds filed a missing login from any answer that mentioned a failed login, without a page it was about: no
-- site, no address, mostly a quoted error or another agent's report. Nothing can be added for those.
UPDATE missing_logins SET status = 'dismissed', updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
WHERE status = 'open' AND service = 'Unknown service' AND url = '';
`,
},
];
18 changes: 14 additions & 4 deletions packages/core/src/runner/openDecision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,10 @@ const CHOICE = /\b(?:or|oder)\b/i;

/** The section says there is nothing to decide. */
const NOTHING =
/^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:open |pending )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i;
/^(?:[-–—]+|(?:none|nothing|n\/a|nope|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|action|input)|keine?[rs]?|nichts|aktuell (?:keine|nichts)|derzeit (?:keine|nichts))\b[^\n?]{0,40})$/i;
/** A sentence that says only that ("nothing new from this run.", "None for now."), not "None of the quotes fits." */
const NOTHING_SENTENCE =
/^(?:none|nothing|nope|n\/a|nichts|keine?[rs]?|not (?:right )?now|no (?:new |open |pending |further )?(?:decisions?|questions?|input))(?: (?:new|neue[ns]?|further|more|else|weiter[es]?))?(?: (?:decisions?|questions?|entscheidung(?:en)?|fragen?))?(?: (?:from|in|for|on|von|aus|seit|für) (?:(?:this|that|the|my|your|today'?s|diesem|dieser|diesen|meiner|meinem|deiner) [\p{L}'-]+|me|you|us|mir|dir|dich|euch|uns|heute|gestern|yesterday|today))?(?: (?:for now|right now|so far|yet|today|this time|bisher|aktuell|heute|gerade))?[.!]?$/iu;

interface Heading {
line: number;
Expand Down Expand Up @@ -153,6 +156,11 @@ function shorten(s: string, max: number): string {

const isQuestion = (s: string) => /\?["'”’)\]]*$/.test(s);

/** Not a pleasantry, a question to someone else or extra work on offer. */
function forTheHuman(s: string): boolean {
return !NOT_FOR_THE_HUMAN.some((re) => re.test(s)) && !(OFFERS.some((re) => re.test(s)) && !CHOICE.test(s));
}

/** The section's question: its first sentence that asks, else its first sentence. */
function titleOf(section: string): string {
const all = sentences(section);
Expand Down Expand Up @@ -185,7 +193,10 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul
const next = headings.find((o) => o.line > h.line && o.level <= h.level);
const section = [h.rest, ...lines.slice(h.line + 1, next?.line ?? lines.length)].join("\n").trim();
const said = plain(section.split("\n").find((l) => l.trim()) ?? "");
if (!section || NOTHING.test(said)) {
// "nothing new from this run. You may want to …" goes on without asking: still nothing.
const all = sentences(section);
const asks = all.some((s) => (isQuestion(s) || WAITS.some((re) => re.test(s))) && forTheHuman(s));
if (!section || NOTHING.test(said) || (NOTHING_SENTENCE.test(all[0] ?? "") && !asks)) {
saidNothing = true;
continue;
}
Expand All @@ -204,8 +215,7 @@ export function detectOpenDecision(text: string, human = ""): OpenDecision | nul
if (!last) return null;
// Only what the answer ends on: a question further up is one the agent goes on to answer itself.
const asked = sentences(last).at(-1);
if (!asked || !(isQuestion(asked) ? ASKS : WAITS).some((re) => re.test(asked))) return null;
if (NOT_FOR_THE_HUMAN.some((re) => re.test(asked)) || (OFFERS.some((re) => re.test(asked)) && !CHOICE.test(asked))) return null;
if (!asked || !(isQuestion(asked) ? ASKS : WAITS).some((re) => re.test(asked)) || !forTheHuman(asked)) return null;
const title = shorten(asked, TITLE_MAX);
return { title, body: bodyOf(last, title) };
}
6 changes: 5 additions & 1 deletion packages/core/src/runner/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,10 @@ const LOGIN_FAILURE_PATTERNS: RegExp[] = [
/\b(?:no|missing)\s+(?:2fa|totp|mfa|two[- ]factor|authenticator)\b/i,
];

/** The sentence passes on a login problem that is already reported ("it reported that, and it's already on your list"). */
const ALREADY_REPORTED =
/\b(?:already\s+(?:reported|flagged|filed)(?:\s+(?:it|that|this|them))?(?=\s*(?:[,.;!)]|$)|\s+(?:to|for)\s+you\b|\s+on\s+your\b)|already\s+on\s+(?:your|the)\s+(?:list|task list|inbox)|(?:is|are|it['’]s|they['’]re|that['’]s)\s+(?:still\s+)?on\s+your\s+(?:list|task list|inbox)|(?:bereits|schon)\s+gemeldet(?!\s*,?\s*dass)|steht\s+(?:schon|bereits)\s+auf\s+deiner\s+liste)\b/i;

/**
* If the final answer says a login failed / is missing, return the sentence that says so (≤300 chars).
* Used when the run did not call report_missing_login itself.
Expand All @@ -847,7 +851,7 @@ export function detectLoginFailure(text: string): string | null {
.map((s) => s.replace(/^[\s>*#\-–•\d.)]+/, "").trim())
.filter(Boolean);
for (const sentence of sentences) {
if (LOGIN_FAILURE_PATTERNS.some((re) => re.test(sentence))) {
if (LOGIN_FAILURE_PATTERNS.some((re) => re.test(sentence)) && !ALREADY_REPORTED.test(sentence)) {
return sentence.length > 300 ? `${sentence.slice(0, 299)}…` : sentence;
}
}
Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/services/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ export interface InterruptedWork {

const CUT_OFF = `((r.status = 'cancelled' AND r.error = ?) OR (r.status = 'failed' AND r.error = ?))`;
const LATEST = `r.id = (SELECT l.id FROM runs l WHERE l.conversation_id = r.conversation_id ORDER BY l.created_at DESC, l.rowid DESC LIMIT 1)`;
// Sorted after the fact (`+`), so SQLite reads the few runs that finished lately from idx_runs_finished instead of walking
// every chat and all of its runs (~130 ms on a cold start).
const OLDEST_FIRST = "+r.created_at, r.rowid";

function cutOffByRestart(r: Pick<RunRow, "status" | "error">): boolean {
return (r.status === "cancelled" && r.error === RUN_SHUT_DOWN) || (r.status === "failed" && r.error === RUN_INTERRUPTED);
Expand All @@ -104,7 +107,7 @@ export function interruptedWork(): InterruptedWork {
const runs = all<RunRow>(
`SELECT r.* FROM runs r JOIN conversations c ON c.id = r.conversation_id
WHERE ${CUT_OFF} AND r.finished_at > ? AND c.runner_id IS NULL AND ${LATEST}
ORDER BY r.created_at, r.rowid`,
ORDER BY ${OLDEST_FIRST}`,
RUN_SHUT_DOWN,
RUN_INTERRUPTED,
since,
Expand Down Expand Up @@ -347,7 +350,7 @@ function offlineFailures(): RunRow[] {
`SELECT r.* FROM runs r JOIN conversations c ON c.id = r.conversation_id
WHERE r.status = 'failed' AND (r.error LIKE 'API Error:%' OR r.error LIKE 'Claude Code exited%' OR r.error LIKE 'Failed to authenticate.%')
AND r.finished_at > ? AND c.runner_id IS NULL AND ${LATEST}
ORDER BY r.created_at, r.rowid`,
ORDER BY ${OLDEST_FIRST}`,
since,
).filter(continuesOffline);
}
Expand Down
14 changes: 13 additions & 1 deletion packages/core/test/missing-logins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ServerEvent } from "@godmode/shared";
import { loadConfig } from "../src/config";
import { closeDb, insert, openDb } from "../src/db";
import { closeDb, getDb, insert, openDb } from "../src/db";
import { MIGRATIONS } from "../src/db/migrations";
import { bus } from "../src/events/bus";
import { setLogLevel } from "../src/log";
import { resetSettingsCache } from "../src/services/settings";
Expand Down Expand Up @@ -134,4 +135,15 @@ describe("missing logins", () => {
expect(item.kind).toBe("other");
expect(catchHttp(() => reportMissingLogin({ ...base, kind: "other", service: " " })).status).toBe(400);
});

test("open items without a site from older builds are dismissed once", () => {
const unknown = reportMissingLogin({ ...base, workspaceId: "wsp_old", kind: "other", service: "Unknown service", reason: "it's already on your list" });
const named = reportMissingLogin({ ...base, workspaceId: "wsp_old2", kind: "other", service: "Unknown service", url: "https://unknown.example.com" });
const real = reportMissingLogin({ ...base, workspaceId: "wsp_old", kind: "other", service: "billing.example.com" });
getDb().run(MIGRATIONS.find((m) => m.name === "dismiss_unknown_missing_logins")!.sql);
const status = (id: string) => listMissingLogins().find((i) => i.id === id)?.status;
expect(status(unknown.id)).toBe("dismissed");
expect(status(named.id)).toBe("open");
expect(status(real.id)).toBe("open");
});
});
23 changes: 23 additions & 0 deletions packages/core/test/open-decision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,29 @@ describe("a decision left open in an answer", () => {
test("a section that says there is nothing to decide", () => {
expect(detectOpenDecision("Done.\n\n## Decisions for you\n\nNone.\n\n## Next\n\nShould I deploy it?")).toBeNull();
expect(detectOpenDecision("Fertig.\n\n**Offene Entscheidungen:** keine")).toBeNull();
expect(
detectOpenDecision(
"| Ticket | Status |\n|---|---|\n| Demo shop | complete |\n\n**Waiting for you:** nothing new from this run. You will probably want to settle the pricing before the next calls, but nobody asked you directly, so I didn't create a task for it.\n\nI recorded all 10 tickets in `workspace/state.json`.",
),
).toBeNull();
expect(detectOpenDecision("Shift done.\n\n4. **Needed from you:** none. I took back H-74, which was created from my \"nothing\" line.")).toBeNull();
expect(detectOpenDecision("No replies.\n\n**Needed from Dana or Sam:** nothing new. Still open from Sam: two addresses and his answers on two leads.", "Dana")).toBeNull();
expect(detectOpenDecision("Done.\n\n**Waiting for you:** nothing new from this run. The pricing is still open on the board.\n\nAnything else?")).toBeNull();
expect(detectOpenDecision("Done.\n\n**Waiting for you:** Nothing for now. I'll check the inbox again tomorrow.\n\nWant me to also draft the emails?")).toBeNull();
});

test("a section that starts with none but still asks", () => {
expect(detectOpenDecision("Checked the inbox.\n\n**Waiting for you:** None of the leads replied. Should I send the follow-ups now or on Monday?")?.title).toBe(
"Should I send the follow-ups now or on Monday?",
);
expect(detectOpenDecision("**Decision for you:** None of the three quotes fits the budget. Pick A (cheaper) or B (faster) and I'll book it.")).not.toBeNull();
expect(detectOpenDecision("**Decisions for you:** Nothing is booked yet. Choose hotel A or hotel B for the 12th.")).not.toBeNull();
expect(detectOpenDecision("**Entscheidung für dich:** Keine Antwort von Sam bisher. Entscheide, ob wir nachfassen.")).not.toBeNull();
expect(detectOpenDecision("**Decisions for you:** No new decisions. Still open from yesterday: go live Friday or Monday?")?.title).toBe(
"Still open from yesterday: go live Friday or Monday?",
);
expect(detectOpenDecision("**Decision for you:** None from the shortlist works. Choose A or B and I'll book it.")).not.toBeNull();
expect(detectOpenDecision("**Entscheidung für dich:** Keiner von beiden Anbietern passt. Nimm A oder B, dann buche ich.")).not.toBeNull();
});

test("a closing question to the human", () => {
Expand Down
9 changes: 9 additions & 0 deletions packages/core/test/stream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,15 @@ describe("helpers", () => {
expect(detectLoginFailure("I logged in and downloaded the invoice.")).toBeNull();
expect(detectLoginFailure("No login required — the page is public.")).toBeNull();
expect(detectLoginFailure("I couldn't find the log in button at first, but then succeeded.")).toBeNull();
expect(detectLoginFailure("The second didn't go out because the forum login failed; it reported that, and it's already on your list.")).toBeNull();
expect(detectLoginFailure("The shop login failed again, which is still on your list.")).toBeNull();
expect(detectLoginFailure("Der Login ist fehlgeschlagen. Couldn't log in to the forum, das habe ich bereits gemeldet.")).toBeNull();
expect(detectLoginFailure("I couldn't log in to the supplier portal: it reported that the password is invalid.")).not.toBeNull();
expect(detectLoginFailure("Login failed because the account is already flagged for suspicious activity.")).not.toBeNull();
expect(detectLoginFailure("I couldn't log in to the tax portal to check the return that was already filed last week.")).not.toBeNull();
expect(detectLoginFailure("Couldn't log in, das Portal hat schon gemeldet, dass das Konto gesperrt ist.")).not.toBeNull();
expect(detectLoginFailure("Login failed; the site already reported this account as locked.")).not.toBeNull();
expect(detectLoginFailure("The forum login failed, which I already flagged for you.")).toBeNull();
});

test("usedBrowser", () => {
Expand Down
Loading