Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/daemon/internal/agent/claudesdk/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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,
},
}
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agent/claudesdk/view_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions apps/daemon/internal/agent/codex/view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
176 changes: 146 additions & 30 deletions apps/daemon/internal/agenthostqualify/qualify_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand All @@ -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=<the printed value> EXIT=<the exit status>"

// 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=<the first code> SECOND=<the second code>"
}

// 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) {
Expand Down Expand Up @@ -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
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion contracts/agents-api/harness-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading