From c8c184b9b3a1dab7f1777bce79ed5cad149fad21 Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Wed, 7 Oct 2026 09:21:07 +0000 Subject: [PATCH] Qualify function tools in the Codex and Claude views Codex's view declares function tools and function-result images, and keeps tool search unsupported. Claude's view derives function tools, result images and tool search from the probed bridge features with the predicates its workspace Runtime uses, since the view runs the bridge in workspace mode. The agent-host qualification answers each function call through dispatch with a text, image and text result. A view that declares function tools runs a second Turn in a new Executor that resumes the native session and calls the function; a view that declares tool search runs a Turn in another Session that finds the deferred function. Codex keeps its dynamic tools when a new app-server resumes the thread. --- apps/daemon/internal/agent/claudesdk/view.go | 14 +- .../internal/agent/claudesdk/view_test.go | 2 +- apps/daemon/internal/agent/codex/view.go | 4 +- .../agenthostqualify/qualify_linux_test.go | 176 +++++++++++++++--- contracts/agents-api/harness-onboarding.md | 2 +- contracts/agents-api/zh/harness-onboarding.md | 4 +- 6 files changed, 161 insertions(+), 41 deletions(-) diff --git a/apps/daemon/internal/agent/claudesdk/view.go b/apps/daemon/internal/agent/claudesdk/view.go index 3716771ad..d9ca20e6e 100644 --- a/apps/daemon/internal/agent/claudesdk/view.go +++ b/apps/daemon/internal/agent/claudesdk/view.go @@ -58,14 +58,18 @@ func newView(probe Config, info RuntimeInfo) (*agent.View, error) { if err != nil { return nil, err } - view := declareView(probe, node, root, filepath.ToSlash(bridge), filepath.ToSlash(info.NativePath), loader) + view := declareView(probe, info, node, root, filepath.ToSlash(bridge), loader) if err := view.Validate(); err != nil { return nil, err } return view, nil } -func declareView(probe Config, node, root, bridge, native string, loader viewloader.Fragment) *agent.View { +// declareView declares the view of the install that info describes. The view +// runs the bridge in workspace mode, so its functions follow the probe as a +// workspace Runtime's do. +func declareView(probe Config, info RuntimeInfo, node, root, bridge string, loader viewloader.Fragment) *agent.View { + native := filepath.ToSlash(info.NativePath) nodeMount := agent.ViewMount{Name: "node", HostDir: filepath.Dir(node)} bundle := agent.ViewMount{Name: "claude-sdk", HostDir: root} layout := viewLayout{node: nodeMount.Path() + "/" + filepath.Base(node), bridge: bundle.Path() + "/" + bridge, libraries: loader.LibraryPath} @@ -81,9 +85,9 @@ func declareView(probe Config, node, root, bridge, native string, loader viewloa Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, - ToolSearch: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilityFromBool(info.SupportsWorkspaceFunctions()), + FunctionResultImages: proto.CapabilityFromBool(info.SupportsFunctionResultImages()), + ToolSearch: proto.CapabilityFromBool(info.SupportsWorkspaceToolSearch()), StdioMCP: proto.CapabilityUnsupported, }, } diff --git a/apps/daemon/internal/agent/claudesdk/view_test.go b/apps/daemon/internal/agent/claudesdk/view_test.go index 1d2a567b2..573aeb21c 100644 --- a/apps/daemon/internal/agent/claudesdk/view_test.go +++ b/apps/daemon/internal/agent/claudesdk/view_test.go @@ -150,7 +150,7 @@ func resolveTestView(t *testing.T) agent.View { } lib := agent.ViewMount{Name: viewloader.MountName, HostDir: filepath.Join(root, "lib")} loader := viewloader.Fragment{Closure: []agent.ViewMount{lib}, Overlays: []agent.ViewOverlay{{Path: "/lib64/ld-linux-x86-64.so.2", Source: filepath.Join(root, "lib", "ld.so"), Exec: true}}, LibraryPath: lib.Path()} - declared := declareView(probe, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", "native/claude", loader) + declared := declareView(probe, RuntimeInfo{NativePath: "native/claude"}, probe.Node, filepath.Join(root, "bundle"), "dist/main.js", loader) registry := agent.NewRegistry() registry.Register(Declaration, agent.Runtime{Info: Declaration.Info, Session: NewFactory(probe), View: declared}) view, err := registry.ResolveView(Declaration.Info.Kind) diff --git a/apps/daemon/internal/agent/codex/view.go b/apps/daemon/internal/agent/codex/view.go index 144b16ed6..3c3872b8a 100644 --- a/apps/daemon/internal/agent/codex/view.go +++ b/apps/daemon/internal/agent/codex/view.go @@ -89,8 +89,8 @@ func newView(binary string, codeModeHost bool) agent.View { Capabilities: agent.ViewCapabilities{ EnvironmentNone: proto.CapabilityUnsupported, Skills: proto.CapabilityUnsupported, - FunctionTools: proto.CapabilityUnsupported, - FunctionResultImages: proto.CapabilityUnsupported, + FunctionTools: proto.CapabilitySupported, + FunctionResultImages: proto.CapabilitySupported, ToolSearch: proto.CapabilityUnsupported, StdioMCP: proto.CapabilityUnsupported, }, diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index a02b8ef25..5478627f1 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -6,14 +6,18 @@ package agenthostqualify import ( + "bytes" "context" "crypto/rand" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" + "image" + "image/png" "io" "log/slog" "math/big" @@ -107,15 +111,19 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { t.Fatalf("%s declares no agent-host view; discovery reported why above", kind) } reg.Register(declaration, *runtime) - qualify(t, h, cfg, sb, kind, sessionModel(t, raw, key)) + qualify(t, h, cfg, sb, kind, runtime.View.Capabilities, sessionModel(t, raw, key)) }) } } -// qualify runs one Turn that writes a file, runs a failing command and -// reports what it printed and its exit status, then checks all three. Only -// the sandbox's tool environment holds the value and the status. -func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, model proto.PromptRequestPayload) { +// qualify runs the kind's Turns through dispatch. The first writes a file, +// runs a failing command and reports what it printed and its exit status, +// then the test checks all three; only the sandbox's tool environment holds +// the value and the status. A view that declares function tools runs a second +// Turn in a new Executor, which resumes the Session's native history, and +// calls a function there. A view that declares tool search runs a Turn in +// another Session that finds the function, deferred, with tool search. +func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, kind string, caps agent.ViewCapabilities, model proto.PromptRequestPayload) { name := "qualify-" + kind + ".txt" value, content := strings.ToLower(rand.Text()), "qualified "+strings.ToLower(rand.Text()[:12]) code, _ := rand.Int(rand.Reader, big.NewInt(90)) @@ -126,43 +134,136 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, " It prints a value and exits with a non-zero status; that is expected.\n" + "3. Answer with exactly one line: VALUE= EXIT=" - // dispatch drives the Session's Turn through the agent host's Executor. - b := sb.binding() - sb.grant(b, cfg.RuntimeID) env := agenthost.Environment{ Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } + configuration := proto.PromptRequestPayload{AgentKind: kind, StrictResume: true, DisableSubagents: true, + Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, + LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}} + if caps.FunctionTools.IsSupported() { + configuration.FunctionTools = []proto.FunctionTool{lookupTicket} + } + k := newTicket(t) + s := sb.session(h, cfg, env, configuration) + done, _ := s.turn(t, "qualify", prompt, k) + if !strings.Contains(done.Content, "VALUE="+value) || !strings.Contains(done.Content, fmt.Sprintf("EXIT=%d", exit)) { + t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", done.Content, value, exit) + } + if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { + t.Errorf("%s holds %q, want %q", name, got, content) + } + + if caps.FunctionTools.IsSupported() { + // The first Executor retired with its Router. + native, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string) + if native == "" { + t.Fatal("the first Turn reported no native session to resume") + } + s.configuration.AgentSessionID = native + done, calls := s.turn(t, "resumed-function", k.prompt("Call the lookup_ticket function"), k) + if resumed, _ := done.Metadata[proto.DoneMetaAgentSessionID].(string); resumed != native { + t.Errorf("the resumed Turn reported the native session %q, want %q", resumed, native) + } + k.check(t, done, calls) + } + if caps.ToolSearch.IsSupported() { + deferred := lookupTicket + deferred.DeferLoading = true + configuration.ToolSearch, configuration.FunctionTools = true, []proto.FunctionTool{deferred} + search := sb.session(h, cfg, env, configuration) + done, calls := search.turn(t, "tool-search", k.prompt("Search your tools for the function that looks up support tickets"), k) + k.check(t, done, calls) + } +} + +// lookupTicket is the function the function Turns call. +var lookupTicket = proto.FunctionTool{Name: "lookup_ticket", Description: "Looks up a support ticket by its number.", + Parameters: json.RawMessage(`{"type":"object","properties":{"ticket":{"type":"string","description":"The ticket number"}},"required":["ticket"],"additionalProperties":false}`)} + +// ticket is lookup_ticket's result for number: a text with first, an image, +// and a text with second. An answer that holds both codes shows that the +// Harness gave its model the whole result. +type ticket struct{ number, first, second, image string } + +func newTicket(t *testing.T) ticket { + var encoded bytes.Buffer + if err := png.Encode(&encoded, image.NewGray(image.Rect(0, 0, 8, 8))); err != nil { + t.Fatal(err) + } + number, _ := rand.Int(rand.Reader, big.NewInt(9000)) + return ticket{number: fmt.Sprint(number.Int64() + 1000), first: strings.ToLower(rand.Text()[:8]), second: strings.ToLower(rand.Text()[:8]), + image: "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())} +} + +func (k ticket) result() []proto.InputContent { + first, second := "Ticket "+k.number+": the first code is "+k.first+".", "The second code is "+k.second+"." + return []proto.InputContent{{Type: "input_text", Text: &first}, {Type: "input_image", ImageURL: &k.image}, {Type: "input_text", Text: &second}} +} + +// prompt asks for one call of the function that find finds. +func (k ticket) prompt(find string) string { + return find + ", and call it once with ticket \"" + k.number + "\".\nAnswer with exactly one line: FIRST= SECOND=" +} + +// check checks that the Turn called lookup_ticket for the ticket and +// answered with both codes. +func (k ticket) check(t *testing.T, done proto.DonePayload, calls []proto.FunctionCallPayload) { + t.Helper() + if len(calls) == 0 || calls[0].Name != lookupTicket.Name || !strings.Contains(string(calls[0].Arguments), k.number) { + t.Errorf("the Turn made %d function calls, want the first to call %s for ticket %s", len(calls), lookupTicket.Name, k.number) + } + if !strings.Contains(done.Content, "FIRST="+k.first) || !strings.Contains(done.Content, "SECOND="+k.second) { + t.Errorf("the answer %q does not report FIRST=%s SECOND=%s", done.Content, k.first, k.second) + } +} + +// session is a Session bound to the sandbox. Each Turn runs in a new +// Executor through a new dispatch Router. +type session struct { + h *agenthost.Host + cfg agenthost.Config + binding agenthost.Binding + env agenthost.Environment + id string + configuration proto.PromptRequestPayload +} + +func (sb *sandbox) session(h *agenthost.Host, cfg agenthost.Config, env agenthost.Environment, configuration proto.PromptRequestPayload) *session { + s := &session{h: h, cfg: cfg, binding: sb.binding(), env: env, id: uuid.NewString(), configuration: configuration} + s.configuration.AgentStateKey = "agents-api-" + s.id + sb.grant(s.binding, cfg.RuntimeID) + return s +} + +// turn prepares an Executor of the Session, runs prompt as its Turn run, +// answers each function call with k's result, and retires the Executor with +// the Router's Shutdown. It returns the Turn's Done and its function calls. +func (s *session) turn(t *testing.T, run, prompt string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { + t.Helper() out := make(sender, 256) - router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: cfg.Log, - Registry: h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { return b, env, nil })}) + router, err := dispatch.New(dispatch.Config{Sender: out, SessionEnvironments: true, Log: s.cfg.Log, + Registry: s.h.Registry(func(proto.PromptRequestPayload) (agenthost.Binding, agenthost.Environment, error) { + return s.binding, s.env, nil + })}) if err != nil { t.Fatal(err) } - session := uuid.NewString() - handle(t, router, proto.TypeExecutionPrepare, "prepare", proto.ExecutionPreparePayload{SessionID: session, Configuration: proto.PromptRequestPayload{ - AgentKind: kind, AgentStateKey: "agents-api-" + session, StrictResume: true, DisableSubagents: true, - Model: model.Model, ModelProvider: model.ModelProvider, ExecutionControls: &proto.ExecutionControls{WebSearch: "disabled", TextVerbosity: "medium"}, - LocalEnvironment: &proto.LocalEnvironment{WorkspaceDirectory: workspace, NetworkAccess: "enabled"}}}) - ready := out.status(t, "prepare") + prepare := "prepare-" + run + handle(t, router, proto.TypeExecutionPrepare, prepare, proto.ExecutionPreparePayload{SessionID: s.id, Configuration: s.configuration}) + ready := out.status(t, prepare) if ready.State != "ready" { t.Fatalf("the preparation is %s (%s), want ready; the log above says why", ready.State, ready.ErrorCode) } - handle(t, router, proto.TypeExecutionStart, "prepare", proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, - RunID: "qualify", Input: proto.TextInput(prompt)}) - answer := out.collect(t, "qualify") + handle(t, router, proto.TypeExecutionStart, prepare, proto.ExecutionStartPayload{Handle: ready.Handle, ExecutorID: ready.ExecutorID, + RunID: run, Input: proto.TextInput(prompt)}) + done, calls := out.collect(t, router, run, k) ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() if err := router.Shutdown(ctx); err != nil { t.Errorf("Shutdown: %v", err) } - - if !strings.Contains(answer, "VALUE="+value) || !strings.Contains(answer, fmt.Sprintf("EXIT=%d", exit)) { - t.Errorf("the answer %q does not report VALUE=%s EXIT=%d", answer, value, exit) - } - if got := sb.read(t, cfg, workspace+"/"+name); strings.TrimRight(got, "\n") != content { - t.Errorf("%s holds %q, want %q", name, got, content) - } + return done, calls } func handle(t *testing.T, router *dispatch.Router, typ, id string, payload any) { @@ -214,21 +315,36 @@ func (s sender) status(t *testing.T, id string) proto.PreparationStatusPayload { } } -// collect reads the Turn's envelopes until its Done and returns its content. -func (s sender) collect(t *testing.T, run string) string { +// collect reads the Turn's envelopes until its Done. It answers each function +// call with k's result through router and checks that dispatch applied it. +func (s sender) collect(t *testing.T, router *dispatch.Router, run string, k ticket) (proto.DonePayload, []proto.FunctionCallPayload) { t.Helper() deadline := time.After(turnLimit) + var calls []proto.FunctionCallPayload for { switch e := s.next(t, run, deadline); e.Type { case proto.TypeError: t.Errorf("Turn error: %s", e.Payload) + case proto.TypeFunctionCall: + var call proto.FunctionCallPayload + if err := e.DecodePayload(&call); err != nil { + t.Fatal(err) + } + t.Logf("function call: %s %s", call.Name, call.Arguments) + calls = append(calls, call) + handle(t, router, proto.TypeFunctionResult, run, proto.FunctionResultPayload{DeliveryID: "result-" + call.CallID, CallID: call.CallID, Success: true, Content: k.result()}) + case proto.TypeInteractionDecisionAck: + var ack proto.InteractionDecisionAckPayload + if err := e.DecodePayload(&ack); err != nil || !ack.Applied { + t.Errorf("a function result was not applied: %s", e.Payload) + } case proto.TypeDone: var d proto.DonePayload - if err := json.Unmarshal(e.Payload, &d); err != nil { + if err := e.DecodePayload(&d); err != nil { t.Fatal(err) } t.Logf("answer: %s", d.Content) - return d.Content + return d, calls } } } diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index bf62a5367..c39df42bb 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -367,7 +367,7 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | | `Capabilities` | Each supported feature runs a Turn through dispatch: environment none in the empty-root view, Skills, function calls and results, tool search, and each stdio binding under its alias. | -`scripts/qualify-agent-host.sh` runs one Turn per Harness through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. +`scripts/qualify-agent-host.sh` runs each Harness's Turns through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. The first Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. When the view declares function tools, a second Turn runs in a new Executor that resumes the Session's native history and calls a function; the test returns a text, image and text result through dispatch, and the answer must report both texts. When the view declares tool search, a Turn in another Session finds the deferred function with tool search and calls it. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. ## Native references diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index 3630b0fa5..3f18bf5fe 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: d62c6b491f137b9cafd254a056c02b285ece3141f26fcd96d290c63210dcf785 +source_hash: b89f8241275419330cf55481c11add0a40b58cf06fa0544ed8e0d7cb3839d3a3 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -369,7 +369,7 @@ stdio 绑定在沙箱中以其别名运行。`ViewSession.MCP` 中索引为 `i` | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | | `Capabilities` | 每项受支持的功能都通过 dispatch 运行一个 Turn:空根视图中的 Environment none、Skills、函数调用及其结果、工具搜索,以及每个以别名运行的 stdio 绑定。 | -`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 为每个 Harness 运行一个 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 运行每个 Harness 的 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。第一个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。视图声明函数工具时,第二个 Turn 在新的 Executor 中运行,该 Executor 恢复 Session 的原生历史并调用一个函数;测试通过 dispatch 返回文本、图片、文本组成的结果,回答必须报告两段文本。视图声明工具搜索时,另一个 Session 中的 Turn 用工具搜索找到延迟加载的函数并调用它。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 ## 原生参考 {#native-references}