@avocadostudio-ai/orchestrator-core 0.22.0 → 0.24.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 +157 -0
- package/dist/agents/edit-safety.d.ts +82 -0
- package/dist/agents/edit-safety.js +425 -0
- package/dist/agents/enabled.d.ts +18 -0
- package/dist/agents/enabled.js +21 -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 +364 -0
- package/dist/agents/learning.d.ts +32 -0
- package/dist/agents/learning.js +74 -0
- package/dist/agents/mention.d.ts +76 -0
- package/dist/agents/mention.js +165 -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 +164 -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 +195 -2
- 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,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CheckRule } from "../checks/types.ts";
|
|
3
|
+
import type { AgentSpec } from "./spec.ts";
|
|
4
|
+
export type TemplateContext = {
|
|
5
|
+
agentId: string;
|
|
6
|
+
stepIndex: number;
|
|
7
|
+
severity: AgentSpec["severity"];
|
|
8
|
+
};
|
|
9
|
+
export type RuleTemplate = {
|
|
10
|
+
id: string;
|
|
11
|
+
/** One line for API callers and, later, the authoring UI. */
|
|
12
|
+
description: string;
|
|
13
|
+
/** An example `params` object, shown by GET /agents/templates. */
|
|
14
|
+
example: Record<string, unknown>;
|
|
15
|
+
/** False for a template the check runner does not execute (Edit safety's diff). */
|
|
16
|
+
runnable: boolean;
|
|
17
|
+
params: z.ZodType<Record<string, unknown>>;
|
|
18
|
+
build(params: Record<string, unknown>, ctx: TemplateContext): CheckRule[];
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A date a person typed into a field: ISO (`2026-09-06`), European
|
|
22
|
+
* (`06.09.2026`, `6/9/2026` read day-first) or anything `Date.parse` reads
|
|
23
|
+
* (`Sat 6 Sep 2026`). Null when it is not a date — that field is skipped,
|
|
24
|
+
* never reported, because a template must not guess.
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseFieldDate(value: unknown): number | null;
|
|
27
|
+
export declare const RULE_TEMPLATES: RuleTemplate[];
|
|
28
|
+
export declare function templateById(id: string): RuleTemplate | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* A spec's detect steps as check rules. Throws, naming the step, on an
|
|
31
|
+
* unknown template or bad params — a spec that cannot compile is refused
|
|
32
|
+
* when it is saved, not discovered when it runs at 03:00.
|
|
33
|
+
*/
|
|
34
|
+
export declare function compileSpec(spec: AgentSpec): CheckRule[];
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { fieldText } from "../checks/field-walk.js";
|
|
3
|
+
import { DRAFT_RULES } from "../checks/rules-draft.js";
|
|
4
|
+
const MAX_TEXT = 10_000;
|
|
5
|
+
const MAX_FINDINGS_PER_PAGE = 50;
|
|
6
|
+
function ruleId(ctx, template) {
|
|
7
|
+
return `${ctx.agentId}/${ctx.stepIndex}:${template}`;
|
|
8
|
+
}
|
|
9
|
+
/** A field whose own key (the last path segment) is `field`: `date` matches `date` and `items[2].date`. */
|
|
10
|
+
function fieldMatches(entry, field) {
|
|
11
|
+
if (entry.path === field)
|
|
12
|
+
return true;
|
|
13
|
+
const last = entry.path.split(".").pop() ?? entry.path;
|
|
14
|
+
return last === field;
|
|
15
|
+
}
|
|
16
|
+
function blockMatches(entry, blockType) {
|
|
17
|
+
return !blockType || entry.blockType === blockType;
|
|
18
|
+
}
|
|
19
|
+
function where(entry) {
|
|
20
|
+
return {
|
|
21
|
+
source: "draft",
|
|
22
|
+
blockId: entry.blockId,
|
|
23
|
+
blockType: entry.blockType,
|
|
24
|
+
...(entry.blockLabel ? { blockLabel: entry.blockLabel } : {}),
|
|
25
|
+
path: entry.path
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function capped(findings) {
|
|
29
|
+
return findings.slice(0, MAX_FINDINGS_PER_PAGE);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The start of today, UTC. A date field "before today" is one whose day has
|
|
33
|
+
* fully passed — an event today is not yet in the past.
|
|
34
|
+
*/
|
|
35
|
+
function startOfToday(now) {
|
|
36
|
+
const d = new Date(now);
|
|
37
|
+
return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* A date a person typed into a field: ISO (`2026-09-06`), European
|
|
41
|
+
* (`06.09.2026`, `6/9/2026` read day-first) or anything `Date.parse` reads
|
|
42
|
+
* (`Sat 6 Sep 2026`). Null when it is not a date — that field is skipped,
|
|
43
|
+
* never reported, because a template must not guess.
|
|
44
|
+
*/
|
|
45
|
+
export function parseFieldDate(value) {
|
|
46
|
+
if (typeof value !== "string")
|
|
47
|
+
return null;
|
|
48
|
+
const text = value.trim();
|
|
49
|
+
if (!text)
|
|
50
|
+
return null;
|
|
51
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
|
|
52
|
+
if (iso)
|
|
53
|
+
return Date.UTC(Number(iso[1]), Number(iso[2]) - 1, Number(iso[3]));
|
|
54
|
+
const dayFirst = /^(\d{1,2})[./](\d{1,2})[./](\d{4})$/.exec(text);
|
|
55
|
+
if (dayFirst)
|
|
56
|
+
return Date.UTC(Number(dayFirst[3]), Number(dayFirst[2]) - 1, Number(dayFirst[1]));
|
|
57
|
+
const parsed = Date.parse(text);
|
|
58
|
+
if (Number.isNaN(parsed))
|
|
59
|
+
return null;
|
|
60
|
+
const d = new Date(parsed);
|
|
61
|
+
return Date.UTC(d.getFullYear(), d.getMonth(), d.getDate());
|
|
62
|
+
}
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const draftRule = {
|
|
65
|
+
id: "draft-rule",
|
|
66
|
+
description: "The draft checker's own rules, by id — how the built-in agents are expressed.",
|
|
67
|
+
example: { ruleIds: ["seo.title-missing", "a11y.alt-missing"] },
|
|
68
|
+
runnable: true,
|
|
69
|
+
params: z.object({ ruleIds: z.array(z.string().min(1)).min(1) }).strict(),
|
|
70
|
+
build(params) {
|
|
71
|
+
const wanted = new Set(params.ruleIds);
|
|
72
|
+
const unknown = [...wanted].filter((id) => !DRAFT_RULES.some((rule) => rule.id === id));
|
|
73
|
+
if (unknown.length > 0)
|
|
74
|
+
throw new Error(`unknown draft rule: ${unknown.join(", ")}`);
|
|
75
|
+
return DRAFT_RULES.filter((rule) => wanted.has(rule.id));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const fieldEmpty = {
|
|
79
|
+
id: "field-empty",
|
|
80
|
+
description: "A field that should have content has none.",
|
|
81
|
+
example: { blockType: "Hero", field: "subheading" },
|
|
82
|
+
runnable: true,
|
|
83
|
+
params: z.object({ field: z.string().min(1).max(80), blockType: z.string().min(1).max(80).optional() }).strict(),
|
|
84
|
+
build(params, ctx) {
|
|
85
|
+
const { field, blockType } = params;
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
id: ruleId(ctx, "field-empty"),
|
|
89
|
+
agent: ctx.agentId,
|
|
90
|
+
severity: ctx.severity,
|
|
91
|
+
run: (check) => capped(check.fields
|
|
92
|
+
.filter((entry) => fieldMatches(entry, field) && blockMatches(entry, blockType))
|
|
93
|
+
.filter((entry) => entry.kind !== "boolean" && fieldText(entry.value).trim() === "")
|
|
94
|
+
.map((entry) => ({
|
|
95
|
+
key: `${entry.blockId}:${entry.path}`,
|
|
96
|
+
title: `${entry.label ?? field} is empty`,
|
|
97
|
+
evidence: where(entry)
|
|
98
|
+
})))
|
|
99
|
+
}
|
|
100
|
+
];
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
const textMatches = {
|
|
104
|
+
id: "text-matches",
|
|
105
|
+
description: "Text that matches a pattern: a word to avoid, last year's date, an old price.",
|
|
106
|
+
example: { pattern: "\\b2025\\b", fields: ["heading", "description"] },
|
|
107
|
+
runnable: true,
|
|
108
|
+
params: z
|
|
109
|
+
.object({
|
|
110
|
+
pattern: z.string().min(1).max(200),
|
|
111
|
+
caseSensitive: z.boolean().optional(),
|
|
112
|
+
blockType: z.string().min(1).max(80).optional(),
|
|
113
|
+
fields: z.array(z.string().min(1).max(80)).max(20).optional(),
|
|
114
|
+
/** The finding's title; `{{match}}` is replaced by what matched. */
|
|
115
|
+
title: z.string().min(1).max(120).optional()
|
|
116
|
+
})
|
|
117
|
+
.strict(),
|
|
118
|
+
build(params, ctx) {
|
|
119
|
+
const p = params;
|
|
120
|
+
let pattern;
|
|
121
|
+
try {
|
|
122
|
+
pattern = new RegExp(p.pattern, p.caseSensitive ? "" : "i");
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
throw new Error(`text-matches: invalid pattern — ${error instanceof Error ? error.message : String(error)}`);
|
|
126
|
+
}
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
id: ruleId(ctx, "text-matches"),
|
|
130
|
+
agent: ctx.agentId,
|
|
131
|
+
severity: ctx.severity,
|
|
132
|
+
run: (check) => {
|
|
133
|
+
const out = [];
|
|
134
|
+
for (const entry of check.fields) {
|
|
135
|
+
if (!blockMatches(entry, p.blockType))
|
|
136
|
+
continue;
|
|
137
|
+
if (p.fields && !p.fields.some((field) => fieldMatches(entry, field)))
|
|
138
|
+
continue;
|
|
139
|
+
const text = fieldText(entry.value).slice(0, MAX_TEXT);
|
|
140
|
+
const match = text ? pattern.exec(text) : null;
|
|
141
|
+
if (!match)
|
|
142
|
+
continue;
|
|
143
|
+
out.push({
|
|
144
|
+
key: `${entry.blockId}:${entry.path}`,
|
|
145
|
+
title: (p.title ?? "“{{match}}” in {{field}}").replace("{{match}}", match[0]).replace("{{field}}", entry.label ?? entry.path),
|
|
146
|
+
evidence: { ...where(entry), excerpt: text.slice(Math.max(0, match.index - 40), match.index + match[0].length + 40) }
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return capped(out);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
];
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const fieldBeforeToday = {
|
|
156
|
+
id: "field-before-today",
|
|
157
|
+
description: "A date field whose day has passed: an event that is over, an offer that expired.",
|
|
158
|
+
example: { blockType: "Card", field: "date" },
|
|
159
|
+
runnable: true,
|
|
160
|
+
params: z.object({ field: z.string().min(1).max(80), blockType: z.string().min(1).max(80).optional() }).strict(),
|
|
161
|
+
build(params, ctx) {
|
|
162
|
+
const { field, blockType } = params;
|
|
163
|
+
return [
|
|
164
|
+
{
|
|
165
|
+
id: ruleId(ctx, "field-before-today"),
|
|
166
|
+
agent: ctx.agentId,
|
|
167
|
+
severity: ctx.severity,
|
|
168
|
+
run: (check) => {
|
|
169
|
+
const today = startOfToday(Date.now());
|
|
170
|
+
return capped(check.fields
|
|
171
|
+
.filter((entry) => fieldMatches(entry, field) && blockMatches(entry, blockType))
|
|
172
|
+
.flatMap((entry) => {
|
|
173
|
+
const at = parseFieldDate(entry.value);
|
|
174
|
+
if (at === null || at >= today)
|
|
175
|
+
return [];
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
key: `${entry.blockId}:${entry.path}`,
|
|
179
|
+
title: `${entry.label ?? field} has passed (${String(entry.value).trim()})`,
|
|
180
|
+
evidence: where(entry)
|
|
181
|
+
}
|
|
182
|
+
];
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
];
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
const notUpdatedFor = {
|
|
190
|
+
id: "not-updated-for",
|
|
191
|
+
description: "A page nobody has changed in a while.",
|
|
192
|
+
example: { days: 180 },
|
|
193
|
+
runnable: true,
|
|
194
|
+
params: z.object({ days: z.number().int().min(1).max(3650) }).strict(),
|
|
195
|
+
build(params, ctx) {
|
|
196
|
+
const { days } = params;
|
|
197
|
+
return [
|
|
198
|
+
{
|
|
199
|
+
id: ruleId(ctx, "not-updated-for"),
|
|
200
|
+
agent: ctx.agentId,
|
|
201
|
+
severity: ctx.severity,
|
|
202
|
+
run: (check) => {
|
|
203
|
+
const updated = Date.parse(check.page.updatedAt);
|
|
204
|
+
if (Number.isNaN(updated))
|
|
205
|
+
return [];
|
|
206
|
+
const age = Math.floor((Date.now() - updated) / 86_400_000);
|
|
207
|
+
if (age < days)
|
|
208
|
+
return [];
|
|
209
|
+
return [{ title: `Not updated for ${age} days`, evidence: { source: "draft" } }];
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
];
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
/** Edit safety's before/after diff. Named so its built-in spec is honest; the check runner skips it. */
|
|
216
|
+
const editDamage = {
|
|
217
|
+
id: "edit-damage",
|
|
218
|
+
description: "Damage an edit did that nobody asked for. Runs on each AI or MCP edit, not on a check run.",
|
|
219
|
+
example: {},
|
|
220
|
+
runnable: false,
|
|
221
|
+
params: z.object({}).strict(),
|
|
222
|
+
build: () => []
|
|
223
|
+
};
|
|
224
|
+
export const RULE_TEMPLATES = [draftRule, fieldEmpty, textMatches, fieldBeforeToday, notUpdatedFor, editDamage];
|
|
225
|
+
export function templateById(id) {
|
|
226
|
+
return RULE_TEMPLATES.find((template) => template.id === id);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* A spec's detect steps as check rules. Throws, naming the step, on an
|
|
230
|
+
* unknown template or bad params — a spec that cannot compile is refused
|
|
231
|
+
* when it is saved, not discovered when it runs at 03:00.
|
|
232
|
+
*/
|
|
233
|
+
export function compileSpec(spec) {
|
|
234
|
+
const rules = [];
|
|
235
|
+
spec.detect.forEach((step, stepIndex) => {
|
|
236
|
+
const template = templateById(step.template);
|
|
237
|
+
if (!template)
|
|
238
|
+
throw new Error(`detect[${stepIndex}]: unknown template "${step.template}"`);
|
|
239
|
+
const parsed = template.params.safeParse(step.params ?? {});
|
|
240
|
+
if (!parsed.success) {
|
|
241
|
+
const issue = parsed.error.issues[0];
|
|
242
|
+
throw new Error(`detect[${stepIndex}] (${step.template}): ${issue?.path.join(".") || "params"} — ${issue?.message ?? "invalid"}`);
|
|
243
|
+
}
|
|
244
|
+
if (!template.runnable)
|
|
245
|
+
return;
|
|
246
|
+
try {
|
|
247
|
+
rules.push(...template.build(parsed.data, { agentId: spec.id, stepIndex, severity: spec.severity }));
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
throw new Error(`detect[${stepIndex}] (${step.template}): ${error instanceof Error ? error.message : String(error)}`);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
return withScope(rules, spec.scope);
|
|
254
|
+
}
|
|
255
|
+
/** `scope.blockTypes` narrows the fields and links every rule sees. Page scope is the runner's. */
|
|
256
|
+
function withScope(rules, scope) {
|
|
257
|
+
const types = scope.blockTypes?.length ? new Set(scope.blockTypes) : null;
|
|
258
|
+
if (!types)
|
|
259
|
+
return rules;
|
|
260
|
+
return rules.map((rule) => ({
|
|
261
|
+
...rule,
|
|
262
|
+
run: (ctx) => rule.run({
|
|
263
|
+
...ctx,
|
|
264
|
+
fields: ctx.fields.filter((entry) => types.has(entry.blockType)),
|
|
265
|
+
links: ctx.links.filter((link) => types.has(link.blockType))
|
|
266
|
+
})
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { DurableStore, InboxItemRecord } from "../durable/types.ts";
|
|
2
|
+
import type { Logger } from "../logger.ts";
|
|
3
|
+
import { type ScheduleTrigger } from "./schedule.ts";
|
|
4
|
+
/** The digest's slot: Monday 06:00 UTC, covering the seven days before it. */
|
|
5
|
+
export declare const DIGEST_SCHEDULE: ScheduleTrigger;
|
|
6
|
+
export type TickOutcome = {
|
|
7
|
+
at: number;
|
|
8
|
+
sites: number;
|
|
9
|
+
ran: Array<{
|
|
10
|
+
scopeKey: string;
|
|
11
|
+
agentId: string;
|
|
12
|
+
opened: number;
|
|
13
|
+
closed: number;
|
|
14
|
+
}>;
|
|
15
|
+
failed: Array<{
|
|
16
|
+
scopeKey: string;
|
|
17
|
+
agentId: string;
|
|
18
|
+
error: string;
|
|
19
|
+
}>;
|
|
20
|
+
digests: string[];
|
|
21
|
+
};
|
|
22
|
+
export type TickArgs = {
|
|
23
|
+
now?: number;
|
|
24
|
+
/**
|
|
25
|
+
* Load a site's pages before its agents read them. Library mode passes the
|
|
26
|
+
* draft bootstrap here: a cold serverless process has no pages, and an
|
|
27
|
+
* agent run against none would close every finding it ever raised.
|
|
28
|
+
*/
|
|
29
|
+
prepare?: (scopeKey: string) => Promise<void>;
|
|
30
|
+
log?: Logger;
|
|
31
|
+
store?: DurableStore;
|
|
32
|
+
};
|
|
33
|
+
export declare function tickAgents(args?: TickArgs): Promise<TickOutcome>;
|
|
34
|
+
export type DigestAgentLine = {
|
|
35
|
+
agentId: string;
|
|
36
|
+
found: number;
|
|
37
|
+
fixed: number;
|
|
38
|
+
open: number;
|
|
39
|
+
};
|
|
40
|
+
export type DigestPayload = {
|
|
41
|
+
weekStart: number;
|
|
42
|
+
weekEnd: number;
|
|
43
|
+
found: number;
|
|
44
|
+
fixed: number;
|
|
45
|
+
open: number;
|
|
46
|
+
caught: number;
|
|
47
|
+
reverted: number;
|
|
48
|
+
agents: DigestAgentLine[];
|
|
49
|
+
};
|
|
50
|
+
export declare function digestItemId(scopeKey: string, slot: number): string;
|
|
51
|
+
/** "9 fixed, 2 need you" — the digest's headline is its result (SO-06). */
|
|
52
|
+
export declare function digestHeadline(d: Pick<DigestPayload, "fixed" | "open" | "found" | "caught">): string;
|
|
53
|
+
/**
|
|
54
|
+
* Post the digest for the week that ended at the latest Monday slot, once.
|
|
55
|
+
* A week in which no agent found, fixed or caught anything posts nothing
|
|
56
|
+
* (SO-07: a run that finds nothing never creates a card). A new digest closes
|
|
57
|
+
* last week's if nobody opened it: two weeks of the same news is noise.
|
|
58
|
+
*/
|
|
59
|
+
export declare function postWeeklyDigest(scopeKey: string, now: number, store: DurableStore): Promise<InboxItemRecord | null>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { getDurableStore } from "../durable/durable-store-singleton.js";
|
|
2
|
+
import { agentOfFinding } from "./inbox.js";
|
|
3
|
+
import { runAgentSpec } from "./run-spec.js";
|
|
4
|
+
import { isDue, latestSlot } from "./schedule.js";
|
|
5
|
+
import { resolveSiteAgents } from "./settings.js";
|
|
6
|
+
import { siteOpsAgentsEnabled } from "./enabled.js";
|
|
7
|
+
/*
|
|
8
|
+
* POST /agents/tick (site-ops-agents.md §8 item 6): run every scheduled agent
|
|
9
|
+
* whose slot has passed, on every site, and post each site's weekly digest.
|
|
10
|
+
*
|
|
11
|
+
* Idempotent by construction. What is due is read from what was recorded —
|
|
12
|
+
* the check-run ledger for agents, the Inbox for digests — so a tick that
|
|
13
|
+
* fires twice, or two processes ticking at once, repeat nothing but the
|
|
14
|
+
* reading. The one window left is two ticks inside the same run; a run
|
|
15
|
+
* records its start before it scans, so that window is milliseconds.
|
|
16
|
+
*/
|
|
17
|
+
/** The digest's slot: Monday 06:00 UTC, covering the seven days before it. */
|
|
18
|
+
export const DIGEST_SCHEDULE = { kind: "schedule", every: "week", weekday: 1, hour: 6 };
|
|
19
|
+
const WEEK = 7 * 24 * 3_600_000;
|
|
20
|
+
/** How far back the ledger is read to find an agent's last scheduled run. */
|
|
21
|
+
const LEDGER_WINDOW = 500;
|
|
22
|
+
export async function tickAgents(args = {}) {
|
|
23
|
+
const now = args.now ?? Date.now();
|
|
24
|
+
const store = args.store ?? getDurableStore();
|
|
25
|
+
const outcome = { at: now, sites: 0, ran: [], failed: [], digests: [] };
|
|
26
|
+
if (!siteOpsAgentsEnabled())
|
|
27
|
+
return outcome;
|
|
28
|
+
for (const scopeKey of await store.listAgentScopes()) {
|
|
29
|
+
outcome.sites += 1;
|
|
30
|
+
try {
|
|
31
|
+
await tickSite(scopeKey, now, store, args, outcome);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
// One site failing must not starve the rest of the tick.
|
|
35
|
+
args.log?.error({ err: String(error), scopeKey }, "[agents] tick failed for a site");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return outcome;
|
|
39
|
+
}
|
|
40
|
+
async function tickSite(scopeKey, now, store, args, outcome) {
|
|
41
|
+
const site = await resolveSiteAgents(scopeKey, store);
|
|
42
|
+
const rows = new Map((await store.listAgentSettings(scopeKey)).map((row) => [row.agentId, row]));
|
|
43
|
+
const scheduled = site.agents.filter((agent) => agent.enabled && agent.spec.trigger.kind === "schedule");
|
|
44
|
+
let ledger = null;
|
|
45
|
+
const due = [];
|
|
46
|
+
for (const agent of scheduled) {
|
|
47
|
+
ledger ??= await store.listCheckRuns(scopeKey, LEDGER_WINDOW);
|
|
48
|
+
const last = ledger.find((run) => run.agent === agent.agentId && run.trigger === "scheduled")?.startedAt ?? 0;
|
|
49
|
+
// A change to the agent counts as a fresh start: switched on at 09:00, it
|
|
50
|
+
// waits for tomorrow's 06:00 rather than catching up on today's.
|
|
51
|
+
const since = Math.max(last, rows.get(agent.agentId)?.updatedAt ?? 0);
|
|
52
|
+
if (isDue(agent.spec.trigger, since, now))
|
|
53
|
+
due.push(agent);
|
|
54
|
+
}
|
|
55
|
+
if (due.length > 0)
|
|
56
|
+
await args.prepare?.(scopeKey);
|
|
57
|
+
for (const agent of due) {
|
|
58
|
+
try {
|
|
59
|
+
const run = await runAgentSpec({ scopeKey, spec: agent.spec, trigger: "scheduled", store, now: () => now });
|
|
60
|
+
outcome.ran.push({ scopeKey, agentId: agent.agentId, opened: run.findingsOpened, closed: run.findingsClosed });
|
|
61
|
+
args.log?.info({ scopeKey, agentId: agent.agentId, opened: run.findingsOpened }, "[agents] scheduled run complete");
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
outcome.failed.push({ scopeKey, agentId: agent.agentId, error: message });
|
|
66
|
+
args.log?.error({ err: message, scopeKey, agentId: agent.agentId }, "[agents] scheduled run failed");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (site.agents.some((agent) => agent.enabled)) {
|
|
70
|
+
const digest = await postWeeklyDigest(scopeKey, now, store);
|
|
71
|
+
if (digest)
|
|
72
|
+
outcome.digests.push(digest.id);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function digestItemId(scopeKey, slot) {
|
|
76
|
+
return `digest:${scopeKey}:${new Date(slot).toISOString().slice(0, 10)}`;
|
|
77
|
+
}
|
|
78
|
+
/** "9 fixed, 2 need you" — the digest's headline is its result (SO-06). */
|
|
79
|
+
export function digestHeadline(d) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (d.fixed > 0)
|
|
82
|
+
parts.push(`${d.fixed} fixed`);
|
|
83
|
+
if (d.open > 0)
|
|
84
|
+
parts.push(`${d.open} need${d.open === 1 ? "s" : ""} you`);
|
|
85
|
+
if (parts.length === 0 && d.found > 0)
|
|
86
|
+
parts.push(`${d.found} found`);
|
|
87
|
+
if (d.caught > 0)
|
|
88
|
+
parts.push(`${d.caught} edit${d.caught === 1 ? "" : "s"} caught`);
|
|
89
|
+
return parts.join(", ");
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Post the digest for the week that ended at the latest Monday slot, once.
|
|
93
|
+
* A week in which no agent found, fixed or caught anything posts nothing
|
|
94
|
+
* (SO-07: a run that finds nothing never creates a card). A new digest closes
|
|
95
|
+
* last week's if nobody opened it: two weeks of the same news is noise.
|
|
96
|
+
*/
|
|
97
|
+
export async function postWeeklyDigest(scopeKey, now, store) {
|
|
98
|
+
const weekEnd = latestSlot(DIGEST_SCHEDULE, now);
|
|
99
|
+
const weekStart = weekEnd - WEEK;
|
|
100
|
+
const id = digestItemId(scopeKey, weekEnd);
|
|
101
|
+
if (await store.getInboxItem(id))
|
|
102
|
+
return null;
|
|
103
|
+
const site = await resolveSiteAgents(scopeKey, store);
|
|
104
|
+
const enabled = new Set(site.agents.filter((agent) => agent.enabled).map((agent) => agent.agentId));
|
|
105
|
+
const inWeek = (at) => at != null && at >= weekStart && at < weekEnd;
|
|
106
|
+
const lines = new Map();
|
|
107
|
+
const line = (agentId) => {
|
|
108
|
+
let entry = lines.get(agentId);
|
|
109
|
+
if (!entry)
|
|
110
|
+
lines.set(agentId, (entry = { agentId, found: 0, fixed: 0, open: 0 }));
|
|
111
|
+
return entry;
|
|
112
|
+
};
|
|
113
|
+
for (const finding of await store.listFindings({ scopeKey })) {
|
|
114
|
+
const agentId = agentOfFinding(finding);
|
|
115
|
+
if (!enabled.has(agentId))
|
|
116
|
+
continue;
|
|
117
|
+
if (inWeek(finding.firstSeenAt))
|
|
118
|
+
line(agentId).found += 1;
|
|
119
|
+
if (finding.status === "fixed" && inWeek(finding.resolvedAt))
|
|
120
|
+
line(agentId).fixed += 1;
|
|
121
|
+
if (finding.status === "open")
|
|
122
|
+
line(agentId).open += 1;
|
|
123
|
+
}
|
|
124
|
+
let caught = 0;
|
|
125
|
+
let reverted = 0;
|
|
126
|
+
if (enabled.has("edit-safety")) {
|
|
127
|
+
for (const alert of await store.listInboxItems({ scopeKey, kind: "alert", agent: "edit-safety" })) {
|
|
128
|
+
if (!inWeek(alert.createdAt))
|
|
129
|
+
continue;
|
|
130
|
+
caught += 1;
|
|
131
|
+
if (alert.resolution === "reverted")
|
|
132
|
+
reverted += 1;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const agents = [...lines.values()].filter((l) => l.found + l.fixed + l.open > 0);
|
|
136
|
+
const sum = (key) => agents.reduce((total, l) => total + l[key], 0);
|
|
137
|
+
const payload = {
|
|
138
|
+
weekStart,
|
|
139
|
+
weekEnd,
|
|
140
|
+
found: sum("found"),
|
|
141
|
+
fixed: sum("fixed"),
|
|
142
|
+
open: sum("open"),
|
|
143
|
+
caught,
|
|
144
|
+
reverted,
|
|
145
|
+
agents
|
|
146
|
+
};
|
|
147
|
+
if (payload.found + payload.fixed + payload.caught === 0)
|
|
148
|
+
return null;
|
|
149
|
+
for (const previous of await store.listInboxItems({ scopeKey, kind: "report", agent: "digest", status: "open" })) {
|
|
150
|
+
await store.resolveInboxItem(previous.id, { resolution: "superseded", at: now });
|
|
151
|
+
}
|
|
152
|
+
return store.putInboxItem({
|
|
153
|
+
id,
|
|
154
|
+
scopeKey,
|
|
155
|
+
kind: "report",
|
|
156
|
+
agent: "digest",
|
|
157
|
+
object: "This week",
|
|
158
|
+
verb: `${payload.found} found, ${payload.fixed} fixed`,
|
|
159
|
+
title: digestHeadline(payload),
|
|
160
|
+
slugs: [],
|
|
161
|
+
payload: payload,
|
|
162
|
+
createdAt: now
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Whether `tool_choice: {type: "tool", name}` is accepted by this model. */
|
|
2
|
+
export declare function supportsForcedToolChoice(model: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Request fields for a call that wants as little thinking as the model allows.
|
|
5
|
+
*
|
|
6
|
+
* - Sonnet 5.5: `between_tools` is its "off" (valid at effort high or below,
|
|
7
|
+
* which is the default, and accepts no other field inside `thinking`).
|
|
8
|
+
* - Opus 5.5 / Fable 5.1: thinking cannot be turned off; `low` effort keeps it
|
|
9
|
+
* short. Opus 5.5 would otherwise run at its `medium` default.
|
|
10
|
+
* - Everything older: send nothing, as before.
|
|
11
|
+
*/
|
|
12
|
+
export declare function minimalThinkingParams(model: string): {
|
|
13
|
+
thinking?: {
|
|
14
|
+
type: "between_tools";
|
|
15
|
+
};
|
|
16
|
+
output_config?: {
|
|
17
|
+
effort: "low";
|
|
18
|
+
};
|
|
19
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// What a given Claude model accepts, for the two request shapes the planner
|
|
2
|
+
// used to send to every model alike.
|
|
3
|
+
//
|
|
4
|
+
// Sonnet 5.5, Opus 5.5 and Fable 5.1 changed both. Forced `tool_choice`
|
|
5
|
+
// (`{type: "tool"}` / `{type: "any"}`) is a 400 on them, and so is
|
|
6
|
+
// `thinking: {type: "disabled"}`; each turns thinking off, or down, its own way.
|
|
7
|
+
// Older models — Haiku 4.5 on the fast tier, and anything a deployment pins
|
|
8
|
+
// through ANTHROPIC_MODEL_* — keep the old behaviour, so this is decided per
|
|
9
|
+
// model rather than by flipping the planner over wholesale.
|
|
10
|
+
/**
|
|
11
|
+
* Version of a Claude model id, e.g. `claude-sonnet-5-5` → sonnet 5.5,
|
|
12
|
+
* `claude-haiku-4-5-20251001` → haiku 4.5, `claude-opus-5` → opus 5.0. A
|
|
13
|
+
* trailing 8-digit date is a snapshot suffix, not a minor version. Returns null
|
|
14
|
+
* for anything that is not a `claude-<family>-<major>` id.
|
|
15
|
+
*/
|
|
16
|
+
function parseClaudeModel(model) {
|
|
17
|
+
const match = /^(?:anthropic\.)?claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?:-\d{8})?(?:@.*)?$/.exec(model.trim().toLowerCase());
|
|
18
|
+
if (!match)
|
|
19
|
+
return null;
|
|
20
|
+
return { family: match[1], version: Number(match[2]) + Number(match[3] ?? 0) / 10 };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Sonnet/Opus 5.5 and later, Fable/Mythos 5.1 and later. Unrecognised ids are
|
|
24
|
+
* treated as old: a forced tool_choice that a new model rejects fails loudly on
|
|
25
|
+
* the first request, while the reverse would silently drop the guarantee.
|
|
26
|
+
*/
|
|
27
|
+
function isPreservedThinkingGeneration(model) {
|
|
28
|
+
const parsed = parseClaudeModel(model);
|
|
29
|
+
if (!parsed)
|
|
30
|
+
return false;
|
|
31
|
+
if (parsed.family === "sonnet" || parsed.family === "opus")
|
|
32
|
+
return parsed.version >= 5.5;
|
|
33
|
+
if (parsed.family === "fable" || parsed.family === "mythos")
|
|
34
|
+
return parsed.version >= 5.1;
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
/** Whether `tool_choice: {type: "tool", name}` is accepted by this model. */
|
|
38
|
+
export function supportsForcedToolChoice(model) {
|
|
39
|
+
return !isPreservedThinkingGeneration(model);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Request fields for a call that wants as little thinking as the model allows.
|
|
43
|
+
*
|
|
44
|
+
* - Sonnet 5.5: `between_tools` is its "off" (valid at effort high or below,
|
|
45
|
+
* which is the default, and accepts no other field inside `thinking`).
|
|
46
|
+
* - Opus 5.5 / Fable 5.1: thinking cannot be turned off; `low` effort keeps it
|
|
47
|
+
* short. Opus 5.5 would otherwise run at its `medium` default.
|
|
48
|
+
* - Everything older: send nothing, as before.
|
|
49
|
+
*/
|
|
50
|
+
export function minimalThinkingParams(model) {
|
|
51
|
+
if (!isPreservedThinkingGeneration(model))
|
|
52
|
+
return {};
|
|
53
|
+
if (parseClaudeModel(model)?.family === "sonnet")
|
|
54
|
+
return { thinking: { type: "between_tools" } };
|
|
55
|
+
return { output_config: { effort: "low" } };
|
|
56
|
+
}
|
|
@@ -8,6 +8,7 @@ import { type ToolRuntime } from "../tools/runtime.ts";
|
|
|
8
8
|
import type { ToolExecutionEvent } from "../tools/types.ts";
|
|
9
9
|
import { type DeferredNativeImageCall } from "./chat-pipeline-shared.ts";
|
|
10
10
|
export type { DeferredNativeImageCall };
|
|
11
|
+
export declare function getAnthropicClient(): Anthropic;
|
|
11
12
|
/** Reset the singleton (useful for tests that swap API keys). */
|
|
12
13
|
export declare function resetAnthropicClient(): void;
|
|
13
14
|
export declare function parseIntentWithAnthropic(args: {
|