Agent tools: plant lookup, plant creation, journal entries (#55)
"Change the garlic garden bed to instead be cucumbers this year" was nearly executable already — describe_garden resolves "the garlic bed" to an object id because it reports the NAMES of what's planted, clear_object empties it, and fill_region replants it. Step three had no way to get its plantId. The toolbox had no plant tool at all, so the agent could see the string "Garlic" come out of describe_garden and had no route from "cucumbers" to an id. One missing tool blocked the whole flagship interaction. find_plant matches the actor's visible catalog and deliberately returns SEVERAL candidates rather than one guess. "garlic" against a catalog holding both "Garlic" and "German Red Garlic" is genuinely ambiguous, and a caller holding the surrounding conversation is far better placed to disambiguate than a fuzzy-match heuristic here. Results are ranked exact, then prefix, then substring, then category, and ordered stably — a tool whose results reshuffle between calls is one a model can't reason about across turns. Each result also reports how much seed is left of that plant across the actor's lots, so the agent can say "you only have enough for half that bed" instead of confidently planting seed that doesn't exist. Omitted rather than zeroed when there are no lots, and dropped when lots disagree about the unit, since summing seeds and grams would be a lie. create_plant lets a variety be named in conversation without leaving the chat. It's user-scoped, not garden-scoped — someone with no editable garden can still name a plant — which the tests assert deliberately rather than assume. add_journal_entry arrived with #52: "note that the west bed has mildew" is squarely the kind of thing you say out loud while walking around. Also reviewed the descriptions on the existing seven tools, since they are the model's only documentation. fill_region's region vocabulary now says north is the top of the garden, gives a worked example of the replant sequence, and notes that filling twice is safe. The demo sequence is now a test: describe_garden → find_plant("cucumber") → clear_object → fill_region, ending with a bed of cucumbers and no active garlic. go.mod is deliberately untouched — majordomo stays out of the module until #56 drops the build tag, so the default build is unaffected. Closes #55 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -69,6 +70,112 @@ func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant
|
||||
return s.store.ListPlantsForActor(ctx, actorID)
|
||||
}
|
||||
|
||||
// PlantMatch is a candidate from FindPlants: the plant, plus what seed the actor
|
||||
// has left of it, so an agent can say "you only have enough for half that bed"
|
||||
// instead of confidently planting seed that doesn't exist.
|
||||
type PlantMatch struct {
|
||||
domain.Plant
|
||||
// SeedRemaining is the total left across the actor's lots of this plant, and
|
||||
// SeedUnit the unit they're counted in — omitted when there are no lots, or
|
||||
// when lots disagree about the unit and summing them would be a lie.
|
||||
SeedRemaining *float64 `json:"seedRemaining,omitempty"`
|
||||
SeedUnit string `json:"seedUnit,omitempty"`
|
||||
}
|
||||
|
||||
// maxPlantMatches caps FindPlants. A model given fifty candidates is not being
|
||||
// helped; if the query is that broad the answer is to ask a better one.
|
||||
const maxPlantMatches = 10
|
||||
|
||||
// FindPlants returns the plants in the actor's visible catalog (built-ins plus
|
||||
// their own) whose name or category matches the query, best match first.
|
||||
//
|
||||
// It deliberately returns SEVERAL candidates rather than one guess. "garlic"
|
||||
// against a catalog holding both "Garlic" and "German Red Garlic" is genuinely
|
||||
// ambiguous, and a caller with the surrounding conversation is far better placed
|
||||
// to disambiguate than a fuzzy-match heuristic here. An empty query returns the
|
||||
// catalog head rather than nothing, so a caller can browse.
|
||||
func (s *Service) FindPlants(ctx context.Context, actorID int64, query string) ([]PlantMatch, error) {
|
||||
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := strings.ToLower(strings.TrimSpace(query))
|
||||
|
||||
type scored struct {
|
||||
plant domain.Plant
|
||||
rank int
|
||||
}
|
||||
ranked := make([]scored, 0, len(plants))
|
||||
for _, p := range plants {
|
||||
name := strings.ToLower(p.Name)
|
||||
switch {
|
||||
case q == "":
|
||||
ranked = append(ranked, scored{p, 3})
|
||||
case name == q:
|
||||
ranked = append(ranked, scored{p, 0})
|
||||
case strings.HasPrefix(name, q):
|
||||
ranked = append(ranked, scored{p, 1})
|
||||
case strings.Contains(name, q):
|
||||
ranked = append(ranked, scored{p, 2})
|
||||
case strings.Contains(strings.ToLower(p.Category), q):
|
||||
ranked = append(ranked, scored{p, 3})
|
||||
}
|
||||
}
|
||||
// Stable by rank then name, so the same query always answers the same way —
|
||||
// a tool whose results reshuffle between calls is one a model can't reason
|
||||
// about across turns.
|
||||
sort.SliceStable(ranked, func(i, j int) bool {
|
||||
if ranked[i].rank != ranked[j].rank {
|
||||
return ranked[i].rank < ranked[j].rank
|
||||
}
|
||||
return ranked[i].plant.Name < ranked[j].plant.Name
|
||||
})
|
||||
if len(ranked) > maxPlantMatches {
|
||||
ranked = ranked[:maxPlantMatches]
|
||||
}
|
||||
|
||||
matches := make([]PlantMatch, 0, len(ranked))
|
||||
for _, r := range ranked {
|
||||
matches = append(matches, PlantMatch{Plant: r.plant})
|
||||
}
|
||||
if err := s.attachSeedRemaining(ctx, actorID, matches); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// attachSeedRemaining fills SeedRemaining/SeedUnit from the actor's lots.
|
||||
func (s *Service) attachSeedRemaining(ctx context.Context, actorID int64, matches []PlantMatch) error {
|
||||
if len(matches) == 0 {
|
||||
return nil
|
||||
}
|
||||
lots, err := s.ListSeedLots(ctx, actorID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
byPlant := map[int64][]domain.SeedLot{}
|
||||
for _, l := range lots {
|
||||
byPlant[l.PlantID] = append(byPlant[l.PlantID], l)
|
||||
}
|
||||
for i := range matches {
|
||||
ls := byPlant[matches[i].ID]
|
||||
if len(ls) == 0 {
|
||||
continue
|
||||
}
|
||||
total := 0.0
|
||||
unit := ls[0].Unit
|
||||
for _, l := range ls {
|
||||
total += l.Remaining
|
||||
if l.Unit != unit {
|
||||
unit = "" // mixed units can't be summed honestly
|
||||
}
|
||||
}
|
||||
matches[i].SeedRemaining = &total
|
||||
matches[i].SeedUnit = unit
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
|
||||
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
|
||||
// (and editable by) the actor.
|
||||
|
||||
Reference in New Issue
Block a user