From b1d0f94d85938798466e47228a13e8e85321d7cc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:23:19 -0400 Subject: [PATCH 1/7] fix: wire native web tools into PlanDB task workers --- docs/changes/unreleased/0-task-web-tools.md | 12 ++ docs/design/task-web-tools.md | 62 +++++++++ internal/manual/chat/how-tasks-run.md | 13 ++ internal/run/bashworker.go | 7 ++ internal/run/bashworker_web_test.go | 131 ++++++++++++++++++++ internal/run/crew.go | 6 + internal/run/trajectory.go | 2 + internal/session/bashbelt_envelope.go | 6 +- internal/session/prompts/bashworker.md | 19 +-- 9 files changed, 248 insertions(+), 10 deletions(-) create mode 100644 docs/changes/unreleased/0-task-web-tools.md create mode 100644 docs/design/task-web-tools.md create mode 100644 internal/run/bashworker_web_test.go diff --git a/docs/changes/unreleased/0-task-web-tools.md b/docs/changes/unreleased/0-task-web-tools.md new file mode 100644 index 0000000000..e5e299e908 --- /dev/null +++ b/docs/changes/unreleased/0-task-web-tools.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: PlanDB run workers can search and fetch through native tools +pr: 0 +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. diff --git a/docs/design/task-web-tools.md b/docs/design/task-web-tools.md new file mode 100644 index 0000000000..a80bdcde77 --- /dev/null +++ b/docs/design/task-web-tools.md @@ -0,0 +1,62 @@ +# 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. + +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..393a6a88ae 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3384,3 +3384,16 @@ 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. 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..73835f8e0d --- /dev/null +++ b/internal/run/bashworker_web_test.go @@ -0,0 +1,131 @@ +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, messages []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. + _, _ = worker.Run(ctx, *store.Task(store.RootID())) + 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() + write := func(pin string) { + t.Helper() + data, _ := json.Marshal(map[string]string{config.KeySearchProvider: pin}) + if err := os.WriteFile(config.BudgetConfigPath(profile), data, 0600); err != nil { + t.Fatal(err) + } + } + write("exa") + 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) + } + write("duckduckgo") + if got := worker.searchProvider.Name(); got != "duckduckgo" { + t.Fatalf("search=%q, want changed pin duckduckgo", got) + } +} 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/prompts/bashworker.md b/internal/session/prompts/bashworker.md index b8fe9e3e3d..49853bc9f0 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; bash can also run `codeaf web search` / `codeaf web fetch`. ## 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 From 348e7692f74d77d31fb788c3a821704024fcdc8f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:23:40 -0400 Subject: [PATCH 2/7] docs: prepare task web capability change entry --- docs/changes/unreleased/0-task-web-tools.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/changes/unreleased/0-task-web-tools.md b/docs/changes/unreleased/0-task-web-tools.md index e5e299e908..2346781019 100644 --- a/docs/changes/unreleased/0-task-web-tools.md +++ b/docs/changes/unreleased/0-task-web-tools.md @@ -1,7 +1,7 @@ --- kind: fixed title: PlanDB run workers can search and fetch through native tools -pr: 0 +pr: 1 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." From 66e0624ea97d6abe1e080074f42f5c8bcfcc31ab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:24:23 -0400 Subject: [PATCH 3/7] docs: align change entry name and number --- .../unreleased/{0-task-web-tools.md => 1-task-web-tools.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename docs/changes/unreleased/{0-task-web-tools.md => 1-task-web-tools.md} (100%) diff --git a/docs/changes/unreleased/0-task-web-tools.md b/docs/changes/unreleased/1-task-web-tools.md similarity index 100% rename from docs/changes/unreleased/0-task-web-tools.md rename to docs/changes/unreleased/1-task-web-tools.md From 97abf9c0e242688155cba9a19f5b95b51c05407d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:25:49 -0400 Subject: [PATCH 4/7] docs: keep task web guidance focused on native calls --- internal/session/prompts/bashworker.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/session/prompts/bashworker.md b/internal/session/prompts/bashworker.md index 49853bc9f0..6c042c0176 100644 --- a/internal/session/prompts/bashworker.md +++ b/internal/session/prompts/bashworker.md @@ -13,7 +13,7 @@ not persist. 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; bash can also run `codeaf web search` / `codeaf web fetch`. +named task logs. ## Think once, then act From 7d23e7006fafc4f6a5943603a04f3394fb356b79 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:31:09 -0400 Subject: [PATCH 5/7] docs: associate task web tools with PR 1783 --- .../unreleased/{1-task-web-tools.md => 1783-task-web-tools.md} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename docs/changes/unreleased/{1-task-web-tools.md => 1783-task-web-tools.md} (98%) diff --git a/docs/changes/unreleased/1-task-web-tools.md b/docs/changes/unreleased/1783-task-web-tools.md similarity index 98% rename from docs/changes/unreleased/1-task-web-tools.md rename to docs/changes/unreleased/1783-task-web-tools.md index 2346781019..9107054b8f 100644 --- a/docs/changes/unreleased/1-task-web-tools.md +++ b/docs/changes/unreleased/1783-task-web-tools.md @@ -1,7 +1,7 @@ --- kind: fixed title: PlanDB run workers can search and fetch through native tools -pr: 1 +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." From c0b6cdd16bd30dc4b22e2f979af6327f20423fcb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:38:35 -0400 Subject: [PATCH 6/7] test: harden native web cancellation and profile coverage --- internal/run/bashworker_web_test.go | 80 ++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 6 deletions(-) diff --git a/internal/run/bashworker_web_test.go b/internal/run/bashworker_web_test.go index 73835f8e0d..b789ed5607 100644 --- a/internal/run/bashworker_web_test.go +++ b/internal/run/bashworker_web_test.go @@ -19,7 +19,7 @@ import ( type webSeat struct{ calls int } -func (s *webSeat) CompleteWithMessages(_ context.Context, messages []ai.Message, _ ...ai.Option) (*ai.Response, error) { +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 { @@ -77,7 +77,10 @@ func TestBashWorkerRunsAndRecordsNativeWebTools(t *testing.T) { 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. - _, _ = worker.Run(ctx, *store.Task(store.RootID())) + 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) @@ -108,14 +111,17 @@ func TestBashWorkerRunsAndRecordsNativeWebTools(t *testing.T) { // than the process default, and observe settings changed after construction. func TestCrewFactoryWiresLiveWebFromItsProfile(t *testing.T) { profile := t.TempDir() - write := func(pin string) { + 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}) + 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") + 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 { @@ -124,8 +130,70 @@ func TestCrewFactoryWiresLiveWebFromItsProfile(t *testing.T) { if got := worker.searchProvider.Name(); got != "exa" { t.Fatalf("search=%q, want profile pin exa", got) } - write("duckduckgo") + 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") + } } From 3deb957c698dabbea820af0017eb083923c5d780 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Tue, 6 Oct 2026 08:46:01 -0400 Subject: [PATCH 7/7] fix: align native action policy and child coordination scope --- docs/changes/unreleased/1783-task-web-tools.md | 4 +++- docs/design/task-web-tools.md | 5 ++++- internal/manual/chat/how-tasks-run.md | 4 ++++ internal/session/bashbelt_worker.go | 2 +- internal/session/bashbelt_worker_test.go | 5 ++++- internal/session/prompt_belt_pages_test.go | 5 +++++ internal/session/prompts/bashtask.md | 4 ++-- 7 files changed, 23 insertions(+), 6 deletions(-) diff --git a/docs/changes/unreleased/1783-task-web-tools.md b/docs/changes/unreleased/1783-task-web-tools.md index 9107054b8f..b802d9387b 100644 --- a/docs/changes/unreleased/1783-task-web-tools.md +++ b/docs/changes/unreleased/1783-task-web-tools.md @@ -9,4 +9,6 @@ invalidates: --- Task trajectories now retain the native tool name beside arguments and returned -observations. Files and PlanDB coordination continue through bash. +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 index a80bdcde77..8a79993de9 100644 --- a/docs/design/task-web-tools.md +++ b/docs/design/task-web-tools.md @@ -24,7 +24,10 @@ 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. +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 diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 393a6a88ae..a8e1ebdf03 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3397,3 +3397,7 @@ 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/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