Files
pansy/web/src/lib/agent.ts
T
steveandClaude Fable 5 f985c264f8
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
Agent: sharing tools that ask first, and a hard delete for a misplaced plop
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]>
2026-08-23 02:12:51 -04:00

296 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The garden assistant's client (#57).
//
// The chat lives in the editor, not on its own page, because watching the canvas
// change as the agent works IS the confirmation — which is what makes acting
// without asking first tolerable. So this module's job is as much about
// surfacing progress as it is about sending a message.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { API_BASE, api } from './api'
import { today } from './dates'
import { gardenFullKey } from './objects'
import { historyKey } from './history'
// What this instance can actually do, so the UI offers only what works: `agent`
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
// default to false so a partial/older response just hides the feature rather
// than failing the whole parse.
const capabilitiesSchema = z.object({
agent: z.boolean().default(false),
vision: z.boolean().default(false),
})
export type Capabilities = z.infer<typeof capabilitiesSchema>
export const capabilitiesKey = ['capabilities'] as const
/** What this instance can do right now: whether the assistant is live and whether
* seed-packet scanning will work. Without a capability the matching feature isn't
* offered at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
* model on or off in Settings (#79), so this must be able to change under a
* running page. The settings save invalidates this key directly; the finite
* staleTime just means another admin's change is picked up on the next
* focus/remount rather than never. */
export function useCapabilities() {
return useQuery({
queryKey: capabilitiesKey,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: 60_000,
})
}
export const agentMessageSchema = z.object({
id: z.number(),
conversationId: z.number(),
role: z.enum(['user', 'assistant']),
body: z.string(),
changeSetId: z.number().optional(),
createdAt: z.string(),
})
export type AgentMessage = z.infer<typeof agentMessageSchema>
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
export function agentHistoryKey(gardenId: number) {
return ['gardens', gardenId, 'agent-history'] as const
}
/** The stored conversation, so a reload doesn't lose the thread — which is
* exactly when someone reloads, to check whether a change actually landed. */
export function useAgentHistory(gardenId: number, enabled: boolean) {
return useQuery({
queryKey: agentHistoryKey(gardenId),
enabled,
queryFn: async (): Promise<AgentMessage[]> =>
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
})
}
export function useClearAgentHistory(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await api.delete(`/gardens/${gardenId}/agent/history`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
})
}
/** One completed turn, as the server reports it. */
export interface AgentTurn {
reply: string
changeSetId?: number
steps: number
truncated?: boolean
}
/** A step the model just finished, named in the app's own vocabulary. */
export interface AgentStep {
index: number
tools: string[]
}
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
// agent is busy; these tell you what it's busy DOING, which is the difference
// between the panel feeling alive and feeling hung.
const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden',
list_years: 'Checking past seasons',
list_plantings: 'Reading a bed',
create_object: 'Adding a bed',
move_object: 'Moving a bed',
update_object: 'Changing a bed',
delete_object: 'Removing a bed',
place_planting: 'Planting',
fill_region: 'Filling a bed',
move_planting: 'Moving a plant',
update_planting: 'Correcting a planting',
remove_planting: 'Pulling a plant',
delete_planting: 'Deleting a planting',
remove_plantings: 'Pulling plants',
clear_object: 'Clearing a bed',
find_plant: 'Looking up a plant',
create_plant: 'Adding a plant to your catalog',
update_plant: 'Updating your catalog',
add_journal_entry: 'Writing a journal note',
read_journal: 'Reading the journal',
update_journal_entry: 'Correcting a journal note',
delete_journal_entry: 'Deleting a journal note',
read_history: 'Reading the history',
undo_change: 'Undoing a change',
list_seed_lots: 'Checking your seed',
record_seed_lot: 'Recording seed',
update_seed_lot: 'Correcting a seed lot',
delete_seed_lot: 'Deleting a seed lot',
delete_plant: 'Removing a plant from your catalog',
copy_garden: 'Copying the garden',
update_garden: 'Changing the garden',
create_garden: 'Starting a garden',
list_shares: 'Checking who has access',
share_garden: 'Sharing the garden',
remove_share: 'Removing access',
public_link: 'Managing the public link',
}
export function describeStep(step: AgentStep): string {
if (step.tools.length === 0) return 'Thinking'
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
// Repeated tools in one step read as one action, not a list of identical ones.
return [...new Set(labels)].join(', ')
}
const chatEventSchema = z.object({
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
done: z
.object({
reply: z.string(),
changeSetId: z.number().optional(),
steps: z.number(),
truncated: z.boolean().optional(),
})
.optional(),
error: z.string().optional(),
// The turn worked but something adjacent to it didn't — currently, the
// exchange couldn't be saved. Dropping this on the floor would recreate
// exactly the silent swallow the server added it to avoid.
warning: z.string().optional(),
})
export interface StreamHandlers {
onStep: (step: AgentStep) => void
onDone: (turn: AgentTurn) => void
onError: (message: string) => void
/** The turn succeeded, but something alongside it didn't. */
onWarning?: (message: string) => void
}
/**
* Send a message and stream the reply.
*
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
* POST body. The wire format is still SSE so a proxy that understands it doesn't
* buffer, and so switching to EventSource later wouldn't change the server.
*/
export async function streamChat(
gardenId: number,
message: string,
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
let res: Response
try {
// `today` is the browser's local day, for the same reason every other
// dated write sends it: the assistant dates what it plants, removes and
// journals with it, and tells the model what day it is. Left to the server
// the date is UTC's; left to the model it was a year from its training data.
res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gardenId, message, today: today() }),
credentials: 'same-origin',
signal,
})
} catch {
// An abort here is the caller's own doing — Stop, or navigating away — not a
// failure to report back to them. The read loop below already knew this; the
// request path did not.
if (signal?.aborted) return
handlers.onError('Could not reach the server.')
return
}
if (res.status === 401) {
// Session expired mid-conversation (#85). Reporting this as "the assistant is
// unavailable" would send the user chasing a config problem that isn't there.
// Send them to sign in again, preserving where they were.
handlers.onError('Your session has expired — please sign in again.')
const back = encodeURIComponent(location.pathname + location.search)
window.location.assign(`/login?redirect=${back}`)
return
}
if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists,
// there's just no model behind it. Distinct from a 404, which would mean the
// whole endpoint is absent.
handlers.onError(
res.status === 503
? "The assistant isn't enabled on this instance."
: res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
let chunk: ReadableStreamReadResult<Uint8Array>
try {
chunk = await reader.read()
} catch {
// An aborted read is the caller navigating away, not a failure worth
// reporting back to them.
if (signal?.aborted) return
handlers.onError('The connection dropped partway through.')
return
}
if (chunk.done) break
buffer += decoder.decode(chunk.value, { stream: true })
// SSE frames are separated by a blank line; anything after the last one is
// a partial frame to keep for the next chunk.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
// working stream, and shouldn't be trusted into the UI either.
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
if (!parsed.success) continue
const e = parsed.data
if (e.warning) handlers.onWarning?.(e.warning)
if (e.error) handlers.onError(e.error)
else if (e.step) handlers.onStep(e.step)
else if (e.done) handlers.onDone(e.done)
}
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* Refreshes for the two moments that need different amounts of work.
*
* Mid-turn, only the canvas can have changed: the change set isn't written until
* the turn commits, and the exchange isn't stored until it finishes. Refetching
* those on every step would be up to 2×(N1) requests per turn for data that
* cannot have moved.
*/
export function useAgentRefresh(gardenId: number) {
const qc = useQueryClient()
const canvas = () => {
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
}
return {
/** After a step: the garden may have changed under the conversation. */
canvas,
/** After a turn: the change set and the stored exchange exist now too. */
everything: () => {
canvas()
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
},
}
}