@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 +52 -2
- package/migrations/agent_initial.cjs +78 -0
- package/package.json +71 -4
- package/src/server/executors.js +97 -0
- package/src/server/goals.js +312 -0
- package/src/server/index.js +11 -0
- package/src/server/notices.js +17 -0
- package/src/server/planning.js +82 -0
- package/src/server/runs.js +105 -0
- package/src/server/summaryStore.js +195 -0
- package/src/server/tools/ask.js +49 -0
- package/src/server/tools/commands.js +175 -0
- package/src/server/tools/defaults.js +30 -0
- package/src/server/tools/files.js +203 -0
- package/src/server/types.js +24 -0
- package/test/ask.test.js +13 -0
- package/test/callbackOptions.test.js +28 -0
- package/test/commands.postgres.test.js +67 -0
- package/test/database.js +23 -0
- package/test/fileTools.test.js +58 -0
- package/test/goals.postgres.test.js +166 -0
- package/test/planning.postgres.test.js +63 -0
- package/test/runs.postgres.test.js +59 -0
- package/test/summaryStore.postgres.test.js +188 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createTool } from "@jskit-ai/agent/server";
|
|
3
|
+
|
|
4
|
+
// Keeping a long job in mind: a to-do list the person sees, as one card in the chat updated in
|
|
5
|
+
// place, and private notes. Both are shown to the model at every turn (planningNotes), so they
|
|
6
|
+
// outlast summaries, goal rounds and restarts.
|
|
7
|
+
//
|
|
8
|
+
// The list is a message of kind "todos" ({ list: conversation id, items: [{ text, status }],
|
|
9
|
+
// updatedAt }) in the messages table; the notes are the conversation's `job_notes` (jsonb array).
|
|
10
|
+
// `post(messages)` adds messages to the chat and tells open pages; `post([])` only tells them.
|
|
11
|
+
export const PLANNING_TABLES = { messages: "agent_messages", conversations: "agent_conversations" };
|
|
12
|
+
|
|
13
|
+
/** The conversation's newest to-do card, if any. */
|
|
14
|
+
export function currentTodos(knex, conversationId, tables = PLANNING_TABLES) {
|
|
15
|
+
return knex(tables.messages).where({ kind: "todos" }).whereRaw("body->>'list' = ?", [String(conversationId)]).orderBy("id", "desc").first();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The to-do and notes tools, for one turn in one conversation; the to-do list only with `post`.
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} options.knex
|
|
22
|
+
* @param {number | string} options.conversationId
|
|
23
|
+
* @param {((messages: object[]) => Promise<unknown>) | null} [options.post]
|
|
24
|
+
* @param {typeof PLANNING_TABLES} [options.tables]
|
|
25
|
+
*/
|
|
26
|
+
export function planningTools({ knex, conversationId, post, tables = PLANNING_TABLES }) {
|
|
27
|
+
async function keepNotes({ notes }) {
|
|
28
|
+
await knex(tables.conversations).where({ id: conversationId }).update({ job_notes: JSON.stringify(notes) });
|
|
29
|
+
return { status: notes.length ? "saved" : "cleared" };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function updateTodos({ items }) {
|
|
33
|
+
const doing = items.filter((item) => item.status === "doing");
|
|
34
|
+
if (doing.length > 1) return { error: `Only one item can be doing at a time; these are all doing: ${doing.map((item) => item.text).join("; ")}.` };
|
|
35
|
+
const card = await currentTodos(knex, conversationId, tables);
|
|
36
|
+
const body = { list: String(conversationId), items, updatedAt: new Date().toISOString() };
|
|
37
|
+
// A finished list stays as it was; the next job's list is a new card.
|
|
38
|
+
if (card && card.body.items.some((item) => item.status !== "done")) {
|
|
39
|
+
await knex(tables.messages).where({ id: card.id }).update({ body: knex.raw("body || ?::jsonb", [JSON.stringify(body)]) });
|
|
40
|
+
await post([]);
|
|
41
|
+
} else {
|
|
42
|
+
await post([{ kind: "todos", role: "bot", body }]);
|
|
43
|
+
}
|
|
44
|
+
const finished = items.every((item) => item.status === "done");
|
|
45
|
+
return { status: "saved", ...(finished ? { note: "Everything on the list is done: check the result, then reply with it." } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
keepNotes: createTool(Object.freeze({
|
|
50
|
+
id: "keep_notes",
|
|
51
|
+
description: "Keep notes for the job in hand: facts you found and will need later in a long job (file names, record ids, what was decided, what's left to check). They're shown to you at every turn, even after the conversation is summarised. Send the whole list every time; send an empty list when the job is over. The person doesn't see them.",
|
|
52
|
+
inputSchema: z.object({ notes: z.array(z.string().min(1).max(300)).max(30) }),
|
|
53
|
+
execute: (input) => keepNotes(input)
|
|
54
|
+
})),
|
|
55
|
+
...(post ? {
|
|
56
|
+
updateTodos: createTool(Object.freeze({
|
|
57
|
+
id: "update_todos",
|
|
58
|
+
description: "Write your to-do list for a job of more than three steps; the person sees it live as a card in the chat. Send the whole list every time: each item a short step, its status todo, doing or done, with exactly one item doing while you work. Tick an item off as soon as it's done. Skip it for quick answers.",
|
|
59
|
+
inputSchema: z.object({
|
|
60
|
+
items: z.array(z.object({
|
|
61
|
+
text: z.string().min(1).max(200),
|
|
62
|
+
status: z.enum(["todo", "doing", "done"])
|
|
63
|
+
})).min(1).max(20)
|
|
64
|
+
}),
|
|
65
|
+
execute: (input) => updateTodos(input)
|
|
66
|
+
}))
|
|
67
|
+
} : {})
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The open to-do list and the notes, for the model's state note at every turn. */
|
|
72
|
+
export async function planningNotes(knex, conversationId, tables = PLANNING_TABLES) {
|
|
73
|
+
const card = await currentTodos(knex, conversationId, tables);
|
|
74
|
+
const notes = (await knex(tables.conversations).where({ id: conversationId }).first("job_notes"))?.job_notes || [];
|
|
75
|
+
const mark = { todo: "[ ]", doing: "[doing]", done: "[x]" };
|
|
76
|
+
return [
|
|
77
|
+
card && card.body.items.some((item) => item.status !== "done")
|
|
78
|
+
? `Your to-do list for this job (keep it up to date with update_todos):\n${card.body.items.map((item) => `${mark[item.status]} ${item.text}`).join("\n")}`
|
|
79
|
+
: "",
|
|
80
|
+
notes.length ? `Your notes for the job in hand (keep them up to date with keep_notes):\n${notes.map((note) => `- ${note}`).join("\n")}` : ""
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Replies in progress, one per conversation at a time, kept in the database so any server can see
|
|
2
|
+
// them (one claimed per conversation, with a heartbeat). Each finished step is saved as it happens,
|
|
3
|
+
// so a reply whose server stops (deploy, crash) is carried on from its last step by any server, as
|
|
4
|
+
// Claude Code resumes from its transcript.
|
|
5
|
+
//
|
|
6
|
+
// The table has: conversation_id (unique), started_at, heartbeat_at, steps (jsonb), input (jsonb),
|
|
7
|
+
// plus whatever `owner` columns the app adds (for example bot_id).
|
|
8
|
+
// A run is stale once its heartbeat (every heartbeatMs) is older than staleSeconds.
|
|
9
|
+
export const RUN_LIMITS = { staleSeconds: 60, heartbeatMs: 15000 };
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The replies in progress, in `table`.
|
|
13
|
+
* @param {object} dependencies
|
|
14
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} dependencies.knex
|
|
15
|
+
* @param {string} [dependencies.table]
|
|
16
|
+
* @param {typeof RUN_LIMITS} [dependencies.limits]
|
|
17
|
+
*/
|
|
18
|
+
export function createRuns({
|
|
19
|
+
knex,
|
|
20
|
+
table = "agent_runs",
|
|
21
|
+
limits = RUN_LIMITS
|
|
22
|
+
}) {
|
|
23
|
+
const stale = (query) => query.where("heartbeat_at", "<", knex.raw(`now() - interval '${Number(limits.staleSeconds)} seconds'`));
|
|
24
|
+
|
|
25
|
+
/** Claims the conversation for one reply: a new run, or one whose server stopped beating. */
|
|
26
|
+
async function claim(conversationId, owner = {}) {
|
|
27
|
+
const [fresh] = await knex(table).insert({ conversation_id: conversationId, ...owner }).onConflict("conversation_id").ignore().returning("conversation_id");
|
|
28
|
+
if (fresh) return true;
|
|
29
|
+
const [taken] = await stale(knex(table).where({ conversation_id: conversationId }))
|
|
30
|
+
.update({ started_at: knex.fn.now(), heartbeat_at: knex.fn.now() }).returning("conversation_id");
|
|
31
|
+
return Boolean(taken);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function heartbeat(conversationId) {
|
|
35
|
+
return knex(table).where({ conversation_id: conversationId }).update({ heartbeat_at: knex.fn.now() });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function release(conversationId) {
|
|
39
|
+
return knex(table).where({ conversation_id: conversationId }).del();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** What a reply saved so far: its steps and what started it. */
|
|
43
|
+
function saved(conversationId) {
|
|
44
|
+
return knex(table).where({ conversation_id: conversationId }).first("steps", "input");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function save(conversationId, steps, input) {
|
|
48
|
+
return knex(table).where({ conversation_id: conversationId }).update({ steps: JSON.stringify(steps), input: JSON.stringify(input), heartbeat_at: knex.fn.now() });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Replies left part-way by a stopped server, each taken over by this one (still stale when
|
|
53
|
+
* claimed, so two servers can't both take it). `resumable(query)` narrows which kinds count.
|
|
54
|
+
*/
|
|
55
|
+
async function takeOverStalled({ resumable = (query) => query, count = 10 } = {}) {
|
|
56
|
+
const rows = await resumable(stale(knex(table).whereNotNull("steps"))).limit(count);
|
|
57
|
+
const taken = [];
|
|
58
|
+
for (const row of rows) {
|
|
59
|
+
const [mine] = await stale(knex(table).where({ conversation_id: row.conversation_id }))
|
|
60
|
+
.update({ started_at: knex.fn.now(), heartbeat_at: knex.fn.now() }).returning("*");
|
|
61
|
+
if (mine) taken.push(mine);
|
|
62
|
+
}
|
|
63
|
+
return taken;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return Object.freeze({
|
|
67
|
+
limits,
|
|
68
|
+
claim,
|
|
69
|
+
heartbeat,
|
|
70
|
+
release,
|
|
71
|
+
saved,
|
|
72
|
+
save,
|
|
73
|
+
takeOverStalled
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Carries on, every `everyMs`, the replies stopped servers left; `resume(run)` restarts one.
|
|
79
|
+
* @param {object} dependencies
|
|
80
|
+
* @param {import("./types.js").AgentRuns} dependencies.runs
|
|
81
|
+
* @param {(run: object) => Promise<unknown>} dependencies.resume
|
|
82
|
+
* @param {(query: object) => object} [dependencies.resumable]
|
|
83
|
+
* @param {number} [dependencies.everyMs]
|
|
84
|
+
*/
|
|
85
|
+
export function startResumer({ runs, resume, resumable, everyMs = 30000 }) {
|
|
86
|
+
let stopped = false;
|
|
87
|
+
let running = null;
|
|
88
|
+
const tick = () => {
|
|
89
|
+
if (stopped || running) return;
|
|
90
|
+
running = runs.takeOverStalled({ resumable })
|
|
91
|
+
.then((taken) => Promise.all(taken.map((run) => resume(run))))
|
|
92
|
+
.catch((error) => console.error(`Resuming replies: ${error?.message || error}`))
|
|
93
|
+
.finally(() => { running = null; });
|
|
94
|
+
};
|
|
95
|
+
const timer = setInterval(tick, everyMs);
|
|
96
|
+
timer.unref();
|
|
97
|
+
tick();
|
|
98
|
+
return {
|
|
99
|
+
async drain(ms) {
|
|
100
|
+
stopped = true;
|
|
101
|
+
clearInterval(timer);
|
|
102
|
+
await Promise.race([running || Promise.resolve(), new Promise((resolve) => setTimeout(resolve, ms))]);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { SUMMARY_LIMITS, assertCallbacks, cutPoint, estimateTokens, transcriptText, writeSummary } from "@jskit-ai/agent/server";
|
|
2
|
+
import { NO_NOTICES } from "./notices.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Summaries between turns, in the background. A reply reads the conversation's newest summary and
|
|
6
|
+
* every message after it word for word. When that part grows past `compactAt`, the conversation is
|
|
7
|
+
* queued; one server claims it and summarises everything but the latest turns, folding in the
|
|
8
|
+
* previous summary. A summary replaces exactly the messages it covers, so anything said meanwhile
|
|
9
|
+
* stays as it was, and no reply waits for it. Summaries are written recall first: what was asked
|
|
10
|
+
* and decided, facts, what was done and promised, what's open. Sized for a 1-million-token window,
|
|
11
|
+
* close to DeepSeek Harness's 727k/127k: a chat is read word for word until it's far into the
|
|
12
|
+
* window, leaving room for one long turn's own steps on top.
|
|
13
|
+
*
|
|
14
|
+
* Tables: summaries (id, conversation_id, covers_up_to, summary, messages, created_at) and a queue
|
|
15
|
+
* (conversation_id unique, requested_at, run_after, lease_until, attempts, last_error).
|
|
16
|
+
*
|
|
17
|
+
* Hooks: `history(conversationId, { after })` gives { messages, inherited, toModel, named }: the
|
|
18
|
+
* stored messages after `after` (message ids), what a conversation inherits from one it continues
|
|
19
|
+
* (only needed before its first summary), the app's way of turning messages into model messages, and
|
|
20
|
+
* whether people's lines carry their names (groups and rooms). `track(conversationId, work)` meters
|
|
21
|
+
* the summary to whoever the conversation belongs to. `summarize({ previous, text })` may replace
|
|
22
|
+
* the model call. `track` and `summarize` are functions, or left out for their defaults. Without
|
|
23
|
+
* `notices`, each server finds queued conversations on its own timer.
|
|
24
|
+
* @param {object} dependencies
|
|
25
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} dependencies.knex
|
|
26
|
+
* @param {import("ai").LanguageModel} [dependencies.model]
|
|
27
|
+
* @param {string} [dependencies.app]
|
|
28
|
+
* @param {(conversationId: number | string, query: { after: number }) => Promise<{ messages: object[], inherited: object[], toModel: (messages: object[]) => Promise<object[]>, named?: boolean }>} dependencies.history
|
|
29
|
+
* @param {(conversationId: number | string, work: () => Promise<string>) => Promise<string>} [dependencies.track]
|
|
30
|
+
* @param {import("./types.js").AgentNotices | null} [dependencies.notices]
|
|
31
|
+
* @param {{ summaries: string, queue: string }} [dependencies.tables]
|
|
32
|
+
* @param {string} [dependencies.channel]
|
|
33
|
+
* @param {(input: { previous: string, text: string }) => Promise<string>} [dependencies.summarize]
|
|
34
|
+
* @param {typeof SUMMARY_LIMITS} [dependencies.limits]
|
|
35
|
+
*/
|
|
36
|
+
export function createSummaries({
|
|
37
|
+
knex,
|
|
38
|
+
model,
|
|
39
|
+
app = "the app",
|
|
40
|
+
history,
|
|
41
|
+
track = runSummaryUnmetered,
|
|
42
|
+
notices,
|
|
43
|
+
tables = { summaries: "agent_summaries", queue: "agent_summary_queue" },
|
|
44
|
+
channel = "agent-summaries",
|
|
45
|
+
summarize = (input) => writeSummary({ model, app, ...input, limits }),
|
|
46
|
+
limits = SUMMARY_LIMITS
|
|
47
|
+
}) {
|
|
48
|
+
assertCallbacks("createSummaries", { track, summarize });
|
|
49
|
+
const channelNotices = notices ?? NO_NOTICES;
|
|
50
|
+
|
|
51
|
+
async function latest(conversationId) {
|
|
52
|
+
return knex(tables.summaries).where({ conversation_id: conversationId }).orderBy("id", "desc").first();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What a reply reads: the newest summary's text, and the id after which messages are read whole. */
|
|
56
|
+
async function current(conversationId) {
|
|
57
|
+
const row = await latest(conversationId);
|
|
58
|
+
return row ? { summary: row.summary, after: Number(row.covers_up_to) } : { summary: "", after: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** After a reply: queue a summary when the part since the last one has grown too big. */
|
|
62
|
+
async function consider(conversationId) {
|
|
63
|
+
const { after } = await current(conversationId);
|
|
64
|
+
const { messages, inherited, toModel } = await history(conversationId, { after });
|
|
65
|
+
const size = estimateTokens(await toModel([...(after ? [] : inherited), ...messages]));
|
|
66
|
+
if (size <= limits.compactAt) return false;
|
|
67
|
+
await knex(tables.queue).insert({ conversation_id: conversationId }).onConflict("conversation_id").ignore();
|
|
68
|
+
await channelNotices.publish(channel, "all", {}).catch(() => {});
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** One summary: everything after the previous one up to the kept turns, folded into it. */
|
|
73
|
+
async function compact(conversationId) {
|
|
74
|
+
const previous = await latest(conversationId);
|
|
75
|
+
const after = previous ? Number(previous.covers_up_to) : 0;
|
|
76
|
+
const { messages, inherited, toModel, named = false } = await history(conversationId, { after });
|
|
77
|
+
const sizes = await Promise.all(messages.map(async (message) => estimateTokens(await toModel([message]))));
|
|
78
|
+
let cut = cutPoint(messages, sizes, limits);
|
|
79
|
+
if (!cut) return { skipped: "nothing to summarise yet" };
|
|
80
|
+
// A huge backlog is folded in a piece at a time, oldest first; the next run takes the rest.
|
|
81
|
+
let reading = estimateTokens(await toModel(previous ? [] : inherited));
|
|
82
|
+
for (let index = 0; index < cut; index += 1) {
|
|
83
|
+
reading += sizes[index];
|
|
84
|
+
if (reading > limits.readTokens && index > 0) {
|
|
85
|
+
cut = index;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const folded = messages.slice(0, cut);
|
|
90
|
+
const modelMessages = await toModel([...(previous ? [] : inherited), ...folded]);
|
|
91
|
+
if (!previous && estimateTokens(modelMessages) < limits.minTokens) return { skipped: "too little to summarise" };
|
|
92
|
+
const summary = await track(conversationId, () => summarize({ previous: previous?.summary || "", text: transcriptText(modelMessages, { named }) }));
|
|
93
|
+
if (!summary) throw new Error("The summary came back empty.");
|
|
94
|
+
const coversUpTo = Number(folded.at(-1).id);
|
|
95
|
+
await knex.transaction(async (trx) => {
|
|
96
|
+
// Another server's newer summary wins; this one is dropped.
|
|
97
|
+
const newest = await trx(tables.summaries).where({ conversation_id: conversationId }).orderBy("id", "desc").forUpdate().first();
|
|
98
|
+
if (newest && Number(newest.covers_up_to) >= coversUpTo) return;
|
|
99
|
+
await trx(tables.summaries).insert({ conversation_id: conversationId, covers_up_to: coversUpTo, summary, messages: folded.length });
|
|
100
|
+
const old = await trx(tables.summaries).where({ conversation_id: conversationId }).orderBy("id", "desc").offset(limits.keepSummaries).pluck("id");
|
|
101
|
+
if (old.length) await trx(tables.summaries).whereIn("id", old).del();
|
|
102
|
+
});
|
|
103
|
+
return { coversUpTo, messages: folded.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Before a reply: if summarising fell behind and the part a reply reads is past
|
|
108
|
+
* `summariseNowAt`, summarise now, a few pieces at most, rather than let the oldest messages drop
|
|
109
|
+
* off unseen. A failure leaves the reply its last-resort cut (withinBudget, @jskit-ai/agent).
|
|
110
|
+
*/
|
|
111
|
+
async function ensureWithin(conversationId) {
|
|
112
|
+
for (let piece = 0; piece < 4; piece += 1) {
|
|
113
|
+
const { after } = await current(conversationId);
|
|
114
|
+
const { messages, inherited, toModel } = await history(conversationId, { after });
|
|
115
|
+
if (estimateTokens(await toModel([...(after ? [] : inherited), ...messages])) <= limits.summariseNowAt) return;
|
|
116
|
+
const done = await compact(conversationId).catch((error) => {
|
|
117
|
+
console.error(`Summarising conversation ${conversationId} before a reply: ${error?.message || error}`);
|
|
118
|
+
return {};
|
|
119
|
+
});
|
|
120
|
+
if (!done.coversUpTo) return;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Claimed once across servers with a lease; a failure waits longer before the next try. */
|
|
125
|
+
async function claim() {
|
|
126
|
+
return knex.transaction(async (trx) => {
|
|
127
|
+
const due = await trx(tables.queue).where("run_after", "<=", trx.fn.now())
|
|
128
|
+
.where((query) => query.whereNull("lease_until").orWhere("lease_until", "<", trx.fn.now()))
|
|
129
|
+
.orderBy("requested_at").forUpdate().skipLocked().first();
|
|
130
|
+
if (!due) return null;
|
|
131
|
+
await trx(tables.queue).where({ conversation_id: due.conversation_id }).update({ lease_until: trx.raw(`now() + interval '${Number(limits.leaseMinutes)} minutes'`) });
|
|
132
|
+
return due;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runOne() {
|
|
137
|
+
const job = await claim();
|
|
138
|
+
if (!job) return false;
|
|
139
|
+
try {
|
|
140
|
+
await compact(job.conversation_id);
|
|
141
|
+
await knex(tables.queue).where({ conversation_id: job.conversation_id }).del();
|
|
142
|
+
// Still too long (a backlog folded in pieces)? Queue the next piece.
|
|
143
|
+
await consider(job.conversation_id);
|
|
144
|
+
} catch (error) {
|
|
145
|
+
const wait = Math.min(60, 2 ** job.attempts);
|
|
146
|
+
await knex(tables.queue).where({ conversation_id: job.conversation_id })
|
|
147
|
+
.update({ attempts: job.attempts + 1, lease_until: null, last_error: String(error?.message || error).slice(0, 500), run_after: knex.raw(`now() + interval '${wait} minutes'`) }).catch(() => {});
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Every serving server runs this loop; a notice wakes it as soon as something is queued. */
|
|
153
|
+
function start({ everyMs = 15000 } = {}) {
|
|
154
|
+
let stopped = false;
|
|
155
|
+
let running = null;
|
|
156
|
+
const tick = () => {
|
|
157
|
+
if (running || stopped) return;
|
|
158
|
+
running = (async () => {
|
|
159
|
+
try {
|
|
160
|
+
for (let count = 0; !stopped && count < 20 && (await runOne()); count += 1);
|
|
161
|
+
} catch {
|
|
162
|
+
// The database is unreachable; the next tick tries again.
|
|
163
|
+
}
|
|
164
|
+
})().finally(() => {
|
|
165
|
+
running = null;
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const timer = setInterval(tick, everyMs);
|
|
169
|
+
timer.unref();
|
|
170
|
+
const unsubscribe = channelNotices.subscribe(channel, "all", () => tick());
|
|
171
|
+
tick();
|
|
172
|
+
return {
|
|
173
|
+
async drain(ms) {
|
|
174
|
+
stopped = true;
|
|
175
|
+
clearInterval(timer);
|
|
176
|
+
unsubscribe();
|
|
177
|
+
await Promise.race([running, new Promise((resolve) => setTimeout(resolve, ms))]);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return Object.freeze({
|
|
183
|
+
current,
|
|
184
|
+
consider,
|
|
185
|
+
compact,
|
|
186
|
+
ensureWithin,
|
|
187
|
+
runOne,
|
|
188
|
+
start
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Runs a summary's work without metering it. */
|
|
193
|
+
function runSummaryUnmetered(_conversationId, work) {
|
|
194
|
+
return work();
|
|
195
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { createTool } from "@jskit-ai/agent/server";
|
|
3
|
+
|
|
4
|
+
// What the model sees of the ask_user tool, whichever way the chat shows the question.
|
|
5
|
+
const ASK_USER = Object.freeze({
|
|
6
|
+
id: "ask_user",
|
|
7
|
+
description: "Ask the person a question you need answered before you can go on, with a few answers to pick from when there are obvious ones; then end your reply and wait. Only for what you can't reasonably decide or find out yourself.",
|
|
8
|
+
inputSchema: z.object({
|
|
9
|
+
question: z.string().min(3).max(1000),
|
|
10
|
+
options: z.array(z.string().min(1).max(100)).max(6).optional()
|
|
11
|
+
})
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Asking the person something only they can decide, in a chat that shows questions as cards: the
|
|
16
|
+
* question is a card with the answers to pick from, and the answer is the person's next message.
|
|
17
|
+
* @param {object} options
|
|
18
|
+
* @param {(messages: object[]) => Promise<unknown>} options.post Shows messages in the chat.
|
|
19
|
+
*/
|
|
20
|
+
export function askTools({ post }) {
|
|
21
|
+
async function askUser({ question, options = [] }) {
|
|
22
|
+
await post([{ kind: "question", role: "bot", body: { question, options } }]);
|
|
23
|
+
return { status: "asked", note: "The person sees the question. End your reply now in one short line, and carry on when they answer." };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
askUser: createTool(Object.freeze({
|
|
28
|
+
...ASK_USER,
|
|
29
|
+
execute: (input) => askUser(input)
|
|
30
|
+
}))
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Asking the person something only they can decide, in a chat that shows no question cards: the
|
|
36
|
+
* question goes in the reply, and the answer is the person's next message.
|
|
37
|
+
*/
|
|
38
|
+
export function askInReplyTools() {
|
|
39
|
+
function askUser() {
|
|
40
|
+
return { status: "ask in your reply", note: "Put the question in your reply, end it there, and carry on when they answer." };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
askUser: createTool(Object.freeze({
|
|
45
|
+
...ASK_USER,
|
|
46
|
+
execute: () => askUser()
|
|
47
|
+
}))
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { createWriteStream } from "node:fs";
|
|
2
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { createTool, textPart } from "@jskit-ai/agent/server";
|
|
7
|
+
|
|
8
|
+
// Commands in the workspace, through an executor (executors.js). One still running after
|
|
9
|
+
// `foregroundSeconds` carries on in the background on this server, with a heartbeat, and the model
|
|
10
|
+
// follows it with check_command; `onEnd(command)` hears when it ends. Every command is a row in the
|
|
11
|
+
// commands table, so check_command works from any server. Its result shows the start and the end
|
|
12
|
+
// of the output; all of it (up to `outputFull`) is a file, outputDir/<conversation>/<run>.log, that
|
|
13
|
+
// read_command_output reads in parts and the next commands can read too (as Claude Code keeps
|
|
14
|
+
// large outputs). Point outputDir at storage every server shares when there are several.
|
|
15
|
+
//
|
|
16
|
+
// The table has: id, conversation_id, why, script, status (running, done, failed, timed_out,
|
|
17
|
+
// stopped), exit_code, output, truncated, server, heartbeat_at, started_at, ended_at.
|
|
18
|
+
export const COMMAND_LIMITS = { foregroundSeconds: 120, maxSeconds: 1800, outputHead: 8000, outputTail: 8000, outputFull: 16 * 1024 * 1024, heartbeatSeconds: 20, staleSeconds: 90 };
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The commands of every conversation, in `table`, run through `executor`.
|
|
22
|
+
* @param {object} dependencies
|
|
23
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex} dependencies.knex
|
|
24
|
+
* @param {import("../types.js").AgentExecutor} dependencies.executor
|
|
25
|
+
* @param {string} [dependencies.table]
|
|
26
|
+
* @param {string} [dependencies.outputDir]
|
|
27
|
+
* @param {(command: object) => unknown} [dependencies.onEnd]
|
|
28
|
+
* @param {typeof COMMAND_LIMITS} [dependencies.limits]
|
|
29
|
+
* @param {string} [dependencies.server]
|
|
30
|
+
*/
|
|
31
|
+
export function createCommands({
|
|
32
|
+
knex,
|
|
33
|
+
executor,
|
|
34
|
+
table = "agent_commands",
|
|
35
|
+
outputDir = path.join(os.tmpdir(), "agent-runs"),
|
|
36
|
+
onEnd = ignoreEnd,
|
|
37
|
+
limits = COMMAND_LIMITS,
|
|
38
|
+
server = `${os.hostname()}:${process.pid}`
|
|
39
|
+
}) {
|
|
40
|
+
const live = new Map();
|
|
41
|
+
const logOf = (conversationId, id) => path.join(outputDir, String(conversationId), `${id}.log`);
|
|
42
|
+
|
|
43
|
+
function view(row) {
|
|
44
|
+
return {
|
|
45
|
+
run: String(row.id),
|
|
46
|
+
status: row.status,
|
|
47
|
+
...(row.exit_code === null || row.exit_code === undefined ? {} : { exitCode: row.exit_code }),
|
|
48
|
+
output: row.output || "",
|
|
49
|
+
...(row.truncated ? { truncated: true, wholeOutput: `The output was cut. All of it is in ${logOf(row.conversation_id, row.id)} (grep, tail and sed work on it), or read it in parts with read_command_output, run ${row.id}.` } : {})
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function run({ conversationId, cwd, why, script, seconds = limits.maxSeconds }) {
|
|
54
|
+
if (!executor.available()) return { error: "Commands can't run on this server (its sandbox isn't installed)." };
|
|
55
|
+
const [row] = await knex(table).insert({ conversation_id: conversationId, why, script, status: "running", server, heartbeat_at: knex.fn.now() }).returning("*");
|
|
56
|
+
const log = logOf(conversationId, row.id);
|
|
57
|
+
await mkdir(path.dirname(log), { recursive: true });
|
|
58
|
+
const whole = createWriteStream(log);
|
|
59
|
+
// The output as it comes: all of it to the file, and its start and end for the result.
|
|
60
|
+
const shown = limits.outputHead + limits.outputTail;
|
|
61
|
+
let kept = "";
|
|
62
|
+
let tail = "";
|
|
63
|
+
let size = 0;
|
|
64
|
+
const take = (chunk) => {
|
|
65
|
+
const text = chunk.toString("utf8");
|
|
66
|
+
if (size < limits.outputFull) whole.write(chunk);
|
|
67
|
+
size += text.length;
|
|
68
|
+
if (kept.length < shown) kept += text;
|
|
69
|
+
tail = (tail + text).slice(-limits.outputTail);
|
|
70
|
+
};
|
|
71
|
+
const child = await executor.spawn({ script, cwd, mounts: [{ source: path.dirname(log) }] });
|
|
72
|
+
child.stdout.on("data", take);
|
|
73
|
+
child.stderr.on("data", take);
|
|
74
|
+
let timedOut = false;
|
|
75
|
+
const timer = setTimeout(() => {
|
|
76
|
+
timedOut = true;
|
|
77
|
+
child.kill();
|
|
78
|
+
}, Math.min(seconds, limits.maxSeconds) * 1000);
|
|
79
|
+
const beat = setInterval(() => knex(table).where({ id: row.id }).update({ heartbeat_at: knex.fn.now() }).catch(() => {}), limits.heartbeatSeconds * 1000);
|
|
80
|
+
live.set(row.id, child);
|
|
81
|
+
const ended = new Promise((resolve) => child.on("close", (code) => resolve(code)))
|
|
82
|
+
.then(async (code) => {
|
|
83
|
+
clearTimeout(timer);
|
|
84
|
+
clearInterval(beat);
|
|
85
|
+
live.delete(row.id);
|
|
86
|
+
await new Promise((resolve) => whole.end(resolve));
|
|
87
|
+
const truncated = size > shown;
|
|
88
|
+
const output = truncated ? `${kept.slice(0, limits.outputHead)}\n… (${size - shown} characters cut) …\n${tail}` : kept;
|
|
89
|
+
const status = timedOut ? "timed_out" : code === 0 ? "done" : "failed";
|
|
90
|
+
const [done] = await knex(table).where({ id: row.id }).update({ status, exit_code: code, output, truncated, ended_at: knex.fn.now() }).returning("*");
|
|
91
|
+
await Promise.resolve(onEnd(done)).catch(() => {});
|
|
92
|
+
return done;
|
|
93
|
+
});
|
|
94
|
+
const finished = await Promise.race([ended, new Promise((resolve) => setTimeout(resolve, limits.foregroundSeconds * 1000, null))]);
|
|
95
|
+
if (finished) return view(finished);
|
|
96
|
+
return { run: String(row.id), status: "running", output: kept.slice(0, limits.outputHead), note: "It's still going and carries on in the background; follow it with check_command." };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** A running command whose server stopped beating has stopped with it. */
|
|
100
|
+
async function check(conversationId, id) {
|
|
101
|
+
if (!/^\d+$/u.test(String(id))) return { error: "Give the run number from run_command." };
|
|
102
|
+
const row = await knex(table).where({ id, conversation_id: conversationId }).first();
|
|
103
|
+
if (!row) return { error: "There's no such command in this conversation." };
|
|
104
|
+
if (row.status === "running" && !live.has(row.id) && Date.now() - new Date(row.heartbeat_at).getTime() > limits.staleSeconds * 1000) {
|
|
105
|
+
const [stopped] = await knex(table).where({ id: row.id, status: "running" }).update({ status: "stopped", output: `${row.output || ""}\n(The server running it stopped, so the command stopped too.)`, ended_at: knex.fn.now() }).returning("*");
|
|
106
|
+
return view(stopped || row);
|
|
107
|
+
}
|
|
108
|
+
return view(row);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function wholeOutput(conversationId, id, fromLine = 1) {
|
|
112
|
+
const row = await knex(table).where({ id, conversation_id: conversationId }).first();
|
|
113
|
+
if (!row) return { error: "There's no such command in this conversation." };
|
|
114
|
+
if (row.status === "running") return { error: "It's still running; check_command shows its output so far." };
|
|
115
|
+
if (!row.truncated) return { note: "Its output wasn't cut, so the result already showed all of it.", output: row.output };
|
|
116
|
+
const whole = await readFile(logOf(conversationId, row.id), "utf8").catch(() => null);
|
|
117
|
+
if (whole === null) return { error: "Its whole output isn't on this server any more." };
|
|
118
|
+
const { lines, shown } = textPart(whole, fromLine);
|
|
119
|
+
return { run: String(row.id), lines, output: shown };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** On shutdown: commands still running here stop, and say so. */
|
|
123
|
+
async function stop() {
|
|
124
|
+
for (const child of live.values()) child.kill();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return Object.freeze({
|
|
128
|
+
run,
|
|
129
|
+
check,
|
|
130
|
+
wholeOutput,
|
|
131
|
+
stop,
|
|
132
|
+
limits
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function ignoreEnd() {}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The command tools, for one turn in one conversation.
|
|
140
|
+
* @param {object} options
|
|
141
|
+
* @param {import("../types.js").AgentCommands} options.commands
|
|
142
|
+
* @param {number | string} options.conversationId
|
|
143
|
+
* @param {string} options.root The workspace, where commands run.
|
|
144
|
+
*/
|
|
145
|
+
export function commandTools({ commands, conversationId, root }) {
|
|
146
|
+
/** The whole output of a run, looked up only by its number from run_command or check_command. */
|
|
147
|
+
async function readOutput({ run, from_line: fromLine = 1 }) {
|
|
148
|
+
return /^\d+$/u.test(run) ? commands.wholeOutput(conversationId, run, fromLine) : { error: "Give the run number from run_command or check_command." };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return {
|
|
152
|
+
runCommand: createTool(Object.freeze({
|
|
153
|
+
id: "run_command",
|
|
154
|
+
description: `Run a bash script in the workspace: build, test, run programs, use git and the usual Unix tools with pipes and loops. Check what you made and fix errors by running again. A command still running after ${Math.round(commands.limits.foregroundSeconds / 60)} minutes carries on in the background; follow it with check_command.`,
|
|
155
|
+
inputSchema: z.object({
|
|
156
|
+
why: z.string().min(3).max(160).describe("What it does, in a few plain words, e.g. Running the tests"),
|
|
157
|
+
script: z.string().min(1).max(20000).describe("The bash script"),
|
|
158
|
+
seconds: z.number().int().min(5).max(commands.limits.maxSeconds).optional().describe("Most it may take in total; default 30 minutes")
|
|
159
|
+
}),
|
|
160
|
+
execute: ({ why, script, seconds }) => commands.run({ conversationId, cwd: root, why, script, seconds })
|
|
161
|
+
})),
|
|
162
|
+
checkCommand: createTool(Object.freeze({
|
|
163
|
+
id: "check_command",
|
|
164
|
+
description: "See how a command that went on in the background is doing: its status and output so far.",
|
|
165
|
+
inputSchema: z.object({ run: z.string().min(1).max(30) }),
|
|
166
|
+
execute: ({ run }) => commands.check(conversationId, run)
|
|
167
|
+
})),
|
|
168
|
+
readCommandOutput: createTool(Object.freeze({
|
|
169
|
+
id: "read_command_output",
|
|
170
|
+
description: "Read the whole output of a command whose result was cut (it says so), in parts: give the run number, and from_line to carry on where the last part stopped.",
|
|
171
|
+
inputSchema: z.object({ run: z.string().min(1).max(30), from_line: z.number().int().min(1).optional() }),
|
|
172
|
+
execute: (input) => readOutput(input)
|
|
173
|
+
}))
|
|
174
|
+
};
|
|
175
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { goalTools } from "../goals.js";
|
|
2
|
+
import { planningTools } from "../planning.js";
|
|
3
|
+
import { askInReplyTools, askTools } from "./ask.js";
|
|
4
|
+
import { commandTools } from "./commands.js";
|
|
5
|
+
import { fileTools } from "./files.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The tools an agent has unless it's given others, for one turn in one conversation: the
|
|
9
|
+
* workspace's files (`root`), commands there (`commands`, from createCommands), a to-do list and
|
|
10
|
+
* notes (`knex`, `tables`; the to-do card needs `post`), goals (`goals`, from
|
|
11
|
+
* goals.operationsFor) and asking the person (a question card with `post`, otherwise in the reply).
|
|
12
|
+
* Each other part is left out when what it needs isn't given.
|
|
13
|
+
* @param {object} options
|
|
14
|
+
* @param {string} options.root
|
|
15
|
+
* @param {import("../types.js").AgentCommands | null} [options.commands]
|
|
16
|
+
* @param {import("@jskit-ai/database-runtime/server/types").RuntimeKnex | null} [options.knex]
|
|
17
|
+
* @param {number | string} options.conversationId
|
|
18
|
+
* @param {((messages: object[]) => Promise<unknown>) | null} [options.post]
|
|
19
|
+
* @param {import("../types.js").AgentGoalOperations | null} [options.goals]
|
|
20
|
+
* @param {typeof import("../planning.js").PLANNING_TABLES} [options.tables]
|
|
21
|
+
*/
|
|
22
|
+
export function defaultTools({ root, commands = null, knex = null, conversationId, post = null, goals = null, tables }) {
|
|
23
|
+
return {
|
|
24
|
+
...fileTools({ root }),
|
|
25
|
+
...(commands ? commandTools({ commands, conversationId, root }) : {}),
|
|
26
|
+
...(knex ? planningTools({ knex, conversationId, post, tables }) : {}),
|
|
27
|
+
...(goals ? goalTools(goals) : {}),
|
|
28
|
+
...(post ? askTools({ post }) : askInReplyTools())
|
|
29
|
+
};
|
|
30
|
+
}
|