Agent: sharing tools that ask first, and a hard delete for a misplaced plop
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 4m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 4m18s

list_shares, share_garden, remove_share and public_link (get / enable /
rotate / disable) wrap the sharing service. They change who can see a garden
beyond the screen, so they are gated twice: the prompt tells the model to say
exactly what it would do and ask, and the tools refuse without confirmed=true,
which their descriptions allow only after a yes in the conversation. The
refusal names the action, so the question the model asks is precise.

share_garden changes the role of an existing share instead of failing on it;
remove_share takes the email list_shares reports; an unknown email explains
that the person has to sign in once first. public_link returns the address
(PANSY_BASE_URL + /g/<token>, via the new Service.PublicShareURL), never a
bare token.

delete_planting is the hard delete for a plop that was never really planted,
as opposed to remove_planting's "it came out"; it is recorded, so undoable.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-23 02:12:51 -04:00
co-authored by Claude Fable 5
parent 97008f5a92
commit f985c264f8
8 changed files with 369 additions and 1 deletions
+198
View File
@@ -235,6 +235,35 @@ func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox
"records it; say so rather than removing those to make it deletable. Built-in plants "+
"can't be deleted. Permanent — the catalog is not in the undo history.",
a.deletePlant),
llm.DefineTool("delete_planting",
"Delete ONE plop outright — for a plop that was never really planted (a misplacement, a "+
"duplicate), as opposed to remove_planting, which records that a real plant came out "+
"and keeps it in the season history. Recorded in the history, so it can be undone. "+
"Needs the plop's id (describe_garden or list_plantings).",
a.deletePlanting),
llm.DefineTool("list_shares",
"List who a garden the user owns is shared with — each person's email, name, role "+
"(viewer|editor) and userId — and whether its public read-only link is on.",
a.listShares),
llm.DefineTool("share_garden",
"Share a garden the user owns with another pansy account by its exact email, as a viewer "+
"or an editor; an existing share is changed to the new role. OUTWARD-FACING: only with "+
"confirmed=true, which you may pass only after the user has said yes, in this "+
"conversation, to sharing THIS garden with THAT email at THAT role — if they have not, "+
"say what you would do and ask. The other person must already have an account.",
a.shareGarden),
llm.DefineTool("remove_share",
"Stop sharing a garden with someone, by their email (see list_shares). OUTWARD-FACING: "+
"only with confirmed=true, after the user has said yes to removing that person from "+
"this garden; otherwise say what you would do and ask.",
a.removeShare),
llm.DefineTool("public_link",
"The garden's public read-only link, which anyone holding it can open without an account. "+
"action get reports whether it is on and the link. enable turns it on (or reports the "+
"existing link), rotate issues a fresh link so the old one stops working, disable turns "+
"it off. OUTWARD-FACING: enable, rotate and disable need confirmed=true, which you may "+
"pass only after the user has said yes to that exact action in this conversation.",
a.publicLink),
), a
}
@@ -931,3 +960,172 @@ func (a *adapter) deletePlant(ctx context.Context, args struct {
}
return map[string]any{"deleted": args.PlantID}, nil
}
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)"`
}) (any, error) {
if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.PlantingID}, nil
}
// errUnconfirmed is the refusal every outward-facing tool gives without
// confirmed=true: the action is named so the model can ask about it precisely.
func errUnconfirmed(action string) error {
return fmt.Errorf("%w: not done — %s is outward-facing, so say exactly what you would do and ask the user first; pass confirmed=true only once they have said yes", domain.ErrInvalidInput, action)
}
// shareView is one share as the tools report it, with the link state alongside
// in list_shares.
type shareView struct {
UserID int64 `json:"userId"`
Email string `json:"email"`
DisplayName string `json:"displayName,omitempty"`
Role string `json:"role"`
}
func (a *adapter) listShares(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose shares to list (the user must own it)"`
}) (any, error) {
shares, err := a.svc.ListShares(ctx, a.actor, args.GardenID)
if err != nil {
return nil, err
}
out := make([]shareView, 0, len(shares))
for _, sh := range shares {
out = append(out, shareView{UserID: sh.UserID, Email: sh.Email, DisplayName: sh.DisplayName, Role: sh.Role})
}
link, err := a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID)
if err != nil {
return nil, err
}
return map[string]any{"shares": out, "publicLink": a.linkView(link)}, nil
}
// linkView is a public link as the tools report it: on/off, and the address
// when on. The token itself is only ever shown as part of that address.
func (a *adapter) linkView(link *service.PublicShareLink) map[string]any {
v := map[string]any{"enabled": link.Enabled}
if link.Enabled {
v["url"] = a.svc.PublicShareURL(link.Token)
}
return v
}
func (a *adapter) shareGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to share (the user must own it)"`
Email string `json:"email" description:"the other person's pansy account email, exactly as the user gave it"`
Role string `json:"role" enum:"viewer,editor" description:"viewer can look; editor can change the garden"`
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to this share in this conversation"`
}) (any, error) {
email := strings.TrimSpace(args.Email)
role := strings.ToLower(strings.TrimSpace(args.Role))
if email == "" || role == "" {
return nil, fmt.Errorf("%w: an email and a role (viewer or editor) are required", domain.ErrInvalidInput)
}
if !args.Confirmed {
return nil, errUnconfirmed(fmt.Sprintf("sharing this garden with %s as %s", email, role))
}
share, err := a.svc.AddShare(ctx, a.actor, args.GardenID, email, role)
switch {
case errors.Is(err, domain.ErrShareUserNotFound):
return nil, fmt.Errorf("%w — they need to sign in to pansy once before a garden can be shared with them", err)
case errors.Is(err, domain.ErrShareExists):
// Already shared: "share it with them as an editor" means change the role.
existing, ferr := a.findShare(ctx, args.GardenID, email)
if ferr != nil {
return nil, ferr
}
if existing.Role == role {
return map[string]any{"share": shareView{UserID: existing.UserID, Email: existing.Email, DisplayName: existing.DisplayName, Role: existing.Role},
"note": "already shared with them at that role; nothing changed"}, nil
}
updated, uerr := a.svc.UpdateShareRole(ctx, a.actor, args.GardenID, existing.UserID, role)
if uerr != nil {
return nil, uerr
}
return map[string]any{"share": shareView{UserID: updated.UserID, Email: existing.Email, DisplayName: existing.DisplayName, Role: updated.Role},
"note": "they already had access; their role is now " + updated.Role}, nil
case err != nil:
return nil, err
}
return map[string]any{"share": shareView{UserID: share.UserID, Email: email, Role: share.Role}}, nil
}
// findShare resolves an email to the garden's share for it, case-insensitively
// — the model has the person's address from the conversation, not their id.
func (a *adapter) findShare(ctx context.Context, gardenID int64, email string) (*domain.ShareWithUser, error) {
shares, err := a.svc.ListShares(ctx, a.actor, gardenID)
if err != nil {
return nil, err
}
for i := range shares {
if strings.EqualFold(strings.TrimSpace(shares[i].Email), strings.TrimSpace(email)) {
return &shares[i], nil
}
}
return nil, fmt.Errorf("%w: this garden is not shared with %s (list_shares shows who it is shared with)", domain.ErrInvalidInput, email)
}
func (a *adapter) removeShare(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to stop sharing (the user must own it)"`
Email string `json:"email" description:"the person's email, as list_shares reports it"`
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to removing this person in this conversation"`
}) (any, error) {
email := strings.TrimSpace(args.Email)
if email == "" {
return nil, fmt.Errorf("%w: say whose access to remove, by email", domain.ErrInvalidInput)
}
if !args.Confirmed {
return nil, errUnconfirmed("removing " + email + " from this garden")
}
share, err := a.findShare(ctx, args.GardenID, email)
if err != nil {
return nil, err
}
if err := a.svc.RemoveShare(ctx, a.actor, args.GardenID, share.UserID); err != nil {
return nil, err
}
return map[string]any{"removed": shareView{UserID: share.UserID, Email: share.Email, DisplayName: share.DisplayName, Role: share.Role}}, nil
}
func (a *adapter) publicLink(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose public link this is about (the user must own it)"`
Action string `json:"action" enum:"get,enable,rotate,disable" description:"get reports it; enable turns it on; rotate replaces it so the old link stops working; disable turns it off"`
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to enabling, rotating or disabling the link in this conversation; not needed for get"`
}) (any, error) {
action := strings.ToLower(strings.TrimSpace(args.Action))
if action == "" {
action = "get"
}
if action != "get" && !args.Confirmed {
return nil, errUnconfirmed(map[string]string{
"enable": "turning the public link on, so anyone with the link can see this garden",
"rotate": "rotating the public link, so the old link stops working",
"disable": "turning the public link off, so the link stops working",
}[action])
}
var (
link *service.PublicShareLink
err error
)
switch action {
case "get":
link, err = a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID)
case "enable":
link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, false)
case "rotate":
link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, true)
case "disable":
if err = a.svc.DisablePublicShareLink(ctx, a.actor, args.GardenID); err == nil {
link = &service.PublicShareLink{Enabled: false}
}
default:
return nil, fmt.Errorf("%w: action must be get, enable, rotate or disable", domain.ErrInvalidInput)
}
if err != nil {
return nil, err
}
return a.linkView(link), nil
}