Address #132 review: one verb list, self-reporting tools, rune-safe log
Build image / build-and-push (push) Successful in 8s

- changeClaim is built from one changeVerbs list; the opener is just
  done/fixed/undone so an informational "Updated totals:" can't trip it.
- public_link (get reads) and undo_change (nothing left to revert) are
  self-reporting: their success no longer counts as a change by name; the
  adapter says whether they changed something (noteChange / didChange).
- whenMissing covers the object and plant tools too (move/update/delete
  object, clear/remove plantings by object, update/delete plant).
- The step summary cuts on a rune boundary.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 02:49:55 -04:00
co-authored by Claude Fable 5
parent d0ca56b79b
commit d4eb62a2ba
3 changed files with 80 additions and 23 deletions
+30 -16
View File
@@ -169,7 +169,7 @@ 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 {
if corrected := honestReply(turn.Reply, result, tools); 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))
@@ -190,12 +190,23 @@ var readOnlyTools = map[string]bool{
"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)`)
// selfReportingTools succeed without necessarily changing anything —
// public_link with action=get reads, and undo_change with nothing left to
// revert reverts nothing — so their results don't count; the adapter says
// whether they changed something (adapter.changed).
var selfReportingTools = map[string]bool{"public_link": true, "undo_change": true}
// changeVerbs are the past participles a claim of change is made of. One
// list, used by both shapes the claim takes.
const changeVerbs = `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`
// changeClaim matches a reply that reports a change as made: a "Done"/"Fixed"/
// "Undone" 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. The opener list is
// short on purpose: "Updated totals:" opening a read-only answer must not
// trip it, and those replies say "I've …" when they mean a change.
var changeClaim = regexp.MustCompile(`(?i)(?:^\s*(?:done|fixed|undone)\b|\bI(?:'ve| have)? (?:just |now |also |already )?(?:` + changeVerbs + `)\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._"
@@ -215,8 +226,10 @@ func describeSteps(r *agent.Result) string {
}
if st.Response != nil {
if text := strings.Join(strings.Fields(st.Response.Text()), " "); text != "" {
if len(text) > 80 {
text = text[:80] + "…"
// Cut on a rune boundary, like turnSummary: a byte slice can
// split a multibyte character and log invalid UTF-8.
if runes := []rune(text); len(runes) > 80 {
text = string(runes[:80]) + "…"
}
fmt.Fprintf(&b, " %q", text)
}
@@ -226,17 +239,18 @@ func describeSteps(r *agent.Result) 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 {
// acted reports whether the run changed anything: a successful call to a tool
// that is neither read-only nor self-reporting, or a self-reporting tool that
// told the adapter it changed something.
func acted(r *agent.Result, tools *adapter) bool {
for _, st := range r.Steps {
for _, res := range st.Results {
if !res.IsError && !readOnlyTools[res.Name] {
if !res.IsError && !readOnlyTools[res.Name] && !selfReportingTools[res.Name] {
return true
}
}
}
return false
return tools != nil && tools.didChange()
}
// honestReply appends a correction to a reply that claims a change when no
@@ -247,8 +261,8 @@ func acted(r *agent.Result) bool {
// 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) {
func honestReply(reply string, r *agent.Result, tools *adapter) string {
if r == nil || acted(r, tools) || !changeClaim.MatchString(reply) {
return reply
}
return reply + unbackedClaim