diff --git a/docs/changes/unreleased/1783-task-web-tools.md b/docs/changes/unreleased/1783-task-web-tools.md new file mode 100644 index 0000000000..b802d9387b --- /dev/null +++ b/docs/changes/unreleased/1783-task-web-tools.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: PlanDB run workers can search and fetch through native tools +pr: 1783 +surface: [engine, chat] +invalidates: + - "The task belt defined web tools but PlanDB run workers received no web dependencies. The crew factory now binds the run profile's live search and fetch pair." + - "The bash worker prompt and retry diagnostic claimed only bash could be called. Workers may call any available native tool, one call per response." +--- + +Task trajectories now retain the native tool name beside arguments and returned +observations. Files and PlanDB coordination continue through bash. Both worker +policy pages allow native calls. Child briefs also distinguish the parent's +coordination instructions from the child's own work order. diff --git a/docs/design/task-web-tools.md b/docs/design/task-web-tools.md new file mode 100644 index 0000000000..8a79993de9 --- /dev/null +++ b/docs/design/task-web-tools.md @@ -0,0 +1,65 @@ +# Native web tools for PlanDB task workers + +## Decision + +Keep filesystem work and PlanDB coordination in bash. Expose web search and +page fetching as native `web_search` and `web_fetch` tools on the same worker +belt, with exactly one tool call per response. Retain the existing shell +commands as an alternate interface to the same implementations. + +## What was missing + +The session task belt already appends `searchTools`, and older graph workers +inherit the parent's provider and fetcher. The active PlanDB runner constructs +standalone workers through `CrewFactory`, `BashWorker.Run` and `NewBeltWorker`. +It passed neither web dependency, so conditional tool composition removed both. +The worker page also claimed only bash existed, despite the belt supporting +other native capabilities. The envelope accepts any carried tool, but its +retry diagnostic incorrectly required a bash call. + +## Ownership and execution + +`CrewFactory` binds `search.Live` to its explicit run profile. Every task role, +new child, retry and wake passes through that factory. `BashWorker` carries the +pair into `session.Config`; the existing tool registry owns schemas, argument +validation, bounds, provider errors and execution. The live resolver rereads +settings for each operation without performing a network availability probe. +The worker cannot edit its settings through this addition. Both policy pages +allow one available tool call, rather than requiring bash for every action. +A leaf reads the full ask for its owned requirements, but parent coordination +instructions are not reassigned to it; it follows its own work order. + +A search returns titles, URLs and snippets; the worker chooses a URL and calls +fetch. Each operation receives the task's context, so run cancellation and wall +limits reach the network implementation. Provider failures return tool errors +which the model can act on. No new credentials, subprocess bridge, vendor +implementation or independent retry loop is introduced. + +A bash wrapper alone would also work, but its subprocess must rediscover the +profile and its record is a bash command. Native calls preserve the direct +context and tool events, and make web use inspectable without parsing shell +strings. PlanDB stays in bash because it already has the run-bound shim and +lifecycle ownership checks. + +## Records and compatibility + +Every native action is recorded using the existing step recorder. The additive +optional `tool` field identifies the action beside its original argument object +and observation. Old trajectories still decode with an empty tool name. Native +web actions have no shell exit code, so a fetch cannot satisfy a declared shell +check merely by succeeding. Failed observations remain visible; no synthetic +successful search or fetch is recorded. + +## Acceptance + +Deterministic coverage executes search and fetch through the actual worker, +asserts the backend was called, and checks successful and failed observations, +tool identities and absence of shell exit codes. Factory coverage asserts an +explicit profile pin and a settings change after worker construction. + +Live acceptance uses OpenRouter `deepseek/deepseek-v4.1-flash` for work, +planning, checking and auxiliary calls. Run a research task which searches, +fetches a discovered URL, writes an answer with citations, and finishes through +PlanDB. Check actual task trajectories and recorded model requests/responses; +a prose claim that it searched, a skipped run, or a substituted model fails +acceptance. Evidence remains outside the source tree. diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index e935c8117f..a8e1ebdf03 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3384,3 +3384,20 @@ its own is retired. Failed tasks and work kept for review retain their copies and timelines. Old temporary conversations still follow the usual seven-day retention policy. Previously orphaned timelines are not automatically purged: a missing directory alone does not prove that its history is disposable. + +## Can bash tasks search the web and fetch pages? + +Yes. PlanDB run workers carry `web_search` and `web_fetch` as native tools, +using the run profile's search settings and credentials. They resolve those +settings again on each operation, including after a worker wakes. Files, +commands and PlanDB coordination still use `bash`. Each response may contain +one tool call: either bash or an available native tool. + +The shell commands `codeaf web search QUERY` and `codeaf web fetch URL` use +the same web backends. Native calls also record their tool name, arguments +and returned observation in the task's trajectory. A failed web call remains +a failed observation, never evidence that a page was read. + +A child reads the overall ask for requirements it owns. Delegation instructions +for the parent remain the parent's coordination work; a child repeats them only +when its own work order assigns coordination. diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 6b3604bf7c..21cb4ebce5 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -20,6 +20,7 @@ import ( "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/search" "github.com/Agent-Field/codeaf/internal/session" ) @@ -66,6 +67,9 @@ type BashWorker struct { // The door explains a refused account without changing the request road // already owned by the injected completer. authKeySource func(model string) string + // The web pair uses the run profile and resolves its settings per operation. + searchProvider search.Provider + searchFetcher search.Fetcher } // NewBashWorker builds the seat the run's factory hands each claimed task to. @@ -117,6 +121,8 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run Model: w.model, WaitForBeltSteps: true, AuthKeySource: w.authKeySource, + SearchProvider: w.searchProvider, + SearchFetcher: w.searchFetcher, }, w.completer, &task, w.store.Path(), w.store.RootID()) if err != nil { return Report{}, err @@ -821,6 +827,7 @@ func (r *stepRecorder) record(n int, event session.Event) error { step := Step{ Kind: trajectoryStepKind, Step: n, + Tool: event.Tool, Command: command, Observation: observationHead(event.Output), FullOutput: r.takeSpill(), diff --git a/internal/run/bashworker_web_test.go b/internal/run/bashworker_web_test.go new file mode 100644 index 0000000000..b789ed5607 --- /dev/null +++ b/internal/run/bashworker_web_test.go @@ -0,0 +1,199 @@ +package run + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/search" + "github.com/Agent-Field/codeaf/internal/session" +) + +type webSeat struct{ calls int } + +func (s *webSeat) CompleteWithMessages(_ context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + s.calls++ + name, args := "web_search", `{"query":"release notes","count":2}` + if s.calls > 1 { + name, args = "web_fetch", `{"url":"https://example.com/notes"}` + } + return &ai.Response{Choices: []ai.Choice{{Message: ai.Message{Role: "assistant", ToolCalls: []ai.ToolCall{{ID: name, Type: "function", Function: ai.ToolCallFunction{Name: name, Arguments: args}}}}}}}, nil +} + +type workerWeb struct { + queries, pages int + fail bool +} + +func (*workerWeb) Name() string { return "worker-web-fixture" } +func (w *workerWeb) Search(ctx context.Context, query string, count int) ([]search.Result, error) { + w.queries++ + if query != "release notes" || count != 2 { + return nil, errors.New("wrong search arguments") + } + return []search.Result{{Title: "Release notes", URL: "https://example.com/notes", Snippet: "Read the release notes"}}, nil +} +func (w *workerWeb) Fetch(ctx context.Context, url string) (string, error) { + w.pages++ + if w.fail { + return "", errors.New("fixture page unavailable") + } + if url != "https://example.com/notes" { + return "", errors.New("wrong fetch URL") + } + return "The release adds scoped initialization.", nil +} + +// The actual run worker must execute native web actions under the single-call +// envelope and preserve their identities and observations in the trajectory. +func TestBashWorkerRunsAndRecordsNativeWebTools(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "success", true: "fetch failure"}[failed], func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + stub := filepath.Join(root, "stub-codeaf") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_PLANDB_BIN", stub) + store, err := plandb.Open(filepath.Join(root, "plan.db"), "web", "root", "Research", "Research release notes") + if err != nil { + t.Fatal(err) + } + defer store.Close() + web := &workerWeb{fail: failed} + worker := NewBashWorker(store, root, "test/model", "", &webSeat{}) + worker.searchProvider, worker.searchFetcher = web, web + ctx, cancel := context.WithTimeout(WithStepsPerTask(t.Context(), 2), 10*time.Second) + defer cancel() + // Two actions exhaust this test's allowance; task completion is proved by + // the live acceptance run, not by a fabricated finish command here. + report, runErr := worker.Run(ctx, *store.Task(store.RootID())) + if runErr == nil || !strings.Contains(runErr.Error(), "stopped at its step cap after 2 steps") || report.Steps != 2 { + t.Fatalf("worker did not stop at the declared allowance: report=%+v err=%v", report, runErr) + } + steps, err := Trajectory(root, store.RootID()) + if err != nil { + t.Fatal(err) + } + if web.queries != 1 || web.pages != 1 || len(steps) != 2 { + t.Fatalf("search=%d fetch=%d steps=%+v", web.queries, web.pages, steps) + } + for i, name := range []string{"web_search", "web_fetch"} { + if steps[i].Tool != name || steps[i].NotRun || steps[i].ExitCode != nil { + t.Fatalf("native action was not recorded honestly: %+v", steps[i]) + } + if !json.Valid([]byte(steps[i].Command)) { + t.Fatalf("arguments lost: %+v", steps[i]) + } + } + want := "The release adds scoped initialization." + if failed { + want = "Fetch failed (worker-web-fixture): fixture page unavailable" + } + if !strings.Contains(steps[1].Observation, want) { + t.Fatalf("fetch observation=%q, want %q", steps[1].Observation, want) + } + }) + } +} + +// Both doors use CrewFactory. Its workers must bind the named profile, rather +// than the process default, and observe settings changed after construction. +func TestCrewFactoryWiresLiveWebFromItsProfile(t *testing.T) { + profile := t.TempDir() + for _, key := range []string{"EXA_API_KEY", "FIRECRAWL_API_KEY", "JINA_API_KEY"} { + t.Setenv(key, "") + } + write := func(pin, exaKey string) { + t.Helper() + data, _ := json.Marshal(map[string]string{config.KeySearchProvider: pin, config.KeyExaKey: exaKey}) + if err := os.WriteFile(config.BudgetConfigPath(profile), data, 0600); err != nil { + t.Fatal(err) + } + } + write("exa", "fixture-key") + factory := CrewFactory(nil, t.TempDir(), profile, Seats{One: "test/model"}, "", func(string) session.Completer { return &webSeat{} }) + worker := factory(plandb.Task{}).(*BashWorker) + if worker.searchProvider == nil || worker.searchFetcher == nil { + t.Fatal("run worker lost the web pair") + } + if got := worker.searchProvider.Name(); got != "exa" { + t.Fatalf("search=%q, want profile pin exa", got) + } + if got := worker.searchFetcher.Name(); got != "exa-fetch" { + t.Fatalf("fetch=%q, want the keyed provider from this profile", got) + } + write("duckduckgo", "") + if got := worker.searchProvider.Name(); got != "duckduckgo" { + t.Fatalf("search=%q, want changed pin duckduckgo", got) + } + if got := worker.searchFetcher.Name(); got != "jina" { + t.Fatalf("fetch=%q, want the keyless fetcher after the profile key was removed", got) + } +} + +// Cancelling a task must reach an in-flight native network action. A handshake +// makes cancellation happen after the backend starts, without a clock delay. +type waitingWorkerSearch struct{ entered, cancelled chan struct{} } + +func (*waitingWorkerSearch) Name() string { return "waiting-worker-search" } +func (w *waitingWorkerSearch) Search(ctx context.Context, _ string, _ int) ([]search.Result, error) { + close(w.entered) + <-ctx.Done() + close(w.cancelled) + return nil, ctx.Err() +} + +func TestBashWorkerCancellationReachesNativeWebBackend(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + root := t.TempDir() + t.Setenv("CODEAF_HOME", root) + stub := filepath.Join(root, "stub-codeaf") + if err := os.WriteFile(stub, []byte("#!/bin/sh\nexit 0\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("CODEAF_PLANDB_BIN", stub) + store, err := plandb.Open(filepath.Join(root, "plan.db"), "cancel-web", "root", "Research", "Research release notes") + if err != nil { + t.Fatal(err) + } + defer store.Close() + backend := &waitingWorkerSearch{entered: make(chan struct{}), cancelled: make(chan struct{})} + worker := NewBashWorker(store, root, "test/model", "", &webSeat{}) + worker.searchProvider = backend + guard, stop := context.WithTimeout(t.Context(), 10*time.Second) + defer stop() + ctx, cancel := context.WithCancel(guard) + defer cancel() + done := make(chan error, 1) + go func() { _, err := worker.Run(ctx, *store.Task(store.RootID())); done <- err }() + select { + case <-backend.entered: + case <-guard.Done(): + t.Fatal("native search never started") + } + cancel() + select { + case <-backend.cancelled: + case <-guard.Done(): + t.Fatal("task cancellation never reached native search") + } + select { + case err := <-done: + if err == nil { + t.Fatal("cancelled task returned success") + } + case <-guard.Done(): + t.Fatal("worker did not finish after its native search was cancelled") + } +} diff --git a/internal/run/crew.go b/internal/run/crew.go index 238263e2ec..bdc5182321 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -41,6 +41,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/search" "github.com/Agent-Field/codeaf/internal/session" ) @@ -134,6 +135,11 @@ func CrewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, } workerFor := func(model string, completer session.Completer) *BashWorker { worker := NewBashWorker(store, workspace, model, standing, completer) + // A run worker has no parent agent to inherit the web pair from. Bind + // the same live resolver the chat and CLI use to this run's profile. + worker.searchProvider, worker.searchFetcher = search.Live(func() search.Options { + return config.SearchOptionsAt(profileDir) + }) worker.authKeySource = func(model string) string { return config.APIKeySourceForModel(profileDir, sources, model) } diff --git a/internal/run/trajectory.go b/internal/run/trajectory.go index cc36e58596..ed5723ad89 100644 --- a/internal/run/trajectory.go +++ b/internal/run/trajectory.go @@ -57,6 +57,8 @@ type Step struct { Kind string `json:"kind"` // Step is the step's number, counted from one over the task across its wakes. Step int `json:"step"` + // Tool identifies the native action; older records leave it empty. + Tool string `json:"tool,omitempty"` // Command is what the worker asked the belt to run, as the model spelled // it — the command a resumed worker must not repeat blind, and the one // address the record has for what this step was. diff --git a/internal/session/bashbelt_envelope.go b/internal/session/bashbelt_envelope.go index ba8cfa4303..1425e6549b 100644 --- a/internal/session/bashbelt_envelope.go +++ b/internal/session/bashbelt_envelope.go @@ -66,7 +66,7 @@ const ( // it, and the recorded sentence ends with [loopLeftUndoneNote] — the one // sentence [endingOfClaim] reads a worker's last words for — so the node's // landing is written by the road that already exists instead of a second one. -const bashEnvelopeStop = "stopped: four responses in a row carried no valid single bash action, so nothing was run · " + loopLeftUndoneNote +const bashEnvelopeStop = "stopped: four responses in a row carried no valid single tool action, so nothing was run · " + loopLeftUndoneNote // bashEnvelopeFault is what is wrong with one submission under the envelope, // and empty for one that may run. A response with no calls is a final answer, @@ -80,11 +80,11 @@ func (a *Agent) bashEnvelopeFault(calls []ai.ToolCall) string { return "" } if len(calls) > 1 { - return bashEnvelopeMark + "no action executed: return exactly one bash tool call per response — this belt has one hand, and the shell carries its own parallelism (& with wait, xargs -P)" + return bashEnvelopeMark + "no action executed: return exactly one tool call per response — use bash for files and commands or an available native tool; the shell carries its own parallelism (& with wait, xargs -P)" } call := calls[0] if !bashCallsAddressable(calls) { - return bashEnvelopeMark + "no action executed: a tool call carries no id, so its result could never be paired with it — send the bash call again as the provider's tool-call form" + return bashEnvelopeMark + "no action executed: a tool call carries no id, so its result could never be paired with it — send the tool call again as the provider's tool-call form" } if !a.beltCarries(call.Function.Name) { return bashEnvelopeMark + "no action executed: `" + call.Function.Name + "` is not on this belt — bash is the hand for files and commands, and what the belt cannot do is spelled in its own page" diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index b93ebb589e..b79ede57df 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -261,7 +261,7 @@ func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, // is for. const ( askSectionHeading = "## The ask this run serves" - askSectionRule = "Your work order above is your part; where it and the ask disagree on a requirement you own, the ask wins, and you say so in your report." + askSectionRule = "Your work order above is your part; where it and the ask disagree on a requirement you own, the ask wins, and you say so in your report. Delegation instructions in the overall ask belong to its coordinator; do not repeat them unless your own work order assigns coordination." // askSectionLimit is the hard cap on the verbatim ask. The whole value of // carrying it is that nothing was edited out, and the bound is for the // pasted-log case, where an unbounded copy would put megabytes into every diff --git a/internal/session/bashbelt_worker_test.go b/internal/session/bashbelt_worker_test.go index e97ebe03bb..2ffcc5ac97 100644 --- a/internal/session/bashbelt_worker_test.go +++ b/internal/session/bashbelt_worker_test.go @@ -107,13 +107,16 @@ func askStore(t *testing.T, rootDescription string) *plandb.Store { // under the heading and its one rule — so the leaf that owns a part reads the // whole ask, omissions and all. func TestBeltWorkerBriefCarriesTheRunsAskToALeaf(t *testing.T) { - ask := "Scoped containers can be initialized independently; the parent container's singletons are not reinitialized." + ask := "Delegate the research to one child. Scoped containers can be initialized independently; the parent container's singletons are not reinitialized." store := askStore(t, ask) doc := BeltWorkerBrief(store, store.Task("leaf"), false, false, "", "") want := askSectionHeading + "\n" + askSectionRule + "\n\n" + ask if !strings.Contains(doc, want) { t.Fatalf("a leaf's document does not carry the run's ask verbatim under %q:\n%s", askSectionHeading, doc) } + if !strings.Contains(doc, "Delegation instructions in the overall ask belong to its coordinator; do not repeat them unless your own work order assigns coordination.") { + t.Fatal("a research leaf was handed the parent's delegation request without an ownership boundary") + } } // TestBeltWorkerBriefLeavesTheRootsOwnDocumentAlone is the emptiness law: the diff --git a/internal/session/prompt_belt_pages_test.go b/internal/session/prompt_belt_pages_test.go index 1377f20b9c..0940f081af 100644 --- a/internal/session/prompt_belt_pages_test.go +++ b/internal/session/prompt_belt_pages_test.go @@ -61,6 +61,11 @@ func TestABeltWorkerReadsTheBeltsPagesAndNothingElse(t *testing.T) { t.Errorf("a belt worker's page carries %q, which only prompts/system.md has", foreign) } } + // The policy precedes the web guidance, so a stale bash-only restriction + // there would override the tools the worker actually carries. + if strings.Contains(page, "one native bash tool call") || !strings.Contains(page, "one available tool call per response") { + t.Fatal("the worker policy still restricts native web actions to bash") + } // AND THE TWO FACTS IT ACTS ON SURVIVE: the finish verb and the plan CLI. for _, want := range []string{"plandb done", "plandb"} { if !strings.Contains(page, want) { diff --git a/internal/session/prompts/bashtask.md b/internal/session/prompts/bashtask.md index 87100cd97b..b4e3b33a09 100644 --- a/internal/session/prompts/bashtask.md +++ b/internal/session/prompts/bashtask.md @@ -1,13 +1,13 @@ You own one task within a shared objective. Every worker uses this same loop. Optimize time to a verified result through useful parallel work and clear -dependencies. Use exactly one native bash tool call per turn. Wait for its real +dependencies. Use exactly one available tool call per response. Wait for its real observation. Never simulate execution or verification. Bash commands run in fresh shells. Your loop is FRAME → PLAN → DISPATCH → WAIT → INTEGRATE, and it repeats until the assignment is verifiably satisfied. -THREE VERBS, AND NO REPLY ENDS A TASK. You ACT with a bash call; you FINISH +THREE VERBS, AND NO REPLY ENDS A TASK. You ACT with one tool call; you FINISH with `plandb done` on your own task, after acceptance holds; you WAIT with `plandb wait` when you are blocked on a dependency or a child. A reply with no action runs nothing and does not end anything — the runtime answers it in its diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index b8fe9e3e3d..6c042c0176 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -1,19 +1,24 @@ ## Working through bash -This belt carries ONE tool: `bash`. Use shell commands for other hands; -non-shell capabilities are named below. +Use `bash` for files, commands and PlanDB. Native tools are also available +when defined. Return exactly ONE tool call per response. Invalid or multiple calls run NOTHING. A `[not run]` reply explains the error; correct it and retry. -Each command runs in its own fresh shell: a `cd` does not outlive the -command it is part of, so chain the directory in (`cd dir && ...`) or use -the path. +Each command starts a fresh shell. Use paths or `cd dir && ...`; `cd` does +not persist. + +## Web sources + +Use available `web_search` and `web_fetch` tools to find and read pages. +Cite source URLs. Failed calls are not evidence. Prefer native calls for +named task logs. ## Think once, then act -Reason between calls only to choose the next command. Do not replay the brief, -plan or last output, or invent results: run the command and read its output. +Choose the next action. Do not replay the brief or last output, or invent +results: execute the action and read its output. ## The plan