@jskit-ai/agent-runtime 0.0.0-stage → 0.1.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/README.md CHANGED
@@ -1,3 +1,53 @@
1
- # Temporary Holding Version
1
+ # @jskit-ai/agent-runtime
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The opt-in parts of `@jskit-ai/agent`, for an app that adds them on purpose: the agent's own
4
+ PostgreSQL tables (saved runs, goals, background summaries, commands) and its coding tools (files,
5
+ commands in gVisor or a local process, a to-do list and notes, goals, asking the person). The loop
6
+ itself, and why it works as it does, is in `@jskit-ai/agent`'s README.
7
+
8
+ ## What it adds
9
+
10
+ | What | Export | Table |
11
+ |---|---|---|
12
+ | Each finished step saved while a reply runs; one a stopped server left is taken over by another | `createRuns`, `startResumer` | `agent_runs` |
13
+ | Goals: a long job carries on by itself in rounds until it's complete, blocked or at a limit | `createGoals`, `goalTools`, `roundPrompt` | `agent_goals` |
14
+ | Summaries between turns, in the background, claimed by one server | `createSummaries` | `agent_summaries`, `agent_summary_queue` |
15
+ | A to-do card in the chat and private job notes, both in the state note at every turn | `planningTools`, `planningNotes`, `currentTodos` | the app's messages and conversations (`PLANNING_TABLES`) |
16
+ | Commands in the workspace; one still running carries on in the background, its whole output a file | `createCommands`, `commandTools` | `agent_commands` |
17
+ | Where commands run: gVisor (`runsc`), or a local process for development | `gvisorExecutor`, `localExecutor` | — |
18
+ | Files: list, find, search, read in parts, write and edit, never outside the workspace | `fileTools`, `inWorkspace` | — |
19
+ | Asking the person: a card with answers to pick from, or in the reply where the chat shows no cards | `askTools`, `askInReplyTools` | — |
20
+ | All of these tools for one turn, each left out when what it needs isn't given | `defaultTools` | — |
21
+
22
+ `migrations/agent_initial.cjs` creates the tables. Each factory takes the names it uses, so an app
23
+ can rename them or add its own columns (`owner(row)` for goals, extra columns on a claimed run).
24
+
25
+ ## Wiring
26
+
27
+ The factories take their collaborators by name, once, at startup:
28
+
29
+ ```js
30
+ const runs = createRuns({ knex });
31
+ const goals = createGoals({ knex, runs, notices, find, respond, claim: (row) => runs.claim(row.id), record });
32
+ const summaries = createSummaries({ knex, model, app: "the app", history, notices });
33
+ const commands = createCommands({ knex, executor: gvisorExecutor() });
34
+ ```
35
+
36
+ `notices` is optional: `{ publish(channel, key, payload), subscribe(channel, key, listener) }`,
37
+ where `subscribe` returns a function that stops listening (PostgreSQL LISTEN/NOTIFY, for example).
38
+ With it, a new goal round or a queued summary wakes every server at once; without it, each server
39
+ finds due work on its own timer. `goals.start()` and `summaries.start()` run those loops.
40
+
41
+ For a turn, `defaultTools({ root, commands, knex, conversationId, post, goals:
42
+ goals.operationsFor(row) })` gives the agent its tools; `post(messages)` adds cards to the chat and
43
+ tells open pages.
44
+
45
+ Settings: `AGENT_JOB_COST_USD` (what one job may spend; 5 dollars by default, 0 for none),
46
+ `AGENT_RUNSC` (the gVisor binary) and `AGENT_RIPGREP` (ripgrep, with `grep` when it's missing).
47
+
48
+ ## Tests
49
+
50
+ `npm test` runs every test file. The `*.postgres.test.js` files need a PostgreSQL test database and
51
+ skip without one: set `DB_NAME` to the same value as `TEST_DB_NAME`, with the usual `DB_HOST`,
52
+ `DB_PORT`, `DB_USER` and `DB_PASSWORD`. Each test makes a schema of its own with only this
53
+ package's tables, and drops it afterwards. The gVisor command test also needs `runsc`.
@@ -0,0 +1,78 @@
1
+ // The agent's own tables. Each row belongs to a conversation by `conversation_id`, without a
2
+ // foreign key: the app says what a conversation is, adding the key (and any columns of its own)
3
+ // in its own migration.
4
+ exports.up = async function up(knex) {
5
+ // A reply in progress, one per conversation, with the steps it has taken so far (runs.js).
6
+ await knex.schema.createTable("agent_runs", (table) => {
7
+ table.bigInteger("conversation_id").primary();
8
+ table.timestamp("started_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
9
+ table.timestamp("heartbeat_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
10
+ table.jsonb("steps");
11
+ table.jsonb("input");
12
+ });
13
+
14
+ // Goals and their rounds (goals.js); one open goal per conversation.
15
+ await knex.schema.createTable("agent_goals", (table) => {
16
+ table.bigIncrements("id").primary();
17
+ table.bigInteger("conversation_id").notNullable();
18
+ table.text("objective").notNullable();
19
+ table.string("status", 12).notNullable().defaultTo("active");
20
+ table.integer("rounds").notNullable().defaultTo(0);
21
+ table.integer("max_rounds").notNullable().defaultTo(24);
22
+ table.text("reason");
23
+ table.string("time_zone", 64).notNullable().defaultTo("UTC");
24
+ table.string("language", 40).notNullable().defaultTo("");
25
+ // When the next round is due (null: none queued), and until when a server holds it.
26
+ table.timestamp("round_at", { useTz: true });
27
+ table.timestamp("lease_until", { useTz: true });
28
+ table.integer("attempts").notNullable().defaultTo(0);
29
+ table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
30
+ table.timestamp("updated_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
31
+ });
32
+ await knex.raw("alter table agent_goals add constraint agent_goals_status check (status in ('active', 'paused', 'complete', 'blocked'))");
33
+ await knex.raw("create unique index ux_agent_goals_open on agent_goals (conversation_id) where status in ('active', 'paused')");
34
+ await knex.raw("create index ix_agent_goals_due on agent_goals (round_at) where round_at is not null");
35
+
36
+ // Summaries between turns (summaryStore.js), and the conversations waiting for one.
37
+ await knex.schema.createTable("agent_summaries", (table) => {
38
+ table.bigIncrements("id").primary();
39
+ table.bigInteger("conversation_id").notNullable();
40
+ // The newest message the summary covers; later messages are read as they are.
41
+ table.bigInteger("covers_up_to").notNullable();
42
+ table.text("summary").notNullable();
43
+ table.integer("messages").notNullable();
44
+ table.timestamp("created_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
45
+ table.index(["conversation_id", "id"], "ix_agent_summaries_latest");
46
+ });
47
+ await knex.schema.createTable("agent_summary_queue", (table) => {
48
+ table.bigInteger("conversation_id").primary();
49
+ table.timestamp("requested_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
50
+ table.timestamp("run_after", { useTz: true }).notNullable().defaultTo(knex.fn.now());
51
+ table.timestamp("lease_until", { useTz: true });
52
+ table.integer("attempts").notNullable().defaultTo(0);
53
+ table.text("last_error");
54
+ table.index(["run_after"], "ix_agent_summary_queue_due");
55
+ });
56
+
57
+ // Commands run with the default tools (tools/commands.js).
58
+ await knex.schema.createTable("agent_commands", (table) => {
59
+ table.bigIncrements("id").primary();
60
+ table.bigInteger("conversation_id").notNullable();
61
+ table.text("why").notNullable().defaultTo("");
62
+ table.text("script").notNullable();
63
+ table.string("status", 12).notNullable().defaultTo("running");
64
+ table.integer("exit_code");
65
+ table.text("output").notNullable().defaultTo("");
66
+ table.boolean("truncated").notNullable().defaultTo(false);
67
+ table.string("server", 128).notNullable();
68
+ table.timestamp("heartbeat_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
69
+ table.timestamp("started_at", { useTz: true }).notNullable().defaultTo(knex.fn.now());
70
+ table.timestamp("ended_at", { useTz: true });
71
+ table.index(["conversation_id", "id"], "ix_agent_commands_conversation");
72
+ });
73
+ await knex.raw("alter table agent_commands add constraint agent_commands_status check (status in ('running', 'done', 'failed', 'timed_out', 'stopped'))");
74
+ };
75
+
76
+ exports.down = async function down(knex) {
77
+ for (const table of ["agent_commands", "agent_summary_queue", "agent_summaries", "agent_goals", "agent_runs"]) await knex.schema.dropTableIfExists(table);
78
+ };
package/package.json CHANGED
@@ -1,6 +1,73 @@
1
1
  {
2
2
  "name": "@jskit-ai/agent-runtime",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "test": "node --test --test-concurrency=1"
7
+ },
8
+ "exports": {
9
+ "./server": "./src/server/index.js",
10
+ "./server/types": "./src/server/types.js"
11
+ },
12
+ "dependencies": {
13
+ "@jskit-ai/agent": "0.1.0",
14
+ "zod": "^4.6.7"
15
+ },
16
+ "description": "Opt-in parts of the agent: its own tables (saved runs, goals, summaries, commands) and coding tools (files, commands in gVisor or a local process, to-do list, notes, goals, asking the person). PostgreSQL.",
17
+ "jskit": {
18
+ "kind": "runtime",
19
+ "capabilities": {
20
+ "provides": [],
21
+ "requires": []
22
+ },
23
+ "runtime": {
24
+ "server": {
25
+ "providers": []
26
+ },
27
+ "client": {
28
+ "providers": []
29
+ }
30
+ },
31
+ "metadata": {
32
+ "jskit": {
33
+ "tableOwnership": {
34
+ "tables": [
35
+ {
36
+ "tableName": "agent_runs"
37
+ },
38
+ {
39
+ "tableName": "agent_goals"
40
+ },
41
+ {
42
+ "tableName": "agent_summaries"
43
+ },
44
+ {
45
+ "tableName": "agent_summary_queue"
46
+ },
47
+ {
48
+ "tableName": "agent_commands"
49
+ }
50
+ ]
51
+ }
52
+ },
53
+ "apiSummary": {
54
+ "surfaces": [
55
+ {
56
+ "subpath": "./server",
57
+ "summary": "Saved runs and their resumer, goal rounds, background summaries, planning notes, the default coding tools and their executors."
58
+ },
59
+ {
60
+ "subpath": "./server/types",
61
+ "summary": "Types of the runs, goals, commands, notices and executors the server parts build or receive, for code that receives them."
62
+ }
63
+ ],
64
+ "capabilities": []
65
+ }
66
+ },
67
+ "migrations": {
68
+ "directories": [
69
+ "migrations"
70
+ ]
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,97 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync } from "node:fs";
4
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+
8
+ // Where commands run. An executor's `spawn({ script, cwd, mounts })` starts `bash -c script` in
9
+ // `cwd` and resolves to its child process (stdout, stderr, "close"; `kill()` ends all of it).
10
+ // `mounts` are other folders the command may read ({ source }). Neither executor passes this
11
+ // server's environment on, so no key or password reaches a command.
12
+ const ENV = { PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", HOME: "/tmp", TMPDIR: "/tmp", LANG: "C.UTF-8", LC_ALL: "C.UTF-8", TERM: "dumb" };
13
+ const onPath = (program) => (program.includes("/") ? existsSync(program) : ENV.PATH.split(":").some((dir) => existsSync(path.join(dir, program))));
14
+
15
+ // gVisor (the default): the command runs on gVisor's own kernel, rootless and with no network. It
16
+ // sees this machine's programs read-only, an empty /tmp, empty stand-ins for the folders that hold
17
+ // people's and services' own files (`hide`), and can change only the workspace, at the same path
18
+ // as outside. Needs gVisor's runsc (https://gvisor.dev): AGENT_RUNSC, or on the PATH.
19
+ export const GVISOR = {
20
+ runsc: process.env.AGENT_RUNSC || "runsc",
21
+ hide: ["/home", "/root", "/run", "/var", "/srv", "/mnt", "/media", "/etc/ssh"],
22
+ scratch: "1g",
23
+ processes: 256,
24
+ processMemory: 2 * 1024 ** 3
25
+ };
26
+
27
+ export function gvisorExecutor(settings = {}) {
28
+ const { runsc, hide, scratch, processes, processMemory } = { ...GVISOR, ...settings };
29
+ const limit = (type, value) => ({ type, hard: value, soft: value });
30
+ const bind = (source, readonly) => ({ destination: source, type: "bind", source, options: ["rbind", readonly ? "ro" : "rw"] });
31
+ const tmpfs = (destination, options = []) => ({ destination, type: "tmpfs", source: "tmpfs", options });
32
+
33
+ return {
34
+ kind: "gvisor",
35
+ available: () => onPath(runsc),
36
+ async spawn({ script, cwd, mounts = [] }) {
37
+ const id = `agent-${randomUUID()}`;
38
+ const dir = await mkdtemp(path.join(os.tmpdir(), "agent-run-"));
39
+ const state = ["--root", path.join(dir, "state")];
40
+ await writeFile(path.join(dir, "config.json"), JSON.stringify({
41
+ ociVersion: "1.0.0",
42
+ process: {
43
+ user: { uid: 0, gid: 0 },
44
+ args: ["/bin/bash", "-c", script],
45
+ env: Object.entries(ENV).map(([key, value]) => `${key}=${value}`),
46
+ cwd,
47
+ noNewPrivileges: true,
48
+ capabilities: { bounding: [], effective: [], inheritable: [], permitted: [], ambient: [] },
49
+ rlimits: [limit("RLIMIT_NPROC", processes), limit("RLIMIT_AS", processMemory), limit("RLIMIT_CORE", 0)]
50
+ },
51
+ root: { path: "/", readonly: true },
52
+ hostname: "agent",
53
+ mounts: [
54
+ { destination: "/proc", type: "proc", source: "proc" },
55
+ tmpfs("/dev"),
56
+ tmpfs("/tmp", [`size=${scratch}`, "mode=1777"]),
57
+ ...hide.filter((folder) => existsSync(folder)).map((folder) => tmpfs(folder, ["size=1m"])),
58
+ bind(cwd, false),
59
+ ...mounts.map((mount) => bind(mount.source, true))
60
+ ],
61
+ linux: { namespaces: [{ type: "pid" }, { type: "network" }, { type: "ipc" }, { type: "uts" }, { type: "mount" }] }
62
+ }));
63
+ const child = spawn(runsc, ["--rootless", "--network=none", "--ignore-cgroups", "--overlay2=root:memory", ...state, "run", "--bundle", dir, id], { env: ENV, stdio: ["ignore", "pipe", "pipe"] });
64
+ const kill = child.kill.bind(child);
65
+ child.kill = () => {
66
+ execFile(runsc, [...state, "kill", id, "KILL"], () => {});
67
+ return kill("SIGKILL");
68
+ };
69
+ child.once("close", () => execFile(runsc, [...state, "delete", "-force", id], () => rm(dir, { recursive: true, force: true }).catch(() => {})));
70
+ return child;
71
+ }
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The local process (opt in): bash in the workspace, with this machine's programs and no sandbox,
77
+ * for a trusted machine or a container of its own.
78
+ */
79
+ export function localExecutor() {
80
+ return {
81
+ kind: "local",
82
+ available: () => true,
83
+ async spawn({ script, cwd }) {
84
+ const child = spawn("/bin/bash", ["-c", script], { cwd, env: ENV, stdio: ["ignore", "pipe", "pipe"], detached: true });
85
+ // The command and everything it started.
86
+ child.kill = () => {
87
+ try {
88
+ process.kill(-child.pid, "SIGKILL");
89
+ } catch {
90
+ // Already ended.
91
+ }
92
+ return true;
93
+ };
94
+ return child;
95
+ }
96
+ };
97
+ }
@@ -0,0 +1,312 @@
1
+ import { z } from "zod";
2
+ import { assertCallbacks, createTool } from "@jskit-ai/agent/server";
3
+ import { NO_NOTICES } from "./notices.js";
4
+
5
+ // Goals and goal rounds, after DeepSeek Harness (MIT; P3–P5 in @jskit-ai/agent's
6
+ // docs/deepseek-harness.md). For a job of several steps the model opens a goal; whenever its turn
7
+ // ends while the goal is active, another round is queued telling it to check the real state and
8
+ // carry on, until it marks the goal complete with evidence, or blocked (not before round 3), or a
9
+ // limit is reached: rounds, or what the job may spend (AGENT_JOB_COST_USD, 5 dollars by default, 0
10
+ // for none). The person's own message always comes first. Rounds are claimed through the database
11
+ // with a lease, so any server carries them on.
12
+ //
13
+ // The table has: id, conversation_id, objective, status (active, paused, complete, blocked), rounds,
14
+ // max_rounds, reason, time_zone, language, round_at, lease_until, attempts, created_at, updated_at,
15
+ // plus whatever `owner(row)` columns the app adds.
16
+ const JOB_COST = process.env.AGENT_JOB_COST_USD;
17
+ export const GOAL_LIMITS = { maxRounds: 24, minRoundsBeforeBlocked: 3, leaseSeconds: 120, retryMs: 5000, costUsd: JOB_COST === undefined || JOB_COST === "" ? 5 : Number(JOB_COST) };
18
+
19
+ const OPEN = ["active", "paused"];
20
+
21
+ export function roundPrompt(goal) {
22
+ return [
23
+ "<goal_round>",
24
+ `Objective: ${JSON.stringify(goal.objective)}`,
25
+ `Round: ${goal.rounds}/${goal.max_rounds}`,
26
+ "",
27
+ "Continue working toward the objective in this same conversation. Treat the current files, tool results and saved state as authoritative; inspect them instead of assuming earlier narration is still current. Make concrete progress and verify the result. Before claiming completion, gather evidence that the whole objective is achieved, read the current goal with get_goal, and mark it complete with update_goal. If work remains, leave the goal active for the next round. Mark it blocked only for a real blocker that needs the person.",
28
+ "</goal_round>"
29
+ ].join("\n");
30
+ }
31
+
32
+ const COMPLETE_NOTE = "The goal is marked complete and this run is ending. Write the closing message to the person now: state the outcome, summarise what was done and how it was verified, and point to the concrete results (files, apps, records). Report only what this conversation's tool results actually establish; when a detail isn't established, say so instead of inventing it. Note anything they should review or do next. Don't call any more tools.";
33
+ const BLOCKED_NOTE = "The goal is marked blocked and this run is ending. Write the closing message to the person now: what was completed, the concrete blocking condition, what you tried, and exactly what you need from them. Don't call any more tools.";
34
+
35
+ /**
36
+ * The goal tools, for one turn: `operations` is what goals.operationsFor(row, …) returns.
37
+ * @param {import("./types.js").AgentGoalOperations} operations
38
+ */
39
+ export function goalTools(operations) {
40
+ return {
41
+ createGoal: createTool(Object.freeze({
42
+ id: "create_goal",
43
+ description: "Set up a goal that keeps you working on the person's request across automatic rounds until it's done. Use it whenever their request is a job with several steps (building or changing an app, working on files, a multi-step errand), even if they didn't say \"goal\"; not for a quick answer. Then start working on it straight away.",
44
+ inputSchema: z.object({ objective: z.string().min(5).max(2000).describe("The whole job, with what counts as done") }),
45
+ execute: ({ objective }) => operations.create(objective.trim())
46
+ })),
47
+ getGoal: createTool(Object.freeze({
48
+ id: "get_goal",
49
+ description: "Read the current goal: its objective, status and round.",
50
+ inputSchema: z.object({}),
51
+ execute: () => operations.get()
52
+ })),
53
+ updateGoal: createTool(Object.freeze({
54
+ id: "update_goal",
55
+ description: "Change the goal: complete (only with evidence that the whole objective is achieved), blocked (a real blocker only the person can remove; not before 3 rounds of trying), pause, or edit its objective.",
56
+ inputSchema: z.object({
57
+ action: z.enum(["complete", "blocked", "pause", "edit"]),
58
+ objective: z.string().max(2000).optional(),
59
+ reason: z.string().max(1000).optional().describe("For blocked: what's blocking and what you need")
60
+ }),
61
+ execute: (input) => operations.update(input)
62
+ }))
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Goals in `table`, and their rounds. Hooks: `find(goal)` the conversation a goal is in (null when
68
+ * it's gone); `respond(row, options)` runs one reply there; `claim(row)` takes the conversation's
69
+ * reply slot (runs.claim); `record(row, { text, detail })` shows an event line; `notify(row, event)`
70
+ * tells open pages; `owner(row)` extra columns for a new goal; `spent(goal)` what the job has cost
71
+ * so far; `track(goal, work)` meters it; `background(work)` keeps a round going after the turn
72
+ * that queued it. Each optional hook is a function, or left out for its named default. Without
73
+ * `notices`, each server finds due rounds on its own timer.
74
+ * @param {object} dependencies
75
+ * @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} dependencies.knex
76
+ * @param {import("./types.js").AgentRuns} dependencies.runs
77
+ * @param {string} [dependencies.table]
78
+ * @param {string} [dependencies.channel]
79
+ * @param {import("./types.js").AgentNotices | null} [dependencies.notices]
80
+ * @param {typeof GOAL_LIMITS} [dependencies.limits]
81
+ * @param {(goal: object) => Promise<object | null>} dependencies.find
82
+ * @param {(row: object, options: { timeZone: string, language: string, prompt: string, goalTurn: boolean }) => Promise<unknown>} dependencies.respond
83
+ * @param {(row: object) => Promise<boolean>} dependencies.claim
84
+ * @param {(row: object, event: { text: string, detail: string }) => Promise<unknown>} dependencies.record
85
+ * @param {(row: object, event: object) => Promise<unknown>} [dependencies.notify]
86
+ * @param {(row: object) => object} [dependencies.owner]
87
+ * @param {(goal: object) => Promise<number>} [dependencies.spent]
88
+ * @param {(goal: object, work: () => Promise<unknown>) => Promise<unknown>} [dependencies.track]
89
+ * @param {(work: Promise<unknown>) => unknown} [dependencies.background]
90
+ */
91
+ export function createGoals({
92
+ knex,
93
+ runs,
94
+ table = "agent_goals",
95
+ channel = "agent-goals",
96
+ notices,
97
+ limits = GOAL_LIMITS,
98
+ find,
99
+ respond,
100
+ claim,
101
+ record,
102
+ notify = notifyNoPages,
103
+ owner = noOwnerColumns,
104
+ spent = nothingSpent,
105
+ track = runUnmetered,
106
+ background = leaveRunning
107
+ }) {
108
+ assertCallbacks("createGoals", { notify, owner, spent, track, background });
109
+ const channelNotices = notices ?? NO_NOTICES;
110
+ const wake = () => channelNotices.publish(channel, "all", {}).catch(() => {});
111
+ const openGoal = (conversationId) => knex(table).where({ conversation_id: conversationId }).whereIn("status", OPEN).first();
112
+ const view = (goal) => goal && { objective: goal.objective, status: goal.status, rounds: goal.rounds, maxRounds: goal.max_rounds, ...(goal.reason ? { reason: goal.reason } : {}) };
113
+
114
+ /**
115
+ * What the goal tools do, for one turn in one conversation. Only a turn started by the person can
116
+ * open a goal; another bot's request or a routine can't (DSH P5).
117
+ */
118
+ function operationsFor(row, { human = false, timeZone = "UTC", language = "" } = {}) {
119
+ return {
120
+ async create(objective) {
121
+ if (!human) return { error: "A goal can only be set up in reply to the person's own message." };
122
+ const existing = await openGoal(row.conversation_id);
123
+ if (existing) return { error: "There's already a goal here; read it with get_goal, or update it.", goal: view(existing) };
124
+ const [goal] = await knex(table).insert({
125
+ ...owner(row), conversation_id: row.conversation_id,
126
+ objective: objective.slice(0, 2000), max_rounds: limits.maxRounds, time_zone: timeZone, language
127
+ }).returning("*");
128
+ await record(row, { text: "Working on a goal", detail: `Goal set: ${goal.objective}` });
129
+ return { status: "created", goal: view(goal), note: "Work on it now. If this turn ends before it's done, you'll get another round to carry on." };
130
+ },
131
+ async get() {
132
+ const goal = await openGoal(row.conversation_id);
133
+ return goal ? { goal: view(goal) } : { goal: null };
134
+ },
135
+ async update({ action, objective, reason }) {
136
+ const goal = await openGoal(row.conversation_id);
137
+ if (!goal) return { error: "There's no open goal here." };
138
+ if (action === "edit") {
139
+ if (!objective) return { error: "Give the new objective." };
140
+ await knex(table).where({ id: goal.id }).update({ objective: objective.slice(0, 2000), updated_at: knex.fn.now() });
141
+ return { status: "edited" };
142
+ }
143
+ if (action === "pause") {
144
+ await knex(table).where({ id: goal.id }).update({ status: "paused", round_at: null, updated_at: knex.fn.now() });
145
+ return { status: "paused", note: "Rounds stop until the person asks you to carry on." };
146
+ }
147
+ if (action === "blocked") {
148
+ if (goal.rounds < limits.minRoundsBeforeBlocked) {
149
+ return { error: `blocked needs at least ${limits.minRoundsBeforeBlocked} rounds of real attempts; this is round ${goal.rounds}. Keep working.` };
150
+ }
151
+ await knex(table).where({ id: goal.id }).update({ status: "blocked", reason: String(reason || "").slice(0, 1000), round_at: null, updated_at: knex.fn.now() });
152
+ await record(row, { text: "Goal blocked", detail: `Blocked: ${goal.objective}. ${reason || ""}` });
153
+ return { status: "blocked", note: BLOCKED_NOTE };
154
+ }
155
+ if (action === "complete") {
156
+ await knex(table).where({ id: goal.id }).update({ status: "complete", round_at: null, updated_at: knex.fn.now() });
157
+ await record(row, { text: "Goal complete", detail: `Complete: ${goal.objective}` });
158
+ return { status: "complete", note: COMPLETE_NOTE };
159
+ }
160
+ return { error: "Use edit, pause, complete or blocked." };
161
+ }
162
+ };
163
+ }
164
+
165
+ /** Opens a goal for a request whose reply ran out of steps or time, so it carries on by itself. */
166
+ function openFor(row, request, options) {
167
+ return operationsFor(row, { ...options, human: true }).create(`Finish what the person asked: ${String(request).slice(0, 1500)}`);
168
+ }
169
+
170
+ /** A goal that stops at a limit says so plainly, so the person can ask for more. */
171
+ async function stopAt(goal, reason, detail) {
172
+ await knex(table).where({ id: goal.id }).update({ status: "blocked", reason, round_at: null, updated_at: knex.fn.now() });
173
+ const row = await find(goal);
174
+ if (row) {
175
+ await record(row, { text: "Stopped working on the goal", detail: `${detail}: ${goal.objective}. Ask to carry on to continue.` });
176
+ await notify(row, { type: "messages" });
177
+ }
178
+ }
179
+
180
+ /** After any turn in a conversation: an active goal gets its next round, unless a limit is reached. */
181
+ async function afterTurn(conversationId) {
182
+ const goal = await knex(table).where({ conversation_id: conversationId, status: "active" }).first();
183
+ if (!goal) return;
184
+ if (goal.rounds >= goal.max_rounds) {
185
+ await stopAt(goal, `Goal reached its limit of ${goal.max_rounds} rounds.`, `Stopped after ${goal.max_rounds} rounds without finishing`);
186
+ return;
187
+ }
188
+ if (limits.costUsd > 0 && await spent(goal) >= limits.costUsd) {
189
+ await stopAt(goal, `Goal reached its cost limit of $${limits.costUsd}.`, `Stopped at the job's cost limit ($${limits.costUsd}) without finishing`);
190
+ return;
191
+ }
192
+ await knex(table).where({ id: goal.id }).whereNull("round_at").update({ round_at: knex.fn.now(), updated_at: knex.fn.now() });
193
+ wake();
194
+ background(runDue());
195
+ }
196
+
197
+ /** The person carrying on with a paused goal makes it active again. */
198
+ async function resume(conversationId) {
199
+ await knex(table).where({ conversation_id: conversationId, status: "paused" }).update({ status: "active", updated_at: knex.fn.now() });
200
+ }
201
+
202
+ async function claimDue() {
203
+ return knex.transaction(async (trx) => {
204
+ const due = await trx(table).where({ status: "active" }).whereNotNull("round_at").where("round_at", "<=", trx.fn.now())
205
+ .where((query) => query.whereNull("lease_until").orWhere("lease_until", "<", trx.fn.now()))
206
+ .orderBy("round_at").forUpdate().skipLocked().first();
207
+ if (!due) return null;
208
+ const [leased] = await trx(table).where({ id: due.id }).update({ lease_until: trx.raw(`now() + interval '${limits.leaseSeconds} seconds'`) }).returning("*");
209
+ return leased;
210
+ });
211
+ }
212
+
213
+ async function round(goal) {
214
+ const row = await find(goal);
215
+ if (!row) {
216
+ await knex(table).where({ id: goal.id }).update({ status: "paused", round_at: null, lease_until: null });
217
+ return;
218
+ }
219
+ // A person's reply (or anything else) running here goes first; the round waits.
220
+ if (!await claim(row)) {
221
+ await knex(table).where({ id: goal.id }).update({ lease_until: null, round_at: knex.raw(`now() + interval '${limits.retryMs / 1000} seconds'`) });
222
+ return;
223
+ }
224
+ const keep = setInterval(() => {
225
+ knex(table).where({ id: goal.id }).update({ lease_until: knex.raw(`now() + interval '${limits.leaseSeconds} seconds'`) }).catch(() => {});
226
+ runs.heartbeat(row.conversation_id).catch(() => {});
227
+ }, runs.limits.heartbeatMs);
228
+ const [current] = await knex(table).where({ id: goal.id }).update({ rounds: goal.rounds + 1, round_at: null, updated_at: knex.fn.now() }).returning("*");
229
+ try {
230
+ await notify(row, { type: "working", working: true, status: "Carrying on with the goal" });
231
+ await track(goal, () => respond(row, { timeZone: goal.time_zone, language: goal.language, prompt: roundPrompt(current), goalTurn: true }));
232
+ await knex(table).where({ id: goal.id }).update({ lease_until: null, attempts: 0 });
233
+ } catch (error) {
234
+ // An error stops the rounds (DSH: disarm); the person can ask to carry on.
235
+ console.error(`Goal ${goal.id} round failed: ${error?.message || error}`);
236
+ await knex(table).where({ id: goal.id }).update({ status: "paused", reason: String(error?.message || error).slice(0, 500), lease_until: null, round_at: null });
237
+ } finally {
238
+ clearInterval(keep);
239
+ await runs.release(row.conversation_id).catch(() => {});
240
+ await notify(row, { type: "messages" });
241
+ await notify(row, { type: "working", working: false });
242
+ }
243
+ await afterTurn(goal.conversation_id);
244
+ }
245
+
246
+ let running = null;
247
+ function runDue() {
248
+ if (running) return running;
249
+ running = (async () => {
250
+ try {
251
+ for (let count = 0; count < 100; count += 1) {
252
+ const goal = await claimDue();
253
+ if (!goal) break;
254
+ await round(goal);
255
+ }
256
+ } catch (error) {
257
+ console.error(`Goals: ${error?.message || error}`);
258
+ } finally {
259
+ running = null;
260
+ }
261
+ })();
262
+ return running;
263
+ }
264
+
265
+ function start({ everyMs = 5000 } = {}) {
266
+ let stopped = false;
267
+ const tick = () => { if (!stopped) background(runDue()); };
268
+ const timer = setInterval(tick, everyMs);
269
+ timer.unref();
270
+ const unsubscribe = channelNotices.subscribe(channel, "all", () => tick());
271
+ tick();
272
+ return {
273
+ async drain(ms) {
274
+ stopped = true;
275
+ clearInterval(timer);
276
+ unsubscribe();
277
+ await Promise.race([running || Promise.resolve(), new Promise((resolve) => setTimeout(resolve, ms))]);
278
+ }
279
+ };
280
+ }
281
+
282
+ return Object.freeze({
283
+ operationsFor,
284
+ openFor,
285
+ afterTurn,
286
+ resume,
287
+ runDue,
288
+ start,
289
+ openGoal
290
+ });
291
+ }
292
+
293
+ async function notifyNoPages() {}
294
+
295
+ /** A job whose cost nobody meters has spent nothing. */
296
+ async function nothingSpent() {
297
+ return 0;
298
+ }
299
+
300
+ /** Runs a goal round's work without metering it. */
301
+ function runUnmetered(_goal, work) {
302
+ return work();
303
+ }
304
+
305
+ /** Leaves a round's work running in this process after the turn that queued it. */
306
+ function leaveRunning(work) {
307
+ return work;
308
+ }
309
+
310
+ function noOwnerColumns() {
311
+ return {};
312
+ }
@@ -0,0 +1,11 @@
1
+ // @jskit-ai/agent-runtime: the agent's own tables and its coding extras, for an app that adds them
2
+ // on purpose. See README.md.
3
+ export { createRuns, startResumer, RUN_LIMITS } from "./runs.js";
4
+ export { createGoals, goalTools, roundPrompt, GOAL_LIMITS } from "./goals.js";
5
+ export { createSummaries } from "./summaryStore.js";
6
+ export { planningTools, planningNotes, currentTodos, PLANNING_TABLES } from "./planning.js";
7
+ export { defaultTools } from "./tools/defaults.js";
8
+ export { fileTools, inWorkspace, FILE_LIMITS } from "./tools/files.js";
9
+ export { createCommands, commandTools, COMMAND_LIMITS } from "./tools/commands.js";
10
+ export { askInReplyTools, askTools } from "./tools/ask.js";
11
+ export { gvisorExecutor, localExecutor, GVISOR } from "./executors.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Notices for an app that has none: what is published reaches no one, and no one is ever told
3
+ * anything, so each loop wakes only on its own timer.
4
+ * @type {import("./types.js").AgentNotices}
5
+ */
6
+ export const NO_NOTICES = Object.freeze({
7
+ publish: publishToNoOne,
8
+ subscribe: listenToNoOne
9
+ });
10
+
11
+ async function publishToNoOne() {}
12
+
13
+ function listenToNoOne() {
14
+ return stopListening;
15
+ }
16
+
17
+ function stopListening() {}