From 00b0d9b0c62c0f7ac13125ce800380458636e49f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 14:00:48 +0800 Subject: [PATCH 1/2] feat: add native deferred function discovery through Runtime --- CONTRIBUTING.md | 41 +++++- .../claudesdk/execution_controls_test.go | 23 +++ .../internal/agent/claudesdk/options.go | 10 ++ .../internal/agent/claudesdk/readiness.go | 4 + apps/parsar-daemon/internal/cli/claude_sdk.go | 1 + .../internal/dispatch/environment.go | 3 + .../internal/dispatch/functions_test.go | 17 +++ contracts/agents-api/README.md | 7 +- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/harnesses.md | 1 + contracts/agents-api/openapi.yaml | 6 +- contracts/agents-api/tool-search.md | 88 +++++++++++ internal/agentdaemon/device/state.go | 1 + internal/agentdaemon/gateway/session.go | 1 + internal/agentdaemon/proto/functions.go | 26 +++- internal/agentdaemon/proto/inbound.go | 1 + internal/agentdaemon/proto/outbound.go | 1 + .../proto/workspace_read_preparation.go | 2 +- packages/claude-sdk-adapter/src/adapter.ts | 10 +- packages/claude-sdk-adapter/src/functions.ts | 4 +- packages/claude-sdk-adapter/src/request.ts | 10 +- .../claude-sdk-adapter/src/runtime_check.ts | 2 +- .../claude-sdk-adapter/src/tool_search.ts | 12 ++ .../tests/functions.test.mjs | 16 ++ .../tests/tool_search.test.mjs | 26 ++++ scripts/check-claude-sdk-runtime.mjs | 2 +- .../internal/api/function_configuration.go | 5 +- services/agents-api/internal/api/handler.go | 2 +- .../internal/api/session_response.go | 15 ++ .../agents-api/internal/api/session_tools.go | 9 +- .../internal/api/tool_search_response_test.go | 21 +++ services/agents-api/internal/engine/claude.go | 15 ++ .../agents-api/internal/engine/profile.go | 1 + .../internal/execution/engine_profile.go | 10 +- .../internal/execution/functions.go | 6 +- .../internal/execution/functions_test.go | 2 +- services/agents-api/internal/execution/mcp.go | 22 ++- .../internal/execution/mcp_support_test.go | 2 +- .../agents-api/internal/execution/mcp_test.go | 2 +- .../agents-api/internal/execution/request.go | 4 +- .../agents-api/internal/execution/support.go | 6 +- .../internal/execution/tool_search_test.go | 56 +++++++ .../internal/store/tool_search_native_test.go | 116 +++++++++++++++ services/agents-api/tests/image_fixture.py | 16 ++ .../tests/official_message_images.py | 15 +- .../agents-api/tests/official_tool_search.py | 138 ++++++++++++++++++ 46 files changed, 719 insertions(+), 61 deletions(-) create mode 100644 contracts/agents-api/tool-search.md create mode 100644 packages/claude-sdk-adapter/src/tool_search.ts create mode 100644 packages/claude-sdk-adapter/tests/tool_search.test.mjs create mode 100644 services/agents-api/internal/api/tool_search_response_test.go create mode 100644 services/agents-api/internal/execution/tool_search_test.go create mode 100644 services/agents-api/internal/store/tool_search_native_test.go create mode 100644 services/agents-api/tests/image_fixture.py create mode 100644 services/agents-api/tests/official_tool_search.py diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c88868317..0f2addedc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,12 +120,15 @@ databases, credentials and migrations. The product uses Core exclusively; it has discovered during a task without switching work or automatically selecting them next. Only a direct acceptance blocker justifies a minimal in-scope fix. After each bounded task passes checks/review and merges, mark its child entry done. - Select large Core tasks in order from the concise TODO, then use the full board - to choose bounded children by value, dependencies, risk and effort. Finish the - selected large task before switching to the next one. The main agent selects - tasks; subagents are for technical design, scoped collaboration and review, - not prioritization. Completing a child or milestone does not stop an explicitly - active long-term goal. Product integration, UI and business Team work remain + Treat each selected concise Core TODO item as a delivery milestone. The long-term + Goal retains the objective, constraints and standards; detailed-board entries are + smaller tasks within that milestone. Batch related small tasks when they share a + functional outcome and safety boundary, then validate and independently review + the batch once stable. Do not manufacture one PR per internal wiring step. + Finish the selected milestone before choosing another. The main agent selects + tasks; subagents handle technical design, scoped collaboration and review, not + prioritization. Stop after the complete milestone when the user sets that boundary; + do not automatically claim the next one or mark the long-term objective complete. Product integration, UI and business Team work remain outside Core delivery. Prioritize a sound architecture skeleton and correct principal workflows with real API validation. Record and defer low-frequency corner cases when risk and ROI permit; do not let minor details @@ -1937,6 +1940,32 @@ foreign, ambiguous or metadata-only history rejects before model input. The Runt volume and shared Environment/Session binding establish ownership; this lookup cannot select another Session's home or infer ownership from a model response. +### Deferred function discovery + +Public `tool_search` and function `defer_loading` are shared Runtime intent. +Core preserves complete immutable definitions, sends `PromptRequestPayload.ToolSearch` +and each `FunctionTool.DeferLoading`, and requires the operation's existing profile +qualification plus the Runtime `tool_search` capability. It never performs native +search, selects native names, interprets provider policies or implements another +model/tool loop. Additional harnesses implement the same intent in their adapters. +The pinned Session AgentTool response union excludes the tool_search input member; +project it out of Session/SSE resources while preserving saved and frozen input. + +The bounded implementation targets Claude's single-agent `environment:none` +function profile, including qualified inline message images. The adapter explicitly enables native ToolSearch and sets +per-function MCP `anthropic/alwaysLoad` from the requested deferral flag. Ordinary +functions stay eager. Existing function callbacks, application receipts, cancellation +and cold continuation remain the only execution/result lifecycle. Runtime discovery +advertises this operation only with an installed bridge supporting `tool_search`. + +Known conflicting native provider modes and search/beta settings reject in the +adapter. The maintained native harness owns dynamic model/provider eligibility; +its SDK exposes no reliable pre-input receipt proving effective deferral after a +policy change. Do not represent tool inventory or an operator allowlist as that +proof. Record exact real model/provider evidence and this detection gap separately. +Search-only, missing-search, duplicate-search, workspace, MCP and Subagent combinations +remain unqualified. See [the operation coverage](contracts/agents-api/tool-search.md). + ### Structured output execution The public `text.format={type:"json_schema",schema:{...}}` is resolved with saved diff --git a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go index 0768159b6..01b975f44 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/execution_controls_test.go @@ -99,3 +99,26 @@ func TestStructuredOutputConfigurationReachesNativeUnchanged(t *testing.T) { t.Fatal("unqualified subagent combination accepted") } } + +func TestToolDiscoveryPreservesFrozenFunctionsAndRejectsOtherProfiles(t *testing.T) { + root := t.TempDir() + t.Setenv("PARSAR_HOME", root) + config := Config{Entrypoint: filepath.Join(root, "worker"), StateDir: filepath.Join(root, "state")} + request := proto.PromptRequestPayload{RunID: "run", Input: proto.TextInput("Original input."), DisableSubagents: true, ToolSearch: true, AgentOptions: map[string]any{"model": "model"}, FunctionTools: []proto.FunctionTool{ + {Name: "lookup", Description: "Lookup", Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"const":"original"}}}`), DeferLoading: true}, + {Name: "clock", Description: "Clock", Parameters: json.RawMessage(`{"type":"object"}`)}, + }} + start, _, err := prepare(config, request) + if err != nil || !start.ToolSearch || !reflect.DeepEqual(start.Functions, request.FunctionTools) { + t.Fatal("function discovery changed native definitions", err) + } + request.DisableSubagents = false + if _, _, err := prepare(config, request); err == nil { + t.Fatal("unqualified combination admitted") + } + request.DisableSubagents = true + request.ToolSearch = false + if _, _, err := prepare(config, request); err == nil { + t.Fatal("deferred definitions became eager") + } +} diff --git a/apps/parsar-daemon/internal/agent/claudesdk/options.go b/apps/parsar-daemon/internal/agent/claudesdk/options.go index 48368ee77..b0ad01366 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/options.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/options.go @@ -23,6 +23,7 @@ type subagentOptions struct { } type startRequest struct { + ToolSearch bool `json:"tool_search,omitempty"` Subagents *subagentOptions `json:"subagents,omitempty"` OutputFormat *proto.OutputFormat `json:"output_format,omitempty"` Type string `json:"type"` @@ -59,6 +60,15 @@ func prepareConfiguration(config Config, req proto.PromptRequestPayload) (startR if req.WorkspaceAuthoring || req.ObserveTools { return fail("requested capability is not available in the private SDK adapter") } + if err := req.ValidateToolSearch(true); err != nil { + return startRequest{}, nil, err + } + if req.ToolSearch { + if config.Workspace != nil || req.LocalEnvironment != nil || req.MCPHTTPServers != nil || !req.DisableSubagents || (req.ExecutionControls != nil && req.ExecutionControls.OutputFormat != nil) { + return fail("tool discovery requires the single-agent text/function profile") + } + start.ToolSearch = true + } if err := validateMCP(req); err != nil { return startRequest{}, nil, err } diff --git a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go index 9c08e1a39..f1852d0c4 100644 --- a/apps/parsar-daemon/internal/agent/claudesdk/readiness.go +++ b/apps/parsar-daemon/internal/agent/claudesdk/readiness.go @@ -29,6 +29,10 @@ func (info RuntimeInfo) SupportsMessageImages() bool { return slices.Contains(info.Features, "message_images") } +func (info RuntimeInfo) SupportsToolSearch() bool { + return slices.Contains(info.Features, "tool_search") +} + func (info RuntimeInfo) SupportsStructuredOutput() bool { return slices.Contains(info.Features, "structured_output") } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index e568a7a79..bbb9fe967 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -98,6 +98,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex } out.Info.Available, out.Info.Version = true, info.SDK out.Info.Capabilities.MessageImages = info.SupportsMessageImages() + out.Info.Capabilities.ToolSearch = out.Config.Workspace == nil && info.SupportsToolSearch() out.Info.Capabilities.StructuredOutput = out.Config.Workspace == nil && info.SupportsStructuredOutput() out.Info.Capabilities.SubagentObservations = info.SupportsSubagents() out.Info.Capabilities.MCPHTTPTools = info.SupportsHTTPMCP() diff --git a/apps/parsar-daemon/internal/dispatch/environment.go b/apps/parsar-daemon/internal/dispatch/environment.go index 6f78d5a7e..577d22b98 100644 --- a/apps/parsar-daemon/internal/dispatch/environment.go +++ b/apps/parsar-daemon/internal/dispatch/environment.go @@ -7,6 +7,9 @@ import ( ) func validateExecutionEnvironment(req proto.PromptRequestPayload, caps proto.AgentKindCapabilities) error { + if err := req.ValidateToolSearch(caps.ToolSearch); err != nil { + return err + } if req.LocalEnvironment != nil && (req.DisableExecutionEnvironment || !caps.LocalEnvironment) { return errors.New("engine does not support this local Environment configuration") } diff --git a/apps/parsar-daemon/internal/dispatch/functions_test.go b/apps/parsar-daemon/internal/dispatch/functions_test.go index e8af2f966..2acb2deb3 100644 --- a/apps/parsar-daemon/internal/dispatch/functions_test.go +++ b/apps/parsar-daemon/internal/dispatch/functions_test.go @@ -159,3 +159,20 @@ func functionResultContent(text string) []proto.InputContent { after := "AFTER-IMAGE" return []proto.InputContent{{Type: "input_text", Text: &text}, {Type: "input_image", ImageURL: &imageURL}, {Type: "input_text", Text: &after}} } + +func TestDiscoveryCannotReachAnEagerOnlyAdapter(t *testing.T) { + reg := agent.NewRegistry() + called := false + reg.RegisterKind(proto.SupportedAgentKind{Kind: "eager-only", Available: true, Capabilities: proto.AgentKindCapabilities{FunctionTools: true}}, func(context.Context, proto.PromptRequestPayload, chan<- proto.Envelope) (agent.Session, error) { + called = true + return nil, nil + }) + router, _ := dispatch.New(dispatch.Config{Registry: reg, Sender: &recSender{}}) + defer router.Shutdown(context.Background()) + for _, search := range []bool{false, true} { + env, _ := proto.NewEnvelope(proto.TypePromptRequest, "discovery", proto.PromptRequestPayload{AgentKind: "eager-only", ToolSearch: search, FunctionTools: []proto.FunctionTool{{Name: "lookup", Parameters: json.RawMessage(`{"type":"object"}`), DeferLoading: true}}}) + if err := router.Handle(t.Context(), env); err == nil || called { + t.Fatal("deferred definitions reached an eager-only adapter", err) + } + } +} diff --git a/contracts/agents-api/README.md b/contracts/agents-api/README.md index 9824f00eb..f7938b3e0 100644 --- a/contracts/agents-api/README.md +++ b/contracts/agents-api/README.md @@ -165,7 +165,7 @@ user-managed enrollment remain outside this qualification. | Subagents / multi_agent | Six reads and same-child recovery have three-harness Docker evidence; optional native operations, live child progress, full lifecycle/interactions and tool combinations remain explicit gaps | | Environment Templates | Unsupported restricted hostname forms, unqualified installation overrides/null network and exact hosted errors remain gaps. CRUD/list, files, env/setup/system/npm/Python, inline/referenced Skills, Plugins, workspace capability directories and Session references have recorded coverage. Environment Plugin MCP transport and placement limits are [listed separately](environment-templates.md#environment-origin-mcp-plugins) | | Input and configuration | Non-text initial input, broader content/configuration unions and reasoning/verbosity combinations; [structured output](structured-output.md) has a qualified Claude function profile, with other combinations remaining gaps | -| Tools and interactions | Deferred functions, other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | +| Tools and interactions | [Deferred discovery qualification](tool-search.md), other tool types, effective tool-set enforcement and result/cancel publication ordering; MiniMax public functions and service-origin MCP remain unsupported | | Vault and Credentials | OAuth/refresh, archive semantics, revocation/concurrent mutation and exact hosted selection/error behavior; static bearer CRUD/token replacement is already present | | Existing resources | Full Item/SSE/Usage variants, omitted/null/default/error semantics, pagination and overlapping lifecycle behavior beyond recorded cases | @@ -631,12 +631,13 @@ server validation define the supported alternatives. ### Public function configuration -Inline `agent.tools` accepts non-deferred `function` definitions with the upstream +Inline `agent.tools` accepts `function` definitions with the upstream required name, description and JSON Schema parameter object. Missing `defer_loading` resolves to `false`; null and other types are rejected. Omitted, null and empty tool lists resolve to an empty list. The resolved tools are part of the immutable Session configuration and creation retry identity. Saved-Agent -inheritance uses the same resolved tools. Deferred discovery, other tool kinds, +inheritance uses the same resolved tools. The bounded [deferred discovery path](tool-search.md) +adds type-only `tool_search` for its qualified profile. Other discovery combinations, other tool kinds, the native 64-definition cap and unique nonblank names of at most 512 bytes remain compatibility gaps. Claude SDK additionally requires object-root schemas and text-only results. Codex internal Goal/Skills/user-input/discovery semantics need diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index ec521b8cf..047a8412d 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -81,7 +81,7 @@ An engine without native tools can guarantee their absence; an engine with tools must actually disable them when requested. Configuration acceptance is not proof of enforcement. -MCP, public function calls, structured output, image inputs, verbosity controls and other optional +MCP, public function calls, deferred function discovery, structured output, image inputs, verbosity controls and other optional operations do not need to match another engine. Reject unqualified combinations explicitly and record the gap. Never advertise a capability to bypass selection. diff --git a/contracts/agents-api/harnesses.md b/contracts/agents-api/harnesses.md index 651baa4c6..62566f4e5 100644 --- a/contracts/agents-api/harnesses.md +++ b/contracts/agents-api/harnesses.md @@ -80,6 +80,7 @@ syntactically or everything either upstream harness can theoretically perform. | Non-default verbosity | Native/model-dependent support | No equivalent qualified; medium only | | Public detailed Usage | Supported native counters | Native raw usage retained; public breakdown gap | | V1 `self_hosted` daemon enrollment at `/workspace` | [Qualified deployment scope](user-managed-runtime-v1.md) | [Qualified deployment scope](user-managed-runtime-v1.md) | +| Deferred function discovery | Unqualified; explicit rejection | [Single-agent text/function profile](tool-search.md) | | Structured output | Unqualified; explicit rejection | [Qualified single-agent function profile](structured-output.md) | | Explicit reasoning, message images | Shared service gaps | Shared service gaps | | Six Subagent reads | [Qualified scope](subagents.md) | [Qualified scope](subagents.md) | diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index ef65eeb9b..060e63ef4 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -2720,7 +2720,11 @@ paths: Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default - updates cannot change committed Session Skill contents. + updates cannot change committed Session Skill contents. Deferred function + discovery uses type-only tool_search and per-function defer_loading in the + qualified single-agent Claude environment:none function profile, including + qualified inline image messages and text results. Other combinations remain + unqualified; see the operation coverage. parameters: - description: agents=v1 in: header diff --git a/contracts/agents-api/tool-search.md b/contracts/agents-api/tool-search.md new file mode 100644 index 000000000..1df4ea1d8 --- /dev/null +++ b/contracts/agents-api/tool-search.md @@ -0,0 +1,88 @@ +# Deferred function discovery + +Baseline: Python SDK 3.13.0, upstream +`d7c41efee1b0802b79f3f88a678ef2052b06e9ce`, Beta `agents=v1`. +This is bounded execution coverage, not complete Agents API compatibility. + +## Contract and boundary + +Saved and inline tools preserve the full function definition and `defer_loading` +(default false). The pinned Agents `tool_search` configuration has only `type`; +Responses-specific execution/parameter fields are not accepted here. The saved-Agent `PersistedAgentTool` union retains `tool_search`, while the +Session `AgentTool` response union omits it. Session/SSE resource projection follows +that pinned distinction; the full frozen configuration still includes it. Exact +hosted response behavior is unverified. The shared +Runtime carries search intent and each deferral flag. Native search and lazy schema +loading belong to the harness adapter. Core retains the frozen definitions and +existing public function calls, results and application receipts. The pinned Items +union contains no tool-search Item; do not invent one. + +The first implementation is Claude SDK 0.3.269 / native 2.1.269, single Agent, +`environment:none`, medium verbosity, object-root function schemas and text results. +It supports a mixture of eager and deferred application functions with text or +previously qualified inline PNG/JPEG message inputs. The native MCP +server marks eager definitions `anthropic/alwaysLoad:true`; deferred definitions use +false and the adapter explicitly enables native ToolSearch. Only declared callbacks +and ToolSearch are allowed. No search index, callback protocol, provider proxy or +model loop is added to production. + +Search-only, missing-search, duplicate-search, workspace, HTTP MCP, structured-output +and Subagent combinations remain unqualified. They are implementation/verification +gaps, not claimed upstream restrictions. Codex and MiniMax discovery remain gaps. +Unknown public combinations reject before execution; an actual Runtime must also +advertise the operation. An advertisement alone cannot qualify a public profile. + +## Evidence and limitations + +Native feasibility passed with Kimi `kimi-k3`: initial provider requests excluded +the deferred target schema; after a real native ToolSearch call that schema became +available, while an unrelated deferred schema stayed unloaded. The model used a +random required argument available only in that schema and returned the exact fresh +callback result. A new native process resumed the same native Session and called +it again with a new callback result. Existing discovered definitions remained in +that native history. Evidence: `~/.parsar/remediation/20260922/deferred-tools/claude-native-1790052750`. + +A first test proxy omitted response Content-Encoding and failed after discovery; +the failed evidence is retained. The corrected test forwards unchanged real provider +responses. The proxy is test observation only and is not part of Runtime. + +Codex source exposes deferred dynamic functions, but the available Kimi Responses +probe rejected native `tool_search`; the MiniMax sample did not produce discovery. +Neither establishes Codex qualification. These observations do not prove that all +models from either provider lack the feature. + +The native harness owns dynamic model/provider policy. Known conflicting modes and +beta/search settings reject in the adapter. Its SDK has no reliable pre-input signal +proving actual deferral after opaque policy changes; init tool inventory is insufficient. +That detection gap and other providers/models remain unverified. No endpoint/model +allowlist or copied native policy evaluator is added to imply a stronger guarantee. + +Public-chain real acceptance passed on 2026-09-22 with Kimi `kimi-k3`: +`tool-search-public-2315339108`, 97.83 seconds. The initial strict-SDK attempt +failed on the Session response union before any model request; that failure is +retained as `public-first.log`. Resource projection follows the fixed schema, +without relaxing SDK validation. Run `TestNativeToolSearchPublicExecution` +with the fixed official SDK, a real private model configuration, a real daemon and +PostgreSQL. It checks saved/inline configuration, mixed functions, result retries, +SSE/Items, cancellation, cold daemon continuation and tenant isolation. Independent review is required before release qualification. + +Shared Go race checks and 132 Claude SDK checks passed. The required browser gate +passed 73 cases locally with Node22 and real Chrome. The server has no browser; +the remaining `make check` targets passed on zju against a dedicated PostgreSQL. +No database query/schema changed, so explicit sqlc generation is not applicable; +the full gate still verifies generated queries. + +The image/discovery combination also passed through the same public chain in +`tool-search-public-2039155387` (`public-image-isolated-tests.log`). A random PNG +was submitted with the opening prompt and a different random PNG was submitted +while the deferred function waited. The answer identified the latest image's band +order and the fresh callback result; cancellation and cold continuation passed in +the same run. The existing PNG generator is shared with the image-input regression. +No new image admission restrictions or native lifecycle were needed. + +The full server gate is `make -o check-web check`, with `make check-web` completed +locally on the same changes. Packaging initially caught an outdated expected Runtime +feature list; it was updated and the full server gate rerun successfully. Image +acceptance uses a separate dedicated database from the full gate. Failed setup +attempts (execution-owner lock and test-database naming guard) are retained; neither +reached model execution. diff --git a/internal/agentdaemon/device/state.go b/internal/agentdaemon/device/state.go index 9fe2d9a12..6fa46797c 100644 --- a/internal/agentdaemon/device/state.go +++ b/internal/agentdaemon/device/state.go @@ -81,6 +81,7 @@ type KindCapabilities struct { ExecutionControls bool `json:"execution_controls,omitempty"` TextVerbosity bool `json:"text_verbosity,omitempty"` StructuredOutput bool `json:"structured_output,omitempty"` + ToolSearch bool `json:"tool_search,omitempty"` MessageImages bool `json:"message_images,omitempty"` SubagentControl bool `json:"subagent_control,omitempty"` FunctionTools bool `json:"function_tools,omitempty"` diff --git a/internal/agentdaemon/gateway/session.go b/internal/agentdaemon/gateway/session.go index e30fdb16d..d8b69adc9 100644 --- a/internal/agentdaemon/gateway/session.go +++ b/internal/agentdaemon/gateway/session.go @@ -561,6 +561,7 @@ func deviceKindsFromHeartbeat(p proto.HeartbeatPayload) []device.SupportedAgentK WebSearchControl: info.Capabilities.WebSearchControl, TextVerbosity: info.Capabilities.TextVerbosity, StructuredOutput: info.Capabilities.StructuredOutput, + ToolSearch: info.Capabilities.ToolSearch, MessageImages: info.Capabilities.MessageImages, ExecutionControls: info.Capabilities.ExecutionControls, SubagentControl: info.Capabilities.SubagentControl, diff --git a/internal/agentdaemon/proto/functions.go b/internal/agentdaemon/proto/functions.go index 0fd337959..b678c135c 100644 --- a/internal/agentdaemon/proto/functions.go +++ b/internal/agentdaemon/proto/functions.go @@ -11,9 +11,29 @@ const ( ) type FunctionTool struct { - Name string `json:"name"` - Description string `json:"description"` - Parameters json.RawMessage `json:"parameters"` + Name string `json:"name"` + Description string `json:"description"` + Parameters json.RawMessage `json:"parameters"` + DeferLoading bool `json:"defer_loading,omitempty"` +} + +// ValidateToolSearch checks only the requested function discovery operation. +// Native search configuration and discovery stay inside the adapter. +func (r PromptRequestPayload) ValidateToolSearch(supported bool) error { + deferred := false + for _, tool := range r.FunctionTools { + deferred = deferred || tool.DeferLoading + } + if !r.ToolSearch && !deferred { + return nil + } + if !supported { + return errors.New("engine does not support deferred function discovery") + } + if !r.ToolSearch || !deferred { + return errors.New("qualified discovery requires tool search and deferred functions") + } + return nil } // FunctionCallPayload belongs to the Run identified by Envelope.ID. diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index fc36e529c..af6a8ab5b 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -273,6 +273,7 @@ type AgentKindCapabilities struct { ExecutionControls bool `json:"execution_controls,omitempty"` TextVerbosity bool `json:"text_verbosity,omitempty"` StructuredOutput bool `json:"structured_output,omitempty"` + ToolSearch bool `json:"tool_search,omitempty"` MessageImages bool `json:"message_images,omitempty"` SubagentControl bool `json:"subagent_control,omitempty"` DurableInputReceipts bool `json:"durable_input_receipts,omitempty"` diff --git a/internal/agentdaemon/proto/outbound.go b/internal/agentdaemon/proto/outbound.go index b1e1a6e95..b60826df3 100644 --- a/internal/agentdaemon/proto/outbound.go +++ b/internal/agentdaemon/proto/outbound.go @@ -90,6 +90,7 @@ type PromptRequestPayload struct { ObserveToolObservations bool `json:"observe_tool_observations,omitempty"` ObserveSubagentIdentities bool `json:"observe_subagent_identities,omitempty"` FunctionTools []FunctionTool `json:"function_tools,omitempty"` + ToolSearch bool `json:"tool_search,omitempty"` DisableExecutionEnvironment bool `json:"disable_execution_environment,omitempty"` DisableSubagents bool `json:"disable_subagents,omitempty"` } diff --git a/internal/agentdaemon/proto/workspace_read_preparation.go b/internal/agentdaemon/proto/workspace_read_preparation.go index a451ff7b5..027e98b83 100644 --- a/internal/agentdaemon/proto/workspace_read_preparation.go +++ b/internal/agentdaemon/proto/workspace_read_preparation.go @@ -8,6 +8,6 @@ func ValidWorkspaceReadPreparation(r PromptRequestPayload) bool { r.ConversationID == "" && r.AgentSessionID == "" && r.WorkDir == "" && !r.RequireExistingNativeSession && !r.WorkspaceAuthoring && !r.DisableExecutionEnvironment && len(r.AgentOptions) == 0 && r.ExecutionControls == nil && r.MCPHTTPServers == nil && - len(r.FunctionTools) == 0 && !r.ObserveMessages && !r.ObserveTools && + len(r.FunctionTools) == 0 && !r.ToolSearch && !r.ObserveMessages && !r.ObserveTools && !r.ObserveToolObservations && !r.ObserveSubagentIdentities } diff --git a/packages/claude-sdk-adapter/src/adapter.ts b/packages/claude-sdk-adapter/src/adapter.ts index 8579673fb..0af8d9884 100644 --- a/packages/claude-sdk-adapter/src/adapter.ts +++ b/packages/claude-sdk-adapter/src/adapter.ts @@ -1,4 +1,5 @@ import { StructuredOutput } from "./structured_output.js"; +import { toolSearchEnvironment } from "./tool_search.js"; import { Subagents } from "./subagents.js"; import type { Fact } from "./subagent_history.js"; import { WorkspaceDirectories, type WorkspaceDirectoryEvent } from "./workspace_directories.js"; @@ -34,8 +35,9 @@ export type Event = | { type: "error"; code: "invalid_request" | "history_unavailable" | "execution_failed" | "cancelled" }; export async function execute(request: Start | Prepare, emit: (event: Event) => Promise, abort: AbortController, functions = new FunctionBridge(emit), inputs = new Inputs(immediateInput(request)), reads = new WorkspaceReads(emit, abort), directories = new WorkspaceDirectories(emit, abort)): Promise { - const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters })); + const definitions = (request.functions ?? []).map(tool => ({ name: tool.name, description: tool.description, inputSchema: tool.parameters, deferLoading: tool.defer_loading })); const names = definitions.map(tool => `mcp__functions__${tool.name}`); + const allowed = [...names, ...(request.tool_search ? ["ToolSearch"] : [])]; const declarations = request.workspace?.mcp ?? request.mcp_http_servers; const profile = declarations === undefined ? undefined : new MCPProfile(declarations, names); const subagents = request.subagents ? new Subagents(request.cwd, request.subagents.max_concurrent, request.resume) : undefined; @@ -77,12 +79,12 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => if (abort.signal.aborted) throw new Error("cancelled"); const options: Options = { cwd: request.cwd, - env: workspace?.options.env ?? { ...process.env, ...(subagents ? { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } : {}) }, + env: request.tool_search ? toolSearchEnvironment(process.env, request.model) : workspace?.options.env ?? { ...process.env, ...(subagents ? { CLAUDE_CODE_DISABLE_BACKGROUND_TASKS: "1" } : {}) }, model: request.model, ...(request.output_format ? { outputFormat: request.output_format } : {}), systemPrompt: request.system_prompt, ...(request.resume ? { resume: request.resume } : {}), - tools: subagents ? ["Agent", "SendMessage"] : [], allowedTools: profile?.allowed ?? names, strictMcpConfig: true, settingSources: [], + tools: subagents ? ["Agent", "SendMessage"] : request.tool_search ? ["ToolSearch"] : [], allowedTools: profile?.allowed ?? allowed, strictMcpConfig: true, settingSources: [], ...(profile && !workspace ? { agent: "parsar_root", disallowedTools: profile.denied, hooks: { PreToolUse: [{ hooks: [profile.beforeTool] }] }, @@ -141,7 +143,7 @@ export async function execute(request: Start | Prepare, emit: (event: Event) => if (!nativeID || (request.resume && nativeID !== request.resume)) throw new Error("unexpected native session"); if (workspace) workspace.verify(message.tools, profile ? await stream.mcpServerStatus() : message.mcp_servers, nativeID); else if (profile) profile.verify(message.tools, await stream.mcpServerStatus(), nativeID); - else if (message.tools.length !== names.length + (subagents ? 2 : 0) + (structured ? 1 : 0) || message.tools.some(name => ![...names, ...(subagents ? ["Task", "SendMessage"] : []), ...(structured ? ["StructuredOutput"] : [])].includes(name)) || + else if (message.tools.length !== allowed.length + (subagents ? 2 : 0) + (structured ? 1 : 0) || message.tools.some(name => ![...allowed, ...(subagents ? ["Task", "SendMessage"] : []), ...(structured ? ["StructuredOutput"] : [])].includes(name)) || message.mcp_servers.length !== (definitions.length ? 1 : 0) || message.mcp_servers.some(server => server.name !== "functions" || server.status !== "connected")) { throw new Error("unexpected native configuration"); diff --git a/packages/claude-sdk-adapter/src/functions.ts b/packages/claude-sdk-adapter/src/functions.ts index 86c14ffae..791274dff 100644 --- a/packages/claude-sdk-adapter/src/functions.ts +++ b/packages/claude-sdk-adapter/src/functions.ts @@ -8,7 +8,7 @@ import { export type FunctionCall = { id: string; name: string; arguments: Record }; export type FunctionHandler = (call: FunctionCall, signal: AbortSignal) => Promise; -export function createFunctionServer(definitions: Tool[], invoke: FunctionHandler): McpSdkServerConfigWithInstance { +export function createFunctionServer(definitions: (Tool & { deferLoading?: boolean })[], invoke: FunctionHandler): McpSdkServerConfigWithInstance { const tools = structuredClone(definitions); const names = new Set(); for (const tool of tools) { @@ -17,7 +17,7 @@ export function createFunctionServer(definitions: Tool[], invoke: FunctionHandle } const instance = new McpServer({ name: "functions", version: "1.0.0" }, { capabilities: { tools: {} } }); instance.server.setRequestHandler(ListToolsRequestSchema, async () => ({ - tools: tools.map(tool => ({ ...tool, _meta: { ...tool._meta, "anthropic/alwaysLoad": true } })), + tools: tools.map(({ deferLoading, ...tool }) => ({ ...tool, _meta: { ...tool._meta, "anthropic/alwaysLoad": !deferLoading } })), })); instance.server.setRequestHandler(CallToolRequestSchema, async (request, extra) => { if (!names.has(request.params.name)) throw new McpError(ErrorCode.InvalidParams, "Unknown function."); diff --git a/packages/claude-sdk-adapter/src/request.ts b/packages/claude-sdk-adapter/src/request.ts index 9ea894c67..f8105a391 100644 --- a/packages/claude-sdk-adapter/src/request.ts +++ b/packages/claude-sdk-adapter/src/request.ts @@ -15,7 +15,8 @@ export type Start = { require_history?: boolean; observe_messages?: boolean; subagents?: { max_concurrent: number }; - functions?: { name: string; description: string; parameters: Tool["inputSchema"] }[]; + tool_search?: boolean; + functions?: { name: string; description: string; parameters: Tool["inputSchema"]; defer_loading?: boolean }[]; mcp_http_servers?: HTTPServer[]; workspace?: Workspace; }; @@ -30,7 +31,7 @@ export function parseRequest(line: string): Start | Prepare { const value: unknown = JSON.parse(line); if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_request"); const request = value as Record; - const allowed = new Set(["type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "mcp_http_servers", "workspace"]); + const allowed = new Set(["type", "input", "model", "system_prompt", "cwd", "resume", "require_history", "observe_messages", "output_format", "subagents", "functions", "tool_search", "mcp_http_servers", "workspace"]); if (Object.keys(request).some(key => !allowed.has(key)) || (request.type !== "start" && request.type !== "prepare") || (request.type === "start" ? !Array.isArray(request.input) : "input" in request) || @@ -42,7 +43,12 @@ export function parseRequest(line: string): Start | Prepare { (request.resume !== undefined && (typeof request.resume !== "string" || !request.resume))) throw new Error("invalid_request"); if (request.functions !== undefined && (!Array.isArray(request.functions) || request.functions.some(tool => !tool || typeof tool.name !== "string" || !tool.name || typeof tool.description !== "string" || + (tool.defer_loading !== undefined && typeof tool.defer_loading !== "boolean") || !tool.parameters || tool.parameters.type !== "object"))) throw new Error("invalid_request"); + const deferred = (request.functions as Start["functions"])?.some(tool => tool.defer_loading) ?? false; + if ((request.tool_search !== undefined && typeof request.tool_search !== "boolean") || + (!!request.tool_search !== deferred) || + (request.tool_search && (request.subagents || request.workspace || request.mcp_http_servers !== undefined || request.output_format))) throw new Error("invalid_request"); if (request.subagents !== undefined) { const value = request.subagents as Record; if (!value || typeof value !== "object" || Object.keys(value).length !== 1 || !Number.isSafeInteger(value.max_concurrent) || (value.max_concurrent as number) < 1 || diff --git a/packages/claude-sdk-adapter/src/runtime_check.ts b/packages/claude-sdk-adapter/src/runtime_check.ts index 51b083e9e..ad2463b27 100644 --- a/packages/claude-sdk-adapter/src/runtime_check.ts +++ b/packages/claude-sdk-adapter/src/runtime_check.ts @@ -45,7 +45,7 @@ try { assert.equal(smoke.error, undefined, "bridge_unavailable"); assert.equal(smoke.status, 0, "bridge_unavailable"); assert.deepEqual(JSON.parse(smoke.stdout), { type: "error", code: "invalid_request" }); - process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 2, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "message_images", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); + process.stdout.write(JSON.stringify({ type: "runtime_ready", protocol: 2, features: [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "message_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"], node: process.versions.node, sdk: sdk.version, mcp: mcp.version, native: nativeVersion }) + "\n"); } catch { // Native diagnostics can include operator environment; never forward them. process.stdout.write(JSON.stringify({ type: "runtime_unavailable" }) + "\n"); diff --git a/packages/claude-sdk-adapter/src/tool_search.ts b/packages/claude-sdk-adapter/src/tool_search.ts new file mode 100644 index 000000000..d95d4f6ff --- /dev/null +++ b/packages/claude-sdk-adapter/src/tool_search.ts @@ -0,0 +1,12 @@ +// The native harness owns discovery, schema loading and its model/provider policy. +// Reject known conflicting modes, without copying its dynamic policy evaluator. +export function toolSearchEnvironment(env: NodeJS.ProcessEnv, model: string): NodeJS.ProcessEnv { + const enabled = (value: string | undefined) => !!value && !["0", "false"].includes(value.toLowerCase()); + if (["CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_USE_FOUNDRY", + "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"].some(key => enabled(env[key])) || + (env.ENABLE_TOOL_SEARCH && env.ENABLE_TOOL_SEARCH !== "true") || + ["claude-3-haiku", "claude-3-5-haiku"].some(name => model.toLowerCase().includes(name))) { + throw new Error("unqualified native tool search configuration"); + } + return { ...env, ENABLE_TOOL_SEARCH: "true" }; +} diff --git a/packages/claude-sdk-adapter/tests/functions.test.mjs b/packages/claude-sdk-adapter/tests/functions.test.mjs index 41aa68743..01384e276 100644 --- a/packages/claude-sdk-adapter/tests/functions.test.mjs +++ b/packages/claude-sdk-adapter/tests/functions.test.mjs @@ -38,6 +38,22 @@ test("official MCP client receives a stable lossless schema snapshot", { timeout }); }); +test("MCP discovery preserves mixed eager and deferred loading without changing callbacks", { timeout: 5000 }, async t => { + const deferred = { ...definition(), deferLoading: true }; + const eager = { ...definition(), name: "eager" }; + const calls = []; + const client = await connect(t, [deferred, eager], async request => { calls.push(request); return {content:[]}; }); + deferred.deferLoading = false; + const listed = (await client.listTools()).tools; + assert.equal(listed[0]._meta["anthropic/alwaysLoad"], false); + assert.equal(listed[1]._meta["anthropic/alwaysLoad"], true); + assert.deepEqual(listed[0].inputSchema, schema); + assert.equal("deferLoading" in listed[0], false); + await client.callTool(call("native-deferred")); + assert.equal(calls[0].id, "native-deferred"); + assert.equal(calls[0].name, "lookup"); +}); + test("identical concurrent calls retain native identity and ordered success/error content", { timeout: 5000 }, async t => { const pending = new Map(); let bothStarted; diff --git a/packages/claude-sdk-adapter/tests/tool_search.test.mjs b/packages/claude-sdk-adapter/tests/tool_search.test.mjs new file mode 100644 index 000000000..7e184f247 --- /dev/null +++ b/packages/claude-sdk-adapter/tests/tool_search.test.mjs @@ -0,0 +1,26 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseStart } from "../dist/request.js"; +import { toolSearchEnvironment } from "../dist/tool_search.js"; + +const request = () => ({type:"start", input:[{content:[{type:"input_text",text:"lookup"}]}], model:"model", system_prompt:"", cwd:process.cwd(), + tool_search:true, functions:[{name:"lookup",description:"Lookup",parameters:{type:"object"},defer_loading:true}]}); + +test("discovery preserves intent and rejects unqualified native combinations", () => { + assert.deepEqual(parseStart(JSON.stringify(request())), request()); + for (const patch of [{tool_search:false}, {tool_search:null}, {output_format:{type:"json_schema",schema:{type:"object"}}}, + {mcp_http_servers:[]}, {subagents:{max_concurrent:2}}, {functions:[{...request().functions[0],defer_loading:false}]}]) { + assert.throws(() => parseStart(JSON.stringify({...request(),...patch})), /invalid_request/); + } +}); + +test("native search uses explicit mode without overriding conflicting operator settings", () => { + const env = {ANTHROPIC_BASE_URL:"https://example.test/anthropic", RETAIN:"yes"}; + assert.deepEqual(toolSearchEnvironment(env,"model"), {...env, ENABLE_TOOL_SEARCH:"true"}); + assert.equal(env.ENABLE_TOOL_SEARCH,undefined); + for (const patch of [{ENABLE_TOOL_SEARCH:"false"},{ENABLE_TOOL_SEARCH:"force"},{ENABLE_TOOL_SEARCH:"auto"}, + {CLAUDE_CODE_USE_BEDROCK:"1"},{CLAUDE_CODE_USE_VERTEX:"1"},{CLAUDE_CODE_USE_FOUNDRY:"1"},{CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS:"true"}]) { + assert.throws(() => toolSearchEnvironment({...env,...patch},"model"), /unqualified/); + } + assert.throws(() => toolSearchEnvironment(env,"claude-3-5-haiku-latest"), /unqualified/); +}); diff --git a/scripts/check-claude-sdk-runtime.mjs b/scripts/check-claude-sdk-runtime.mjs index c3beb0bf9..115f19533 100644 --- a/scripts/check-claude-sdk-runtime.mjs +++ b/scripts/check-claude-sdk-runtime.mjs @@ -23,7 +23,7 @@ assert.equal(probe.status, 0, "Exported runtime is unavailable"); const report = JSON.parse(probe.stdout); assert.equal(report.type, "runtime_ready"); assert.equal(report.protocol, 2); -assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "message_images", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); +assert.deepEqual(report.features, [...(process.platform === "linux" ? ["workspace_directory", "local_runtime_v1", "workspace_functions"] : []), "message_images", "tool_search", "structured_output", "subagent_resources", "mcp_http_tools", "mcp_http_bearer_auth", "mcp_http_required", "workspace_tools", "workspace_prepare", "workspace_read", "workspace_command_observations"]); assert.equal(report.sdk, source.dependencies["@anthropic-ai/claude-agent-sdk"]); assert.equal(report.mcp, source.dependencies["@modelcontextprotocol/sdk"]); console.log(`Verified exported SDK ${report.sdk}, MCP ${report.mcp}, ${report.native}`); diff --git a/services/agents-api/internal/api/function_configuration.go b/services/agents-api/internal/api/function_configuration.go index 2e91e5238..3872bf551 100644 --- a/services/agents-api/internal/api/function_configuration.go +++ b/services/agents-api/internal/api/function_configuration.go @@ -16,16 +16,13 @@ func resolveFunctions(input []v1.FunctionToolInput) ([]json.RawMessage, error) { } names := make(map[string]bool, len(input)) for _, tool := range input { - value, deferred, err := resolveFunction(tool) + value, _, err := resolveFunction(tool) if err != nil { return nil, err } if strings.TrimSpace(*tool.Name) == "" || len(*tool.Name) > 512 || names[*tool.Name] { return nil, errors.New("Function names must be nonempty, unique and at most 512 bytes.") } - if deferred { - return nil, errors.New("Deferred function discovery is not supported by this service yet.") - } names[*tool.Name] = true tools = append(tools, value) diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index cc1d3577b..6ff481e6b 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -124,7 +124,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... // createSession atomically reserves or admits initial text with the Session. // @Summary Create an execution Session -// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Claude SDK environment:none supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions; other combinations remain unsupported. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. +// @Description Supports inline configuration or a tenant-owned saved agent_id with per-Session field replacements. Execution supports model/instructions, text verbosity, non-deferred function tools, adapter-qualified multi_agent with persisted Subagent reads, implicit reasoning, service tier auto and environment type none, subject to the configured engine. Codex additionally supports HTTP MCP with explicit service origin, native allowed_tools and boolean required defaulting to false. Session vault_ids attach only project-owned Vaults; credential_id selects an attached static bearer credential for the exact HTTPS URL, while null/omission selects a unique match or remains anonymous. Ambiguous selection rejects creation. Frozen private selections never populate an omitted public credential_id; missing decryption configuration fails dispatch without anonymous fallback. Required initialization uses native startup before the first native Turn, including cold resume, and requires a separately advertised capability; exact hosted creation timing and error parity remain unverified. Other MCP origins and OAuth remain unsupported. The self_hosted profile requires Codex, an absolute workspace_directory and empty capability_directories, with optional non-deferred function tools and HTTP MCP using explicit service origin, optionally authenticated by the attached Vault rules. Remote MCP and remote Bearer authentication each require separately advertised combination support; old peers cannot receive unsupported work. Omitted/null capability_directories use the empty-list default; self_hosted requires configured execution plus executor registry. Claude SDK currently requires medium verbosity and object-root function schemas. It supports anonymous or attached static-bearer service-origin HTTP MCP on none with boolean required and separately advertised MCP/bearer/required runtime support. Required servers must be connected before the first native input is released; pending or failed startup rejects execution. The shared Vault selection and immutable binding rules apply; unsupported native labels/tool names reject before persistence. An attached Vault with no matching credential may remain anonymous; missing keys or failed credential lookup/decryption never fall back to anonymous execution. Omitted stream defaults to false; stream and agent_id cannot be null. Metadata may be null, but its values must be strings. Initial input accepts a string or ordered user-message array. Codex and Claude SDK on none also accept inline PNG/JPEG image content; other image combinations and remote URLs are unsupported. None initial input atomically starts a Turn; self_hosted initial input is reserved while returning its Environment connection target, with execution deferred to native readiness and Session failure on initial timeout. Omitted or null input creates an idle Session. With stream=true, returns live Session events starting at creation; disconnect does not cancel execution. New Sessions retain their authenticated creator; all creation retries require the same typed subject, including across key rotation. Saved-Agent retries and inline requests using Vault attachments or credential references retain caller intent independently of later resource changes; unrelated inline retries preserve resolved/default equivalences. Unknown historical creators reject retries; known creators without recorded intent retain resolved-snapshot retry rules. These conflict policies are local and not verified hosted parity. Creation retries observe future events without replay; retry with stream=false to retrieve the Session. Claude SDK environment:none supports qualified object-root json_schema output with medium verbosity, single-Agent execution and ordinary functions; other combinations remain unsupported. Non-text initial input remains unsupported. Basic Codex and Claude SDK openai_hosted creation requires an explicitly configured managed provider. The Claude workspace profile supports non-deferred function tools with text results alongside native workspace tools; HTTP MCP remains unsupported. Idle Sessions provision automatically; initial provisioning has no caller connection action. Network defaults to enabled; disabled and restricted exact ASCII hostnames are supported. Restricted policy requires 1–100 allowed domains. Unsupported hostname forms and startup installations are rejected. Confidential env, system/npm/Python packages and ordered setup commands use the shared initialization lifecycle; requested network applies after setup. Initial inline and tenant-owned file_id files freeze encrypted bytes before provisioning, then install through the common Core lifecycle before native execution or live Files access. Referenced files/env/packages/setup overrides are rejected pending semantic verification. Tenant-owned environment_template_id references inherit omitted network and allow only narrowing overrides. Referenced network:null is explicitly unsupported pending semantic verification. Core freezes effective configuration; template updates/deletion do not alter Session snapshots or same-intent creation retries. Inline or tenant-owned skill_reference Skills share initialization. Templates preserve default/latest/explicit selectors; Session creation freezes concrete metadata and encrypted content atomically. Skill-list omission inherits and a supplied list replaces; null overrides and null version selectors remain unqualified and reject. Source deletion/default updates cannot change committed Session Skill contents. Deferred function discovery uses type-only tool_search and per-function defer_loading in the qualified single-agent Claude environment:none function profile, including qualified inline image messages and text results. Other combinations remain unqualified; see the operation coverage. // @Tags Sessions // @Accept json // @Produce json,text/event-stream diff --git a/services/agents-api/internal/api/session_response.go b/services/agents-api/internal/api/session_response.go index 810d0ebaa..0d1eb70c3 100644 --- a/services/agents-api/internal/api/session_response.go +++ b/services/agents-api/internal/api/session_response.go @@ -21,6 +21,21 @@ func sessionResponse(session store.Session, executorURL string) (v1.Session, err if cfg.Agent.XAgentsCore != nil && session.Engine != "" { cfg.Agent.XAgentsCore = &v1.AgentsCore{Harness: session.Engine} } + // The pinned Session AgentTool resource union excludes the tool_search + // input declaration. Retain it in saved Agents and frozen execution input. + tools := make([]json.RawMessage, 0, len(cfg.Agent.Tools)) + for _, raw := range cfg.Agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return v1.Session{}, errors.New("unsupported stored tool configuration") + } + if tool.Type != "tool_search" { + tools = append(tools, raw) + } + } + cfg.Agent.Tools = tools environment, err := sessionEnvironment(session, cfg.Environment.Type, executorURL) if err != nil { return v1.Session{}, err diff --git a/services/agents-api/internal/api/session_tools.go b/services/agents-api/internal/api/session_tools.go index 9f33f6d09..7a5fade4f 100644 --- a/services/agents-api/internal/api/session_tools.go +++ b/services/agents-api/internal/api/session_tools.go @@ -12,6 +12,7 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { functions := make([]v1.FunctionToolInput, 0, len(input)) positions := make([]int, 0, len(input)) servers := map[string]bool{} + search := false for i, raw := range input { var kind struct { Type string `json:"type"` @@ -20,6 +21,12 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { return nil, errors.New("Invalid execution tool configuration.") } switch kind.Type { + case "tool_search": + if search || decodeInputObject(raw, &kind, "type") != nil { + return nil, errors.New("Execution requires one type-only tool_search declaration.") + } + search = true + tools[i], _ = json.Marshal(kind) case "mcp": resolved, err := resolveMCPTool(raw, false) if err != nil { @@ -39,7 +46,7 @@ func resolveSessionTools(input []json.RawMessage) ([]json.RawMessage, error) { functions = append(functions, function) positions = append(positions, i) default: - return nil, errors.New("Execution currently supports non-deferred functions and the service-origin HTTP MCP profile only.") + return nil, errors.New("Execution currently supports functions, tool_search and the service-origin HTTP MCP profile only.") } } resolved, err := resolveFunctions(functions) diff --git a/services/agents-api/internal/api/tool_search_response_test.go b/services/agents-api/internal/api/tool_search_response_test.go new file mode 100644 index 000000000..2ae331a52 --- /dev/null +++ b/services/agents-api/internal/api/tool_search_response_test.go @@ -0,0 +1,21 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestDiscoveryResourceProjectionRetainsExecutionConfiguration(t *testing.T) { + raw := json.RawMessage(`{"agent":{"id":"agent","model":"model","tools":[{"type":"tool_search"},{"type":"function","name":"lookup","description":"Lookup","parameters":{"type":"object"},"defer_loading":true}]},"environment":{"type":"none"}}`) + session := store.Session{ID: "session", Configuration: raw} + resource, err := sessionResponse(session, "") + if err != nil || len(resource.Agent.Tools) != 1 || !strings.Contains(string(resource.Agent.Tools[0]), `"defer_loading":true`) { + t.Fatal(resource.Agent.Tools, err) + } + if !strings.Contains(string(session.Configuration), `"type":"tool_search"`) { + t.Fatal("public projection changed frozen execution configuration") + } +} diff --git a/services/agents-api/internal/engine/claude.go b/services/agents-api/internal/engine/claude.go index 7143c5d87..d9f474e46 100644 --- a/services/agents-api/internal/engine/claude.go +++ b/services/agents-api/internal/engine/claude.go @@ -12,6 +12,7 @@ import ( func claudeProfile() Profile { return Profile{ StructuredOutput: true, + ToolSearch: true, MessageImagePlacements: []string{"none"}, Placements: []string{"none", "openai_hosted", "self_hosted"}, MCPBearer: true, ValidateConfiguration: validateClaudeConfiguration, @@ -55,6 +56,20 @@ func validateClaudeConfiguration(agent v1.Agent, environment *v1.Environment, ha } else if agent.Text.Format.Type != "" && agent.Text.Format.Type != "text" { return ErrInvalidInput } + search, otherTools := false, false + for _, raw := range agent.Tools { + var tool struct { + Type string `json:"type"` + } + if json.Unmarshal(raw, &tool) != nil { + return ErrInvalidInput + } + search = search || tool.Type == "tool_search" + otherTools = otherTools || (tool.Type != "function" && tool.Type != "tool_search") + } + if search && (environment.Type != "none" || agent.MultiAgent.Enabled || otherTools || agent.Text.Format.Type == "json_schema") { + return errors.New("Tool discovery currently requires a single-agent environment:none function profile.") + } return rejectSubagentTools(agent, "function", "mcp") } diff --git a/services/agents-api/internal/engine/profile.go b/services/agents-api/internal/engine/profile.go index 8f19929aa..873012653 100644 --- a/services/agents-api/internal/engine/profile.go +++ b/services/agents-api/internal/engine/profile.go @@ -15,6 +15,7 @@ type Profile struct { Placements []string WebSearchControl, TextVerbosity, MCPBearer bool StructuredOutput bool + ToolSearch bool MessageImagePlacements []string ValidateConfiguration func(agent v1.Agent, environment *v1.Environment, hasDaemon bool) error ValidateTools func(environment *v1.Environment, hasDaemon bool, functions []proto.FunctionTool, mcp []proto.MCPHTTPServer) error diff --git a/services/agents-api/internal/execution/engine_profile.go b/services/agents-api/internal/execution/engine_profile.go index 604301a1f..7204fd995 100644 --- a/services/agents-api/internal/execution/engine_profile.go +++ b/services/agents-api/internal/execution/engine_profile.go @@ -4,6 +4,8 @@ import ( "encoding/json" "errors" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -24,11 +26,17 @@ func validateProfileConfiguration(profile engine.Profile, snapshot Snapshot) err return profileError(err) } } - functions, mcp, err := executionTools(snapshot.Agent.Tools) + functions, mcp, search, err := executionTools(snapshot.Agent.Tools) // Preserve each profile's admission error precedence when tool decoding fails. if profile.ValidateConfiguration != nil && err != nil { return err } + if err == nil { + request := proto.PromptRequestPayload{ToolSearch: search, FunctionTools: functions} + if err := request.ValidateToolSearch(profile.ToolSearch); err != nil { + return err + } + } if profile.ValidateTools != nil { if validationErr := profile.ValidateTools(snapshot.Environment, snapshot.Daemon != nil, functions, mcp); validationErr != nil { return profileError(validationErr) diff --git a/services/agents-api/internal/execution/functions.go b/services/agents-api/internal/execution/functions.go index 8c8e5dbb8..83f316bca 100644 --- a/services/agents-api/internal/execution/functions.go +++ b/services/agents-api/internal/execution/functions.go @@ -34,11 +34,11 @@ func functionTools(raw []json.RawMessage) ([]proto.FunctionTool, error) { return nil, err } var schema map[string]json.RawMessage - if tool.Type != "function" || tool.DeferLoading || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { - return nil, errors.New("execution requires unique non-deferred functions with object schemas") + if tool.Type != "function" || strings.TrimSpace(tool.Name) == "" || len(tool.Name) > 512 || names[tool.Name] || json.Unmarshal(tool.Parameters, &schema) != nil || schema == nil { + return nil, errors.New("execution requires unique functions with object schemas") } names[tool.Name] = true - tools = append(tools, proto.FunctionTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters}) + tools = append(tools, proto.FunctionTool{Name: tool.Name, Description: tool.Description, Parameters: tool.Parameters, DeferLoading: tool.DeferLoading}) } return tools, nil } diff --git a/services/agents-api/internal/execution/functions_test.go b/services/agents-api/internal/execution/functions_test.go index f16805ffc..5985ef0bf 100644 --- a/services/agents-api/internal/execution/functions_test.go +++ b/services/agents-api/internal/execution/functions_test.go @@ -15,7 +15,7 @@ func TestFunctionDefinitionsRejectUnsupportedConfiguration(t *testing.T) { if err != nil || len(tools) != 1 || tools[0].Name != "lookup" || tools[0].Description != "Find it" { t.Fatal(tools, err) } - for _, raw := range []string{`{"type":"mcp"}`, `{"type":"function","name":"lookup","parameters":null}`, `{"type":"function","name":"lookup","parameters":{},"defer_loading":true}`, `{"type":"function","name":"lookup","parameters":{},"unknown":true}`} { + for _, raw := range []string{`{"type":"mcp"}`, `{"type":"function","name":"lookup","parameters":null}`, `{"type":"function","name":"lookup","parameters":{},"unknown":true}`} { if _, err := functionTools([]json.RawMessage{json.RawMessage(raw)}); err == nil { t.Fatal("unsupported configuration admitted", raw) } diff --git a/services/agents-api/internal/execution/mcp.go b/services/agents-api/internal/execution/mcp.go index 0e9c97d09..752e0b9aa 100644 --- a/services/agents-api/internal/execution/mcp.go +++ b/services/agents-api/internal/execution/mcp.go @@ -11,16 +11,26 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func executionTools(raw []json.RawMessage) ([]proto.FunctionTool, []proto.MCPHTTPServer, error) { +func executionTools(raw []json.RawMessage) ([]proto.FunctionTool, []proto.MCPHTTPServer, bool, error) { functions := make([]json.RawMessage, 0, len(raw)) var servers []proto.MCPHTTPServer + search := false names := map[string]bool{} for _, value := range raw { var kind struct { Type string `json:"type"` } if json.Unmarshal(value, &kind) != nil { - return nil, nil, errors.New("invalid execution tool") + return nil, nil, false, errors.New("invalid execution tool") + } + if kind.Type == "tool_search" { + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + if decoder.Decode(&kind) != nil || search { + return nil, nil, false, errors.New("execution requires one type-only tool_search declaration") + } + search = true + continue } if kind.Type != "mcp" { functions = append(functions, value) @@ -30,16 +40,16 @@ func executionTools(raw []json.RawMessage) ([]proto.FunctionTool, []proto.MCPHTT decoder := json.NewDecoder(bytes.NewReader(value)) decoder.DisallowUnknownFields() if decoder.Decode(&tool) != nil || strings.TrimSpace(tool.ServerLabel) == "" || names[tool.ServerLabel] || tool.ConnectionOrigin != "service" || len(tool.RequestMetadata) != 0 || tool.Transport.Type != "http" || tool.Transport.Headers != nil { - return nil, nil, errors.New("unsupported execution MCP configuration") + return nil, nil, false, errors.New("unsupported execution MCP configuration") } u, err := url.Parse(tool.Transport.ServerURL) if err != nil || u.Hostname() == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.Fragment != "" || u.RawQuery != "" || u.ForceQuery { - return nil, nil, errors.New("unsupported execution MCP URL") + return nil, nil, false, errors.New("unsupported execution MCP URL") } if tool.AllowedTools != nil { for _, name := range *tool.AllowedTools { if name == "" { - return nil, nil, errors.New("invalid execution MCP tool name") + return nil, nil, false, errors.New("invalid execution MCP tool name") } } } @@ -48,5 +58,5 @@ func executionTools(raw []json.RawMessage) ([]proto.FunctionTool, []proto.MCPHTT ServerURL: tool.Transport.ServerURL, AllowedTools: tool.AllowedTools, Required: tool.Required}) } resolved, err := functionTools(functions) - return resolved, servers, err + return resolved, servers, search, err } diff --git a/services/agents-api/internal/execution/mcp_support_test.go b/services/agents-api/internal/execution/mcp_support_test.go index 884cae103..7cf9af17f 100644 --- a/services/agents-api/internal/execution/mcp_support_test.go +++ b/services/agents-api/internal/execution/mcp_support_test.go @@ -17,7 +17,7 @@ func mcpSupportFixture(t *testing.T) (Snapshot, []proto.MCPHTTPServer, device.Ki tool := json.RawMessage(`{"type":"mcp","server_label":"tickets","connection_origin":"service","transport":{"type":"http","server_url":"https://mcp.example/tools"}}`) snapshot := Snapshot{Agent: v1.Agent{Model: "model", Tools: []json.RawMessage{tool}}, Environment: &v1.Environment{Type: "none"}, VaultIDs: []string{vault}, MCPCredentials: []store.MCPCredentialBinding{{ServerLabel: "tickets", ServerURL: "https://mcp.example/tools", VaultID: vault, CredentialID: credential, AuthType: "static_bearer"}}} - _, servers, err := executionTools(snapshot.Agent.Tools) + _, servers, _, err := executionTools(snapshot.Agent.Tools) if err != nil { t.Fatal(err) } diff --git a/services/agents-api/internal/execution/mcp_test.go b/services/agents-api/internal/execution/mcp_test.go index bf3df9bf4..aba22efeb 100644 --- a/services/agents-api/internal/execution/mcp_test.go +++ b/services/agents-api/internal/execution/mcp_test.go @@ -27,7 +27,7 @@ func TestMCPRequiresSupportedServicePlacement(t *testing.T) { } } function := json.RawMessage(`{"type":"function","name":"lookup","description":"Read","parameters":{"type":"object"},"defer_loading":false}`) - functions, servers, err := executionTools([]json.RawMessage{tool, function}) + functions, servers, _, err := executionTools([]json.RawMessage{tool, function}) if err != nil || len(functions) != 1 || functions[0].Name != "lookup" || len(servers) != 1 || servers[0].AllowedTools == nil || len(*servers[0].AllowedTools) != 0 { t.Fatal("mixed tool configuration lost the deny-all declaration", functions, servers, err) } diff --git a/services/agents-api/internal/execution/request.go b/services/agents-api/internal/execution/request.go index 2c5ae7286..e12b7d1f4 100644 --- a/services/agents-api/internal/execution/request.go +++ b/services/agents-api/internal/execution/request.go @@ -15,7 +15,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session if recoverNativeSession && !caps.NativeSessionRecovery { return proto.PromptRequestPayload{}, errors.New("native session recovery is unavailable") } - functions, mcp, err := executionTools(snapshot.Agent.Tools) + functions, mcp, search, err := executionTools(snapshot.Agent.Tools) if err != nil { return proto.PromptRequestPayload{}, err } @@ -45,7 +45,7 @@ func (d *Dispatcher) executionRequest(ctx context.Context, session store.Session if snapshot.Agent.Text.Format.Type == "json_schema" { controls.OutputFormat = &proto.OutputFormat{Type: "json_schema", Schema: snapshot.Agent.Text.Format.Schema} } - request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: functions, + request := proto.PromptRequestPayload{AgentKind: session.Engine, FunctionTools: functions, ToolSearch: search, AgentOptions: options, ExecutionControls: controls, AgentStateKey: "agents-api-" + session.ID, AgentSessionID: bound.NativeSessionID, ReleaseOnCompletion: true, StrictResume: true, RequireExistingNativeSession: recoverNativeSession, diff --git a/services/agents-api/internal/execution/support.go b/services/agents-api/internal/execution/support.go index 5338a18d3..f8ec22604 100644 --- a/services/agents-api/internal/execution/support.go +++ b/services/agents-api/internal/execution/support.go @@ -7,6 +7,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" ) @@ -106,10 +107,13 @@ func (p Policy) engineCapabilities(peer *gateway.Session, engine string, snapsho if !snapshot.Agent.MultiAgent.Enabled && !caps.SubagentControl { return fail("device must advertise subagent_control") } - functions, mcp, err := executionTools(snapshot.Agent.Tools) + functions, mcp, search, err := executionTools(snapshot.Agent.Tools) if err != nil { return fail("invalid execution tool configuration") } + if err := (proto.PromptRequestPayload{ToolSearch: search, FunctionTools: functions}).ValidateToolSearch(caps.ToolSearch); err != nil { + return fail(err.Error()) + } if len(functions) > 0 && !caps.FunctionTools { return fail("device must advertise function_tools") } diff --git a/services/agents-api/internal/execution/tool_search_test.go b/services/agents-api/internal/execution/tool_search_test.go new file mode 100644 index 000000000..1a0ac6d4c --- /dev/null +++ b/services/agents-api/internal/execution/tool_search_test.go @@ -0,0 +1,56 @@ +package execution + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" +) + +const discoveryConfiguration = `{"agent":{"model":"model","tools":[{"type":"tool_search"},{"type":"function","name":"lookup","description":"Lookup","parameters":{"type":"object"},"defer_loading":true},{"type":"function","name":"clock","description":"Clock","parameters":{"type":"object"}}]},"environment":{"type":"none"}}` + +func TestDiscoveryUsesSharedOperationQualification(t *testing.T) { + for _, qualified := range []bool{false, true} { + policy := Policy{Engines: engine.NewCatalog(map[string]engine.Profile{"new_harness": {Placements: []string{"none"}, ToolSearch: qualified}})} + if err := policy.ValidateSessionConfiguration("new_harness", json.RawMessage(discoveryConfiguration)); (err == nil) != qualified { + t.Fatal("common operation qualification was not applied", qualified, err) + } + } + workspace := strings.Replace(discoveryConfiguration, `"type":"none"`, `"type":"openai_hosted"`, 1) + policy := Policy{Engines: engine.NewCatalog(map[string]engine.Profile{"new_harness": {Placements: []string{"openai_hosted"}, ToolSearch: true}})} + if err := policy.ValidateSessionConfiguration("new_harness", json.RawMessage(workspace)); err != nil { + t.Fatal("Core imposed another adapter's placement restriction", err) + } + for _, kind := range []string{"codex", "claude_sdk", "mcode"} { + if err := (Policy{}).ValidateSessionConfiguration(kind, json.RawMessage(discoveryConfiguration)); (err == nil) != (kind == "claude_sdk") { + t.Fatal(kind, err) + } + } +} + +func TestDiscoveryKeepsMixedFunctionDefinitions(t *testing.T) { + var snapshot Snapshot + if err := json.Unmarshal([]byte(discoveryConfiguration), &snapshot); err != nil { + t.Fatal(err) + } + functions, mcp, search, err := executionTools(snapshot.Agent.Tools) + if err != nil || len(mcp) != 0 || !search || len(functions) != 2 || !functions[0].DeferLoading || functions[1].DeferLoading { + t.Fatal(functions, mcp, search, err) + } + request := proto.PromptRequestPayload{ToolSearch: search, FunctionTools: functions} + if request.ValidateToolSearch(true) != nil || request.ValidateToolSearch(false) == nil { + t.Fatal("Runtime support is not operation-specific") + } + for _, raw := range []string{ + strings.Replace(discoveryConfiguration, `{"type":"tool_search"},`, "", 1), + strings.Replace(discoveryConfiguration, `"defer_loading":true`, `"defer_loading":false`, 1), + strings.Replace(discoveryConfiguration, `{"type":"tool_search"}`, `{"type":"tool_search","execution":"client"}`, 1), + strings.Replace(discoveryConfiguration, `{"type":"tool_search"}`, `{"type":"tool_search"},{"type":"tool_search"}`, 1), + } { + if err := (Policy{}).ValidateSessionConfiguration("claude_sdk", json.RawMessage(raw)); err == nil { + t.Fatal("unqualified discovery accepted", raw) + } + } +} diff --git a/services/agents-api/internal/store/tool_search_native_test.go b/services/agents-api/internal/store/tool_search_native_test.go new file mode 100644 index 000000000..dd25f360b --- /dev/null +++ b/services/agents-api/internal/store/tool_search_native_test.go @@ -0,0 +1,116 @@ +package store_test + +import ( + "context" + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/api" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" + "github.com/google/uuid" +) + +func TestNativeToolSearchPublicExecution(t *testing.T) { + python, binary, root, optionsFile := os.Getenv("PARSAR_OFFICIAL_SDK_PYTHON"), os.Getenv("PARSAR_NATIVE_DAEMON_BIN"), os.Getenv("PARSAR_NATIVE_PROOF_DIR"), os.Getenv("PARSAR_TOOL_SEARCH_REAL_OPTIONS") + if python == "" || binary == "" || root == "" || optionsFile == "" { + t.Skip("native daemon, fixed SDK, real model options and evidence directory required") + } + raw, err := os.ReadFile(optionsFile) + if err != nil { + t.Fatal(err) + } + var options map[string]any + if json.Unmarshal(raw, &options) != nil { + t.Fatal("invalid private options") + } + model, _ := options["model"].(string) + if model == "" { + t.Fatal("real model required") + } + h := newDispatchHarness(t) + h.d.Options = func(context.Context, store.Session) (map[string]any, error) { return options, nil } + home, err := os.MkdirTemp(root, "tool-search-public-") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Minute) + defer cancel() + worker, err := execution.StartWorker(ctx, h.d) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- worker.Run(ctx) }() + defer func() { + cancel() + select { + case <-done: + case <-time.After(20 * time.Second): + t.Error("worker did not stop") + } + }() + token, foreign := uuid.NewString(), uuid.NewString() + auth, err := api.NewAuthenticator([]api.APIKey{ + {OrganizationID: "test", ProjectID: h.tenant, SubjectKind: "service_account", SubjectID: "owner", TokenSHA256: device.HashCredential(token), TenantID: h.tenant}, + {OrganizationID: "test", ProjectID: uuid.NewString(), SubjectKind: "service_account", SubjectID: "other", TokenSHA256: device.HashCredential(foreign), TenantID: uuid.NewString()}, + }) + if err != nil { + t.Fatal(err) + } + handler, err := api.NewHandler(h.s, auth, "claude_sdk", api.WithExecution(worker)) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler) + defer server.Close() + stop := startNativeEngineDaemon(t, h, home, binary, "claude_sdk") + defer func() { stop() }() + evidence := filepath.Join(home, "public.json") + run := func(stage string) { + cmd := exec.CommandContext(ctx, python, "../../tests/official_tool_search.py", server.URL, token, foreign, model, stage, evidence) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("tool search %s: %v %s; evidence %s", stage, err, output, home) + } + } + run("initial") + var proof struct { + Session string `json:"session"` + Turn string `json:"turn"` + Call string `json:"call"` + } + raw, err = os.ReadFile(evidence) + if err != nil || json.Unmarshal(raw, &proof) != nil { + t.Fatal("invalid evidence", err) + } + call, err := h.s.GetFunctionCall(ctx, h.tenant, proof.Session, proof.Turn, proof.Call) + if err != nil || !call.Applied { + t.Fatal("function application receipt missing", err) + } + turn, err := h.s.GetTurn(ctx, h.tenant, proof.Session, proof.Turn) + if err != nil { + t.Fatal(err) + } + var outcome execution.Result + if json.Unmarshal(turn.Outcome, &outcome) != nil || outcome.Done.Usage.Raw["claude_sdk_result"] == nil || outcome.AppliedThrough < 1 { + t.Fatal("native usage or input receipt missing") + } + before, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID == "" { + t.Fatal("native binding missing", err) + } + stop() + stop = startNativeEngineDaemon(t, h, home, binary, "claude_sdk") + run("resume") + after, err := h.s.GetSessionExecutionBinding(ctx, h.tenant, proof.Session) + if err != nil || before.NativeSessionID != after.NativeSessionID { + t.Fatal("native history changed", err) + } + t.Logf("Real tool search SDK/raw HTTP, function receipt, cold daemon recovery and isolation passed: %s", home) +} diff --git a/services/agents-api/tests/image_fixture.py b/services/agents-api/tests/image_fixture.py new file mode 100644 index 000000000..323d70a5a --- /dev/null +++ b/services/agents-api/tests/image_fixture.py @@ -0,0 +1,16 @@ +"""Small deterministic PNG fixture shared by real image acceptance scripts.""" +import base64 +import struct +import zlib + + +def picture(names): + colors = {"red": (255, 0, 0), "blue": (0, 0, 255), "green": (0, 170, 0), "yellow": (255, 255, 0)} + rows = b"".join(b"\0" + b"".join(bytes(colors[n]) * 100 for n in names) for _ in range(140)) + + def chunk(kind, value): + return struct.pack(">I", len(value)) + kind + value + struct.pack(">I", zlib.crc32(kind + value) & 0xffffffff) + + image = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 400, 140, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") + return "data:image/png;base64," + base64.b64encode(image).decode() + diff --git a/services/agents-api/tests/official_message_images.py b/services/agents-api/tests/official_message_images.py index 05d9d960b..d7aeee37c 100644 --- a/services/agents-api/tests/official_message_images.py +++ b/services/agents-api/tests/official_message_images.py @@ -1,17 +1,15 @@ """Ordered image input through the pinned client, Core and a real native Runtime.""" -import base64 import importlib.metadata import json import secrets -import struct import sys import time import uuid -import zlib from pathlib import Path import httpx2 from openai import OpenAI +from image_fixture import picture base, token, foreign, model, stage, evidence = sys.argv[1:] pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) @@ -30,17 +28,6 @@ def save(): Path(evidence).write_text(json.dumps(proof, indent=2)) -def picture(names): - colors = {"red": (255, 0, 0), "blue": (0, 0, 255), "green": (0, 170, 0), "yellow": (255, 255, 0)} - rows = b"".join(b"\0" + b"".join(bytes(colors[n]) * 100 for n in names) for _ in range(140)) - - def chunk(kind, value): - return struct.pack(">I", len(value)) + kind + value + struct.pack(">I", zlib.crc32(kind + value) & 0xffffffff) - - image = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 400, 140, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") - return "data:image/png;base64," + base64.b64encode(image).decode() - - def text(value): return {"type": "input_text", "text": value} diff --git a/services/agents-api/tests/official_tool_search.py b/services/agents-api/tests/official_tool_search.py new file mode 100644 index 000000000..6e4ab7514 --- /dev/null +++ b/services/agents-api/tests/official_tool_search.py @@ -0,0 +1,138 @@ +"""Real deferred functions through the pinned SDK and raw Agents API HTTP.""" +import importlib.metadata +import json +import secrets +import sys +import uuid +from pathlib import Path + +import httpx2 +from openai import BadRequestError, NotFoundError, OpenAI +from image_fixture import picture + +base, token, foreign, model, stage, evidence = sys.argv[1:] +pin = json.loads((Path(__file__).resolve().parents[3] / "contracts/agents-api/upstream.json").read_text()) +source = json.loads(importlib.metadata.distribution("openai").read_text("direct_url.json")) +assert source["vcs_info"]["commit_id"] == pin["commit"] +client = OpenAI(base_url=base + "/v1", api_key=token, max_retries=0, + _strict_response_validation=True, http_client=httpx2.Client(trust_env=False, timeout=150)) +other = client.with_options(api_key=foreign) +sessions = client.beta.agents.sessions +headers = {"Authorization": "Bearer " + token, "OpenAI-Beta": "agents=v1"} +proof = {} if stage == "initial" else json.loads(Path(evidence).read_text()) + + +def run(session, name, cancel=False, images=False): + marker = "RESULT-" + str(uuid.uuid4()) + events, handled = [], False + prompt = "Call " + name + " exactly once, using the exact required ticket from its schema. Return only the fresh tool result. Discover its definition if needed." + message = {"type":"agent.session.input.message", "input":[{"role":"user","content":[{"type":"input_text","text":prompt}]}]} + colors = secrets.SystemRandom().sample(["red", "green", "blue", "yellow"], 4) + active_colors = secrets.SystemRandom().sample(["red", "green", "blue", "yellow"], 4) + if images: + message["input"][0]["content"].append({"type":"input_image","image_url":picture(colors)}) + message["input"][0]["content"].append({"type":"input_text","text":"Also remember these four band colors in left-to-right order."}) + with sessions.events.stream(session, timeout=150) as stream: + sessions.events.create(session, events=[message], idempotency_key=str(uuid.uuid4())) + for event in stream: + events.append(event.to_dict()) + if event.type == "agent.session.requires_action": + assert not handled and len(event.session.required_actions) == 1, event.to_dict() + handled = True + action = event.session.required_actions[0] + assert action.name == name and action.arguments == {"ticket":proof["parameter"]}, action.to_dict() + assert sessions.turns.retrieve(action.turn_id, session_id=session).status == "waiting" + if cancel: + sessions.events.create(session, events=[{"type":"agent.session.input.cancel"}]) + else: + if images: + update = {"type":"agent.session.input.message", "input":[{"role":"user","content":[ + {"type":"input_text","text":"New instruction: use this latest image instead. In your final answer print its four band colors from left to right and the tool result. Do not make another tool call."}, + {"type":"input_image","image_url":picture(active_colors)}]}]} + sessions.events.create(session, events=[update], idempotency_key="active-image") + if "turn" not in proof: + proof.update(turn=action.turn_id, call=action.call_id) + payload = {"type":"agent.session.input.tool_result", "turn_id":action.turn_id, "call_id":action.call_id, + "success":True, "output":[{"type":"input_text","text":marker}]} + result_key = str(uuid.uuid4()) + for _ in range(2): + sessions.events.create(session, events=[payload], idempotency_key=result_key) + assert event.type not in {"agent.session.failed", "agent.session.turn.failed"}, event.to_dict() + if event.type == "agent.session.idle": + break + else: + raise AssertionError("stream ended without idle") + assert handled + proof.setdefault("runs", []).append(events) + Path(evidence).write_text(json.dumps(proof, indent=2)) + turns = sessions.turns.list(session, order="asc", limit=100).data + turn = turns[-1] + assert turn.status == ("cancelled" if cancel else "completed"), turn.to_dict() + kinds = [e["type"] for e in events] + assert kinds.index("agent.session.turn." + turn.status) < kinds.index("agent.session.idle") + assert len({e["event_id"] for e in events}) == len(events) + items = sessions.items.list(session, limit=100, order="asc").data + calls = [i for i in items if i.type == "function_call" and i.turn_id == turn.id] + outputs = [i for i in items if i.type == "function_call_output" and i.call_id == action.call_id] + assert len(calls) == 1 and calls[0].call_id == action.call_id + assert len(outputs) == (0 if cancel else 1) + if not cancel: + answers = [i for i in items if i.type == "message" and i.role == "assistant" and i.turn_id == turn.id] + assert any(marker in json.dumps(i.to_dict()) for i in answers), [i.to_dict() for i in answers] + if images: + answer = " ".join(c.text for i in answers for c in i.content if c.type == "output_text").lower() + positions = [answer.find(c) for c in active_colors] + assert all(p >= 0 for p in positions) and positions == sorted(positions), answer + proof["image_colors"] = {"initial":colors,"active":active_colors} + assert outputs[0].output[0].text == marker + assert any(e["type"] == "agent.session.turn.item.added" and e["item"]["id"] == outputs[0].id for e in events) + with httpx2.Client(trust_env=False) as raw: + response = raw.get(base + "/v1/agents/sessions/" + session + "/items", headers=headers, params={"order":"asc","limit":100}) + assert response.status_code == 200 + assert [i["id"] for i in response.json()["data"]] == [i.id for i in items] + return turn.id + + +try: + if stage == "initial": + proof["parameter"] = "ARG-" + str(uuid.uuid4()) + schema = {"type":"object","properties":{"ticket":{"type":"string","enum":[proof["parameter"]]}}, "required":["ticket"],"additionalProperties":False} + tools = [{"type":"tool_search"}] + [ + {"type":"function","name":name,"description":description,"parameters":schema,"defer_loading":deferred} + for name, description, deferred in [("lookup_account","Return the account result.",True), ("clock","Return the current clock result.",False), ("unrelated_report","Read an unrelated report.",True)]] + config = {"model":model,"tools":tools} + saved = client.beta.agents.create(**config) + session = sessions.create(agent_id=saved.id, environment={"type":"none"}, extra_headers={"Idempotency-Key":"discovery-create"}) + assert sessions.create(agent_id=saved.id, environment={"type":"none"}, extra_headers={"Idempotency-Key":"discovery-create"}).id == session.id + assert [t.to_dict() for t in saved.tools] == tools + assert [t.to_dict() for t in session.agent.tools] == tools[1:] + proof.update(session=session.id, agent=saved.id, tools=tools) + run(session.id, "lookup_account", images=True) + run(session.id, "clock") + for action in [lambda:other.beta.agents.sessions.retrieve(session.id), lambda:other.beta.agents.sessions.create(agent_id=saved.id,environment={"type":"none"})]: + try: + action() + raise AssertionError("foreign tenant accessed discovery configuration") + except NotFoundError: + pass + for invalid in [[tools[1]], [tools[0]], [tools[0],tools[0],tools[1]], [{"type":"tool_search","execution":"client"},tools[1]]]: + try: + sessions.create(agent={"model":model,"tools":invalid}, environment={"type":"none"}) + raise AssertionError("unqualified discovery configuration admitted") + except BadRequestError: + pass + else: + session = sessions.retrieve(proof["session"]) + assert [t.to_dict() for t in session.agent.tools] == proof["tools"][1:] + run(session.id, "lookup_account") + assert len(sessions.turns.list(session.id).data) == 3 + assert len([i for i in sessions.items.list(session.id,limit=100).data if i.type == "function_call"]) == 3 + # Inline configuration exercises a fresh native Session and pending-call cancellation. + cancelled = sessions.create(agent={"model":model,"tools":proof["tools"]}, environment={"type":"none"}) + run(cancelled.id, "lookup_account", cancel=True) + proof["cancel_session"] = cancelled.id + proof["passed"] = True +finally: + Path(evidence).write_text(json.dumps(proof, indent=2)) + client.close() + other.close() From 0742a0290171bc33f1f8bb29a4ef2d4f5159eccd Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Tue, 22 Sep 2026 14:04:57 +0800 Subject: [PATCH 2/2] style: remove trailing fixture blank line --- services/agents-api/tests/image_fixture.py | 1 - 1 file changed, 1 deletion(-) diff --git a/services/agents-api/tests/image_fixture.py b/services/agents-api/tests/image_fixture.py index 323d70a5a..02e7e45fe 100644 --- a/services/agents-api/tests/image_fixture.py +++ b/services/agents-api/tests/image_fixture.py @@ -13,4 +13,3 @@ def chunk(kind, value): image = b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", 400, 140, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(rows)) + chunk(b"IEND", b"") return "data:image/png;base64," + base64.b64encode(image).decode() -