174 lines
7.2 KiB
JavaScript
174 lines
7.2 KiB
JavaScript
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,
|
|
};
|
|
}
|