@avocadostudio-ai/orchestrator-core 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent-loop-openai.js +3 -1
- package/dist/agent/agent-provider.js +2 -2
- package/dist/agents/backtest.d.ts +35 -0
- package/dist/agents/backtest.js +92 -0
- package/dist/agents/builtins.d.ts +21 -0
- package/dist/agents/builtins.js +102 -0
- package/dist/agents/draft-spec.d.ts +44 -0
- package/dist/agents/draft-spec.js +195 -0
- package/dist/agents/edit-safety-resolve.d.ts +70 -0
- package/dist/agents/edit-safety-resolve.js +195 -0
- package/dist/agents/edit-safety-runner.d.ts +68 -0
- package/dist/agents/edit-safety-runner.js +156 -0
- package/dist/agents/edit-safety.d.ts +82 -0
- package/dist/agents/edit-safety.js +425 -0
- package/dist/agents/fix-writer.d.ts +38 -0
- package/dist/agents/fix-writer.js +197 -0
- package/dist/agents/inbox.d.ts +141 -0
- package/dist/agents/inbox.js +360 -0
- package/dist/agents/learning.d.ts +32 -0
- package/dist/agents/learning.js +71 -0
- package/dist/agents/mention.d.ts +76 -0
- package/dist/agents/mention.js +161 -0
- package/dist/agents/run-spec.d.ts +27 -0
- package/dist/agents/run-spec.js +82 -0
- package/dist/agents/schedule.d.ts +22 -0
- package/dist/agents/schedule.js +53 -0
- package/dist/agents/settings.d.ts +30 -0
- package/dist/agents/settings.js +64 -0
- package/dist/agents/spec.d.ts +84 -0
- package/dist/agents/spec.js +70 -0
- package/dist/agents/stats.d.ts +15 -0
- package/dist/agents/stats.js +48 -0
- package/dist/agents/templates.d.ts +34 -0
- package/dist/agents/templates.js +268 -0
- package/dist/agents/tick.d.ts +59 -0
- package/dist/agents/tick.js +161 -0
- package/dist/chat/anthropic-model-caps.d.ts +19 -0
- package/dist/chat/anthropic-model-caps.js +56 -0
- package/dist/chat/anthropic-planner.d.ts +1 -0
- package/dist/chat/anthropic-planner.js +72 -15
- package/dist/chat/chat-pipeline.js +54 -1
- package/dist/chat/model-defaults.js +6 -3
- package/dist/checks/rules-draft.d.ts +6 -1
- package/dist/checks/rules-draft.js +10 -7
- package/dist/checks/rules-i18n.d.ts +25 -0
- package/dist/checks/rules-i18n.js +193 -0
- package/dist/checks/run-checks.d.ts +16 -1
- package/dist/checks/run-checks.js +80 -63
- package/dist/checks/session-runner.js +7 -0
- package/dist/checks/types.d.ts +6 -0
- package/dist/durable/in-memory-durable-store.d.ts +34 -1
- package/dist/durable/in-memory-durable-store.js +122 -1
- package/dist/durable/index.d.ts +1 -1
- package/dist/durable/sqlite-durable-store.d.ts +19 -1
- package/dist/durable/sqlite-durable-store.js +220 -1
- package/dist/durable/types.d.ts +146 -0
- package/dist/handler/create-orchestrator.js +191 -1
- package/dist/http/history-actions.d.ts +22 -0
- package/dist/http/history-actions.js +33 -5
- package/dist/http/inbox-actions.d.ts +156 -0
- package/dist/http/inbox-actions.js +498 -0
- package/dist/http/ops-actions.d.ts +17 -1
- package/dist/http/ops-actions.js +36 -0
- package/dist/http/variation-preview-actions.d.ts +30 -0
- package/dist/http/variation-preview-actions.js +75 -0
- package/dist/index.d.ts +10 -1
- package/dist/index.js +9 -0
- package/dist/nlp/deterministic-planner.d.ts +1 -1
- package/dist/nlp/intent-detection.d.ts +15 -0
- package/dist/nlp/intent-detection.js +5 -0
- package/dist/state/session-state.d.ts +11 -3
- package/dist/state/session-state.js +8 -0
- package/dist/state/sqlite-store.d.ts +12 -0
- package/dist/telemetry/usage.js +4 -0
- package/package.json +3 -3
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
2
|
+
import { getSessionDraft } from "../state/session-state.js";
|
|
3
|
+
import { agentOfFinding } from "./inbox.js";
|
|
4
|
+
import { runAgentSpec } from "./run-spec.js";
|
|
5
|
+
import { resolveSiteAgents } from "./settings.js";
|
|
6
|
+
import { CUSTOM_AGENT_PREFIX } from "./spec.js";
|
|
7
|
+
const WEEK = 7 * 24 * 3_600_000;
|
|
8
|
+
const TOP = 3;
|
|
9
|
+
/** An agent's handle in chat: its id, without the `custom:` prefix. */
|
|
10
|
+
export function handleOf(agentId) {
|
|
11
|
+
return agentId.startsWith(CUSTOM_AGENT_PREFIX) ? agentId.slice(CUSTOM_AGENT_PREFIX.length) : agentId;
|
|
12
|
+
}
|
|
13
|
+
/** English and German keywords, matched on whole words. Order is priority. */
|
|
14
|
+
const INTENTS = [
|
|
15
|
+
["help", /\b(help|hilfe|what can you do|was kannst du)\b/i],
|
|
16
|
+
["pause", /\b(pause|stop|mute|switch off|turn off|pausier\w*|anhalten|ausschalten|stopp)\b/i],
|
|
17
|
+
["resume", /\b(resume|switch on|turn on|unpause|start again|einschalten|fortsetzen|weitermachen)\b/i],
|
|
18
|
+
["week", /\b(this week|last week|changed|did you do|diese woche|letzte woche|geändert|gemacht)\b/i],
|
|
19
|
+
["report", /\b(found|findings|status|report|what have you|anything|gefunden|meldungen|bericht)\b/i],
|
|
20
|
+
["run", /\b(check|run|scan|look at|go|now|prüf\w*|lauf\w*|schau)\b/i]
|
|
21
|
+
];
|
|
22
|
+
export function parseIntent(rest, all) {
|
|
23
|
+
for (const [intent, pattern] of INTENTS)
|
|
24
|
+
if (pattern.test(rest))
|
|
25
|
+
return intent;
|
|
26
|
+
// "@agents" alone lists them; "@search-health" alone runs it.
|
|
27
|
+
return all ? "list" : rest.trim() ? "help" : "run";
|
|
28
|
+
}
|
|
29
|
+
/** The first `/path` in the message that is a page on this site. */
|
|
30
|
+
export function pageIn(rest, pages) {
|
|
31
|
+
const match = /(^|\s)(\/[\w\-/.~%]*)/.exec(rest);
|
|
32
|
+
if (!match)
|
|
33
|
+
return {};
|
|
34
|
+
const wanted = match[2].replace(/[.,!?]+$/, "");
|
|
35
|
+
const normalized = wanted.length > 1 ? wanted.replace(/\/+$/, "") : wanted;
|
|
36
|
+
const page = pages.find((p) => p.slug === normalized);
|
|
37
|
+
return page ? { slug: page.slug } : { unknown: normalized };
|
|
38
|
+
}
|
|
39
|
+
function topFindings(findings) {
|
|
40
|
+
return [...findings]
|
|
41
|
+
.sort((a, b) => b.impact - a.impact)
|
|
42
|
+
.slice(0, TOP)
|
|
43
|
+
.map((f) => ({ slug: f.slug, title: f.title }));
|
|
44
|
+
}
|
|
45
|
+
async function openFor(scopeKey, agentId, store) {
|
|
46
|
+
const open = await store.listFindings({ scopeKey, status: "open" });
|
|
47
|
+
return open.filter((f) => agentOfFinding(f) === agentId);
|
|
48
|
+
}
|
|
49
|
+
export async function handleMention(args) {
|
|
50
|
+
const head = /^\s*@([a-z0-9][a-z0-9:-]*)\b(.*)$/is.exec(args.message);
|
|
51
|
+
if (!head)
|
|
52
|
+
return { handled: false };
|
|
53
|
+
const handle = head[1].toLowerCase();
|
|
54
|
+
const rest = head[2] ?? "";
|
|
55
|
+
const store = args.store ?? getDurableStore();
|
|
56
|
+
const site = await resolveSiteAgents(args.scopeKey, store);
|
|
57
|
+
const all = handle === "agents" || handle === "agenten";
|
|
58
|
+
const agent = all
|
|
59
|
+
? undefined
|
|
60
|
+
: site.agents.find((a) => handleOf(a.agentId) === handle || a.agentId === handle);
|
|
61
|
+
if (!all && !agent)
|
|
62
|
+
return { handled: false };
|
|
63
|
+
const intent = parseIntent(rest, all);
|
|
64
|
+
const handles = site.agents.map((a) => handleOf(a.agentId));
|
|
65
|
+
if (all) {
|
|
66
|
+
if (intent === "week")
|
|
67
|
+
return weekSummary(args.scopeKey, site.agents, store, args.now ?? Date.now());
|
|
68
|
+
if (intent === "help")
|
|
69
|
+
return { handled: true, kind: "help", handles };
|
|
70
|
+
const open = await store.listFindings({ scopeKey: args.scopeKey, status: "open" });
|
|
71
|
+
return {
|
|
72
|
+
handled: true,
|
|
73
|
+
kind: "list",
|
|
74
|
+
agents: site.agents.map((a) => ({
|
|
75
|
+
agentId: a.agentId,
|
|
76
|
+
handle: handleOf(a.agentId),
|
|
77
|
+
enabled: a.enabled,
|
|
78
|
+
open: open.filter((f) => agentOfFinding(f) === a.agentId).length
|
|
79
|
+
}))
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
const target = agent;
|
|
83
|
+
switch (intent) {
|
|
84
|
+
case "help":
|
|
85
|
+
return { handled: true, kind: "help", agentId: target.agentId, handles };
|
|
86
|
+
case "week":
|
|
87
|
+
return weekSummary(args.scopeKey, [target], store, args.now ?? Date.now());
|
|
88
|
+
case "report": {
|
|
89
|
+
const open = await openFor(args.scopeKey, target.agentId, store);
|
|
90
|
+
return { handled: true, kind: "report", agentId: target.agentId, open: open.length, top: topFindings(open) };
|
|
91
|
+
}
|
|
92
|
+
case "pause": {
|
|
93
|
+
if (!target.enabled)
|
|
94
|
+
return { handled: true, kind: "already", agentId: target.agentId, enabled: false };
|
|
95
|
+
await store.putAgentSetting({ scopeKey: args.scopeKey, agentId: target.agentId, enabled: false, ...(target.owner ? { owner: target.owner } : {}) });
|
|
96
|
+
return { handled: true, kind: "paused", agentId: target.agentId, enabled: false };
|
|
97
|
+
}
|
|
98
|
+
case "resume": {
|
|
99
|
+
if (target.enabled)
|
|
100
|
+
return { handled: true, kind: "already", agentId: target.agentId, enabled: true };
|
|
101
|
+
// Switched on only for the owner it already has: naming someone is the Agents page's job.
|
|
102
|
+
if (!target.owner)
|
|
103
|
+
return { handled: true, kind: "refused", agentId: target.agentId, reason: "needs_owner" };
|
|
104
|
+
await store.putAgentSetting({ scopeKey: args.scopeKey, agentId: target.agentId, enabled: true, owner: target.owner });
|
|
105
|
+
return { handled: true, kind: "resumed", agentId: target.agentId, enabled: true };
|
|
106
|
+
}
|
|
107
|
+
case "run":
|
|
108
|
+
case "list": {
|
|
109
|
+
if (!target.enabled)
|
|
110
|
+
return { handled: true, kind: "refused", agentId: target.agentId, reason: "off" };
|
|
111
|
+
if (target.agentId === "edit-safety")
|
|
112
|
+
return { handled: true, kind: "refused", agentId: target.agentId, reason: "not_runnable" };
|
|
113
|
+
const page = pageIn(rest, [...getSessionDraft(args.scopeKey).values()]);
|
|
114
|
+
if (page.unknown)
|
|
115
|
+
return { handled: true, kind: "refused", agentId: target.agentId, reason: "unknown_page", slug: page.unknown };
|
|
116
|
+
// A page named in the message narrows this one run; the agent's own scope is unchanged.
|
|
117
|
+
const spec = page.slug ? { ...target.spec, scope: { ...target.spec.scope, slugs: [page.slug] } } : target.spec;
|
|
118
|
+
const run = await runAgentSpec({ scopeKey: args.scopeKey, spec, trigger: "manual", store });
|
|
119
|
+
const open = (await openFor(args.scopeKey, target.agentId, store)).filter((f) => !page.slug || f.slug === page.slug);
|
|
120
|
+
return {
|
|
121
|
+
handled: true,
|
|
122
|
+
kind: "ran",
|
|
123
|
+
agentId: target.agentId,
|
|
124
|
+
...(page.slug ? { slug: page.slug } : {}),
|
|
125
|
+
opened: run.findingsOpened,
|
|
126
|
+
closed: run.findingsClosed,
|
|
127
|
+
open: open.length,
|
|
128
|
+
top: topFindings(open)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/** The last seven days, rolling: what each agent found and fixed, and what Edit safety caught. */
|
|
134
|
+
async function weekSummary(scopeKey, agents, store, now) {
|
|
135
|
+
const since = now - WEEK;
|
|
136
|
+
const ids = new Set(agents.map((a) => a.agentId));
|
|
137
|
+
const lines = new Map(agents.map((a) => [a.agentId, { agentId: a.agentId, found: 0, fixed: 0, open: 0 }]));
|
|
138
|
+
for (const finding of await store.listFindings({ scopeKey })) {
|
|
139
|
+
const line = lines.get(agentOfFinding(finding));
|
|
140
|
+
if (!line)
|
|
141
|
+
continue;
|
|
142
|
+
if (finding.firstSeenAt >= since)
|
|
143
|
+
line.found += 1;
|
|
144
|
+
if (finding.status === "fixed" && (finding.resolvedAt ?? 0) >= since)
|
|
145
|
+
line.fixed += 1;
|
|
146
|
+
if (finding.status === "open")
|
|
147
|
+
line.open += 1;
|
|
148
|
+
}
|
|
149
|
+
let caught = 0;
|
|
150
|
+
let reverted = 0;
|
|
151
|
+
if (ids.has("edit-safety")) {
|
|
152
|
+
for (const alert of await store.listInboxItems({ scopeKey, kind: "alert", agent: "edit-safety" })) {
|
|
153
|
+
if (alert.createdAt < since)
|
|
154
|
+
continue;
|
|
155
|
+
caught += 1;
|
|
156
|
+
if (alert.resolution === "reverted")
|
|
157
|
+
reverted += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return { handled: true, kind: "week", since, agents: [...lines.values()], caught, reverted };
|
|
161
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { type PageDoc } from "@avocadostudio-ai/shared";
|
|
2
|
+
import type { CheckRunRecord, CheckRunTrigger, DurableStore, FindingInput } from "../durable/types.ts";
|
|
3
|
+
import type { Logger } from "../logger.ts";
|
|
4
|
+
import type { AgentSpec } from "./spec.ts";
|
|
5
|
+
/** The pages a spec looks at: `scope.slugs` if given, minus `scope.exclude`. */
|
|
6
|
+
export declare function slugsInScope(pages: PageDoc[], scope: AgentSpec["scope"]): string[];
|
|
7
|
+
/** Run a spec now and record what it finds. Throws on a spec with nothing the checker can run. */
|
|
8
|
+
export declare function runAgentSpec(args: {
|
|
9
|
+
scopeKey: string;
|
|
10
|
+
spec: AgentSpec;
|
|
11
|
+
trigger: CheckRunTrigger;
|
|
12
|
+
store?: DurableStore;
|
|
13
|
+
/** The run's clock. A scheduled run is stamped with its tick's time, which is what the next tick reads. */
|
|
14
|
+
now?: () => number;
|
|
15
|
+
}): Promise<CheckRunRecord>;
|
|
16
|
+
/** What a spec would find today, writing nothing — the backtest (SO-09). */
|
|
17
|
+
export declare function evaluateAgentSpec(scopeKey: string, spec: AgentSpec): Promise<FindingInput[]>;
|
|
18
|
+
/**
|
|
19
|
+
* The custom agents switched on for an event. Built-ins are not here: the
|
|
20
|
+
* draft checker already runs their rules on the same triggers, and Edit
|
|
21
|
+
* safety runs inside the edit itself.
|
|
22
|
+
*/
|
|
23
|
+
export declare function runCustomAgentsOn(args: {
|
|
24
|
+
scopeKey: string;
|
|
25
|
+
on: "apply" | "publish";
|
|
26
|
+
log?: Logger;
|
|
27
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { buildBlockManifest } from "@avocadostudio-ai/shared";
|
|
2
|
+
import { evaluateDraftChecks, runDraftChecks } from "../checks/run-checks.js";
|
|
3
|
+
import { getSessionDraft, getSiteConfig } from "../state/session-state.js";
|
|
4
|
+
import { getSiteAssets } from "../state/site-assets.js";
|
|
5
|
+
import { resolveSiteAgents } from "./settings.js";
|
|
6
|
+
import { compileSpec } from "./templates.js";
|
|
7
|
+
/*
|
|
8
|
+
* One runner for every agent spec (site-ops-agents.md SO-04): a spec is
|
|
9
|
+
* compiled into check rules and run on the draft checker's engine, so a
|
|
10
|
+
* custom agent's findings land in the same table, reconcile the same way,
|
|
11
|
+
* and reach the Inbox grouped per rule like any other.
|
|
12
|
+
*
|
|
13
|
+
* Page scope is applied here; block-type scope is applied by `compileSpec`.
|
|
14
|
+
*/
|
|
15
|
+
/** The pages a spec looks at: `scope.slugs` if given, minus `scope.exclude`. */
|
|
16
|
+
export function slugsInScope(pages, scope) {
|
|
17
|
+
const only = scope.slugs?.length ? new Set(scope.slugs) : null;
|
|
18
|
+
const never = new Set(scope.exclude ?? []);
|
|
19
|
+
return pages.map((page) => page.slug).filter((slug) => (only ? only.has(slug) : true) && !never.has(slug));
|
|
20
|
+
}
|
|
21
|
+
async function inputs(scopeKey) {
|
|
22
|
+
const assets = await getSiteAssets();
|
|
23
|
+
return {
|
|
24
|
+
pages: [...getSessionDraft(scopeKey).values()],
|
|
25
|
+
manifest: buildBlockManifest(),
|
|
26
|
+
siteConfig: getSiteConfig(scopeKey),
|
|
27
|
+
...(assets ? { assets } : {})
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/** Run a spec now and record what it finds. Throws on a spec with nothing the checker can run. */
|
|
31
|
+
export async function runAgentSpec(args) {
|
|
32
|
+
const rules = compileSpec(args.spec);
|
|
33
|
+
if (rules.length === 0)
|
|
34
|
+
throw new Error(`${args.spec.name} does not run on a check: it acts on each edit instead`);
|
|
35
|
+
const base = await inputs(args.scopeKey);
|
|
36
|
+
const slugs = slugsInScope(base.pages, args.spec.scope);
|
|
37
|
+
return runDraftChecks({
|
|
38
|
+
scopeKey: args.scopeKey,
|
|
39
|
+
...base,
|
|
40
|
+
rules,
|
|
41
|
+
slugs,
|
|
42
|
+
agent: args.spec.id,
|
|
43
|
+
trigger: args.trigger,
|
|
44
|
+
...(args.store ? { store: args.store } : {}),
|
|
45
|
+
...(args.now ? { now: args.now } : {})
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** What a spec would find today, writing nothing — the backtest (SO-09). */
|
|
49
|
+
export async function evaluateAgentSpec(scopeKey, spec) {
|
|
50
|
+
const rules = compileSpec(spec);
|
|
51
|
+
if (rules.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
const base = await inputs(scopeKey);
|
|
54
|
+
return evaluateDraftChecks({ scopeKey, ...base, rules, slugs: slugsInScope(base.pages, spec.scope) }).findings;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* The custom agents switched on for an event. Built-ins are not here: the
|
|
58
|
+
* draft checker already runs their rules on the same triggers, and Edit
|
|
59
|
+
* safety runs inside the edit itself.
|
|
60
|
+
*/
|
|
61
|
+
export async function runCustomAgentsOn(args) {
|
|
62
|
+
const site = await resolveSiteAgents(args.scopeKey);
|
|
63
|
+
for (const agent of site.agents) {
|
|
64
|
+
if (agent.builtin || !agent.enabled)
|
|
65
|
+
continue;
|
|
66
|
+
const trigger = agent.spec.trigger;
|
|
67
|
+
if (trigger.kind !== "event" || trigger.on !== args.on)
|
|
68
|
+
continue;
|
|
69
|
+
try {
|
|
70
|
+
const run = await runAgentSpec({
|
|
71
|
+
scopeKey: args.scopeKey,
|
|
72
|
+
spec: agent.spec,
|
|
73
|
+
trigger: args.on === "apply" ? "on_apply" : "on_publish"
|
|
74
|
+
});
|
|
75
|
+
args.log?.info({ agentId: agent.agentId, opened: run.findingsOpened, closed: run.findingsClosed }, "agent run complete");
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
// One agent failing must not stop the others, or the edit that woke them.
|
|
79
|
+
args.log?.error({ err: String(error), agentId: agent.agentId }, "agent run failed");
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const scheduleTriggerSchema: z.ZodObject<{
|
|
3
|
+
kind: z.ZodLiteral<"schedule">;
|
|
4
|
+
every: z.ZodEnum<{
|
|
5
|
+
day: "day";
|
|
6
|
+
week: "week";
|
|
7
|
+
}>;
|
|
8
|
+
hour: z.ZodDefault<z.ZodNumber>;
|
|
9
|
+
weekday: z.ZodDefault<z.ZodNumber>;
|
|
10
|
+
}, z.core.$strip>;
|
|
11
|
+
export type ScheduleTrigger = z.infer<typeof scheduleTriggerSchema>;
|
|
12
|
+
/** The most recent moment at or before `now` that the schedule names. */
|
|
13
|
+
export declare function latestSlot(trigger: ScheduleTrigger, now: number): number;
|
|
14
|
+
/** The next moment after `now` that the schedule names. */
|
|
15
|
+
export declare function nextSlot(trigger: ScheduleTrigger, now: number): number;
|
|
16
|
+
/**
|
|
17
|
+
* Whether an agent should run now. `since` is the later of its last scheduled
|
|
18
|
+
* run and the moment it was created or last switched on — so a new agent
|
|
19
|
+
* waits for its first slot instead of firing on the next tick, and an agent
|
|
20
|
+
* that was off over its slot does not run the moment it is switched back on.
|
|
21
|
+
*/
|
|
22
|
+
export declare function isDue(trigger: ScheduleTrigger, since: number, now: number): boolean;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/*
|
|
3
|
+
* When a scheduled agent is due (site-ops-agents.md §8 item 6).
|
|
4
|
+
*
|
|
5
|
+
* There is no cron in here. Something calls POST /agents/tick — a timer in the
|
|
6
|
+
* standalone server, or an outside scheduler in library mode, where there is
|
|
7
|
+
* no process between requests — and the tick asks each agent one question:
|
|
8
|
+
* has a slot of your schedule passed since you last ran?
|
|
9
|
+
*
|
|
10
|
+
* Asking it that way makes the tick's own cadence irrelevant to correctness.
|
|
11
|
+
* A tick every minute runs a daily agent once a day; a tick that did not fire
|
|
12
|
+
* for three days runs it once, not three times; two ticks racing each other
|
|
13
|
+
* are harmless because the second finds the run the first recorded.
|
|
14
|
+
*
|
|
15
|
+
* Times are UTC. A site's time zone is not known yet, and a schedule that
|
|
16
|
+
* silently meant the server's zone would move when the server did.
|
|
17
|
+
*/
|
|
18
|
+
export const scheduleTriggerSchema = z.object({
|
|
19
|
+
kind: z.literal("schedule"),
|
|
20
|
+
every: z.enum(["day", "week"]),
|
|
21
|
+
/** Hour of the day, UTC. */
|
|
22
|
+
hour: z.number().int().min(0).max(23).default(6),
|
|
23
|
+
/** For `every: "week"`: 0 = Sunday … 6 = Saturday. Ignored for a daily agent. */
|
|
24
|
+
weekday: z.number().int().min(0).max(6).default(1)
|
|
25
|
+
});
|
|
26
|
+
const HOUR = 3_600_000;
|
|
27
|
+
const DAY = 24 * HOUR;
|
|
28
|
+
/** The most recent moment at or before `now` that the schedule names. */
|
|
29
|
+
export function latestSlot(trigger, now) {
|
|
30
|
+
const d = new Date(now);
|
|
31
|
+
let slot = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), trigger.hour);
|
|
32
|
+
if (slot > now)
|
|
33
|
+
slot -= DAY;
|
|
34
|
+
if (trigger.every === "week") {
|
|
35
|
+
const back = (new Date(slot).getUTCDay() - trigger.weekday + 7) % 7;
|
|
36
|
+
slot -= back * DAY;
|
|
37
|
+
}
|
|
38
|
+
return slot;
|
|
39
|
+
}
|
|
40
|
+
/** The next moment after `now` that the schedule names. */
|
|
41
|
+
export function nextSlot(trigger, now) {
|
|
42
|
+
return latestSlot(trigger, now) + (trigger.every === "week" ? 7 : 1) * DAY;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether an agent should run now. `since` is the later of its last scheduled
|
|
46
|
+
* run and the moment it was created or last switched on — so a new agent
|
|
47
|
+
* waits for its first slot instead of firing on the next tick, and an agent
|
|
48
|
+
* that was off over its slot does not run the moment it is switched back on.
|
|
49
|
+
*/
|
|
50
|
+
export function isDue(trigger, since, now) {
|
|
51
|
+
const slot = latestSlot(trigger, now);
|
|
52
|
+
return since < slot && slot <= now;
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { DurableStore } from "../durable/types.ts";
|
|
2
|
+
import { type BuiltinAgentId } from "./builtins.ts";
|
|
3
|
+
import { type AgentSpec } from "./spec.ts";
|
|
4
|
+
export type AgentState = {
|
|
5
|
+
agentId: string;
|
|
6
|
+
builtin: boolean;
|
|
7
|
+
name: string;
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
owner?: string;
|
|
10
|
+
spec: AgentSpec;
|
|
11
|
+
};
|
|
12
|
+
export type SiteAgents = {
|
|
13
|
+
/**
|
|
14
|
+
* False until someone has been through "Meet your agents" for this site.
|
|
15
|
+
* Every built-in then reports its default, and the editor offers the first
|
|
16
|
+
* run. A custom agent created through the API does not count: the built-ins
|
|
17
|
+
* have still not been looked at.
|
|
18
|
+
*/
|
|
19
|
+
configured: boolean;
|
|
20
|
+
/** The built-ins first, in catalogue order, then custom agents by name. */
|
|
21
|
+
agents: AgentState[];
|
|
22
|
+
};
|
|
23
|
+
/** The state of every agent for one site: built-ins with defaults filled in, then custom ones. */
|
|
24
|
+
export declare function resolveSiteAgents(scopeKey: string, store?: DurableStore): Promise<SiteAgents>;
|
|
25
|
+
/**
|
|
26
|
+
* Whether one built-in is on. Never throws: an agent whose state cannot be
|
|
27
|
+
* read is treated as its default, because a store outage must not turn Edit
|
|
28
|
+
* safety on (and hold every publish) or turn the draft checker off.
|
|
29
|
+
*/
|
|
30
|
+
export declare function agentState(scopeKey: string, agentId: BuiltinAgentId): Promise<AgentState>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
2
|
+
import { BUILTIN_AGENT_IDS, BUILTIN_SPECS, DEFAULT_ENABLED } from "./builtins.js";
|
|
3
|
+
import { agentSpecSchema, isCustomAgentId } from "./spec.js";
|
|
4
|
+
/** The state of every agent for one site: built-ins with defaults filled in, then custom ones. */
|
|
5
|
+
export async function resolveSiteAgents(scopeKey, store = getDurableStore()) {
|
|
6
|
+
const rows = await store.listAgentSettings(scopeKey);
|
|
7
|
+
const byId = new Map(rows.map((row) => [row.agentId, row]));
|
|
8
|
+
const builtins = BUILTIN_AGENT_IDS.map((agentId) => {
|
|
9
|
+
const row = byId.get(agentId);
|
|
10
|
+
const spec = BUILTIN_SPECS[agentId];
|
|
11
|
+
return {
|
|
12
|
+
agentId,
|
|
13
|
+
builtin: true,
|
|
14
|
+
name: spec.name,
|
|
15
|
+
enabled: row ? row.enabled : DEFAULT_ENABLED[agentId],
|
|
16
|
+
...(row?.owner ? { owner: row.owner } : {}),
|
|
17
|
+
spec
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
const customs = [];
|
|
21
|
+
for (const row of rows) {
|
|
22
|
+
if (!isCustomAgentId(row.agentId) || !row.spec)
|
|
23
|
+
continue;
|
|
24
|
+
// A stored spec that no longer parses (a field this build dropped) is left
|
|
25
|
+
// out rather than run half-understood.
|
|
26
|
+
const parsed = agentSpecSchema.safeParse(row.spec);
|
|
27
|
+
if (!parsed.success)
|
|
28
|
+
continue;
|
|
29
|
+
customs.push({
|
|
30
|
+
agentId: row.agentId,
|
|
31
|
+
builtin: false,
|
|
32
|
+
name: parsed.data.name,
|
|
33
|
+
enabled: row.enabled,
|
|
34
|
+
...(row.owner ? { owner: row.owner } : {}),
|
|
35
|
+
spec: parsed.data
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
customs.sort((a, b) => a.name.localeCompare(b.name));
|
|
39
|
+
return {
|
|
40
|
+
configured: rows.some((row) => BUILTIN_AGENT_IDS.includes(row.agentId)),
|
|
41
|
+
agents: [...builtins, ...customs]
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether one built-in is on. Never throws: an agent whose state cannot be
|
|
46
|
+
* read is treated as its default, because a store outage must not turn Edit
|
|
47
|
+
* safety on (and hold every publish) or turn the draft checker off.
|
|
48
|
+
*/
|
|
49
|
+
export async function agentState(scopeKey, agentId) {
|
|
50
|
+
const fallback = {
|
|
51
|
+
agentId,
|
|
52
|
+
builtin: true,
|
|
53
|
+
name: BUILTIN_SPECS[agentId].name,
|
|
54
|
+
enabled: DEFAULT_ENABLED[agentId],
|
|
55
|
+
spec: BUILTIN_SPECS[agentId]
|
|
56
|
+
};
|
|
57
|
+
try {
|
|
58
|
+
const site = await resolveSiteAgents(scopeKey);
|
|
59
|
+
return site.agents.find((a) => a.agentId === agentId) ?? fallback;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return fallback;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const CUSTOM_AGENT_PREFIX = "custom:";
|
|
3
|
+
export declare const detectStepSchema: z.ZodObject<{
|
|
4
|
+
template: z.ZodString;
|
|
5
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
export declare const agentTriggerSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
8
|
+
kind: z.ZodLiteral<"manual">;
|
|
9
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
10
|
+
kind: z.ZodLiteral<"event">;
|
|
11
|
+
on: z.ZodEnum<{
|
|
12
|
+
apply: "apply";
|
|
13
|
+
publish: "publish";
|
|
14
|
+
}>;
|
|
15
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
16
|
+
kind: z.ZodLiteral<"schedule">;
|
|
17
|
+
every: z.ZodEnum<{
|
|
18
|
+
day: "day";
|
|
19
|
+
week: "week";
|
|
20
|
+
}>;
|
|
21
|
+
hour: z.ZodDefault<z.ZodNumber>;
|
|
22
|
+
weekday: z.ZodDefault<z.ZodNumber>;
|
|
23
|
+
}, z.core.$strip>], "kind">;
|
|
24
|
+
export declare const agentScopeSchema: z.ZodDefault<z.ZodObject<{
|
|
25
|
+
slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
26
|
+
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
27
|
+
blockTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
export declare const agentSpecSchema: z.ZodObject<{
|
|
30
|
+
id: z.ZodString;
|
|
31
|
+
name: z.ZodString;
|
|
32
|
+
brief: z.ZodDefault<z.ZodString>;
|
|
33
|
+
origin: z.ZodEnum<{
|
|
34
|
+
custom: "custom";
|
|
35
|
+
builtin: "builtin";
|
|
36
|
+
}>;
|
|
37
|
+
trigger: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
38
|
+
kind: z.ZodLiteral<"manual">;
|
|
39
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
40
|
+
kind: z.ZodLiteral<"event">;
|
|
41
|
+
on: z.ZodEnum<{
|
|
42
|
+
apply: "apply";
|
|
43
|
+
publish: "publish";
|
|
44
|
+
}>;
|
|
45
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
46
|
+
kind: z.ZodLiteral<"schedule">;
|
|
47
|
+
every: z.ZodEnum<{
|
|
48
|
+
day: "day";
|
|
49
|
+
week: "week";
|
|
50
|
+
}>;
|
|
51
|
+
hour: z.ZodDefault<z.ZodNumber>;
|
|
52
|
+
weekday: z.ZodDefault<z.ZodNumber>;
|
|
53
|
+
}, z.core.$strip>], "kind">;
|
|
54
|
+
scope: z.ZodDefault<z.ZodObject<{
|
|
55
|
+
slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
56
|
+
exclude: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
57
|
+
blockTypes: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
58
|
+
}, z.core.$strip>>;
|
|
59
|
+
detect: z.ZodArray<z.ZodObject<{
|
|
60
|
+
template: z.ZodString;
|
|
61
|
+
params: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
62
|
+
}, z.core.$strip>>;
|
|
63
|
+
severity: z.ZodDefault<z.ZodEnum<{
|
|
64
|
+
info: "info";
|
|
65
|
+
error: "error";
|
|
66
|
+
warning: "warning";
|
|
67
|
+
}>>;
|
|
68
|
+
}, z.core.$strip>;
|
|
69
|
+
export type AgentSpec = z.infer<typeof agentSpecSchema>;
|
|
70
|
+
export type DetectStep = z.infer<typeof detectStepSchema>;
|
|
71
|
+
export declare function isCustomAgentId(value: unknown): value is string;
|
|
72
|
+
/**
|
|
73
|
+
* Parse a custom spec sent by a caller. The id must be a `custom:` id, and
|
|
74
|
+
* `origin` is forced to "custom" — a caller cannot author a built-in.
|
|
75
|
+
* Template names and their params are checked separately, against the
|
|
76
|
+
* registry, by `compileSpec`.
|
|
77
|
+
*/
|
|
78
|
+
export declare function parseCustomSpec(raw: unknown): {
|
|
79
|
+
ok: true;
|
|
80
|
+
spec: AgentSpec;
|
|
81
|
+
} | {
|
|
82
|
+
ok: false;
|
|
83
|
+
error: string;
|
|
84
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { scheduleTriggerSchema } from "./schedule.js";
|
|
3
|
+
/*
|
|
4
|
+
* An agent is data (docs/ideas/site-ops-agents.md §2, SO-03, SO-04).
|
|
5
|
+
*
|
|
6
|
+
* One record per agent per site. A built-in and a custom agent are the same
|
|
7
|
+
* shape with a different `origin`, so there is one runner, one test surface
|
|
8
|
+
* and one Agents page. What an agent notices is `detect`: a list of rule
|
|
9
|
+
* templates with parameters, each compiled into ordinary check rules — the
|
|
10
|
+
* same deterministic engine the draft checker runs, so an agent gives the same
|
|
11
|
+
* answer every time and can be backtested.
|
|
12
|
+
*
|
|
13
|
+
* The spec holds only what runs today. Not here yet, deliberately:
|
|
14
|
+
* - `judge` steps (a model call) — §8 item 8 onward;
|
|
15
|
+
* - `autonomy` and `budget` — nothing an agent does applies itself yet.
|
|
16
|
+
*
|
|
17
|
+
* A `schedule` trigger runs from POST /agents/tick (agents/schedule.ts).
|
|
18
|
+
*/
|
|
19
|
+
export const CUSTOM_AGENT_PREFIX = "custom:";
|
|
20
|
+
const CUSTOM_ID = /^custom:[a-z0-9][a-z0-9-]{0,47}$/;
|
|
21
|
+
export const detectStepSchema = z.object({
|
|
22
|
+
template: z.string().min(1).max(40),
|
|
23
|
+
params: z.record(z.string(), z.unknown()).default({})
|
|
24
|
+
});
|
|
25
|
+
export const agentTriggerSchema = z.discriminatedUnion("kind", [
|
|
26
|
+
z.object({ kind: z.literal("manual") }),
|
|
27
|
+
z.object({ kind: z.literal("event"), on: z.enum(["apply", "publish"]) }),
|
|
28
|
+
scheduleTriggerSchema
|
|
29
|
+
]);
|
|
30
|
+
export const agentScopeSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
/** Only these pages. Absent: every page. */
|
|
33
|
+
slugs: z.array(z.string().min(1)).max(200).optional(),
|
|
34
|
+
/** Never these pages. */
|
|
35
|
+
exclude: z.array(z.string().min(1)).max(200).optional(),
|
|
36
|
+
/** Only fields of these block types. */
|
|
37
|
+
blockTypes: z.array(z.string().min(1)).max(50).optional()
|
|
38
|
+
})
|
|
39
|
+
.default({});
|
|
40
|
+
export const agentSpecSchema = z.object({
|
|
41
|
+
id: z.string().min(1).max(64),
|
|
42
|
+
name: z.string().trim().min(1).max(60),
|
|
43
|
+
/** The job in one sentence, as the person wrote it. Kept verbatim. */
|
|
44
|
+
brief: z.string().trim().max(500).default(""),
|
|
45
|
+
origin: z.enum(["builtin", "custom"]),
|
|
46
|
+
trigger: agentTriggerSchema,
|
|
47
|
+
scope: agentScopeSchema,
|
|
48
|
+
detect: z.array(detectStepSchema).min(1).max(10),
|
|
49
|
+
severity: z.enum(["error", "warning", "info"]).default("warning")
|
|
50
|
+
});
|
|
51
|
+
export function isCustomAgentId(value) {
|
|
52
|
+
return typeof value === "string" && CUSTOM_ID.test(value);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Parse a custom spec sent by a caller. The id must be a `custom:` id, and
|
|
56
|
+
* `origin` is forced to "custom" — a caller cannot author a built-in.
|
|
57
|
+
* Template names and their params are checked separately, against the
|
|
58
|
+
* registry, by `compileSpec`.
|
|
59
|
+
*/
|
|
60
|
+
export function parseCustomSpec(raw) {
|
|
61
|
+
const parsed = agentSpecSchema.safeParse({ ...raw, origin: "custom" });
|
|
62
|
+
if (!parsed.success) {
|
|
63
|
+
const issue = parsed.error.issues[0];
|
|
64
|
+
return { ok: false, error: `invalid spec: ${issue?.path.join(".") || "spec"} — ${issue?.message ?? "invalid"}` };
|
|
65
|
+
}
|
|
66
|
+
if (!isCustomAgentId(parsed.data.id)) {
|
|
67
|
+
return { ok: false, error: 'a custom agent id is "custom:" followed by lowercase letters, digits and dashes' };
|
|
68
|
+
}
|
|
69
|
+
return { ok: true, spec: parsed.data };
|
|
70
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { CheckRunRecord, DurableStore } from "../durable/types.ts";
|
|
2
|
+
export type AgentStats = {
|
|
3
|
+
/** Findings raised, all time (as far as the store keeps them). */
|
|
4
|
+
found: number;
|
|
5
|
+
open: number;
|
|
6
|
+
fixed: number;
|
|
7
|
+
rejected: number;
|
|
8
|
+
/** Edit safety only. */
|
|
9
|
+
caught?: number;
|
|
10
|
+
reverted?: number;
|
|
11
|
+
intended?: number;
|
|
12
|
+
/** The most recent runs, newest first. */
|
|
13
|
+
runs: CheckRunRecord[];
|
|
14
|
+
};
|
|
15
|
+
export declare function agentStats(scopeKey: string, store?: DurableStore): Promise<Record<string, AgentStats>>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
2
|
+
import { BUILTIN_AGENT_IDS } from "./builtins.js";
|
|
3
|
+
import { EDIT_SAFETY_AGENT } from "./edit-safety-runner.js";
|
|
4
|
+
import { agentOfFinding } from "./inbox.js";
|
|
5
|
+
import { isCustomAgentId } from "./spec.js";
|
|
6
|
+
const RUNS_PER_AGENT = 10;
|
|
7
|
+
function empty() {
|
|
8
|
+
return { found: 0, open: 0, fixed: 0, rejected: 0, runs: [] };
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Which agent a check run belongs to. A custom agent's run carries its id; the
|
|
12
|
+
* draft checker's runs ("checks", or one rule's agent) are both built-in
|
|
13
|
+
* checker agents' runs, because they run those rules together.
|
|
14
|
+
*/
|
|
15
|
+
function runOwners(run) {
|
|
16
|
+
if (isCustomAgentId(run.agent) || BUILTIN_AGENT_IDS.includes(run.agent))
|
|
17
|
+
return [run.agent];
|
|
18
|
+
return ["search-health", "unfinished-work", "translation-drift"];
|
|
19
|
+
}
|
|
20
|
+
export async function agentStats(scopeKey, store = getDurableStore()) {
|
|
21
|
+
const out = {};
|
|
22
|
+
const of = (agentId) => (out[agentId] ??= empty());
|
|
23
|
+
const findings = await store.listFindings({ scopeKey, limit: 5000 });
|
|
24
|
+
for (const finding of findings) {
|
|
25
|
+
const stats = of(agentOfFinding(finding));
|
|
26
|
+
stats.found += 1;
|
|
27
|
+
if (finding.status === "open" || finding.status === "snoozed")
|
|
28
|
+
stats.open += 1;
|
|
29
|
+
else if (finding.status === "fixed")
|
|
30
|
+
stats.fixed += 1;
|
|
31
|
+
else if (finding.status === "dismissed")
|
|
32
|
+
stats.rejected += 1;
|
|
33
|
+
}
|
|
34
|
+
const alerts = await store.listInboxItems({ scopeKey, agent: EDIT_SAFETY_AGENT, limit: 1000 });
|
|
35
|
+
const edit = of(EDIT_SAFETY_AGENT);
|
|
36
|
+
edit.caught = alerts.length;
|
|
37
|
+
edit.reverted = alerts.filter((item) => item.resolution === "reverted").length;
|
|
38
|
+
edit.intended = alerts.filter((item) => item.resolution === "intended").length;
|
|
39
|
+
edit.open += alerts.filter((item) => item.status === "open").length;
|
|
40
|
+
for (const run of await store.listCheckRuns(scopeKey, 200)) {
|
|
41
|
+
for (const owner of runOwners(run)) {
|
|
42
|
+
const stats = of(owner);
|
|
43
|
+
if (stats.runs.length < RUNS_PER_AGENT)
|
|
44
|
+
stats.runs.push(run);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|