From f7c438271592af05227dcccbca055d2133e38e56 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:18:26 -0700 Subject: [PATCH 1/4] fix(hosted): a guard-stopped hosted turn answers, or says it couldn't, and costs no turn A hosted turn the content guard stopped after reading a web page ended failed with nothing posted: the person's @mention went unanswered and still used one of their turns. Measured: 3 of 437 hosted runs over 14 days, all after a page read, stopped on a site footer's link labels (a false positive). - On the first guard stop after web reads, each web result is checked on its own through LiteLLM's /guardrails/apply_guardrail (no model call). The result the guard stops is replaced with a fixed set-aside notice, every round after the one that brought it in is dropped, and the model gets one more round with no lookups. If the check cannot be made, every web result from the first web round on is set aside. - A hosted turn someone was waiting on (a mention) that still ends failed with nothing posted posts one fixed line. A declined turn (NO_REPLY, a silent group-pod turn) ends succeeded and never posts it. - A failed hosted run that posted nothing is stamped metered: false. The person's meter and their plan line leave it out; the per-user spend ceiling still counts every run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../services/hostedRuntimeService.test.js | 2 + .../nativeRuntimeService.guardStop.test.js | 346 ++++++++++++++++++ backend/models/AgentRun.ts | 10 + backend/services/hostedRuntimeService.ts | 4 +- backend/services/nativeRuntimeService.ts | 127 ++++++- docs/runbooks/litellm-guardrails.md | 22 ++ 6 files changed, 508 insertions(+), 3 deletions(-) create mode 100644 backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js diff --git a/backend/__tests__/unit/services/hostedRuntimeService.test.js b/backend/__tests__/unit/services/hostedRuntimeService.test.js index 4f3369b2d6..dfcd3cd55c 100644 --- a/backend/__tests__/unit/services/hostedRuntimeService.test.js +++ b/backend/__tests__/unit/services/hostedRuntimeService.test.js @@ -113,6 +113,8 @@ describe('hostedRuntimeService', () => { const filter = mockRunCount.mock.calls[0][0]; expect(filter).toMatchObject({ agentName: 'scout', instanceId: 'default', trigger: { $ne: 'first_contact' } }); expect(filter.startedAt.$gte.toISOString()).toMatch(/T00:00:00\.000Z$/); + // A run that failed and posted nothing is not one of the person's turns. + expect(filter.metered).toEqual({ $ne: false }); }); it('meters acked events by ACK time (deliveredAt) since the UTC day start and reports the reset', async () => { diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js new file mode 100644 index 0000000000..6bd711fa1f --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -0,0 +1,346 @@ +/** + * A hosted turn the guard stops, or one that fails, must not leave the person + * in silence or cost them a turn (2026-10-17: a research brief went unanswered + * twice and used two turns, because the guard stopped on a site's footer). + * + * Contract under test: + * - a guard stop after page reads sets aside the result the guard stops on + * its own, keeps the clean ones, and gives the model one more round with no + * lookups, so the person gets an answer from one run + * - every round after the one that brought the stopped text in is dropped + * - when the check cannot be made, every web result from the first web round + * on is set aside + * - a failed hosted turn someone was waiting on, with nothing posted, gets one + * fixed line; a declined turn, a greeting and a turn that already posted + * get none + * - a failed hosted run that posted nothing is left off the person's meter + * and their plan line; the per-user spend ceiling still counts it + */ + +jest.mock('axios', () => ({ __esModule: true, default: { post: jest.fn() } })); +jest.mock('../../../models/Pod', () => ({ + findById: jest.fn(() => ({ lean: jest.fn().mockResolvedValue({ name: 'Research' }) })), +})); +jest.mock('../../../models/AgentRun', () => ({ create: jest.fn(), countDocuments: jest.fn() })); +jest.mock('../../../models/User', () => ({ + findOne: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve(null) }) })), + findById: jest.fn(() => ({ select: () => ({ lean: () => Promise.resolve({ entitlements: { pro: false }, isBot: false }) }) })), +})); +jest.mock('../../../services/agentTypingService', () => ({ emitAgentTypingStart: jest.fn(), emitAgentTypingStop: jest.fn() })); +jest.mock('../../../services/agentMessageService', () => ({ postMessage: jest.fn() })); +jest.mock('../../../services/messageClaimService', () => ({ + claim: jest.fn().mockResolvedValue({ claimed: true, expiresAt: new Date() }), + release: jest.fn().mockResolvedValue({ released: true }), +})); +jest.mock('../../../services/messageClaimHandoffService', () => ({ handOff: jest.fn().mockResolvedValue(undefined) })); +jest.mock('../../../models/pg/Message', () => ({ findByPodId: jest.fn().mockResolvedValue([]) })); +jest.mock('../../../services/nativeOutsiderGrantPolicy', () => ({ + podHasOutsiderWritableGrant: jest.fn().mockResolvedValue(false), +})); +jest.mock('../../../services/webSearchService', () => ({ isSearchConfigured: () => false, searchWeb: jest.fn() })); +const mockBridged = jest.fn(); +jest.mock('../../../services/outsiderGrantPodEligibility', () => ({ + podIsBridged: (...args) => mockBridged(...args), +})); +jest.mock('../../../services/turnSandboxService', () => ({ isSandboxConfigured: () => false, TurnSandbox: jest.fn() })); +const mockGetPage = jest.fn(); +jest.mock('../../../services/outboundUrlGuard', () => ({ + ...jest.requireActual('../../../services/outboundUrlGuard'), + getFromPublicAddress: (...args) => mockGetPage(...args), +})); +jest.mock('../../../models/Message', () => { + const chain = { + sort: () => chain, limit: () => chain, select: () => chain, populate: () => chain, lean: () => Promise.resolve([]), + }; + return { find: jest.fn(() => chain) }; +}); + +const axios = require('axios').default; +const AgentRun = require('../../../models/AgentRun'); +const AgentMessageService = require('../../../services/agentMessageService'); +const { runAgent, HOSTED_RUN_FAILED_LINE } = require('../../../services/nativeRuntimeService'); + +const POD = '507f1f77bcf86cd799439011'; +const OWNER = '507f1f77bcf86cd799439099'; +const STOPPED_URL = 'https://stopped.example/blog'; +const CLEAN_URL = 'https://clean.example/changelog'; +const LATER_URL = 'https://later.example/releases'; +const STOPPED_TEXT = 'A page the filter stops.'; +const CLEAN_TEXT = 'Clean changelog entries.'; +const LATER_TEXT = 'Later release notes.'; +const GUARD_BODY = { error: { message: 'Rejected by guardrail: test-rule matched' } }; + +const hostedInstall = () => ({ + podId: POD, + agentName: 'maya-agent', + instanceId: 'default', + displayName: 'Maya', + installedBy: OWNER, + config: { runtime: { runtimeType: 'hosted' }, hosted: { engine: 'native', provisionedAt: new Date() } }, +}); +const mention = { + type: 'chat.mention', + eventId: '507f1f77bcf86cd799439012', + payload: { + content: `@maya-agent read ${STOPPED_URL} and ${CLEAN_URL} and ${LATER_URL}`, + userId: OWNER, + messageId: 'msg-1', + }, +}; + +const call = (id, name, args) => ({ id, type: 'function', function: { name, arguments: JSON.stringify(args) } }); +const toolCalls = (...calls) => ({ + status: 200, + headers: {}, + data: { + choices: [{ message: { role: 'assistant', content: null, tool_calls: calls } }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +}); +const reply = (content) => ({ + status: 200, + headers: {}, + data: { choices: [{ message: { role: 'assistant', content } }], usage: { prompt_tokens: 12, completion_tokens: 4, total_tokens: 16 } }, +}); +const guardStop = { status: 400, headers: {}, data: GUARD_BODY }; +const fetchCall = (id, url) => call(id, 'commonly_fetch_url', { url }); + +// The model's replies, in order. The guard check (no model call) answers by +// text: it stops only the page the filter stops. +let chatReplies; +let checkUnavailable; +let chatBodies; +const guardChecks = () => axios.post.mock.calls.filter(([url]) => url.endsWith('/guardrails/apply_guardrail')); +const offeredIn = (body) => body.tools.map((tool) => tool.function.name); +const posted = () => AgentMessageService.postMessage.mock.calls.map(([args]) => args.content); + +describe('a hosted turn the guard stops, or that fails', () => { + let savedRun; + beforeEach(() => { + jest.clearAllMocks(); + process.env.LITELLM_BASE_URL = 'http://litellm.test'; + process.env.LITELLM_MASTER_KEY = 'test-key'; + chatReplies = []; + chatBodies = []; + checkUnavailable = false; + mockBridged.mockResolvedValue(false); + AgentMessageService.postMessage.mockResolvedValue({ success: true }); + AgentRun.countDocuments.mockResolvedValue(0); + AgentRun.create.mockImplementation(async (doc) => { + savedRun = { _id: 'run-1', ...doc, save: jest.fn().mockResolvedValue(undefined) }; + return savedRun; + }); + const pages = { [STOPPED_URL]: STOPPED_TEXT, [CLEAN_URL]: CLEAN_TEXT, [LATER_URL]: LATER_TEXT }; + mockGetPage.mockImplementation(async (url) => ({ + ok: true, status: 200, contentType: 'text/html', text: pages[url], truncated: false, url, + })); + axios.post.mockImplementation(async (url, body) => { + if (url.endsWith('/guardrails/apply_guardrail')) { + if (checkUnavailable) return { status: 404, data: {} }; + return String(body.text).includes(STOPPED_TEXT) ? { status: 400, data: {} } : { status: 200, data: {} }; + } + // The runtime re-sends one messages array; keep what each call carried. + chatBodies.push(JSON.parse(JSON.stringify(body))); + const next = chatReplies.shift(); + if (!next) throw new Error('unexpected model call'); + return next; + }); + }); + + test('sets aside the page the guard stops alone, keeps the clean one, and answers in one more round', async () => { + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL), fetchCall('c2', CLEAN_URL)), + guardStop, + reply('Here is the changelog. I could not use the blog page.'), + ]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('succeeded'); + expect(savedRun.webSetAside).toBe(1); + expect(savedRun.metered).toBeUndefined(); + const extra = JSON.stringify(chatBodies[2].messages); + expect(extra).not.toContain(STOPPED_TEXT); + expect(extra).toContain(CLEAN_TEXT); + expect(extra).toMatch(/This result was set aside/); + // No lookups in the extra round. + expect(offeredIn(chatBodies[2])).not.toContain('commonly_fetch_url'); + expect(offeredIn(chatBodies[0])).toContain('commonly_fetch_url'); + expect(posted()).toEqual(['Here is the changelog. I could not use the blog page.']); + }); + + test('drops every round after the one that brought the stopped text in', async () => { + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL)), + // Chosen with the stopped page in front of the model. + toolCalls(fetchCall('c2', LATER_URL)), + guardStop, + reply('I could not use the blog page.'), + ]; + + await runAgent(hostedInstall(), mention); + + const extra = chatBodies[3].messages; + const text = JSON.stringify(extra); + expect(text).not.toContain(STOPPED_TEXT); + expect(text).not.toContain(LATER_TEXT); + expect(extra.flatMap((m) => m.tool_calls || []).map((tc) => tc.id)).toEqual(['c1']); + expect(extra.filter((m) => m.role === 'tool').map((m) => m.tool_call_id)).toEqual(['c1']); + }); + + test('when the check cannot be made, every web result from the first web round on is set aside', async () => { + checkUnavailable = true; + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL), fetchCall('c2', CLEAN_URL)), + guardStop, + reply('I could not use either page.'), + ]; + + await runAgent(hostedInstall(), mention); + + const extra = JSON.stringify(chatBodies[2].messages); + expect(extra).not.toContain(STOPPED_TEXT); + expect(extra).not.toContain(CLEAN_TEXT); + expect(savedRun.webSetAside).toBe(2); + }); + + test('a stop the web results do not explain ends the run with the fixed line, and no turn used', async () => { + chatReplies = [toolCalls(fetchCall('c1', CLEAN_URL)), guardStop]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'guardrail_blocked' })); + expect(guardChecks()).not.toHaveLength(0); + expect(chatBodies).toHaveLength(2); + expect(savedRun.webSetAside).toBeUndefined(); + expect(savedRun.metered).toBe(false); + expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + }); + + test('a second stop in the extra round ends the run, with one set-aside and one fixed line', async () => { + chatReplies = [toolCalls(fetchCall('c1', STOPPED_URL)), guardStop, guardStop]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('failed'); + expect(chatBodies).toHaveLength(3); + expect(savedRun.webSetAside).toBe(1); + expect(savedRun.metered).toBe(false); + expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + }); + + test('a stop with no web read checks nothing and ends with the fixed line', async () => { + chatReplies = [guardStop]; + + await runAgent(hostedInstall(), mention); + + expect(guardChecks()).toHaveLength(0); + expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + }); + + test('a model error with nothing posted gets the fixed line and uses no turn', async () => { + chatReplies = [{ status: 502, headers: {}, data: { error: 'upstream unavailable' } }]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'llm_error' })); + expect(savedRun.metered).toBe(false); + expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + }); + + test('neither the fixed line nor the set-aside notice repeats the guard or the page', async () => { + chatReplies = [toolCalls(fetchCall('c1', STOPPED_URL)), guardStop, guardStop]; + + await runAgent(hostedInstall(), mention); + + const everything = JSON.stringify([chatBodies[2].messages, posted()]); + expect(everything).not.toContain('test-rule'); + expect(everything).not.toContain(STOPPED_TEXT); + expect(HOSTED_RUN_FAILED_LINE).not.toMatch(/filter|guard|page/i); + }); + + describe('the fixed line goes only to a turn someone was waiting on', () => { + test('a declined turn (NO_REPLY) ends succeeded and posts nothing more', async () => { + AgentMessageService.postMessage.mockResolvedValue({ skipped: true }); + chatReplies = [reply('NO_REPLY')]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('succeeded'); + expect(posted()).toEqual(['NO_REPLY']); + expect(savedRun.metered).toBeUndefined(); + }); + + test('a silent turn (no text, no post) ends succeeded and posts nothing', async () => { + chatReplies = [reply('')]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('succeeded'); + expect(posted()).toEqual([]); + expect(savedRun.metered).toBeUndefined(); + }); + + test('a failed greeting posts nothing, though it costs no turn', async () => { + chatReplies = [guardStop]; + + await runAgent(hostedInstall(), { type: 'first_contact', eventId: '507f1f77bcf86cd799439013', payload: {} }); + + expect(posted()).toEqual([]); + expect(savedRun.metered).toBe(false); + }); + + test('a turn that posted before it failed counts, and gets no fixed line', async () => { + chatReplies = [ + toolCalls(call('c1', 'commonly_post_message', { podId: POD, content: 'Here is what I found.' })), + { status: 502, headers: {}, data: { error: 'upstream unavailable' } }, + ]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('failed'); + expect(posted()).toEqual(['Here is what I found.']); + expect(savedRun.metered).toBeUndefined(); + }); + + test('a pod bridged mid-turn after a page read gets no fixed line', async () => { + // The bridge is added once the page is in the turn. + mockBridged.mockImplementation(async () => mockGetPage.mock.calls.length > 0); + chatReplies = [toolCalls(fetchCall('c1', CLEAN_URL)), guardStop]; + + await runAgent(hostedInstall(), mention); + + expect(posted()).toEqual([]); + expect(savedRun.metered).toBe(false); + }); + + test('a native install that is not hosted is unchanged: no fixed line, no meter flag', async () => { + chatReplies = [guardStop]; + + await runAgent({ + podId: POD, + agentName: 'analyst', + instanceId: 'default', + displayName: 'Analyst', + installedBy: OWNER, + config: { runtime: { runtimeType: 'native' }, tools: ['commonly_read_context', 'commonly_post_message'] }, + }, mention); + + expect(posted()).toEqual([]); + expect(savedRun.metered).toBeUndefined(); + }); + }); + + test('the plan line leaves unmetered runs out; the per-user spend ceiling counts every run', async () => { + chatReplies = [reply('Done.')]; + + await runAgent(hostedInstall(), mention); + + const queries = AgentRun.countDocuments.mock.calls.map(([query]) => query); + const planLine = queries.find((query) => query.podId); + const ceiling = queries.find((query) => query.userId); + expect(planLine.metered).toEqual({ $ne: false }); + expect(ceiling).toBeDefined(); + expect(ceiling).not.toHaveProperty('metered'); + }); +}); diff --git a/backend/models/AgentRun.ts b/backend/models/AgentRun.ts index 54a854e78f..4b677e83cf 100644 --- a/backend/models/AgentRun.ts +++ b/backend/models/AgentRun.ts @@ -85,6 +85,14 @@ export interface IAgentRun extends Document { webSearches?: number; /** Web pages the turn requested (TASK-267b); absent when it read none. */ webFetches?: number; + /** Web results set aside after the guard stopped the turn on them; absent when none were. */ + webSetAside?: number; + /** + * False when the person's turn meter leaves this run out: a hosted run that + * failed and posted nothing. Absent means counted. The per-user spend + * ceiling counts every run, whatever this says. + */ + metered?: boolean; startedAt: Date; completedAt?: Date; errorKind?: AgentRunErrorKind; @@ -143,6 +151,8 @@ const AgentRunSchema = new Schema( sandboxSeconds: { type: Number }, webSearches: { type: Number }, webFetches: { type: Number }, + webSetAside: { type: Number }, + metered: { type: Boolean }, startedAt: { type: Date, default: Date.now }, completedAt: { type: Date }, errorKind: { diff --git a/backend/services/hostedRuntimeService.ts b/backend/services/hostedRuntimeService.ts index ad916def7c..702f93bd5d 100644 --- a/backend/services/hostedRuntimeService.ts +++ b/backend/services/hostedRuntimeService.ts @@ -160,7 +160,8 @@ export const turnsToday = async (agentName: string, instanceId: string): Promise // model is actually called. The event count above also counts an event that // was acked without a run, such as an addressed turn that stood down to a // peer's claim (QA walk 4, 2026-10-16: the ask got no answer and still showed -// as a used turn). The greeting is left out here too. +// as a used turn). The greeting is left out here too, and so is a run that +// failed and posted nothing (`metered: false`): the person got no answer. export const nativeTurnsToday = async (agentName: string, instanceId: string): Promise => { // eslint-disable-next-line global-require, @typescript-eslint/no-require-imports const AgentRun = require('../models/AgentRun'); @@ -169,6 +170,7 @@ export const nativeTurnsToday = async (agentName: string, instanceId: string): P instanceId: instanceId || 'default', startedAt: { $gte: utcDayStart() }, trigger: { $ne: 'first_contact' }, + metered: { $ne: false }, }); }; diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 3065597f40..30c7a16b92 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -101,6 +101,19 @@ const LITELLM_TIMEOUT_MS = Number(process.env.NATIVE_RUNTIME_TIMEOUT_MS) || 45_0 const NATIVE_RUNTIME_GUARDRAILS = ( process.env.NATIVE_RUNTIME_GUARDRAILS ?? 'openai-moderation-enforce,injection-guard' ).split(',').map((s) => s.trim()).filter(Boolean); +// How a guardrail rejection reads in a chat call's error body. +const GUARDRAIL_REJECTION = /guardrail|moderation|prompt-injection/i; +const GUARD_CHECK_TIMEOUT_MS = 10_000; + +// A web result the guard stops on its own is replaced with this before the +// turn's one extra round. It names no rule and repeats nothing from the result. +const WEB_SET_ASIDE_NOTICE = 'This result was set aside: a content filter stopped it, and its text is no longer ' + + 'in this conversation. Answer with what you have, and tell the person which page or search you could not use.'; +// What a hosted agent posts when a turn someone was waiting on ends on a guard +// stop or an error with nothing posted. Fixed text, so it carries nothing the +// turn read. +export const HOSTED_RUN_FAILED_LINE = 'I couldn\'t finish a reply to this, and it didn\'t count against today\'s ' + + 'turns. You can ask again, or ask it a different way.'; // --- helpers --------------------------------------------------------------- @@ -127,6 +140,43 @@ export function isNativeRuntimeAvailable(): boolean { return resolveLiteLLM() !== null; } +/** + * Which of these texts the turn's guardrails stop on their own, checked with + * no model call: LiteLLM's /guardrails/apply_guardrail answers 2xx for a pass + * and 400 for a stop. Null when a check cannot be made, so the caller sets + * aside every candidate instead of guessing which one was clean. + */ +async function textsTheGuardStops( + litellm: { baseUrl: string; apiKey: string }, + texts: string[], +): Promise { + try { + return await Promise.all(texts.map(async (text) => { + for (const guardrailName of NATIVE_RUNTIME_GUARDRAILS) { + // eslint-disable-next-line no-await-in-loop + const resp = await axios.post( + `${litellm.baseUrl}/guardrails/apply_guardrail`, + { guardrail_name: guardrailName, text }, + { + headers: { Authorization: `Bearer ${litellm.apiKey}`, 'Content-Type': 'application/json' }, + timeout: GUARD_CHECK_TIMEOUT_MS, + validateStatus: () => true, + }, + ); + if (resp?.status === 400) return true; + if (!(resp?.status >= 200 && resp?.status < 300)) throw new Error(`guardrail check HTTP ${resp?.status}`); + } + return false; + })); + } catch (error) { + console.warn( + '[native-runtime] guardrail check unavailable; setting aside every web result:', + (error as Error).message, + ); + return null; + } +} + /** * Whether a hosted agent on `engine` can run on this instance. The hosted * routes and Scout's hosted offer both ask, so the offer never shows a button @@ -1338,6 +1388,10 @@ export async function runAgent( instanceId, startedAt: { $gte: dayStart }, trigger: { $ne: UNMETERED_TRIGGER }, + // For a hosted install this cap is the person's plan line, so it + // leaves out the runs their meter leaves out (nativeTurnsToday). The + // per-user ceiling below counts every run. + ...(hostedNative ? { metered: { $ne: false } } : {}), }); if (runsToday >= dailyRunCap) { console.warn( @@ -1589,6 +1643,7 @@ export async function runAgent( run.status = 'failed'; run.errorKind = 'config'; run.errorMessage = 'LiteLLM resolved to null at dispatch time'; + if (hostedNative) run.metered = false; run.completedAt = new Date(); await run.save(); emitStop(); @@ -1666,6 +1721,32 @@ export async function runAgent( } } + // A guard stop after the turn read the web is most often a page, not the + // person. So the turn keeps where each web result sits (its tool-call round + // and its message), and on the first stop sets aside the result the guard + // stops on its own, drops every round after the one that brought it in, and + // gives the model one more round with no lookups. Every later round was + // produced with that text in front of the model, so none of it is kept. + const webResults: Array<{ round: number; index: number }> = []; + const roundEnds: number[] = []; + let webSetAside = false; + const setAsideStoppedWebResult = async (): Promise => { + const stops = await textsTheGuardStops(litellm, webResults.map(({ index }) => String(messages[index].content))); + // The guard stopped on something else, the pod's text or the request. + if (stops && !stops.includes(true)) return 0; + const firstRound = stops ? webResults[stops.indexOf(true)].round : webResults[0].round; + const aside = webResults.filter((result, i) => result.round === firstRound && (!stops || stops[i])); + messages.length = roundEnds[firstRound]; + roundEnds.length = firstRound + 1; + aside.forEach(({ index }) => { + messages[index] = { ...messages[index], content: JSON.stringify({ ok: false, error: WEB_SET_ASIDE_NOTICE }) }; + }); + const kept = webResults.filter((result) => result.round < firstRound + || (result.round === firstRound && !aside.includes(result))); + webResults.splice(0, webResults.length, ...kept); + return aside.length; + }; + try { // Tool names offered in the most recent model call; a call to anything // else is refused at dispatch. @@ -1706,8 +1787,10 @@ export async function runAgent( // (TASK-267a): only in-pod read and post, and searching, remain. const webTainted = Boolean(dispatchCtx.webTainted); const podBridged = mayLookUp ? await podBridgedNow() : false; + const lookupsOff = webSetAside; const offeredTools = [ - ...toolsForConfig(cfg, inPodOnly, { webTainted, podBridged }), + ...toolsForConfig(cfg, inPodOnly, { webTainted, podBridged }) + .filter((tool) => !lookupsOff || !WEB_LOOKUP_TOOLS.has(tool.function.name)), ...(webTainted ? [] : broker.tools), ]; offeredToolNames = new Set(offeredTools.map((tool: any) => String(tool?.function?.name || ''))); @@ -1746,7 +1829,16 @@ export async function runAgent( // error status from LiteLLM. Tag it distinctly so it's observable in // AgentRun metrics (told apart from genuine LLM failures) and never // surfaces the raw proxy payload back to a possibly-malicious user. - if (/guardrail|moderation|prompt-injection/i.test(bodyText)) { + if (GUARDRAIL_REJECTION.test(bodyText)) { + // Once a turn, and never after a post: the person already has that answer. + if (!webSetAside && !postedViaTool && webResults.length > 0) { + const setAside = await setAsideStoppedWebResult(); + if (setAside > 0) { + webSetAside = true; + run.webSetAside = setAside; + continue; + } + } run.status = 'failed'; run.errorKind = 'guardrail_blocked'; run.errorMessage = 'Request blocked by a content guardrail (prompt-injection / moderation).'; @@ -1783,6 +1875,7 @@ export async function runAgent( const toolCalls = msg.tool_calls || []; if (toolCalls.length > 0) { + const round = roundEnds.length; // Preserve the assistant message with tool_calls so the next turn's // tool response messages can reference tool_call_id. messages.push({ @@ -1887,8 +1980,10 @@ export async function runAgent( ? `${untrustedConnectorMarker(result.untrustedSourceTitle)}\n` : '') + JSON.stringify(result.content ?? { ok: !result.error }), }); + if (WEB_LOOKUP_TOOLS.has(toolName) && !result.error) webResults.push({ round, index: messages.length - 1 }); } } + roundEnds.push(messages.length); turn.elapsedMs = Date.now() - turnStart; run.turns.push(turn); @@ -1956,6 +2051,34 @@ export async function runAgent( run.errorMessage = (loopErr as Error).message?.slice(0, 1000) || 'unknown error'; } + // A hosted turn that failed (a guard stop or an error) and posted nothing + // costs the person no turn. When someone was waiting on it, a mention or a + // DM, it posts one fixed line instead of leaving them in silence. A turn + // that declined (NO_REPLY, a silent group-pod turn) ended succeeded and + // never reaches this. The per-user spend ceiling still counts the run. + if (hostedNative && run.status === 'failed' && !postedViaTool && !finalMessage) { + run.metered = false; + if (wasDirectlyAddressed(trigger) && !(dispatchCtx.webTainted && await podBridgedNow())) { + try { + const AgentMessageService = require('./agentMessageService'); + await AgentMessageService.postMessage({ + agentName, + instanceId, + podId, + content: HOSTED_RUN_FAILED_LINE, + messageType: 'text', + displayName, + installationConfig: cfg, + metadata: { source: 'native-runtime', runFailed: true }, + noOutboundRelay: Boolean(dispatchCtx.webTainted), + answeringMessageId: dispatchCtx.answeringMessageId, + }); + } catch (postErr) { + console.warn('[native-runtime] failed-run line not posted:', (postErr as Error).message); + } + } + } + // TASK-266: the sandbox lives for one turn. Close it whatever happened, and // record how long it ran: E2B has no spending cap, so the spend alarm prices it. if (dispatchCtx.sandbox) { diff --git a/docs/runbooks/litellm-guardrails.md b/docs/runbooks/litellm-guardrails.md index 64aa10ed16..3c924ab6fa 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -31,6 +31,28 @@ Bounded by the opt-in scoping + the per-path env off-switches below. If it bites either flip the env off-switch or override that category with a trimmed `category_file`. +**Second known false positive: page text.** A hosted agent reads web pages, +and a page reduced to text runs its navigation labels together with no +sentence breaks, so a site's footer can read as one sentence and match the +data-exfiltration category. Nothing in such a page is an attack. Measured +2026-10-17: 3 of 437 hosted runs over 14 days were stopped, all after a page +read. + +**When the guard stops a hosted turn** (`nativeRuntimeService.runAgent`): + +- If the turn has read the web and posted nothing yet, each web result is + checked on its own through LiteLLM's `/guardrails/apply_guardrail`, which + calls no model. The result the guard stops is replaced with a fixed + set-aside notice, every round after the one that brought it in is dropped, + and the model gets one more round with no lookups. If the check cannot be + made, every web result from the first web round on is set aside instead. +- If the turn still ends failed with nothing posted, and someone was waiting + on it (a mention), the agent posts one fixed line. A declined turn + (`NO_REPLY`, a silent group-pod turn) ends succeeded and never posts it. +- A failed hosted run that posted nothing is stamped `metered: false`: the + person's meter and their plan line leave it out, and the per-user spend + ceiling still counts it. + ## The scoping (why dev agents aren't blocked) Both guardrails are `default_on: false`, so they run **only when a caller opts in**. From 6f06ebdbce8de2743586f456b820e41c56e0fc51 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:48:56 -0700 Subject: [PATCH 2/4] fix(hosted): fail unposted fallback turns Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium --- .../services/nativeRuntimeService.guardStop.test.js | 11 +++++++++++ backend/services/nativeRuntimeService.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js index 6bd711fa1f..46a6bcdf22 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -248,6 +248,17 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); }); + test('a thrown fallback post fails unmetered and tries the fixed line', async () => { + AgentMessageService.postMessage.mockRejectedValueOnce(new Error('post unavailable')); + chatReplies = [reply('Draft answer')]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'tool_error' })); + expect(savedRun.metered).toBe(false); + expect(posted()).toEqual(['Draft answer', HOSTED_RUN_FAILED_LINE]); + }); + test('neither the fixed line nor the set-aside notice repeats the guard or the page', async () => { chatReplies = [toolCalls(fetchCall('c1', STOPPED_URL)), guardStop, guardStop]; diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 30c7a16b92..192d952c89 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -2037,6 +2037,7 @@ export async function runAgent( // complete the claim and swallow the human wake a second time. if (!postResult?.skipped) finalMessage = textOut; } catch (postErr) { + run.status = 'failed'; run.errorMessage = (postErr as Error).message; run.errorKind = 'tool_error'; } From 90d356df9c8c4d4ad3742bc2d15516ac6bb7f782 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:39:48 -0700 Subject: [PATCH 3/4] fix(hosted): the round after a set-aside is one model call that ends the run Rhea's HOLD at f7c43827: after a set-aside, a recovery that called a tool fell through to the ordinary loop, so a refused lookup or a valid post was followed by another model call that could publish a second reply. The recovery round is now offered only a post, publishes at most one reply, and ends the run: a reply or a declined turn succeeds, anything else fails and gets the fixed handling. Call counts are pinned at three for a tool post, two posts in one call, a refused lookup and a declined reply. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../nativeRuntimeService.guardStop.test.js | 71 ++++++++++++++++++- backend/services/nativeRuntimeService.ts | 31 ++++++-- docs/runbooks/litellm-guardrails.md | 6 +- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js index 46a6bcdf22..3606eaff9f 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -163,12 +163,79 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(extra).not.toContain(STOPPED_TEXT); expect(extra).toContain(CLEAN_TEXT); expect(extra).toMatch(/This result was set aside/); - // No lookups in the extra round. - expect(offeredIn(chatBodies[2])).not.toContain('commonly_fetch_url'); + // The extra round is offered a post and nothing else. + expect(offeredIn(chatBodies[2])).toEqual(['commonly_post_message']); expect(offeredIn(chatBodies[0])).toContain('commonly_fetch_url'); + expect(chatBodies).toHaveLength(3); expect(posted()).toEqual(['Here is the changelog. I could not use the blog page.']); }); + // Rhea on #2305: the extra round is one model call, not the rest of the budget. + describe('the extra round is one model call', () => { + const neverAsked = reply('A later round.'); + + test('a reply posted through the tool ends the run, with one post', async () => { + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL)), + guardStop, + toolCalls(call('c2', 'commonly_post_message', { podId: POD, content: 'Recovered once.' })), + neverAsked, + ]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('succeeded'); + expect(chatBodies).toHaveLength(3); + expect(posted()).toEqual(['Recovered once.']); + expect(savedRun.metered).toBeUndefined(); + }); + + test('two posts in that call publish one', async () => { + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL)), + guardStop, + toolCalls( + call('c2', 'commonly_post_message', { podId: POD, content: 'Recovered once.' }), + call('c3', 'commonly_post_message', { podId: POD, content: 'Recovered twice.' }), + ), + neverAsked, + ]; + + await runAgent(hostedInstall(), mention); + + expect(chatBodies).toHaveLength(3); + expect(posted()).toEqual(['Recovered once.']); + }); + + test('a lookup asked for in that call is refused and the run ends with the fixed line', async () => { + chatReplies = [ + toolCalls(fetchCall('c1', STOPPED_URL)), + guardStop, + toolCalls(fetchCall('c2', CLEAN_URL)), + neverAsked, + ]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result).toEqual(expect.objectContaining({ status: 'failed', errorKind: 'guardrail_blocked' })); + expect(chatBodies).toHaveLength(3); + expect(mockGetPage.mock.calls).toEqual([[STOPPED_URL]]); + expect(posted()).toEqual([HOSTED_RUN_FAILED_LINE]); + expect(savedRun.metered).toBe(false); + }); + + test('a declined reply in that call ends the run without the fixed line', async () => { + AgentMessageService.postMessage.mockResolvedValue({ skipped: true }); + chatReplies = [toolCalls(fetchCall('c1', STOPPED_URL)), guardStop, reply('NO_REPLY'), neverAsked]; + + const result = await runAgent(hostedInstall(), mention); + + expect(result.status).toBe('succeeded'); + expect(chatBodies).toHaveLength(3); + expect(posted()).toEqual(['NO_REPLY']); + }); + }); + test('drops every round after the one that brought the stopped text in', async () => { chatReplies = [ toolCalls(fetchCall('c1', STOPPED_URL)), diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index 192d952c89..c4c38fcfc0 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -109,6 +109,7 @@ const GUARD_CHECK_TIMEOUT_MS = 10_000; // turn's one extra round. It names no rule and repeats nothing from the result. const WEB_SET_ASIDE_NOTICE = 'This result was set aside: a content filter stopped it, and its text is no longer ' + 'in this conversation. Answer with what you have, and tell the person which page or search you could not use.'; +const RECOVERY_REPLIED_REFUSAL = 'This turn has posted its reply.'; // What a hosted agent posts when a turn someone was waiting on ends on a guard // stop or an error with nothing posted. Fixed text, so it carries nothing the // turn read. @@ -1787,11 +1788,13 @@ export async function runAgent( // (TASK-267a): only in-pod read and post, and searching, remain. const webTainted = Boolean(dispatchCtx.webTainted); const podBridged = mayLookUp ? await podBridgedNow() : false; - const lookupsOff = webSetAside; + // The round after a set-aside is the recovery round: one model call, + // offered only a post, and the run ends after it whatever it did. + const recoveryRound = webSetAside; const offeredTools = [ ...toolsForConfig(cfg, inPodOnly, { webTainted, podBridged }) - .filter((tool) => !lookupsOff || !WEB_LOOKUP_TOOLS.has(tool.function.name)), - ...(webTainted ? [] : broker.tools), + .filter((tool) => !recoveryRound || tool.function.name === 'commonly_post_message'), + ...(webTainted || recoveryRound ? [] : broker.tools), ]; offeredToolNames = new Set(offeredTools.map((tool: any) => String(tool?.function?.name || ''))); try { @@ -1941,9 +1944,13 @@ export async function runAgent( // naming the grant; any other tool not offered this turn is refused here. const narrowedAway = Boolean(dispatchCtx.inPodOnly) && !IN_POD_NATIVE_TOOLS.has(toolName); const notOffered = `${toolName || 'That tool'} is not available to you in this turn.`; - const result: DispatchResult = offeredToolNames.has(toolName) || narrowedAway || webTaintedAway - ? await dispatchTool(toolName, parsedArgs, dispatchCtx) - : { content: { ok: false, error: notOffered }, error: notOffered }; + // The recovery round posts at most one reply. + const replied = recoveryRound && postedViaTool ? RECOVERY_REPLIED_REFUSAL : ''; + let result: DispatchResult; + if (replied) result = { content: { ok: false, error: replied }, error: replied }; + else if (offeredToolNames.has(toolName) || narrowedAway || webTaintedAway) { + result = await dispatchTool(toolName, parsedArgs, dispatchCtx); + } else result = { content: { ok: false, error: notOffered }, error: notOffered }; const elapsed = Date.now() - toolStart; turn.toolCalls.push({ name: toolName, @@ -1991,6 +1998,18 @@ export async function runAgent( await run.save(); turnIndex += 1; + // The recovery round ends the run: answered (or declined through a + // post) is a success, anything else could not finish. + if (recoveryRound) { + if (postedViaTool) { + run.status = run.status === 'running' ? 'succeeded' : run.status; + } else { + run.status = 'failed'; + run.errorKind = 'guardrail_blocked'; + run.errorMessage = 'Request blocked by a content guardrail (prompt-injection / moderation).'; + } + break; + } // A hosted agent's greeting is one post. Given another round, the // model posted the greeting's last line as a second message (QA walk // 4, 2026-10-16), so its first-contact run ends at its first post. diff --git a/docs/runbooks/litellm-guardrails.md b/docs/runbooks/litellm-guardrails.md index 3c924ab6fa..fdda28dbb6 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -44,8 +44,10 @@ read. checked on its own through LiteLLM's `/guardrails/apply_guardrail`, which calls no model. The result the guard stops is replaced with a fixed set-aside notice, every round after the one that brought it in is dropped, - and the model gets one more round with no lookups. If the check cannot be - made, every web result from the first web round on is set aside instead. + and the model gets one more call, offered only a post. That call ends the + run: one reply at most, and anything other than a reply or a declined turn + is a failure. If the check cannot be made, every web result from the first + web round on is set aside instead. - If the turn still ends failed with nothing posted, and someone was waiting on it (a mention), the agent posts one fixed line. A declined turn (`NO_REPLY`, a silent group-pod turn) ends succeeded and never posts it. From 32e0b3b3b5792c497af621bc4e80a7887563780f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 17 Oct 2026 07:50:13 -0700 Subject: [PATCH 4/4] fix(native): a reply whose post throws fails the run; webSetAside counts every web result lost From vera's notes on #2305 at f7c43827. When the fallback post threw, the run was still marked succeeded, so a hosted turn ended silent, got no fixed line and used a turn. It now fails with tool_error, for every native run, since a reply that never reached the pod is not an answer. webSetAside now counts the results set aside plus those in the rounds dropped after them, not just the first round's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wkz5axAzCvoLEFkZ5KtkKG --- .../nativeRuntimeService.guardStop.test.js | 2 ++ backend/models/AgentRun.ts | 5 ++++- backend/services/nativeRuntimeService.ts | 15 ++++++++++----- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js index 3606eaff9f..04dd24f8a5 100644 --- a/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -253,6 +253,8 @@ describe('a hosted turn the guard stops, or that fails', () => { expect(text).not.toContain(LATER_TEXT); expect(extra.flatMap((m) => m.tool_calls || []).map((tc) => tc.id)).toEqual(['c1']); expect(extra.filter((m) => m.role === 'tool').map((m) => m.tool_call_id)).toEqual(['c1']); + // The stopped page and the one read after it. + expect(savedRun.webSetAside).toBe(2); }); test('when the check cannot be made, every web result from the first web round on is set aside', async () => { diff --git a/backend/models/AgentRun.ts b/backend/models/AgentRun.ts index 4b677e83cf..25fa789322 100644 --- a/backend/models/AgentRun.ts +++ b/backend/models/AgentRun.ts @@ -85,7 +85,10 @@ export interface IAgentRun extends Document { webSearches?: number; /** Web pages the turn requested (TASK-267b); absent when it read none. */ webFetches?: number; - /** Web results set aside after the guard stopped the turn on them; absent when none were. */ + /** + * Web results the turn lost after the guard stopped it: the ones set aside + * plus the ones in the rounds dropped after them. Absent when none were. + */ webSetAside?: number; /** * False when the person's turn meter leaves this run out: a hosted run that diff --git a/backend/services/nativeRuntimeService.ts b/backend/services/nativeRuntimeService.ts index c4c38fcfc0..cf24e38545 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -1731,10 +1731,13 @@ export async function runAgent( const webResults: Array<{ round: number; index: number }> = []; const roundEnds: number[] = []; let webSetAside = false; - const setAsideStoppedWebResult = async (): Promise => { + // Returns how many results were set aside, and how many web results the + // turn lost in all (those plus the ones in the dropped rounds). + const setAsideStoppedWebResult = async (): Promise<{ setAside: number; removed: number }> => { const stops = await textsTheGuardStops(litellm, webResults.map(({ index }) => String(messages[index].content))); // The guard stopped on something else, the pod's text or the request. - if (stops && !stops.includes(true)) return 0; + if (stops && !stops.includes(true)) return { setAside: 0, removed: 0 }; + const before = webResults.length; const firstRound = stops ? webResults[stops.indexOf(true)].round : webResults[0].round; const aside = webResults.filter((result, i) => result.round === firstRound && (!stops || stops[i])); messages.length = roundEnds[firstRound]; @@ -1745,7 +1748,7 @@ export async function runAgent( const kept = webResults.filter((result) => result.round < firstRound || (result.round === firstRound && !aside.includes(result))); webResults.splice(0, webResults.length, ...kept); - return aside.length; + return { setAside: aside.length, removed: before - kept.length }; }; try { @@ -1835,10 +1838,10 @@ export async function runAgent( if (GUARDRAIL_REJECTION.test(bodyText)) { // Once a turn, and never after a post: the person already has that answer. if (!webSetAside && !postedViaTool && webResults.length > 0) { - const setAside = await setAsideStoppedWebResult(); + const { setAside, removed } = await setAsideStoppedWebResult(); if (setAside > 0) { webSetAside = true; - run.webSetAside = setAside; + run.webSetAside = removed; continue; } } @@ -2056,6 +2059,8 @@ export async function runAgent( // complete the claim and swallow the human wake a second time. if (!postResult?.skipped) finalMessage = textOut; } catch (postErr) { + // The reply did not reach the pod, so the run did not answer: it + // fails, and a hosted one gets the fixed handling below (vera on #2305). run.status = 'failed'; run.errorMessage = (postErr as Error).message; run.errorKind = 'tool_error';