agnets done

This commit is contained in:
2026-08-28 11:02:02 +05:30
parent ab9adde6cd
commit eaf08e061d
58 changed files with 3506 additions and 4308 deletions

View File

@@ -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,

View File

@@ -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) {

View 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]);
}

View File

@@ -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;
}
/**

View File

@@ -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,