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
6 changes: 5 additions & 1 deletion mcp/src/graph_agent/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,11 @@ async function prepareGraphAgent(
instructions: systemPrompt,
tools,
stopWhen,
stopSequences: ["[END_OF_ANSWER]"],
// No stopSequences for the marker: termination is detected from the text
// (stopWhen / needsContinuation / extractFinalAnswer), which works on every
// provider. A stop sequence consumes the marker and leaves only the raw
// stop reason, which gateways rewrite (Bifrost maps stop_sequence to
// end_turn), making every proper finish look like a stall.
onStepFinish: (sf) => {
const now = Date.now();
const elapsedMs = now - lastStepTime;
Expand Down
6 changes: 5 additions & 1 deletion mcp/src/log/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,11 @@ export async function log_agent_context(
tools,
providerOptions: getProviderOptions(provider, undefined, modelId) as any,
stopWhen: hasEndMarker,
stopSequences: ["[END_OF_ANSWER]"],
// No stopSequences for the marker: termination is detected from the text
// (stopWhen / needsContinuation / extractFinalAnswer), which works on every
// provider. A stop sequence consumes the marker and leaves only the raw
// stop reason, which gateways rewrite (Bifrost maps stop_sequence to
// end_turn), making every proper finish look like a stall.
onStepFinish: (sf) => {
logStepMaybe(redactSecretsDeep(sf.content, redactOpts), opts.printAgentProgress);
const usage = normalizeUsage(sf.usage);
Expand Down
12 changes: 12 additions & 0 deletions mcp/src/repo/__tests__/turns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,18 @@ test.describe("turn emission", () => {
expect(turns).toEqual([]);
});

test("the [END_OF_ANSWER] protocol marker is stripped from reasoning turns", async () => {
const { emitStepTurns } = await import("../turns.js");
const sid = `sess-${randomUUID().slice(0, 8)}`;
const turns = emitStepTurns(sid, "hive", [
{ type: "text", text: "The answer.\n[END_OF_ANSWER]" },
{ type: "text", text: "[END_OF_ANSWER]" },
]);
expect(turns.length).toBe(1);
expect(turns[0].turn_type).toBe("reasoning");
expect(turns[0].content).toBe("The answer.");
});

test("a Concept-bearing tool result carries the concept link", async () => {
const { emitStepTurns } = await import("../turns.js");
const sid = `sess-${randomUUID().slice(0, 8)}`;
Expand Down
6 changes: 5 additions & 1 deletion mcp/src/repo/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,7 +925,11 @@ If the user's prompt mentions a sub-agent with an @mention (e.g. "@${validSubAge
instructions: transparent ? undefined : instructions,
tools,
stopWhen,
stopSequences: ["[END_OF_ANSWER]"],
// No stopSequences for the marker: termination is detected from the text
// (stopWhen / needsContinuation / extractFinalAnswer), which works on every
// provider. A stop sequence consumes the marker and leaves only the raw
// stop reason, which gateways rewrite (Bifrost maps stop_sequence to
// end_turn), making every proper finish look like a stall.
onStepFinish: (sf) => {
const now = Date.now();
const elapsedMs = now - lastStepTime;
Expand Down
6 changes: 3 additions & 3 deletions mcp/src/repo/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,9 @@ export interface StepMeta {
turn: number;
label?: string;
finishReason?: string;
// Raw provider stop reason (e.g. Anthropic "end_turn" vs "stop_sequence") —
// the unified finishReason maps both to "stop", hiding the difference
// between a proper [END_OF_ANSWER] finish and a stall.
// Raw provider stop reason (e.g. Anthropic "end_turn" vs "max_tokens") —
// the unified finishReason coarsens it. Diagnostic only: a proper finish is
// recognized by the [END_OF_ANSWER] marker in the text, not by this value.
rawFinishReason?: string;
usage: AiUsageWithLegacy;
cumulativeInput: number;
Expand Down
6 changes: 5 additions & 1 deletion mcp/src/repo/toolsJarvis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,11 @@ function registerGraphSubAgentTool(
tools: runTools,
providerOptions: getProviderOptions(details.provider, undefined, modelId) as any,
stopWhen: maxSteps > 0 ? [hasEndMarker, stepCountIs(maxSteps)] : hasEndMarker,
stopSequences: ["[END_OF_ANSWER]"],
// No stopSequences for the marker: termination is detected from the text
// (stopWhen / needsContinuation / extractFinalAnswer), which works on every
// provider. A stop sequence consumes the marker and leaves only the raw
// stop reason, which gateways rewrite (Bifrost maps stop_sequence to
// end_turn), making every proper finish look like a stall.
onStepFinish: (sf) => {
if (!childSessionId) return;
const now = Date.now();
Expand Down
15 changes: 13 additions & 2 deletions mcp/src/repo/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,15 @@ import path from "path";
import { db } from "../graph/neo4j.js";
import { conceptReadsFrom } from "./concepts.js";

/**
* The agents end their final answer with a literal [END_OF_ANSWER] marker
* (kept in the text on purpose — see needsContinuation in utils.ts). It is a
* protocol token, not content: drop it before the text becomes a Turn.
*/
function stripEndMarker(text: string): string {
return text.replace(/\[END_OF_ANSWER\]/g, "").trim();
}

// ── Live Turn emission ───────────────────────────────────────────────
// Mirrors each agent step into the graph as it happens:
//
Expand Down Expand Up @@ -299,7 +308,8 @@ export function emitStepTurns(

for (const part of assistantParts) {
if (part?.type === "text" && part.text) {
turns.push(buildTurn(sessionId, state, "reasoning", part.text, null));
const text = stripEndMarker(part.text);
if (text) turns.push(buildTurn(sessionId, state, "reasoning", text, null));
} else if (part?.type === "tool-call") {
if (part.toolCallId) {
state.pendingToolInputs.set(part.toolCallId, part.input);
Expand Down Expand Up @@ -461,7 +471,8 @@ export function turnsFromTranscript(
}
} else if (message.role === "assistant") {
if (part?.type === "text" && part.text) {
push("reasoning", part.text, null);
const text = stripEndMarker(part.text);
if (text) push("reasoning", text, null);
} else if (part?.type === "tool-call") {
if (part.toolCallId) pendingInputs.set(part.toolCallId, part.input);
let inputJson: string;
Expand Down
16 changes: 10 additions & 6 deletions mcp/src/repo/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,16 +117,20 @@ export function createHasEndMarkerCondition<
/**
* True when a run ended without a proper termination and should be nudged to
* continue. Proper terminations are: an ask_clarifying_questions call, or the
* [END_OF_ANSWER] marker — present in text, or matched as a stop sequence
* (surfaced as rawFinishReason "stop_sequence"). A run whose last step has no
* [END_OF_ANSWER] marker present in the text. A run whose last step has no
* tool calls and none of those is either a voluntary early stop (raw
* "end_turn" — the model narrated a plan or emitted only reasoning and quit)
* or a truncation ("length"); both are recoverable by asking it to continue.
*
* Detection relies on the raw provider stop reason: a proper Anthropic finish
* hits the [END_OF_ANSWER] stop sequence (raw "stop_sequence") while a stall
* ends with raw "end_turn". OpenAI-compatible providers report a plain "stop"
* either way, so their stalls are not detectable this way and are left alone.
* The marker is deliberately NOT registered as a provider stop sequence: a
* stop sequence consumes it, leaving only the raw stop reason to tell a
* finish from a stall, and that reason does not survive an LLM gateway
* (Bifrost maps Anthropic's "stop_sequence" to "stop" and back to "end_turn",
* so every proper finish looked like a stall and burned the nudge allowance).
* Marker-in-text works identically on every provider. The "stop_sequence"
* check below stays as a harmless belt-and-braces for any caller that still
* registers one. OpenAI-compatible providers report a plain "stop" for a
* voluntary stall, so those are not detectable and are left alone.
*/
/**
* True when the step cap ended a run mid-work, rather than the model finishing.
Expand Down
Loading