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: 14 additions & 0 deletions docs/changes/unreleased/1783-task-web-tools.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 65 additions & 0 deletions docs/design/task-web-tools.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions internal/manual/chat/how-tasks-run.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
7 changes: 7 additions & 0 deletions internal/run/bashworker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand Down
199 changes: 199 additions & 0 deletions internal/run/bashworker_web_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
6 changes: 6 additions & 0 deletions internal/run/crew.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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)
}
Expand Down
2 changes: 2 additions & 0 deletions internal/run/trajectory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 3 additions & 3 deletions internal/session/bashbelt_envelope.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion internal/session/bashbelt_worker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading