Agent: a turn that changed nothing cannot say it did #132
@@ -192,6 +192,13 @@ Conventions that follow from it:
|
|||||||
the argument is what makes the rule visible in the schema, the prompt is what
|
the argument is what makes the rule visible in the schema, the prompt is what
|
||||||
makes the model ask. Neither is a guarantee, and a new outward-facing tool
|
makes the model ask. Neither is a guarantee, and a new outward-facing tool
|
||||||
gets the same pair.
|
gets the same pair.
|
||||||
|
- **A turn that changed nothing cannot say it did.** `honestReply` in
|
||||||
|
`runtime.go` appends a correction when the reply claims a change ("Done —
|
||||||
|
I've deleted…") and no non-read-only tool call succeeded in the run. Live,
|
||||||
|
glm-5.2 did this twice in one session (a journal entry, then a seed lot:
|
||||||
|
"Done", nothing deleted). The prompt rule stays; the guard is for when the
|
||||||
|
model ignores it. `readOnlyTools` must list every tool that changes nothing
|
||||||
|
— a new read-only tool left out of it makes a turn look like it acted.
|
||||||
- **Garden notes are the assistant's memory.** `systemPrompt` quotes
|
- **Garden notes are the assistant's memory.** `systemPrompt` quotes
|
||||||
`Garden.Notes` (owner-written, `%q`) as standing context, and `update_garden`
|
`Garden.Notes` (owner-written, `%q`) as standing context, and `update_garden`
|
||||||
is how the model adds "we're in zone 6a" to them. Notes are replaced whole,
|
is how the model adds "we're in zone 6a" to them. Notes are replaced whole,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -168,6 +169,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
|||||||
if result != nil {
|
if result != nil {
|
||||||
turn.Reply = result.Output
|
turn.Reply = result.Output
|
||||||
turn.Steps = len(result.Steps)
|
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 == "" {
|
if turn.Reply == "" {
|
||||||
turn.Reply = fallbackReply(turn)
|
turn.Reply = fallbackReply(turn)
|
||||||
@@ -175,6 +182,78 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, toda
|
|||||||
return turn, nil
|
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` +
|
||||||
|
gitea-actions
commented
🟠 changeClaim regex duplicates verb lists across two alternations, creating a maintenance hazard maintainability · flagged by 4 models
🪰 Gadfly · advisory 🟠 **changeClaim regex duplicates verb lists across two alternations, creating a maintenance hazard**
_maintainability · flagged by 4 models_
- `internal/agent/runtime.go:197-198` — `changeClaim` duplicates its verb inventory across two alternations. A single source of truth (e.g., a slice composed into the pattern) would be easier to keep consistent and less prone to omissions. As it stands, the two lists already diverge: `created`, `set`, `made`, `edited` and others appear only in the `I've …` branch, while `done` appears only in the sentence-opener branch. That makes the regex harder to maintain and more likely to miss new claim ph…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
`|\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] + "…"
|
||||||
|
gitea-actions
commented
⚪ text[:80] byte-slice can split a multibyte rune, producing invalid UTF-8 in the log line error-handling · flagged by 2 models
🪰 Gadfly · advisory ⚪ **text[:80] byte-slice can split a multibyte rune, producing invalid UTF-8 in the log line**
_error-handling · flagged by 2 models_
- **`internal/agent/runtime.go:219` — `text[:80]` slices on a byte boundary and can split a multibyte rune.** Model text often contains non-ASCII (em-dashes, accented plant names); truncating at byte 80 can cut mid-rune, yielding invalid UTF-8 in the `%q`-formatted log line. No panic and no user impact (log-only; `%q` escapes the bad bytes), but trivially wrong. Fix: truncate on a rune boundary. Severity trivial.
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
}
|
||||||
|
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] {
|
||||||
|
gitea-actions
commented
🟠 undo_change that reverts nothing (cs==nil) counts as 'acted', so honestReply misses a false 'I've undone it' claim error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟠 **undo_change that reverts nothing (cs==nil) counts as 'acted', so honestReply misses a false 'I've undone it' claim**
_error-handling · flagged by 1 model_
- **`internal/agent/runtime.go:234` — a successful tool that changed nothing still counts as "acted", so the guard has a hole for exactly the case it targets.** `acted` returns true for any `!IsError && !readOnlyTools[name]` result. But `undo_change` returns a **non-error** result when it reverted nothing: `undoChange` (`tools.go:716`) handles `cs == nil` ("every revision was a conflict, or the set was empty") by returning `(res, nil)` with `res.Changes = "nothing"`. So a turn where the model ca…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
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) {
|
||||||
|
gitea-actions
commented
🟡 changeClaim opener false-positives on read-only informational replies starting with 'Done'/'Updated', appending a bogus correction error-handling · flagged by 1 model
🪰 Gadfly · advisory 🟡 **changeClaim opener false-positives on read-only informational replies starting with 'Done'/'Updated', appending a bogus correction**
_error-handling · flagged by 1 model_
- **`internal/agent/runtime.go:251` — the guard false-positives on a read-only turn whose reply merely opens with a change verb.** The `^\s*(?:done|fixed|updated|…)\b` opener matches replies like *"Done — here's your garden: …"* or *"Updated: you have 12 beds"* to a pure information request answered with only read-only tools (`acted() == false`). Such a turn gets *"nothing actually changed in this turn … Ask again and I'll do it properly"* appended even though the user never asked for a change —…
<sub>🪰 Gadfly · advisory</sub>
|
|||||||
|
return reply
|
||||||
|
}
|
||||||
|
return reply + unbackedClaim
|
||||||
|
}
|
||||||
|
|
||||||
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
// 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.
|
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
||||||
func isLoopLimit(err error) bool {
|
func isLoopLimit(err error) bool {
|
||||||
@@ -318,7 +397,10 @@ How to work:
|
|||||||
How to behave:
|
How to behave:
|
||||||
- Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked,
|
- 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
|
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
|
- 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.
|
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"
|
- When a request could mean materially different things — "swap the cucumbers and the melons"
|
||||||
|
|||||||
@@ -591,3 +591,63 @@ func TestSystemPromptCarriesTheGardenersNotes(t *testing.T) {
|
|||||||
t.Error("a garden without notes doesn't say so")
|
t.Error("a garden without notes doesn't say so")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAClaimedChangeNoToolMadeIsCorrected — the live model, asked to delete a
|
||||||
|
// journal entry, answered "Done — I've deleted it" having called nothing, and
|
||||||
|
// the entry was found still there a turn later. The prompt forbids that; the
|
||||||
|
// run now catches it too: a reply that claims a change, in a turn where no
|
||||||
|
// tool call changed anything, gets a correction the person can read.
|
||||||
|
func TestAClaimedChangeNoToolMadeIsCorrected(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garden: %v", err)
|
||||||
|
}
|
||||||
|
entry, err := svc.CreateJournalEntry(ctx, owner, g.ID, service.JournalInput{Body: "Aphids on the cucumbers."})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("journal: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
run := func(steps ...fake.Step) string {
|
||||||
|
t.Helper()
|
||||||
|
turn, err := scriptedRunner(t, svc, steps...).Run(ctx, owner, g.ID, "delete that note", "", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
return turn.Reply
|
||||||
|
}
|
||||||
|
corrected := func(reply string) bool { return strings.Contains(reply, "nothing actually changed") }
|
||||||
|
|
||||||
|
// No tool at all, a confident claim: corrected.
|
||||||
|
if r := run(fake.Reply("Done — I've deleted the journal entry about the aphids.")); !corrected(r) {
|
||||||
|
t.Errorf("a claim with no tool call passed uncorrected: %q", r)
|
||||||
|
}
|
||||||
|
// Only reads, then a claim: corrected.
|
||||||
|
if r := run(toolCall("read_journal", map[string]any{"gardenId": g.ID}), fake.Reply("I've deleted it.")); !corrected(r) {
|
||||||
|
t.Errorf("a claim over read-only calls passed uncorrected: %q", r)
|
||||||
|
}
|
||||||
|
// A tool that FAILED, then a claim: corrected — and the failure names the
|
||||||
|
// entry and where the ids come from, so a model that reads it has no
|
||||||
|
// excuse to guess again.
|
||||||
|
box := NewToolbox(svc, owner, "")
|
||||||
|
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "delete_journal_entry", Arguments: mustJSON(t, map[string]any{"entryId": 999})})
|
||||||
|
if !res.IsError || !strings.Contains(res.Content, "read_journal") || !strings.Contains(res.Content, "nothing was changed") {
|
||||||
|
t.Errorf("deleting a missing entry = %q, want a refusal naming read_journal and saying nothing changed", res.Content)
|
||||||
|
}
|
||||||
|
if r := run(toolCall("delete_journal_entry", map[string]any{"entryId": 999}), fake.Reply("Done — it's gone.")); !corrected(r) {
|
||||||
|
t.Errorf("a claim over a failed call passed uncorrected: %q", r)
|
||||||
|
}
|
||||||
|
// No claim: nothing appended, whatever the tools did.
|
||||||
|
if r := run(fake.Reply("That note is still there — want me to delete it?")); corrected(r) {
|
||||||
|
t.Errorf("an offer was corrected as if it were a claim: %q", r)
|
||||||
|
}
|
||||||
|
// A real deletion: the claim stands.
|
||||||
|
r := run(toolCall("delete_journal_entry", map[string]any{"entryId": entry.ID}), fake.Reply("Done — I've deleted the journal entry."))
|
||||||
|
if corrected(r) {
|
||||||
|
t.Errorf("a true claim was corrected: %q", r)
|
||||||
|
}
|
||||||
|
if _, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{}); err != nil {
|
||||||
|
t.Fatalf("journal after: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+29
-11
@@ -157,7 +157,8 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
|
|||||||
a.updateJournalEntry),
|
a.updateJournalEntry),
|
||||||
llm.DefineTool("delete_journal_entry",
|
llm.DefineTool("delete_journal_entry",
|
||||||
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+
|
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+
|
||||||
"not in the undo history — so delete only the entry the user pointed at.",
|
"not in the undo history — so delete only the entry the user pointed at. Get the id "+
|
||||||
|
"from read_journal in this turn: ids from earlier turns are not in front of you.",
|
||||||
a.deleteJournalEntry),
|
a.deleteJournalEntry),
|
||||||
llm.DefineTool("read_history",
|
llm.DefineTool("read_history",
|
||||||
"Read the garden's change history: every change anyone made — by hand in the editor, or "+
|
"Read the garden's change history: every change anyone made — by hand in the editor, or "+
|
||||||
@@ -227,7 +228,8 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
|
|||||||
llm.DefineTool("delete_seed_lot",
|
llm.DefineTool("delete_seed_lot",
|
||||||
"Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+
|
"Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+
|
||||||
"to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+
|
"to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+
|
||||||
"are not in the undo history — so delete only the lot the user pointed at.",
|
"are not in the undo history — so delete only the lot the user pointed at. Get the id "+
|
||||||
|
"from list_seed_lots in this turn: ids from earlier turns are not in front of you.",
|
||||||
a.deleteSeedLot),
|
a.deleteSeedLot),
|
||||||
llm.DefineTool("delete_plant",
|
llm.DefineTool("delete_plant",
|
||||||
"Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+
|
"Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+
|
||||||
@@ -468,8 +470,9 @@ func (a *adapter) movePlanting(ctx context.Context, args struct {
|
|||||||
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"`
|
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"`
|
||||||
ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"`
|
ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
return a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
|
pl, err := a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
|
||||||
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
|
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
|
||||||
|
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||||
@@ -608,7 +611,19 @@ func (a *adapter) updatePlanting(ctx context.Context, args struct {
|
|||||||
case args.SeedLotID != nil:
|
case args.SeedLotID != nil:
|
||||||
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
|
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
|
||||||
}
|
}
|
||||||
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
|
pl, err := a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
|
||||||
|
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// whenMissing turns the store's bare "not found" into a message that says what
|
||||||
|
// was not found and where the ids come from, so the model re-reads instead of
|
||||||
|
// guessing at another id — or, as the live one did, reporting success over
|
||||||
|
// the error.
|
||||||
|
func whenMissing(err error, what string, id int64, from string) error {
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return fmt.Errorf("%w: no %s with id %d that you can act on — %s lists the ids; nothing was changed", domain.ErrNotFound, what, id, from)
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseDay checks a date the model typed, so a malformed one fails with a
|
// parseDay checks a date the model typed, so a malformed one fails with a
|
||||||
@@ -660,15 +675,16 @@ func (a *adapter) updateJournalEntry(ctx context.Context, args struct {
|
|||||||
}
|
}
|
||||||
observed = &on
|
observed = &on
|
||||||
}
|
}
|
||||||
return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
|
e, err := a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
|
||||||
service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version)
|
service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version)
|
||||||
|
return e, whenMissing(err, "journal entry", args.EntryID, "read_journal")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
|
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
|
||||||
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
|
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
|
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
|
||||||
return nil, err
|
return nil, whenMissing(err, "journal entry", args.EntryID, "read_journal")
|
||||||
}
|
}
|
||||||
return map[string]any{"deleted": args.EntryID}, nil
|
return map[string]any{"deleted": args.EntryID}, nil
|
||||||
}
|
}
|
||||||
@@ -694,7 +710,7 @@ func (a *adapter) undoChange(ctx context.Context, args struct {
|
|||||||
}
|
}
|
||||||
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
|
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, whenMissing(err, "change", args.ChangeSetID, "read_history")
|
||||||
}
|
}
|
||||||
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
|
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
|
||||||
if cs == nil {
|
if cs == nil {
|
||||||
@@ -806,7 +822,8 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on)
|
pl, err := a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on)
|
||||||
|
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||||||
@@ -934,14 +951,15 @@ func (a *adapter) updateSeedLot(ctx context.Context, args struct {
|
|||||||
SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear,
|
SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear,
|
||||||
SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct,
|
SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct,
|
||||||
}
|
}
|
||||||
return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
|
l, err := a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
|
||||||
|
return l, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *adapter) deleteSeedLot(ctx context.Context, args struct {
|
func (a *adapter) deleteSeedLot(ctx context.Context, args struct {
|
||||||
LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"`
|
LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil {
|
if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil {
|
||||||
return nil, err
|
return nil, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
|
||||||
}
|
}
|
||||||
return map[string]any{"deleted": args.LotID}, nil
|
return map[string]any{"deleted": args.LotID}, nil
|
||||||
}
|
}
|
||||||
@@ -965,7 +983,7 @@ func (a *adapter) deletePlanting(ctx context.Context, args struct {
|
|||||||
PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"`
|
PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil {
|
if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil {
|
||||||
return nil, err
|
return nil, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||||||
}
|
}
|
||||||
return map[string]any{"deleted": args.PlantingID}, nil
|
return map[string]any{"deleted": args.PlantingID}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user
🟠 public_link action=get bypasses honestReply: acted() returns true for a read-only call, silencing the false-claim correction
correctness, maintainability · flagged by 3 models
internal/agent/runtime.go:187–191—public_link action=getbypasses the honest-reply guard (confirmed)🪰 Gadfly · advisory