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..04dd24f8a5 --- /dev/null +++ b/backend/__tests__/unit/services/nativeRuntimeService.guardStop.test.js @@ -0,0 +1,426 @@ +/** + * 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/); + // 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)), + // 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']); + // 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 () => { + 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('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]; + + 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..25fa789322 100644 --- a/backend/models/AgentRun.ts +++ b/backend/models/AgentRun.ts @@ -85,6 +85,17 @@ export interface IAgentRun extends Document { webSearches?: number; /** Web pages the turn requested (TASK-267b); absent when it read none. */ webFetches?: number; + /** + * 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 + * 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 +154,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..cf24e38545 100644 --- a/backend/services/nativeRuntimeService.ts +++ b/backend/services/nativeRuntimeService.ts @@ -101,6 +101,20 @@ 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.'; +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. +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 +141,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 +1389,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 +1644,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 +1722,35 @@ 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; + // 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 { 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]; + 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 { setAside: aside.length, removed: before - kept.length }; + }; + try { // Tool names offered in the most recent model call; a call to anything // else is refused at dispatch. @@ -1706,9 +1791,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; + // 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 }), - ...(webTainted ? [] : broker.tools), + ...toolsForConfig(cfg, inPodOnly, { webTainted, podBridged }) + .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 { @@ -1746,7 +1835,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, removed } = await setAsideStoppedWebResult(); + if (setAside > 0) { + webSetAside = true; + run.webSetAside = removed; + continue; + } + } run.status = 'failed'; run.errorKind = 'guardrail_blocked'; run.errorMessage = 'Request blocked by a content guardrail (prompt-injection / moderation).'; @@ -1783,6 +1881,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({ @@ -1848,9 +1947,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, @@ -1887,8 +1990,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); @@ -1896,6 +2001,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. @@ -1942,6 +2059,9 @@ 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'; } @@ -1956,6 +2076,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..fdda28dbb6 100644 --- a/docs/runbooks/litellm-guardrails.md +++ b/docs/runbooks/litellm-guardrails.md @@ -31,6 +31,30 @@ 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 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. +- 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**.