import { useCallback, useMemo } from 'react'; import { usePreferences, useUpdatePreferences } from '@/lib/krowHooks'; import { useAgentDefinitions, useAuthoredSources, useDeleteAgentDefinition, useMigrateStoredAgents, useSaveAgentDefinition, } from './agentStore'; import { reportSave } from '@/lib/skills/saveFeedback'; import { AGENTS, parseAgent, readAgentRegistry, validateAgentSource } from './registry'; import { customAgentSource } from './customAgents'; import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle'; /** * Reading and writing agents, in one place. * * Every screen that changes an agent goes through here. That is deliberate: the * list can publish, the detail page can publish, and two implementations of * "publish" would eventually disagree about what publishing means. * * Storage is the account's preferences, holding **Markdown** — the same * artefact a file in `src/agents/` is. The forms never expose it: a person * fills in fields, `agentPatch` writes them into frontmatter, and the parser * reads them back. Markdown stays the definition format without ever being * something an HR user has to see. * * Editing a shipped agent writes an account definition of the same id, which * the registry reports as `shadowed`. That is the existing override mechanism, * not a new one — a shipped agent is never mutated on disk. */ export function useAgents() { const preferences = usePreferences(); /* Authored agents come from the registry the runtime resolves from, not from this account's preferences. They used to come from preferences, and the consequence was an agent that the list called "published" and every run answered 404: the backend stored it faithfully, in a table the runtime does not read. Skills are still preferences-backed — that is a separate move. */ const definitions = useAgentDefinitions(); const saveDefinition = useSaveAgentDefinition(); const deleteDefinition = useDeleteAgentDefinition(); const rows = useMemo(() => definitions.data || [], [definitions.data]); const stored = useAuthoredSources(rows); const customSkills = useMemo(() => preferences.customSkills || [], [preferences.customSkills]); /* Anything an author wrote before the store moved. Without this it would stop being displayed rather than being carried across — the rows would sit in the preferences column, unread, and the agent would appear to have been deleted. Runs once, and clears the old copy only after every write lands. */ const updatePreferences = useUpdatePreferences(); const legacy = useMemo(() => preferences.customAgents || [], [preferences.customAgents]); useMigrateStoredAgents({ rows, stored: legacy, enabled: !definitions.isPending, clearStored: useCallback( () => updatePreferences.mutateAsync({ customAgents: [] }), [updatePreferences] ), }); const { agents, diagnostics } = useMemo( () => readAgentRegistry(stored, { customSkills }), [stored, customSkills] ); /** Whether this id is shipped with the product rather than authored here. */ const isShipped = useCallback((id) => AGENTS.some((a) => a.id === id), []); /** Whether the account has its own definition for this id. */ const isOverridden = useCallback((id) => customAgentSource(stored, id) !== null, [stored]); /** * The Markdown behind an agent. * * The account's own copy when there is one, otherwise the shipped definition — * so editing a shipped agent starts from what it actually says rather than * from a blank. */ const sourceFor = useCallback( (id) => customAgentSource(stored, id) || AGENTS.find((a) => a.id === id)?.markdown || null, [stored] ); /** * Writes a definition. * * Validated first and refused with a message rather than stored broken: a * definition that cannot be read is an agent that silently stops existing. * Returns `{ ok, error }` so a form can stay on screen and say why. */ const save = useCallback(async (/** @type {string} */ source, /** @type {any} */ { message } = {}) => { const problem = validateAgentSource(source); if (problem) return { ok: false, error: problem }; const agent = parseAgent(source, { custom: true }); try { await saveDefinition.mutateAsync({ definitionId: agent.id, markdown: source }); } catch (error) { /* The service refuses a definition naming a tool that does not exist, and says which. Surfaced rather than swallowed: the author picked it, and the alternative is an agent quietly missing the capability. */ return { ok: false, error: error?.message || 'That agent could not be saved.' }; } reportSave(message || `${agent.name} saved`); return { ok: true, agent }; }, [saveDefinition]); /** Removes the account's definition. A shipped agent returns to its shipped form. */ const remove = useCallback(async (id) => { try { await deleteDefinition.mutateAsync(id); } catch (error) { return { ok: false, error: error?.message || 'That agent could not be removed.' }; } reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed'); return { ok: true }; }, [deleteDefinition, isShipped]); /** * Publishes, refusing to overwrite a newer published version. * * Returns `{ ok: false, conflict }` when the stored definition has moved on, * so the screen can say what would be lost instead of losing it. */ const publish = useCallback(async (id) => { const source = sourceFor(id); if (!source) return { ok: false, error: 'That agent has no definition to publish.' }; const live = agents.find((a) => a.id === id); const result = publishAgent(source, { publishedVersion: live?.status === 'published' ? live.version : 0, }); if (result.conflict) return { ok: false, conflict: result.conflict }; return save(result.source, { message: `${live?.name || id} published` }); }, [sourceFor, agents, save]); const archive = useCallback(async (id) => { const source = sourceFor(id); if (!source) return { ok: false, error: 'That agent has no definition to archive.' }; return save(archiveAgent(source), { message: `${agents.find((a) => a.id === id)?.name || id} archived` }); }, [sourceFor, agents, save]); const restore = useCallback(async (id) => { const source = sourceFor(id); if (!source) return { ok: false, error: 'That agent has no definition to restore.' }; return save(restoreAgent(source), { message: 'Restored as a draft' }); }, [sourceFor, save]); const duplicate = useCallback(async (id) => { const source = sourceFor(id); if (!source) return { ok: false, error: 'That agent has no definition to copy.' }; const copy = duplicateAgent(source, { existingIds: agents.map((a) => a.id) }); const result = await save(copy, { message: 'Copy created as a draft' }); return result.ok ? { ...result, id: parseAgent(copy, { custom: true }).id } : result; }, [sourceFor, agents, save]); return { agents, diagnostics, loading: definitions.isPending, saving: saveDefinition.isPending || deleteDefinition.isPending, isShipped, isOverridden, sourceFor, save, remove, publish, archive, restore, duplicate, }; }