@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.
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { PLANNING_TABLES, currentTodos, planningNotes, planningTools } from "../src/server/index.js";
4
+ import { DATABASE, testDatabase } from "./database.js";
5
+
6
+ // A to-do list the person sees, as one card updated in place, and notes only the model sees; the
7
+ // open list and the notes are in the state note at every turn.
8
+ async function chat(context) {
9
+ const knex = await testDatabase(context);
10
+ await knex.schema.createTable(PLANNING_TABLES.conversations, (table) => {
11
+ table.bigIncrements("id").primary();
12
+ table.jsonb("job_notes");
13
+ });
14
+ await knex.schema.createTable(PLANNING_TABLES.messages, (table) => {
15
+ table.bigIncrements("id").primary();
16
+ table.string("kind", 20).notNullable();
17
+ table.string("role", 20).notNullable();
18
+ table.jsonb("body").notNullable();
19
+ });
20
+ const [{ id }] = await knex(PLANNING_TABLES.conversations).insert({}).returning("id");
21
+ const told = [];
22
+ const post = async (messages) => {
23
+ told.push(messages.length);
24
+ for (const message of messages) await knex(PLANNING_TABLES.messages).insert(message);
25
+ };
26
+ return { knex, conversationId: id, post, told };
27
+ }
28
+
29
+ test("the to-do card is made, ticked off in place, remembered, and replaced for the next job", { skip: DATABASE }, async (context) => {
30
+ const { knex, conversationId, post, told } = await chat(context);
31
+ const tools = planningTools({ knex, conversationId, post });
32
+ const cards = () => knex(PLANNING_TABLES.messages).where({ kind: "todos" }).orderBy("id");
33
+ const made = await tools.updateTodos.execute({ items: [{ text: "Read the CRM", status: "doing" }, { text: "Add the field", status: "todo" }, { text: "Test it", status: "todo" }] });
34
+ assert.deepEqual(made, { status: "saved" });
35
+ assert.match((await tools.updateTodos.execute({ items: [{ text: "A", status: "doing" }, { text: "B", status: "doing" }] })).error, /Only one item can be doing at a time; these are all doing: A; B\./u);
36
+ await tools.updateTodos.execute({ items: [{ text: "Read the CRM", status: "done" }, { text: "Add the field", status: "doing" }, { text: "Test it", status: "todo" }] });
37
+ let list = await cards();
38
+ assert.equal(list.length, 1, "one card, updated in place");
39
+ assert.deepEqual(list[0].body.items.map((item) => item.status), ["done", "doing", "todo"]);
40
+ assert.deepEqual(told, [1, 0], "open pages are told each time");
41
+ const [state] = await planningNotes(knex, conversationId);
42
+ assert.equal(state, "Your to-do list for this job (keep it up to date with update_todos):\n[x] Read the CRM\n[doing] Add the field\n[ ] Test it");
43
+
44
+ const last = await tools.updateTodos.execute({ items: [{ text: "Read the CRM", status: "done" }, { text: "Add the field", status: "done" }, { text: "Test it", status: "done" }] });
45
+ assert.match(last.note, /Everything on the list is done/u);
46
+ assert.deepEqual(await planningNotes(knex, conversationId), [], "a finished list isn't carried");
47
+ await tools.updateTodos.execute({ items: [{ text: "Plan the trip", status: "doing" }] });
48
+ list = await cards();
49
+ assert.equal(list.length, 2, "the next job gets its own card");
50
+ assert.equal((await currentTodos(knex, conversationId)).body.items[0].text, "Plan the trip");
51
+ assert.equal(planningTools({ knex, conversationId }).updateTodos, undefined, "no card without a chat to show it");
52
+ });
53
+
54
+ test("job notes are kept for the model, shown at the next turn, and cleared", { skip: DATABASE }, async (context) => {
55
+ const { knex, conversationId } = await chat(context);
56
+ const tools = planningTools({ knex, conversationId });
57
+ assert.deepEqual(await planningNotes(knex, conversationId), []);
58
+ assert.equal((await tools.keepNotes.execute({ notes: ["The CRM table is contacts", "Due date goes after Stage"] })).status, "saved");
59
+ assert.deepEqual(await planningNotes(knex, conversationId), ["Your notes for the job in hand (keep them up to date with keep_notes):\n- The CRM table is contacts\n- Due date goes after Stage"]);
60
+ assert.equal((await tools.keepNotes.execute({ notes: [] })).status, "cleared");
61
+ assert.deepEqual(await planningNotes(knex, conversationId), [], "and gone once cleared");
62
+ assert.equal(await knex(PLANNING_TABLES.messages).count("* as n").first().then((row) => Number(row.n)), 0, "nothing shows in the chat");
63
+ });
@@ -0,0 +1,59 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createRuns, startResumer } from "../src/server/index.js";
4
+ import { DATABASE, testDatabase } from "./database.js";
5
+
6
+ // A reply saves its steps as each one finishes; one a stopped server left part-way is taken over
7
+ // by another server, once, and carried on from them.
8
+ const call = { type: "tool-call", toolCallId: "c1", toolName: "list_files", input: {} };
9
+ const result = { type: "tool-result", toolCallId: "c1", toolName: "list_files", output: { type: "json", value: { entries: ["notes.txt"] } } };
10
+ const steps = [{ role: "assistant", content: [call] }, { role: "tool", content: [result] }];
11
+ const quiet = (knex) => knex.raw("now() - interval '5 minutes'");
12
+
13
+ test("a reply claims its conversation, saves its steps while it runs, and is gone once it ends", { skip: DATABASE }, async (context) => {
14
+ const knex = await testDatabase(context);
15
+ await knex.schema.alterTable("agent_runs", (table) => table.bigInteger("bot_id"));
16
+ const runs = createRuns({ knex });
17
+ assert.equal(await runs.claim(1, { bot_id: 9 }), true);
18
+ assert.equal(await runs.claim(1), false, "one reply per conversation at a time");
19
+ assert.equal((await knex("agent_runs").where({ conversation_id: 1 }).first()).bot_id, "9", "with the app's own columns");
20
+ await runs.save(1, steps, { human: true, answers: 5 });
21
+ const saved = await runs.saved(1);
22
+ assert.deepEqual(saved.steps.map((message) => message.role), ["assistant", "tool"]);
23
+ assert.deepEqual(saved.input, { human: true, answers: 5 });
24
+ await knex("agent_runs").where({ conversation_id: 1 }).update({ heartbeat_at: quiet(knex) });
25
+ await runs.heartbeat(1);
26
+ assert.equal(await runs.claim(1), false, "a beating run stays its server's");
27
+ await runs.release(1);
28
+ assert.equal(await runs.saved(1), undefined);
29
+ assert.equal(await runs.claim(1), true, "free again");
30
+ });
31
+
32
+ test("a run whose server stopped beating is taken over once, with its steps", { skip: DATABASE }, async (context) => {
33
+ const knex = await testDatabase(context);
34
+ const runs = createRuns({ knex });
35
+ await knex("agent_runs").insert([
36
+ { conversation_id: 2, heartbeat_at: quiet(knex), steps: JSON.stringify(steps), input: JSON.stringify({ human: true }) },
37
+ { conversation_id: 3, heartbeat_at: quiet(knex), steps: JSON.stringify(steps), input: JSON.stringify({ human: false }) },
38
+ { conversation_id: 4, heartbeat_at: quiet(knex) },
39
+ { conversation_id: 5, steps: JSON.stringify(steps), input: JSON.stringify({ human: true }) }
40
+ ]);
41
+ const human = (query) => query.whereRaw("input->>'human' = 'true'");
42
+ const taken = await runs.takeOverStalled({ resumable: human });
43
+ assert.deepEqual(taken.map((run) => Number(run.conversation_id)), [2], "only stale runs with steps, of the kinds that count");
44
+ assert.deepEqual(taken[0].steps.map((message) => message.role), ["assistant", "tool"]);
45
+ assert.deepEqual(await runs.takeOverStalled({ resumable: human }), [], "taken over once");
46
+ assert.equal(await runs.claim(4), true, "a stale run without steps can be claimed afresh");
47
+ assert.equal(await runs.claim(5), false);
48
+ });
49
+
50
+ test("the resumer carries on what stopped servers left, then drains", { skip: DATABASE }, async (context) => {
51
+ const knex = await testDatabase(context);
52
+ const runs = createRuns({ knex });
53
+ await knex("agent_runs").insert({ conversation_id: 6, heartbeat_at: quiet(knex), steps: JSON.stringify(steps), input: JSON.stringify({ human: true }) });
54
+ const resumed = [];
55
+ const resumer = startResumer({ runs, resume: async (run) => resumed.push(Number(run.conversation_id)), everyMs: 50 });
56
+ for (let tries = 0; tries < 40 && !resumed.length; tries += 1) await new Promise((resolve) => setTimeout(resolve, 50));
57
+ await resumer.drain(1000);
58
+ assert.deepEqual(resumed, [6]);
59
+ });
@@ -0,0 +1,188 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { SUMMARY_LIMITS } from "@jskit-ai/agent/server";
4
+ import { createSummaries } from "../src/server/index.js";
5
+ import { DATABASE, testDatabase } from "./database.js";
6
+
7
+ // Summarising conversations in the background: past a size, a conversation is queued; one server
8
+ // summarises all but its latest turns, folding in the previous summary; replies read the summary
9
+ // plus every message after it word for word; messages that arrive while it runs stay as they are;
10
+ // failures wait and try again; a first summary takes in what the conversation inherited.
11
+ const limits = { ...SUMMARY_LIMITS, compactAt: 500, keepTurns: 2, keepTokens: 400, minTokens: 50 };
12
+
13
+ async function conversations(context) {
14
+ const knex = await testDatabase(context);
15
+ await knex.schema.createTable("chat_messages", (table) => {
16
+ table.bigIncrements("id").primary();
17
+ table.bigInteger("conversation_id").notNullable();
18
+ table.string("kind", 20).notNullable().defaultTo("text");
19
+ table.string("role", 20).notNullable();
20
+ table.jsonb("body").notNullable();
21
+ });
22
+ const inherited = new Map();
23
+ const say = (conversationId, role, text) => knex("chat_messages").insert({ conversation_id: conversationId, role, body: { text } });
24
+ // The app's side: its stored messages after a summary, what a conversation inherits, and how it
25
+ // turns messages into model messages.
26
+ const history = async (conversationId, { after }) => ({
27
+ messages: await knex("chat_messages").where({ conversation_id: conversationId }).where("id", ">", after).orderBy("id"),
28
+ inherited: inherited.get(conversationId) || [],
29
+ toModel: async (messages) => messages.map((message) => ({ role: message.role === "user" ? "user" : "assistant", content: message.body.text })),
30
+ named: false
31
+ });
32
+ return { knex, say, history, inherited };
33
+ }
34
+
35
+ test("long conversations are summarised in the background, once, and replies read the summary", { skip: DATABASE }, async (context) => {
36
+ const { knex, say, history } = await conversations(context);
37
+ const summaries = [];
38
+ let summarizer = async ({ previous, text }) => {
39
+ summaries.push({ previous, text });
40
+ return `SUMMARY ${summaries.length}`;
41
+ };
42
+ const tracked = [];
43
+ const store = createSummaries({ knex, history, limits, summarize: (input) => summarizer(input), track: (conversationId, work) => (tracked.push(conversationId), work()) });
44
+ const queued = () => knex("agent_summary_queue").where({ conversation_id: 1 }).first();
45
+ const exchange = async (n, size) => {
46
+ await say(1, "user", `${n === 1 ? "My name is Ada and my budget is 4,200 euros. " : `Message ${n}. `}${"More detail. ".repeat(size)}`);
47
+ await say(1, "bot", `Answer ${n}`);
48
+ };
49
+
50
+ await exchange(1, 20);
51
+ assert.equal(await store.consider(1), false);
52
+ assert.equal(await queued(), undefined, "below the size nothing is queued");
53
+ for (let n = 2; n <= 6; n += 1) await exchange(n, 30);
54
+ assert.equal(await store.consider(1), true);
55
+ assert.ok(await queued(), "queued once the conversation grew past the limit");
56
+ assert.deepEqual(await store.current(1), { summary: "", after: 0 }, "no summary yet: a reply reads everything");
57
+
58
+ // Two servers try at once: one summarises.
59
+ let release;
60
+ const gate = new Promise((resolve) => (release = resolve));
61
+ summarizer = async ({ previous, text }) => {
62
+ await gate;
63
+ summaries.push({ previous, text });
64
+ return `SUMMARY ${summaries.length}`;
65
+ };
66
+ const first = store.runOne();
67
+ await new Promise((resolve) => setTimeout(resolve, 100));
68
+ assert.equal(await store.runOne(), false, "the other server finds nothing to claim");
69
+ // A message arriving while the summary is written stays word for word after it.
70
+ await say(1, "user", "Arrived during the summary.");
71
+ release();
72
+ assert.equal(await first, true);
73
+ const [summary] = await knex("agent_summaries").where({ conversation_id: 1 });
74
+ assert.equal(summaries.length, 1);
75
+ assert.match(summaries[0].text, /^Person: My name is Ada and my budget is 4,200 euros\./mu);
76
+ assert.match(summaries[0].text, /^Bot: Answer 1$/mu);
77
+ assert.ok(!summaries[0].text.includes("Arrived during the summary."));
78
+ assert.deepEqual(tracked.map(Number), [1], "metered to the conversation");
79
+ assert.equal(await queued(), undefined, "done, and nothing more queued while the rest is small");
80
+
81
+ // A reply reads the summary and only the messages after it.
82
+ const { summary: text, after } = await store.current(1);
83
+ assert.equal(text, "SUMMARY 1");
84
+ assert.equal(after, Number(summary.covers_up_to));
85
+ const { messages } = await history(1, { after });
86
+ assert.equal(messages[0].role, "user", "it starts at a turn");
87
+ assert.ok(messages.some((message) => message.body.text === "Arrived during the summary."));
88
+ assert.ok(!messages.some((message) => /My name is Ada/u.test(message.body.text)), "summarised messages aren't read again");
89
+
90
+ // A failure waits and tries again later.
91
+ for (let n = 7; n <= 12; n += 1) await exchange(n, 30);
92
+ assert.equal(await store.consider(1), true);
93
+ summarizer = async () => {
94
+ throw new Error("model unavailable");
95
+ };
96
+ assert.equal(await store.runOne(), true);
97
+ const failed = await queued();
98
+ assert.equal(failed.attempts, 1);
99
+ assert.match(failed.last_error, /model unavailable/u);
100
+ assert.ok(new Date(failed.run_after) > new Date(), "tried again later");
101
+ assert.equal(await store.runOne(), false, "not before its time");
102
+
103
+ // The next summary folds in the previous one.
104
+ await knex("agent_summary_queue").where({ conversation_id: 1 }).update({ run_after: knex.fn.now() });
105
+ summarizer = async ({ previous, text: folded }) => {
106
+ summaries.push({ previous, text: folded });
107
+ return `SUMMARY ${summaries.length}`;
108
+ };
109
+ assert.equal(await store.runOne(), true);
110
+ assert.equal(summaries.at(-1).previous, "SUMMARY 1");
111
+ assert.ok(!summaries.at(-1).text.includes("My name is Ada"), "only what came after the previous summary");
112
+ assert.equal((await store.current(1)).summary, "SUMMARY 2");
113
+ assert.equal(await knex("chat_messages").where({ conversation_id: 1 }).count("* as n").first().then((row) => Number(row.n)), 25, "no message is deleted");
114
+ });
115
+
116
+ test("a first summary takes in what the conversation inherited", { skip: DATABASE }, async (context) => {
117
+ const { knex, say, history, inherited } = await conversations(context);
118
+ const captured = [];
119
+ const store = createSummaries({ knex, history, limits: { ...limits, minTokens: 1 }, summarize: async ({ text }) => (captured.push(text), "ROOM SUMMARY") });
120
+ inherited.set(2, [{ id: "0", role: "user", kind: "text", body: { text: "Inherited: the launch is on 3 March." } }]);
121
+ for (let n = 0; n < 4; n += 1) {
122
+ await say(2, "user", `Room message ${n}`);
123
+ await say(2, "bot", `Room answer ${n}`);
124
+ }
125
+ const done = await store.compact(2);
126
+ assert.equal(done.messages, 4, "all but the latest two turns");
127
+ assert.match(captured[0], /^Person: Inherited: the launch is on 3 March\.$/mu);
128
+ assert.match(captured[0], /^Person: Room message 0$/mu);
129
+ assert.match(captured[0], /^Bot: Room answer 0$/mu);
130
+ assert.equal((await knex("agent_summaries").where({ conversation_id: 2 }).first()).summary, "ROOM SUMMARY");
131
+ assert.deepEqual(await createSummaries({ knex, history, limits }).compact(3), { skipped: "nothing to summarise yet" });
132
+ await say(4, "user", "a");
133
+ await say(4, "bot", "b");
134
+ await say(4, "user", "c");
135
+ assert.deepEqual(await createSummaries({ knex, history, limits: { ...limits, keepTurns: 1 } }).compact(4), { skipped: "too little to summarise" });
136
+ });
137
+
138
+ test("a reply summarises first, rather than dropping messages unseen, when summarising fell behind", { skip: DATABASE }, async (context) => {
139
+ const { knex, say, history } = await conversations(context);
140
+ const filler = "We talked about the garden, the weather and the week ahead. ".repeat(14);
141
+ for (let n = 0; n < 12; n += 1) {
142
+ await say(5, "user", `${n === 0 ? "My sister Lucia's birthday is 14 March. " : ""}${filler}`);
143
+ await say(5, "bot", `Noted. ${filler}`);
144
+ }
145
+ const summaries = [];
146
+ const store = createSummaries({
147
+ knex,
148
+ history,
149
+ limits: { ...SUMMARY_LIMITS, compactAt: 1000000, summariseNowAt: 1500, keepTurns: 2, keepTokens: 400, minTokens: 50 },
150
+ summarize: async ({ text }) => {
151
+ summaries.push(text);
152
+ return `SUMMARY: ${/Lucia's birthday is 14 March/u.test(text) ? "Lucia's birthday is 14 March." : "nothing"}`;
153
+ }
154
+ });
155
+ await store.ensureWithin(5);
156
+ assert.ok(summaries.length >= 1, "it summarised before the reply");
157
+ const { summary, after } = await store.current(5);
158
+ assert.match(summary, /Lucia's birthday is 14 March/u, "so the oldest fact isn't lost");
159
+ assert.ok((await history(5, { after })).messages.length < 24, "while fewer messages are read word for word");
160
+ });
161
+
162
+ test("a notice wakes every server's loop, which summarises what's queued", { skip: DATABASE }, async (context) => {
163
+ const { knex, say, history } = await conversations(context);
164
+ const listeners = [];
165
+ const published = [];
166
+ const notices = {
167
+ publish: async (channel, key) => {
168
+ published.push(`${channel}:${key}`);
169
+ for (const listener of listeners) listener();
170
+ },
171
+ subscribe: (_channel, _key, listener) => {
172
+ listeners.push(listener);
173
+ return () => listeners.splice(listeners.indexOf(listener), 1);
174
+ }
175
+ };
176
+ const store = createSummaries({ knex, history, notices, limits, summarize: async () => "WOKEN SUMMARY" });
177
+ const loop = store.start({ everyMs: 60000 });
178
+ for (let n = 0; n < 6; n += 1) {
179
+ await say(6, "user", `Message ${n}. ${"Detail. ".repeat(60)}`);
180
+ await say(6, "bot", `Answer ${n}`);
181
+ }
182
+ assert.equal(await store.consider(6), true);
183
+ assert.deepEqual(published, ["agent-summaries:all"]);
184
+ for (let tries = 0; tries < 40 && !(await knex("agent_summaries").where({ conversation_id: 6 }).first()); tries += 1) await new Promise((resolve) => setTimeout(resolve, 50));
185
+ await loop.drain(1000);
186
+ assert.equal((await store.current(6)).summary, "WOKEN SUMMARY");
187
+ assert.equal(listeners.length, 0, "drained, it stops listening");
188
+ });