diff --git a/adapters/native/backend.go b/adapters/native/backend.go index eb86098..62704bf 100644 --- a/adapters/native/backend.go +++ b/adapters/native/backend.go @@ -52,6 +52,8 @@ type Options struct { Subagent subagent.Factory // HostTools 是宿主产品工具(登记工件、卡片、知识库)。与内核工具同名时失败,不覆盖。 HostTools []tools.Tool + // MaxToolCalls 是单步工具调用上限。0 表示不限制。请求自带预算时以请求为准。 + MaxToolCalls int // Log 可选。nil 时丢弃日志。 Log *logsys.Logger // ExecutionID 写入日志,不参与执行控制。 @@ -77,6 +79,19 @@ type Backend struct { lastFailure map[string]string } +type workDirKey struct{} + +// WithWorkDir 把本步工作目录放进上下文,供宿主工具读取内核刚写出的文件。 +func WithWorkDir(ctx context.Context, dir string) context.Context { + return context.WithValue(ctx, workDirKey{}, dir) +} + +// WorkDir 返回 WithWorkDir 写入的目录。没有时返回空串。 +func WorkDir(ctx context.Context) string { + dir, _ := ctx.Value(workDirKey{}).(string) + return dir +} + // New 创建 native 后端。 func New(opts Options) *Backend { return &Backend{opts: opts, lastFailure: map[string]string{}} @@ -205,6 +220,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, timeout = b.opts.Timeout } var runBudget *runtime.Budget + if b.opts.MaxToolCalls > 0 { + runBudget = &runtime.Budget{MaxToolCalls: b.opts.MaxToolCalls} + } if req.Budget != nil { runBudget = &runtime.Budget{ MaxPromptTokens: req.Budget.MaxPromptTokens, @@ -212,6 +230,9 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, MaxToolCalls: req.Budget.MaxToolCalls, Deadline: req.Budget.Deadline, } + if runBudget.MaxToolCalls == 0 { + runBudget.MaxToolCalls = b.opts.MaxToolCalls + } if !req.Budget.Deadline.IsZero() { // Deadline 与 Timeout 先到者生效:把更短者作为 ctx 超时。 if until := time.Until(req.Budget.Deadline); timeout <= 0 || until < timeout { @@ -219,10 +240,10 @@ func (b *Backend) Execute(ctx context.Context, req exec.Request) (*exec.Result, } } } - runCtx := ctx + runCtx := WithWorkDir(ctx, workDir) cancel := func() {} if timeout > 0 { - runCtx, cancel = context.WithTimeout(ctx, timeout) + runCtx, cancel = context.WithTimeout(runCtx, timeout) } defer cancel() diff --git a/adapters/native/host_tools_test.go b/adapters/native/host_tools_test.go index 017fd8e..89cf594 100644 --- a/adapters/native/host_tools_test.go +++ b/adapters/native/host_tools_test.go @@ -62,6 +62,33 @@ func TestHostToolAndUsageEvent(t *testing.T) { } } +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",