Let hosts register tools and observe token usage

Why: product runners need register_artifact and card tools inside the kernel loop, and the UI needs token usage on the same event stream as text and tool calls.
Evidence: go test ./... in agent-native; TestHostToolAndUsageEvent and TestHostToolNameConflictFailsClosed.
Impact: Options.HostTools fail closed on name clashes; ProcessEvent and runtime events gain a usage type with token counts. Existing callers that ignore unknown event types stay compatible.
Refs: deliverable-quality-system B1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-01 08:57:21 +00:00
co-authored by Cursor
parent f9ab2ef698
commit 951c141f95
5 changed files with 152 additions and 7 deletions
+14 -4
View File
@@ -50,6 +50,8 @@ type Options struct {
Compact *CompactConfig
// Subagent 可选:提供时注册 delegate_task(委派由宿主实现,内核只开端口)。
Subagent subagent.Factory
// HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。
HostTools []tools.Tool
// Log 可选。nil 时丢弃日志。
Log *logsys.Logger
// ExecutionID 写入日志,不参与执行控制。
@@ -168,6 +170,12 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result,
}
}
subagent.Register(reg, b.opts.Subagent)
for _, tool := range b.opts.HostTools {
if reg.Has(tool.Name()) {
return &exec.Result{Success: false, ErrorReason: "host tool name conflicts with a builtin: " + tool.Name()}, nil
}
reg.Register(tool)
}
if err := reg.Restrict(req.ToolAllowlist); err != nil {
return &exec.Result{Success: false, ErrorReason: err.Error()}, nil
}
@@ -313,10 +321,12 @@ func (b *Backend) run(ctx context.Context, model string, budget *runtime.Budget,
logs.WriteByte('\n')
if onProcess != nil {
onProcess(exec.ProcessEvent{
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
Type: ev.Type,
Text: ev.Text,
Tool: ev.Tool,
Detail: ev.Detail,
PromptTokens: ev.PromptTokens,
CompletionTokens: ev.CompletionTokens,
})
}
},
+119
View File
@@ -0,0 +1,119 @@
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 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
}