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()