diff --git a/packages/core/src/agent/runner.ts b/packages/core/src/agent/runner.ts index 1f137f94..04504866 100644 --- a/packages/core/src/agent/runner.ts +++ b/packages/core/src/agent/runner.ts @@ -43,6 +43,7 @@ import { } from "./system-prompt/index.js"; import type { AuthProfileFailureReason } from "./auth-profiles/index.js"; import { + analyzeCrossTurnWebFetchNeed, shouldEnforceWebFetchAfterSearch, summarizeWebToolUsage, type ToolExecutionRecord, @@ -142,6 +143,16 @@ const WEB_SEARCH_FETCH_ENFORCEMENT_PROMPT = [ "If all fetch attempts fail, explicitly say so and avoid relying on snippets for specific claims.", ].join("\n"); +const CROSS_TURN_WEB_FETCH_ENFORCEMENT_PROMPT = [ + "You are about to finalize a web-dependent answer, but no successful web_fetch happened in this turn.", + "Do not rely only on snippets or prior-turn memory for fresh factual claims.", + "Before finalizing your answer, you MUST:", + "1) If relevant URLs are already available in this conversation, call web_fetch on 1-3 of them.", + "2) If no URLs are available, call web_search to find candidates, then web_fetch on 1-3 relevant URLs.", + "3) Revise your answer using fetched page content as primary evidence.", + "If all fetch attempts fail, explicitly report that limitation and avoid specific claims not backed by fetched content.", +].join("\n"); + export class Agent { private readonly agent: PiAgentCore; private output; @@ -580,6 +591,10 @@ export class Agent { }); await this.agent.prompt(prompt); await this.enforceWebFetchAfterSearchIfNeeded(toolExecutionStartIndex); + await this.enforceCrossTurnWebFetchIfNeeded({ + toolExecutionStartIndex, + userPrompt: prompt, + }); this.runLog.log("llm_result", { duration_ms: Date.now() - llmStart, }); @@ -851,6 +866,58 @@ export class Agent { } } + private async enforceCrossTurnWebFetchIfNeeded(params: { + toolExecutionStartIndex: number; + userPrompt: string; + }): Promise { + if (this._internalRun) return; + + const activeTools = new Set( + (this.agent.state.tools ?? []).map((tool) => tool.name.toLowerCase()), + ); + const webFetchAvailable = activeTools.has("web_fetch"); + const currentTurnExecutions = this.currentRunToolExecutions.slice( + params.toolExecutionStartIndex, + ); + const usage = summarizeWebToolUsage(currentTurnExecutions); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable, + userPrompt: params.userPrompt, + assistantText: this.output.state.lastAssistantText ?? "", + }); + + if (!analysis.shouldEnforce) return; + + this.runLog.log("web_cross_turn_fetch_guard", { + fetch_calls: usage.fetchCalls, + fetch_success: usage.fetchSuccess, + explicit_fetch_request: analysis.explicitFetchRequest, + user_provides_url: analysis.userProvidesUrl, + freshness_cue: analysis.freshnessCue, + web_cue: analysis.webCue, + user_needs_fresh_web_evidence: analysis.userNeedsFreshWebEvidence, + user_blocks_web_fetch: analysis.userBlocksWebFetch, + assistant_web_claim_signal: analysis.assistantHasWebClaimSignal, + }); + + try { + await this.agent.prompt(CROSS_TURN_WEB_FETCH_ENFORCEMENT_PROMPT); + this.runLog.log("web_cross_turn_fetch_guard_applied", { + explicit_fetch_request: analysis.explicitFetchRequest, + user_provides_url: analysis.userProvidesUrl, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.runLog.log("web_cross_turn_fetch_guard_failed", { + error: message.slice(0, 200), + }); + if (this.debug) { + this.stderr.write(`[web-cross-turn-guard] Failed to enforce fetch: ${message}\n`); + } + } + } + private handleRunLogEvent(event: AgentEvent) { if (event.type === "tool_execution_start") { const toolName = (event as any).toolName ?? "unknown"; diff --git a/packages/core/src/agent/web-tools-policy.test.ts b/packages/core/src/agent/web-tools-policy.test.ts index bb613161..dd99d3ac 100644 --- a/packages/core/src/agent/web-tools-policy.test.ts +++ b/packages/core/src/agent/web-tools-policy.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + analyzeCrossTurnWebFetchNeed, shouldEnforceWebFetchAfterSearch, summarizeWebToolUsage, type ToolExecutionRecord, @@ -142,4 +143,91 @@ describe("web-tools-policy", () => { ).toBe(true); }); }); + + describe("analyzeCrossTurnWebFetchNeed", () => { + it("enforces when user explicitly asks to refetch page content", () => { + const usage = summarizeWebToolUsage([]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "Please refetch the page body this turn and verify with sources.", + assistantText: "Here is a quick summary.", + }); + + expect(analysis.shouldEnforce).toBe(true); + expect(analysis.explicitFetchRequest).toBe(true); + }); + + it("enforces for freshness requests when assistant makes web-style claims", () => { + const usage = summarizeWebToolUsage([]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "Give me the latest web news about OpenAI with sources.", + assistantText: "According to Reuters, OpenAI announced a new release.", + }); + + expect(analysis.shouldEnforce).toBe(true); + expect(analysis.freshnessCue).toBe(true); + expect(analysis.webCue).toBe(true); + expect(analysis.assistantHasWebClaimSignal).toBe(true); + }); + + it("does not enforce when a fetch was already attempted in this turn", () => { + const usage = summarizeWebToolUsage([ + buildRecord({ + toolName: "web_fetch", + details: { error: true, code: "fetch_failed" }, + }), + ]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "Please verify with the latest web sources.", + assistantText: "According to Reuters, ...", + }); + + expect(analysis.shouldEnforce).toBe(false); + }); + + it("does not enforce when user explicitly blocks web fetch", () => { + const usage = summarizeWebToolUsage([]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "Do not browse the web, only use snippets.", + assistantText: "According to Reuters, ...", + }); + + expect(analysis.shouldEnforce).toBe(false); + expect(analysis.userBlocksWebFetch).toBe(true); + }); + + it("enforces when user provides a direct URL but no fetch happened", () => { + const usage = summarizeWebToolUsage([]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "Summarize https://example.com/article and include key takeaways.", + assistantText: "I can summarize it for you.", + }); + + expect(analysis.shouldEnforce).toBe(true); + expect(analysis.userProvidesUrl).toBe(true); + }); + + it("does not enforce for non-web freshness requests", () => { + const usage = summarizeWebToolUsage([]); + const analysis = analyzeCrossTurnWebFetchNeed({ + usage, + webFetchAvailable: true, + userPrompt: "What is the latest version in this repository?", + assistantText: "The latest version is 1.2.3.", + }); + + expect(analysis.shouldEnforce).toBe(false); + expect(analysis.freshnessCue).toBe(true); + expect(analysis.webCue).toBe(false); + }); + }); }); diff --git a/packages/core/src/agent/web-tools-policy.ts b/packages/core/src/agent/web-tools-policy.ts index df6b999f..21e3ad8c 100644 --- a/packages/core/src/agent/web-tools-policy.ts +++ b/packages/core/src/agent/web-tools-policy.ts @@ -12,6 +12,57 @@ export type WebToolUsage = { fetchSuccess: number; }; +export type CrossTurnWebFetchGuardAnalysis = { + shouldEnforce: boolean; + explicitFetchRequest: boolean; + userProvidesUrl: boolean; + freshnessCue: boolean; + webCue: boolean; + userNeedsFreshWebEvidence: boolean; + userBlocksWebFetch: boolean; + assistantHasWebClaimSignal: boolean; +}; + +const URL_PATTERN = /https?:\/\/[^\s)]+/i; + +const USER_EXPLICIT_FETCH_PATTERNS: RegExp[] = [ + /\b(re[-\s]?fetch|fetch (again|fresh)|verify with sources?|cite sources?|provide (sources?|links?))\b/i, + /\b(revisit|revalidate|double-check)\b.*\b(source|link|url|web|website)\b/i, + /(?:\u672c\u8f6e|\u8fd9\u4e00\u8f6e).*(?:\u91cd\u65b0|\u518d\u6b21).*(?:\u6293\u53d6|\u83b7\u53d6|\u62c9\u53d6)/, + /(?:\u91cd\u65b0|\u518d\u6b21).*(?:\u6293\u53d6|\u83b7\u53d6).*(?:\u7f51\u9875|\u6b63\u6587|\u539f\u6587|\u94fe\u63a5)/, + /(?:\u7ed9\u51fa|\u63d0\u4f9b).*(?:\u6765\u6e90|\u94fe\u63a5|\u5f15\u7528)/, + /(?:\u6838\u5b9e|\u67e5\u8bc1|\u9a8c\u8bc1).*(?:\u6765\u6e90|\u7f51\u9875)/, +]; + +const USER_FRESHNESS_PATTERNS: RegExp[] = [ + /\b(latest|most recent|recent|today|current|up-to-date|newest|breaking)\b/i, + /\b(news|update|updates)\b/i, + /(?:\u6700\u65b0|\u6700\u8fd1|\u4eca\u5929|\u5f53\u524d|\u8fd1\u671f|\u52a8\u6001|\u65b0\u95fb|\u8d44\u8baf)/, +]; + +const USER_WEB_CONTEXT_PATTERNS: RegExp[] = [ + /\b(web|internet|online|url|urls|link|links|website|article|source|sources|news)\b/i, + /(?:\u7f51\u9875|\u7f51\u7ad9|\u7f51\u7edc|\u4e92\u8054\u7f51|\u94fe\u63a5|\u6765\u6e90|\u65b0\u95fb|\u62a5\u9053|\u6587\u7ae0)/, +]; + +const USER_WEB_BLOCK_PATTERNS: RegExp[] = [ + /\b(do not|don't|no|without)\s+(browse|web|internet|web_search|web_fetch|fetch)\b/i, + /\bonly\b.*\b(snippet|snippets)\b/i, + /(?:\u4e0d\u8981|\u4e0d\u9700)\s*(?:\u8054\u7f51|\u6293\u53d6|\u641c\u7d22|\u83b7\u53d6\u7f51\u9875|web_fetch|web_search)/, + /(?:\u4ec5|\u53ea).*(?:snippet|\u6458\u8981)/i, +]; + +const ASSISTANT_WEB_CLAIM_PATTERNS: RegExp[] = [ + /\b(according to|reported by|as reported|source|sources|citation|cited|press release)\b/i, + /\b(reuters|bloomberg|associated press|ap news|financial times|wall street journal)\b/i, + /(?:\u636e[^。\n]{0,24}(?:\u62a5\u9053|\u663e\u793a|\u79f0)|\u6765\u6e90|\u62a5\u9053\u79f0|\u516c\u544a|\u53d1\u5e03|\u5ba3\u5e03)/, +]; + +function hasAnyPattern(text: string, patterns: RegExp[]): boolean { + if (!text.trim()) return false; + return patterns.some((pattern) => pattern.test(text)); +} + function hasToolError(details: Record | null): boolean { return details?.error === true; } @@ -84,3 +135,46 @@ export function shouldEnforceWebFetchAfterSearch(params: { return true; } + +export function analyzeCrossTurnWebFetchNeed(params: { + usage: WebToolUsage; + webFetchAvailable: boolean; + userPrompt: string; + assistantText: string; +}): CrossTurnWebFetchGuardAnalysis { + const userPrompt = params.userPrompt ?? ""; + const assistantText = params.assistantText ?? ""; + + const explicitFetchRequest = hasAnyPattern( + userPrompt, + USER_EXPLICIT_FETCH_PATTERNS, + ); + const userProvidesUrl = URL_PATTERN.test(userPrompt); + const freshnessCue = hasAnyPattern(userPrompt, USER_FRESHNESS_PATTERNS); + const webCue = userProvidesUrl || hasAnyPattern(userPrompt, USER_WEB_CONTEXT_PATTERNS); + const userNeedsFreshWebEvidence = + explicitFetchRequest || userProvidesUrl || (freshnessCue && webCue); + const userBlocksWebFetch = hasAnyPattern(userPrompt, USER_WEB_BLOCK_PATTERNS); + const assistantHasWebClaimSignal = + URL_PATTERN.test(assistantText) || + hasAnyPattern(assistantText, ASSISTANT_WEB_CLAIM_PATTERNS); + + const shouldEnforce = + params.webFetchAvailable && + params.usage.fetchCalls === 0 && + params.usage.fetchSuccess === 0 && + !userBlocksWebFetch && + userNeedsFreshWebEvidence && + (explicitFetchRequest || userProvidesUrl || assistantHasWebClaimSignal); + + return { + shouldEnforce, + explicitFetchRequest, + userProvidesUrl, + freshnessCue, + webCue, + userNeedsFreshWebEvidence, + userBlocksWebFetch, + assistantHasWebClaimSignal, + }; +}