Agent: a turn that changed nothing cannot say it did
Live, asked to delete a journal entry and later a seed lot, the model
answered "Done — I've deleted it" both times having deleted nothing; each
was still there a turn later. The prompt already forbade that. Now the run
catches it: honestReply appends a correction when the reply claims a change
("Done", "I've deleted…") and no non-read-only tool call succeeded, and logs
the steps so the mechanism can be read off the log next time.
Alongside: the id-taking tools turn a bare "not found" into a message that
names what was missing and which tool lists the ids ("nothing was changed"),
the two delete descriptions say to look the id up in THIS turn, and the
prompt says an error result means the thing did not happen.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -168,6 +169,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
||||
if result != nil {
|
||||
turn.Reply = result.Output
|
||||
turn.Steps = len(result.Steps)
|
||||
if corrected := honestReply(turn.Reply, result); corrected != turn.Reply {
|
||||
// The steps are logged so the mechanism can be read off the log
|
||||
// next time — which tool it tried, what came back, what it said.
|
||||
slog.Warn("agent: reply claimed a change no tool made", "run", runID, "garden", gardenID, "steps", describeSteps(result))
|
||||
turn.Reply = corrected
|
||||
}
|
||||
}
|
||||
if turn.Reply == "" {
|
||||
turn.Reply = fallbackReply(turn)
|
||||
@@ -175,6 +182,78 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
||||
return turn, nil
|
||||
}
|
||||
|
||||
// readOnlyTools are the tools whose success changes nothing — a turn made of
|
||||
// these alone has not done anything, whatever its reply says.
|
||||
var readOnlyTools = map[string]bool{
|
||||
"list_gardens": true, "describe_garden": true, "list_years": true, "list_plantings": true,
|
||||
"find_plant": true, "read_journal": true, "read_history": true, "list_seed_lots": true,
|
||||
"list_shares": true,
|
||||
}
|
||||
|
||||
// changeClaim matches a reply that reports a change as made: a "Done"/"Fixed"
|
||||
// opener, or a first-person past-tense claim ("I've deleted", "I moved"). A
|
||||
// question or an offer ("want me to delete it?", "I'll remove it") does not
|
||||
// match — only a claim of something already done.
|
||||
var changeClaim = regexp.MustCompile(`(?i)(?:^\s*(?:done|fixed|undone|deleted|removed|updated|changed|added|saved|moved|planted|filled|cleared|corrected|recorded|marked|shared|renamed|reverted)\b` +
|
||||
`|\bI(?:'ve| have)? (?:just |now |also |already )?(?:deleted|removed|updated|changed|added|saved|moved|planted|filled|cleared|corrected|recorded|marked|shared|renamed|reverted|undone|set|put|pulled|replaced|swapped|rotated|rewrote|rewritten|edited|created|started|attached|restored|made)\b)`)
|
||||
|
||||
// unbackedClaim is what the person reads under a claim no tool backs up.
|
||||
const unbackedClaim = "\n\n_Correction: nothing actually changed in this turn — no tool call that changes anything succeeded. Ask again and I'll do it properly._"
|
||||
|
||||
// describeSteps summarizes a run for a log line: per step, the tools it
|
||||
// called (with ! on a failure) and the start of what the model said.
|
||||
func describeSteps(r *agent.Result) string {
|
||||
parts := make([]string, 0, len(r.Steps))
|
||||
for _, st := range r.Steps {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%d:", st.Index)
|
||||
for _, res := range st.Results {
|
||||
b.WriteString(" " + res.Name)
|
||||
if res.IsError {
|
||||
b.WriteString("!")
|
||||
}
|
||||
}
|
||||
if st.Response != nil {
|
||||
if text := strings.Join(strings.Fields(st.Response.Text()), " "); text != "" {
|
||||
if len(text) > 80 {
|
||||
text = text[:80] + "…"
|
||||
}
|
||||
fmt.Fprintf(&b, " %q", text)
|
||||
}
|
||||
}
|
||||
parts = append(parts, b.String())
|
||||
}
|
||||
return strings.Join(parts, " | ")
|
||||
}
|
||||
|
||||
// acted reports whether any tool call in the run succeeded at something that
|
||||
// is not read-only.
|
||||
func acted(r *agent.Result) bool {
|
||||
for _, st := range r.Steps {
|
||||
for _, res := range st.Results {
|
||||
if !res.IsError && !readOnlyTools[res.Name] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// honestReply appends a correction to a reply that claims a change when no
|
||||
// tool call in the run made one. The prompt already forbids this, and the
|
||||
// live model did it anyway: asked to delete a journal entry it answered
|
||||
// "Done — I've deleted it" having deleted nothing, and the entry was found
|
||||
// still there a turn later. The person should hear that from the app, not
|
||||
// discover it. A reply that claims nothing, or a run in which some change
|
||||
// succeeded, passes through unchanged — this cannot tell a true claim from a
|
||||
// false one once anything at all was done, so it only speaks when nothing was.
|
||||
func honestReply(reply string, r *agent.Result) string {
|
||||
if r == nil || acted(r) || !changeClaim.MatchString(reply) {
|
||||
return reply
|
||||
}
|
||||
return reply + unbackedClaim
|
||||
}
|
||||
|
||||
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
||||
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
||||
func isLoopLimit(err error) bool {
|
||||
@@ -318,7 +397,10 @@ How to work:
|
||||
How to behave:
|
||||
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked,
|
||||
say so plainly — never describe a change you did not make, and never say something is undone
|
||||
unless undo_change did it.
|
||||
unless undo_change did it. A tool result that is an error means the thing did not happen: say
|
||||
it failed and why, and what you will try instead. Before you say you deleted, changed or added
|
||||
something, there must be a successful tool result for it in THIS turn — an earlier turn does
|
||||
not count, and neither does meaning to.
|
||||
- Every reply of yours that changed the garden has an "Undo this" button under it, and the
|
||||
History panel can revert any change; mention that when it helps.
|
||||
- When a request could mean materially different things — "swap the cucumbers and the melons"
|
||||
|
||||
Reference in New Issue
Block a user