From c8c596a7c32659ec126ef96fdbfdde1d3977e779 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Wed, 30 Sep 2026 21:32:30 -0400 Subject: [PATCH] tasks: a declined proposal is respected until the next human message Declining a task proposal previously returned ordinary feedback without stopping the chat from editing the same files or starting replacement work in that turn. The engine now holds action tools when a person declines, while leaving reads and questions available. It checks staged sibling admission and automatic handoffs as well, so those roads cannot bypass the answer. Broad reading falls back to inline reads instead of silently starting a quick worker after a no. A new human message permits normal work again, while an automatic wake does not. The decline remains a normal result and explains that the work was not started. The task and senior-dev manual pages and the system prompt state the refusal and the limit on all shell commands after a decline. Scripted tests drive a real Agent and real file tools through ordinary and senior-dev declines, attempted replacement work, automatic wakes and a later direct-fix request. Existing design tests remain unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/manual/chat/senior-dev.md | 12 + internal/manual/chat/tasks.md | 29 ++- internal/session/checkpoint.go | 10 +- internal/session/hooks.go | 5 + internal/session/prompts/system.md | 1 + internal/session/proposal_decline.go | 53 ++++ internal/session/proposal_decline_test.go | 302 ++++++++++++++++++++++ internal/session/route_judge.go | 5 + internal/session/session.go | 3 + internal/session/task.go | 14 +- internal/session/task_brief.go | 1 + internal/session/task_quick.go | 5 + 12 files changed, 435 insertions(+), 5 deletions(-) create mode 100644 internal/session/proposal_decline.go create mode 100644 internal/session/proposal_decline_test.go diff --git a/internal/manual/chat/senior-dev.md b/internal/manual/chat/senior-dev.md index 8167394ba8..83e01a0f73 100644 --- a/internal/manual/chat/senior-dev.md +++ b/internal/manual/chat/senior-dev.md @@ -48,6 +48,18 @@ was, and that proposal passes. card. Work codeaf moves to a task on its own, because a reply ran long or looked like work, goes to codeaf's own worker, never to senior-dev. +## If I decline senior-dev, does the chat fix it anyway + +Press `2` with the message box empty to decline a senior-dev proposal. The card +says `no · declined`, and no run starts. The chat cannot then edit your files, +run shell commands, or hand that work out again while answering the same request. +It can explain that the work was not started and ask or offer in words. + +A new message from you can ask for a direct fix, and normal work is allowed +again. An automatic task or job ending does not lift the refusal. The exact +refusal sentence and the limit on all shell commands are in the task manual's +“After I say no, can codeaf fix it itself” section. + ## Watching senior-dev work — open its task, what it is doing step by step, how long it has run, stop it A senior-dev run is a task of the conversation that started it. Its compact side-list diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 94eecd66ee..09abee80f4 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -304,7 +304,9 @@ four files to answer one question about them is one quick task with four items, the fourth read is worth more to somebody who has seen the first three. And **one quick task is a few files and a few minutes** — see *How big one quick task should be* below. -You can overrule it either way in words, and the model follows. +You can overrule it either way in words, and the model follows. After you decline +a task proposal, reading stays in the conversation until a new message from you; +codeaf does not start a quick worker for it. ## How big one quick task should be — a quick task that ran out of rounds, one that read twenty files and cost a dollar, why a big package became several @@ -1434,6 +1436,27 @@ moving hand is not answered by a keystroke aimed at your sentence. You can also click an answer: each answer's row is pressable along its whole width. +## After I say no, can codeaf fix it itself — declined task and senior-dev proposals + +Pressing `2` on a task proposal means the work was not started. This includes a +proposal handed to senior-dev. The chat can still read files or the manual and +ask or offer in words, but it cannot write files, generate files, run shell +commands, or start replacement work while answering that same request. Reading +stays in the conversation; it does not start a new quick task after a decline. + +An attempted action is refused with this exact sentence: + +> the person declined a task proposal, so no work was started; do not change files or start replacement work until a new message from the person; explain that and ask or offer in words + +All shell commands are held after a decline, including commands that look like +checks: a shell command's text cannot establish that it will leave files alone. +Work already running before your answer is not cancelled or undone. + +A **new message from you** may ask for the fix directly, and the chat can then +work normally. A task or background job ending by itself is not a new message +from you and does not lift the refusal. An approved correction or the countdown +starting a task is a yes, and does not impose this refusal. + ## Choosing a different model or opening the task proposal's brief **Honest limit:** there is no longer any way to pick the model from the proposal. When a @@ -1501,7 +1524,9 @@ typed does not restart it. **`1` start it** admits the work exactly as briefed. **`2` no** declines. Nothing is spawned, no row appears on the roster, and no room exists. -This is a normal answer, not an error. +This is a normal answer, not an error. The chat does not then do that work itself or +hand it out again while answering the same request. A new message from you can ask +for a direct fix, and normal work is allowed again. **Anything you type is a correction**, and a correction is a yes to the corrected version. Your words travel verbatim and are appended to the brief; this is the last moment the brief diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index c91a4afe05..7315d4a9d8 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -2144,6 +2144,9 @@ const ( // every drop above because nothing was decided about the WORK: the seam has // no owner to admit a task on behalf of ([Agent.handOverRunningTurn]). checkpointCeilingAbandoned = "dropped:turn-abandoned" + // checkpointCeilingDeclined records a handover withheld because the person + // already said no to work in this request, rather than because it was done. + checkpointCeilingDeclined = "dropped:task-declined" ) // THE LAW: ONE ENDING ROW PER ENDING, WRITTEN AT THE SEAM THAT TOOK IT. @@ -3178,7 +3181,7 @@ func (a *Agent) checkpoints(ctx context.Context, user userMessage) bool { } a.mu.Lock() defer a.mu.Unlock() - return !a.closed + return !a.closed && !a.proposalDeclined } // ── a turn ends; the ask does not ─────────────────────────────────────────── @@ -4180,6 +4183,11 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us if ctx.Err() != nil { return checkpointHandover{decision: checkpointCeilingAbandoned} } + // A decline must not become a paid handover through the automatic road. + // This check precedes every reading, brief and task ground it could create. + if a.taskWorkDeclined() { + return checkpointHandover{decision: checkpointCeilingDeclined, reason: declinedProposalRefusal} + } // THE FLOOR STANDS IN FRONT OF EVERYTHING ELSE. A caller that reached here // through [Agent.checkpoints] already asked, but looped.go's looping // handoff and any future door share this function, and a handover that diff --git a/internal/session/hooks.go b/internal/session/hooks.go index eacc42981f..3235cc6e51 100644 --- a/internal/session/hooks.go +++ b/internal/session/hooks.go @@ -505,6 +505,11 @@ type approvalGate struct{ agent *Agent } func (approvalGate) Name() string { return "approval" } func (g approvalGate) PreAction(ctx context.Context, ep *episode, hub *eventHub, call ai.ToolCall) (ai.ToolCall, toolResult, bool) { + // A blanket allow cannot unsay a no the person just gave this turn. The + // same gate covers early reads and ordinary batches, before either acts. + if refused, held := g.agent.declinedProposalCall(call); held { + return call, refused, false + } if ep != nil && ep.userBash { refused, allowed := g.agent.approveUserBash(ctx, hub, call) return call, refused, allowed diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index f500c32edc..10c1c5a68b 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -196,4 +196,5 @@ BEFORE RUNNING A COMMAND, CHECK THE TRANSCRIPT. If its answer is already here, u # Critical - Don't end the turn while work the person asked for remains; phase boundary/todo flip/sub-step never stops: same turn. +- A DECLINED TASK IS NOT WORK THAT REMAINS: say it was not started and ask or offer in words. Do not do it yourself or hand it out again until a new human message asks for work; action tools are held after a decline. - HANDED-OFF WORK IS NOT WORK THAT REMAINS: end your reply once nothing independent of it is left. A task of your own still owes its deliverable whatever it hands out. diff --git a/internal/session/proposal_decline.go b/internal/session/proposal_decline.go new file mode 100644 index 0000000000..432a4176ba --- /dev/null +++ b/internal/session/proposal_decline.go @@ -0,0 +1,53 @@ +package session + +// A NO TO A TASK IS NOT AN INVITATION TO DO IT HERE. The model may explain or +// ask what should happen next, but the same request cannot pay for a different +// road to the work the person just declined. This is an authority check before +// approval, not advice a model can choose to ignore. + +import ( + "encoding/json" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +const declinedProposalRefusal = "the person declined a task proposal, so no work was started; do not change files or start replacement work until a new message from the person; explain that and ask or offer in words" + +// taskWorkDeclined survives automatic wakes because only a new human message +// can authorize work again. Its caller never keeps the lock while running a +// tool or waiting for an answer. +func (a *Agent) taskWorkDeclined() bool { + a.mu.Lock() + defer a.mu.Unlock() + return a.proposalDeclined +} + +// declinedProposalCall leaves only observation and conversation available. +// SHELL TEXT CANNOT PROVE AN ABSENCE OF WRITES, so every bash call is held, as +// are unknown tools: a new writing hand must not silently bypass the person's +// no. Tools with both reading and acting verbs are checked by their arguments. +func (a *Agent) declinedProposalCall(call ai.ToolCall) (toolResult, bool) { + if !a.taskWorkDeclined() || proposalObservation(call) { + return toolResult{}, false + } + return toolResult{text: declinedProposalRefusal, isError: true}, true +} + +func proposalObservation(call ai.ToolCall) bool { + switch call.Function.Name { + case "read", "ls", "find", "grep", "read_document", "manual", "view_image", + "web_search", "web_fetch", "search_conversations", "settings", "services", + "list_harnesses", "list_subharnesses", "use_skill", loadCapabilityToolName, "ask": + return true + case "tasks": + var args tasksArguments + return json.Unmarshal([]byte(call.Function.Arguments), &args) == nil && + !args.Stop && !args.Forward && !args.Continue && strings.TrimSpace(args.Resolve) == "" && + strings.TrimSpace(args.Say) == "" && strings.TrimSpace(args.Note) == "" + case "jobs": + args, err := parseJobsArguments(json.RawMessage(call.Function.Arguments)) + return err == nil && (args.Action == "list" || args.Action == "output") + } + return false +} diff --git a/internal/session/proposal_decline_test.go b/internal/session/proposal_decline_test.go new file mode 100644 index 0000000000..a78da45409 --- /dev/null +++ b/internal/session/proposal_decline_test.go @@ -0,0 +1,302 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/manual" +) + +// These scripts try to do the refused work anyway. They exercise the real +// Agent and the real file and shell tools, so an obedient model cannot hide a +// missing runtime guard. +func TestADeclinedProposalPreventsSameRequestWritesAndReplacementWork(t *testing.T) { + for _, via := range []string{"", "senior-dev"} { + name := via + if name == "" { + name = "ordinary task" + } + t.Run(name, func(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &scriptedCompleter{steps: []step{ + declineProposalCall(via), + declineActionCall("edit", `{"path":"strutil.py","edits":[{"oldText":"return s.lower()","newText":"return '-'.join(s.lower().split())"}]}`), + writeCall("call-write", "written.txt", "unasked\n"), + bashCall("call-bash", "printf unasked > shell.txt"), + declineProposalCall(via), + declineActionCall("quick_task", `{"title":"fix slugify","brief":"fix slugify in strutil.py","acceptance":"tests pass"}`), + declineActionCall("read", `{"path":"strutil.py"}`), + finalText("The work was not started. Tell me if you want a direct fix."), + }} + agent, workspace := declineTestAgent(t, completer) + const original = "def slugify(s):\n return s.lower()\n" + writeFile(t, filepath.Join(workspace, "strutil.py"), original) + graph := stubbedGraph(agent, func(*TaskNode) { + t.Error("work started after the person declined") + }) + asked := "fix slugify and its tests" + if via != "" { + asked += " with " + via + } + collected := drainAnsweringTasks(t, mustSubmit(t, agent, asked), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + agent.ResolveTask(event.Task.ID, TaskAnswer{Redirect: "leave it alone"}) + } + }) + if got, err := os.ReadFile(filepath.Join(workspace, "strutil.py")); err != nil || string(got) != original { + t.Fatalf("declined work changed strutil.py: %q, %v", got, err) + } + for _, path := range []string{"written.txt", "shell.txt"} { + if _, err := os.Stat(filepath.Join(workspace, path)); !os.IsNotExist(err) { + t.Fatalf("declined work created %s: %v", path, err) + } + } + if got := admitted(graph); got != 0 { + t.Fatalf("declined work admitted %d tasks", got) + } + for _, tool := range []string{"edit", "write", "bash", "quick_task"} { + assertDeclineRefusal(t, collected, tool) + } + outputs := proposeOutputs(collected) + if len(outputs) != 2 || !strings.Contains(outputs[0], "the person declined this task: leave it alone") || + !strings.Contains(outputs[0], "no work was started") || !strings.Contains(outputs[1], "until a new message from the person") { + t.Fatalf("proposal results lost the decline or allowed replacement work: %q", outputs) + } + if kind, ok := toolResultKind(collected, "propose_task"); !ok || kind != EventToolEnd { + t.Fatal("the original decline became an error") + } + if kind, ok := toolResultKind(collected, "read"); !ok || kind != EventToolEnd { + t.Fatal("reading the unchanged file was refused") + } + }) + } +} + +func TestANewHumanMessageAllowsWorkAfterADeclinedProposal(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + declineProposalCall(""), + writeCall("call-unasked", "strutil.py", "unasked\n"), + finalText("The work was not started. Would you like a direct fix?"), + writeCall("call-asked", "strutil.py", "def slugify(s):\n return '-'.join(s.lower().split())\n"), + finalText("Applied the direct fix you asked for."), + }} + agent, workspace := declineTestAgent(t, completer) + writeFile(t, filepath.Join(workspace, "strutil.py"), "original\n") + first := drainAnsweringTasks(t, mustSubmit(t, agent, "fix slugify and its tests"), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + agent.ResolveTask(event.Task.ID, TaskAnswer{}) + } + }) + assertDeclineRefusal(t, first, "write") + second := collect(t, mustSubmit(t, agent, "Fix strutil.py directly now.")) + if kind, ok := toolResultKind(second, "write"); !ok || kind != EventToolEnd { + t.Fatal("a new direct-fix request did not lift the refusal") + } + if got, err := os.ReadFile(filepath.Join(workspace, "strutil.py")); err != nil || !strings.Contains(string(got), "'-'.join") { + t.Fatalf("the newly requested fix did not reach the file: %q, %v", got, err) + } +} + +func TestAnAutomaticWakeDoesNotLiftADeclinedProposal(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + declineProposalCall(""), + finalText("The work was not started."), + writeCall("call-wake", "strutil.py", "unasked\n"), + finalText("The work still needs your word."), + }} + agent, workspace := declineTestAgent(t, completer) + writeFile(t, filepath.Join(workspace, "strutil.py"), "original\n") + drainAnsweringTasks(t, mustSubmit(t, agent, "fix slugify and its tests"), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + agent.ResolveTask(event.Task.ID, TaskAnswer{}) + } + }) + events, err := agent.submitUser(context.Background(), wakeNote("a background job ended")) + if err != nil { + t.Fatalf("automatic wake: %v", err) + } + assertDeclineRefusal(t, collect(t, events), "write") + if got, err := os.ReadFile(filepath.Join(workspace, "strutil.py")); err != nil || string(got) != "original\n" { + t.Fatalf("an automatic wake changed the declined file: %q, %v", got, err) + } +} + +func TestADeclineStopsAnAlreadyStagedSiblingFromStarting(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + proposalsInOneMessage("Fix slugify", "Fix its tests"), + finalText("The work was not started."), + }} + agent, _ := declineTestAgent(t, completer) + graph := stubbedGraph(agent, func(*TaskNode) { t.Error("a sibling started after a no") }) + var first uint64 + var questions int + drainAnsweringTasks(t, mustSubmit(t, agent, "fix slugify and its tests"), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + questions++ + if first == 0 { + first = event.Task.ID + return + } + // Both cards are open before either is answered. The second call + // already passed pre-action, so admission must still respect the no. + agent.ResolveTask(first, TaskAnswer{}) + agent.ResolveTask(event.Task.ID, TaskAnswer{Approved: true}) + } + }) + if questions != 2 || admitted(graph) != 0 { + t.Fatalf("a decline admitted sibling work: questions %d, nodes %d", questions, admitted(graph)) + } +} + +func TestADeclineHoldsActionsAndKeepsObservation(t *testing.T) { + agent, _ := declineTestAgent(t, &scriptedCompleter{}) + agent.mu.Lock() + agent.proposalDeclined = true + agent.mu.Unlock() + for _, c := range []struct { + tool, args string + observe bool + }{ + {"read", `{"path":"strutil.py"}`, true}, + {"manual", `{"query":"decline a task"}`, true}, + {"ask", `{}`, true}, + {"tasks", `{}`, true}, + {"tasks", `{"id":"1","continue":true}`, false}, + {"tasks", `{"id":"1","say":"fix slugify"}`, false}, + {"tasks", `{"id":"1","forward":true}`, false}, + {"tasks", `{"id":"1","note":"fix slugify"}`, false}, + {"jobs", `{"action":"list"}`, true}, + {"jobs", `{"action":"output","id":1}`, true}, + {"jobs", `{"action":"kill","id":1}`, false}, + {"bash", `{"command":"python3 -m unittest"}`, false}, + {"generate_image", `{"prompt":"a picture"}`, false}, + {"workspace_restore", `{"confirm":true}`, false}, + {"workspace_merge", `{}`, false}, + {"propose_subharness", `{}`, false}, + {"a_future_writing_tool", `{}`, false}, + } { + t.Run(c.tool+"/"+c.args, func(t *testing.T) { + call := ai.ToolCall{Function: ai.ToolCallFunction{Name: c.tool, Arguments: c.args}} + result, held := agent.declinedProposalCall(call) + if held == c.observe || (held && !strings.Contains(result.text, "until a new message from the person")) { + t.Fatalf("%s observation %v, held %v, result %q", c.tool, c.observe, held, result.text) + } + }) + } +} + +func TestADeclinedProposalCannotBecomeAnAutomaticHandoff(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + declineProposalCall(""), + finalText("The work was not started."), + }} + agent, _ := declineTestAgent(t, completer) + graph := stubbedGraph(agent, func(*TaskNode) { t.Error("automatic replacement work started") }) + drainAnsweringTasks(t, mustSubmit(t, agent, "fix slugify and its tests"), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + agent.ResolveTask(event.Task.ID, TaskAnswer{}) + } + }) + if agent.checkpoints(context.Background(), userText("fix slugify and its tests")) { + t.Fatal("declined work was eligible to be moved automatically") + } + said, id := agent.launchRouteTask(nil, routeVerdict{Goal: "fix slugify and its tests"}, "Fix slugify", drawnDivision{}, nil) + if id != 0 || !strings.Contains(said, "until a new message from the person") { + t.Fatalf("automatic route answered id %d, %q", id, said) + } + ending := agent.handOverRunningTurn(context.Background(), nil, &Usage{}, time.Now(), "test/model", "move the work", "test", 0, nil, routeVerdict{}, checkpointRead{}, nil) + if ending.moved || ending.taskID != 0 || !strings.Contains(ending.reason, "until a new message from the person") { + t.Fatalf("pending automatic handoff escaped the decline: %+v", ending) + } + if admitted(graph) != 0 { + t.Fatal("automatic replacement work was admitted") + } +} + +func TestReadingAfterADeclineDoesNotStartAQuickWorker(t *testing.T) { + registerBeltRunEngine(t, newBeltRunDouble("unused")) + completer := &scriptedCompleter{steps: []step{ + declineProposalCall(""), + declineActionCall("read", `{"path":"strutil.py"}`), + declineActionCall("read", `{"path":"test_strutil.py"}`), + declineActionCall("read", `{"path":"README.md"}`), + finalText("The work was not started. I can discuss what I read."), + }} + agent, workspace := declineTestAgent(t, completer) + for _, path := range []string{"strutil.py", "test_strutil.py", "README.md"} { + writeFile(t, filepath.Join(workspace, path), "unchanged "+path+"\n") + } + graph := stubbedGraph(agent, func(node *TaskNode) { + // Settle immediately so a missing guard fails on admission rather than + // spending the read handoff's real timeout waiting for a fake worker. + node.graph.complete(node, TaskDone) + }) + collected := drainAnsweringTasks(t, mustSubmit(t, agent, "fix slugify and its tests"), func(event Event) { + if event.Kind == EventTaskProposal && event.Task.Decided == nil { + agent.ResolveTask(event.Task.ID, TaskAnswer{}) + } + }) + if admitted(graph) != 0 { + t.Fatal("reading after a decline started replacement work through a quick worker") + } + for _, path := range []string{"strutil.py", "test_strutil.py", "README.md"} { + found := false + for _, event := range collected { + found = found || (event.Tool == "read" && event.Kind == EventToolEnd && strings.Contains(event.Output, "unchanged "+path)) + } + if !found { + t.Fatalf("%s was not read inline after the decline", path) + } + } +} + +func declineTestAgent(t *testing.T, completer Completer) (*Agent, string) { + t.Helper() + return newTestAgent(t, completer, func(config *Config) { + config.AskConsent = true + config.TaskAutoApproveSeconds = 0 + config.ApprovalPolicy = &approval.Policy{Default: approval.ActionAllow} + config.Delegates = testPrograms("senior-dev") + }) +} + +func declineProposalCall(via string) step { + arguments, _ := json.Marshal(taskArguments{ + Title: "Fix slugify and its tests", Summary: "Fix the slug utility and tests.", + Brief: "Fix slugify in strutil.py and its tests.", Deliverable: "the fixed utility and tests", + Acceptance: "python3 -m unittest passes", Via: via, + }) + return declineActionCall("propose_task", string(arguments)) +} + +func declineActionCall(tool, arguments string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("call-"+tool, tool, arguments), nil + } +} + +func assertDeclineRefusal(t *testing.T, events []Event, tool string) { + t.Helper() + for _, event := range events { + if event.Tool == tool && event.Kind == EventToolFailed && event.Refused && + strings.Contains(event.Output, "until a new message from the person") { + return + } + } + t.Fatalf("%s did not return the engine's decline refusal; events: %v", tool, kinds(events)) +} + +func TestTheTaskManualQuotesTheDeclineGuard(t *testing.T) { + page, ok := manual.Chat().Page("tasks") + const sentence = "the person declined a task proposal, so no work was started; do not change files or start replacement work until a new message from the person; explain that and ask or offer in words" + if !ok || !strings.Contains(page, sentence) { + t.Fatalf("the task manual does not quote the decline refusal %q", sentence) + } +} diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 0732b42b98..2a5091b0e1 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -1121,6 +1121,11 @@ func (a *Agent) confirmRouteAhead(ctx context.Context, asked string) (routeVerdi // things this function decides — a done-condition, a place on the ground // ladder, a name, a width to arm — are the things a quick node does not have. func (a *Agent) launchRouteTask(hub *eventHub, verdict routeVerdict, title string, drawn drawnDivision, ahead *nameAhead) (string, uint64) { + // Automatic handoffs owe the same no as a model's next proposal. This + // door has no card and never passes through the tool approval gate. + if a.taskWorkDeclined() { + return declinedProposalRefusal, 0 + } // THE LAST LINE OF THE FLOOR (spawnfloor.go). Both roads into this function // already return above on a one-command ask; a reserved id for work that // must not start would be the floor leaking a node number into a conversation diff --git a/internal/session/session.go b/internal/session/session.go index 3048bc4384..79287dbae4 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2753,6 +2753,9 @@ type Agent struct { // personSeq numbers those turns so that the same sentence typed twice is two // events rather than one. personSeq uint64 + // proposalDeclined holds action tools after a person says no to a task. + // Only another human message clears it; a task or job wake is no new ask. + proposalDeclined bool // personAt is when the last thing they typed was heard, which is the order // their corrections are weighed in once a task can be reached through two // doors of different speeds (assignment.go's [taskAssignment.lastSpokenApplied]). diff --git a/internal/session/task.go b/internal/session/task.go index 2d119f259c..0e6f74b97e 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -863,9 +863,14 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // question and got a plain no; handing it a failure would put a red row // in the transcript for a conversation working exactly as intended. if reason := strings.TrimSpace(answer.Redirect); reason != "" { - return withElsewhere("the person declined this task: "+reason, elsewhere), false, nil + return withElsewhere("the person declined this task: "+reason+"; "+declinedProposalRefusal, elsewhere), false, nil } - return withElsewhere("the person declined this task", elsewhere), false, nil + return withElsewhere("the person declined this task; "+declinedProposalRefusal, elsewhere), false, nil + } + // A sibling can have passed pre-action while this card was still open. + // Recheck at admission so an early-staged call cannot start after a no. + if a.taskWorkDeclined() { + return declinedProposalRefusal, true, nil } if redirect := strings.TrimSpace(answer.Redirect); redirect != "" { // APPENDED, never merged into the brief's prose. The person's words @@ -1238,6 +1243,11 @@ func (a *Agent) ResolveTask(id uint64, answer TaskAnswer) { question, waiting := a.taskAnswers[id] if waiting { delete(a.taskAnswers, id) + // THE NO TAKES EFFECT BEFORE ITS RESULT. A card can be answered while + // its call is still streaming or a sibling is waiting to be admitted. + if !answer.Approved { + a.proposalDeclined = true + } } a.mu.Unlock() if !waiting { diff --git a/internal/session/task_brief.go b/internal/session/task_brief.go index 977bdd8ce0..b562dbd82a 100644 --- a/internal/session/task_brief.go +++ b/internal/session/task_brief.go @@ -1029,6 +1029,7 @@ func (a *Agent) rememberAskLocked(user userMessage) { return } if text := strings.TrimSpace(user.text()); text != "" { + a.proposalDeclined = false a.personAsk = text // AND WHICH TURN THEY TYPED IT INTO, which is the difference between words // the person is saying now and words they said before the last thing that diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 5195812b23..70f1985df8 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -478,6 +478,11 @@ func (a *Agent) startQuickTask(_ context.Context, args json.RawMessage) (string, // the admission can fail, and [TaskGraph.admit] hands the slot back itself as // the node starts counting for itself, so there is no release path to remember. func (a *Agent) admitQuick(ask quickAsk) (uint64, taskSpec, quickRefusal) { + // Reads can ask this door for a worker without making a tool call. After + // a no, their refusal falls back to inline reading instead of starting work. + if a.taskWorkDeclined() { + return 0, taskSpec{}, quickRefusal{said: declinedProposalRefusal} + } graph := a.graph() graph.quickGate.Lock() defer graph.quickGate.Unlock()