Compare commits

..

2 Commits

Author SHA1 Message Date
Claude
8f56426ea7
Summarize project conversations and retry contended handoffs
Group threads, which hold every project's main conversation, were never
compacted. Runs there saw only the last 200 raw messages, so the original
brief and earlier decisions fell out of context. The lead bot now
summarizes them like a bot thread; the summary stays local.

A group handoff now retries when its transaction hits a deadlock, so a
specialist that finished its work no longer fails at the hand-back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE
2026-10-22 17:18:21 +00:00
Claude
aa989f50b3
Push agents to act when they end a turn announcing work
A local model often ends a turn with "let me set that up now" and no tool
call. Nothing runs after a turn ends, so the user waits on work that never
happens. A text-only turn that announces unfinished work now gets up to two
follow-ups that ask the agent to do it or report what is open.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011VfFvSskgrByEU75QLyreE
2026-10-22 17:18:20 +00:00
7 changed files with 427 additions and 179 deletions

View File

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { announcesUnfinishedWork } from "./announced-work.js";
describe("announcesUnfinishedWork", () => {
it.each([
"Nein, noch kein separates Repository. Lass mich das jetzt anlegen.",
"Fertig: Vite+React, 6 Sektionen.\n\nNoch offen: Deployment. Ich arbeite gerade am Build und melde mich.",
"Dependencies installiert. Jetzt bauen und testen:",
"Ich lege jetzt das Repo an.",
"Jetzt baue ich die Seite.",
"The domain is free. I'll now deploy the page.",
"Let me check the server logs first.",
"I'm working on the build right now.",
])("flags %j", (text) => {
expect(announcesUnfinishedWork(text)).toBe(true);
});
it.each([
"Die Landingpage ist live unter http://example.test:8082/ und funktioniert.",
"Hier sind alle drei Logo-Varianten.\n\nLass mich wissen, welche dir gefällt.",
"Done. Let me know if you want changes.",
"Ich starte jetzt einen neuen Task beim Grafiker. Welchen Stil möchtest du?",
"Sag mir Bescheid, wenn ich weitermachen soll.",
"",
])("accepts %j", (text) => {
expect(announcesUnfinishedWork(text)).toBe(false);
});
it("only looks at the closing paragraph", () => {
expect(
announcesUnfinishedWork(
"Lass mich kurz zusammenfassen.\n\nDie Seite ist gebaut, gepusht und live.",
),
).toBe(false);
});
});

View File

@ -0,0 +1,51 @@
/**
* Local models often end a turn by announcing the next step ("Lass mich das jetzt anlegen",
* "I'll now deploy it") without calling a tool. Nothing runs after a turn ends, so the user is
* left waiting on work that never happens. This spots such endings so the runtime can push the
* agent once more to either do the work or report honestly.
*/
/** Phrases that look like announcements but hand the turn to the user. */
const HANDS_BACK =
/\b(?:lass(?:t)? (?:mich|uns) (?:es )?wissen|sag(?:t)? (?:mir )?bescheid|gib (?:mir )?bescheid|let me know|tell me)\b/gi;
const ANNOUNCES_WORK = [
// German: "Lass mich …", "Ich lege jetzt …", "Jetzt baue ich …", "Ich arbeite gerade am Build".
/\blass(?:t)? (?:mich|uns)\b/i,
/\bich (?:werde|starte|mache|baue|lege|erstelle|prüfe|kümmere mich|schaue|deploye|beginne|fange|schreibe|richte|lade|teste)\b[^.!?\n]*\b(?:jetzt|gleich|nun|sofort|als nächstes|direkt)\b/i,
/\b(?:jetzt|nun|gleich|als nächstes)\s+(?:lege|baue|erstelle|mache|starte|deploye|prüfe|schreibe|kümmere|richte|teste|lade)\s+ich\b/i,
/\bich arbeite (?:gerade|jetzt|noch|daran)\b/i,
/\bbin (?:gerade |jetzt )?dabei\b/i,
// English.
/\blet me\b/i,
/\bi(?:'ll| will) (?:now|start|go ahead|get started|create|build|deploy|set up|write|fix|run)\b/i,
/\bnow i(?:'ll| will)\b/i,
/\bnext,? i(?:'ll| will)\b/i,
/\bi(?:'m| am) (?:now |currently |still )?(?:working on|starting|building|creating|setting up|deploying)\b/i,
];
/** Last paragraph of the reply, where a closing announcement sits. */
function closingText(text: string): string {
const paragraphs = text
.trim()
.split(/\n\s*\n/)
.map((part) => part.trim())
.filter(Boolean);
return (paragraphs.at(-1) ?? "").slice(-400);
}
export function announcesUnfinishedWork(text: string): boolean {
const closing = closingText(text);
if (!closing) return false;
// A question hands the turn to the user; waiting for the answer is right.
if (/\?\s*\W*$/u.test(closing)) return false;
// "Jetzt bauen und testen:" — the turn ends where a tool call should follow.
if (/:\s*$/.test(closing)) return true;
const withoutHandBack = closing.replace(HANDS_BACK, "");
return ANNOUNCES_WORK.some((pattern) => pattern.test(withoutHandBack));
}
export const MAX_ANNOUNCED_WORK_CONTINUATIONS = 2;
export const ANNOUNCED_WORK_CONTINUATION_PROMPT =
"Your last message announced work you have not done, and nothing runs after your turn ends. Do that work now with your tools. If you cannot, tell the user plainly what is done, what is still open, and what you need from them.";

View File

@ -13,6 +13,7 @@ import {
lockOwnedGroup, lockOwnedGroup,
type PrismaClient, type PrismaClient,
touchGroupUpdatedAt, touchGroupUpdatedAt,
withTransactionRetry,
} from "@rakazo/db"; } from "@rakazo/db";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
import type { ExecutorDeps } from "./executor.js"; import type { ExecutorDeps } from "./executor.js";
@ -30,180 +31,185 @@ export async function handoffToGroupBot(
input: { bot_id?: string; confirm_name?: string; message: string }, input: { bot_id?: string; confirm_name?: string; message: string },
) { ) {
const deliveryKey = `group-handoff:${run.id}`; const deliveryKey = `group-handoff:${run.id}`;
const committed = await deps.prisma.$transaction(async (tx) => { // Runs finishing on the same thread contend for its event sequence; a deadlock here used to
try { // fail a specialist whose work was already done.
await lockOwnedGroup(tx, run, groupId); const committed = await withTransactionRetry(() =>
} catch (error) { deps.prisma.$transaction(async (tx) => {
if (error instanceof IsolationError) try {
return { error: "group is no longer available" } as const; await lockOwnedGroup(tx, run, groupId);
throw error; } catch (error) {
} if (error instanceof IsolationError)
const [group, activeSource] = await Promise.all([ return { error: "group is no longer available" } as const;
tx.chatGroup.findFirst({ throw error;
where: { id: groupId, archivedAt: null, thread: { id: run.threadId } }, }
include: { const [group, activeSource] = await Promise.all([
members: { tx.chatGroup.findFirst({
where: { bot: { archivedAt: null } }, where: { id: groupId, archivedAt: null, thread: { id: run.threadId } },
include: { bot: { select: { id: true, name: true } } }, include: {
members: {
where: { bot: { archivedAt: null } },
include: { bot: { select: { id: true, name: true } } },
orderBy: { createdAt: "asc" },
},
},
}),
tx.run.findFirst({
where: {
id: run.id,
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
userId: run.userId,
status: "running",
},
select: { id: true, sourceMessage: { select: { blocks: true } } },
}),
]);
if (!group || !activeSource) return { error: "source run is no longer active" } as const;
if (!group.members.some((member) => member.bot.id === run.botId)) {
return { error: "source bot is no longer a group member" } as const;
}
// In a group with a lead, members hand results back to the lead and the lead delegates
// the next stage, so the bounce rules below only apply between members.
const leadBotId = group.members.some((member) => member.bot.id === group.leadBotId)
? group.leadBotId
: null;
const existing = await tx.message.findUnique({
where: { threadId_clientNonce: { threadId: run.threadId, clientNonce: deliveryKey } },
select: {
sourceRuns: {
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
take: 1,
select: { id: true, botId: true },
}, },
}, },
}),
tx.run.findFirst({
where: {
id: run.id,
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
userId: run.userId,
status: "running",
},
select: { id: true, sourceMessage: { select: { blocks: true } } },
}),
]);
if (!group || !activeSource) return { error: "source run is no longer active" } as const;
if (!group.members.some((member) => member.bot.id === run.botId)) {
return { error: "source bot is no longer a group member" } as const;
}
// In a group with a lead, members hand results back to the lead and the lead delegates
// the next stage, so the bounce rules below only apply between members.
const leadBotId = group.members.some((member) => member.bot.id === group.leadBotId)
? group.leadBotId
: null;
const existing = await tx.message.findUnique({
where: { threadId_clientNonce: { threadId: run.threadId, clientNonce: deliveryKey } },
select: {
sourceRuns: {
orderBy: { createdAt: "asc" },
take: 1,
select: { id: true, botId: true },
},
},
});
if (existing) {
const nextRun = existing.sourceRuns[0];
const event = await tx.event.findFirst({
where: { threadId: run.threadId, runId: run.id, type: "group.handoff" },
orderBy: { seq: "desc" },
select: { seq: true },
}); });
if (!nextRun || !event) return { error: "recorded handoff is incomplete" } as const; if (existing) {
return { const nextRun = existing.sourceRuns[0];
ok: true, const event = await tx.event.findFirst({
botId: nextRun.botId, where: { threadId: run.threadId, runId: run.id, type: "group.handoff" },
runId: nextRun.id, orderBy: { seq: "desc" },
eventSeq: event.seq, select: { seq: true },
toLead: nextRun.botId === leadBotId, });
} as const; if (!nextRun || !event) return { error: "recorded handoff is incomplete" } as const;
} return {
ok: true,
let targetId = input.bot_id?.trim(); botId: nextRun.botId,
if (!targetId && input.confirm_name?.trim()) { runId: nextRun.id,
const name = input.confirm_name.trim().toLowerCase(); eventSeq: event.seq,
targetId = group.members.find((member) => member.bot.name.toLowerCase() === name)?.bot.id; toLead: nextRun.botId === leadBotId,
} } as const;
if (!targetId) return { error: "handoff target bot is required" } as const;
if (targetId === run.botId) return { error: "cannot hand off to yourself" } as const;
if (!group.members.some((member) => member.bot.id === targetId)) {
return { error: "handoff target is not a group member" } as const;
}
let sourceBlocks: MessageBlock[] = [];
if (activeSource.sourceMessage) {
const parsedSource = MessageBlock.array().safeParse(activeSource.sourceMessage.blocks);
if (!parsedSource.success) {
return { error: "cannot verify the group handoff chain" } as const;
} }
sourceBlocks = parsedSource.data;
}
const sourceHandoff = sourceBlocks.find(
(block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
);
const leadInvolved = leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
return {
error:
"do not hand this stage back to its sender; post the result in the shared thread instead",
} as const;
}
if (
run.botId !== leadBotId &&
sourceHandoff?.toBotId === run.botId &&
isSameStageHandoffBounce(sourceHandoff.text, input.message)
) {
return {
error:
"you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task",
} as const;
}
const hop = nextBotMessageHop(sourceHandoff?.hop);
if (botMessageHopExhausted(hop)) {
return {
error:
"group handoff limit reached for this chain; finish the current stage in the shared thread instead",
} as const;
}
const handoffBlock: MessageBlock = { let targetId = input.bot_id?.trim();
kind: "handoff", if (!targetId && input.confirm_name?.trim()) {
fromBotId: run.botId, const name = input.confirm_name.trim().toLowerCase();
toBotId: targetId, targetId = group.members.find((member) => member.bot.name.toLowerCase() === name)?.bot.id;
text: input.message, }
hop, if (!targetId) return { error: "handoff target bot is required" } as const;
}; if (targetId === run.botId) return { error: "cannot hand off to yourself" } as const;
const message = await createThreadMessageInTransaction(tx, { if (!group.members.some((member) => member.bot.id === targetId)) {
threadId: run.threadId, return { error: "handoff target is not a group member" } as const;
role: "bot", }
blocks: [handoffBlock],
botId: run.botId, let sourceBlocks: MessageBlock[] = [];
runId: run.id, if (activeSource.sourceMessage) {
clientNonce: deliveryKey, const parsedSource = MessageBlock.array().safeParse(activeSource.sourceMessage.blocks);
}); if (!parsedSource.success) {
const task = await tx.task.create({ return { error: "cannot verify the group handoff chain" } as const;
data: { }
spaceId: run.spaceId, sourceBlocks = parsedSource.data;
botId: targetId, }
threadId: run.threadId, const sourceHandoff = sourceBlocks.find(
userId: run.userId, (block): block is Extract<MessageBlock, { kind: "handoff" }> => block.kind === "handoff",
prompt: input.message, );
status: "queued", const leadInvolved =
}, leadBotId !== null && (targetId === leadBotId || run.botId === leadBotId);
}); if (sourceHandoff?.fromBotId === targetId && !leadInvolved) {
const nextRun = await tx.run.create({ return {
data: { error:
spaceId: run.spaceId, "do not hand this stage back to its sender; post the result in the shared thread instead",
botId: targetId, } as const;
threadId: run.threadId, }
taskId: task.id, if (
userId: run.userId, run.botId !== leadBotId &&
status: "queued", sourceHandoff?.toBotId === run.botId &&
trigger: "follow_up", isSameStageHandoffBounce(sourceHandoff.text, input.message)
sourceMessageId: message.id, ) {
}, return {
}); error:
const event = await appendEventInTransaction(tx, { "you own this handed-off stage; workshop with the user and post your concept here instead of re-delegating the same task",
spaceId: run.spaceId, } as const;
threadId: run.threadId, }
botId: run.botId, const hop = nextBotMessageHop(sourceHandoff?.hop);
type: "group.handoff", if (botMessageHopExhausted(hop)) {
runId: run.id, return {
payload: { error:
messageId: message.id, "group handoff limit reached for this chain; finish the current stage in the shared thread instead",
} as const;
}
const handoffBlock: MessageBlock = {
kind: "handoff",
fromBotId: run.botId, fromBotId: run.botId,
toBotId: targetId, toBotId: targetId,
text: input.message, text: input.message,
}, hop,
}); };
await touchGroupUpdatedAt(tx, groupId); const message = await createThreadMessageInTransaction(tx, {
return { threadId: run.threadId,
ok: true, role: "bot",
botId: targetId, blocks: [handoffBlock],
runId: nextRun.id, botId: run.botId,
eventSeq: event.seq, runId: run.id,
toLead: targetId === leadBotId, clientNonce: deliveryKey,
} as const; });
}); const task = await tx.task.create({
data: {
spaceId: run.spaceId,
botId: targetId,
threadId: run.threadId,
userId: run.userId,
prompt: input.message,
status: "queued",
},
});
const nextRun = await tx.run.create({
data: {
spaceId: run.spaceId,
botId: targetId,
threadId: run.threadId,
taskId: task.id,
userId: run.userId,
status: "queued",
trigger: "follow_up",
sourceMessageId: message.id,
},
});
const event = await appendEventInTransaction(tx, {
spaceId: run.spaceId,
threadId: run.threadId,
botId: run.botId,
type: "group.handoff",
runId: run.id,
payload: {
messageId: message.id,
fromBotId: run.botId,
toBotId: targetId,
text: input.message,
},
});
await touchGroupUpdatedAt(tx, groupId);
return {
ok: true,
botId: targetId,
runId: nextRun.id,
eventSeq: event.seq,
toLead: targetId === leadBotId,
} as const;
}),
);
if ("error" in committed) return committed; if ("error" in committed) return committed;
await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => { await deps.events.notify(run.threadId, committed.eventSeq).catch((error) => {
getLogger().error("group handoff realtime notification", error); getLogger().error("group handoff realtime notification", error);

View File

@ -241,6 +241,7 @@ function compactionHarness(
defaultMemoryScope: string; defaultMemoryScope: string;
} | null; } | null;
timeZone?: string | null; timeZone?: string | null;
groupLeadBotId?: string | null;
} = {}, } = {},
) { ) {
const messages: HarnessMessage[] = const messages: HarnessMessage[] =
@ -252,7 +253,8 @@ function compactionHarness(
})); }));
const thread = { const thread = {
id: "thread-1", id: "thread-1",
botId: "bot-1", botId: options.groupLeadBotId === undefined ? ("bot-1" as string | null) : null,
group: options.groupLeadBotId === undefined ? null : { leadBotId: options.groupLeadBotId },
spaceId: "workspace-1", spaceId: "workspace-1",
userId: "user-1", userId: "user-1",
nextEventSeq: 0, nextEventSeq: 0,
@ -390,6 +392,33 @@ function compactionHarness(
} }
describe("compactHistory", () => { describe("compactHistory", () => {
it("summarizes a group thread with its lead and keeps the summary local", async () => {
const harness = compactionHarness({
deploymentModelKey: "openrouter-key",
groupLeadBotId: "lead-bot",
});
await compactHistory(harness.deps, "thread-1");
const [request] = harness.runtime.run.mock.calls[0]!;
expect(request.botId).toBe("lead-bot");
expect(harness.thread.historyCompactedUpToSeq).toBe(49);
expect(harness.thread.historyCompactionSummary).toBe("Summary of 50 messages.");
expect(harness.memoryProviders.resolve).not.toHaveBeenCalled();
expect(harness.saveMemory).not.toHaveBeenCalled();
});
it("skips a group thread without a lead", async () => {
const harness = compactionHarness({
deploymentModelKey: "openrouter-key",
groupLeadBotId: null,
});
await compactHistory(harness.deps, "thread-1");
expect(harness.runtime.run).not.toHaveBeenCalled();
});
it("anchors the summarizer in the thread owner's time zone", async () => { it("anchors the summarizer in the thread owner's time zone", async () => {
const harness = compactionHarness({ const harness = compactionHarness({
deploymentModelKey: "openrouter-key", deploymentModelKey: "openrouter-key",

View File

@ -147,8 +147,14 @@ export interface CompactHistoryDeps {
} }
export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> { export async function compactHistory(deps: CompactHistoryDeps, threadId: string): Promise<void> {
const thread = await deps.prisma.thread.findUniqueOrThrow({ where: { id: threadId } }); const thread = await deps.prisma.thread.findUniqueOrThrow({
if (!thread.botId) return; where: { id: threadId },
include: { group: { select: { leadBotId: true } } },
});
// A group thread (a project's main conversation included) is summarized by its lead. Its
// summary stays local: the external memory store is keyed by one bot's own thread.
const summarizerBotId = thread.botId ?? thread.group?.leadBotId;
if (!summarizerBotId) return;
const previousCursor = thread.historyCompactedUpToSeq; const previousCursor = thread.historyCompactedUpToSeq;
const previousGeneration = thread.historyCompactionGeneration; const previousGeneration = thread.historyCompactionGeneration;
const previousSummary = thread.historyCompactionSummary?.trim() || null; const previousSummary = thread.historyCompactionSummary?.trim() || null;
@ -267,7 +273,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
? await deps.resolveModel({ ? await deps.resolveModel({
userId: thread.userId, userId: thread.userId,
spaceId: thread.spaceId, spaceId: thread.spaceId,
botId: thread.botId, botId: summarizerBotId,
}) })
: deps.deploymentModelKey : deps.deploymentModelKey
? { ? {
@ -296,7 +302,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
let runtimeReportedFailure = false; let runtimeReportedFailure = false;
for await (const event of deps.runtime.run( for await (const event of deps.runtime.run(
{ {
botId: thread.botId, botId: summarizerBotId,
threadId, threadId,
runId: `compact:${threadId}:${fromSeqExclusive}`, runId: `compact:${threadId}:${fromSeqExclusive}`,
prompt, prompt,
@ -353,12 +359,14 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
// Local compaction is first-party behavior and must not depend on an optional external store. // Local compaction is first-party behavior and must not depend on an optional external store.
// Saving only after the compare-and-set also prevents losing workers from creating duplicates. // Saving only after the compare-and-set also prevents losing workers from creating duplicates.
let semanticMemory: ConfiguredMemoryProvider | null = null; let semanticMemory: ConfiguredMemoryProvider | null = null;
try { if (thread.botId) {
semanticMemory = await deps.memoryProviders.resolve( try {
memoryProviderResolveRequest(thread.spaceId, thread.userId), semanticMemory = await deps.memoryProviders.resolve(
); memoryProviderResolveRequest(thread.spaceId, thread.userId),
} catch (error) { );
getLogger().error("Failed to load semantic memory provider for history compaction", error); } catch (error) {
getLogger().error("Failed to load semantic memory provider for history compaction", error);
}
} }
let externalSaveAttempted = false; let externalSaveAttempted = false;
const memoryContext = { const memoryContext = {
@ -366,7 +374,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
traceId: `history-compact:${threadId}`, traceId: `history-compact:${threadId}`,
spaceId: thread.spaceId, spaceId: thread.spaceId,
userId: thread.userId, userId: thread.userId,
botId: thread.botId, botId: summarizerBotId,
signal: new AbortController().signal, signal: new AbortController().signal,
}; };
if (semanticMemory) { if (semanticMemory) {
@ -376,7 +384,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
{ {
content: summary, content: summary,
scope: "isolated", scope: "isolated",
botId: thread.botId, botId: summarizerBotId,
source: { kind: "history", generation: previousGeneration }, source: { kind: "history", generation: previousGeneration },
}, },
memoryContext, memoryContext,
@ -407,7 +415,7 @@ export async function compactHistory(deps: CompactHistoryDeps, threadId: string)
) { ) {
try { try {
const removed = await semanticMemory.provider.purgeHistory( const removed = await semanticMemory.provider.purgeHistory(
{ botId: thread.botId, generations: [previousGeneration] }, { botId: summarizerBotId, generations: [previousGeneration] },
memoryContext, memoryContext,
); );
if (!removed.ok) { if (!removed.ok) {

View File

@ -7,6 +7,7 @@ const fakeAgentState = vi.hoisted(() => ({
| "empty" | "empty"
| "two-boundaries" | "two-boundaries"
| "silent-continuation" | "silent-continuation"
| "announce"
| "subagent-limit" | "subagent-limit"
| "parent-limit" | "parent-limit"
| "parent-parallel" | "parent-parallel"
@ -134,6 +135,35 @@ vi.mock("@earendil-works/pi-agent-core", () => ({
return; return;
} }
if (fakeAgentState.mode === "announce") {
const announce = (text: string) => {
this.emit({
type: "message_update",
assistantMessageEvent: { type: "text_delta", delta: text },
});
this.emit({
type: "message_end",
message: { role: "assistant", content: [{ type: "text", text }] },
});
this.emit({
type: "turn_end",
message: { role: "assistant", content: [{ type: "text", text }] },
toolResults: [],
});
};
// Keeps announcing; the runtime nudges a bounded number of times, then lets it end.
announce("Lass mich das Repo jetzt anlegen.");
while (fakeAgentState.followUpMessages.length > fakeAgentState.promptInputs.length - 1) {
fakeAgentState.promptInputs.push("follow-up");
announce(
fakeAgentState.emitFinalAfterFollowUp
? "Das Repo ist angelegt."
: "Ich lege das Repo jetzt an.",
);
}
return;
}
if (fakeAgentState.mode === "parent-limit") { if (fakeAgentState.mode === "parent-limit") {
const shell = this.tools.find((tool) => tool.name === "shell"); const shell = this.tools.find((tool) => tool.name === "shell");
if (!shell) throw new Error("shell was not exposed"); if (!shell) throw new Error("shell was not exposed");
@ -731,6 +761,72 @@ describe("Pi connector tool dispatch", () => {
}); });
}); });
it("pushes once more when a text-only turn announces work it did not do", async () => {
fakeAgentState.mode = "announce";
const runtime = new PiAgentRuntime();
const events: unknown[] = [];
for await (const event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "announce",
prompt: "leg ein Repo an",
instructions: "Use your tools.",
history: [],
tools: [destinationTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool: vi.fn(async () => ({ ok: true })),
},
{
operationId: "announce",
traceId: "announce",
spaceId: "w",
userId: "u",
signal: new AbortController().signal,
},
)) {
events.push(event);
}
expect(fakeAgentState.followUpMessages).toEqual([
expect.objectContaining({
role: "user",
content: expect.stringContaining("nothing runs after your turn ends"),
}),
]);
expect(events).toContainEqual({ type: "text", text: "Das Repo ist angelegt." });
});
it("stops nudging an agent that keeps announcing", async () => {
fakeAgentState.mode = "announce";
fakeAgentState.emitFinalAfterFollowUp = false;
const runtime = new PiAgentRuntime();
for await (const _event of runtime.run(
{
botId: "b",
threadId: "t",
runId: "announce-loop",
prompt: "leg ein Repo an",
instructions: "Use your tools.",
history: [],
tools: [destinationTool],
model: { provider: "test", id: "dispatch-test-model" },
executeTool: vi.fn(async () => ({ ok: true })),
},
{
operationId: "announce-loop",
traceId: "announce-loop",
spaceId: "w",
userId: "u",
signal: new AbortController().signal,
},
)) {
// drain
}
expect(fakeAgentState.followUpMessages).toHaveLength(2);
});
it("does not wait for a slow tool completion hook", async () => { it("does not wait for a slow tool completion hook", async () => {
let resolveAudit!: () => void; let resolveAudit!: () => void;
const audit = new Promise<void>((resolve) => { const audit = new Promise<void>((resolve) => {

View File

@ -12,6 +12,11 @@ import type {
} from "@rakazo/adapter-kit"; } from "@rakazo/adapter-kit";
import { MODEL_TIERS, ModelTierSchema } from "@rakazo/contracts"; import { MODEL_TIERS, ModelTierSchema } from "@rakazo/contracts";
import { getLogger } from "@rakazo/logging"; import { getLogger } from "@rakazo/logging";
import {
ANNOUNCED_WORK_CONTINUATION_PROMPT,
announcesUnfinishedWork,
MAX_ANNOUNCED_WORK_CONTINUATIONS,
} from "./announced-work.js";
import { isToolPauseResult } from "./approval-effect.js"; import { isToolPauseResult } from "./approval-effect.js";
import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js"; import { DELEGATION_TOOL_NAMES, SUBAGENT_MODEL_TIER_DESCRIPTION } from "./builtin-tools.js";
import { CATALOG_EXECUTE, prepareCatalogExecuteArguments } from "./lazy-tool-catalog.js"; import { CATALOG_EXECUTE, prepareCatalogExecuteArguments } from "./lazy-tool-catalog.js";
@ -341,6 +346,7 @@ export class PiAgentRuntime implements AgentRuntime {
let toolCalls = 0; let toolCalls = 0;
let toolActivityShowing = false; let toolActivityShowing = false;
let silentToolContinuations = 0; let silentToolContinuations = 0;
let announcedWorkContinuations = 0;
let toolWorkPendingFinal = false; let toolWorkPendingFinal = false;
agent.subscribe(async (event) => { agent.subscribe(async (event) => {
if (event.type === "message_end") { if (event.type === "message_end") {
@ -405,6 +411,22 @@ export class PiAgentRuntime implements AgentRuntime {
}); });
} }
} }
// A text-only turn that announces the next step ends the run with nothing done.
if (
!hasToolCalls &&
!hasToolResults &&
!host.pausePending &&
tools.length > 0 &&
announcedWorkContinuations < MAX_ANNOUNCED_WORK_CONTINUATIONS &&
announcesUnfinishedWork(messageText)
) {
announcedWorkContinuations += 1;
agent.followUp({
role: "user",
content: ANNOUNCED_WORK_CONTINUATION_PROMPT,
timestamp: Date.now(),
});
}
} }
if (event.type === "message_end" && event.message.role === "assistant") { if (event.type === "message_end" && event.message.role === "assistant") {
const text = assistantText(event.message); const text = assistantText(event.message);