agnets done
This commit is contained in:
@@ -300,6 +300,16 @@ export function normalizeAgent(raw, { agentId = '', body = '' } = {}) {
|
||||
webSearch: data.webSearch === true || data.web_search === true,
|
||||
pages: normalizePages(data.pages, { errors }),
|
||||
skills: uniqueStrings(data.skills, { where: 'skills', errors, label: 'a skill id' }),
|
||||
/* The tools this agent may call, by registry name.
|
||||
|
||||
Skills are guidance the model reads; tools are what it can actually do.
|
||||
An agent with skills and no tools can discuss the work and look nothing
|
||||
up — which is what every agent authored in this editor was, because
|
||||
this field did not exist and the parser dropped `tools:` on the way in.
|
||||
|
||||
The choices come from GET /api/v1/tools rather than a list kept here,
|
||||
so a tool renamed in the backend cannot leave a stale option in a form. */
|
||||
tools: uniqueStrings(data.tools, { where: 'tools', errors, label: 'a tool name' }),
|
||||
subagents,
|
||||
knowledge,
|
||||
starters,
|
||||
|
||||
@@ -31,6 +31,7 @@ export const EMPTY_AGENT_FIELDS = Object.freeze({
|
||||
webSearch: false,
|
||||
pages: [],
|
||||
skills: [],
|
||||
tools: [],
|
||||
subagents: [],
|
||||
knowledge: [],
|
||||
starters: [],
|
||||
@@ -66,6 +67,7 @@ export function agentFieldsFromSource(source) {
|
||||
webSearch: agent.webSearch,
|
||||
pages: [...agent.pages],
|
||||
skills: [...agent.skills],
|
||||
tools: [...(agent.tools || [])],
|
||||
subagents: [...agent.subagents],
|
||||
knowledge: agent.knowledge.map((k) => ({ ...k })),
|
||||
starters: agent.starters.map((s) => ({ ...s })),
|
||||
@@ -116,6 +118,9 @@ export function agentPatch(fields = {}) {
|
||||
}
|
||||
|
||||
if (fields.skills !== undefined) patch.skills = listOrRemove(fields.skills);
|
||||
/* Capability, as opposed to guidance. Written the same way as skills so the
|
||||
round trip is the same one: form → frontmatter → parser → form. */
|
||||
if (fields.tools !== undefined) patch.tools = listOrRemove(fields.tools);
|
||||
if (fields.subagents !== undefined) patch.subagents = listOrRemove(fields.subagents);
|
||||
|
||||
if (fields.starters !== undefined) {
|
||||
|
||||
181
src/lib/agents/agentStore.js
Normal file
181
src/lib/agents/agentStore.js
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { base44 } from '@/api/base44Client';
|
||||
import { request } from '@/api/httpClient';
|
||||
import toast from 'react-hot-toast';
|
||||
import { parseAgent } from './registry';
|
||||
|
||||
/**
|
||||
* The authored-agent registry, over the API.
|
||||
*
|
||||
* Authored agents used to live in the account's preferences as Markdown. That
|
||||
* persisted — preferences are a real column in a real table — but it persisted
|
||||
* to the wrong place: the runtime resolves an agent from `agent_definitions`
|
||||
* and never reads preferences, so an agent created in the editor showed as
|
||||
* "published" in the list and answered every run with 404.
|
||||
*
|
||||
* This writes to `/api/v1/agent-definitions`, which is the table the runtime
|
||||
* loads from. An agent saved here is one Owliver can actually be asked to run.
|
||||
*
|
||||
* Markdown stays the artefact on both sides, and the same parser reads it in
|
||||
* both processes — `internal/definition` is checked against this one by a
|
||||
* conformance test replaying a capture of the real frontend module graph, so
|
||||
* "the backend understood it differently" is a failing test rather than a
|
||||
* support ticket.
|
||||
*/
|
||||
const KEY = ['agentDefinitions'];
|
||||
|
||||
/** Every authored agent this account can see, newest first. */
|
||||
export function useAgentDefinitions() {
|
||||
return useQuery({
|
||||
queryKey: KEY,
|
||||
queryFn: () => base44.entities.AgentDefinition.list('-created_date', 200),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored rows as the registry reader wants them.
|
||||
*
|
||||
* `{ path, raw }` is the shape `readAgentRegistry` already takes, so the reader
|
||||
* did not have to learn where definitions come from — only the store changed.
|
||||
*/
|
||||
export function sourcesFrom(rows) {
|
||||
return (rows || [])
|
||||
.filter((r) => r && typeof r.markdown === 'string' && r.markdown.trim())
|
||||
.map((r) => ({ path: `authored/${r.definition_id || r.id}.md`, raw: r.markdown }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update by definition id.
|
||||
*
|
||||
* The id an author writes (`activity-agent`) is not the row's id (a uuid), and
|
||||
* the two endpoints take different ones: the registry is a CRUD resource keyed
|
||||
* by uuid, while a run is addressed by the definition id. Resolving that here
|
||||
* keeps every caller in the author's vocabulary.
|
||||
*/
|
||||
export function useSaveAgentDefinition() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({ definitionId, markdown, visibility = 'personal' }) => {
|
||||
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
|
||||
const existing = (rows || []).find((r) => r.definition_id === definitionId);
|
||||
/* visibility is immutable after creation, so it is sent only on create —
|
||||
patching it back is rejected by the service and would turn a plain save
|
||||
into an error the author cannot act on. */
|
||||
return existing
|
||||
? base44.entities.AgentDefinition.update(existing.id, { markdown })
|
||||
: base44.entities.AgentDefinition.create({ markdown, visibility });
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Deletes the authored definition. A shipped agent returns to its shipped form. */
|
||||
export function useDeleteAgentDefinition() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (definitionId) => {
|
||||
const rows = qc.getQueryData(KEY) || (await base44.entities.AgentDefinition.list('-created_date', 200));
|
||||
const existing = (rows || []).find((r) => r.definition_id === definitionId);
|
||||
if (!existing) return { ok: true };
|
||||
return base44.entities.AgentDefinition.delete(existing.id);
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: KEY }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The tools an author may choose from, as the backend reports them.
|
||||
*
|
||||
* Served rather than listed here: a tool renamed or removed in the registry
|
||||
* would otherwise leave a stale option in this form, and the agent built from
|
||||
* it would fail at resolve time with nothing on screen to explain why.
|
||||
*/
|
||||
export function useToolCatalogue() {
|
||||
return useQuery({
|
||||
queryKey: ['toolCatalogue'],
|
||||
queryFn: () => request('GET', '/tools'),
|
||||
/* The tool set changes when the backend is deployed, not while somebody is
|
||||
filling in a form. */
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Row lookup by the id an author writes, for callers that need the uuid. */
|
||||
export function useRowFor(rows) {
|
||||
return useCallback(
|
||||
(definitionId) => (rows || []).find((r) => r.definition_id === definitionId) || null,
|
||||
[rows]
|
||||
);
|
||||
}
|
||||
|
||||
/** Memoised sources, so the registry is not rebuilt on every render. */
|
||||
export function useAuthoredSources(rows) {
|
||||
return useMemo(() => sourcesFrom(rows), [rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves agents left in the account's preferences into the registry.
|
||||
*
|
||||
* Authored agents used to be stored as `preferences.customAgents`. Reading
|
||||
* moved to the registry, and without this that history would simply stop being
|
||||
* shown: the rows stay in the preferences column, the list no longer reads
|
||||
* them, and an agent somebody wrote disappears with no message. Losing an
|
||||
* author's work quietly is worse than any of the problems this change fixed.
|
||||
*
|
||||
* Runs once per session, and only forward:
|
||||
*
|
||||
* - an id already in the registry is left alone. Re-posting would overwrite
|
||||
* a definition the author may have edited since.
|
||||
* - preferences are cleared only after every write has succeeded, so a failed
|
||||
* migration can be retried rather than having eaten the originals.
|
||||
* - a definition the backend refuses (an unknown tool, say) leaves everything
|
||||
* in place and reports, rather than dropping that one on the floor.
|
||||
*/
|
||||
export function useMigrateStoredAgents({ rows, stored, clearStored, enabled }) {
|
||||
const save = useSaveAgentDefinition();
|
||||
const done = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || done.current || !stored?.length) return;
|
||||
done.current = true;
|
||||
|
||||
(async () => {
|
||||
const known = new Set((rows || []).map((r) => r.definition_id));
|
||||
const failures = [];
|
||||
let moved = 0;
|
||||
|
||||
for (const entry of stored) {
|
||||
const markdown = typeof entry === 'string' ? entry : entry?.raw;
|
||||
if (!markdown) continue;
|
||||
let parsed = null;
|
||||
try {
|
||||
parsed = parseAgent(markdown, { custom: true });
|
||||
} catch {
|
||||
failures.push('a definition that could not be read');
|
||||
continue;
|
||||
}
|
||||
if (known.has(parsed.id)) continue;
|
||||
try {
|
||||
await save.mutateAsync({ definitionId: parsed.id, markdown });
|
||||
moved += 1;
|
||||
} catch (error) {
|
||||
failures.push(`${parsed.id}: ${error?.message || 'refused'}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
toast.error(`Some stored agents could not be moved. ${failures.join('; ')}`);
|
||||
return;
|
||||
}
|
||||
if (moved > 0) {
|
||||
await clearStored();
|
||||
toast.success(`${moved} stored agent${moved === 1 ? '' : 's'} moved into the registry.`);
|
||||
} else {
|
||||
/* Nothing to move — every stored id already exists in the registry, so
|
||||
the preferences copy is redundant and can go. */
|
||||
await clearStored();
|
||||
}
|
||||
})();
|
||||
}, [enabled, rows, stored, clearStored, save]);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { DOMAIN_SURFACES, canonicalPage } from '@/lib/skills/surfaces';
|
||||
import { pageKeyForContext } from '@/lib/skills/registry';
|
||||
import { getAgent } from './registry';
|
||||
import { reasoningFor } from './vocabulary';
|
||||
@@ -122,19 +122,33 @@ export function agentsForContext(agents = [], contextId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* The general agent every page falls back to.
|
||||
* Whether an agent is a *general* one: it covers every domain surface.
|
||||
*
|
||||
* Named once, here, because two different things need it and neither should
|
||||
* carry its own copy: resolving a default, and deciding whether a page has an
|
||||
* agent *of its own*.
|
||||
* Derived from the definition rather than matched against an id. This used to
|
||||
* be `FALLBACK_AGENT_ID = 'krow-workforce-agent'` — a literal agent key that
|
||||
* two functions below branched on, which is precisely the thing agent specs
|
||||
* being data is supposed to make impossible. With a key in the runtime,
|
||||
* renaming the general agent silently demotes it, deleting it leaves two dead
|
||||
* branches, and a workspace can never write a second general agent because
|
||||
* only one id is privileged.
|
||||
*
|
||||
* Reading `pages` instead makes it a fact about the spec: an agent listing
|
||||
* every surface that holds workforce records is an agent with no speciality,
|
||||
* which is exactly what makes it the sensible fallback. A general agent may
|
||||
* list *more* than the domain surfaces — the workforce agent also covers the
|
||||
* agent workspace — so this is a subset test, never an equality one.
|
||||
*/
|
||||
export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
|
||||
export function isGeneralAgent(agent) {
|
||||
if (!agent?.pages?.length) return false;
|
||||
const covered = new Set(agent.pages);
|
||||
return DOMAIN_SURFACES.every((id) => covered.has(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent written *for* this page, if there is one.
|
||||
*
|
||||
* The general agent is deliberately excluded. It covers every surface — which
|
||||
* is what makes it a fallback — so counting it as a page's own agent would make
|
||||
* General agents are deliberately excluded. One covers every surface — which is
|
||||
* what makes it a fallback — so counting it as a page's own agent would make
|
||||
* "does this page have a native agent?" true everywhere and the distinction
|
||||
* meaningless.
|
||||
*
|
||||
@@ -142,20 +156,26 @@ export const FALLBACK_AGENT_ID = 'krow-workforce-agent';
|
||||
* a broken one: see `resolveDefaultAgent`.
|
||||
*/
|
||||
export function nativeAgentForContext(agents = [], contextId) {
|
||||
return agentsForContext(agents, contextId).find((a) => a.id !== FALLBACK_AGENT_ID) || null;
|
||||
return agentsForContext(agents, contextId).find((a) => !isGeneralAgent(a)) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The general agent, when it can answer here.
|
||||
* The general agent, when one can answer here.
|
||||
*
|
||||
* Falls through to whichever published agent covers the page if the general one
|
||||
* has been archived or does not list this surface — a page must never be left
|
||||
* without an agent because of how the registry happens to be configured.
|
||||
* `agentsForContext` has already narrowed to published agents covering this
|
||||
* page and sorted them most-specific-first, so general agents sit at the end
|
||||
* and the *last* of them is the broadest. Taking that one is identical to the
|
||||
* old behaviour while exactly one general agent exists, and is a stated choice
|
||||
* rather than an arbitrary one once a workspace has written a second.
|
||||
*
|
||||
* Falls through to whichever published agent covers the page when no general
|
||||
* one does — a page must never be left without an agent because of how the
|
||||
* registry happens to be configured.
|
||||
*/
|
||||
export function fallbackAgentForContext(agents = [], contextId) {
|
||||
const general = getAgent(agents, FALLBACK_AGENT_ID);
|
||||
if (general && general.status === 'published' && agentCovers(general, contextId)) return general;
|
||||
return agentsForContext(agents, contextId)[0] || null;
|
||||
const covering = agentsForContext(agents, contextId);
|
||||
const general = covering.filter(isGeneralAgent);
|
||||
return general[general.length - 1] || covering[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
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, removeCustomAgent, upsertCustomAgent } from './customAgents';
|
||||
import { customAgentSource } from './customAgents';
|
||||
import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agentLifecycle';
|
||||
|
||||
/**
|
||||
@@ -24,11 +28,36 @@ import { archiveAgent, duplicateAgent, publishAgent, restoreAgent } from './agen
|
||||
*/
|
||||
export function useAgents() {
|
||||
const preferences = usePreferences();
|
||||
const updatePreferences = useUpdatePreferences();
|
||||
|
||||
const stored = useMemo(() => preferences.customAgents || [], [preferences.customAgents]);
|
||||
/* 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]
|
||||
@@ -63,23 +92,29 @@ export function useAgents() {
|
||||
const problem = validateAgentSource(source);
|
||||
if (problem) return { ok: false, error: problem };
|
||||
|
||||
const { agent, next } = upsertCustomAgent(stored, source);
|
||||
await updatePreferences.mutateAsync(
|
||||
{ customAgents: next },
|
||||
reportSave(message || `${agent.name} saved`)
|
||||
);
|
||||
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 };
|
||||
}, [stored, updatePreferences]);
|
||||
}, [saveDefinition]);
|
||||
|
||||
/** Removes the account's definition. A shipped agent returns to its shipped form. */
|
||||
const remove = useCallback(async (id) => {
|
||||
const next = removeCustomAgent(stored, id);
|
||||
await updatePreferences.mutateAsync(
|
||||
{ customAgents: next },
|
||||
reportSave(isShipped(id) ? 'Reverted to the shipped definition' : 'Agent removed')
|
||||
);
|
||||
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 };
|
||||
}, [stored, updatePreferences, isShipped]);
|
||||
}, [deleteDefinition, isShipped]);
|
||||
|
||||
/**
|
||||
* Publishes, refusing to overwrite a newer published version.
|
||||
@@ -123,7 +158,8 @@ export function useAgents() {
|
||||
return {
|
||||
agents,
|
||||
diagnostics,
|
||||
saving: updatePreferences.isPending,
|
||||
loading: definitions.isPending,
|
||||
saving: saveDefinition.isPending || deleteDefinition.isPending,
|
||||
isShipped,
|
||||
isOverridden,
|
||||
sourceFor,
|
||||
|
||||
Reference in New Issue
Block a user