Why: Host tools need the kernel workspace to register files the step just wrote, and product budgets must stop a step that keeps calling tools. Evidence: go test ./adapters/native/ ./runtime/ -count=1 passed, including the workdir visibility test. Impact: Host tools can read WorkDir from context. MaxToolCalls limits one kernel step when the request budget leaves it unset. Refs: deliverable-quality-system B1 Co-authored-by: Cursor <cursoragent@cursor.com>
147 lines
4.4 KiB
Go
147 lines
4.4 KiB
Go
package native
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"sync"
|
|
"testing"
|
|
|
|
"agent-native/exec"
|
|
"agent-native/runtime/llm"
|
|
"agent-native/runtime/tools"
|
|
)
|
|
|
|
type hostTool struct{ name string }
|
|
|
|
func (h hostTool) Name() string { return h.name }
|
|
func (h hostTool) Description() string { return "host tool" }
|
|
func (h hostTool) Parameters() json.RawMessage {
|
|
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
|
|
}
|
|
func (h hostTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
|
return tools.Result{Content: "registered " + h.name}, nil
|
|
}
|
|
|
|
func TestHostToolAndUsageEvent(t *testing.T) {
|
|
var events []exec.ProcessEvent
|
|
b := New(Options{
|
|
Model: "fake",
|
|
HostTools: []tools.Tool{hostTool{name: "register_artifact"}},
|
|
Stream: usageScript(),
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "登记",
|
|
OnEvent: func(ev exec.ProcessEvent) { events = append(events, ev) },
|
|
})
|
|
if err != nil || !res.Success {
|
|
t.Fatalf("execute: err=%v res=%+v logs=%s", err, res, logsOf(res))
|
|
}
|
|
sawText, sawStart, sawEnd, sawUsage := false, false, false, false
|
|
for _, ev := range events {
|
|
switch ev.Type {
|
|
case "text_delta":
|
|
if ev.Text != "" {
|
|
sawText = true
|
|
}
|
|
case "tool_execution_start":
|
|
if ev.Tool == "register_artifact" {
|
|
sawStart = true
|
|
}
|
|
case "tool_execution_end":
|
|
if ev.Tool == "register_artifact" {
|
|
sawEnd = true
|
|
}
|
|
case "usage":
|
|
if ev.PromptTokens == 11 && ev.CompletionTokens == 7 {
|
|
sawUsage = true
|
|
}
|
|
}
|
|
}
|
|
if !sawText || !sawStart || !sawEnd || !sawUsage {
|
|
t.Fatalf("events missing text/tool/usage: %+v", events)
|
|
}
|
|
}
|
|
|
|
func TestHostToolSeesStepWorkDir(t *testing.T) {
|
|
var seen string
|
|
b := New(Options{
|
|
Model: "fake",
|
|
Stream: script(llm.Message{Role: llm.RoleAssistant, StopReason: llm.StopToolCalls, ToolCalls: []llm.ToolCall{{ID: "c1", Name: "peek", Arguments: `{}`}}}),
|
|
HostTools: []tools.Tool{peekTool{got: &seen}},
|
|
})
|
|
dir := t.TempDir()
|
|
_, err := b.Execute(context.Background(), exec.Request{WorkDir: dir, Prompt: "x", Targets: []string{"output.md"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seen != dir {
|
|
t.Fatalf("workdir = %q, want %s", seen, dir)
|
|
}
|
|
}
|
|
|
|
type peekTool struct{ got *string }
|
|
|
|
func (p peekTool) Name() string { return "peek" }
|
|
func (p peekTool) Description() string { return "peek" }
|
|
func (p peekTool) Parameters() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
|
|
func (p peekTool) Execute(ctx context.Context, args json.RawMessage) (tools.Result, error) {
|
|
*p.got = WorkDir(ctx)
|
|
return tools.Result{Content: "ok"}, nil
|
|
}
|
|
|
|
func TestHostToolNameConflictFailsClosed(t *testing.T) {
|
|
b := New(Options{
|
|
Model: "fake",
|
|
Stream: script(llm.Message{Role: llm.RoleAssistant, Content: "x", StopReason: llm.StopEnd}),
|
|
HostTools: []tools.Tool{hostTool{name: "read_file"}},
|
|
})
|
|
res, err := b.Execute(context.Background(), exec.Request{
|
|
WorkDir: t.TempDir(), Prompt: "x", Targets: []string{"output.md"},
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.Success || res.ErrorReason == "" {
|
|
t.Fatalf("conflict must fail closed, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func usageScript() llm.StreamFunc {
|
|
var mu sync.Mutex
|
|
step := 0
|
|
return func(ctx context.Context, model string, messages []llm.Message, td []llm.ToolDecl, opts llm.StreamOptions) <-chan llm.Event {
|
|
ch := make(chan llm.Event, 4)
|
|
go func() {
|
|
defer close(ch)
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
step++
|
|
switch step {
|
|
case 1:
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: "register_artifact", Arguments: `{"path":"a.docx"}`}},
|
|
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
|
|
case 2:
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
|
Role: llm.RoleAssistant, StopReason: llm.StopToolCalls,
|
|
ToolCalls: []llm.ToolCall{{ID: "c2", Name: "write_file", Arguments: `{"path":"output.md","content":"done"}`}},
|
|
}, Usage: llm.Usage{PromptTokens: 4, CompletionTokens: 2}}
|
|
default:
|
|
ch <- llm.Event{Kind: llm.KindTextDelta, Text: "已登记"}
|
|
ch <- llm.Event{Kind: llm.KindMessageEnd, Message: llm.Message{
|
|
Role: llm.RoleAssistant, Content: "已登记", StopReason: llm.StopEnd,
|
|
}, Usage: llm.Usage{PromptTokens: 3, CompletionTokens: 3}}
|
|
}
|
|
}()
|
|
return ch
|
|
}
|
|
}
|
|
|
|
func logsOf(res *exec.Result) string {
|
|
if res == nil {
|
|
return ""
|
|
}
|
|
return res.Logs
|
|
}
|