@avocadostudio-ai/orchestrator-core 0.29.0 → 0.30.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.
Files changed (84) hide show
  1. package/dist/agents/assistant/assemble.d.ts +29 -0
  2. package/dist/agents/assistant/assemble.js +151 -0
  3. package/dist/agents/assistant/builtin-skills.d.ts +1 -0
  4. package/dist/agents/assistant/builtin-skills.js +149 -0
  5. package/dist/agents/assistant/connectors/analytics.d.ts +27 -0
  6. package/dist/agents/assistant/connectors/analytics.js +109 -0
  7. package/dist/agents/assistant/connectors/drive.d.ts +15 -0
  8. package/dist/agents/assistant/connectors/drive.js +203 -0
  9. package/dist/agents/assistant/connectors/google-access.d.ts +11 -0
  10. package/dist/agents/assistant/connectors/google-access.js +21 -0
  11. package/dist/agents/assistant/connectors/pagespeed.d.ts +39 -0
  12. package/dist/agents/assistant/connectors/pagespeed.js +103 -0
  13. package/dist/agents/assistant/connectors/types.d.ts +31 -0
  14. package/dist/agents/assistant/connectors/types.js +1 -0
  15. package/dist/agents/assistant/connectors.d.ts +14 -0
  16. package/dist/agents/assistant/connectors.js +66 -0
  17. package/dist/agents/assistant/context.d.ts +14 -0
  18. package/dist/agents/assistant/context.js +67 -0
  19. package/dist/agents/assistant/media-sink.d.ts +12 -0
  20. package/dist/agents/assistant/media-sink.js +19 -0
  21. package/dist/agents/assistant/next-steps.d.ts +10 -0
  22. package/dist/agents/assistant/next-steps.js +34 -0
  23. package/dist/agents/assistant/permissions.d.ts +62 -0
  24. package/dist/agents/assistant/permissions.js +76 -0
  25. package/dist/agents/assistant/planner-tool.d.ts +9 -0
  26. package/dist/agents/assistant/planner-tool.js +79 -0
  27. package/dist/agents/assistant/router.d.ts +20 -0
  28. package/dist/agents/assistant/router.js +69 -0
  29. package/dist/agents/assistant/routines.d.ts +85 -0
  30. package/dist/agents/assistant/routines.js +142 -0
  31. package/dist/agents/assistant/site-connections.d.ts +17 -0
  32. package/dist/agents/assistant/site-connections.js +51 -0
  33. package/dist/agents/assistant/skills.d.ts +31 -0
  34. package/dist/agents/assistant/skills.js +79 -0
  35. package/dist/agents/learning.js +21 -7
  36. package/dist/agents/runtime/apply.d.ts +29 -0
  37. package/dist/agents/runtime/apply.js +72 -0
  38. package/dist/agents/runtime/approve.d.ts +71 -0
  39. package/dist/agents/runtime/approve.js +145 -0
  40. package/dist/agents/runtime/definition.d.ts +88 -0
  41. package/dist/agents/runtime/definition.js +12 -0
  42. package/dist/agents/runtime/engine.d.ts +88 -0
  43. package/dist/agents/runtime/engine.js +322 -0
  44. package/dist/agents/runtime/model.d.ts +34 -0
  45. package/dist/agents/runtime/model.js +80 -0
  46. package/dist/agents/runtime/registry.d.ts +22 -0
  47. package/dist/agents/runtime/registry.js +14 -0
  48. package/dist/agents/runtime/service.d.ts +62 -0
  49. package/dist/agents/runtime/service.js +217 -0
  50. package/dist/agents/runtime/toolkit.d.ts +64 -0
  51. package/dist/agents/runtime/toolkit.js +479 -0
  52. package/dist/agents/search/follow-up.d.ts +11 -0
  53. package/dist/agents/search/follow-up.js +76 -0
  54. package/dist/agents/search/opportunities.d.ts +42 -0
  55. package/dist/agents/search/opportunities.js +153 -0
  56. package/dist/agents/search/tools.d.ts +31 -0
  57. package/dist/agents/search/tools.js +134 -0
  58. package/dist/agents/tick.d.ts +3 -0
  59. package/dist/agents/tick.js +12 -0
  60. package/dist/checks/session-runner.js +14 -3
  61. package/dist/connections/google-auth.d.ts +55 -0
  62. package/dist/connections/google-auth.js +159 -0
  63. package/dist/connections/google-oauth.d.ts +60 -0
  64. package/dist/connections/google-oauth.js +207 -0
  65. package/dist/connections/search-console.d.ts +76 -0
  66. package/dist/connections/search-console.js +129 -0
  67. package/dist/connections/secrets.d.ts +15 -0
  68. package/dist/connections/secrets.js +88 -0
  69. package/dist/durable/in-memory-durable-store.d.ts +15 -1
  70. package/dist/durable/in-memory-durable-store.js +110 -1
  71. package/dist/durable/sqlite-durable-store.d.ts +19 -1
  72. package/dist/durable/sqlite-durable-store.js +198 -0
  73. package/dist/durable/types.d.ts +84 -0
  74. package/dist/handler/auth.js +7 -1
  75. package/dist/handler/create-orchestrator.d.ts +6 -1
  76. package/dist/handler/create-orchestrator.js +171 -3
  77. package/dist/http/agent-runtime-actions.d.ts +58 -0
  78. package/dist/http/agent-runtime-actions.js +247 -0
  79. package/dist/http/assistant-actions.d.ts +83 -0
  80. package/dist/http/assistant-actions.js +366 -0
  81. package/dist/http/connections-actions.d.ts +41 -0
  82. package/dist/http/connections-actions.js +145 -0
  83. package/dist/nlp/deterministic-planner-suggestions.js +23 -2
  84. package/package.json +3 -3
@@ -0,0 +1,31 @@
1
+ import type { RuntimeTool } from "../runtime/definition.ts";
2
+ export type Skill = {
3
+ /** Lowercase, digits and dashes; the name `use_skill` takes. */
4
+ name: string;
5
+ /** A label for people, e.g. "Search analysis". */
6
+ title: string;
7
+ /** One line: what it is for and when to use it. Always in the prompt. */
8
+ description: string;
9
+ /** The method. Loaded on demand. */
10
+ body: string;
11
+ /** Connector ids it cannot work without. */
12
+ needs: string[];
13
+ origin: "builtin" | "site";
14
+ };
15
+ export declare const SKILL_MAX_CHARS = 12000;
16
+ /** Parse a `SKILL.md`. Only `name`, `title`, `description` and `needs` are read from the frontmatter. */
17
+ export declare function parseSkillMarkdown(text: string, origin: Skill["origin"]): Skill | {
18
+ error: string;
19
+ };
20
+ export declare const BUILTIN_SKILLS: Skill[];
21
+ /** Built-ins first; a site skill with a built-in's name is ignored rather than allowed to replace it. */
22
+ export declare function mergeSkills(site: Skill[]): Skill[];
23
+ /** The lines that tell a run which skills exist. */
24
+ export declare function skillCatalog(skills: Skill[]): string;
25
+ /** A skill's body as preloaded into a routine's instructions. */
26
+ export declare function skillSection(skill: Skill): string;
27
+ /**
28
+ * `use_skill`: load a skill's method. Reading is its only effect. What it
29
+ * returns is instructions from the site's own skills, so it is not fenced.
30
+ */
31
+ export declare function useSkillTool(skills: Skill[]): RuntimeTool;
@@ -0,0 +1,79 @@
1
+ import { BUILTIN_SKILL_TEXTS } from "./builtin-skills.js";
2
+ const NAME = /^[a-z0-9][a-z0-9-]{1,47}$/;
3
+ export const SKILL_MAX_CHARS = 12_000;
4
+ function listValue(raw) {
5
+ return raw
6
+ .replace(/^\[|\]$/g, "")
7
+ .split(",")
8
+ .map((part) => part.trim().replace(/^["']|["']$/g, ""))
9
+ .filter(Boolean);
10
+ }
11
+ /** Parse a `SKILL.md`. Only `name`, `title`, `description` and `needs` are read from the frontmatter. */
12
+ export function parseSkillMarkdown(text, origin) {
13
+ if (text.length > SKILL_MAX_CHARS)
14
+ return { error: `a skill is at most ${SKILL_MAX_CHARS} characters` };
15
+ const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(text.trim());
16
+ if (!match)
17
+ return { error: "a skill starts with frontmatter between --- lines" };
18
+ const fields = {};
19
+ for (const line of match[1].split(/\r?\n/)) {
20
+ const pair = /^([a-z_]+):\s*(.*)$/.exec(line.trim());
21
+ if (pair)
22
+ fields[pair[1]] = pair[2].trim().replace(/^["']|["']$/g, "");
23
+ }
24
+ const name = fields.name ?? "";
25
+ if (!NAME.test(name))
26
+ return { error: "name must be lowercase letters, digits and dashes" };
27
+ const description = fields.description ?? "";
28
+ if (!description)
29
+ return { error: "description is required" };
30
+ const body = match[2].trim();
31
+ if (!body)
32
+ return { error: "the skill has no body" };
33
+ const title = fields.title || name.replace(/-/g, " ").replace(/^./, (c) => c.toUpperCase());
34
+ return { name, title, description, body, needs: fields.needs ? listValue(fields.needs) : [], origin };
35
+ }
36
+ function parseBuiltin(text) {
37
+ const parsed = parseSkillMarkdown(text, "builtin");
38
+ if ("error" in parsed)
39
+ throw new Error(`built-in skill does not parse: ${parsed.error}`);
40
+ return parsed;
41
+ }
42
+ export const BUILTIN_SKILLS = BUILTIN_SKILL_TEXTS.map(parseBuiltin);
43
+ /** Built-ins first; a site skill with a built-in's name is ignored rather than allowed to replace it. */
44
+ export function mergeSkills(site) {
45
+ const builtin = new Set(BUILTIN_SKILLS.map((skill) => skill.name));
46
+ return [...BUILTIN_SKILLS, ...site.filter((skill) => !builtin.has(skill.name))];
47
+ }
48
+ /** The lines that tell a run which skills exist. */
49
+ export function skillCatalog(skills) {
50
+ if (skills.length === 0)
51
+ return "";
52
+ return [
53
+ "Skills you can load with use_skill, by name, when a task matches one. Load it before you act on that task:",
54
+ ...skills.map((skill) => `- ${skill.name}: ${skill.description}`)
55
+ ].join("\n");
56
+ }
57
+ /** A skill's body as preloaded into a routine's instructions. */
58
+ export function skillSection(skill) {
59
+ return `## Skill: ${skill.title}\n\n${skill.body}`;
60
+ }
61
+ /**
62
+ * `use_skill`: load a skill's method. Reading is its only effect. What it
63
+ * returns is instructions from the site's own skills, so it is not fenced.
64
+ */
65
+ export function useSkillTool(skills) {
66
+ return {
67
+ name: "use_skill",
68
+ effect: "read",
69
+ description: "Load a skill's method by name before doing the task it covers. The list of skills is in your instructions.",
70
+ inputSchema: { type: "object", properties: { name: { type: "string" } }, required: ["name"] },
71
+ run: async (input) => {
72
+ const name = typeof input.name === "string" ? input.name.trim() : "";
73
+ const skill = skills.find((candidate) => candidate.name === name);
74
+ if (!skill)
75
+ return { result: `No skill ${name}. Use one of: ${skills.map((s) => s.name).join(", ")}.`, isError: true };
76
+ return { result: skillSection(skill), trace: `loaded skill ${skill.name}` };
77
+ }
78
+ };
79
+ }
@@ -38,6 +38,13 @@ export const SITE_FACT_KINDS = ["fact", "preference", "glossary"];
38
38
  export function isSiteFactKind(value) {
39
39
  return typeof value === "string" && SITE_FACT_KINDS.includes(value);
40
40
  }
41
+ /** The About-this-site sections (agents/assistant/context.ts keys), as the planner reads them. */
42
+ const ABOUT_LABELS = {
43
+ "about.business": "About the business",
44
+ "about.voice": "Voice and tone",
45
+ "about.goals": "What counts as a win",
46
+ "about.avoid": "Do not touch"
47
+ };
41
48
  /** How many facts reach the planner. The rest stay visible in the editor. */
42
49
  export const SITE_FACTS_IN_PROMPT = 40;
43
50
  /** Longer than this and a "fact" is a document; it belongs in the site's content. */
@@ -59,13 +66,20 @@ export async function siteFactsForPlanner(scopeKey, log) {
59
66
  if (!siteOpsAgentsEnabled())
60
67
  return [];
61
68
  try {
62
- const facts = await listSiteFacts(scopeKey, SITE_FACTS_IN_PROMPT);
63
- if (facts.length === 0)
64
- return [];
65
- void getDurableStore()
66
- .touchMemory(facts.map((fact) => fact.id))
67
- .catch(() => { });
68
- return facts.map((fact) => fact.value);
69
+ const [facts, about] = await Promise.all([
70
+ listSiteFacts(scopeKey, SITE_FACTS_IN_PROMPT),
71
+ getDurableStore().listMemory({ scopeKey, scope: "site", kind: "decision", status: "active", limit: 20 })
72
+ ]);
73
+ if (facts.length > 0) {
74
+ void getDurableStore()
75
+ .touchMemory(facts.map((fact) => fact.id))
76
+ .catch(() => { });
77
+ }
78
+ // About this site (agents/assistant/context.ts): what the assistant reads first, the planner reads too.
79
+ const aboutLines = about
80
+ .filter((row) => ABOUT_LABELS[row.key])
81
+ .map((row) => `${ABOUT_LABELS[row.key]}: ${row.value}`);
82
+ return [...aboutLines, ...facts.map((fact) => fact.value)];
69
83
  }
70
84
  catch (error) {
71
85
  log?.warn({ err: error }, "[learning] site facts unavailable; planning without them");
@@ -0,0 +1,29 @@
1
+ import type { Logger } from "../../logger.ts";
2
+ import type { VersionActor } from "../../state/sqlite-store.ts";
3
+ import { type ProposedChange } from "./toolkit.ts";
4
+ export type ChangeSetOutcome = {
5
+ applied: Array<{
6
+ slug: string;
7
+ changes: number;
8
+ }>;
9
+ /** A field somebody changed after it was read: left as the person wrote it. */
10
+ stale: Array<{
11
+ slug: string;
12
+ path: string;
13
+ }>;
14
+ failed: Array<{
15
+ slug: string;
16
+ error: string;
17
+ }>;
18
+ /** Pages that no longer exist. */
19
+ missing: string[];
20
+ previewVersion?: number;
21
+ };
22
+ export declare function applyChangeSet(args: {
23
+ scopeKey: string;
24
+ changes: ProposedChange[];
25
+ actor: VersionActor;
26
+ /** The version log's line for it. */
27
+ summary: string;
28
+ log?: Logger;
29
+ }): Promise<ChangeSetOutcome>;
@@ -0,0 +1,72 @@
1
+ /*
2
+ * Applying a set of field changes to the draft, page by page, with undo.
3
+ *
4
+ * Two callers: approving an agent's proposal in the Inbox (approve.ts), and
5
+ * the site assistant changing the site in chat, where the person who asked is
6
+ * the approval (`apply_changes`, toolkit.ts). Both write the same way: each
7
+ * change only onto the value it was written against, each page atomically and
8
+ * with its own undo entry, and one version-log entry naming who made it.
9
+ */
10
+ import { buildBlockManifest } from "@avocadostudio-ai/shared";
11
+ import { consoleLogger } from "../../logger.js";
12
+ import { applyOpsAtomically } from "../../ops/ops-engine.js";
13
+ import { bumpVersion, getPage, pushUndo, pushVersionEntry, schedulePersistState } from "../../state/session-state.js";
14
+ import { readField, writeOpFor } from "./toolkit.js";
15
+ const sameValue = (a, b) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
16
+ export async function applyChangeSet(args) {
17
+ const byPage = new Map();
18
+ for (const change of args.changes)
19
+ byPage.set(change.pageSlug, [...(byPage.get(change.pageSlug) ?? []), change]);
20
+ const outcome = { applied: [], stale: [], failed: [], missing: [] };
21
+ const appliedOps = [];
22
+ let lastSlug;
23
+ const manifest = buildBlockManifest();
24
+ for (const [slug, changes] of byPage) {
25
+ const page = getPage(args.scopeKey, slug);
26
+ if (!page) {
27
+ outcome.missing.push(slug);
28
+ continue;
29
+ }
30
+ const ops = [];
31
+ for (const change of changes) {
32
+ const current = readField(page, change.blockId, change.path);
33
+ if (!current.found || !sameValue(current.value, change.base)) {
34
+ outcome.stale.push({ slug, path: `${change.blockId}.${change.path}` });
35
+ continue;
36
+ }
37
+ const op = writeOpFor({ pageSlug: slug, blockId: change.blockId, path: change.path, value: change.value, ...(change.itemId ? { itemId: change.itemId } : {}) });
38
+ if (op)
39
+ ops.push(op);
40
+ }
41
+ if (ops.length === 0)
42
+ continue;
43
+ const before = structuredClone(page);
44
+ try {
45
+ await applyOpsAtomically(args.scopeKey, ops, { componentsManifest: manifest });
46
+ pushUndo(args.scopeKey, slug, before);
47
+ appliedOps.push(...ops);
48
+ lastSlug = slug;
49
+ outcome.applied.push({ slug, changes: ops.length });
50
+ }
51
+ catch (error) {
52
+ outcome.failed.push({ slug, error: error instanceof Error ? error.message : String(error) });
53
+ }
54
+ }
55
+ if (appliedOps.length > 0 && lastSlug) {
56
+ const previewVersion = bumpVersion(args.scopeKey);
57
+ const snapshot = getPage(args.scopeKey, lastSlug);
58
+ pushVersionEntry(args.scopeKey, {
59
+ version: previewVersion,
60
+ slug: lastSlug,
61
+ summary: args.summary,
62
+ opTypes: appliedOps.map((op) => op.op),
63
+ opCount: appliedOps.length,
64
+ source: "direct",
65
+ actor: args.actor,
66
+ snapshot: snapshot ? structuredClone(snapshot) : null
67
+ });
68
+ schedulePersistState(args.log ?? consoleLogger());
69
+ outcome.previewVersion = previewVersion;
70
+ }
71
+ return outcome;
72
+ }
@@ -0,0 +1,71 @@
1
+ import type { DurableStore } from "../../durable/types.ts";
2
+ import type { Logger } from "../../logger.ts";
3
+ export type ProposalOutcome = {
4
+ applied: Array<{
5
+ slug: string;
6
+ changes: number;
7
+ }>;
8
+ stale: Array<{
9
+ slug: string;
10
+ path: string;
11
+ }>;
12
+ failed: Array<{
13
+ slug: string;
14
+ error: string;
15
+ }>;
16
+ rejected: string[];
17
+ /** True once every page of the proposal is decided and the Inbox item closed. */
18
+ closed: boolean;
19
+ previewVersion?: number;
20
+ };
21
+ /**
22
+ * Apply a proposal's changes, for every page or for `slugs` only.
23
+ *
24
+ * Each page applies atomically on its own: one page's refusal does not undo
25
+ * another's success, and the outcome says which is which.
26
+ */
27
+ export declare function approveAgentProposal(args: {
28
+ store: DurableStore;
29
+ scopeKey: string;
30
+ inboxItemId: string;
31
+ slugs?: string[];
32
+ by?: string;
33
+ now?: () => number;
34
+ log?: Logger;
35
+ }): Promise<ProposalOutcome>;
36
+ export declare function rejectAgentProposal(args: {
37
+ store: DurableStore;
38
+ scopeKey: string;
39
+ inboxItemId: string;
40
+ slugs?: string[];
41
+ by?: string;
42
+ note?: string;
43
+ now?: () => number;
44
+ }): Promise<ProposalOutcome>;
45
+ /**
46
+ * The owner answered an agent's question: close it, resume the run, and keep the
47
+ * answer as a site fact when they asked for that.
48
+ */
49
+ export declare function answerAgentQuestion(args: {
50
+ store: DurableStore;
51
+ scopeKey: string;
52
+ inboxItemId: string;
53
+ answer: string;
54
+ by?: string;
55
+ remember?: {
56
+ key: string;
57
+ };
58
+ now?: () => number;
59
+ }): Promise<{
60
+ resumed: boolean;
61
+ runId: string;
62
+ }>;
63
+ /**
64
+ * Questions older than their expiry close, and their runs stop: a run that has
65
+ * waited a week on an answer is acting on a week-old picture of the site.
66
+ */
67
+ export declare function expireAgentQuestions(args: {
68
+ store: DurableStore;
69
+ scopeKey: string;
70
+ now?: () => number;
71
+ }): Promise<number>;
@@ -0,0 +1,145 @@
1
+ import { answerRun, stopRun } from "./engine.js";
2
+ import { applyChangeSet } from "./apply.js";
3
+ async function openProposal(store, inboxItemId) {
4
+ const item = await store.getInboxItem(inboxItemId);
5
+ if (!item || item.kind !== "proposal")
6
+ throw new Error(`no proposal ${inboxItemId}`);
7
+ if (item.status !== "open")
8
+ throw new Error("this proposal is already decided");
9
+ return item;
10
+ }
11
+ function changesOf(item) {
12
+ return (item.payload?.changes ?? []).slice();
13
+ }
14
+ /** Record which pages are decided; close the item and the proposal once all are. */
15
+ async function recordDecisions(store, item, decisions, by, now, appliedSlugs = []) {
16
+ const decided = { ...(item.payload?.decided ?? {}), ...decisions };
17
+ // When each page's change reached the draft: what a follow-up measures from.
18
+ const appliedAt = {
19
+ ...(item.payload?.appliedAt ?? {}),
20
+ ...Object.fromEntries(appliedSlugs.map((slug) => [slug, now]))
21
+ };
22
+ const allSlugs = [...new Set(changesOf(item).map((c) => c.pageSlug))];
23
+ const done = allSlugs.every((slug) => decided[slug]);
24
+ const { status: _status, revision: _revision, updatedAt: _updatedAt, ...input } = item;
25
+ await store.putInboxItem({ ...input, payload: { ...item.payload, decided, appliedAt } });
26
+ if (!done)
27
+ return false;
28
+ const values = Object.values(decided);
29
+ const resolution = values.every((d) => d === "approved") ? "approved" : values.every((d) => d === "rejected") ? "rejected" : "partly_approved";
30
+ await store.resolveInboxItem(item.id, { resolution, ...(by ? { resolvedBy: by } : {}), at: now });
31
+ const proposalId = item.payload?.proposalId;
32
+ if (typeof proposalId === "string")
33
+ await store.setProposalStatus(proposalId, resolution === "rejected" ? "discarded" : "approved", now);
34
+ return true;
35
+ }
36
+ /**
37
+ * Apply a proposal's changes, for every page or for `slugs` only.
38
+ *
39
+ * Each page applies atomically on its own: one page's refusal does not undo
40
+ * another's success, and the outcome says which is which.
41
+ */
42
+ export async function approveAgentProposal(args) {
43
+ const now = args.now ?? Date.now;
44
+ const item = await openProposal(args.store, args.inboxItemId);
45
+ if (item.scopeKey !== args.scopeKey)
46
+ throw new Error(`no proposal ${args.inboxItemId}`);
47
+ const decided = item.payload?.decided ?? {};
48
+ const byPage = new Map();
49
+ for (const change of changesOf(item)) {
50
+ if (decided[change.pageSlug])
51
+ continue;
52
+ if (args.slugs && !args.slugs.includes(change.pageSlug))
53
+ continue;
54
+ byPage.set(change.pageSlug, [...(byPage.get(change.pageSlug) ?? []), change]);
55
+ }
56
+ const applied = await applyChangeSet({
57
+ scopeKey: args.scopeKey,
58
+ changes: [...byPage.values()].flat(),
59
+ actor: { kind: "agent", id: item.agent },
60
+ summary: `Approved ${item.agent}'s proposal: ${item.title}${args.by ? ` (by ${args.by})` : ""}`,
61
+ ...(args.log ? { log: args.log } : {})
62
+ });
63
+ const outcome = {
64
+ applied: applied.applied,
65
+ stale: applied.stale,
66
+ failed: [...applied.failed, ...applied.missing.map((slug) => ({ slug, error: "the page no longer exists" }))],
67
+ rejected: [],
68
+ closed: false,
69
+ ...(applied.previewVersion !== undefined ? { previewVersion: applied.previewVersion } : {})
70
+ };
71
+ // A page that is gone is decided as rejected; every other page asked about is approved, stale fields and all.
72
+ const decisions = Object.fromEntries([...byPage.keys()].map((slug) => [slug, applied.missing.includes(slug) ? "rejected" : "approved"]));
73
+ outcome.closed = await recordDecisions(args.store, item, decisions, args.by, now(), outcome.applied.map((a) => a.slug));
74
+ return outcome;
75
+ }
76
+ export async function rejectAgentProposal(args) {
77
+ const now = args.now ?? Date.now;
78
+ const item = await openProposal(args.store, args.inboxItemId);
79
+ if (item.scopeKey !== args.scopeKey)
80
+ throw new Error(`no proposal ${args.inboxItemId}`);
81
+ const decided = item.payload?.decided ?? {};
82
+ const slugs = [...new Set(changesOf(item).map((c) => c.pageSlug))].filter((slug) => !decided[slug] && (!args.slugs || args.slugs.includes(slug)));
83
+ const decisions = Object.fromEntries(slugs.map((slug) => [slug, "rejected"]));
84
+ const closed = await recordDecisions(args.store, item, decisions, args.by, now());
85
+ if (closed && args.note) {
86
+ await args.store.resolveInboxItem(item.id, { resolution: "rejected", note: args.note, ...(args.by ? { resolvedBy: args.by } : {}) });
87
+ }
88
+ return { applied: [], stale: [], failed: [], rejected: slugs, closed };
89
+ }
90
+ /**
91
+ * The owner answered an agent's question: close it, resume the run, and keep the
92
+ * answer as a site fact when they asked for that.
93
+ */
94
+ export async function answerAgentQuestion(args) {
95
+ const now = args.now ?? Date.now;
96
+ const answer = args.answer.trim();
97
+ if (!answer)
98
+ throw new Error("an answer is required");
99
+ const item = await args.store.getInboxItem(args.inboxItemId);
100
+ if (!item || item.kind !== "question" || item.scopeKey !== args.scopeKey)
101
+ throw new Error(`no question ${args.inboxItemId}`);
102
+ if (item.status !== "open")
103
+ throw new Error("this question is already closed");
104
+ const runId = String(item.payload?.runId ?? "");
105
+ const resumed = await answerRun({ store: args.store, now }, runId, answer);
106
+ await args.store.resolveInboxItem(item.id, {
107
+ resolution: "answered",
108
+ note: answer,
109
+ ...(args.by ? { resolvedBy: args.by } : {}),
110
+ at: now()
111
+ });
112
+ if (args.remember?.key) {
113
+ await args.store.putMemory({
114
+ scope: "site",
115
+ scopeKey: args.scopeKey,
116
+ kind: "fact",
117
+ key: args.remember.key,
118
+ value: answer,
119
+ source: "user",
120
+ sourceRef: item.id,
121
+ confidence: 1
122
+ }, now());
123
+ }
124
+ return { resumed, runId };
125
+ }
126
+ /**
127
+ * Questions older than their expiry close, and their runs stop: a run that has
128
+ * waited a week on an answer is acting on a week-old picture of the site.
129
+ */
130
+ export async function expireAgentQuestions(args) {
131
+ const now = (args.now ?? Date.now)();
132
+ const open = await args.store.listInboxItems({ scopeKey: args.scopeKey, kind: "question", status: "open" });
133
+ let expired = 0;
134
+ for (const item of open) {
135
+ const expiresAt = Number(item.payload?.expiresAt ?? Number.POSITIVE_INFINITY);
136
+ if (expiresAt > now)
137
+ continue;
138
+ await args.store.resolveInboxItem(item.id, { resolution: "expired", at: now });
139
+ const runId = item.payload?.runId;
140
+ if (typeof runId === "string")
141
+ await stopRun({ store: args.store, now: () => now }, runId, "question_expired");
142
+ expired += 1;
143
+ }
144
+ return expired;
145
+ }
@@ -0,0 +1,88 @@
1
+ import type { DurableStore } from "../../durable/types.ts";
2
+ export type AgentTier = "fast" | "balanced" | "reasoning";
3
+ export type AgentBudget = {
4
+ /** Stop a run once it has spent this much. */
5
+ usdPerRun: number;
6
+ /** Stop starting steps once the agent has spent this much in a site this calendar month (UTC). */
7
+ usdPerMonth: number;
8
+ };
9
+ /** Decided 2026-10-03: per agent, per site; overridable per agent. */
10
+ export declare const DEFAULT_AGENT_BUDGET: AgentBudget;
11
+ /** A run that keeps calling tools without converging is stopped, not paid for. */
12
+ export declare const DEFAULT_MAX_STEPS = 25;
13
+ /**
14
+ * Output tokens a step may use; only what is used is paid for. 4096 was too
15
+ * few: on real content Sonnet 5.5 spent all of it thinking about a large
16
+ * proposal and never wrote one. A step that still hits its cap is retried with
17
+ * double, up to `MAX_TOKENS_CEILING` (see engine.ts).
18
+ */
19
+ export declare const DEFAULT_MAX_TOKENS_PER_STEP = 8192;
20
+ export declare const MAX_TOKENS_CEILING = 32768;
21
+ export type ToolContext = {
22
+ runId: string;
23
+ scopeKey: string;
24
+ agentId: string;
25
+ store: DurableStore;
26
+ now: () => number;
27
+ };
28
+ export type ToolOutcome =
29
+ /**
30
+ * `trace` is the one line the run page shows; `result` is what the model
31
+ * reads, and `images` what it sees beside it (base64, a type Claude reads).
32
+ */
33
+ {
34
+ result: string;
35
+ isError?: boolean;
36
+ trace?: string;
37
+ images?: Array<{
38
+ mediaType: "image/jpeg" | "image/png" | "image/webp" | "image/gif";
39
+ data: string;
40
+ }>;
41
+ }
42
+ /** The run pauses until the owner answers this Inbox item (the `ask` tool). */
43
+ | {
44
+ waitFor: {
45
+ inboxItemId: string;
46
+ };
47
+ trace?: string;
48
+ };
49
+ /**
50
+ * What calling a tool does, which is what a permission is about (assistant
51
+ * concept §3). `apply` changes the draft at once, and is given only to the
52
+ * chat assistant, where the person who asked is the approval. `outbound` sends something off the site — an email, a message,
53
+ * a write to someone else's system — and is the one the lethal-trifecta rule
54
+ * guards: once a run has read untrusted content, it is refused.
55
+ */
56
+ export type ToolEffect = "read" | "propose" | "apply" | "ask" | "remember" | "report" | "outbound";
57
+ export type RuntimeTool = {
58
+ name: string;
59
+ /** Defaults to "read". */
60
+ effect?: ToolEffect;
61
+ description: string;
62
+ inputSchema: {
63
+ type: "object";
64
+ properties: Record<string, unknown>;
65
+ required?: string[];
66
+ };
67
+ run: (input: Record<string, unknown>, ctx: ToolContext) => Promise<ToolOutcome>;
68
+ };
69
+ export type AgentDefinition = {
70
+ id: string;
71
+ /** One line, in the owner's language: what it is for. */
72
+ job: string;
73
+ /** The system prompt, after the preamble. */
74
+ instructions: string;
75
+ /** Replaces the runtime's default preamble: the chat assistant talks to a person, not to an Inbox. */
76
+ preamble?: string;
77
+ tools: RuntimeTool[];
78
+ tier: AgentTier;
79
+ budget: AgentBudget;
80
+ maxSteps?: number;
81
+ maxTokensPerStep?: number;
82
+ /**
83
+ * Tools that close a run: a step that calls only these, beside its text,
84
+ * runs them and finishes with that text, without another model call. The
85
+ * chat assistant's next_steps is one: it goes with the answer.
86
+ */
87
+ finishOn?: string[];
88
+ };
@@ -0,0 +1,12 @@
1
+ /** Decided 2026-10-03: per agent, per site; overridable per agent. */
2
+ export const DEFAULT_AGENT_BUDGET = { usdPerRun: 0.5, usdPerMonth: 20 };
3
+ /** A run that keeps calling tools without converging is stopped, not paid for. */
4
+ export const DEFAULT_MAX_STEPS = 25;
5
+ /**
6
+ * Output tokens a step may use; only what is used is paid for. 4096 was too
7
+ * few: on real content Sonnet 5.5 spent all of it thinking about a large
8
+ * proposal and never wrote one. A step that still hits its cap is retried with
9
+ * double, up to `MAX_TOKENS_CEILING` (see engine.ts).
10
+ */
11
+ export const DEFAULT_MAX_TOKENS_PER_STEP = 8192;
12
+ export const MAX_TOKENS_CEILING = 32_768;
@@ -0,0 +1,88 @@
1
+ import type Anthropic from "@anthropic-ai/sdk";
2
+ import type { AgentRunRecord, DurableStore, RunStepRecord, RunStepToolCall } from "../../durable/types.ts";
3
+ import type { Logger } from "../../logger.ts";
4
+ import { type AgentDefinition } from "./definition.ts";
5
+ import { type AgentModel } from "./model.ts";
6
+ export type RuntimeDeps = {
7
+ store: DurableStore;
8
+ model: AgentModel;
9
+ /** Defaults to the planner's tier defaults. Tests pin it. */
10
+ modelForTier?: (tier: AgentDefinition["tier"]) => string;
11
+ now?: () => number;
12
+ log?: Logger;
13
+ };
14
+ export type StepOutcome = {
15
+ status: "continue";
16
+ } | {
17
+ status: "waiting";
18
+ inboxItemId: string;
19
+ } | {
20
+ status: "done";
21
+ summary: string;
22
+ } | {
23
+ status: "stopped";
24
+ reason: string;
25
+ }
26
+ /** The model call failed. The run is left as it was; the next slice tries again. */
27
+ | {
28
+ status: "error";
29
+ message: string;
30
+ };
31
+ /** Midnight UTC on the first of `now`'s month: the start of the monthly budget. */
32
+ export declare function startOfMonthUtc(now: number): number;
33
+ /** The conversation a run has had so far, rebuilt from its steps. */
34
+ export declare function transcriptOf(run: Pick<AgentRunRecord, "input">, steps: RunStepRecord[]): Anthropic.Messages.MessageParam[];
35
+ export declare function startAgentRun(deps: RuntimeDeps, definition: AgentDefinition, args: {
36
+ scopeKey: string;
37
+ trigger: string;
38
+ input: string;
39
+ idempotencyKey?: string;
40
+ }): Promise<AgentRunRecord>;
41
+ export declare const OUTBOUND_REFUSED = "Refused: this run has read site or connector content, so it may not send anything off the site on its own. Describe what you would send in your summary; the owner decides.";
42
+ /**
43
+ * The lethal-trifecta rule (assistant concept §3). A run that holds private
44
+ * data, has read content a stranger could have written, and can send data out
45
+ * can be steered into leaking it. Every read tool returns such content (pages,
46
+ * search queries, connector rows), so once any read has happened in a run, an
47
+ * outbound tool is refused, whatever the site's permissions say. Enforced here,
48
+ * not in a prompt, because the prompt is exactly what an injection rewrites.
49
+ */
50
+ export declare function readsUntrusted(definition: AgentDefinition, steps: RunStepRecord[], current: RunStepToolCall[]): boolean;
51
+ /** Advance a run by one step. Callers hold its lease (see `driveRun`). */
52
+ export declare function stepRun(deps: RuntimeDeps, definition: AgentDefinition, runId: string): Promise<StepOutcome>;
53
+ /**
54
+ * The owner answered: give the waiting step its missing tool result and let the
55
+ * run continue. Answering twice, or a run that is not waiting, changes nothing.
56
+ */
57
+ /** What answering and stopping need: no model is called. */
58
+ export type StoreDeps = Pick<RuntimeDeps, "store" | "now">;
59
+ export declare function answerRun(deps: StoreDeps, runId: string, answer: string): Promise<boolean>;
60
+ /** Stop a run from outside it: an expired question, a person pressing Stop. */
61
+ export declare function stopRun(deps: StoreDeps, runId: string, reason: string): Promise<void>;
62
+ export type DriveOptions = {
63
+ /** Who is driving, for the lease: a process id, a request id. */
64
+ owner: string;
65
+ /** Stop starting new steps after this long; a later slice continues. */
66
+ sliceMs?: number;
67
+ /** How long the lease holds without renewal. Renewed before every step. */
68
+ leaseMs?: number;
69
+ };
70
+ export type DriveResult = {
71
+ status: "busy";
72
+ } | {
73
+ status: "continue";
74
+ steps: number;
75
+ } | Exclude<StepOutcome, {
76
+ status: "continue";
77
+ }> & {
78
+ steps: number;
79
+ };
80
+ /**
81
+ * Advance a run for one time slice under its lease.
82
+ *
83
+ * A long-lived process calls this until the run settles; a serverless host
84
+ * calls it once per invocation (`waitUntil`, a tick) and the next call picks up
85
+ * from `run_steps`. The lease is what stops two processes, or a tick and a
86
+ * request, from stepping the same run at once.
87
+ */
88
+ export declare function driveRun(deps: RuntimeDeps, definition: AgentDefinition, runId: string, options: DriveOptions): Promise<DriveResult>;