From 5ffc1ed3616be9f562200268c09a94b304a6ebd6 Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Tue, 4 Aug 2026 21:04:38 +0800 Subject: [PATCH 1/2] feat(search): add provider-native web search --- .../desktop-backend-tool-surface.test.ts | 98 +++++ .../__tests__/web-search-agent-tool.test.ts | 3 + apps/desktop/src/main/boot.ts | 2 + .../src/main/desktop-backend-tool-surface.ts | 23 +- apps/desktop/src/main/web-search-ipc-main.ts | 26 +- .../desktop/src/main/web-search/agent-tool.ts | 25 +- .../locales/settings-web-search-copy.ts | 34 +- .../settings/web-search-settings-page.tsx | 148 ++++--- docs/web-search-provider-capability.md | 221 ++++++++++ .../src/__tests__/runtime-bootstrap.test.ts | 118 +++++- packages/cli/src/runtime-bootstrap.ts | 81 +++- packages/core/package.json | 1 + .../core/src/__tests__/model-metadata.test.ts | 6 + .../src/__tests__/model-web-search.test.ts | 131 ++++++ .../core/src/__tests__/web-search.test.ts | 4 +- packages/core/src/events.ts | 8 + packages/core/src/index.ts | 7 + packages/core/src/llm-connections.ts | 2 + packages/core/src/model-metadata.ts | 9 +- packages/core/src/model-web-search.ts | 132 ++++++ packages/core/src/provider-registry.ts | 6 +- packages/core/src/runtime-event.ts | 29 +- packages/core/src/runtime-policy.ts | 14 +- .../connection-catalog-codec.ts | 2 +- .../runtime-policy/credential-vault-codec.ts | 4 +- packages/core/src/session.ts | 15 +- packages/core/src/web-search.ts | 16 +- packages/headless/README.md | 5 + packages/headless/harbor/maka_agent.py | 1 + packages/headless/harbor/maka_trajectory.py | 34 +- .../src/__tests__/harbor-adapter.test.ts | 30 ++ .../src/__tests__/harbor-cell.test.ts | 84 +++- packages/headless/src/harbor-cell.ts | 25 +- .../src/__tests__/dependency-boundary.test.ts | 1 + .../execution-model-composition.test.ts | 75 +++- .../src/__tests__/web-search-tool.test.ts | 43 +- .../src/server/execution-composition.ts | 33 +- .../src/server/execution-model-composition.ts | 44 +- .../src/server/web-search-tool.ts | 6 + .../src/__tests__/ai-sdk-backend.test.ts | 395 ++++++++++++++++++ .../src/__tests__/context-budget.test.ts | 35 ++ .../src/__tests__/model-adapter.test.ts | 124 ++++++ .../__tests__/native-web-search-tool.test.ts | 135 ++++++ .../__tests__/provider-conformance.test.ts | 325 +++++++++++++- .../__tests__/runtime-event-backfill.test.ts | 90 ++++ .../runtime-event-read-model.test.ts | 117 ++++++ packages/runtime/src/ai-sdk-backend.ts | 335 +++++++++++++-- packages/runtime/src/ai-sdk-flow.ts | 17 +- .../runtime/src/context-budget-helpers.ts | 8 +- packages/runtime/src/index.ts | 5 + packages/runtime/src/model-adapter.ts | 79 +++- packages/runtime/src/model-history.ts | 21 +- packages/runtime/src/model-protocol.ts | 24 +- .../runtime/src/native-web-search-tool.ts | 95 +++++ packages/runtime/src/request-shape.ts | 1 + .../runtime/src/runtime-event-adapters.ts | 11 +- .../runtime/src/runtime-event-backfill.ts | 32 ++ .../runtime/src/runtime-event-read-model.ts | 15 + packages/runtime/src/session-manager.ts | 24 +- packages/runtime/src/tool-result-archive.ts | 2 + packages/runtime/src/tool-runtime.ts | 9 + packages/runtime/src/web-search-tool.ts | 4 +- .../__tests__/runtime-policy-stores.test.ts | 27 +- .../storage/src/runtime-policy/coordinator.ts | 4 + .../storage/src/runtime-policy/operations.ts | 6 +- 65 files changed, 3287 insertions(+), 199 deletions(-) create mode 100644 docs/web-search-provider-capability.md create mode 100644 packages/core/src/__tests__/model-web-search.test.ts create mode 100644 packages/core/src/model-web-search.ts create mode 100644 packages/runtime/src/__tests__/native-web-search-tool.test.ts create mode 100644 packages/runtime/src/native-web-search-tool.ts diff --git a/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts b/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts index c2619357c9..74109275e9 100644 --- a/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts @@ -273,6 +273,18 @@ describe('Desktop backend tool surface', () => { webSearch, ], deepResearchTools: [deepResearchStatus], + getWebSearchSettings: async () => ({ + enabled: true, + defaultProvider: 'tavily', + providers: { + tavily: { + apiKey: 'tavily-key', + credentialSource: 'saved', + credentialVersion: 1, + credentialStatus: 'valid', + }, + }, + }), }); const input = inputFor('claude-sonnet-4-5-20250929'); input.header.labels = ['mode:deep_research']; @@ -285,6 +297,92 @@ describe('Desktop backend tool surface', () => { ); }); + it('replaces client-executed WebSearch with provider-native search for DeepSeek V4', async () => { + const clientSearch = tool('WebSearch', 'web_read'); + const deepseek: LlmConnection = { + ...connectionFor('deepseek-v4-flash'), + providerType: 'deepseek', + models: [ + { + id: 'deepseek-v4-flash', + apiProtocol: 'openai-responses', + capabilities: { webSearch: true }, + }, + ], + }; + const deps = makeDeps({ + builtinTools: [readTool, clientSearch], + getReadyConnection: async () => ({ + connection: deepseek, + apiKey: 'deepseek-key', + model: 'deepseek-v4-flash', + }), + getWebSearchSettings: async () => ({ + enabled: true, + defaultProvider: 'model', + providers: { + tavily: { + apiKey: '', + credentialSource: 'none', + credentialVersion: 0, + credentialStatus: 'untested', + }, + }, + }), + }); + + const surface = await resolveDesktopBackendToolSurface( + deps, + inputFor('deepseek-v4-flash'), + ); + const searches = surface.selectedTools.filter((candidate) => candidate.name === 'WebSearch'); + assert.equal(searches.length, 1); + assert.deepEqual(searches[0]?.providerTool, { + kind: 'openai-web-search', + searchContextSize: 'medium', + }); + }); + + it('omits every WebSearch implementation while privacy mode is active', async () => { + const clientSearch = tool('WebSearch', 'web_read'); + const deepseek: LlmConnection = { + ...connectionFor('deepseek-v4-flash'), + providerType: 'deepseek', + models: [{ id: 'deepseek-v4-flash', apiProtocol: 'openai-responses' }], + }; + const deps = makeDeps({ + builtinTools: [readTool, clientSearch], + getReadyConnection: async () => ({ + connection: deepseek, + apiKey: 'deepseek-key', + model: 'deepseek-v4-flash', + }), + getWebSearchSettings: async () => ({ + enabled: true, + defaultProvider: 'model', + providers: { + tavily: { + apiKey: '', + credentialSource: 'none', + credentialVersion: 0, + credentialStatus: 'untested', + }, + }, + }), + getPrivacySettings: async () => ({ incognitoActive: true }), + }); + + const surface = await resolveDesktopBackendToolSurface( + deps, + inputFor('deepseek-v4-flash'), + ); + + assert.equal( + surface.selectedTools.some((candidate) => candidate.name === 'WebSearch'), + false, + ); + }); + it('uses explicit preview inputs without reading a nonexistent session plan', async () => { let connectionReads = 0; let planReads = 0; diff --git a/apps/desktop/src/main/__tests__/web-search-agent-tool.test.ts b/apps/desktop/src/main/__tests__/web-search-agent-tool.test.ts index a0443db667..7c805adec9 100644 --- a/apps/desktop/src/main/__tests__/web-search-agent-tool.test.ts +++ b/apps/desktop/src/main/__tests__/web-search-agent-tool.test.ts @@ -70,6 +70,7 @@ describe('WebSearch agent tool (PR-AGENT-WEB-SEARCH-TOOL-0)', () => { webSearch: { ...defaultWebSearchSettings(), enabled: true, + defaultProvider: 'tavily', providers: { tavily: { ...defaultWebSearchSettings().providers.tavily, apiKey: 'tvly-xxx' } }, }, })); @@ -89,6 +90,7 @@ describe('WebSearch agent tool (PR-AGENT-WEB-SEARCH-TOOL-0)', () => { webSearch: { ...defaultWebSearchSettings(), enabled: false, + defaultProvider: 'tavily', providers: { tavily: { ...defaultWebSearchSettings().providers.tavily, apiKey: 'tvly-real-key' } }, }, })); @@ -143,6 +145,7 @@ describe('WebSearch agent tool (PR-AGENT-WEB-SEARCH-TOOL-0)', () => { webSearch: { ...defaultWebSearchSettings(), enabled: true, + defaultProvider: 'tavily', providers: { tavily: { ...defaultWebSearchSettings().providers.tavily, apiKey: '' } }, }, })); diff --git a/apps/desktop/src/main/boot.ts b/apps/desktop/src/main/boot.ts index 1ebb8d8999..5f66245105 100644 --- a/apps/desktop/src/main/boot.ts +++ b/apps/desktop/src/main/boot.ts @@ -748,6 +748,8 @@ const desktopBackendToolSurfaceDeps = { builtinTools, toolEconomy: desktopProductToolSurface.identity.policy.economy, planStore, + getWebSearchSettings: async () => (await settingsStore.get()).webSearch, + getPrivacySettings: async () => (await settingsStore.get()).privacy, getAgentGraphSupervisorTools: (sessionId: string) => agentGraphCoordinator.toolsForSession(sessionId), }; diff --git a/apps/desktop/src/main/desktop-backend-tool-surface.ts b/apps/desktop/src/main/desktop-backend-tool-surface.ts index 5ea6181b65..39dbb1786f 100644 --- a/apps/desktop/src/main/desktop-backend-tool-surface.ts +++ b/apps/desktop/src/main/desktop-backend-tool-surface.ts @@ -1,10 +1,16 @@ import { activePlanExecution, DEFAULT_SESSION_NAME, + defaultWebSearchSettings, isDeepResearchSession, resolveModelVisionSupport, } from '@maka/core'; -import type { CollaborationMode, LlmConnection, SessionHeader } from '@maka/core'; +import type { + AppSettings, + CollaborationMode, + LlmConnection, + SessionHeader, +} from '@maka/core'; import { emptyPlanSessionState, type PlanExecution, @@ -20,6 +26,7 @@ import { buildToolsForAgentDefinition, buildUpdatePlanTool, projectEffectiveProductToolSurface, + routeWebSearchTools, selectCollaborationTools, } from '@maka/runtime'; import type { @@ -48,6 +55,8 @@ export interface DesktopBackendToolSurfaceDeps { sessionId: string, header: SessionHeader, ) => Promise; + getWebSearchSettings?: () => Promise; + getPrivacySettings?: () => Promise; } export interface DesktopBackendToolSurfaceInput { @@ -206,6 +215,16 @@ export async function resolveDesktopBackendToolSurface( !input.tools && isDeepResearchSession(input.header.labels) ? unscopedCandidateTools.filter(isDeepResearchToolAllowed) : unscopedCandidateTools; + const webSearchSettings = await (deps.getWebSearchSettings?.() ?? + Promise.resolve(defaultWebSearchSettings())); + const privacySettings = await deps.getPrivacySettings?.(); + const routedCandidateTools = routeWebSearchTools({ + tools: candidateTools, + settings: webSearchSettings, + connection, + model, + ...(privacySettings ? { privacy: privacySettings } : {}), + }); const toolEconomy = deps.isComputerUseRealModelE2e ? false : deps.toolEconomy; const planControlTools = input.tools @@ -219,7 +238,7 @@ export async function resolveDesktopBackendToolSurface( ] : []; const backendTools = computerUseToolsForModel( - [...candidateTools, ...planControlTools], + [...routedCandidateTools, ...planControlTools], deps.computerUseTools, supportsVision, ); diff --git a/apps/desktop/src/main/web-search-ipc-main.ts b/apps/desktop/src/main/web-search-ipc-main.ts index c56c56fa48..fe1c8067df 100644 --- a/apps/desktop/src/main/web-search-ipc-main.ts +++ b/apps/desktop/src/main/web-search-ipc-main.ts @@ -29,8 +29,8 @@ export function registerWebSearchIpc(deps: WebSearchIpcDeps): void { _event, request: { query?: unknown; limit?: unknown; provider?: unknown; apiKey?: unknown }, ) => { - const provider = request?.provider; - if (provider !== undefined && !isWebSearchProvider(provider)) { + const requestedProvider = request?.provider; + if (requestedProvider !== undefined && !isWebSearchProvider(requestedProvider)) { return unsupportedWebSearchProviderResponse; } const query = normalizeWebSearchQuery(request?.query); @@ -46,7 +46,15 @@ export function registerWebSearchIpc(deps: WebSearchIpcDeps): void { return { ok: false, reason: 'not_configured' as const, - message: '请先在 设置 · 联网搜索 中启用 Tavily。', + message: '请先在 设置 · 联网搜索 中启用联网搜索。', + }; + } + const provider = requestedProvider ?? settings.webSearch.defaultProvider; + if (provider === 'model') { + return { + ok: false, + reason: 'unsupported_provider' as const, + message: '原生联网搜索由对话中的主模型请求执行,不支持从设置页单独调用。', }; } const effectiveKey = resolveTavilyApiKey({ settings, draftKey: request?.apiKey }); @@ -61,11 +69,19 @@ export function registerWebSearchIpc(deps: WebSearchIpcDeps): void { _event, request: { provider?: unknown; apiKey?: unknown } | undefined, ) => { - const provider = request?.provider; - if (provider !== undefined && !isWebSearchProvider(provider)) { + const requestedProvider = request?.provider; + if (requestedProvider !== undefined && !isWebSearchProvider(requestedProvider)) { return unsupportedWebSearchProviderResponse; } const settings = await deps.settingsStore.get(); + const provider = requestedProvider ?? settings.webSearch.defaultProvider; + if (provider === 'model') { + return { + ok: false, + reason: 'unsupported_provider' as const, + message: '原生联网搜索由对话中的主模型请求执行,不需要单独测试搜索凭据。', + }; + } const effectiveKey = resolveTavilyApiKey({ settings, draftKey: request?.apiKey }); return queryTavily({ apiKey: effectiveKey, diff --git a/apps/desktop/src/main/web-search/agent-tool.ts b/apps/desktop/src/main/web-search/agent-tool.ts index 60455729bc..13913cdb57 100644 --- a/apps/desktop/src/main/web-search/agent-tool.ts +++ b/apps/desktop/src/main/web-search/agent-tool.ts @@ -27,6 +27,7 @@ import { validateWorkspacePrivacyContext, type WebSearchCredentialSource, type WebSearchErrorReason, + type WebSearchProvider, } from '@maka/core'; import { defaultWorkspacePrivacyContext } from '@maka/core/incognito'; import type { MakaTool } from '@maka/runtime'; @@ -41,11 +42,12 @@ function webSearchErrorContent(input: { message: string; query?: string; credentialSource?: WebSearchCredentialSource; + provider?: WebSearchProvider; }) { return { kind: 'web_search_error' as const, ok: false as const, - provider: 'tavily', + provider: input.provider ?? 'tavily', ...(input.query ? { query: input.query } : {}), reason: input.reason, message: input.message, @@ -60,7 +62,7 @@ export function buildWebSearchAgentTool(deps: { return { name: WEB_SEARCH_TOOL_NAME, description: - 'Query the live web via the configured search provider (Tavily). ' + + 'Query the live web through the configured external search provider. ' + 'Returns a short list of {title, url, snippet, source} rows. ' + 'Use ONLY when the user asks for current external information; ' + 'never call speculatively. Each call is gated on explicit user ' + @@ -80,7 +82,7 @@ export function buildWebSearchAgentTool(deps: { .describe(`Max results to return (default ${WEB_SEARCH_DEFAULT_LIMIT}).`), }), displayName: '联网搜索', - impl: async ({ query, limit }) => { + impl: async ({ query, limit }, context) => { const normalizedQuery = normalizeWebSearchQuery(query); if (normalizedQuery === null) { return webSearchErrorContent({ @@ -105,13 +107,24 @@ export function buildWebSearchAgentTool(deps: { }); } const settings = await deps.settingsStore.get(); - const credentialSource = getTavilyCredentialSource(settings); + const credentialSource = + settings.webSearch.defaultProvider === 'tavily' + ? getTavilyCredentialSource(settings) + : undefined; if (!settings.webSearch.enabled) { return webSearchErrorContent({ reason: 'not_configured', - message: '请先在 设置 · 联网搜索 中启用 Tavily 后再让 Maka 调用联网搜索工具。', + message: '请先在 设置 · 联网搜索 中启用联网搜索。', query: normalizedQuery, - credentialSource, + ...(credentialSource ? { credentialSource } : {}), + }); + } + if (settings.webSearch.defaultProvider === 'model') { + return webSearchErrorContent({ + reason: 'unsupported_provider', + message: '原生联网搜索必须由主模型请求执行,不能通过本地 WebSearch 工具调用。', + query: normalizedQuery, + provider: 'model', }); } const apiKey = resolveTavilyApiKey({ settings }); diff --git a/apps/desktop/src/renderer/locales/settings-web-search-copy.ts b/apps/desktop/src/renderer/locales/settings-web-search-copy.ts index b78977783c..43fbb8e271 100644 --- a/apps/desktop/src/renderer/locales/settings-web-search-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-web-search-copy.ts @@ -3,14 +3,16 @@ import type { UiCatalog, UiLocale, WebSearchCredentialStatus, WebSearchErrorReas export type WebSearchSettingsCopy = { saveFailed: string; saveStatusFailed: string; keySaved: string; keySavedDetail: string; credentialsCleared: string; credentialsClearedDetail: string; credentialValid: string; resultCount(count: number): string; testFailed: string; testError: string; enabled: string; enabledHelp: string; + provider: string; providerHelp: string; providerModel: string; providerTavily: string; + modelCredential: string; modelCredentialHelp: string; statusAria: string; lastTest: string; enabledAria: string; key: string; envKeyHelp: string; savedKeyHelp: string; envPlaceholder: string; storedPlaceholder: string; keyPlaceholder: string; keyAria: string; actions: string; actionsHelp: string; saving: string; saveKey: string; testing: string; testKey: string; clearing: string; clearKey: string; testSearch: string; testSearchHelp: string; queryPlaceholder: string; searching: string; search: string; queryFailed(error: string): string; noResults: string; resultsAria: string; disabledReasons: { noKey: string; disabled: string; noQuery: string }; - statuses: Record & { validEnabled: string; validDisabled: string; unknownEnabled: string }; - sources: { envWithSaved: string; env: string; saved: string; none: string }; + statuses: Record & { validEnabled: string; validDisabled: string; unknownEnabled: string; modelEnabled: string; modelDisabled: string }; + sources: { model: string; envWithSaved: string; env: string; saved: string; none: string }; errors: Record; }; @@ -18,32 +20,36 @@ const SETTINGS_WEB_SEARCH_COPY = { zh: { saveFailed: '保存联网搜索设置失败', saveStatusFailed: '保存联网搜索状态失败', keySaved: '已保存 Tavily 密钥', keySavedDetail: '可点击「测试」做一次真实请求验证。', credentialsCleared: '已清空 Tavily 凭据', credentialsClearedDetail: '联网搜索已自动关闭。', credentialValid: 'Tavily 凭据可用', resultCount: (count) => `返回 ${count} 条结果。`, - testFailed: 'Tavily 测试失败', testError: 'Tavily 测试出错', enabled: '启用联网搜索', enabledHelp: '开关启用后,界面里显式触发的查询才会真的请求 Tavily。模型不会自动调用。', + testFailed: '联网搜索测试失败', testError: '联网搜索测试出错', enabled: '启用联网搜索', enabledHelp: '启用后,Maka 可以在需要最新外部信息时调用所选搜索来源。', + provider: '搜索来源', providerHelp: '优先复用当前模型的服务端搜索;不支持时可显式改用 Tavily。', providerModel: '当前模型', providerTavily: 'Tavily', + modelCredential: '主模型原生搜索', modelCredentialHelp: 'Maka 会在每个对话回合开始时,根据当前连接与精确模型决定是否把原生 web_search 注入同一次模型请求。不保存第二份搜索密钥,也不会从设置页另发一次模型调用。', statusAria: '联网搜索凭据状态', lastTest: '最近测试 ', enabledAria: '启用联网搜索', key: 'Tavily 密钥', envKeyHelp: '当前使用环境变量 TAVILY_API_KEY / MAKA_TAVILY_API_KEY;如需改用保存的密钥,请移除环境变量后重启。', savedKeyHelp: '密钥只保存在本机。申请地址:', envPlaceholder: '由环境变量提供', storedPlaceholder: '已保存(输入新密钥可替换)', keyPlaceholder: 'tvly-xxxxxxxx', keyAria: 'Tavily 密钥', actions: '凭据操作', actionsHelp: '保存后可以测试一次真实请求;清空凭据会同步关闭联网搜索。', saving: '保存中…', saveKey: '保存密钥', testing: '测试中…', testKey: '测试凭据', clearing: '清空中…', clearKey: '清空密钥', - testSearch: '测试搜索', testSearchHelp: '发一条真实查询,确认联网搜索是否配置可用。结果只显示在这里,不写入会话。', queryPlaceholder: '例如:本周 AI 产品发布动态', + testSearch: '测试搜索', testSearchHelp: '发一条真实查询,确认所选联网搜索来源是否配置可用。结果只显示在这里,不写入会话。', queryPlaceholder: '例如:本周 AI 产品发布动态', searching: '搜索中…', search: '搜索', queryFailed: (error) => `查询失败:${error}`, noResults: '没有结果。', resultsAria: '联网搜索真实查询结果', - disabledReasons: { noKey: '先保存 Tavily 密钥', disabled: '先启用联网搜索', noQuery: '输入查询后再搜索' }, - statuses: { valid: '已验证', invalid_credentials: '密钥无效', rate_limited: 'Tavily 限流', timeout: '测试超时', network_error: '网络异常', not_configured: '等待配置', untested: '未测试', validEnabled: '已验证 · 已启用', validDisabled: '已验证 · 未启用', unknownEnabled: '未测试 · 已启用' }, - sources: { envWithSaved: '来源:环境变量(已保存密钥备用)', env: '来源:环境变量', saved: '来源:本机已保存密钥', none: '来源:未配置' }, - errors: { invalid_query: '请输入有效的搜索内容。', incognito_active: '无痕模式下无法使用联网搜索。', not_configured: '请先配置 Tavily 密钥并启用联网搜索。', invalid_credentials: 'Tavily 密钥无效,请更新后重试。', rate_limited: 'Tavily 请求过于频繁,请稍后重试。', network_error: '网络请求失败,请检查网络后重试。', timeout: 'Tavily 请求超时,请重试。', unsupported_provider: '当前配置不支持这个搜索引擎,请选择 Tavily 后重试。', experimental_disabled: '联网搜索实验功能当前已关闭。' }, + disabledReasons: { noKey: '先配置所选搜索来源', disabled: '先启用联网搜索', noQuery: '输入查询后再搜索' }, + statuses: { valid: '已验证', invalid_credentials: '密钥无效', rate_limited: '服务限流', timeout: '测试超时', network_error: '网络异常', not_configured: '等待配置', untested: '未测试', validEnabled: '已验证 · 已启用', validDisabled: '已验证 · 未启用', unknownEnabled: '未测试 · 已启用', modelEnabled: '已启用 · 按会话模型判定', modelDisabled: '当前模型来源 · 未启用' }, + sources: { model: '来源:当前模型连接', envWithSaved: '来源:环境变量(已保存密钥备用)', env: '来源:环境变量', saved: '来源:本机已保存密钥', none: '来源:未配置' }, + errors: { invalid_query: '请输入有效的搜索内容。', incognito_active: '无痕模式下无法使用联网搜索。', not_configured: '所选搜索来源尚未配置完成。', invalid_credentials: '搜索来源拒绝了当前凭据,请更新后重试。', rate_limited: '搜索请求过于频繁,请稍后重试。', network_error: '网络请求失败,请检查网络后重试。', timeout: '搜索请求超时,请重试。', unsupported_provider: '当前模型不支持服务端搜索,或 Maka 尚未实现它的协议;可改用 Tavily。', experimental_disabled: '联网搜索实验功能当前已关闭。' }, }, en: { saveFailed: 'Failed to save web search settings', saveStatusFailed: 'Failed to save web search status', keySaved: 'Tavily key saved', keySavedDetail: 'Select Test credentials to verify it with a real request.', credentialsCleared: 'Tavily credentials cleared', credentialsClearedDetail: 'Web search was disabled automatically.', credentialValid: 'Tavily credentials work', resultCount: (count) => `Returned ${count} ${count === 1 ? 'result' : 'results'}.`, - testFailed: 'Tavily test failed', testError: 'Tavily test error', enabled: 'Enable web search', enabledHelp: 'When enabled, only queries explicitly started in the interface call Tavily. The model does not invoke it automatically.', + testFailed: 'Web search test failed', testError: 'Web search test error', enabled: 'Enable web search', enabledHelp: 'When enabled, Maka can call the selected search source for current external information.', + provider: 'Search source', providerHelp: 'Reuse the current model provider when it supports hosted search, or explicitly use Tavily.', providerModel: 'Current model', providerTavily: 'Tavily', + modelCredential: 'Primary-model native search', modelCredentialHelp: 'At the start of each turn, Maka uses the current connection and exact model to decide whether to inject native web_search into the same model request. It stores no second search key and sends no separate model call from Settings.', statusAria: 'Web search credential status', lastTest: 'Last tested ', enabledAria: 'Enable web search', key: 'Tavily key', envKeyHelp: 'Currently using TAVILY_API_KEY / MAKA_TAVILY_API_KEY from the environment. Remove the environment variable and restart to use a saved key.', savedKeyHelp: 'The key is stored only on this machine. Apply at:', envPlaceholder: 'Provided by environment variable', storedPlaceholder: 'Saved (enter a new key to replace)', keyPlaceholder: 'tvly-xxxxxxxx', keyAria: 'Tavily key', actions: 'Credential actions', actionsHelp: 'After saving, test with a real request. Clearing credentials also disables web search.', saving: 'Saving…', saveKey: 'Save key', testing: 'Testing…', testKey: 'Test credentials', clearing: 'Clearing…', clearKey: 'Clear key', - testSearch: 'Test search', testSearchHelp: 'Send a real query to confirm web search is configured and working. Results appear here only and are not written to the conversation.', queryPlaceholder: 'For example: AI product launches this week', + testSearch: 'Test search', testSearchHelp: 'Send a real query to confirm the selected web search source is configured and working. Results appear here only and are not written to the conversation.', queryPlaceholder: 'For example: AI product launches this week', searching: 'Searching…', search: 'Search', queryFailed: (error) => `Query failed: ${error}`, noResults: 'No results.', resultsAria: 'Web search live query results', - disabledReasons: { noKey: 'Save a Tavily key first', disabled: 'Enable web search first', noQuery: 'Enter a query before searching' }, - statuses: { valid: 'Verified', invalid_credentials: 'Invalid key', rate_limited: 'Rate limited by Tavily', timeout: 'Test timed out', network_error: 'Network error', not_configured: 'Needs setup', untested: 'Not tested', validEnabled: 'Verified · enabled', validDisabled: 'Verified · disabled', unknownEnabled: 'Not tested · enabled' }, - sources: { envWithSaved: 'Source: environment variable (saved key available as backup)', env: 'Source: environment variable', saved: 'Source: key saved on this device', none: 'Source: not configured' }, - errors: { invalid_query: 'Enter a valid search query.', incognito_active: 'Web search is unavailable in incognito mode.', not_configured: 'Configure a Tavily key and enable web search first.', invalid_credentials: 'The Tavily key is invalid. Update it and try again.', rate_limited: 'Tavily is receiving too many requests. Try again later.', network_error: 'The network request failed. Check your connection and try again.', timeout: 'The Tavily request timed out. Try again.', unsupported_provider: 'This search provider is not supported by the current configuration. Select Tavily and try again.', experimental_disabled: 'The experimental web search feature is currently disabled.' }, + disabledReasons: { noKey: 'Configure the selected search source first', disabled: 'Enable web search first', noQuery: 'Enter a query before searching' }, + statuses: { valid: 'Verified', invalid_credentials: 'Invalid key', rate_limited: 'Rate limited', timeout: 'Test timed out', network_error: 'Network error', not_configured: 'Needs setup', untested: 'Not tested', validEnabled: 'Verified · enabled', validDisabled: 'Verified · disabled', unknownEnabled: 'Not tested · enabled', modelEnabled: 'Enabled · checked per session model', modelDisabled: 'Current model source · disabled' }, + sources: { model: 'Source: current model connection', envWithSaved: 'Source: environment variable (saved key available as backup)', env: 'Source: environment variable', saved: 'Source: key saved on this device', none: 'Source: not configured' }, + errors: { invalid_query: 'Enter a valid search query.', incognito_active: 'Web search is unavailable in incognito mode.', not_configured: 'The selected search source is not configured.', invalid_credentials: 'The search provider rejected the current credential. Update it and try again.', rate_limited: 'The search provider is receiving too many requests. Try again later.', network_error: 'The network request failed. Check your connection and try again.', timeout: 'The search request timed out. Try again.', unsupported_provider: 'The current model does not support hosted search, or Maka has not implemented its protocol yet. Select Tavily to continue.', experimental_disabled: 'The experimental web search feature is currently disabled.' }, }, } satisfies UiCatalog; diff --git a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx index 692ae0a854..2f7f351934 100644 --- a/apps/desktop/src/renderer/settings/web-search-settings-page.tsx +++ b/apps/desktop/src/renderer/settings/web-search-settings-page.tsx @@ -2,7 +2,7 @@ import { useRef, useState } from 'react'; import { Link } from '@astryxdesign/core'; import type { AppSettings, UpdateAppSettingsResult, WebSearchCredentialStatus } from '@maka/core'; import { normalizeSearchUrl, webSearchCredentialStatusFromResponse } from '@maka/core'; -import { Button, StatusDot, TextInput, RelativeTime, Switch, redactSecrets, useMountedRef, useToast, useUiLocale } from '@maka/ui'; +import { Button, Selector, StatusDot, TextInput, RelativeTime, Switch, redactSecrets, useMountedRef, useToast, useUiLocale } from '@maka/ui'; import { getWebSearchSettingsCopy, type WebSearchSettingsCopy } from '../locales/settings-web-search-copy'; import { getSettingsSharedCopy } from '../locales/settings-shared-copy.js'; import { SettingsActions, SettingsField, SettingsPage, SettingsRow, SettingsSection } from './settings-section'; @@ -32,6 +32,7 @@ export function WebSearchSettingsPage(props: { const copy = getWebSearchSettingsCopy(locale); const sharedCopy = getSettingsSharedCopy(locale); const webSearch = props.settings.webSearch; + const usingModelSearch = webSearch.defaultProvider === 'model'; const tavily = webSearch.providers.tavily; const tavilyKey = tavily.apiKey; const credentialSource = tavily.credentialSource; @@ -145,15 +146,15 @@ export function WebSearchSettingsPage(props: { const releaseTest = webSearchActionGuard.begin('test'); if (!releaseTest) return; setTesting(true); - const usesDraftKey = draftKey.trim().length > 0; + const usesDraftKey = !usingModelSearch && draftKey.trim().length > 0; const testedCredentialVersion = tavily.credentialVersion; try { const result = await window.maka.webSearch.test({ - provider: 'tavily', + provider: webSearch.defaultProvider, apiKey: usesDraftKey ? draftKey : undefined, }); if (!webSearchMountedRef.current) return; - if (!usesDraftKey && hasUsableKey) { + if (!usingModelSearch && !usesDraftKey && hasUsableKey) { void persistCredentialStatus(webSearchCredentialStatusFromResponse(result), testedCredentialVersion); } if (result.ok) { @@ -186,19 +187,19 @@ export function WebSearchSettingsPage(props: { const queriedCredentialVersion = tavily.credentialVersion; try { const result = await window.maka.webSearch.query({ - provider: 'tavily', + provider: webSearch.defaultProvider, query: trimmed, limit: 5, }); if (!isCurrentLiveQuery(queryOwner)) return; if (result.ok) { setLiveQueryResults(result.results); - if (hasUsableKey) { + if (!usingModelSearch && hasUsableKey) { void persistCredentialStatus('valid', queriedCredentialVersion); } } else { setLiveQueryError(copy.errors[result.reason]); - if (hasUsableKey) { + if (!usingModelSearch && hasUsableKey) { void persistCredentialStatus(webSearchCredentialStatusFromResponse(result), queriedCredentialVersion); } } @@ -216,14 +217,20 @@ export function WebSearchSettingsPage(props: { const hasStoredKey = tavilyKey.length > 0; const hasUsableKey = hasStoredKey || usingEnvKey; - const statusCopy = presentWebSearchCredentialStatus( - credentialSource, - webSearch.enabled, - tavily.credentialStatus, - copy, - ); + const hasUsableProvider = usingModelSearch || hasUsableKey; + const statusCopy = usingModelSearch + ? { + label: webSearch.enabled ? copy.statuses.modelEnabled : copy.statuses.modelDisabled, + tone: webSearch.enabled ? ('info' as const) : ('warning' as const), + } + : presentWebSearchCredentialStatus( + credentialSource, + webSearch.enabled, + tavily.credentialStatus, + copy, + ); const queryDisabledReason = webSearchQueryDisabledReason({ - hasUsableKey, + hasUsableKey: hasUsableProvider, enabled: webSearch.enabled, query: liveQuery, copy, @@ -231,7 +238,7 @@ export function WebSearchSettingsPage(props: { const checkedAtMs = tavily.credentialCheckedAt ? Date.parse(tavily.credentialCheckedAt) : Number.NaN; - const hasCheckedAt = Number.isFinite(checkedAtMs); + const hasCheckedAt = !usingModelSearch && Number.isFinite(checkedAtMs); const credentialActionBusy = pendingCredentialAction !== null || testing; return ( @@ -240,6 +247,24 @@ export function WebSearchSettingsPage(props: { title={sharedCopy.groups.searchProvider} description={sharedCopy.groups.searchProviderHelp} > + + void updateWebSearch({ + defaultProvider: value === 'tavily' ? 'tavily' : 'model', + }) + } + />} + /> )} - {presentWebSearchCredentialSource(credentialSource, hasStoredKey, copy)} + + {usingModelSearch + ? copy.sources.model + : presentWebSearchCredentialSource(credentialSource, hasStoredKey, copy)} + void setEnabled(enabled)} /> } /> - {/* The key was an input squeezed into the row's end slot with the + {!usingModelSearch && <>{/* The key was an input squeezed into the row's end slot with the actions posing as a second labeled row. Astryx's own form idiom: a full-width credential Field, then the section's one action cluster (save primary, test secondary, clear ghost). */} @@ -309,51 +338,50 @@ export function WebSearchSettingsPage(props: { /> )} + } + {usingModelSearch && ( + + )} - - {/* UX audit (owner msg `30f736ed`): one action wore three labels — - 真实查询验证 over 查询 over 执行查询, each with its own help line, - for what is a single act: type a query, press the button, read the - result. One label now, on the field the user actually fills in. - - The query keeps the full row width — it was squeezed into a row's - end slot before, an ~360px input for a real search query. */} - - updateLiveQuery(value)} - placeholder={copy.queryPlaceholder} - label={copy.testSearch} - description={copy.testSearchHelp} - width="100%" - onKeyDown={(event) => { - if (event.key === 'Enter' && !liveQueryRunning) { - event.preventDefault(); - void runLiveQuery(); - } - }} - /> - - -
-
-
-
+ + +
+
+
+ + )} {liveQueryError && (
diff --git a/docs/web-search-provider-capability.md b/docs/web-search-provider-capability.md new file mode 100644 index 0000000000..65303c8fa1 --- /dev/null +++ b/docs/web-search-provider-capability.md @@ -0,0 +1,221 @@ +# Provider-hosted web search capability + +Status: implemented for OpenAI Responses and Anthropic Messages +`web_search_20250305`; other provider-native wires remain explicit follow-ups. +Verified against public provider documentation and shipped client/SDK behavior +on 2026-08-04. + +## Problem + +Maka originally exposed only a Tavily-backed `WebSearch` tool. That made live +search require a second credential even when the selected model provider already +offered a server-side search tool. + +The missing abstraction was not another search-provider enum entry. Hosted +search depends on all three of: + +1. the configured provider access path; +2. the exact selected model; +3. a provider-specific protocol and tool shape. + +`functionCalling: true` does not imply hosted search. OpenAI-compatible chat +does not imply Responses `web_search`, and Anthropic-compatible messages do not +imply support for `web_search_20250305`. + +## Current routing + +The user chooses one explicit search source: + +- `model`: reuse the current session model connection and credential; +- `tavily`: use the separately configured Tavily credential. + +There is no silent fallback. If `model` is selected and the exact provider/model +pair is unsupported, Maka omits the native tool from that turn. Tavily remains +an explicit alternative rather than taking over after a failed native call. + +The capability decision lives in +`packages/core/src/model-web-search.ts`. Stored model metadata can explicitly +set `capabilities.webSearch`; otherwise narrow provider/model rules apply. + +The implemented native adapters are: + +- `openai-responses` for Codex-style `web_search`; +- `anthropic-messages` for Claude Code-compatible `web_search_20250305`. + +## Execution surfaces + +All production `AiSdkBackend` composition roots use the same +`routeWebSearchTools` decision: + +| Surface | Configuration authority | Root behavior | +| --- | --- | --- | +| Desktop | persisted `webSearch.enabled/defaultProvider` settings | May add provider-native `WebSearch` | +| CLI / TUI / `maka run` | the same persisted settings | May add provider-native `WebSearch` | +| Runtime Host | runtime-policy web-search settings | May add provider-native `WebSearch` | +| Headless Harbor | explicit `MAKA_WEB_SEARCH_ENABLED=true` | May add provider-native `WebSearch` | + +Headless remains opt-in because silently enabling network search would change +benchmark semantics and historical baselines. Merely speaking Anthropic +Messages is not enough to infer hosted-search support; Maka uses explicit model +metadata or narrow model-id rules, including DeepSeek V4 Flash on an +`anthropic-compatible` connection. + +An explicit `BackendFactoryContext.tools` list is a hard ceiling. Root surfaces +may add native search, but scoped child agents do not gain it unless their +supplied tool list already contains `WebSearch`. Dedicated model experiments +and auxiliary calls such as title generation, history compaction, goal judging, +connection tests, and daily review do not receive search implicitly. + +Privacy mode removes both provider-native and client-executed `WebSearch` +before the model request is built. + +The built-in `web_research` child profile is derived from that same routed +per-session child tool surface. It is not advertised when search is disabled, +privacy mode is active, or the selected provider/model wire is unsupported. + +```text +turn-start tool surface + -> provider/model capability resolution + -> replace client WebSearch with provider-native WebSearch + -> primary model POST /responses + tools: [{ type: "web_search" }] + -> provider-executed search call/result + -> same model stream continues to final text +``` + +The search request uses the existing model credential. It does not duplicate the +secret into web-search settings, start a nested model request, or expose the +secret to the renderer. Provider search call/results are marked +`providerExecuted`, preserved separately from local ToolRuntime execution, and +replayed through the native provider-tool shape. + +The durable event keeps normalized `result` data for the canonical read model, +UI, and exports, plus opaque `providerOutput` data for provider-protocol replay. +Anthropic replay places `server_tool_use` and `web_search_tool_result` in the +same assistant message. Responses replay uses a provider item reference and +never emits an orphan `function_call_output`. + +Context budgeting measures the opaque replay payload rather than the smaller +display projection. Provider-native results are not partially archived: history +compaction removes the complete old turn instead of archiving only `result` +while accidentally retaining an unbounded `providerOutput`. + +DeepSeek live verification on 2026-08-04 completed one real Maka `AiSdkBackend` +turn with two provider-executed WebSearch calls and a final answer in the same +stream. DeepSeek returned search actions but no structured source rows or URL +annotations in that response, so Maka preserves citations when supplied but +does not synthesize or invent them. + +The same key was also verified through DeepSeek's Anthropic-compatible endpoint: +one real Maka turn sent `web_search_20250305`, received one provider-executed +WebSearch result containing ten source rows, and completed the final answer +without a second model call. This pins both the Codex/Responses and Claude +Code/Messages wire formats against live provider behavior. + +Full production-surface smoke verification on 2026-08-04 then ran: + +- `maka run` through a persisted DeepSeek Responses connection; +- `maka run` through a persisted DeepSeek Anthropic-compatible connection; +- Headless Harbor with Responses and `MAKA_WEB_SEARCH_ENABLED=true`; +- Headless Harbor with Anthropic Messages and the same explicit opt-in. + +All four runs completed with provider-executed WebSearch call/result pairs, +final model text, no local ToolRuntime execution of `WebSearch`, and durable +runtime events. Both headless runs also persisted token summaries. The +Responses CLI turn emitted search plus page-open actions; the Anthropic CLI +turn returned ten structured source rows. + +After review fixes, local HTTP wire conformance also verified second-turn +replay through the real AI SDK provider converters for both protocols. A later +live-provider rerun on 2026-08-04 timed out before either provider returned its +first model event, so it did not replace the earlier successful live smoke or +the deterministic second-turn wire tests. + +## Dual-wire selection + +Native search never switches protocol independently from the primary model +request. The selected connection wire is authoritative: + +- an OpenAI Responses session receives `openai.web_search`; +- an Anthropic Messages session receives `anthropic.web_search_20250305`. + +For a provider that supports both, an explicit connection/model `apiProtocol` +wins. An ambiguous standard DeepSeek V4 Flash connection defaults to Responses; +using the CC wire requires an explicit Anthropic-compatible connection. Maka +does not retry a failed native search over the other protocol. + +A single same-prompt DeepSeek V4 Flash comparison on 2026-08-04 observed: + +| Wire | Latency | Search calls | Visible source rows | Tokens | Estimated cost | +| --- | ---: | ---: | ---: | ---: | ---: | +| Responses | 9.46 s | 2 | 0 | 5,943 | $0.00046 | +| Anthropic Messages | 3.13 s | 1 | 10 | 9,638 | $0.00134 | + +This is a smoke comparison, not a latency benchmark. It shows the trade-off: +the CC wire provided stricter `maxUses`, lower observed latency, and structured +source rows; Responses used much more cache, cost about one third as much, and +matched the general Codex/coding-agent wire. The default therefore remains +Responses, while the Anthropic-compatible route stays an explicit choice for +search-heavy workflows that value source visibility over cache economics. + +## Provider matrix + +| Provider or access path | Official hosted search surface | Model boundary | Maka state | +| --- | --- | --- | --- | +| DeepSeek | Responses `web_search`, server-executed | `deepseek-v4-flash`; DeepSeek documents V4 Pro as not yet supported by Responses | Integrated through `openai-responses` | +| OpenAI API | Responses `web_search` tool | Maka currently enables the native path for GPT-5 families, whose runtime wire is already Responses | Integrated through `openai-responses` | +| Custom Responses relay | Responses `web_search` tool when explicitly declared by model metadata | `openai-responses-compatible` connections with `apiProtocol=openai-responses` and `capabilities.webSearch=true` | Integrated through `openai-responses` | +| xAI API / OAuth | Responses Agent Tools `web_search` | Maka currently enables the verified Grok 4.5 Responses route | Integrated through `openai-responses` | +| Alibaba Model Studio | Responses `web_search` | Qwen 3.5 Plus/Flash provider support is recorded | Provider supports it; Maka Responses adapter pending | +| Anthropic / Claude subscription | Messages `web_search_20250305` | Current Claude Opus/Sonnet/Haiku/Fable families | Integrated through `anthropic-messages` | +| MiniMax API / Coding Plan | Anthropic-compatible `web_search_20250305` | MiniMax M2.7/M3 families | Integrated through `anthropic-messages`; live provider verification pending | +| Google Gemini | Gemini API grounding with Google Search | Supported Gemini 2.0+ model families vary by release | Provider supports it; Maka adapter pending | +| Z.AI / GLM Coding Plan | Chat tool and standalone Web Search API | GLM model/tool availability varies by endpoint | Provider supports it; Maka adapter pending | +| Mistral | Agents/Conversations `websearch` connector | Agent-capable models | Provider supports it; Maka adapter pending | +| Groq | Compound systems with built-in web search | `groq/compound` and `groq/compound-mini` | Provider supports it; Maka adapter pending | +| OpenRouter | Web plugin and `:online` variants | Depends on routed model/plugin support | Provider supports it; Maka adapter pending | +| Moonshot API | No general model-API hosted search contract found | Kimi Code exposes `SearchWeb`, but that is a managed coding-platform tool | Not treated as Moonshot model capability | +| Cohere, Together, Fireworks, SiliconFlow | No general hosted-search contract found in the reviewed API docs | Client tools or external search remain required | Use Tavily or another future external provider | + +## Official references + +- DeepSeek Responses compatibility: + https://api-docs.deepseek.com/guides/responses_api +- DeepSeek Anthropic compatibility: + https://api-docs.deepseek.com/guides/anthropic_api/ +- OpenAI web search: + https://developers.openai.com/api/docs/guides/tools-web-search +- Anthropic web search: + https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool +- Gemini grounding with Google Search: + https://ai.google.dev/gemini-api/docs/google-search +- xAI search tools: + https://docs.x.ai/developers/tools/search-tools +- Alibaba Qwen web search: + https://www.alibabacloud.com/help/en/model-studio/web-search +- Z.AI web search: + https://docs.bigmodel.cn/cn/guide/tools/web-search +- Mistral websearch connector: + https://docs.mistral.ai/agents/tools/built-in/websearch +- Groq web search: + https://console.groq.com/docs/web-search +- OpenRouter web search: + https://openrouter.ai/docs/guides/features/plugins/web-search + +## Follow-up adapters + +Add adapters in this order: + +1. Add an opt-in `web_search_20260209` capability for Anthropic deployments + that support dynamic filtering; retain `20250305` as the compatibility + baseline used by Claude Code and third-party Anthropic-compatible services. +2. Gemini grounding with Google Search. +3. Z.AI native model tool. Keep its standalone Search API outside the + provider-native path. +4. OpenRouter web plugin. +5. Mistral Agents and Groq Compound, after defining their cost and result-row + projection contracts. + +Every adapter must execute in the primary model request, preserve provider tool +events and citation metadata, keep credential isolation, and retain explicit +no-fallback behavior. diff --git a/packages/cli/src/__tests__/runtime-bootstrap.test.ts b/packages/cli/src/__tests__/runtime-bootstrap.test.ts index 3d07c5e81b..94c9ccb33a 100644 --- a/packages/cli/src/__tests__/runtime-bootstrap.test.ts +++ b/packages/cli/src/__tests__/runtime-bootstrap.test.ts @@ -11,6 +11,7 @@ import { import { createConnectionStore, createFileCredentialStore, + createSettingsStore, createSessionStore, createSqliteShellRunStore, } from '@maka/storage'; @@ -27,6 +28,7 @@ import { GOAL_SET_TOOL_NAME, GOAL_STATUS_TOOL_NAME, IMPLEMENTATION_AGENT_ID, + WEB_RESEARCH_AGENT_ID, UPDATE_AGENT_GRAPH_TOOL_NAME, VIEW_AGENT_GRAPH_TOOL_NAME, YIELD_AGENT_GRAPH_TOOL_NAME, @@ -174,6 +176,115 @@ describe('Maka CLI runtime bootstrap', () => { }); }); + for (const wire of [ + { + name: 'Responses', + slug: 'deepseek-responses', + providerType: 'deepseek', + model: 'deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com', + apiProtocol: 'openai-responses', + providerToolKind: 'openai-web-search', + }, + { + name: 'Anthropic Messages', + slug: 'deepseek-anthropic', + providerType: 'anthropic-compatible', + model: 'deepseek-v4-flash', + baseUrl: 'https://api.deepseek.com/anthropic', + apiProtocol: 'anthropic-messages', + providerToolKind: 'anthropic-web-search-20250305', + }, + ] as const) { + test(`routes root native WebSearch over ${wire.name} without widening scoped child tools`, async () => { + await withWorkspace(async (workspaceRoot) => { + const connectionStore = createConnectionStore(workspaceRoot); + await connectionStore.create({ + slug: wire.slug, + name: `DeepSeek ${wire.name}`, + providerType: wire.providerType, + baseUrl: wire.baseUrl, + defaultModel: wire.model, + }); + await connectionStore.update(wire.slug, { + models: [ + { + id: wire.model, + apiProtocol: wire.apiProtocol, + }, + ], + }); + await createFileCredentialStore(workspaceRoot).setSecret(wire.slug, 'api_key', 'test-key'); + const settingsStore = createSettingsStore(workspaceRoot); + await settingsStore.update({ + webSearch: { enabled: true, defaultProvider: 'model' }, + }); + + const context = await createMakaCliRuntimeContext({ + surface: 'run', + workspaceRoot, + cwd: '/repo', + }); + try { + const session = await context.runtime.createSession({ + cwd: context.cwd, + backend: 'ai-sdk', + llmConnectionSlug: wire.slug, + model: wire.model, + permissionMode: 'bypass', + name: `native-search-${wire.name}`, + }); + const runtimeDeps = (context.runtime as unknown as RuntimeWithPrivateDeps).deps; + const header = await runtimeDeps.store.readHeader(session.id); + const rootBackend = await runtimeDeps.backends.build('ai-sdk', { + sessionId: session.id, + workspaceRoot, + header, + store: runtimeDeps.store, + }); + const rootInput = (rootBackend as unknown as { input: AiSdkBackendInput }).input; + const rootWebSearch = rootInput.tools.find((tool) => tool.name === 'WebSearch'); + assert.equal(rootWebSearch?.providerTool?.kind, wire.providerToolKind); + + const scopedBackend = await runtimeDeps.backends.build('ai-sdk', { + sessionId: session.id, + workspaceRoot, + header, + store: runtimeDeps.store, + tools: [ + { + name: 'ReadOnlyProbe', + description: 'Read-only test probe', + parameters: {}, + impl: () => 'ok', + }, + ], + }); + const scopedInput = (scopedBackend as unknown as { input: AiSdkBackendInput }).input; + assert.deepEqual( + scopedInput.tools.map((tool) => tool.name), + ['ReadOnlyProbe'], + ); + + await settingsStore.update({ privacy: { incognitoActive: true } }); + const privateBackend = await runtimeDeps.backends.build('ai-sdk', { + sessionId: session.id, + workspaceRoot, + header, + store: runtimeDeps.store, + }); + const privateInput = (privateBackend as unknown as { input: AiSdkBackendInput }).input; + assert.equal( + privateInput.tools.some((tool) => tool.name === 'WebSearch'), + false, + ); + } finally { + await context.close(); + } + }); + }); + } + test('treats child tools and prompt from BackendFactoryContext as hard boundaries', async () => { await withWorkspace(async (workspaceRoot) => { const connectionStore = createConnectionStore(workspaceRoot); @@ -594,7 +705,7 @@ describe('Maka CLI runtime bootstrap', () => { }); assert.deepEqual( runtimeDeps.childTools?.map((tool) => tool.name), - ['Read', 'Glob', 'Grep', 'Write', 'Edit', 'Bash'], + ['Read', 'Glob', 'Grep', 'WebSearch', 'Write', 'Edit', 'Bash'], ); assert.equal( runtimeDeps.childTools?.some((tool) => @@ -613,6 +724,11 @@ describe('Maka CLI runtime bootstrap', () => { ?.availability, { status: 'available' }, ); + assert.deepEqual( + childAgents.definitions.find((definition) => definition.id === WEB_RESEARCH_AGENT_ID) + ?.availability, + { status: 'unavailable', reason: 'missing_tools', missingTools: ['WebSearch'] }, + ); assert.equal(context.skills.host.toolNames.has(AGENT_SPAWN_TOOL_NAME), true); assert.equal(context.skills.host.toolNames.has(AGENT_SWARM_TOOL_NAME), true); } finally { diff --git a/packages/cli/src/runtime-bootstrap.ts b/packages/cli/src/runtime-bootstrap.ts index 9de962fccb..fdb9821fff 100644 --- a/packages/cli/src/runtime-bootstrap.ts +++ b/packages/cli/src/runtime-bootstrap.ts @@ -36,6 +36,7 @@ import { assertProductBindingCatalogClean, AGENT_TOOL_GROUP_ID, buildLlmHistorySummarizer, + buildNativeWebSearchTool, cleanupLegacyHistoryCompactArtifacts, buildProviderOptions, buildSubscriptionModelFetch, @@ -43,10 +44,13 @@ import { getAIModel, generateSessionTitle as generateRuntimeSessionTitle, loadHistoryCompactBlocksFromArtifacts, + listRunnableBuiltinAgentDefinitions, + replayPlanItemsToModelMessages, recoverAgentGraphSupervisorContextOverflow, resolveSkillDiscoveryPaths, resolveSelectedModelContextWindow, projectEffectiveProductToolSurface, + routeWebSearchTools, type AutomationDefinition, type EffectiveProductToolSurface, type HostCapabilities, @@ -336,8 +340,8 @@ export async function createMakaCliRuntimeContext( // worktree executor for implementation children). Agent tools are excluded // so children cannot recursively spawn from this surface. const childAgentTools = agentGraphEnabled - ? buildChildAgentTools( - buildBuiltinTools({ + ? buildChildAgentTools([ + ...buildBuiltinTools({ snapshotImage: createReadImageSnapshotter(artifactStore), ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker @@ -346,7 +350,8 @@ export async function createMakaCliRuntimeContext( } : {}), }), - ) + buildNativeWebSearchTool(), + ]) : []; const automationManager = new AutomationManager({ generateId: () => randomUUID(), @@ -569,7 +574,15 @@ export async function createMakaCliRuntimeContext( getTokenCount: (sessionId: string) => goalTokenCache.get(sessionId) ?? 0, }) : []; - const subagentTools = agentGraphEnabled ? buildParentAgentTools() : []; + const subagentTools = agentGraphEnabled + ? buildParentAgentTools({ + definitions: listRunnableBuiltinAgentDefinitions({ + tools: childAgentTools.filter((tool) => tool.name !== 'WebSearch'), + worktreeChildExecutorAvailable: worktreeChildExecutor !== undefined, + }), + }) + : []; + const subagentToolNames = new Set(subagentTools.map((tool) => tool.name)); const surfaceTools = input.surface === 'tui' ? [buildAskUserQuestionTool(), buildRequestSandboxBoundaryTool()] : []; let cliProductToolSurface: EffectiveProductToolSurface; @@ -644,9 +657,43 @@ export async function createMakaCliRuntimeContext( !ctx.tools && agentGraphEnabled ? await agentGraphCoordinator!.toolsForSession(ctx.sessionId) : []; + const settings = await settingsStore.get(); + const routedChildTools = routeWebSearchTools({ + tools: + settings.webSearch.defaultProvider === 'model' + ? childAgentTools + : childAgentTools.filter((tool) => tool.name !== 'WebSearch'), + settings: settings.webSearch, + connection: ready.connection, + model: ready.model, + privacy: settings.privacy, + }); + const targetSubagentTools = + !ctx.tools && agentGraphEnabled + ? buildParentAgentTools({ + definitions: listRunnableBuiltinAgentDefinitions({ + tools: routedChildTools, + worktreeChildExecutorAvailable: worktreeChildExecutor !== undefined, + }), + }) + : []; + const routedTools = routeWebSearchTools({ + tools: ctx.tools + ? ctx.tools + : [ + ...allTools.filter((tool) => !subagentToolNames.has(tool.name)), + ...targetSubagentTools, + ...agentGraphSupervisorTools, + ], + settings: settings.webSearch, + connection: ready.connection, + model: ready.model, + privacy: settings.privacy, + allowAddNative: ctx.tools === undefined, + }); const productToolSurface = projectEffectiveProductToolSurface({ host: 'cli', - tools: ctx.tools ? ctx.tools : [...allTools, ...agentGraphSupervisorTools], + tools: routedTools, policy: cliProductToolSurface.identity.policy, }); const backendTools = [...productToolSurface.tools]; @@ -786,6 +833,26 @@ export async function createMakaCliRuntimeContext( }); }); + const resolveChildTools = async (sessionId: string): Promise => { + const header = await store.readHeader(sessionId); + const ready = await resolveSessionTargetForSlug(header.llmConnectionSlug, { + connectionStore, + credentialStore, + requestedModel: header.model, + }); + const settings = await settingsStore.get(); + return routeWebSearchTools({ + tools: + settings.webSearch.defaultProvider === 'model' + ? childAgentTools + : childAgentTools.filter((tool) => tool.name !== 'WebSearch'), + settings: settings.webSearch, + connection: ready.connection, + model: ready.model, + privacy: settings.privacy, + }); + }; + runtime = new SessionManager({ store, runStore, @@ -823,8 +890,8 @@ export async function createMakaCliRuntimeContext( ); }, }), - ...(cliProductToolSurface.boundSurfaceIds.includes(AGENT_TOOL_GROUP_ID) - ? { childTools: childAgentTools, worktreeChildExecutor } + ...(agentGraphEnabled + ? { childTools: childAgentTools, resolveChildTools, worktreeChildExecutor } : {}), runtimeInvocationObserver: input.runtimeInvocationObserver, onSessionTitleChanged: input.onSessionTitleChanged, diff --git a/packages/core/package.json b/packages/core/package.json index c7c9f7ee29..712f90ce4c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -79,6 +79,7 @@ "./provider-contract-matrix": "./dist/provider-contract-matrix.js", "./model-catalog": "./dist/model-catalog.js", "./model-metadata": "./dist/model-metadata.js", + "./model-web-search": "./dist/model-web-search.js", "./model-thinking": "./dist/model-thinking.js", "./connection-readiness": "./dist/connection-readiness.js", "./provider-auth": "./dist/provider-auth.js", diff --git a/packages/core/src/__tests__/model-metadata.test.ts b/packages/core/src/__tests__/model-metadata.test.ts index 8e37b167c5..0cac0c5026 100644 --- a/packages/core/src/__tests__/model-metadata.test.ts +++ b/packages/core/src/__tests__/model-metadata.test.ts @@ -251,6 +251,12 @@ describe('openAiAdapterApiProtocol', () => { assert.equal(openAiAdapterApiProtocol('grok-4.5', 'openai'), 'openai-chat'); }); + it('routes only DeepSeek V4 Flash through the provider Responses wire', () => { + assert.equal(openAiAdapterApiProtocol('deepseek-v4-flash', 'deepseek'), 'openai-responses'); + assert.equal(openAiAdapterApiProtocol('deepseek-v4-pro', 'deepseek'), 'openai-chat'); + assert.equal(openAiAdapterApiProtocol('deepseek-chat', 'deepseek'), 'openai-chat'); + }); + it('reuses xAI model metadata for the OAuth access path', () => { assert.deepEqual( lookupModelMetadata('xai-oauth', 'grok-4.5'), diff --git a/packages/core/src/__tests__/model-web-search.test.ts b/packages/core/src/__tests__/model-web-search.test.ts new file mode 100644 index 0000000000..52bd3a9ff6 --- /dev/null +++ b/packages/core/src/__tests__/model-web-search.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import { resolveHostedWebSearchCapability } from '../model-web-search.js'; + +describe('hosted web search capability', () => { + it('enables the implemented Responses path only for declared model families', () => { + assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-flash'), { + adapter: 'openai-responses', + implemented: true, + }); + assert.equal(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-chat'), null); + assert.deepEqual(resolveHostedWebSearchCapability('openai', undefined, 'gpt-5.5'), { + adapter: 'openai-responses', + implemented: true, + }); + assert.deepEqual(resolveHostedWebSearchCapability('xai', undefined, 'grok-4.5'), { + adapter: 'openai-responses', + implemented: true, + }); + assert.deepEqual(resolveHostedWebSearchCapability('alibaba', undefined, 'qwen3.5-plus'), { + adapter: 'openai-responses', + implemented: false, + }); + assert.equal(resolveHostedWebSearchCapability('openai', undefined, 'gpt-4.1'), null); + }); + + it('reports provider support separately from Maka adapter readiness', () => { + assert.deepEqual( + resolveHostedWebSearchCapability('anthropic', undefined, 'claude-sonnet-4-6'), + { adapter: 'anthropic-messages', implemented: true }, + ); + assert.deepEqual(resolveHostedWebSearchCapability('google', undefined, 'gemini-2.5-flash'), { + adapter: 'google-grounding', + implemented: false, + }); + assert.deepEqual(resolveHostedWebSearchCapability('zai', undefined, 'glm-5'), { + adapter: 'zai-web-search', + implemented: false, + }); + assert.equal(resolveHostedWebSearchCapability('moonshot', undefined, 'kimi-k2.6'), null); + }); + + it('lets explicit connection model metadata deny or grant provider-hosted search', () => { + assert.equal( + resolveHostedWebSearchCapability( + 'deepseek', + [{ id: 'deepseek-v4-flash', capabilities: { webSearch: false } }], + 'deepseek-v4-flash', + ), + null, + ); + assert.deepEqual( + resolveHostedWebSearchCapability( + 'openai', + [ + { + id: 'custom-responses-model', + apiProtocol: 'openai-responses', + capabilities: { webSearch: true }, + }, + ], + 'custom-responses-model', + ), + { adapter: 'openai-responses', implemented: true }, + ); + assert.equal( + resolveHostedWebSearchCapability( + 'openai', + [ + { + id: 'custom-chat-model', + apiProtocol: 'openai-chat', + capabilities: { webSearch: true }, + }, + ], + 'custom-chat-model', + ), + null, + ); + assert.deepEqual( + resolveHostedWebSearchCapability( + 'openai-responses-compatible', + [ + { + id: 'relay-model', + apiProtocol: 'openai-responses', + capabilities: { webSearch: true }, + }, + ], + 'relay-model', + ), + { adapter: 'openai-responses', implemented: true }, + ); + }); + + it('keeps dual-wire providers on the configured connection protocol', () => { + assert.deepEqual(resolveHostedWebSearchCapability('deepseek', undefined, 'deepseek-v4-flash'), { + adapter: 'openai-responses', + implemented: true, + }); + assert.deepEqual( + resolveHostedWebSearchCapability( + 'anthropic-compatible', + [ + { + id: 'deepseek-v4-flash', + apiProtocol: 'anthropic-messages', + }, + ], + 'deepseek-v4-flash', + ), + { adapter: 'anthropic-messages', implemented: true }, + ); + assert.equal( + resolveHostedWebSearchCapability( + 'deepseek', + [{ id: 'deepseek-v4-flash', apiProtocol: 'openai-chat' }], + 'deepseek-v4-flash', + ), + null, + ); + assert.equal( + resolveHostedWebSearchCapability( + 'openai', + [{ id: 'gpt-5.5', apiProtocol: 'openai-chat' }], + 'gpt-5.5', + ), + null, + ); + }); +}); diff --git a/packages/core/src/__tests__/web-search.test.ts b/packages/core/src/__tests__/web-search.test.ts index 65cd4e8a21..7cede9934f 100644 --- a/packages/core/src/__tests__/web-search.test.ts +++ b/packages/core/src/__tests__/web-search.test.ts @@ -2,6 +2,7 @@ import { strict as assert } from 'node:assert'; import { describe, it } from 'node:test'; import { WEB_SEARCH_CREDENTIAL_SOURCES, + WEB_SEARCH_CREDENTIAL_PROVIDERS, WEB_SEARCH_PROVIDERS, defaultWebSearchSettings, isWebSearchCredentialSource, @@ -40,6 +41,7 @@ describe('web search settings', () => { it('accepts only closed provider and credential enums', () => { for (const provider of WEB_SEARCH_PROVIDERS) assert.equal(isWebSearchProvider(provider), true); for (const value of ['google', '', undefined]) assert.equal(isWebSearchProvider(value), false); + assert.deepEqual(WEB_SEARCH_CREDENTIAL_PROVIDERS, ['tavily']); for (const source of WEB_SEARCH_CREDENTIAL_SOURCES) { assert.equal(isWebSearchCredentialSource(source), true); @@ -142,7 +144,7 @@ describe('web search settings', () => { assert.deepEqual(normalized, { enabled: false, - defaultProvider: 'tavily', + defaultProvider: 'model', providers: { tavily: { apiKey: '', diff --git a/packages/core/src/events.ts b/packages/core/src/events.ts index b5ef2f3f49..08f63b9d10 100644 --- a/packages/core/src/events.ts +++ b/packages/core/src/events.ts @@ -450,6 +450,8 @@ export interface TextCompleteEvent extends BaseEvent { type: 'text_complete'; messageId: string; text: string; + /** Provider-owned text metadata such as Responses URL citations. */ + providerOptions?: Record; } export interface ThinkingDeltaEvent extends BaseEvent { @@ -479,6 +481,8 @@ export interface ToolStartEvent extends BaseEvent { args: unknown; /** Provider-owned opaque call metadata that must survive model replay. */ providerOptions?: Record; + /** True when the provider executed the tool inside the model request. */ + providerExecuted?: boolean; displayName?: string; intent?: string; /** @@ -525,6 +529,10 @@ export interface ToolResultEvent extends BaseEvent { toolUseId: string; /** Runtime-owned durable tool-operation identity (Phase 2). */ operationId?: string; + /** True when the provider executed the tool inside the model request. */ + providerExecuted?: boolean; + /** Raw provider result retained for provider-native replay; never rendered directly. */ + providerOutput?: unknown; isError: boolean; content: ToolResultContent; durationMs?: number; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7d1c8fb2da..40eca67cf0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1550,6 +1550,11 @@ export { resolveModelVisionSupport, resolveModelVoiceMetadata, } from './model-metadata.js'; +export type { + HostedWebSearchAdapter, + HostedWebSearchCapability, +} from './model-web-search.js'; +export { resolveHostedWebSearchCapability } from './model-web-search.js'; // settings.ts export type { @@ -1783,6 +1788,7 @@ export type { WebSearchErrorReason, WebSearchCredentialStatus, WebSearchCredentialSource, + WebSearchCredentialProvider, WebSearchProvider, WebSearchProviderSettings, WebSearchResponse, @@ -1794,6 +1800,7 @@ export { WEB_SEARCH_DEFAULT_LIMIT, WEB_SEARCH_CREDENTIAL_STATUSES, WEB_SEARCH_CREDENTIAL_SOURCES, + WEB_SEARCH_CREDENTIAL_PROVIDERS, WEB_SEARCH_MAX_LIMIT, WEB_SEARCH_PROVIDERS, WEB_SEARCH_QUERY_MAX_CHARS, diff --git a/packages/core/src/llm-connections.ts b/packages/core/src/llm-connections.ts index 40ece17e83..d0c51b459c 100644 --- a/packages/core/src/llm-connections.ts +++ b/packages/core/src/llm-connections.ts @@ -56,6 +56,8 @@ export interface ModelInfo { reasoning?: boolean; functionCalling?: boolean; imageGeneration?: boolean; + /** Provider-hosted live web search, using this exact model and connection. */ + webSearch?: boolean; }; /** * Voice is transport-sensitive: an `audio` modality alone does not prove diff --git a/packages/core/src/model-metadata.ts b/packages/core/src/model-metadata.ts index e446ae8fe3..d5687e3ea3 100644 --- a/packages/core/src/model-metadata.ts +++ b/packages/core/src/model-metadata.ts @@ -102,7 +102,8 @@ export function openAiAdapterApiProtocol( providerType?: ProviderType, ): 'openai-responses' | 'openai-chat' { const id = modelId.trim(); - return /^gpt-5/i.test(id) || + return (providerType === 'deepseek' && id === 'deepseek-v4-flash') || + /^gpt-5/i.test(id) || ((providerType === 'xai' || providerType === 'xai-oauth') && id === 'grok-4.5') ? 'openai-responses' : 'openai-chat'; @@ -475,6 +476,12 @@ const STATIC_MODEL_METADATA: Partial model.id === id); + if (stored?.capabilities?.webSearch === false) return null; + + const adapter = providerHostedWebSearchAdapter(providerType); + if (!adapter) return null; + if ( + stored?.apiProtocol !== undefined && + ((adapter.adapter === 'openai-responses' && stored.apiProtocol !== 'openai-responses') || + (adapter.adapter === 'anthropic-messages' && stored.apiProtocol !== 'anthropic-messages')) + ) { + return null; + } + if (stored?.capabilities?.webSearch === true) { + return adapter; + } + return providerDefaultHostedWebSearchCapability(providerType, id, adapter); +} + +function providerHostedWebSearchAdapter( + providerType: ProviderType, +): HostedWebSearchCapability | null { + switch (providerType) { + case 'deepseek': + case 'openai': + case 'openai-responses-compatible': + case 'xai': + case 'xai-oauth': + return { adapter: 'openai-responses', implemented: true }; + case 'alibaba': + return { adapter: 'openai-responses', implemented: false }; + case 'anthropic': + case 'claude-subscription': + case 'MiniMax': + case 'MiniMax-cn': + case 'minimax-coding-plan': + case 'anthropic-compatible': + return { adapter: 'anthropic-messages', implemented: true }; + case 'google': + case 'gemini-cli': + return { adapter: 'google-grounding', implemented: false }; + case 'zai': + case 'zai-coding-plan': + return { adapter: 'zai-web-search', implemented: false }; + case 'mistral': + return { adapter: 'mistral-agents', implemented: false }; + case 'groq': + return { adapter: 'groq-compound', implemented: false }; + case 'openrouter': + return { adapter: 'openrouter-web-plugin', implemented: false }; + default: + return null; + } +} + +function providerDefaultHostedWebSearchCapability( + providerType: ProviderType, + modelId: string, + capability: HostedWebSearchCapability, +): HostedWebSearchCapability | null { + switch (providerType) { + case 'deepseek': + return modelId === 'deepseek-v4-flash' ? capability : null; + case 'openai': + return /^gpt-5(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'xai': + case 'xai-oauth': + return modelId === 'grok-4.5' ? capability : null; + case 'alibaba': + return /^qwen3\.5-(?:plus|flash)(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'anthropic': + case 'claude-subscription': + return /^claude-(?:[\d.]+-)*(?:opus|sonnet|haiku|fable)\b/i.test(modelId) ? capability : null; + case 'MiniMax': + case 'MiniMax-cn': + case 'minimax-coding-plan': + return /^MiniMax-M(?:2\.7|3)(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'anthropic-compatible': + return modelId === 'deepseek-v4-flash' ? capability : null; + case 'openai-responses-compatible': + return null; + case 'google': + case 'gemini-cli': + return /^gemini-(?:2\.0|2\.5|3|3\.1|3\.5)(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'zai': + case 'zai-coding-plan': + return /^glm-(?:4\.5|4\.6|4\.7|5)(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'mistral': + return capability; + case 'groq': + return /^groq\/compound(?:-mini)?$/i.test(modelId) ? capability : null; + case 'openrouter': + return capability; + default: + return null; + } +} diff --git a/packages/core/src/provider-registry.ts b/packages/core/src/provider-registry.ts index 1f635ff4bd..e5dd4e2e12 100644 --- a/packages/core/src/provider-registry.ts +++ b/packages/core/src/provider-registry.ts @@ -817,7 +817,11 @@ const providerRegistry = { fallbackModels: ['deepseek-chat', 'deepseek-reasoner'], status: 'ready', protocol: 'openai', - runtimeAdapter: { kind: 'openai-compatible', name: 'provider' }, + runtimeAdapter: { + kind: 'openai-compatible', + name: 'provider', + supportsOpenAiResponses: true, + }, modelDiscovery: { kind: 'protocol' }, category: 'domestic', catalogGroup: 'api', diff --git a/packages/core/src/runtime-event.ts b/packages/core/src/runtime-event.ts index f5d65e8ea1..1ae2935d41 100644 --- a/packages/core/src/runtime-event.ts +++ b/packages/core/src/runtime-event.ts @@ -122,6 +122,8 @@ export function isTerminalRuntimeEventStatus(value: unknown): boolean { export interface RuntimeEventTextContent extends MessageContent { kind: 'text'; + /** Provider-owned text metadata such as Responses URL citations. */ + providerOptions?: Record; /** Durable provenance for a host-authored user-role turn. */ origin?: TurnOrigin; /** @@ -151,6 +153,7 @@ export interface RuntimeEventFunctionCallContent { args: unknown; /** Provider-owned opaque call metadata that must survive model replay. */ providerOptions?: Record; + providerExecuted?: boolean; } export interface RuntimeEventFunctionResponseContent { @@ -160,6 +163,9 @@ export interface RuntimeEventFunctionResponseContent { name: string; result: unknown; isError?: boolean; + providerExecuted?: boolean; + /** Raw provider result retained for provider-native replay; never rendered directly. */ + providerOutput?: unknown; } export interface RuntimeEventErrorContent { @@ -426,7 +432,15 @@ const RUNTIME_EVENT_SHAPE = defineObjectShape()( ); const TEXT_CONTENT_SHAPE = defineObjectShape()( ['kind', 'text'], - ['displayText', 'origin', 'attachments', 'quotes', 'inlineReferences', 'steering'], + [ + 'displayText', + 'origin', + 'attachments', + 'quotes', + 'inlineReferences', + 'steering', + 'providerOptions', + ], ); const THINKING_CONTENT_SHAPE = defineObjectShape()( ['kind', 'text'], @@ -434,11 +448,11 @@ const THINKING_CONTENT_SHAPE = defineObjectShape()( ); const FUNCTION_CALL_CONTENT_SHAPE = defineObjectShape()( ['kind', 'id', 'name', 'args'], - ['providerOptions'], + ['providerOptions', 'providerExecuted'], ); const FUNCTION_RESPONSE_CONTENT_SHAPE = defineObjectShape()( ['kind', 'id', 'name', 'result'], - ['isError'], + ['isError', 'providerExecuted', 'providerOutput'], ); const ERROR_CONTENT_SHAPE = defineObjectShape()( ['kind', 'message'], @@ -627,7 +641,8 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { if ( !hasExactShape(value, TEXT_CONTENT_SHAPE) || (value.origin !== undefined && !isTurnOrigin(value.origin)) || - (value.steering !== undefined && value.steering !== true) + (value.steering !== undefined && value.steering !== true) || + (value.providerOptions !== undefined && !isRecord(value.providerOptions)) ) { return false; } @@ -653,7 +668,8 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.id === 'string' && typeof value.name === 'string' && Object.hasOwn(value, 'args') && - (value.providerOptions === undefined || isRecord(value.providerOptions)) + (value.providerOptions === undefined || isRecord(value.providerOptions)) && + (value.providerExecuted === undefined || typeof value.providerExecuted === 'boolean') ); case 'function_response': return ( @@ -661,7 +677,8 @@ function isRuntimeEventContent(value: unknown): value is RuntimeEventContent { typeof value.id === 'string' && typeof value.name === 'string' && Object.hasOwn(value, 'result') && - (value.isError === undefined || typeof value.isError === 'boolean') + (value.isError === undefined || typeof value.isError === 'boolean') && + (value.providerExecuted === undefined || typeof value.providerExecuted === 'boolean') ); case 'error': return ( diff --git a/packages/core/src/runtime-policy.ts b/packages/core/src/runtime-policy.ts index 5b5b65308f..9acccae319 100644 --- a/packages/core/src/runtime-policy.ts +++ b/packages/core/src/runtime-policy.ts @@ -6,7 +6,11 @@ import type { } from './llm-connections.js'; import type { ProviderType } from './provider-registry.js'; import type { ChatDefaultPermissionMode, ProxyProtocol } from './settings.js'; -import { WEB_SEARCH_PROVIDERS, type WebSearchProvider } from './web-search.js'; +import { + WEB_SEARCH_PROVIDERS, + type WebSearchCredentialProvider, + type WebSearchProvider, +} from './web-search.js'; export { WEB_SEARCH_PROVIDERS }; export type { ConnectionTestErrorClass, ModelDiscoverySource } from './llm-connections.js'; @@ -139,7 +143,7 @@ export function createDefaultRuntimePolicy(): RuntimePolicy { workspaceInstructions: { enabled: true }, privacy: { incognitoActive: false }, chatDefaults: { permissionMode: 'ask' }, - webSearch: { enabled: false, defaultProvider: 'tavily' }, + webSearch: { enabled: false, defaultProvider: 'model' }, }; } @@ -239,7 +243,11 @@ export type CredentialLocator = readonly connectionId: EntityId; readonly kind: 'api_key' | 'oauth_token'; } - | { readonly scope: 'web_search'; readonly provider: WebSearchProvider; readonly kind: 'api_key' } + | { + readonly scope: 'web_search'; + readonly provider: WebSearchCredentialProvider; + readonly kind: 'api_key'; + } | { readonly scope: 'network_proxy'; readonly kind: 'password' }; export interface CredentialIdentity { diff --git a/packages/core/src/runtime-policy/connection-catalog-codec.ts b/packages/core/src/runtime-policy/connection-catalog-codec.ts index 387a9fd8be..7a26886338 100644 --- a/packages/core/src/runtime-policy/connection-catalog-codec.ts +++ b/packages/core/src/runtime-policy/connection-catalog-codec.ts @@ -254,7 +254,7 @@ export function decodeConnectionModel(value: unknown): ConnectionModel { const raw = exactRecord( item.capabilities, 'connection model capabilities', - ['chat', 'vision', 'reasoning', 'functionCalling', 'imageGeneration'], + ['chat', 'vision', 'reasoning', 'functionCalling', 'imageGeneration', 'webSearch'], [], ); capabilities = {}; diff --git a/packages/core/src/runtime-policy/credential-vault-codec.ts b/packages/core/src/runtime-policy/credential-vault-codec.ts index 3b147ec2c5..f672888d1f 100644 --- a/packages/core/src/runtime-policy/credential-vault-codec.ts +++ b/packages/core/src/runtime-policy/credential-vault-codec.ts @@ -5,7 +5,7 @@ import type { DeleteCredentialInput, SetCredentialInput, } from '../runtime-policy.js'; -import { WEB_SEARCH_PROVIDERS } from '../web-search.js'; +import { WEB_SEARCH_CREDENTIAL_PROVIDERS } from '../web-search.js'; import { domainError, entityIdValue, @@ -40,7 +40,7 @@ export function decodeCredentialLocator(value: unknown): CredentialLocator { const item = exactRecord(value, 'web search credential locator', ['scope', 'provider', 'kind']); if ( item.kind !== 'api_key' || - !(WEB_SEARCH_PROVIDERS as readonly unknown[]).includes(item.provider) + !(WEB_SEARCH_CREDENTIAL_PROVIDERS as readonly unknown[]).includes(item.provider) ) { throw domainError('web search credential locator is invalid'); } diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 1f79ab7929..96e5bc5179 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -713,6 +713,8 @@ export interface AssistantMessage { turnId: string; ts: number; text: string; + /** Provider-owned text metadata such as Responses URL citations. */ + providerOptions?: Record; thinking?: AssistantThinking; /** * First-observed order of visible content inside this assistant step. @@ -759,6 +761,7 @@ export interface ToolCallMessage { args: unknown; /** Provider-owned opaque call metadata retained for recovery backfill. */ providerOptions?: Record; + providerExecuted?: boolean; /** * Assistant step this call belongs to (equals the step's AssistantMessage * id, stamped from the same source as ToolStartEvent.stepId). Optional for @@ -780,6 +783,9 @@ export interface ToolResultMessage { toolUseId: string; isError: boolean; content: ToolResultContent; + providerExecuted?: boolean; + /** Raw provider result retained only for provider-native replay. */ + providerOutput?: unknown; durationMs?: number; } @@ -898,15 +904,15 @@ const USER_MESSAGE_SHAPE = defineObjectShape()( ); const ASSISTANT_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'text', 'modelId'], - ['thinking', 'contentOrder'], + ['thinking', 'contentOrder', 'providerOptions'], ); const TOOL_CALL_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolName', 'args'], - ['activityKind', 'displayName', 'intent', 'providerOptions', 'stepId'], + ['activityKind', 'displayName', 'intent', 'providerOptions', 'providerExecuted', 'stepId'], ); const TOOL_RESULT_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolUseId', 'isError', 'content'], - ['durationMs'], + ['durationMs', 'providerExecuted', 'providerOutput'], ); const PERMISSION_DECISION_MESSAGE_SHAPE = defineObjectShape()( ['type', 'id', 'turnId', 'ts', 'toolUseId', 'toolName', 'decision'], @@ -1030,6 +1036,7 @@ function decodeStoredMessage( hasMessageEnvelope(message, true) && typeof message.text === 'string' && typeof message.modelId === 'string' && + (message.providerOptions === undefined || isRecord(message.providerOptions)) && (message.thinking === undefined || isAssistantThinking(message.thinking)) && (message.contentOrder === undefined || (Array.isArray(message.contentOrder) && @@ -1050,6 +1057,7 @@ function decodeStoredMessage( isOptionalString(message.displayName) && isOptionalString(message.intent) && (message.providerOptions === undefined || isRecord(message.providerOptions)) && + (message.providerExecuted === undefined || typeof message.providerExecuted === 'boolean') && isOptionalString(message.stepId) ) return message as unknown as ToolCallMessage; @@ -1060,6 +1068,7 @@ function decodeStoredMessage( hasMessageEnvelope(message, true) && typeof message.toolUseId === 'string' && typeof message.isError === 'boolean' && + (message.providerExecuted === undefined || typeof message.providerExecuted === 'boolean') && isOptionalFiniteDuration(message.durationMs) ) return message as unknown as ToolResultMessage; diff --git a/packages/core/src/web-search.ts b/packages/core/src/web-search.ts index 516f7d7d90..ff89c618c8 100644 --- a/packages/core/src/web-search.ts +++ b/packages/core/src/web-search.ts @@ -8,9 +8,11 @@ * fields and never expose cleartext credentials or raw provider errors. */ -/** Closed enum of providers V0.1 will accept. */ -export const WEB_SEARCH_PROVIDERS = ['tavily'] as const; +/** Closed enum of search execution sources. */ +export const WEB_SEARCH_PROVIDERS = ['model', 'tavily'] as const; export type WebSearchProvider = (typeof WEB_SEARCH_PROVIDERS)[number]; +export const WEB_SEARCH_CREDENTIAL_PROVIDERS = ['tavily'] as const; +export type WebSearchCredentialProvider = (typeof WEB_SEARCH_CREDENTIAL_PROVIDERS)[number]; /** Renderer-safe result row. No raw HTML, no provider tag soup. */ export interface WebSearchResultRow { @@ -35,7 +37,11 @@ export type WebSearchErrorReason = /** Discriminated response: success = array, error = typed object. */ export type WebSearchResponse = - | { readonly ok: true; readonly results: ReadonlyArray } + | { + readonly ok: true; + readonly provider?: WebSearchProvider; + readonly results: ReadonlyArray; + } | { readonly ok: false; readonly reason: WebSearchErrorReason; readonly message: string }; export const WEB_SEARCH_QUERY_MAX_CHARS = 200; @@ -128,7 +134,7 @@ export type WebSearchSettingsPatch = Partial<{ export function defaultWebSearchSettings(): WebSearchSettings { return { enabled: false, - defaultProvider: 'tavily', + defaultProvider: 'model', providers: { tavily: { apiKey: '', @@ -209,7 +215,7 @@ export function normalizeWebSearchSettings(settings: WebSearchSettings): WebSear const enabled = settings.enabled === true; const defaultProvider = isWebSearchProvider(settings.defaultProvider) ? settings.defaultProvider - : 'tavily'; + : 'model'; // Cap apiKey length defensively. Tavily keys are < 64 chars; anything // longer is almost certainly garbage that would break log redaction. const rawApiKey = settings.providers?.tavily?.apiKey; diff --git a/packages/headless/README.md b/packages/headless/README.md index fe550ba567..4373931284 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -52,6 +52,11 @@ default**: runners project that config to the canonical cell setting `MAKA_AGENT_TOOLS=true`; direct Harbor cell/CLI entrypoints accept the same environment setting (`false` is the default). +- Provider-native web search is opt-in for benchmark reproducibility. Pass + `MAKA_WEB_SEARCH_ENABLED=true` through `HarborTaskRunnerOptions.agentEnv` or + the direct cell environment to expose native `WebSearch` when the exact + provider/model wire supports it. The Harbor adapter forwards this flag to the + in-container cell without forwarding a second search credential. Executors can implement native file-operation methods, or rely on the command-backed fallback when the isolated workspace has `node` available. The headless helper rejects absolute paths, `..` escapes, and absolute glob diff --git a/packages/headless/harbor/maka_agent.py b/packages/headless/harbor/maka_agent.py index 0fa61203e9..c030fe0d67 100644 --- a/packages/headless/harbor/maka_agent.py +++ b/packages/headless/harbor/maka_agent.py @@ -619,6 +619,7 @@ def _cell_env(self, instruction_path: Any) -> dict[str, str]: "MAKA_TRIAL_PRICING_SOURCE", "MAKA_REASONING_EFFORT", "MAKA_AGENT_TOOLS", + "MAKA_WEB_SEARCH_ENABLED", "MAKA_MODEL_API_PROTOCOL", # Default per-command timeout floor for the in-container Bash tool, so # long builds/tests do not hit a hard-coded 2-minute ceiling. diff --git a/packages/headless/harbor/maka_trajectory.py b/packages/headless/harbor/maka_trajectory.py index 5429d70c1a..b71b95ac82 100644 --- a/packages/headless/harbor/maka_trajectory.py +++ b/packages/headless/harbor/maka_trajectory.py @@ -1795,6 +1795,7 @@ def _is_runtime_content(content: Any) -> bool: "attachments", "quotes", "steering", + "providerOptions", } and isinstance(content.get("text"), str) and ("displayText" not in content or isinstance(content["displayText"], str)) @@ -1808,6 +1809,10 @@ def _is_runtime_content(content: Any) -> bool: and all(_is_quote_ref(item) for item in content["quotes"]) )) and ("steering" not in content or content["steering"] is True) + and ( + "providerOptions" not in content + or isinstance(content["providerOptions"], dict) + ) ) if kind == "thinking": return ( @@ -1821,7 +1826,15 @@ def _is_runtime_content(content: Any) -> bool: ) if kind == "function_call": return ( - set(content) <= {"kind", "id", "name", "args", "providerOptions"} + set(content) + <= { + "kind", + "id", + "name", + "args", + "providerOptions", + "providerExecuted", + } and isinstance(content.get("id"), str) and isinstance(content.get("name"), str) and "args" in content @@ -1829,14 +1842,31 @@ def _is_runtime_content(content: Any) -> bool: "providerOptions" not in content or isinstance(content["providerOptions"], dict) ) + and ( + "providerExecuted" not in content + or isinstance(content["providerExecuted"], bool) + ) ) if kind == "function_response": return ( - set(content) <= {"kind", "id", "name", "result", "isError"} + set(content) + <= { + "kind", + "id", + "name", + "result", + "isError", + "providerExecuted", + "providerOutput", + } and isinstance(content.get("id"), str) and isinstance(content.get("name"), str) and "result" in content and ("isError" not in content or isinstance(content["isError"], bool)) + and ( + "providerExecuted" not in content + or isinstance(content["providerExecuted"], bool) + ) ) if kind == "error": return ( diff --git a/packages/headless/src/__tests__/harbor-adapter.test.ts b/packages/headless/src/__tests__/harbor-adapter.test.ts index 604381838b..38d3e95fbb 100644 --- a/packages/headless/src/__tests__/harbor-adapter.test.ts +++ b/packages/headless/src/__tests__/harbor-adapter.test.ts @@ -52,6 +52,7 @@ describe('Harbor adapter contract', () => { apiKey: 'private-provider-api-key', }, }, + providerExecuted: true, }, refs: { stepId: 'step-1', toolCallId: 'call-1' }, }); @@ -2303,6 +2304,12 @@ with tempfile.TemporaryDirectory() as tmp: })._cell_env(Path("/logs/agent/instruction.txt")) assert agent_tools_env["MAKA_AGENT_TOOLS"] == "true", agent_tools_env + web_search_env = MakaAgent(Path(tmp), extra_env={ + "MAKA_BACKEND": "fake", + "MAKA_WEB_SEARCH_ENABLED": "true", + })._cell_env(Path("/logs/agent/instruction.txt")) + assert web_search_env["MAKA_WEB_SEARCH_ENABLED"] == "true", web_search_env + # Host-side LLM mode must not forward provider secrets into the task-cell env. host_agent = MakaAgent(Path(tmp), extra_env={ "MAKA_HOST_API_KEY_FILE": "/host/secrets/deepseek-key", @@ -2837,6 +2844,29 @@ assert provider_options_extra["maka_provider_options"] == { } }, provider_options_extra +native_search_events = json.loads(json.dumps(events)) +native_search_events[5]["content"]["providerExecuted"] = True +native_search_events[6]["content"]["providerExecuted"] = True +native_search_events[6]["content"]["providerOutput"] = [ + { + "type": "web_search_result", + "url": "https://maka.example/", + "title": "Maka", + "pageAge": None, + "encryptedContent": "encrypted-result", + } +] +native_search_events[7]["content"]["providerOptions"] = { + "openai": { + "itemId": "message-1", + "annotations": [{"type": "url_citation", "url": "https://maka.example/"}], + } +} +native_search = build_runtime_trajectory( + native_search_events, "completed", runtime_refs +) +assert native_search.artifact_kind == "full", native_search + schema_corpus = json.loads(${JSON.stringify(schemaCorpusJson)}) schema_runtime_refs = { key: schema_corpus["baseEvent"][key] diff --git a/packages/headless/src/__tests__/harbor-cell.test.ts b/packages/headless/src/__tests__/harbor-cell.test.ts index 2ccbe92c01..5561d7031a 100644 --- a/packages/headless/src/__tests__/harbor-cell.test.ts +++ b/packages/headless/src/__tests__/harbor-cell.test.ts @@ -2096,7 +2096,13 @@ describe('runHarborCell', () => { for (const expected of ['Bash', 'Read', 'Write', 'Edit', 'Glob', 'Grep']) { assert.ok(toolNames.includes(expected), `expected provider schema tool ${expected}`); } - for (const unexpected of ['agent_spawn', 'agent_swarm', 'agent_list', 'agent_output']) { + for (const unexpected of [ + 'WebSearch', + 'agent_spawn', + 'agent_swarm', + 'agent_list', + 'agent_output', + ]) { assert.ok(!toolNames.includes(unexpected), `unexpected default Agent tool ${unexpected}`); } assert.match( @@ -2136,6 +2142,82 @@ describe('runHarborCell', () => { }); }); + test('Harbor opt-in routes native WebSearch over Responses and Anthropic Messages', async () => { + await withDirs(async ({ workspaceDir, artifactStore }) => { + const toolExecutor = fakeToolExecutor(); + for (const wire of [ + { + provider: 'deepseek', + providerToolKind: 'openai-web-search', + env: { + DEEPSEEK_API_KEY: 'test-key', + MAKA_WEB_SEARCH_ENABLED: 'true', + }, + }, + { + provider: 'anthropic-compatible', + providerToolKind: 'anthropic-web-search-20250305', + env: { + MAKA_HOST_API_KEY: 'test-key', + MAKA_HOST_BASE_URL: 'https://api.deepseek.com/anthropic', + MAKA_WEB_SEARCH_ENABLED: 'true', + }, + }, + ] as const) { + const registry = new BackendRegistry(); + const register = buildAiSdkCellBackendRegistration({ + provider: wire.provider, + model: 'deepseek-v4-flash', + env: wire.env, + now: () => 123, + newId: () => 'id', + }); + await registerProjectedAiSdkBackend(register, registry, { + config: { + id: `harbor-native-search-${wire.provider}`, + backend: 'ai-sdk', + llmConnectionSlug: wire.provider, + model: 'deepseek-v4-flash', + systemPrompt: DEFAULT_HEADLESS_SYSTEM_PROMPT, + }, + task: { id: 'harbor-cell', instruction: 'search', workspaceDir }, + storageRoot: workspaceDir, + workspaceDir, + artifactStore, + realBackendIsolation: { + kind: 'external', + label: 'Harbor task container', + toolExecutor, + }, + toolExecutor, + ...createHeadlessSessionCapabilityBridge().capabilities, + }); + + const rootBackend = await registry.build('ai-sdk', backendContext(workspaceDir)); + const rootInput = (rootBackend as unknown as { input: AiSdkBackendInput }).input; + const rootWebSearch = rootInput.tools.find((tool) => tool.name === 'WebSearch'); + assert.equal(rootWebSearch?.providerTool?.kind, wire.providerToolKind); + + const scopedBackend = await registry.build('ai-sdk', { + ...backendContext(workspaceDir), + tools: [ + { + name: 'ReadOnlyProbe', + description: 'Read-only test probe', + parameters: {}, + impl: () => 'ok', + }, + ], + }); + const scopedInput = (scopedBackend as unknown as { input: AiSdkBackendInput }).input; + assert.deepEqual( + scopedInput.tools.map((tool) => tool.name), + ['ReadOnlyProbe'], + ); + } + }); + }); + test('Harbor ai-sdk backend registration binds the deferred Agent group only when enabled', async () => { await withDirs(async ({ workspaceDir, artifactStore }) => { const registry = new BackendRegistry(); diff --git a/packages/headless/src/harbor-cell.ts b/packages/headless/src/harbor-cell.ts index 507d484629..45fd4aa67b 100644 --- a/packages/headless/src/harbor-cell.ts +++ b/packages/headless/src/harbor-cell.ts @@ -27,6 +27,7 @@ import { loadSynthesisCacheBlocksFromArtifacts, persistSynthesisCacheBlocksToArtifacts, projectEffectiveProductToolSurface, + routeWebSearchTools, type InvocationResult, type SynthesisCacheArtifactStore, type SynthesisCacheLoader, @@ -1059,6 +1060,8 @@ export function buildAiSdkCellBackendRegistration(input: { env: input.env, ts: input.now(), }); + const nativeWebSearchEnabled = + booleanEnv(input.env.MAKA_WEB_SEARCH_ENABLED, 'MAKA_WEB_SEARCH_ENABLED') ?? false; const modelKey = `${connection.providerType}:${input.model}`; const pricingOverride = resolveHarborCellPricingOverride(input.env, modelKey); const lookupPricing = pricingOverride @@ -1104,13 +1107,21 @@ export function buildAiSdkCellBackendRegistration(input: { ...(input.fetch ? { fetchFn: input.fetch } : {}), }); const providerFetch = subscriptionFetch ?? input.fetch; - const productToolSurface = ctx.tools - ? projectEffectiveProductToolSurface({ - host: 'headless', - tools: ctx.tools, - policy: context.productToolSurface!.identity.policy, - }) - : context.productToolSurface!; + const routedTools = routeWebSearchTools({ + tools: ctx.tools ?? context.productToolSurface!.tools, + settings: { + enabled: nativeWebSearchEnabled, + defaultProvider: 'model', + }, + connection, + model: input.model, + allowAddNative: ctx.tools === undefined, + }); + const productToolSurface = projectEffectiveProductToolSurface({ + host: 'headless', + tools: routedTools, + policy: context.productToolSurface!.identity.policy, + }); const productTools = [...productToolSurface.tools]; const supplementalTools = ctx.tools ? [] diff --git a/packages/runtime-host/src/__tests__/dependency-boundary.test.ts b/packages/runtime-host/src/__tests__/dependency-boundary.test.ts index 53bf0a68a3..2591f303e1 100644 --- a/packages/runtime-host/src/__tests__/dependency-boundary.test.ts +++ b/packages/runtime-host/src/__tests__/dependency-boundary.test.ts @@ -38,6 +38,7 @@ const allowedServerExternalImports = new Set([ '@maka/core/model-call-usage-projection', '@maka/core/model-catalog', '@maka/core/model-metadata', + '@maka/core/model-web-search', '@maka/core/model-thinking', '@maka/core/oauth-subscription', '@maka/core/plan', diff --git a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts index 5d31766120..2899ec6d07 100644 --- a/packages/runtime-host/src/__tests__/execution-model-composition.test.ts +++ b/packages/runtime-host/src/__tests__/execution-model-composition.test.ts @@ -22,6 +22,7 @@ import { serializeOAuthSubscriptionTokens, type OAuthSubscriptionTokens, type BackendFactoryContext, + type AiSdkBackendInput, type FilesystemWorkerExecuteInput, type MakaTool, type MakaToolContext, @@ -280,6 +281,56 @@ test('backend creation does not acquire Client Capabilities beyond a bound tool } }); +test('backend creation routes a bound WebSearch tool without widening the child ceiling', async () => { + const clientSearch: MakaTool = { + name: 'WebSearch', + description: 'Client web search', + parameters: {}, + impl: async () => undefined, + }; + const ready = { + kind: 'ready' as const, + connection: { + slug: 'deepseek-responses', + providerType: 'deepseek' as const, + enabledModelIds: ['deepseek-v4-flash'], + models: [{ id: 'deepseek-v4-flash', apiProtocol: 'openai-responses' as const }], + }, + networkProxy: { enabled: false }, + secretMaterial: { connection: { secret: API_KEY } }, + }; + const policy = { + ...createDefaultRuntimePolicy(), + webSearch: { enabled: true, defaultProvider: 'model' as const }, + }; + const runtimePolicy = { + operations: { resolveExecutionConnection: async () => ready }, + runtimePolicy: { + getSnapshot: async () => ({ revision: 1, policy }), + }, + } as unknown as RuntimePolicyStoresWriter; + const backend = await createHostAiSdkBackend( + backendCreationFixture({ + abortSignal: new AbortController().signal, + resolveExecutionConnection: async () => ready, + readPricing: async () => ({ revision: 0, overrides: [] }), + runtimePolicy, + tools: [clientSearch], + modelId: 'deepseek-v4-flash', + }), + ); + try { + const input = (backend as unknown as { input: AiSdkBackendInput }).input; + assert.deepEqual( + input.tools.map((tool) => tool.name), + ['WebSearch'], + ); + assert.equal(input.tools[0]?.providerTool?.kind, 'openai-web-search'); + } finally { + await backend.dispose(); + } +}); + test('production backend creation continues after a Session Client Capability is lost', async () => { const coordinator = new HostClientCapabilityCoordinator({ activation: new RuntimePolicyActivationGate(), @@ -593,6 +644,15 @@ test('production Host executes a canonical ai-sdk Session against a real provide }, }); assert.equal(memoryEnabled.kind, 'committed'); + policySnapshot = await policy.runtimePolicy.getSnapshot(); + const webSearchEnabled = await policy.runtimePolicy.mutate({ + expectedRevision: policySnapshot.revision, + operation: { + kind: 'set_web_search', + value: { enabled: true, defaultProvider: 'tavily' }, + }, + }); + assert.equal(webSearchEnabled.kind, 'committed'); const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const session = await execution.sessionStore.create({ @@ -1061,6 +1121,15 @@ test('production Host executes a durable runnable child with an exact tool ceili 'committed', ); await publishConnectionModel(policy, connection.connectionId, MODEL_ID, 32_768); + const policySnapshot = await policy.runtimePolicy.getSnapshot(); + const webSearchEnabled = await policy.runtimePolicy.mutate({ + expectedRevision: policySnapshot.revision, + operation: { + kind: 'set_web_search', + value: { enabled: true, defaultProvider: 'tavily' }, + }, + }); + assert.equal(webSearchEnabled.kind, 'committed'); const execution = await openInteractiveExecutionStoresForWrite(owner.lease); const parent = await execution.sessionStore.create({ @@ -1169,7 +1238,7 @@ test('production Host executes a durable runnable child with an exact tool ceili provider: 'tavily', query: 'latest hosted web result', reason: 'not_configured', - message: 'Enable web search before using this tool.', + message: 'Configure a Tavily API key before using web search.', }); const artifacts = await openInteractiveArtifactStoreForWrite(owner.lease); const childArtifacts = await artifacts.listTurnArtifacts(child.id, childRuns[0]!.turnId); @@ -1326,7 +1395,6 @@ test('production Host publishes and retires an implementation child patch', asyn assert.ok(toolNames(requests[1]?.body).includes('agent_spawn')); assert.deepEqual(toolParameterEnum(requests[1]?.body, 'agent_spawn', 'profile'), [ 'local_read', - 'web_research', 'implementation', ]); assert.deepEqual(toolNames(requests[2]?.body), [ @@ -2436,6 +2504,7 @@ function backendCreationFixture(input: { oauthCredentials?: HostOAuthExecutionAuthority; claudeDeviceId?: string; tools?: readonly MakaTool[]; + modelId?: string; snapshotClientCapabilities?: () => unknown; executionBoundary?: unknown; loadTurnRuntimeEvents?: () => Promise; @@ -2449,7 +2518,7 @@ function backendCreationFixture(input: { workspaceRoot: '/workspace', header: { llmConnectionSlug: 'backend-creation-connection', - model: MODEL_ID, + model: input.modelId ?? MODEL_ID, cwd: '/workspace', permissionMode: 'bypass', }, diff --git a/packages/runtime-host/src/__tests__/web-search-tool.test.ts b/packages/runtime-host/src/__tests__/web-search-tool.test.ts index e53c1e4fe6..02c1c13dd2 100644 --- a/packages/runtime-host/src/__tests__/web-search-tool.test.ts +++ b/packages/runtime-host/src/__tests__/web-search-tool.test.ts @@ -33,7 +33,7 @@ test('Host WebSearch fails closed before transport creation for unavailable poli throw new Error('transport must not be created'); }, }); - const result = (await tool.impl({ query: 'current result' }, {} as MakaToolContext)) as { + const result = (await tool.impl({ query: 'current result' }, context())) as { kind: string; reason: string; }; @@ -103,7 +103,7 @@ test('Host WebSearch consumes one canonical credential/proxy snapshot and closes }, }); - const result = await tool.impl({ query: ' latest Maka ', limit: 1 }, {} as MakaToolContext); + const result = await tool.impl({ query: ' latest Maka ', limit: 1 }, context()); assert.deepEqual(proxy, { enabled: true, type: 'https', @@ -153,9 +153,13 @@ test('Host WebSearch closes its transport when the owning turn is cancelled', as }), }); const running = Promise.resolve( - tool.impl({ query: 'cancel me' }, { - abortSignal: abort.signal, - } as MakaToolContext), + tool.impl( + { query: 'cancel me' }, + { + ...context(), + abortSignal: abort.signal, + }, + ), ); const reason = new DOMException('Turn stopped', 'AbortError'); abort.abort(reason); @@ -163,6 +167,24 @@ test('Host WebSearch closes its transport when the owning turn is cancelled', as assert.equal(closed, true); }); +test('Host client WebSearch refuses provider-native execution outside the primary request', async () => { + let transportCreated = false; + const tool = createHostWebSearchTool({ + policy: resolver({ kind: 'model_native_only', provider: 'model' }), + createFetchTransport: () => ({ + fetch: async () => { + transportCreated = true; + throw new Error('provider-native search must not create a client transport'); + }, + close: async () => {}, + }), + }); + + const result = await tool.impl({ query: 'DeepSeek current news', limit: 1 }, context()); + assert.equal((result as { reason?: string }).reason, 'unsupported_provider'); + assert.equal(transportCreated, false); +}); + function resolver( result: ResolveWebSearchExecutionResult, ): Pick { @@ -184,3 +206,14 @@ function readyDirectExecution(): ResolveWebSearchExecutionResult { }, }; } + +function context(): MakaToolContext { + return { + sessionId: 'session-1', + turnId: 'turn-1', + cwd: '/tmp', + toolCallId: 'tool-1', + abortSignal: new AbortController().signal, + emitOutput: () => {}, + }; +} diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 910285046d..04b03766dd 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -16,9 +16,11 @@ import { isBuiltinFilesystemWorkerSandboxAvailable, prepareSkillInvocationMessageFromInventory, RuntimeReadModel, + routeWebSearchTools, SessionManager, SessionActivityRegistry, ShellRunProcessManager, + type MakaTool, type RuntimeHostedRootAuthority, } from '@maka/runtime'; import { createAgentGraphControlStore } from '@maka/storage/agent-graph-control-store'; @@ -216,7 +218,11 @@ export async function createExecutionRuntimeHostComposition( ...(sandboxManager ? { sandboxManager } : {}), ...(filesystemWorker ? { filesystemWorker } : {}), }; - const hostTools = [createHostWebSearchTool({ policy: runtimePolicyStores.operations })]; + const hostTools = [ + createHostWebSearchTool({ + policy: runtimePolicyStores.operations, + }), + ]; const childAgentTools = createHostChildAgentToolComposition({ taskLedger, builtinTools, @@ -391,6 +397,8 @@ export async function createExecutionRuntimeHostComposition( resolveRootTools: (sessionId) => requireGraphCoordinator(graphCoordinator).toolsForSession(sessionId), parentAgentTools: childAgentTools.parentTools, + childTools: childAgentTools.childTools, + worktreePatchWriteBackAvailable: true, childAgents: bindHostChildAgentBackend( requireSessionManager(manager), backendContext.sessionId, @@ -467,6 +475,28 @@ export async function createExecutionRuntimeHostComposition( requestDrain: context.requestDrain, }); sessionEffects = sessionEffectCoordinator; + const resolveChildTools = async (sessionId: string): Promise => { + const header = await stores.sessionStore.readHeader(sessionId); + const [resolved, snapshot] = await Promise.all([ + runtimePolicyStores.operations.resolveExecutionConnection(header.llmConnectionSlug), + runtimePolicyStores.runtimePolicy.getSnapshot(), + ]); + if (resolved.kind !== 'ready') { + return childAgentTools.childTools.filter((tool) => tool.name !== 'WebSearch'); + } + const { models, ...connection } = resolved.connection; + return routeWebSearchTools({ + tools: childAgentTools.childTools, + settings: snapshot.policy.webSearch, + connection: { + ...connection, + defaultModel: header.model, + ...(models ? { models: [...models] } : {}), + }, + model: header.model, + privacy: snapshot.policy.privacy, + }); + }; manager = new SessionManager({ store: stores.sessionStore, runStore: stores.agentRunStore, @@ -522,6 +552,7 @@ export async function createExecutionRuntimeHostComposition( shellRuns, planStore: openedPlanStore, childTools: childAgentTools.childTools, + resolveChildTools, worktreeChildExecutor, listArtifactsForTurn: (sessionId, turnId) => openedArtifactStore.listTurnArtifacts(sessionId, turnId), diff --git a/packages/runtime-host/src/server/execution-model-composition.ts b/packages/runtime-host/src/server/execution-model-composition.ts index 0d5f9c9655..7bc2998dd4 100644 --- a/packages/runtime-host/src/server/execution-model-composition.ts +++ b/packages/runtime-host/src/server/execution-model-composition.ts @@ -21,6 +21,7 @@ import { buildLlmHistorySummarizer, buildPersonalizationPromptFragment, buildCancelPlanTool, + buildParentAgentTools, buildPricingLookup, buildProviderOptions, buildSubmitPlanTool, @@ -35,8 +36,10 @@ import { createProxiedFetchTransport, getAIModel, isDeepResearchToolAllowed, + listRunnableBuiltinAgentDefinitions, projectEffectiveProductToolSurface, recordToolInvocation, + routeWebSearchTools, resolveProjectGitInfo, resolveSelectedModelContextWindow, renderInterruptedPlanContext, @@ -272,6 +275,8 @@ export interface HostAiSdkBackendInput { readonly automationTool?: MakaTool; readonly goalTools?: readonly MakaTool[]; readonly parentAgentTools?: readonly MakaTool[]; + readonly childTools?: readonly MakaTool[]; + readonly worktreePatchWriteBackAvailable?: boolean; readonly childAgents?: HostChildAgentBackendCapabilities; readonly planStore?: PlanStore; readonly deepResearchTools?: readonly MakaTool[]; @@ -296,6 +301,10 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom input.context.abortSignal, ); const pricing = buildPricingLookup(pricingSnapshot.overrides); + const runtimePolicySnapshot = await readDuringBackendCreation( + () => input.runtimePolicy.runtimePolicy.getSnapshot(), + input.context.abortSignal, + ); const transport = createFetchTransport( toRuntimePolicyProxy(target.networkProxy, target.proxySecret), ); @@ -348,20 +357,49 @@ export async function createHostAiSdkBackend(input: HostAiSdkBackendInput): Prom input.context.abortSignal, ) : []; - const hostTools = [...(input.hostTools ?? []), ...rootTools]; + const candidateHostTools = [...(input.hostTools ?? []), ...rootTools]; + const webSearchRouting = { + tools: candidateHostTools, + settings: runtimePolicySnapshot.policy.webSearch, + connection: target.connection, + model: target.model, + privacy: runtimePolicySnapshot.policy.privacy, + } as const; + const hostTools = routeWebSearchTools(webSearchRouting); + const boundTools = input.context.tools + ? routeWebSearchTools({ + ...webSearchRouting, + tools: input.context.tools, + }) + : undefined; + const routedChildTools = input.childTools + ? routeWebSearchTools({ + ...webSearchRouting, + tools: input.childTools, + }) + : undefined; + const parentAgentTools = routedChildTools + ? buildParentAgentTools({ + taskLedger: input.taskLedger, + definitions: listRunnableBuiltinAgentDefinitions({ + tools: routedChildTools, + worktreeChildExecutorAvailable: input.worktreePatchWriteBackAvailable, + }), + }) + : input.parentAgentTools; modelComposition = createHostExecutionModelComposition({ policy: input.runtimePolicy.runtimePolicy, skills: input.skills, memory: input.memory, taskLedger: input.taskLedger, ...(input.context.systemPrompt ? { childInstruction: input.context.systemPrompt } : {}), - ...(input.context.tools ? { boundTools: input.context.tools } : {}), + ...(boundTools ? { boundTools } : {}), ...(clientCapabilities ? { clientCapabilities } : {}), ...(input.builtinTools ? { builtinTools: input.builtinTools } : {}), ...(hostTools.length > 0 ? { hostTools } : {}), ...(input.automationTool ? { automationTool: input.automationTool } : {}), ...(input.goalTools ? { goalTools: input.goalTools } : {}), - ...(input.parentAgentTools ? { parentAgentTools: input.parentAgentTools } : {}), + ...(parentAgentTools ? { parentAgentTools } : {}), ...(planState && input.planStore ? { plan: { diff --git a/packages/runtime-host/src/server/web-search-tool.ts b/packages/runtime-host/src/server/web-search-tool.ts index caad14f89e..dbf848b66a 100644 --- a/packages/runtime-host/src/server/web-search-tool.ts +++ b/packages/runtime-host/src/server/web-search-tool.ts @@ -30,6 +30,12 @@ export function createHostWebSearchTool(input: { reason: 'not_configured', message: 'Enable web search before using this tool.', }; + case 'model_native_only': + return { + ok: false, + reason: 'unsupported_provider', + message: 'Provider-native web search executes inside the primary model request.', + }; case 'credential_not_configured': return { ok: false, diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index d9fdf2fcc2..bd4aff1d7f 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -35,6 +35,7 @@ import { } from '../ai-sdk-backend.js'; import type { DurableSessionEventSink, MakaTool, ToolRuntime } from '../tool-runtime.js'; import { LOAD_TOOLS_NAME } from '../tool-availability.js'; +import { buildNativeWebSearchTool } from '../native-web-search-tool.js'; import { canonicalizeToolSet, computeRequestShapeDiagnostic, @@ -2074,6 +2075,89 @@ describe('AiSdkBackend model history', () => { ]); }); + test('replays provider-executed CC web search with encrypted result content intact', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'anthropic-messages' })], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result', + }, + ], + providerExecuted: true, + }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ + role: string; + content: Array>; + }>; + const assistant = prompt.find((message) => message.role === 'assistant'); + const call = assistant?.content.find((part) => part.type === 'tool-call'); + const result = assistant?.content.find((part) => part.type === 'tool-result'); + assert.equal(call?.providerExecuted, true, JSON.stringify(prompt)); + assert.deepEqual(call?.input, { query: 'latest Maka' }); + assert.match(JSON.stringify(result?.output), /encrypted-result/); + assert.equal( + prompt.some((message) => message.role === 'tool'), + false, + JSON.stringify(prompt), + ); + }); + test('replays an image tool result as provider image data', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); const model = completionModel(); @@ -13314,6 +13398,317 @@ describe('AiSdkBackend steering durability and identity', () => { ); } }); + + test('merges citation metadata across multiple provider text items', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'One' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { + itemId: 'message-1', + annotations: [{ type: 'url_citation', start_index: 0, end_index: 3 }], + }, + }, + }, + { type: 'text-start', id: 'text-2' }, + { type: 'text-delta', id: 'text-2', delta: 'Two' }, + { + type: 'text-end', + id: 'text-2', + providerMetadata: { + openai: { + itemId: 'message-2', + annotations: [{ type: 'url_citation', start_index: 0, end_index: 3 }], + }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain(backend.send({ turnId: 'turn-1', text: 'cite twice', context: [] })); + + const assistant = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.equal(assistant?.text, 'OneTwo'); + assert.deepEqual(assistant?.providerOptions, { + openai: { + annotations: [ + { type: 'url_citation', start_index: 0, end_index: 3 }, + { type: 'url_citation', start_index: 3, end_index: 6 }, + ], + }, + }); + }); + + test('executes native WebSearch inside the primary provider stream', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'WebSearch', + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + result: { + action: { type: 'search', queries: ['latest Maka'] }, + sources: [{ type: 'url', url: 'https://maka.example/' }], + }, + providerExecuted: true, + }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Maka is current.' }, + { + type: 'text-end', + id: 'text-1', + providerMetadata: { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }, + }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const appended: StoredMessage[] = []; + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async (message) => { + appended.push(message); + }, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool()], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + + await collectEvents(backend.send({ turnId: 'turn-1', text: 'search', context: [] }), events); + + assert.equal( + model.doStreamCalls[0]?.tools?.some( + (tool) => tool.type === 'provider' && tool.id === 'openai.web_search', + ), + true, + ); + const start = events.find((event) => event.type === 'tool_start'); + assert.equal(start?.type === 'tool_start' ? start.providerExecuted : undefined, true); + const result = events.find((event) => event.type === 'tool_result'); + assert.equal(result?.type === 'tool_result' ? result.providerExecuted : undefined, true); + assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'maka.example', + url: 'https://maka.example/', + snippet: '', + source: 'maka.example', + }, + ], + }); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + const assistant = appended.find( + (message): message is AssistantMessage => message.type === 'assistant', + ); + assert.deepEqual(assistant?.providerOptions, { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }); + const mappingMemory = createSessionEventMapMemory(); + const anchor = runtimeTextEvent({ + id: 'native-search-user', + turnId: 'turn-1', + role: 'user', + author: 'user', + text: 'search', + }); + const mappingContext: InvocationContext = { + sessionId: 'session-1', + invocationId: 'invocation-search', + runId: 'run-search', + turnId: 'turn-1', + source: 'desktop', + startedAt: 1, + request: { + sessionId: 'session-1', + turnId: 'turn-1', + text: 'search', + source: 'desktop', + initialRuntimeEvent: anchor, + }, + newId: idGenerator(), + now: monotonicClock(), + }; + for (const event of events) { + const mapped = mapSessionEventToRuntimeEvent(event, mappingContext, mappingMemory); + if (mapped.partial !== true && mapped.content) { + assert.doesNotThrow(() => encodeCanonicalRuntimeEvent(mapped)); + } + } + }); + + test('projects CC-format Anthropic web search without exposing encrypted content', async () => { + const model = new MockLanguageModelV4({ + doStream: async () => ({ + stream: simulateReadableStream({ + chunks: [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'search-cc-1', + toolName: 'WebSearch', + input: JSON.stringify({ query: 'latest Maka' }), + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-cc-1', + toolName: 'WebSearch', + result: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result', + }, + ], + providerExecuted: true, + }, + { type: 'text-start', id: 'text-cc-1' }, + { type: 'text-delta', id: 'text-cc-1', delta: 'Maka is current.' }, + { type: 'text-end', id: 'text-cc-1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'end_turn' }, + usage: emptyUsage(), + }, + ] as LanguageModelV4StreamPart[], + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }), + }); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'anthropic-messages' })], + newId: idGenerator(), + now: monotonicClock(), + }); + const events: SessionEvent[] = []; + + await collectEvents(backend.send({ turnId: 'turn-1', text: 'search', context: [] }), events); + + assert.equal( + model.doStreamCalls[0]?.tools?.some( + (tool) => tool.type === 'provider' && tool.id === 'anthropic.web_search_20250305', + ), + true, + ); + const result = events.find((event) => event.type === 'tool_result'); + const start = events.find((event) => event.type === 'tool_start'); + assert.deepEqual(start?.type === 'tool_start' ? start.args : undefined, { + query: 'latest Maka', + }); + assert.deepEqual(result?.type === 'tool_result' ? result.content : undefined, { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'Maka', + url: 'https://maka.example/', + snippet: '2026-08-04', + source: 'maka.example', + }, + ], + }); + assert.doesNotMatch( + JSON.stringify(result?.type === 'tool_result' ? result.content : null), + /encrypted-result/, + ); + }); }); function textCompletionModel(text: string): MockLanguageModelV4 { diff --git a/packages/runtime/src/__tests__/context-budget.test.ts b/packages/runtime/src/__tests__/context-budget.test.ts index b2a67b66e4..90b35df333 100644 --- a/packages/runtime/src/__tests__/context-budget.test.ts +++ b/packages/runtime/src/__tests__/context-budget.test.ts @@ -23,8 +23,43 @@ import { import { applyRuntimeEventHistoryCompact } from '../history-compact.js'; import { renderSynthesisCacheBlock, selectSynthesisCacheForReplay } from '../synthesis-cache.js'; import { historyCompactBlockToCompactionBoundary } from '../compaction-boundary.js'; +import { estimateRuntimeEventChars } from '../context-budget-helpers.js'; describe('context-budget archive retrieval', () => { + test('counts provider-native replay payloads and never archives only their display projection', () => { + const rawProviderOutput = [{ encryptedContent: 'x'.repeat(10_000) }]; + const event = { + ...toolResult('result-native', 'turn-native', 'search-native', { + kind: 'web_search', + provider: 'model', + query: 'latest', + rows: [], + }), + content: { + kind: 'function_response' as const, + id: 'search-native', + name: 'WebSearch', + result: { kind: 'web_search', provider: 'model', query: 'latest', rows: [] }, + providerExecuted: true, + providerOutput: rawProviderOutput, + }, + }; + + assert.ok(estimateRuntimeEventChars(event) >= 10_000); + const budgeted = applyRuntimeEventContextBudget([event], { + staleToolResultPrune: { + enabled: true, + maxResultEstimatedTokens: 1, + minRecentTurnsFull: 0, + archiveRefs: [], + }, + charsPerToken: 1, + }); + assert.ok(budgeted); + assert.equal(budgeted.diagnostic.prunedToolResults ?? 0, 0); + assert.deepEqual(budgeted.events[0], event); + }); + test('prunes the newest turn when the full-result protection window is zero', () => { const sentinel = 'newest-turn-full-result-must-not-be-reinjected'; const originalResult = { kind: 'text', text: sentinel.repeat(4) }; diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 1e09a98a40..43d09a7ec6 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -229,6 +229,130 @@ describe('ModelAdapter stream and error normalization', () => { ]); }); + test('normalizes provider-executed search results and text citation metadata', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + + assert.deepEqual( + adapter.translateChunk({ + type: 'tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + providerExecuted: true, + output: { + action: { type: 'search', queries: ['latest Maka'] }, + sources: [{ type: 'url', url: 'https://maka.example/' }], + }, + } as Chunk), + [ + { + kind: 'provider-tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + output: { + action: { type: 'search', queries: ['latest Maka'] }, + sources: [{ type: 'url', url: 'https://maka.example/' }], + }, + }, + ], + ); + + assert.deepEqual( + adapter.translateChunk({ + type: 'text-end', + providerMetadata: { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }, + } as Chunk), + [ + { + kind: 'text-metadata', + providerOptions: { + openai: { + itemId: 'message-1', + annotations: [ + { + type: 'url_citation', + url: 'https://maka.example/', + title: 'Maka', + startIndex: 0, + endIndex: 4, + }, + ], + }, + }, + }, + ], + ); + }); + + test('normalizes Anthropic web search results and server-tool errors', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + const result = [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result', + }, + ]; + + assert.deepEqual( + adapter.translateChunk({ + type: 'tool-result', + toolCallId: 'search-anthropic', + toolName: 'WebSearch', + providerExecuted: true, + output: result, + } as Chunk), + [ + { + kind: 'provider-tool-result', + toolCallId: 'search-anthropic', + toolName: 'WebSearch', + output: result, + }, + ], + ); + assert.deepEqual( + adapter.translateChunk({ + type: 'tool-error', + toolCallId: 'search-anthropic-error', + toolName: 'WebSearch', + providerExecuted: true, + error: { + type: 'web_search_tool_result_error', + errorCode: 'max_uses_exceeded', + }, + } as Chunk), + [ + { + kind: 'provider-tool-result', + toolCallId: 'search-anthropic-error', + toolName: 'WebSearch', + output: { + type: 'web_search_tool_result_error', + errorCode: 'max_uses_exceeded', + }, + isError: true, + }, + ], + ); + }); + test('reduces AI SDK 7 step boundaries to Maka-owned step-finish events', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/__tests__/native-web-search-tool.test.ts b/packages/runtime/src/__tests__/native-web-search-tool.test.ts new file mode 100644 index 0000000000..ecd20616ea --- /dev/null +++ b/packages/runtime/src/__tests__/native-web-search-tool.test.ts @@ -0,0 +1,135 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + buildNativeWebSearchTool, + NATIVE_WEB_SEARCH_TOOL_NAME, + routeWebSearchTools, +} from '../native-web-search-tool.js'; +import type { MakaTool } from '../tool-runtime.js'; + +test('native WebSearch is a provider-executed descriptor, not a local implementation', () => { + const tool = buildNativeWebSearchTool(); + assert.equal(tool.name, NATIVE_WEB_SEARCH_TOOL_NAME); + assert.equal(tool.categoryHint, 'web_read'); + assert.equal(tool.activityKind, 'websearch'); + assert.deepEqual(tool.providerTool, { + kind: 'openai-web-search', + searchContextSize: 'medium', + }); + assert.throws(() => tool.impl({}, {} as never), /must not execute through ToolRuntime/); +}); + +test('turn-start routing keeps native and client-executed search mutually exclusive', () => { + const clientSearch = { + name: NATIVE_WEB_SEARCH_TOOL_NAME, + description: 'Tavily', + parameters: {}, + impl: async () => undefined, + } satisfies MakaTool; + const read = { + name: 'Read', + description: 'Read', + parameters: {}, + impl: async () => undefined, + } satisfies MakaTool; + const connection = { + slug: 'deepseek', + providerType: 'deepseek' as const, + defaultModel: 'deepseek-v4-flash', + models: [{ id: 'deepseek-v4-flash', capabilities: { webSearch: true } }], + }; + + const native = routeWebSearchTools({ + tools: [read, clientSearch], + settings: { enabled: true, defaultProvider: 'model' }, + connection, + model: 'deepseek-v4-flash', + }); + assert.equal(native.filter((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME).length, 1); + assert.equal( + native.find((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME)?.providerTool?.kind, + 'openai-web-search', + ); + + const external = routeWebSearchTools({ + tools: [read, clientSearch], + settings: { enabled: true, defaultProvider: 'tavily' }, + connection, + model: 'deepseek-v4-flash', + }); + assert.equal( + external.find((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME), + clientSearch, + ); + + const disabled = routeWebSearchTools({ + tools: [read, clientSearch], + settings: { enabled: false, defaultProvider: 'model' }, + connection, + model: 'deepseek-v4-flash', + }); + assert.deepEqual( + disabled.map((tool) => tool.name), + ['Read'], + ); + + const incognito = routeWebSearchTools({ + tools: [read, clientSearch], + settings: { enabled: true, defaultProvider: 'model' }, + privacy: { incognitoActive: true }, + connection, + model: 'deepseek-v4-flash', + }); + assert.deepEqual( + incognito.map((tool) => tool.name), + ['Read'], + ); +}); + +test('turn-start routing compiles Claude models to the CC-compatible Anthropic tool', () => { + const clientSearch = { + name: NATIVE_WEB_SEARCH_TOOL_NAME, + description: 'Tavily', + parameters: {}, + impl: async () => undefined, + } satisfies MakaTool; + const routed = routeWebSearchTools({ + tools: [clientSearch], + settings: { enabled: true, defaultProvider: 'model' }, + connection: { + slug: 'anthropic', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-6', + }, + model: 'claude-sonnet-4-6', + }); + + assert.deepEqual(routed[0]?.providerTool, { + kind: 'anthropic-web-search-20250305', + maxUses: 8, + }); +}); + +test('root surfaces may add native search without widening scoped child tools', () => { + const connection = { + slug: 'deepseek', + providerType: 'deepseek' as const, + defaultModel: 'deepseek-v4-flash', + }; + const root = routeWebSearchTools({ + tools: [], + settings: { enabled: true, defaultProvider: 'model' }, + connection, + model: 'deepseek-v4-flash', + allowAddNative: true, + }); + assert.equal(root[0]?.providerTool?.kind, 'openai-web-search'); + + const child = routeWebSearchTools({ + tools: [], + settings: { enabled: true, defaultProvider: 'model' }, + connection, + model: 'deepseek-v4-flash', + }); + assert.deepEqual(child, []); +}); diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 9d66e0065e..fd479b6594 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -2,7 +2,9 @@ import assert from 'node:assert/strict'; import type { IncomingMessage } from 'node:http'; import { after, describe, test } from 'node:test'; import { PROVIDER_DEFAULTS, type LlmConnection } from '@maka/core'; -import { generateText, isStepCount, streamText, tool } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; +import { generateText, isStepCount, streamText, tool, type ModelMessage } from 'ai'; import { z } from 'zod'; import { fetchProviderModels } from '../model-fetcher.js'; import { buildProviderOptions, getAIModel } from '../model-factory.js'; @@ -66,6 +68,172 @@ describe('models.dev provider conformance', () => { assert.deepEqual(requestBody?.cache_control, { type: 'ephemeral' }); }); + test('Anthropic Messages accepts the Claude Code web_search_20250305 tool', async () => { + let requestBody: Record | undefined; + const server = await startJsonServer(async (request, response) => { + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'msg_anthropic_search', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [ + { + type: 'server_tool_use', + id: 'search_cc', + name: 'web_search', + input: { query: 'latest Maka' }, + }, + { + type: 'web_search_tool_result', + tool_use_id: 'search_cc', + content: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + encrypted_content: 'encrypted-result', + page_age: '2026-08-04', + }, + ], + }, + { type: 'text', text: 'Search complete.' }, + ], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 8, output_tokens: 3 }, + }); + }); + const connection: LlmConnection = { + slug: 'anthropic-search', + name: 'Anthropic Search', + providerType: 'anthropic', + baseUrl: server.url, + defaultModel: 'claude-sonnet-4-6', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'anthropic-test-key', + modelId: connection.defaultModel, + }), + prompt: 'Search.', + tools: { + WebSearch: anthropic.tools.webSearch_20250305({ maxUses: 8 }), + }, + maxRetries: 0, + }); + + assert.deepEqual(requestBody?.tools, [ + { + type: 'web_search_20250305', + name: 'web_search', + max_uses: 8, + }, + ]); + }); + + test('Anthropic Messages replays provider-executed web search inside assistant content', async () => { + let requestBody: Record | undefined; + const server = await startJsonServer(async (request, response) => { + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'msg_anthropic_search_replay', + type: 'message', + role: 'assistant', + model: 'deepseek-v4-flash', + content: [{ type: 'text', text: 'Replay complete.' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 8, output_tokens: 3 }, + }); + }); + const connection: LlmConnection = { + slug: 'anthropic-compatible-search-replay', + name: 'Anthropic-compatible Search Replay', + providerType: 'anthropic-compatible', + baseUrl: server.url, + defaultModel: 'deepseek-v4-flash', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + const messages: ModelMessage[] = [ + { role: 'user', content: 'Search.' }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'search_cc', + toolName: 'WebSearch', + input: { query: 'latest Maka' }, + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search_cc', + toolName: 'WebSearch', + output: { + type: 'json', + value: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: null, + encryptedContent: 'encrypted-result', + }, + ], + }, + }, + ], + }, + { role: 'user', content: 'Continue without searching.' }, + ]; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'anthropic-test-key', + modelId: connection.defaultModel, + }), + messages, + tools: { + WebSearch: anthropic.tools.webSearch_20250305({ maxUses: 8 }), + }, + maxRetries: 0, + }); + + const requestMessages = requestBody?.messages as + | Array<{ role?: string; content?: Array> }> + | undefined; + const assistant = requestMessages?.find((message) => message.role === 'assistant'); + assert.deepEqual(assistant?.content?.[0], { + type: 'server_tool_use', + id: 'search_cc', + name: 'web_search', + input: { query: 'latest Maka' }, + }); + assert.deepEqual(assistant?.content?.[1], { + type: 'web_search_tool_result', + tool_use_id: 'search_cc', + content: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + page_age: null, + encrypted_content: 'encrypted-result', + }, + ], + }); + }); + test('xAI OAuth credential completes a Grok 4.5 Responses reasoning tool loop', async () => { const modelId = 'grok-4.5'; const requestBodies: Array> = []; @@ -447,6 +615,161 @@ describe('models.dev provider conformance', () => { assert.equal(gpt4o.text, 'Chat wire.'); }); + test('DeepSeek V4 Flash uses Responses and accepts provider-native web search', async () => { + let requestBody: Record | undefined; + let requestUrl: string | undefined; + let authorization: string | undefined; + const server = await startJsonServer(async (request, response) => { + requestUrl = request.url; + authorization = request.headers.authorization; + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'resp_deepseek_search', + object: 'response', + created_at: 1, + status: 'completed', + model: 'deepseek-v4-flash', + output: [ + { + type: 'web_search_call', + id: 'search_deepseek', + status: 'completed', + action: { type: 'search', queries: ['latest Maka'] }, + }, + { + type: 'message', + id: 'msg_deepseek', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Search complete.', + annotations: [], + logprobs: [], + }, + ], + }, + ], + usage: { input_tokens: 8, output_tokens: 3, total_tokens: 11 }, + }); + }); + const connection: LlmConnection = { + slug: 'deepseek', + name: 'DeepSeek', + providerType: 'deepseek', + baseUrl: server.url, + defaultModel: 'deepseek-v4-flash', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'deepseek-test-key', + modelId: connection.defaultModel, + }), + prompt: 'Search.', + tools: { WebSearch: openai.tools.webSearch() }, + maxRetries: 0, + }); + + assert.equal(requestUrl, '/responses'); + assert.equal(authorization, 'Bearer deepseek-test-key'); + assert.deepEqual(requestBody?.tools, [{ type: 'web_search' }]); + }); + + test('DeepSeek Responses replays hosted web search as an item reference, not an orphan output', async () => { + let requestBody: Record | undefined; + const server = await startJsonServer(async (request, response) => { + requestBody = JSON.parse(await readBody(request)) as Record; + respondJson(response, 200, { + id: 'resp_deepseek_search_replay', + object: 'response', + created_at: 2, + status: 'completed', + model: 'deepseek-v4-flash', + output: [ + { + type: 'message', + id: 'msg_deepseek_replay', + status: 'completed', + role: 'assistant', + content: [ + { + type: 'output_text', + text: 'Replay complete.', + annotations: [], + logprobs: [], + }, + ], + }, + ], + usage: { input_tokens: 8, output_tokens: 3, total_tokens: 11 }, + }); + }); + const connection: LlmConnection = { + slug: 'deepseek-search-replay', + name: 'DeepSeek Search Replay', + providerType: 'deepseek', + baseUrl: server.url, + defaultModel: 'deepseek-v4-flash', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + const messages: ModelMessage[] = [ + { role: 'user', content: 'Search.' }, + { + role: 'assistant', + content: [ + { + type: 'tool-call', + toolCallId: 'search_deepseek', + toolName: 'WebSearch', + input: {}, + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search_deepseek', + toolName: 'WebSearch', + output: { + type: 'json', + value: { action: { type: 'search', queries: ['latest Maka'] } }, + }, + }, + ], + }, + { role: 'user', content: 'Continue without searching.' }, + ]; + + await generateText({ + model: getAIModel({ + connection, + apiKey: 'deepseek-test-key', + modelId: connection.defaultModel, + }), + messages, + tools: { WebSearch: openai.tools.webSearch() }, + maxRetries: 0, + }); + + const input = requestBody?.input as Array> | undefined; + assert.equal( + input?.some((item) => item.type === 'function_call_output'), + false, + JSON.stringify(input), + ); + assert.equal( + input?.some((item) => item.type === 'item_reference' && item.id === 'search_deepseek'), + true, + JSON.stringify(input), + ); + }); + test('OpenCode Zen routes GPT through Responses and preserves tool results across both stages', async () => { const modelId = 'gpt-5.5'; const requestBodies: Array> = []; diff --git a/packages/runtime/src/__tests__/runtime-event-backfill.test.ts b/packages/runtime/src/__tests__/runtime-event-backfill.test.ts index b0525469df..9d6b535dba 100644 --- a/packages/runtime/src/__tests__/runtime-event-backfill.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-backfill.test.ts @@ -128,6 +128,96 @@ describe('runtime event backfill', () => { expect(result.events.map((event) => event.invocationId)).toEqual(['persisted-invocation']); }); + test('preserves provider-native identity in StoredMessage fallback backfill', () => { + const providerOutput = [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: null, + encryptedContent: 'encrypted-result', + }, + ]; + const result = backfillRuntimeEventsFromStoredMessages({ + run, + messages: [ + { + type: 'tool_call', + id: 'search-1', + turnId: 'turn-1', + ts: 120, + toolName: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + { + type: 'tool_result', + id: 'search-result-1', + turnId: 'turn-1', + ts: 130, + toolUseId: 'search-1', + isError: false, + content: { kind: 'web_search', provider: 'model', query: 'latest Maka', rows: [] }, + providerExecuted: true, + providerOutput, + }, + ], + newId: nextIds(), + now: () => 999, + }); + + expect(result.events[0]?.content).toMatchObject({ + kind: 'function_call', + providerExecuted: true, + }); + expect(result.events[1]?.content).toMatchObject({ + kind: 'function_response', + providerExecuted: true, + providerOutput, + }); + }); + + test('drops provider-native fallback history when opaque replay output is unavailable', () => { + const result = backfillRuntimeEventsFromStoredMessages({ + run, + messages: [ + { + type: 'tool_call', + id: 'search-legacy', + turnId: 'turn-1', + ts: 120, + toolName: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + { + type: 'tool_result', + id: 'search-result-legacy', + turnId: 'turn-1', + ts: 130, + toolUseId: 'search-legacy', + isError: false, + content: { kind: 'web_search', provider: 'model', query: 'latest Maka', rows: [] }, + providerExecuted: true, + }, + ], + newId: nextIds(), + now: () => 999, + }); + + expect(result.events.map((event) => event.content?.kind ?? event.status)).toEqual([ + 'completed', + ]); + expect(result.diagnostics).toEqual([ + { + code: 'skipped_provider_native_replay_gap', + message: + 'provider-native tool history requires the opaque provider output for lossless recovery', + detail: { messageId: 'search-legacy', toolUseId: 'search-legacy' }, + }, + ]); + }); + test('backfills a host-authored graph wake without attributing it to the user', () => { const result = backfillRuntimeEventsFromStoredMessages({ run, diff --git a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts index f14873edfc..e3cc9bff94 100644 --- a/packages/runtime/src/__tests__/runtime-event-read-model.test.ts +++ b/packages/runtime/src/__tests__/runtime-event-read-model.test.ts @@ -332,6 +332,123 @@ describe('projectRuntimeEventsToStoredMessages', () => { expect(out.diagnostics).toEqual([]); }); + test('projects provider-native search through the canonical read model while replay keeps raw output', () => { + const rawProviderOutput = [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: null, + encryptedContent: 'encrypted-result', + }, + ]; + const events = [ + ev({ + id: 'evt-native-text', + ts: ts + 1, + role: 'model', + author: 'agent', + content: { + kind: 'text', + text: 'Maka is current.', + providerOptions: { + openai: { + itemId: 'message-1', + annotations: [{ type: 'url_citation', url: 'https://maka.example/' }], + }, + }, + }, + refs: { providerEventId: 'step-native' }, + }), + ev({ + id: 'evt-native-call', + ts: ts + 2, + role: 'model', + author: 'agent', + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerOptions: { anthropic: { type: 'server_tool_use' } }, + providerExecuted: true, + }, + refs: { toolCallId: 'search-1', stepId: 'step-native' }, + }), + ev({ + id: 'evt-native-result', + ts: ts + 3, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'Maka', + url: 'https://maka.example/', + snippet: '', + source: 'maka.example', + }, + ], + }, + providerExecuted: true, + providerOutput: rawProviderOutput, + }, + refs: { toolCallId: 'search-1' }, + }), + ]; + + const projected = projectRuntimeEventsToStoredMessages(events, { runHeaders: [header] }); + expect(projected.diagnostics).toEqual([]); + expect(projected.messages[0]).toMatchObject({ + type: 'assistant', + providerOptions: { + openai: { + itemId: 'message-1', + annotations: [{ type: 'url_citation', url: 'https://maka.example/' }], + }, + }, + }); + expect(projected.messages[1]).toMatchObject({ + type: 'tool_call', + providerOptions: { anthropic: { type: 'server_tool_use' } }, + providerExecuted: true, + }); + expect(projected.messages[2]).toMatchObject({ + type: 'tool_result', + providerExecuted: true, + providerOutput: rawProviderOutput, + content: { + kind: 'web_search', + provider: 'model', + query: 'latest Maka', + rows: [ + { + title: 'Maka', + url: 'https://maka.example/', + snippet: '', + source: 'maka.example', + }, + ], + }, + }); + + const replay = buildRuntimeEventModelReplayPlan(events); + expect(replay.diagnostics).toEqual([]); + expect( + replay.items.find((item) => item.kind === 'tool_result' && item.toolCallId === 'search-1'), + ).toMatchObject({ + output: rawProviderOutput, + providerExecuted: true, + }); + }); + test('projects an AskUserQuestion round trip without a legacy row for the live request', () => { const out = projectRuntimeEventsToStoredMessages( [ diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index 54fdae88b9..acdb97da8d 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -31,6 +31,9 @@ import type { ThinkingDeltaEvent, ProviderRetryEvent, ProviderRetryReason, + ToolResultEvent, + ToolResultContent, + ToolStartEvent, StorageRef, AttachmentRef, QuoteRef, @@ -227,6 +230,176 @@ const CHILD_STEP_BUDGET_FINALIZATION_PROMPT = [ '', ].join('\n'); +function providerToolResultContent( + toolName: string, + output: unknown, + input?: unknown, +): ToolResultContent { + if (output === undefined) { + return { kind: 'text', text: `${toolName} completed without a structured result.` }; + } + if (toolName !== 'WebSearch') { + return { kind: 'json', value: output }; + } + const queryFromInput = providerWebSearchQuery(input); + if (Array.isArray(output)) { + const rows: Array<{ title: string; url: string; snippet: string; source: string }> = []; + for (const result of output) { + if ( + !result || + typeof result !== 'object' || + (result as { type?: unknown }).type !== 'web_search_result' || + typeof (result as { url?: unknown }).url !== 'string' + ) { + continue; + } + const item = result as { + url: string; + title?: unknown; + pageAge?: unknown; + }; + try { + const parsed = new URL(item.url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; + rows.push({ + title: typeof item.title === 'string' && item.title.trim() ? item.title : parsed.hostname, + url: parsed.toString(), + snippet: typeof item.pageAge === 'string' ? item.pageAge : '', + source: parsed.hostname, + }); + } catch { + // Provider source rows are untrusted; malformed URLs are dropped. + } + } + return { kind: 'web_search', provider: 'model', query: queryFromInput, rows }; + } + if (!output || typeof output !== 'object') return { kind: 'json', value: output }; + const providerError = output as { type?: unknown; errorCode?: unknown }; + if ( + providerError.type === 'web_search_tool_result_error' || + typeof providerError.errorCode === 'string' + ) { + return { + kind: 'web_search_error', + ok: false, + provider: 'model', + ...(queryFromInput ? { query: queryFromInput } : {}), + reason: 'provider_error', + message: + typeof providerError.errorCode === 'string' + ? `Provider web search failed: ${providerError.errorCode}` + : 'Provider web search failed.', + }; + } + const action = (output as { action?: unknown }).action; + const sources = (output as { sources?: unknown }).sources; + let query = queryFromInput; + if (action && typeof action === 'object') { + const value = action as { type?: unknown; query?: unknown; queries?: unknown }; + if (Array.isArray(value.queries)) { + query = value.queries.filter((item): item is string => typeof item === 'string').join(' | '); + } else if (typeof value.query === 'string') { + query = value.query; + } + } + const rows: Array<{ title: string; url: string; snippet: string; source: string }> = []; + if (Array.isArray(sources)) { + for (const source of sources) { + if ( + !source || + typeof source !== 'object' || + (source as { type?: unknown }).type !== 'url' || + typeof (source as { url?: unknown }).url !== 'string' + ) { + continue; + } + const url = (source as { url: string }).url; + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') continue; + rows.push({ + title: parsed.hostname, + url: parsed.toString(), + snippet: '', + source: parsed.hostname, + }); + } catch { + // Provider source rows are untrusted; malformed URLs are dropped. + } + } + } + return { kind: 'web_search', provider: 'model', query, rows }; +} + +function providerWebSearchQuery(input: unknown): string { + let value = input; + if (typeof input === 'string') { + try { + value = JSON.parse(input); + } catch { + return ''; + } + } + if (!value || typeof value !== 'object') return ''; + const query = (value as { query?: unknown }).query; + return typeof query === 'string' ? query : ''; +} + +function mergeTextProviderOptions( + current: NonNullable | undefined, + next: NonNullable, + textOffset: number, +): NonNullable { + const shifted = structuredClone(next); + const shiftedOpenAi = shifted.openai; + if (shiftedOpenAi && typeof shiftedOpenAi === 'object' && !Array.isArray(shiftedOpenAi)) { + const annotations = (shiftedOpenAi as { annotations?: unknown }).annotations; + if (Array.isArray(annotations) && textOffset > 0) { + (shiftedOpenAi as { annotations: unknown[] }).annotations = annotations.map((annotation) => { + if (!annotation || typeof annotation !== 'object' || Array.isArray(annotation)) { + return annotation; + } + const value = { ...annotation } as Record; + if (typeof value.startIndex === 'number') value.startIndex += textOffset; + if (typeof value.endIndex === 'number') value.endIndex += textOffset; + if (typeof value.start_index === 'number') value.start_index += textOffset; + if (typeof value.end_index === 'number') value.end_index += textOffset; + return value; + }); + } + } + if (!current) return shifted; + + const merged = { ...structuredClone(current), ...shifted }; + const currentOpenAi = current.openai; + if ( + currentOpenAi && + typeof currentOpenAi === 'object' && + !Array.isArray(currentOpenAi) && + shiftedOpenAi && + typeof shiftedOpenAi === 'object' && + !Array.isArray(shiftedOpenAi) + ) { + const left = currentOpenAi as Record; + const right = shiftedOpenAi as Record; + const openai: Record = { ...left, ...right }; + const leftAnnotations = Array.isArray(left.annotations) ? left.annotations : []; + const rightAnnotations = Array.isArray(right.annotations) ? right.annotations : []; + if (leftAnnotations.length > 0 || rightAnnotations.length > 0) { + openai.annotations = [...leftAnnotations, ...rightAnnotations]; + } + if ( + typeof left.itemId === 'string' && + typeof right.itemId === 'string' && + left.itemId !== right.itemId + ) { + delete openai.itemId; + } + merged.openai = openai as NonNullable[string]; + } + return merged; +} + // ============================================================================ // AgentBackend interface — port contract now lives in @maka/core/backend-types; // re-exported here for backward compatibility with existing import sites. @@ -749,6 +922,8 @@ export class AiSdkBackend implements AgentBackend { // at its step boundary (see the stream loop below). let currentStepMessageId = this.newId(); let stepText = ''; + let stepTextProviderOptions: NonNullable | undefined; + let stepTextPartStartOffset = 0; let stepThinking = ''; let sawStepThinking = false; let stepThinkingProviderOptions: NonNullable | undefined; @@ -787,6 +962,9 @@ export class AiSdkBackend implements AgentBackend { turnId, ts: this.now(), text: stepText, + ...(stepTextProviderOptions !== undefined + ? { providerOptions: stepTextProviderOptions } + : {}), modelId: this.input.modelId, ...(hasThinking ? { @@ -833,8 +1011,13 @@ export class AiSdkBackend implements AgentBackend { ts: this.now(), messageId: stepId, text: stepText, + ...(stepTextProviderOptions !== undefined + ? { providerOptions: stepTextProviderOptions } + : {}), } satisfies TextCompleteEvent); stepText = ''; + stepTextProviderOptions = undefined; + stepTextPartStartOffset = 0; stepThinking = ''; sawStepThinking = false; stepThinkingProviderOptions = undefined; @@ -955,10 +1138,13 @@ export class AiSdkBackend implements AgentBackend { const modelTools: ModelToolSet = {}; for (const t of providerTools) { - modelTools[t.name] = { - description: t.description, - inputSchema: t.parameters, - }; + modelTools[t.name] = t.providerTool + ? { kind: 'provider', providerTool: t.providerTool } + : { + kind: 'function', + description: t.description, + inputSchema: t.parameters, + }; } // --- Build messages from RuntimeEvent history and its compatibility projection. --- @@ -1363,10 +1549,13 @@ export class AiSdkBackend implements AgentBackend { let attemptMessages = projectedMessages; let providerAttempt = 1; const returnedToolCalls: ToolCallPart[] = []; + let providerToolActivityCount = 0; + const providerToolInputs = new Map(); const providerStepId = currentStepMessageId; let providerStepUsage: NormalizedUsage | undefined; const attemptHasNoObservableOutput = () => returnedToolCalls.length === 0 && + providerToolActivityCount === 0 && stepText.length === 0 && stepThinking.length === 0 && stepSignature === undefined; @@ -1440,7 +1629,9 @@ export class AiSdkBackend implements AgentBackend { if (event.kind === 'finish' || event.kind === 'step-finish') { rawFinishReason = event.finishReason ?? rawFinishReason; } - if (event.kind === 'text') { + if (event.kind === 'text-start') { + stepTextPartStartOffset = stepText.length; + } else if (event.kind === 'text') { stepText += event.text; queue.push({ type: 'text_delta', @@ -1450,6 +1641,14 @@ export class AiSdkBackend implements AgentBackend { messageId: currentStepMessageId, text: event.text, } satisfies TextDeltaEvent); + } else if (event.kind === 'text-metadata') { + stepTextProviderOptions = mergeTextProviderOptions( + stepTextProviderOptions, + stripUndefinedDeep(event.providerOptions) as NonNullable< + ModelMessage['providerOptions'] + >, + stepTextPartStartOffset, + ); } else if (event.kind === 'thinking') { sawStepThinking = true; stepThinking += event.text; @@ -1494,7 +1693,49 @@ export class AiSdkBackend implements AgentBackend { } else if (event.kind === 'thinking-signature') { stepSignature = event.signature; } else if (event.kind === 'tool-call') { - returnedToolCalls.push(event.toolCall); + if (event.toolCall.providerExecuted) { + providerToolActivityCount += 1; + providerToolInputs.set(event.toolCall.toolCallId, event.toolCall.input); + queue.push({ + type: 'tool_start', + id: this.newId(), + turnId, + ts: this.now(), + toolUseId: event.toolCall.toolCallId, + toolName: event.toolCall.toolName, + args: event.toolCall.input, + providerExecuted: true, + activityKind: 'websearch', + displayName: 'Web search', + stepId: providerStepId, + ...(event.toolCall.providerOptions !== undefined + ? { + providerOptions: stripUndefinedDeep(event.toolCall.providerOptions), + } + : {}), + } satisfies ToolStartEvent); + } else { + returnedToolCalls.push(event.toolCall); + } + } else if (event.kind === 'provider-tool-result') { + providerToolActivityCount += 1; + const providerOutput = stripUndefinedDeep(event.output); + queue.push({ + type: 'tool_result', + id: this.newId(), + turnId, + ts: this.now(), + toolUseId: event.toolCallId, + providerExecuted: true, + ...(providerOutput !== undefined ? { providerOutput } : {}), + isError: event.isError === true, + content: providerToolResultContent( + event.toolName, + providerOutput, + providerToolInputs.get(event.toolCallId), + ), + } satisfies ToolResultEvent); + providerToolInputs.delete(event.toolCallId); } else if (event.kind === 'step-finish') { // The step's text/thinking deltas are all in (the stream is // drained in order), so flush this step's AssistantMessage and @@ -2654,6 +2895,7 @@ export class AiSdkBackend implements AgentBackend { type ToolCallItem = Extract; type ToolResultItem = Extract; type ThinkingItem = Extract; + type TextItem = Extract; type ReplayReasoning = { part?: ReasoningPart; providerOptions?: NonNullable; @@ -2662,7 +2904,7 @@ export class AiSdkBackend implements AgentBackend { let bufferedCalls: ToolCallItem[] = []; const results = new Map(); const reasoningByStep = new Map(); - const textByStep = new Map(); + const textByStep = new Map(); const replaySupport = this.modelAdapter.runtimeEventReplaySupport(); const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { @@ -2720,10 +2962,18 @@ export class AiSdkBackend implements AgentBackend { // back — the plan flags them as `unmatched_tool_result` (a non-blocking // diagnostic precisely so this drop path is reachable; see // hasBlockingReplayDiagnostics). - const pushToolResults = async (calls: readonly ToolCallItem[]) => { + const materializeReplayToolResult = async (result: ToolResultItem): Promise => + settledModelOutputs?.get(result.toolCallId) ?? + (await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + )); + const pushClientToolResults = async (calls: readonly ToolCallItem[]) => { for (const call of calls) { const result = results.get(call.toolCallId); - if (!result) continue; + if (!result || result.providerExecuted === true) continue; results.delete(call.toolCallId); out.push({ role: 'tool', @@ -2732,14 +2982,7 @@ export class AiSdkBackend implements AgentBackend { type: 'tool-result', toolCallId: result.toolCallId, toolName: result.toolName, - output: - settledModelOutputs?.get(result.toolCallId) ?? - (await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - )), + output: await materializeReplayToolResult(result), }, ], }); @@ -2749,7 +2992,7 @@ export class AiSdkBackend implements AgentBackend { // then the step's tool calls, followed by those calls' tool results. const emitStep = async ( reasoning: readonly ThinkingItem[] | undefined, - text: string, + text: TextItem | undefined, calls: readonly ToolCallItem[], ) => { const content: unknown[] = []; @@ -2759,7 +3002,13 @@ export class AiSdkBackend implements AgentBackend { for (const item of replayReasoning ?? []) { if (item.part) content.push(item.part); } - if (text.length > 0) content.push({ type: 'text', text }); + if (text && text.content.length > 0) { + content.push({ + type: 'text', + text: text.content, + ...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}), + }); + } for (const call of calls) { content.push({ type: 'tool-call', @@ -2767,6 +3016,20 @@ export class AiSdkBackend implements AgentBackend { toolName: call.toolName, input: call.input, ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + ...(call.providerExecuted !== undefined + ? { providerExecuted: call.providerExecuted } + : {}), + }); + } + for (const call of calls) { + const result = results.get(call.toolCallId); + if (!result || result.providerExecuted !== true) continue; + results.delete(call.toolCallId); + content.push({ + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result), }); } const replayProviderOptions = replayReasoning?.find( @@ -2779,7 +3042,7 @@ export class AiSdkBackend implements AgentBackend { ...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}), } as ModelMessage); } - await pushToolResults(calls); + await pushClientToolResults(calls); }; // Emit tool calls no assistant text closed: a thinking + tool step with no // text (its empty closer is skipped from the plan), a pure-tool step, or a @@ -2794,7 +3057,7 @@ export class AiSdkBackend implements AgentBackend { if (group.length === 0) return; const stepId = group[0]!.stepId; const reasoning = stepId !== undefined ? reasoningByStep.get(stepId) : undefined; - const text = stepId !== undefined ? (textByStep.get(stepId) ?? '') : ''; + const text = stepId !== undefined ? textByStep.get(stepId) : undefined; if (stepId !== undefined) { reasoningByStep.delete(stepId); textByStep.delete(stepId); @@ -2824,7 +3087,7 @@ export class AiSdkBackend implements AgentBackend { } for (const [stepId, reasoning] of reasoningByStep) { reasoningByStep.delete(stepId); - await emitStep(reasoning, '', []); + await emitStep(reasoning, undefined, []); } }; @@ -2871,18 +3134,24 @@ export class AiSdkBackend implements AgentBackend { // reasoning, if any) so step order is preserved. if (otherCalls.length > 0) await emitGroupedCalls(otherCalls); if (thisCalls.length > 0) { - await emitStep(reasoningByStep.get(stepId), item.content, thisCalls); + await emitStep(reasoningByStep.get(stepId), item, thisCalls); reasoningByStep.delete(stepId); } else { // Runtime-owned settlement persists assistant facts before the // matching tool calls. Hold the step closer until those calls // arrive; a terminal text-only step flushes below. - textByStep.set(stepId, item.content); + textByStep.set(stepId, item); } } else { // Legacy per-turn assistant text: standalone after any tool block. await flushPendingSteps(); - out.push({ role: 'assistant', content: item.content }); + out.push({ + role: 'assistant', + content: item.content, + ...(item.providerOptions !== undefined + ? { providerOptions: item.providerOptions } + : {}), + }); } break; } @@ -2927,7 +3196,11 @@ export class AiSdkBackend implements AgentBackend { ), } as ModelMessage; } - return { role: item.role, content: item.content }; + return { + role: item.role, + content: item.content, + ...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}), + }; } private async materializePriorMessages( @@ -2970,7 +3243,15 @@ export class AiSdkBackend implements AgentBackend { // replaying it as an empty text block is a hard 400 on Anthropic-protocol // providers. else if (m.type === 'assistant' && m.text.length > 0) - out.push({ role: 'assistant', content: m.text }); + out.push({ + role: 'assistant', + content: m.text, + ...(m.providerOptions !== undefined + ? { + providerOptions: m.providerOptions as NonNullable, + } + : {}), + } as ModelMessage); // empty assistant / tool_call / tool_result / permission_decision / token_usage / system_note skipped } return out; diff --git a/packages/runtime/src/ai-sdk-flow.ts b/packages/runtime/src/ai-sdk-flow.ts index a7c6232e8b..5f38291161 100644 --- a/packages/runtime/src/ai-sdk-flow.ts +++ b/packages/runtime/src/ai-sdk-flow.ts @@ -192,7 +192,13 @@ function mapBackendSessionEvent( ...base, role: 'model', author: 'agent', - content: { kind: 'text', text: event.text }, + content: { + kind: 'text', + text: event.text, + ...(event.providerOptions !== undefined + ? { providerOptions: structuredClone(event.providerOptions) } + : {}), + }, refs: { providerEventId: event.messageId }, }; @@ -237,6 +243,9 @@ function mapBackendSessionEvent( ...(event.providerOptions !== undefined ? { providerOptions: structuredClone(event.providerOptions) } : {}), + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), }, refs: { toolCallId: event.toolUseId, @@ -288,6 +297,12 @@ function mapBackendSessionEvent( name, result: event.content, ...(event.isError ? { isError: true } : {}), + ...(event.providerExecuted !== undefined + ? { providerExecuted: event.providerExecuted } + : {}), + ...(event.providerExecuted && event.providerOutput !== undefined + ? { providerOutput: structuredClone(event.providerOutput) } + : {}), }, refs: { toolCallId: event.toolUseId, diff --git a/packages/runtime/src/context-budget-helpers.ts b/packages/runtime/src/context-budget-helpers.ts index c897af63d2..1504612d90 100644 --- a/packages/runtime/src/context-budget-helpers.ts +++ b/packages/runtime/src/context-budget-helpers.ts @@ -31,7 +31,13 @@ export function estimateRuntimeEventChars(event: RuntimeEvent): number { else if (content?.kind === 'function_call') total += content.name.length + stableJsonLength(content.args); else if (content?.kind === 'function_response') - total += content.name.length + stableJsonLength(content.result); + total += + content.name.length + + stableJsonLength( + content.providerExecuted && content.providerOutput !== undefined + ? content.providerOutput + : content.result, + ); else if (content?.kind === 'error') total += content.message.length; return total; } diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 5df5e8a8fd..0f10141b4d 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -16,6 +16,11 @@ export { } from './session-manager.js'; export { RuntimeContextCompactError } from './runtime-kernel.js'; export type { ModelMessage, JSONValue } from './model-protocol.js'; +export { + buildNativeWebSearchTool, + NATIVE_WEB_SEARCH_TOOL_NAME, + routeWebSearchTools, +} from './native-web-search-tool.js'; export type { CompactSessionInput, PlanSafeBoundaryContinuationInput, diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index df040fd919..cf1c2e1eb9 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -1,4 +1,6 @@ import type { ErrorEvent, CompleteEvent } from '@maka/core/events'; +import { openai } from '@ai-sdk/openai'; +import { anthropic } from '@ai-sdk/anthropic'; import { providerAuthRequiresSecret, type RuntimeExecutionConnection, @@ -191,19 +193,23 @@ export class ModelAdapter { }, }) : input.model; - const schemaOnlyTools: ModelToolSet = Object.fromEntries( + const sdkTools = Object.fromEntries( Object.entries(input.tools).map(([name, definition]) => [ name, - { - ...(definition.description !== undefined ? { description: definition.description } : {}), - inputSchema: definition.inputSchema, - }, + definition.kind === 'provider' + ? compileProviderTool(definition.providerTool) + : { + ...(definition.description !== undefined + ? { description: definition.description } + : {}), + inputSchema: definition.inputSchema, + }, ]), ); const sdkResult = streamText({ model: trackedModel, messages: lowerNativeAudioMessages(input.messages), - tools: schemaOnlyTools, + tools: sdkTools, activeTools: input.activeTools, // An empty active set is an authoritative tool-free request (not merely // an empty provider schema). Some OpenAI-compatible models, including @@ -482,6 +488,8 @@ interface AiSdkStreamChunk { args?: unknown; providerExecuted?: boolean; result?: unknown; + output?: unknown; + isError?: boolean; usage?: AiSdkUsageLike; finishReason?: unknown; error?: unknown; @@ -549,10 +557,21 @@ function translateChunk( kimiOpenAiTransportState?: KimiOpenAiTransportState, ): ModelStreamEvent[] { switch (chunk.type) { + case 'text-start': + return [{ kind: 'text-start' }]; case 'text-delta': { const text = chunk.text ?? chunk.textDelta ?? chunk.delta ?? ''; return text ? [{ kind: 'text', text }] : []; } + case 'text-end': { + if (!chunk.providerMetadata || typeof chunk.providerMetadata !== 'object') return []; + return [ + { + kind: 'text-metadata', + providerOptions: chunk.providerMetadata as NonNullable, + }, + ]; + } case 'reasoning': case 'reasoning-delta': { const text = @@ -621,14 +640,34 @@ function translateChunk( case 'reasoning-start': case 'start-step': case 'tool-result': - return []; + case 'tool-error': { + if ( + chunk.providerExecuted !== true || + typeof chunk.toolCallId !== 'string' || + typeof chunk.toolName !== 'string' + ) { + return []; + } + return [ + { + kind: 'provider-tool-result', + toolCallId: chunk.toolCallId, + toolName: chunk.toolName, + output: chunk.type === 'tool-error' ? chunk.error : (chunk.output ?? chunk.result), + ...(chunk.type === 'tool-error' || chunk.isError === true ? { isError: true } : {}), + }, + ]; + } case 'tool-call': { if (typeof chunk.toolCallId !== 'string' || typeof chunk.toolName !== 'string') return []; const toolCall: ToolCallPart = { type: 'tool-call', toolCallId: chunk.toolCallId, toolName: chunk.toolName, - input: chunk.input ?? chunk.args, + input: + chunk.providerExecuted === true + ? parseProviderExecutedToolInput(chunk.input ?? chunk.args) + : (chunk.input ?? chunk.args), ...(chunk.providerExecuted !== undefined ? { providerExecuted: chunk.providerExecuted } : {}), @@ -645,6 +684,30 @@ function translateChunk( } } +function parseProviderExecutedToolInput(input: unknown): unknown { + if (typeof input !== 'string') return input; + try { + return JSON.parse(input); + } catch { + return input; + } +} + +function compileProviderTool( + tool: NonNullable, +): unknown { + switch (tool.kind) { + case 'openai-web-search': + return openai.tools.webSearch({ + ...(tool.searchContextSize ? { searchContextSize: tool.searchContextSize } : {}), + }); + case 'anthropic-web-search-20250305': + return anthropic.tools.webSearch_20250305({ + ...(tool.maxUses !== undefined ? { maxUses: tool.maxUses } : {}), + }); + } +} + function normalizeModelFailure(error: unknown): ModelFailure { if (isModelFailure(error)) return error; const errorClass = classifyError(error); diff --git a/packages/runtime/src/model-history.ts b/packages/runtime/src/model-history.ts index 45c4c0047c..70e49a8211 100644 --- a/packages/runtime/src/model-history.ts +++ b/packages/runtime/src/model-history.ts @@ -109,6 +109,7 @@ export type RuntimeEventModelReplayItem = kind: 'text'; role: 'user' | 'assistant' | 'system'; content: string; + providerOptions?: NonNullable; /** Original attachments (if any) so replay can render image parts. */ attachments?: AttachmentRef[]; /** Assistant step id (model-role text only); groups a step's parts. */ @@ -139,6 +140,7 @@ export type RuntimeEventModelReplayItem = toolName: string; input: unknown; providerOptions?: NonNullable; + providerExecuted?: boolean; /** Assistant step id (from tool_start); groups the call with its step. */ stepId?: string; eventId: string; @@ -150,6 +152,7 @@ export type RuntimeEventModelReplayItem = toolName: string; output: unknown; isError: boolean; + providerExecuted?: boolean; eventId: string; ts: number; }; @@ -455,6 +458,13 @@ export function buildRuntimeEventModelReplayPlan( : formatTextWithInlineRefs(event.content), ...(steeringReplay ? { steering: { eventId: event.id } } : {}), ...(event.content.attachments ? { attachments: event.content.attachments } : {}), + ...(event.content.providerOptions !== undefined + ? { + providerOptions: event.content.providerOptions as NonNullable< + ModelMessage['providerOptions'] + >, + } + : {}), // Live events carry providerEventId; missing-ledger recovery carries // the same assistant message identity as storedMessageId. ...(assistantStepId ? { stepId: assistantStepId } : {}), @@ -532,6 +542,9 @@ export function buildRuntimeEventModelReplayPlan( >, } : {}), + ...(event.content.providerExecuted !== undefined + ? { providerExecuted: event.content.providerExecuted } + : {}), ...(event.refs?.stepId ? { stepId: event.refs.stepId } : {}), eventId: event.id, ts: event.ts, @@ -556,7 +569,10 @@ export function buildRuntimeEventModelReplayPlan( } const shellResult = normalizeShellToolResultContent(event.content.result); let invalidResultMessage: string | undefined; - let normalizedResult: unknown = event.content.result; + let normalizedResult: unknown = + event.content.providerExecuted && event.content.providerOutput !== undefined + ? event.content.providerOutput + : event.content.result; if (shellResult.state === 'invalid') { invalidResultMessage = 'function_response contains an invalid shell tool result'; } else if ( @@ -616,6 +632,9 @@ export function buildRuntimeEventModelReplayPlan( toolName: event.content.name, output: normalizedResult, isError: event.content.isError === true, + ...(event.content.providerExecuted !== undefined + ? { providerExecuted: event.content.providerExecuted } + : {}), eventId: event.id, ts: event.ts, }); diff --git a/packages/runtime/src/model-protocol.ts b/packages/runtime/src/model-protocol.ts index 5983b21ec6..045c923c76 100644 --- a/packages/runtime/src/model-protocol.ts +++ b/packages/runtime/src/model-protocol.ts @@ -246,10 +246,17 @@ export type ToolResultOutput = * schema construction is a local implementation detail. Execution belongs to * ToolRuntime and never crosses the provider adapter boundary. */ -export interface ModelToolDefinition { - description?: string; - inputSchema: unknown; -} +export type ModelToolDefinition = + | { + kind?: 'function'; + description?: string; + inputSchema: unknown; + execute?: unknown; + } + | { + kind: 'provider'; + providerTool: NonNullable; + }; export type ModelToolSet = Record; @@ -435,10 +442,19 @@ export interface ModelRequestMetadata { * recovery and terminal error emission. */ export type ModelStreamEvent = + | { kind: 'text-start' } | { kind: 'text'; text: string } + | { kind: 'text-metadata'; providerOptions: ProviderOptions } | { kind: 'thinking'; text: string; providerOptions?: ProviderOptions } | { kind: 'thinking-signature'; signature: string } | { kind: 'tool-call'; toolCall: ToolCallPart } + | { + kind: 'provider-tool-result'; + toolCallId: string; + toolName: string; + output: unknown; + isError?: boolean; + } | { kind: 'step-finish'; usage?: NormalizedUsage; finishReason?: ModelFinishReason } | { kind: 'finish'; finishReason?: ModelFinishReason } | { kind: 'error'; failure: ModelFailure }; diff --git a/packages/runtime/src/native-web-search-tool.ts b/packages/runtime/src/native-web-search-tool.ts new file mode 100644 index 0000000000..017edcb9c3 --- /dev/null +++ b/packages/runtime/src/native-web-search-tool.ts @@ -0,0 +1,95 @@ +import { z } from 'zod'; +import { resolveHostedWebSearchCapability } from '@maka/core/model-web-search'; +import type { HostedWebSearchAdapter } from '@maka/core/model-web-search'; +import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; +import type { WebSearchSettings } from '@maka/core'; +import type { MakaTool } from './tool-runtime.js'; + +export const NATIVE_WEB_SEARCH_TOOL_NAME = 'WebSearch'; + +/** + * Provider-executed search descriptor. AI SDK compiles this into the selected + * provider's native tool; the local implementation is an invariant guard only. + */ +export function buildNativeWebSearchTool(input?: { + readonly adapter?: Extract; + readonly searchContextSize?: 'low' | 'medium' | 'high'; + readonly maxUses?: number; +}): MakaTool { + const adapter = input?.adapter ?? 'openai-responses'; + return { + name: NATIVE_WEB_SEARCH_TOOL_NAME, + displayName: 'Web search', + activityKind: 'websearch', + categoryHint: 'web_read', + description: + 'Search and read the live web through the current model provider. Use it for current external information and source-backed answers.', + parameters: z.object({}).strict(), + providerTool: { + ...(adapter === 'anthropic-messages' + ? { + kind: 'anthropic-web-search-20250305' as const, + maxUses: input?.maxUses ?? 8, + } + : { + kind: 'openai-web-search' as const, + searchContextSize: input?.searchContextSize ?? 'medium', + }), + }, + impl: () => { + throw new Error('Provider-native WebSearch must not execute through ToolRuntime'); + }, + }; +} + +/** Freezes one unambiguous WebSearch tool meaning for the selected model turn. */ +export function routeWebSearchTools(input: { + readonly tools: readonly MakaTool[]; + readonly settings: Pick; + readonly connection: RuntimeExecutionConnection; + readonly model: string; + readonly privacy?: { readonly incognitoActive: boolean }; + /** Root surfaces may add native search even when no client WebSearch exists. */ + readonly allowAddNative?: boolean; +}): MakaTool[] { + const firstSearchIndex = input.tools.findIndex( + (tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME, + ); + const withoutWebSearch = input.tools.filter((tool) => tool.name !== NATIVE_WEB_SEARCH_TOOL_NAME); + if (!input.settings.enabled || input.privacy?.incognitoActive === true) return withoutWebSearch; + let selected: MakaTool | undefined; + if (input.settings.defaultProvider === 'tavily') { + selected = input.tools.find((tool) => tool.name === NATIVE_WEB_SEARCH_TOOL_NAME); + } else { + const capability = resolveHostedWebSearchCapability( + input.connection.providerType, + input.connection.models, + input.model, + ); + if ( + (firstSearchIndex >= 0 || input.allowAddNative === true) && + capability?.implemented === true && + capability.adapter === 'openai-responses' + ) { + selected = buildNativeWebSearchTool({ adapter: 'openai-responses' }); + } else if ( + (firstSearchIndex >= 0 || input.allowAddNative === true) && + capability?.implemented === true && + capability.adapter === 'anthropic-messages' + ) { + selected = buildNativeWebSearchTool({ adapter: 'anthropic-messages' }); + } + } + if (!selected) return withoutWebSearch; + const insertionIndex = + firstSearchIndex < 0 + ? withoutWebSearch.length + : input.tools + .slice(0, firstSearchIndex) + .filter((tool) => tool.name !== NATIVE_WEB_SEARCH_TOOL_NAME).length; + return [ + ...withoutWebSearch.slice(0, insertionIndex), + selected, + ...withoutWebSearch.slice(insertionIndex), + ]; +} diff --git a/packages/runtime/src/request-shape.ts b/packages/runtime/src/request-shape.ts index 447bbae46d..716f9bd0b7 100644 --- a/packages/runtime/src/request-shape.ts +++ b/packages/runtime/src/request-shape.ts @@ -558,6 +558,7 @@ function toolShapeForDiagnostics(tool: MakaTool): unknown { name: tool.name, description: tool.description, inputSchema: schemaShapeForHash(tool.parameters), + ...(tool.providerTool ? { providerTool: tool.providerTool } : {}), }; } diff --git a/packages/runtime/src/runtime-event-adapters.ts b/packages/runtime/src/runtime-event-adapters.ts index 28a50d900c..4b12b4b055 100644 --- a/packages/runtime/src/runtime-event-adapters.ts +++ b/packages/runtime/src/runtime-event-adapters.ts @@ -130,7 +130,13 @@ export function storedMessageToRuntimeEvent( partial: false, role: 'model', author: 'agent', - content: { kind: 'text', text: message.text }, + content: { + kind: 'text', + text: message.text, + ...(message.providerOptions !== undefined + ? { providerOptions: structuredClone(message.providerOptions) } + : {}), + }, refs: { storedMessageId: message.id }, }; @@ -281,6 +287,9 @@ export function runtimeEventToStoredMessageDraft( turnId: event.turnId, ts: event.ts, text: content.text, + ...(content.providerOptions !== undefined + ? { providerOptions: structuredClone(content.providerOptions) } + : {}), modelId: options.modelId, }; return draft; diff --git a/packages/runtime/src/runtime-event-backfill.ts b/packages/runtime/src/runtime-event-backfill.ts index f64b481e77..6643325729 100644 --- a/packages/runtime/src/runtime-event-backfill.ts +++ b/packages/runtime/src/runtime-event-backfill.ts @@ -15,6 +15,7 @@ export const RUNTIME_EVENT_BACKFILL_STATE_KEY = 'makaRuntimeRecovery'; export type RuntimeEventBackfillDiagnosticCode = | 'skipped_high_risk_message' + | 'skipped_provider_native_replay_gap' | 'skipped_unmatched_tool_result' | 'skipped_unmatched_permission_decision' | 'skipped_unsafe_terminal_state'; @@ -63,6 +64,16 @@ export function backfillRuntimeEventsFromStoredMessages( .slice() .sort((a, b) => a.ts - b.ts || messageId(a).localeCompare(messageId(b))); const toolCalls = new Map(); + const replayableProviderToolUseIds = new Set( + turnMessages + .filter( + (message): message is ToolResultMessage => + message.type === 'tool_result' && + message.providerExecuted === true && + message.providerOutput !== undefined, + ) + .map((message) => message.toolUseId), + ); for (const message of turnMessages) { if (message.type === 'tool_call') { @@ -141,6 +152,15 @@ export function backfillRuntimeEventsFromStoredMessages( break; case 'tool_call': { + if (message.providerExecuted === true && !replayableProviderToolUseIds.has(message.id)) { + diagnostics.push({ + code: 'skipped_provider_native_replay_gap', + message: + 'provider-native tool history requires the opaque provider output for lossless recovery', + detail: { messageId: message.id, toolUseId: message.id }, + }); + break; + } const stateDelta = toolCallStateDelta(message); events.push({ ...base, @@ -155,6 +175,9 @@ export function backfillRuntimeEventsFromStoredMessages( ...(message.providerOptions !== undefined ? { providerOptions: structuredClone(message.providerOptions) } : {}), + ...(message.providerExecuted !== undefined + ? { providerExecuted: message.providerExecuted } + : {}), }, ...(stateDelta ? { actions: { stateDelta } } : {}), // Carry the persisted step id into refs.stepId so post-restart model @@ -170,6 +193,9 @@ export function backfillRuntimeEventsFromStoredMessages( } case 'tool_result': { + if (message.providerExecuted === true && message.providerOutput === undefined) { + break; + } const call = safePriorToolCall(toolCalls, message); if (!call) { diagnostics.push({ @@ -196,6 +222,12 @@ export function backfillRuntimeEventsFromStoredMessages( name: call.toolName, result: message.content, isError: message.isError, + ...(message.providerExecuted !== undefined + ? { providerExecuted: message.providerExecuted } + : {}), + ...(message.providerExecuted && message.providerOutput !== undefined + ? { providerOutput: structuredClone(message.providerOutput) } + : {}), }, ...(message.durationMs !== undefined ? { actions: { stateDelta: { durationMs: message.durationMs } } } diff --git a/packages/runtime/src/runtime-event-read-model.ts b/packages/runtime/src/runtime-event-read-model.ts index d7ecf1288b..b5a5b58c62 100644 --- a/packages/runtime/src/runtime-event-read-model.ts +++ b/packages/runtime/src/runtime-event-read-model.ts @@ -627,6 +627,9 @@ function projectText( turnId: event.turnId, ts: event.ts, text: event.content.text, + ...(event.content.providerOptions !== undefined + ? { providerOptions: structuredClone(event.content.providerOptions) } + : {}), ...(contentOrder ? { contentOrder } : {}), modelId: header.modelId, }); @@ -764,6 +767,12 @@ function projectFunctionCall( // the UI timeline falls back to legacy tools-before-text ordering. ...(event.refs?.stepId ? { stepId: event.refs.stepId } : {}), args: event.content.args, + ...(event.content.providerOptions !== undefined + ? { providerOptions: structuredClone(event.content.providerOptions) } + : {}), + ...(event.content.providerExecuted !== undefined + ? { providerExecuted: event.content.providerExecuted } + : {}), }); return true; } @@ -859,6 +868,12 @@ function projectFunctionResponse( toolUseId, isError: event.content.isError === true, content: resultContent, + ...(event.content.providerExecuted !== undefined + ? { providerExecuted: event.content.providerExecuted } + : {}), + ...(event.content.providerExecuted && event.content.providerOutput !== undefined + ? { providerOutput: structuredClone(event.content.providerOutput) } + : {}), ...(numberStateDelta(event, 'durationMs') !== undefined ? { durationMs: numberStateDelta(event, 'durationMs') } : {}), diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 343b915f90..37ea81b307 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -783,6 +783,7 @@ interface SessionManagerBaseDeps { newId: () => string; now: () => number; childTools?: readonly MakaTool[]; + resolveChildTools?: (sessionId: string) => Promise; /** Host-owned user catalog. Runtime receives ids from models, never raw model targets. */ subagentCatalog?: { list(): Promise; @@ -2401,7 +2402,7 @@ export class SessionManager { const definition = requireBuiltinAgentDefinition(input.agentId); assertAgentDefinitionRunnable({ definition, - tools: this.deps.childTools ?? [], + tools: await this.childToolsForSession(input.source.sessionId), worktreeChildExecutorAvailable: this.hasWorktreeChildExecutor(), }); const childPermissionMode = @@ -2968,7 +2969,7 @@ export class SessionManager { this.assertActiveParentRun(parentSessionId, parentRun, input.spawnedBy.parentTurnId); const definition = requireBuiltinAgentDefinitionByProfile(input.agentProfile); - const availableChildTools = this.deps.childTools ?? []; + const availableChildTools = await this.childToolsForSession(parentSessionId); assertAgentDefinitionRunnable({ definition, tools: availableChildTools, @@ -3307,7 +3308,7 @@ export class SessionManager { await this.ensureChildWorkspace(sessionHeader); assertAgentDefinitionRunnable({ definition, - tools: this.deps.childTools ?? [], + tools: await this.childToolsForSession(sessionId), worktreeChildExecutorAvailable: this.hasWorktreeChildExecutor(), }); const visited = new Set(); @@ -3433,7 +3434,7 @@ export class SessionManager { } await this.ensureChildWorkspace(child); await this.assertLinkedChildBoundaryMatchesParent(parentSessionId, child.id); - const runnableTools = buildToolsForAgentDefinition(this.deps.childTools ?? [], { + const runnableTools = buildToolsForAgentDefinition(await this.childToolsForSession(child.id), { id: snapshot.agentId, permissionMode: child.permissionMode, tools: snapshot.toolNames, @@ -3938,9 +3939,10 @@ export class SessionManager { const resolved = requireBuiltinAgentDefinition(sourceRun.agentId); definition = { ...resolved, - toolNames: buildToolsForAgentDefinition(this.deps.childTools ?? [], resolved).map( - (tool) => tool.name, - ), + toolNames: buildToolsForAgentDefinition( + await this.childToolsForSession(sessionId), + resolved, + ).map((tool) => tool.name), }; } const authority = runtimeContinuationAuthority(this.deps.runtimeEventStore); @@ -4283,7 +4285,7 @@ export class SessionManager { async listChildAgents(sessionId: string): Promise { const definitions = listBuiltinAgentDefinitions({ - tools: this.deps.childTools ?? [], + tools: await this.childToolsForSession(sessionId), worktreeChildExecutorAvailable: this.hasWorktreeChildExecutor(), }); const presets = this.deps.subagentCatalog ? await this.deps.subagentCatalog.list() : []; @@ -4391,6 +4393,12 @@ export class SessionManager { }; } + private async childToolsForSession(sessionId: string): Promise { + return this.deps.resolveChildTools + ? await this.deps.resolveChildTools(sessionId) + : (this.deps.childTools ?? []); + } + async readChildAgentOutput( sessionId: string, input: AgentOutputInput, diff --git a/packages/runtime/src/tool-result-archive.ts b/packages/runtime/src/tool-result-archive.ts index 542642b0d1..12bee599c3 100644 --- a/packages/runtime/src/tool-result-archive.ts +++ b/packages/runtime/src/tool-result-archive.ts @@ -323,6 +323,7 @@ export function pruneStaleToolResultsBeforeCompact( if ( event.partial || content?.kind !== 'function_response' || + (content.providerExecuted === true && content.providerOutput !== undefined) || protectedTurnIds.has(turnKey(event)) ) { return event; @@ -412,6 +413,7 @@ export function collectStaleToolResultArchiveCandidates( if ( event.partial || content?.kind !== 'function_response' || + (content.providerExecuted === true && content.providerOutput !== undefined) || protectedTurnIds.has(turnKey(event)) || isArchivedToolResultPlaceholder(content.result) ) { diff --git a/packages/runtime/src/tool-runtime.ts b/packages/runtime/src/tool-runtime.ts index 9fa28fa310..0d7745037e 100644 --- a/packages/runtime/src/tool-runtime.ts +++ b/packages/runtime/src/tool-runtime.ts @@ -112,6 +112,15 @@ export interface MakaTool

{ categoryHint?: ToolCategory; /** Optional trusted facts about the executor that runs this tool. */ executionFacts?: ToolExecutionFacts; + /** + * Provider-native tool declaration. The provider executes this tool inside + * the primary model request; ToolRuntime must never dispatch `impl` for it. + */ + providerTool?: { + readonly kind: 'openai-web-search' | 'anthropic-web-search-20250305'; + readonly searchContextSize?: 'low' | 'medium' | 'high'; + readonly maxUses?: number; + }; /** Crash-recovery contract used by the durable tool boundary. */ recoveryMode?: ToolRecoveryMode; /** Step-level admission contract. Exclusive tools cannot share an assistant step. */ diff --git a/packages/runtime/src/web-search-tool.ts b/packages/runtime/src/web-search-tool.ts index 78eb822daf..e798affa6d 100644 --- a/packages/runtime/src/web-search-tool.ts +++ b/packages/runtime/src/web-search-tool.ts @@ -15,6 +15,7 @@ interface WebSearchExecutor { search(input: { readonly query: string; readonly limit: number; + readonly sessionId: string; readonly abortSignal?: AbortSignal; }): Promise; } @@ -47,12 +48,13 @@ export function buildWebSearchTool(executor: WebSearchExecutor): MakaTool { const response = await executor.search({ query: normalizedQuery, limit: normalizeWebSearchLimit(limit), + sessionId: context.sessionId, ...(context.abortSignal ? { abortSignal: context.abortSignal } : {}), }); if (!response.ok) return webSearchError(response.reason, response.message, normalizedQuery); return { kind: 'web_search' as const, - provider: 'tavily' as const, + provider: response.provider ?? response.results[0]?.provider ?? ('tavily' as const), query: normalizedQuery, rows: response.results.map((row) => ({ title: row.title, diff --git a/packages/storage/src/__tests__/runtime-policy-stores.test.ts b/packages/storage/src/__tests__/runtime-policy-stores.test.ts index 1ef4a882d7..400db396c6 100644 --- a/packages/storage/src/__tests__/runtime-policy-stores.test.ts +++ b/packages/storage/src/__tests__/runtime-policy-stores.test.ts @@ -1755,7 +1755,7 @@ describe('runtime policy stores', () => { await withInteractiveOwner(async ({ stores }) => { assert.deepEqual(await stores.operations.resolveWebSearchExecution(), { kind: 'disabled', - provider: 'tavily', + provider: 'model', }); const enabled = await stores.runtimePolicy.mutate({ @@ -1787,8 +1787,8 @@ describe('runtime policy stores', () => { ); const direct = await stores.operations.resolveWebSearchExecution(); assert.equal(direct.kind, 'ready'); - if (direct.kind !== 'ready') return; - assert.equal(direct.secretMaterial.webSearch?.secret, 'tavily-execution-secret'); + if (direct.kind !== 'ready' || direct.provider !== 'tavily') return; + assert.equal(direct.secretMaterial.webSearch.secret, 'tavily-execution-secret'); assert.equal(direct.secretMaterial.networkProxy, undefined); assert.equal(direct.networkProxy.enabled, false); @@ -1824,8 +1824,8 @@ describe('runtime policy stores', () => { ); const ready = await stores.operations.resolveWebSearchExecution(); assert.equal(ready.kind, 'ready'); - if (ready.kind !== 'ready') return; - assert.equal(ready.secretMaterial.webSearch?.secret, 'tavily-execution-secret'); + if (ready.kind !== 'ready' || ready.provider !== 'tavily') return; + assert.equal(ready.secretMaterial.webSearch.secret, 'tavily-execution-secret'); assert.equal(ready.secretMaterial.networkProxy?.secret, 'proxy-execution-secret'); assert.equal(ready.networkProxy.host, 'proxy.example'); @@ -1840,6 +1840,23 @@ describe('runtime policy stores', () => { }); }); + test('keeps provider-native WebSearch outside the client search credential resolver', async () => { + await withInteractiveOwner(async ({ stores }) => { + const policy = await stores.runtimePolicy.mutate({ + expectedRevision: 0, + operation: { + kind: 'set_web_search', + value: { enabled: true, defaultProvider: 'model' }, + }, + }); + assert.equal(policy.kind, 'committed'); + assert.deepEqual(await stores.operations.resolveWebSearchExecution(), { + kind: 'model_native_only', + provider: 'model', + }); + }); + }); + test('removes credentials only for a matching connection revision and converges on partial retries', async () => { await withInteractiveOwner(async ({ stores }) => { const original = await createConnection( diff --git a/packages/storage/src/runtime-policy/coordinator.ts b/packages/storage/src/runtime-policy/coordinator.ts index ea1df94f05..c7fb83471e 100644 --- a/packages/storage/src/runtime-policy/coordinator.ts +++ b/packages/storage/src/runtime-policy/coordinator.ts @@ -544,6 +544,10 @@ export class RuntimePolicyCoordinator { return deepFreeze({ kind: 'disabled' as const, provider }); } + if (provider === 'model') { + return deepFreeze({ kind: 'model_native_only' as const, provider }); + } + const vault = await this.vault.read(root); const locator = { scope: 'web_search', provider, kind: 'api_key' } as const; const webSearchCredential = findCredential(vault, locator); diff --git a/packages/storage/src/runtime-policy/operations.ts b/packages/storage/src/runtime-policy/operations.ts index 3ec49facf0..dbf37b8bfa 100644 --- a/packages/storage/src/runtime-policy/operations.ts +++ b/packages/storage/src/runtime-policy/operations.ts @@ -35,10 +35,14 @@ export type ResolveWebSearchExecutionResult = readonly kind: 'disabled'; readonly provider: RuntimePolicy['webSearch']['defaultProvider']; } + | { + readonly kind: 'model_native_only'; + readonly provider: 'model'; + } | { readonly kind: 'credential_not_configured'; readonly status: CredentialStatus } | { readonly kind: 'ready'; - readonly provider: RuntimePolicy['webSearch']['defaultProvider']; + readonly provider: 'tavily'; readonly secretMaterial: { readonly webSearch: RuntimePolicyCredentialMaterial; readonly networkProxy?: RuntimePolicyCredentialMaterial; From c213f2ba2c651635cfd31e02872ab36c95ab33f9 Mon Sep 17 00:00:00 2001 From: jackwener Date: Wed, 5 Aug 2026 00:06:13 +0800 Subject: [PATCH 2/2] fix(search): align native search routing and replay --- .../desktop-backend-tool-surface.test.ts | 73 ++++++++++++++++ apps/desktop/src/main/boot.ts | 22 +++++ .../src/main/desktop-backend-tool-surface.ts | 70 +++++++++++++++- .../src/__tests__/ai-sdk-backend.test.ts | 84 +++++++++++++++++++ packages/runtime/src/ai-sdk-backend.ts | 55 +++++++----- 5 files changed, 284 insertions(+), 20 deletions(-) diff --git a/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts b/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts index 74109275e9..a8cce184ae 100644 --- a/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts +++ b/apps/desktop/src/main/__tests__/desktop-backend-tool-surface.test.ts @@ -6,11 +6,14 @@ import type { McpClientManager } from '@maka/mcp'; import { type AiSdkBackendInput, type BackendFactoryContext, + buildParentAgentTools, + listRunnableBuiltinAgentDefinitions, type MakaTool, type ToolAvailabilityConfig, } from '@maka/runtime'; import { resolveDesktopBackendToolSurface, + resolveDesktopChildToolSurface, resolveDesktopNewSessionSkillHost, resolveDesktopSessionSkillHost, type DesktopBackendToolSurfaceDeps, @@ -383,6 +386,76 @@ describe('Desktop backend tool surface', () => { ); }); + it('derives Desktop parent and child agent availability from the routed search surface', async () => { + const clientSearch = tool('WebSearch', 'web_read'); + const staticParentTools = buildParentAgentTools(); + const deepseek: LlmConnection = { + ...connectionFor('deepseek-v4-flash'), + providerType: 'deepseek', + models: [{ id: 'deepseek-v4-flash', apiProtocol: 'openai-responses' }], + }; + const childTools = [readTool, clientSearch]; + const routedProfiles = new Map(); + const makeSearchDeps = (enabled: boolean) => + makeDeps({ + builtinTools: [readTool, ...staticParentTools], + childTools, + buildParentAgentToolsForChildSurface: (routedChildTools) => { + const definitions = listRunnableBuiltinAgentDefinitions({ tools: routedChildTools }); + routedProfiles.set(enabled, definitions.map((definition) => definition.profile)); + return buildParentAgentTools({ definitions }); + }, + getReadyConnection: async () => ({ + connection: deepseek, + apiKey: 'deepseek-key', + model: 'deepseek-v4-flash', + }), + getWebSearchSettings: async () => ({ + enabled, + defaultProvider: 'model', + providers: { + tavily: { + apiKey: '', + credentialSource: 'none', + credentialVersion: 0, + credentialStatus: 'untested', + }, + }, + }), + }); + + const disabledDeps = makeSearchDeps(false); + await resolveDesktopBackendToolSurface( + disabledDeps, + inputFor('deepseek-v4-flash'), + ); + assert.equal(routedProfiles.get(false)?.includes('web_research'), false); + assert.deepEqual( + ( + await resolveDesktopChildToolSurface(disabledDeps, { + header: inputFor('deepseek-v4-flash').header, + tools: childTools, + }) + ).map((tool) => tool.name), + ['Read'], + ); + + const enabledDeps = makeSearchDeps(true); + await resolveDesktopBackendToolSurface( + enabledDeps, + inputFor('deepseek-v4-flash'), + ); + assert.equal(routedProfiles.get(true)?.includes('web_research'), true); + const routedChildTools = await resolveDesktopChildToolSurface(enabledDeps, { + header: inputFor('deepseek-v4-flash').header, + tools: childTools, + }); + assert.equal( + routedChildTools.find((tool) => tool.name === 'WebSearch')?.providerTool?.kind, + 'openai-web-search', + ); + }); + it('uses explicit preview inputs without reading a nonexistent session plan', async () => { let connectionReads = 0; let planReads = 0; diff --git a/apps/desktop/src/main/boot.ts b/apps/desktop/src/main/boot.ts index 42f1f3e845..879952ddfc 100644 --- a/apps/desktop/src/main/boot.ts +++ b/apps/desktop/src/main/boot.ts @@ -47,6 +47,8 @@ import { BotRegistry, ShellRunProcessManager, SessionActivityRegistry, + buildParentAgentTools, + listRunnableBuiltinAgentDefinitions, listInvocableSkills, prepareSkillInvocationMessage, resolveSkillDiscoveryPaths, @@ -57,6 +59,7 @@ import type { GoalTurnOutcome, HostCapabilities, HostCapabilitiesResolver, + MakaTool, } from '@maka/runtime'; import type { LlmConnection } from '@maka/core/llm-connections'; import { @@ -140,6 +143,7 @@ import { createSettingsRuntimeEffects } from './settings-runtime-effects.js'; import { createAiSdkBackendFactory, createSessionStreamer } from './session-stream.js'; import { resolveDesktopBackendToolSurface, + resolveDesktopChildToolSurface, resolveDesktopNewSessionSkillHost, resolveDesktopSessionSkillHost, } from './desktop-backend-tool-surface.js'; @@ -751,9 +755,26 @@ const desktopBackendToolSurfaceDeps = { planStore, getWebSearchSettings: async () => (await settingsStore.get()).webSearch, getPrivacySettings: async () => (await settingsStore.get()).privacy, + childTools: childAgentTools, + buildParentAgentToolsForChildSurface: (tools: readonly MakaTool[]) => + buildParentAgentTools({ + taskLedger: taskLedgerStore, + definitions: listRunnableBuiltinAgentDefinitions({ + tools, + worktreeChildExecutorAvailable: worktreeChildExecutor !== undefined, + }), + }), getAgentGraphSupervisorTools: (sessionId: string) => agentGraphCoordinator.toolsForSession(sessionId), }; + +async function resolveDesktopChildTools(sessionId: string) { + const header = await store.readHeader(sessionId); + return resolveDesktopChildToolSurface(desktopBackendToolSurfaceDeps, { + header, + tools: childAgentTools, + }); +} // Cursor-overlay teardown assigns a module-scoped `let`, so it stays in boot.ts. onMainWindowClose = () => { computerUseOverlay.destroyAll(); @@ -917,6 +938,7 @@ const runtime = new SessionManager({ shellRuns, backends, childTools: childAgentTools, + resolveChildTools: resolveDesktopChildTools, subagentCatalog, worktreeChildExecutor, safeBoundaryResumeEnabled: process.env.MAKA_RUNTIME_SAFE_BOUNDARY_RESUME === '1', diff --git a/apps/desktop/src/main/desktop-backend-tool-surface.ts b/apps/desktop/src/main/desktop-backend-tool-surface.ts index 39dbb1786f..b7b5c3addd 100644 --- a/apps/desktop/src/main/desktop-backend-tool-surface.ts +++ b/apps/desktop/src/main/desktop-backend-tool-surface.ts @@ -18,6 +18,7 @@ import { type PlanStore, } from '@maka/core/plan'; import { + AGENT_TOOL_NAMES, AGENT_TOOL_GROUP_ID, buildCancelPlanTool, isDeepResearchToolAllowed, @@ -57,6 +58,10 @@ export interface DesktopBackendToolSurfaceDeps { ) => Promise; getWebSearchSettings?: () => Promise; getPrivacySettings?: () => Promise; + /** Complete child catalog before per-session search routing. */ + childTools?: readonly MakaTool[]; + /** Rebuilds parent agent tools from the routed child capability surface. */ + buildParentAgentToolsForChildSurface?: (childTools: readonly MakaTool[]) => readonly MakaTool[]; } export interface DesktopBackendToolSurfaceInput { @@ -91,6 +96,33 @@ export interface DesktopNewSessionSkillContext { collaborationMode?: CollaborationMode; } +/** + * Resolve the child capability surface from the same connection, search policy, + * and privacy authority used by backend creation. + */ +export async function resolveDesktopChildToolSurface( + deps: DesktopBackendToolSurfaceDeps, + input: { + header: SessionHeader; + tools: readonly MakaTool[]; + readyConnection?: ReadyConnection; + }, +): Promise { + const { connection, model } = + input.readyConnection ?? + (await deps.getReadyConnection(input.header.llmConnectionSlug, input.header.model)); + const webSearchSettings = await (deps.getWebSearchSettings?.() ?? + Promise.resolve(defaultWebSearchSettings())); + const privacySettings = await deps.getPrivacySettings?.(); + return routeWebSearchTools({ + tools: input.tools, + settings: webSearchSettings, + connection, + model, + ...(privacySettings ? { privacy: privacySettings } : {}), + }); +} + /** * Resolve Skill capabilities for an existing Desktop session from the same * durable child-tool snapshot that Runtime uses when it builds that session's @@ -225,6 +257,22 @@ export async function resolveDesktopBackendToolSurface( model, ...(privacySettings ? { privacy: privacySettings } : {}), }); + const routedChildTools = deps.childTools + ? routeWebSearchTools({ + tools: deps.childTools, + settings: webSearchSettings, + connection, + model, + ...(privacySettings ? { privacy: privacySettings } : {}), + }) + : undefined; + const effectiveCandidateTools = + !input.tools && routedChildTools && deps.buildParentAgentToolsForChildSurface + ? replaceParentAgentTools( + routedCandidateTools, + deps.buildParentAgentToolsForChildSurface(routedChildTools), + ) + : routedCandidateTools; const toolEconomy = deps.isComputerUseRealModelE2e ? false : deps.toolEconomy; const planControlTools = input.tools @@ -238,7 +286,7 @@ export async function resolveDesktopBackendToolSurface( ] : []; const backendTools = computerUseToolsForModel( - [...routedCandidateTools, ...planControlTools], + [...effectiveCandidateTools, ...planControlTools], deps.computerUseTools, supportsVision, ); @@ -269,6 +317,26 @@ export async function resolveDesktopBackendToolSurface( }; } +function replaceParentAgentTools( + tools: readonly MakaTool[], + replacements: readonly MakaTool[], +): MakaTool[] { + const parentToolNames = new Set(AGENT_TOOL_NAMES); + const result: MakaTool[] = []; + let replaced = false; + for (const tool of tools) { + if (!parentToolNames.has(tool.name)) { + result.push(tool); + continue; + } + if (!replaced) { + result.push(...replacements); + replaced = true; + } + } + return result; +} + function modelSupportsVision(connection: LlmConnection, model: string): boolean { return resolveModelVisionSupport(connection.providerType, connection.models, model); } diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index bd4aff1d7f..232e07dd9e 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -2158,6 +2158,88 @@ describe('AiSdkBackend model history', () => { ); }); + test('replays provider-executed web search before its grounded assistant text', async () => { + const model = completionModel(); + const backend = createTestAiSdkBackend({ + sessionId: 'session-1', + header: header(), + appendMessage: async () => {}, + connection: connection(), + apiKey: 'sk-test', + modelId: 'mock-model-id', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'openai-responses' })], + newId: idGenerator(), + now: monotonicClock(), + }); + + await drain( + backend.send({ + turnId: 'turn-current', + text: 'continue', + context: [], + runtimeContext: [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: { type: 'web_search_result', query: 'latest Maka' }, + providerOutput: { type: 'web_search_result', id: 'ws_123' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ], + }), + ); + + const prompt = compactPrompt(model) as Array<{ role: string; content: any[] }>; + const assistant = prompt.find( + (message) => + message.role === 'assistant' && message.content.some((part) => part.type === 'tool-call'), + ); + assert.deepEqual( + assistant?.content.map((part) => part.type), + ['tool-call', 'tool-result', 'text'], + ); + assert.match(JSON.stringify(assistant), /ws_123/); + assert.match(JSON.stringify(assistant), /Maka shipped the feature/); + }); + test('replays an image tool result as provider image data', async () => { const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]); const model = completionModel(); @@ -13923,6 +14005,7 @@ function runtimeEvent(input: { content?: RuntimeEvent['content']; status?: RuntimeEvent['status']; actions?: RuntimeEvent['actions']; + refs?: RuntimeEvent['refs']; }): RuntimeEvent { return { id: input.id, @@ -13937,6 +14020,7 @@ function runtimeEvent(input: { ...(input.content ? { content: input.content } : {}), ...(input.status ? { status: input.status } : {}), ...(input.actions ? { actions: input.actions } : {}), + ...(input.refs ? { refs: input.refs } : {}), }; } diff --git a/packages/runtime/src/ai-sdk-backend.ts b/packages/runtime/src/ai-sdk-backend.ts index acdb97da8d..30e89f3777 100644 --- a/packages/runtime/src/ai-sdk-backend.ts +++ b/packages/runtime/src/ai-sdk-backend.ts @@ -2879,12 +2879,15 @@ export class AiSdkBackend implements AgentBackend { * * The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking, * text (the per-step AssistantMessage flushes at `finish-step`, after the - * step's tool events). Model text carries the step id and closes the step: it - * emits `[reasoning, text, tool-call…]` then the tool results. Steps with no - * text closer — a thinking + tool step (its empty text closer is skipped from - * the plan as `empty_text_skipped`) or a pure-tool step — flush grouped by - * stepId, claiming any parked reasoning for that step. Legacy per-turn items - * (no step id) keep the older shape: tool calls form a tool-only assistant, + * step's tool events). Model text carries the step id and closes the step. + * Client tools replay as `[reasoning, text, tool-call…]` followed by tool + * messages; provider-executed tools replay as + * `[reasoning, tool-call, tool-result, text]`, preserving provider chronology + * for item references and grounded text. Steps with no text closer — a + * thinking + tool step (its empty text closer is skipped from the plan as + * `empty_text_skipped`) or a pure-tool step — flush grouped by stepId, + * claiming any parked reasoning for that step. Legacy per-turn items (no step + * id) keep the older shape: tool calls form a tool-only assistant, * text/thinking become standalone messages. */ private async materializeRuntimeReplayPlan( @@ -2988,8 +2991,8 @@ export class AiSdkBackend implements AgentBackend { }); } }; - // Emit one assistant message for a step: reasoning (if any), text (if any), - // then the step's tool calls, followed by those calls' tool results. + // Emit one assistant message for a step, preserving the distinct client- + // and provider-executed tool chronologies described above. const emitStep = async ( reasoning: readonly ThinkingItem[] | undefined, text: TextItem | undefined, @@ -3002,6 +3005,30 @@ export class AiSdkBackend implements AgentBackend { for (const item of replayReasoning ?? []) { if (item.part) content.push(item.part); } + // Provider-owned tools execute before the grounded assistant text in the + // same provider step. Preserve that chronology for Responses item + // references and Anthropic server_tool_use/result replay. Client tools + // stay after text because their execution begins only after this step. + for (const call of calls) { + if (call.providerExecuted !== true) continue; + content.push({ + type: 'tool-call', + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + providerExecuted: true, + }); + const result = results.get(call.toolCallId); + if (!result || result.providerExecuted !== true) continue; + results.delete(call.toolCallId); + content.push({ + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result), + }); + } if (text && text.content.length > 0) { content.push({ type: 'text', @@ -3010,6 +3037,7 @@ export class AiSdkBackend implements AgentBackend { }); } for (const call of calls) { + if (call.providerExecuted === true) continue; content.push({ type: 'tool-call', toolCallId: call.toolCallId, @@ -3021,17 +3049,6 @@ export class AiSdkBackend implements AgentBackend { : {}), }); } - for (const call of calls) { - const result = results.get(call.toolCallId); - if (!result || result.providerExecuted !== true) continue; - results.delete(call.toolCallId); - content.push({ - type: 'tool-result', - toolCallId: result.toolCallId, - toolName: result.toolName, - output: await materializeReplayToolResult(result), - }); - } const replayProviderOptions = replayReasoning?.find( (item) => item.providerOptions !== undefined, )?.providerOptions;