Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions internal/manual/chat/senior-dev.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 27 additions & 2 deletions internal/manual/chat/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 9 additions & 1 deletion internal/session/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ───────────────────────────────────────────
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions internal/session/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/session/prompts/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
53 changes: 53 additions & 0 deletions internal/session/proposal_decline.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading