@chloejs/core 0.2.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/LICENSE +21 -0
- package/README.md +221 -0
- package/channels/api.ts +41 -0
- package/channels/shared.ts +250 -0
- package/channels/slack.ts +390 -0
- package/channels/telegram.ts +396 -0
- package/core/clock.ts +126 -0
- package/core/confine.ts +45 -0
- package/core/db.ts +117 -0
- package/core/markdown.ts +95 -0
- package/core/notes.ts +44 -0
- package/core/paths.ts +29 -0
- package/core/root.ts +26 -0
- package/core/settings.ts +124 -0
- package/core/steps.ts +896 -0
- package/core/turn.ts +314 -0
- package/do/email.ts +45 -0
- package/do/files.ts +96 -0
- package/do/mail.ts +155 -0
- package/do/run.ts +56 -0
- package/do/scripts.ts +49 -0
- package/do/web.ts +192 -0
- package/index.ts +52 -0
- package/load/job.ts +84 -0
- package/load/load.ts +478 -0
- package/model/ask.ts +84 -0
- package/model/claude.ts +261 -0
- package/model/memory.ts +68 -0
- package/model/model.ts +185 -0
- package/model/tool.ts +53 -0
- package/model/tools/files.ts +71 -0
- package/model/tools/gmail.ts +43 -0
- package/model/tools/index.ts +28 -0
- package/model/tools/memory.ts +23 -0
- package/model/tools/run_script.ts +44 -0
- package/model/tools/send_email.ts +29 -0
- package/model/tools/web.ts +23 -0
- package/model/tools/write_skill.ts +31 -0
- package/ops/account.ts +109 -0
- package/ops/agent.ts +290 -0
- package/ops/check.ts +37 -0
- package/ops/evals.ts +206 -0
- package/ops/install.sh +101 -0
- package/ops/test.ts +1976 -0
- package/package.json +65 -0
- package/scorers/calls.ts +50 -0
- package/scorers/expectations.ts +118 -0
- package/scorers/index.ts +5 -0
- package/serve/alerts.ts +79 -0
- package/serve/errors.ts +10 -0
- package/serve/files.ts +70 -0
- package/serve/http.ts +767 -0
- package/serve/login.ts +299 -0
- package/serve/memory.ts +372 -0
- package/serve/page.ts +142 -0
- package/serve/pass.ts +45 -0
- package/serve/recentWork.ts +69 -0
- package/serve/site.ts +409 -0
- package/serve/tokens.ts +132 -0
- package/server.ts +170 -0
- package/timer/cron.ts +92 -0
- package/timer/every.ts +153 -0
- package/timer/index.ts +4 -0
package/core/steps.ts
ADDED
|
@@ -0,0 +1,896 @@
|
|
|
1
|
+
// A job that is code, with a model as one step inside it.
|
|
2
|
+
//
|
|
3
|
+
// `core/turn.ts` is the other half of this runtime: ask a model and let it
|
|
4
|
+
// decide. This file is for the jobs where the deciding is already written
|
|
5
|
+
// down. Nothing here asks a model unless the job calls `model(...)`, so a job
|
|
6
|
+
// made of `step(...)` costs nothing and the run record says so.
|
|
7
|
+
//
|
|
8
|
+
// The job is an async function, so branching is `if` and looping is `for`.
|
|
9
|
+
// That choice has one consequence, and it is the whole of the rest of this
|
|
10
|
+
// file: to carry on after a pause, the function is run again from the top and
|
|
11
|
+
// every finished step hands back what it returned last time. So:
|
|
12
|
+
//
|
|
13
|
+
// Work happens inside a step. Code outside a step only decides.
|
|
14
|
+
//
|
|
15
|
+
// A line outside a step runs again on every resume. If it sends, writes or
|
|
16
|
+
// spends, it does so twice.
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
import { deliver, owner as whoOwns } from "#chloe/model/ask.ts";
|
|
22
|
+
import { db } from "#chloe/core/db.ts";
|
|
23
|
+
import { oneLineSummary } from "#chloe/core/markdown.ts";
|
|
24
|
+
import type { Agent, Job } from "#chloe/load/load.ts";
|
|
25
|
+
import { ask as askModel, type Message } from "#chloe/model/model.ts";
|
|
26
|
+
import { loop, money } from "#chloe/core/turn.ts";
|
|
27
|
+
import type { Approve, Call, Tool, Tools } from "#chloe/model/tool.ts";
|
|
28
|
+
|
|
29
|
+
/** One finished step, and the record that lets it not run twice. */
|
|
30
|
+
export interface Line {
|
|
31
|
+
/** Its place in the order the job called things. This is the replay key. */
|
|
32
|
+
seq: number;
|
|
33
|
+
name: string;
|
|
34
|
+
kind: "step" | "model" | "ask" | "agent";
|
|
35
|
+
at: string;
|
|
36
|
+
ms: number;
|
|
37
|
+
cost: number;
|
|
38
|
+
result?: unknown;
|
|
39
|
+
/** For an ask: who was asked, and what they were asked. */
|
|
40
|
+
note?: string;
|
|
41
|
+
/** For a model step: what it was asked. */
|
|
42
|
+
prompt?: string;
|
|
43
|
+
/** For an ask: the question, and what the person typed before it was understood. */
|
|
44
|
+
question?: string;
|
|
45
|
+
reply?: string;
|
|
46
|
+
/** For an agent step: every tool it ran, in the order it ran them, and the ones it was not allowed to. */
|
|
47
|
+
calls?: Call[];
|
|
48
|
+
/** Why this step did not finish. The run usually stops here, unless the job caught it. */
|
|
49
|
+
failed?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** What a job is waiting on. Null on the run means it is not waiting. */
|
|
53
|
+
interface Parked {
|
|
54
|
+
seq: number;
|
|
55
|
+
name: string;
|
|
56
|
+
who: string;
|
|
57
|
+
question: string;
|
|
58
|
+
asked: string;
|
|
59
|
+
expires: string;
|
|
60
|
+
/** What came back, not yet understood. */
|
|
61
|
+
reply?: string;
|
|
62
|
+
/** How many times an answer did not fit. Three and the job gives up. */
|
|
63
|
+
confusions?: number;
|
|
64
|
+
/** Set by the sweep when nobody answered in time. */
|
|
65
|
+
late?: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* One question for a model, inside a workflow that stays code: you know what to
|
|
70
|
+
* ask, and the answer has to come back in the shape you asked for.
|
|
71
|
+
*/
|
|
72
|
+
export interface ModelStep<S extends z.ZodType> {
|
|
73
|
+
prompt: string;
|
|
74
|
+
/** The shape the answer has to be in. Free text cannot steer the next step. */
|
|
75
|
+
output: S;
|
|
76
|
+
/** When this one step wants a model the rest of the job does not. */
|
|
77
|
+
model?: string;
|
|
78
|
+
system?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Bounded autonomy. You give the goal and the tools, the model works out the
|
|
83
|
+
* order. Reach for this only when the order cannot be known in advance: when
|
|
84
|
+
* you know the steps, they are `step` calls, and when you know the question, it
|
|
85
|
+
* is one `model` call.
|
|
86
|
+
*/
|
|
87
|
+
export interface AgentStep<S extends z.ZodType = z.ZodType> {
|
|
88
|
+
/** What you want done, not how to do it. */
|
|
89
|
+
goal: string;
|
|
90
|
+
/** Everything it may do. Nothing outside this list is reachable from inside. */
|
|
91
|
+
tools: Tool[] | Tools;
|
|
92
|
+
/** The shape the final answer has to be in. Without one, you get its words. */
|
|
93
|
+
output?: S;
|
|
94
|
+
/** Most turns of the loop before it has to stop. Ten by default. */
|
|
95
|
+
maxSteps?: number;
|
|
96
|
+
/**
|
|
97
|
+
* What it may spend, in dollars, before it has to stop. Checked between
|
|
98
|
+
* turns, so the turn that crosses the line is paid for and nothing after it
|
|
99
|
+
* is: size it as the point where you want the step to give up, not as a
|
|
100
|
+
* ceiling it cannot pass. Without one, `maxSteps` is the only limit, and ten
|
|
101
|
+
* turns of a large model is not a small number. Going over is an error, like
|
|
102
|
+
* running out of steps.
|
|
103
|
+
*/
|
|
104
|
+
budget?: number;
|
|
105
|
+
/**
|
|
106
|
+
* Asked before each tool runs, with the arguments the model chose. The tools
|
|
107
|
+
* say what it may do at all; this says which particular calls are allowed.
|
|
108
|
+
* It decides now, in code: to have a person decide, ask them with `ask`
|
|
109
|
+
* before the step and let this read the answer.
|
|
110
|
+
*/
|
|
111
|
+
approve?: Approve;
|
|
112
|
+
/** When this step wants a model the rest of the job does not. */
|
|
113
|
+
model?: string;
|
|
114
|
+
/** What it should know before it starts. */
|
|
115
|
+
system?: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A question for a person. The run parks, the question goes out to an address,
|
|
120
|
+
* and what they type is matched against the shape rather than read by a model.
|
|
121
|
+
*/
|
|
122
|
+
export interface AskStep<S extends z.ZodType> {
|
|
123
|
+
question: string;
|
|
124
|
+
/** The shape the person's answer has to be in. */
|
|
125
|
+
answer: S;
|
|
126
|
+
/** An address, "channel:who". Defaults to the run's owner. */
|
|
127
|
+
who?: string;
|
|
128
|
+
/** How long to wait: "30m", "4h", "2d". Two hours by default. */
|
|
129
|
+
within?: string;
|
|
130
|
+
/** What to carry on with when nobody answers. Without one, the job stops. */
|
|
131
|
+
otherwise?: z.infer<S>;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** What a job's `run` is handed. */
|
|
135
|
+
export interface Work<State = Record<string, unknown>, Input = Record<string, unknown>> {
|
|
136
|
+
/** Do something, once, and write down what it returned. */
|
|
137
|
+
step<T>(name: string, fn: () => Promise<T> | T): Promise<T>;
|
|
138
|
+
/** Ask a model one question and get an answer in the shape you asked for. */
|
|
139
|
+
model<S extends z.ZodType>(name: string, options: ModelStep<S>): Promise<z.infer<S>>;
|
|
140
|
+
/** Hand a goal and some tools to a model and let it pick the order. The most autonomy, so the last resort. */
|
|
141
|
+
agent<S extends z.ZodType>(name: string, options: AgentStep<S> & { output: S }): Promise<z.infer<S>>;
|
|
142
|
+
agent(name: string, options: Omit<AgentStep, "output">): Promise<string>;
|
|
143
|
+
/** Stop and wait for a person. The process may restart while it waits. */
|
|
144
|
+
ask<S extends z.ZodType>(name: string, options: AskStep<S>): Promise<z.infer<S>>;
|
|
145
|
+
/** The shared store. Survives a pause. */
|
|
146
|
+
readonly state: State;
|
|
147
|
+
setState(next: Partial<State>): Promise<void>;
|
|
148
|
+
/**
|
|
149
|
+
* What this run was started with, already checked against the job's `input`
|
|
150
|
+
* shape. Empty for a run the clock started. It does not change, so there is
|
|
151
|
+
* nothing to set: the store above is the part that moves.
|
|
152
|
+
*/
|
|
153
|
+
readonly input: Input;
|
|
154
|
+
readonly owner: string;
|
|
155
|
+
/** Whose job this is. Notes, scripts and folders are filed under it. */
|
|
156
|
+
readonly agentName: string;
|
|
157
|
+
/**
|
|
158
|
+
* Where that agent remembers things: its memory folder. A job that files
|
|
159
|
+
* something there reads the path from here rather than writing it down again,
|
|
160
|
+
* so the agent's definition is the one place that says where.
|
|
161
|
+
*/
|
|
162
|
+
readonly memory: string;
|
|
163
|
+
readonly runId: string;
|
|
164
|
+
readonly signal?: AbortSignal;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What a run of a job came back with, whether it finished or parked. */
|
|
168
|
+
export interface Result {
|
|
169
|
+
runId: string;
|
|
170
|
+
text: string;
|
|
171
|
+
/** The job's one line about what it did, for the overview. */
|
|
172
|
+
summary?: string | null;
|
|
173
|
+
/** What a chat is sent: the job's `reply`, or its summary. */
|
|
174
|
+
reply?: string;
|
|
175
|
+
steps: number;
|
|
176
|
+
cost: number;
|
|
177
|
+
parked: boolean;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** A step's result has to fit in the run record, which is one database row. */
|
|
181
|
+
const MOST = 64_000;
|
|
182
|
+
const WAIT = "2h";
|
|
183
|
+
|
|
184
|
+
/** Not an error: the job stopped on purpose and is waiting for somebody. */
|
|
185
|
+
class Waiting extends Error {}
|
|
186
|
+
/** Nobody answered, and the ask had nothing to carry on with. */
|
|
187
|
+
class Unanswered extends Error {}
|
|
188
|
+
/** The file changed under a run that was already part way through. */
|
|
189
|
+
class Changed extends Error {}
|
|
190
|
+
|
|
191
|
+
interface Ctx {
|
|
192
|
+
runId: string;
|
|
193
|
+
agent: Agent;
|
|
194
|
+
job: Job;
|
|
195
|
+
lines: Line[];
|
|
196
|
+
seq: number;
|
|
197
|
+
cost: number;
|
|
198
|
+
state: Record<string, unknown>;
|
|
199
|
+
/** What the run was started with. Checked once, then never changed. */
|
|
200
|
+
input: Record<string, unknown>;
|
|
201
|
+
parked?: Parked;
|
|
202
|
+
owner: string;
|
|
203
|
+
/** "code" until a model step runs, then whichever model it used. */
|
|
204
|
+
model: string;
|
|
205
|
+
/** The step running right now, while one is. A job pauses between steps, not inside one. */
|
|
206
|
+
inside?: string;
|
|
207
|
+
signal?: AbortSignal;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Start a job from the beginning. */
|
|
211
|
+
export async function work(options: {
|
|
212
|
+
agent: Agent;
|
|
213
|
+
job: Job;
|
|
214
|
+
/** The channel it came in on, like "telegram" or "api". Left out, it is "unknown". */
|
|
215
|
+
source?: string;
|
|
216
|
+
/** What to start it with. Checked against the job's `input` shape first. */
|
|
217
|
+
input?: unknown;
|
|
218
|
+
signal?: AbortSignal;
|
|
219
|
+
}): Promise<Result> {
|
|
220
|
+
const { agent, job } = options;
|
|
221
|
+
if (!job.run) throw new Error(`${agent.name}/${job.id} is a prompt, not code.`);
|
|
222
|
+
|
|
223
|
+
// Before the run exists, so a caller that sent the wrong thing is told so
|
|
224
|
+
// rather than left reading a failed run to find out.
|
|
225
|
+
const input = checkInput(job, options.input);
|
|
226
|
+
|
|
227
|
+
const runId = randomUUID();
|
|
228
|
+
const owner = whoOwns(agent.name);
|
|
229
|
+
const state = starting(job);
|
|
230
|
+
db.prepare(
|
|
231
|
+
`insert into runs (id, agent, started, source, job, model, prompt, kind, owner, state, input)
|
|
232
|
+
values (?, ?, ?, ?, ?, 'code', '', 'job', ?, ?, ?)`,
|
|
233
|
+
).run(
|
|
234
|
+
runId,
|
|
235
|
+
agent.name,
|
|
236
|
+
new Date().toISOString(),
|
|
237
|
+
options.source ?? "unknown",
|
|
238
|
+
job.id,
|
|
239
|
+
owner || null,
|
|
240
|
+
JSON.stringify(state),
|
|
241
|
+
JSON.stringify(input),
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
return drive({
|
|
245
|
+
runId,
|
|
246
|
+
agent,
|
|
247
|
+
job,
|
|
248
|
+
lines: [],
|
|
249
|
+
seq: 0,
|
|
250
|
+
cost: 0,
|
|
251
|
+
state,
|
|
252
|
+
input,
|
|
253
|
+
owner,
|
|
254
|
+
model: "code",
|
|
255
|
+
signal: options.signal,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** What was sent to start a job does not fit the shape that job declares. */
|
|
260
|
+
export class WrongInput extends Error {}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Checks what a job is being started with against its `input` shape, and hands
|
|
264
|
+
* back the parsed values. Throws `WrongInput` when they do not fit.
|
|
265
|
+
*
|
|
266
|
+
* A job that declares no shape takes nothing, so sending it something is a
|
|
267
|
+
* mistake worth saying out loud rather than quietly dropping. A job that does
|
|
268
|
+
* declare one and is started by the clock gets `{}` put through the same
|
|
269
|
+
* check, which is what makes a required field and a cron line an error at the
|
|
270
|
+
* first tick rather than a puzzle later.
|
|
271
|
+
*
|
|
272
|
+
* Called before a run exists, so whoever started it is told rather than left
|
|
273
|
+
* reading a failed run to find out.
|
|
274
|
+
*/
|
|
275
|
+
export function checkInput(job: Job, sent: unknown): Record<string, unknown> {
|
|
276
|
+
if (!job.input) {
|
|
277
|
+
const keys = sent && typeof sent === "object" ? Object.keys(sent as object) : [];
|
|
278
|
+
if (keys.length) {
|
|
279
|
+
throw new WrongInput(
|
|
280
|
+
`${job.agent}/${job.id} does not take anything, so it cannot be started with ${keys.join(", ")}. ` +
|
|
281
|
+
"Give the job an `input` shape if it should.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return {};
|
|
285
|
+
}
|
|
286
|
+
const checked = job.input.safeParse(sent ?? {});
|
|
287
|
+
if (!checked.success) throw new WrongInput(`${job.agent}/${job.id}: ${z.prettifyError(checked.error)}`);
|
|
288
|
+
return checked.data as Record<string, unknown>;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Carry on a job that was waiting for a person. */
|
|
292
|
+
export async function resume(runId: string, agents: Map<string, Agent>, signal?: AbortSignal): Promise<Result> {
|
|
293
|
+
const row = db.prepare("select * from runs where id = ?").get(runId) as Row | undefined;
|
|
294
|
+
if (!row) throw new Error(`There is no run ${JSON.stringify(runId)}.`);
|
|
295
|
+
if (!row.parked) throw new Error(`Run ${JSON.stringify(runId)} is not waiting for anything.`);
|
|
296
|
+
|
|
297
|
+
const agent = agents.get(row.agent);
|
|
298
|
+
if (!agent) throw new Error(`${row.agent} is not an agent here any more, so run ${runId} cannot carry on.`);
|
|
299
|
+
const job = agent.jobs.find((s) => s.id === row.job);
|
|
300
|
+
if (!job?.run) throw new Error(`${row.agent}/${row.job} is not code any more, so run ${runId} cannot carry on.`);
|
|
301
|
+
|
|
302
|
+
return drive({
|
|
303
|
+
runId,
|
|
304
|
+
agent,
|
|
305
|
+
job,
|
|
306
|
+
input: (row.input ? JSON.parse(row.input) : {}) as Record<string, unknown>,
|
|
307
|
+
lines: JSON.parse(row.trace) as Line[],
|
|
308
|
+
seq: 0,
|
|
309
|
+
cost: row.cost,
|
|
310
|
+
state: row.state ? (JSON.parse(row.state) as Record<string, unknown>) : starting(job),
|
|
311
|
+
parked: JSON.parse(row.parked) as Parked,
|
|
312
|
+
owner: row.owner ?? whoOwns(agent.name),
|
|
313
|
+
model: row.model,
|
|
314
|
+
signal,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function drive(ctx: Ctx): Promise<Result> {
|
|
319
|
+
const api: Work = {
|
|
320
|
+
step: (name, fn) => once(ctx, name, "step", async () => ({ value: await fn() })),
|
|
321
|
+
model: (name, options) => modelStep(ctx, name, options),
|
|
322
|
+
agent: ((name: string, options: AgentStep<z.ZodType>) => agentStep(ctx, name, options)) as Work["agent"],
|
|
323
|
+
ask: (name, options) => askStep(ctx, name, options),
|
|
324
|
+
get state() {
|
|
325
|
+
return ctx.state;
|
|
326
|
+
},
|
|
327
|
+
setState: async (next) => {
|
|
328
|
+
ctx.state = { ...ctx.state, ...next };
|
|
329
|
+
save(ctx);
|
|
330
|
+
},
|
|
331
|
+
input: ctx.input,
|
|
332
|
+
owner: ctx.owner,
|
|
333
|
+
agentName: ctx.agent.name,
|
|
334
|
+
memory: ctx.agent.memory.folder,
|
|
335
|
+
runId: ctx.runId,
|
|
336
|
+
signal: ctx.signal,
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
try {
|
|
340
|
+
const value = await ctx.job.run!(api);
|
|
341
|
+
ctx.parked = undefined;
|
|
342
|
+
const reply = typeof value === "string" ? value : JSON.stringify(value ?? { ok: true }, null, 2);
|
|
343
|
+
const summary = summarise(ctx.job, value);
|
|
344
|
+
finish(ctx, reply, summary);
|
|
345
|
+
return { runId: ctx.runId, text: reply, summary, reply: chatReply(ctx.job, value) ?? summary ?? undefined, steps: ctx.lines.length, cost: ctx.cost, parked: false };
|
|
346
|
+
} catch (error) {
|
|
347
|
+
if (error instanceof Waiting) {
|
|
348
|
+
save(ctx);
|
|
349
|
+
return { runId: ctx.runId, text: error.message, steps: ctx.lines.length, cost: ctx.cost, parked: true };
|
|
350
|
+
}
|
|
351
|
+
ctx.parked = undefined;
|
|
352
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
353
|
+
fail(ctx, why);
|
|
354
|
+
if (error instanceof Unanswered || error instanceof Changed) {
|
|
355
|
+
return { runId: ctx.runId, text: why, steps: ctx.lines.length, cost: ctx.cost, parked: false };
|
|
356
|
+
}
|
|
357
|
+
throw error;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* The replay. A step that is already in the record hands back what it returned
|
|
363
|
+
* and does not run. The name is checked as well as the place, because a job
|
|
364
|
+
* that was edited while a run was parked would otherwise hand the wrong answer
|
|
365
|
+
* to the wrong step and look like it worked.
|
|
366
|
+
*/
|
|
367
|
+
async function once<T>(
|
|
368
|
+
ctx: Ctx,
|
|
369
|
+
name: string,
|
|
370
|
+
kind: Line["kind"],
|
|
371
|
+
fn: (charge: (amount: number) => number, calls: Call[]) => Promise<{ value: T; note?: string; prompt?: string }>,
|
|
372
|
+
): Promise<T> {
|
|
373
|
+
const seen = ctx.lines[ctx.seq];
|
|
374
|
+
if (seen) {
|
|
375
|
+
if (seen.name !== name || seen.kind !== kind) {
|
|
376
|
+
throw new Changed(
|
|
377
|
+
`This job changed while the run was waiting: step ${ctx.seq} was ${JSON.stringify(seen.name)} ` +
|
|
378
|
+
`and is now ${JSON.stringify(name)}. Start it again rather than carrying on from the middle.`,
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
ctx.seq++;
|
|
382
|
+
// A step that failed is in the record too, so replay has to fail the same
|
|
383
|
+
// way rather than hand back the nothing it returned. A job that caught it
|
|
384
|
+
// the first time catches it again.
|
|
385
|
+
if (seen.failed !== undefined) throw new Error(seen.failed);
|
|
386
|
+
return seen.result as T;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const began = Date.now();
|
|
390
|
+
let spent = 0;
|
|
391
|
+
/**
|
|
392
|
+
* Charged to the run the moment it is spent, not when the step returns, so a
|
|
393
|
+
* step that fails, or a run cut off part way through one, is still charged
|
|
394
|
+
* for what it used. Returns this step's total.
|
|
395
|
+
*/
|
|
396
|
+
const charge = (amount: number): number => {
|
|
397
|
+
ctx.cost += amount;
|
|
398
|
+
return (spent += amount);
|
|
399
|
+
};
|
|
400
|
+
/** Written as the step goes, for the same reason. */
|
|
401
|
+
const calls: Call[] = [];
|
|
402
|
+
const outer = ctx.inside;
|
|
403
|
+
ctx.inside = name;
|
|
404
|
+
try {
|
|
405
|
+
const { value, note, prompt } = await fn(charge, calls);
|
|
406
|
+
const size = JSON.stringify(value ?? null)?.length ?? 0;
|
|
407
|
+
if (size > MOST) {
|
|
408
|
+
throw new Error(
|
|
409
|
+
`Step ${JSON.stringify(name)} returned ${size} characters, and a step's result has to fit in the run ` +
|
|
410
|
+
`record. Return what the next step needs rather than everything it read.`,
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
ctx.lines.push({
|
|
414
|
+
seq: ctx.seq,
|
|
415
|
+
name,
|
|
416
|
+
kind,
|
|
417
|
+
at: new Date().toISOString(),
|
|
418
|
+
ms: Date.now() - began,
|
|
419
|
+
cost: spent,
|
|
420
|
+
result: value,
|
|
421
|
+
note,
|
|
422
|
+
prompt,
|
|
423
|
+
calls: calls.length > 0 ? calls : undefined,
|
|
424
|
+
});
|
|
425
|
+
ctx.seq++;
|
|
426
|
+
save(ctx);
|
|
427
|
+
return value;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
// A step that failed is still a step that happened. What it spent and what
|
|
430
|
+
// it called are written down before the run gives up, because that is what
|
|
431
|
+
// somebody reading the failure needs.
|
|
432
|
+
ctx.lines.push({
|
|
433
|
+
seq: ctx.seq,
|
|
434
|
+
name,
|
|
435
|
+
kind,
|
|
436
|
+
at: new Date().toISOString(),
|
|
437
|
+
ms: Date.now() - began,
|
|
438
|
+
cost: spent,
|
|
439
|
+
calls: calls.length > 0 ? calls : undefined,
|
|
440
|
+
failed: error instanceof Error ? error.message : String(error),
|
|
441
|
+
});
|
|
442
|
+
ctx.seq++;
|
|
443
|
+
throw error;
|
|
444
|
+
} finally {
|
|
445
|
+
ctx.inside = outer;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** One question, one shape, and the run priced for it. */
|
|
450
|
+
function modelStep<S extends z.ZodType>(ctx: Ctx, name: string, options: ModelStep<S>): Promise<z.infer<S>> {
|
|
451
|
+
return once(ctx, name, "model", async (charge) => {
|
|
452
|
+
const using = options.model ?? ctx.job.model ?? ctx.agent.model;
|
|
453
|
+
const shape = shapeOf(options.output);
|
|
454
|
+
|
|
455
|
+
// The shape goes in the words rather than in a provider flag, so this
|
|
456
|
+
// works the same on every model the gateway can reach.
|
|
457
|
+
const messages: Message[] = [
|
|
458
|
+
{
|
|
459
|
+
role: "system",
|
|
460
|
+
content:
|
|
461
|
+
(options.system ? `${options.system}\n\n` : "") +
|
|
462
|
+
"Answer with JSON and nothing else: no explanation, no code fence. " +
|
|
463
|
+
`It has to fit this shape exactly:\n${JSON.stringify(shape)}`,
|
|
464
|
+
},
|
|
465
|
+
{ role: "user", content: options.prompt },
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
let complaint = "";
|
|
469
|
+
// Twice: a model told exactly what did not fit usually fixes it, and a
|
|
470
|
+
// third go has never been the difference.
|
|
471
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
472
|
+
const answer = await askModel({ model: using, messages, signal: ctx.signal });
|
|
473
|
+
// Charged whether or not it came back usable, which is why this is not
|
|
474
|
+
// left until the end.
|
|
475
|
+
charge(answer.cost);
|
|
476
|
+
ctx.model = using;
|
|
477
|
+
const checked = options.output.safeParse(unfence(answer.text));
|
|
478
|
+
if (checked.success) return { value: checked.data as z.infer<S>, note: using, prompt: options.prompt };
|
|
479
|
+
complaint = checked.error.issues.map((i) => `${i.path.join(".") || "the answer"} ${i.message}`).join("; ");
|
|
480
|
+
messages.push({ role: "assistant", content: answer.text });
|
|
481
|
+
messages.push({ role: "user", content: `That did not fit: ${complaint}. Answer again, JSON only.` });
|
|
482
|
+
}
|
|
483
|
+
throw new Error(`The model step ${JSON.stringify(name)} did not answer in the shape asked for: ${complaint}`);
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/** Ten turns of the loop, unless the step says otherwise. */
|
|
488
|
+
const AGENT_STEPS = 10;
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* The most autonomy a job can hand over, and the least of it that works is the
|
|
492
|
+
* right amount. The model chooses the order and this runs what it asks for, so
|
|
493
|
+
* the job keeps the limits: the tools are what it may do at all, `approve` is
|
|
494
|
+
* which of those calls may run, and `maxSteps` and `budget` are how far it may
|
|
495
|
+
* go before it has to stop.
|
|
496
|
+
*
|
|
497
|
+
* Every tool it ran is written into the run's line, and the whole step is
|
|
498
|
+
* recorded once, so a run that resumes does not live through it twice.
|
|
499
|
+
*/
|
|
500
|
+
function agentStep<S extends z.ZodType>(ctx: Ctx, name: string, options: AgentStep<S>): Promise<unknown> {
|
|
501
|
+
return once<unknown>(ctx, name, "agent", async (charge, calls) => {
|
|
502
|
+
const using = options.model ?? ctx.job.model ?? ctx.agent.model;
|
|
503
|
+
const tools: Tools = Array.isArray(options.tools)
|
|
504
|
+
? Object.fromEntries(options.tools.map((one) => [one.id, one]))
|
|
505
|
+
: options.tools;
|
|
506
|
+
if (Object.keys(tools).length === 0) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
`agent(${JSON.stringify(name)}) was given no tools. An agent step with nothing to call is a model step: use model(...).`,
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
// A budget that is not a number of dollars would let every check below it
|
|
512
|
+
// pass without stopping anything, which is the opposite of asking for one.
|
|
513
|
+
if (options.budget !== undefined && !(options.budget > 0)) {
|
|
514
|
+
throw new Error(
|
|
515
|
+
`agent(${JSON.stringify(name)}) was given a budget of ${JSON.stringify(options.budget)}. ` +
|
|
516
|
+
`A budget is an amount of dollars above zero, or leave it out.`,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const shape = options.output ? shapeOf(options.output) : undefined;
|
|
521
|
+
const messages: Message[] = [
|
|
522
|
+
{
|
|
523
|
+
role: "system",
|
|
524
|
+
content: [
|
|
525
|
+
options.system,
|
|
526
|
+
"You have a goal and some tools. Work out the order yourself: call a tool, read what comes back, " +
|
|
527
|
+
"decide what to do next, and stop when the goal is met. Call nothing you were not given.",
|
|
528
|
+
shape
|
|
529
|
+
? `When you are done, answer with JSON and nothing else, no explanation and no code fence. It has to fit this shape exactly:\n${JSON.stringify(shape)}`
|
|
530
|
+
: "When you are done, say what you found in a few plain sentences.",
|
|
531
|
+
]
|
|
532
|
+
.filter(Boolean)
|
|
533
|
+
.join("\n\n"),
|
|
534
|
+
},
|
|
535
|
+
{ role: "user", content: options.goal },
|
|
536
|
+
];
|
|
537
|
+
|
|
538
|
+
const maxSteps = options.maxSteps ?? AGENT_STEPS;
|
|
539
|
+
let spent = 0;
|
|
540
|
+
let complaint = "";
|
|
541
|
+
|
|
542
|
+
const tooDear = (): Error =>
|
|
543
|
+
new Error(
|
|
544
|
+
`The agent step ${JSON.stringify(name)} spent ${money(spent)} of its ${money(options.budget ?? 0)} budget ` +
|
|
545
|
+
`without finishing. Raise budget, narrow the goal, or do the parts you already know as step calls.`,
|
|
546
|
+
);
|
|
547
|
+
const wrongShape = (): Error =>
|
|
548
|
+
new Error(`The agent step ${JSON.stringify(name)} did not answer in the shape asked for: ${complaint}`);
|
|
549
|
+
|
|
550
|
+
// Twice, and only over the shape: a model told exactly what did not fit
|
|
551
|
+
// usually fixes it, and the tools it already ran are not run again.
|
|
552
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
553
|
+
if (options.budget !== undefined && spent >= options.budget) throw tooDear();
|
|
554
|
+
const done = await loop({
|
|
555
|
+
model: using,
|
|
556
|
+
messages,
|
|
557
|
+
tools,
|
|
558
|
+
maxSteps: attempt === 0 ? maxSteps : 1,
|
|
559
|
+
budget: options.budget === undefined ? undefined : options.budget - spent,
|
|
560
|
+
approve: options.approve,
|
|
561
|
+
signal: ctx.signal,
|
|
562
|
+
// Each turn and each call as it happens, rather than at the end, so a
|
|
563
|
+
// step that fails half way through still says what it spent and ran.
|
|
564
|
+
onStep: (line) => {
|
|
565
|
+
if (line.cost) spent = charge(line.cost);
|
|
566
|
+
if (line.tool) calls.push({ tool: line.tool, args: line.args, result: line.result, ...(line.refused && { refused: true }) });
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
ctx.model = using;
|
|
570
|
+
|
|
571
|
+
if (done.stopped === "budget") throw tooDear();
|
|
572
|
+
if (done.stopped === "steps") {
|
|
573
|
+
if (attempt === 1) throw wrongShape();
|
|
574
|
+
throw new Error(
|
|
575
|
+
`The agent step ${JSON.stringify(name)} ran out of steps after ${maxSteps} without finishing. ` +
|
|
576
|
+
`Raise maxSteps, narrow the goal, or do the parts you already know as step calls.`,
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!options.output) return { value: done.text, note: using, prompt: options.goal };
|
|
581
|
+
|
|
582
|
+
const checked = options.output.safeParse(unfence(done.text));
|
|
583
|
+
if (checked.success) return { value: checked.data as z.infer<S>, note: using, prompt: options.goal };
|
|
584
|
+
complaint = checked.error.issues.map((i) => `${i.path.join(".") || "the answer"} ${i.message}`).join("; ");
|
|
585
|
+
if (attempt === 1) throw wrongShape();
|
|
586
|
+
messages.push({ role: "assistant", content: done.text });
|
|
587
|
+
messages.push({ role: "user", content: `That did not fit: ${complaint}. Answer again, JSON only.` });
|
|
588
|
+
}
|
|
589
|
+
throw new Error(`The agent step ${JSON.stringify(name)} did not finish.`);
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Stop and wait for a person.
|
|
595
|
+
*
|
|
596
|
+
* Parking writes down where the job is and what it asked, so the process can
|
|
597
|
+
* restart while it waits. The answer arrives through whichever channel the
|
|
598
|
+
* address names, and the job then runs again from the top with every finished
|
|
599
|
+
* step handing back what it returned.
|
|
600
|
+
*/
|
|
601
|
+
async function askStep<S extends z.ZodType>(ctx: Ctx, name: string, options: AskStep<S>): Promise<z.infer<S>> {
|
|
602
|
+
if (ctx.inside) {
|
|
603
|
+
throw new Error(
|
|
604
|
+
`ask(${JSON.stringify(name)}) was called inside the step ${JSON.stringify(ctx.inside)}. A job pauses between ` +
|
|
605
|
+
`steps and not inside one, so this would park where that step belongs and run it again on the way back. ` +
|
|
606
|
+
`Ask before the step and hand the answer in.`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
const seen = ctx.lines[ctx.seq];
|
|
610
|
+
if (seen) {
|
|
611
|
+
if (seen.name !== name || seen.kind !== "ask") {
|
|
612
|
+
throw new Changed(
|
|
613
|
+
`This job changed while the run was waiting: step ${ctx.seq} was ${JSON.stringify(seen.name)} ` +
|
|
614
|
+
`and is now ${JSON.stringify(name)}. Start it again rather than carrying on from the middle.`,
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
ctx.seq++;
|
|
618
|
+
return seen.result as z.infer<S>;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
const who = options.who ?? ctx.owner;
|
|
622
|
+
if (!who) {
|
|
623
|
+
throw new Error(
|
|
624
|
+
`ask(${JSON.stringify(name)}) has nobody to ask. Give the agent a channel with a chat in it, or pass who.`,
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const waiting = ctx.parked?.seq === ctx.seq ? ctx.parked : undefined;
|
|
629
|
+
|
|
630
|
+
if (waiting?.late) {
|
|
631
|
+
if ("otherwise" in options) return settle(ctx, name, options.otherwise as z.infer<S>, `${who} did not answer in time`, options.question);
|
|
632
|
+
throw new Unanswered(`Nobody answered ${JSON.stringify(options.question)} within ${options.within ?? WAIT}.`);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (waiting?.reply !== undefined) {
|
|
636
|
+
const understood = understand(waiting.reply, options.answer);
|
|
637
|
+
if (understood.ok) return settle(ctx, name, understood.value as z.infer<S>, `${who} answered`, options.question, waiting.reply);
|
|
638
|
+
|
|
639
|
+
// Not understood: ask again rather than guessing, and give up rather than
|
|
640
|
+
// going round forever.
|
|
641
|
+
const confusions = (waiting.confusions ?? 0) + 1;
|
|
642
|
+
if (confusions > 3) {
|
|
643
|
+
throw new Unanswered(`${who} answered ${JSON.stringify(options.question)} three times and none of it fitted.`);
|
|
644
|
+
}
|
|
645
|
+
ctx.parked = { ...waiting, reply: undefined, confusions };
|
|
646
|
+
save(ctx);
|
|
647
|
+
await deliver(who, `I did not understand that. ${options.question}\n${hint(options.answer)}`, ctx.agent.name, choices(options.answer));
|
|
648
|
+
throw new Waiting(`waiting on ${who}`);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
ctx.parked = {
|
|
652
|
+
seq: ctx.seq,
|
|
653
|
+
name,
|
|
654
|
+
who,
|
|
655
|
+
question: options.question,
|
|
656
|
+
asked: new Date().toISOString(),
|
|
657
|
+
expires: new Date(Date.now() + minutes(options.within ?? WAIT) * 60_000).toISOString(),
|
|
658
|
+
};
|
|
659
|
+
save(ctx);
|
|
660
|
+
await deliver(who, `${options.question}\n${hint(options.answer)}`, ctx.agent.name, choices(options.answer));
|
|
661
|
+
throw new Waiting(`waiting on ${who}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/** Write the answer down as the ask's result and carry on past it. */
|
|
665
|
+
function settle<T>(ctx: Ctx, name: string, value: T, note: string, question: string, reply?: string): T {
|
|
666
|
+
ctx.lines.push({
|
|
667
|
+
seq: ctx.seq,
|
|
668
|
+
name,
|
|
669
|
+
kind: "ask",
|
|
670
|
+
at: new Date().toISOString(),
|
|
671
|
+
ms: 0,
|
|
672
|
+
cost: 0,
|
|
673
|
+
result: value,
|
|
674
|
+
note,
|
|
675
|
+
question,
|
|
676
|
+
reply,
|
|
677
|
+
});
|
|
678
|
+
ctx.seq++;
|
|
679
|
+
ctx.parked = undefined;
|
|
680
|
+
save(ctx);
|
|
681
|
+
return value;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* What a person typed, in the shape the job asked for.
|
|
686
|
+
*
|
|
687
|
+
* A person answers a yes or no question with "yes", not with `true`, so the
|
|
688
|
+
* plain ways of saying it are tried before the schema sees anything.
|
|
689
|
+
*/
|
|
690
|
+
function understand(text: string, schema: z.ZodType): { ok: true; value: unknown } | { ok: false } {
|
|
691
|
+
const trimmed = text.trim();
|
|
692
|
+
const low = trimmed.toLowerCase();
|
|
693
|
+
const tries: unknown[] = [];
|
|
694
|
+
|
|
695
|
+
if (["yes", "y", "yep", "ok", "okay", "sure", "go", "go ahead", "do it", "true"].includes(low)) tries.push(true);
|
|
696
|
+
if (["no", "n", "nope", "stop", "dont", "don't", "no thanks", "false"].includes(low)) tries.push(false);
|
|
697
|
+
if (trimmed !== "" && Number.isFinite(Number(trimmed))) tries.push(Number(trimmed));
|
|
698
|
+
try {
|
|
699
|
+
tries.push(JSON.parse(trimmed));
|
|
700
|
+
} catch {
|
|
701
|
+
// Not JSON, which is the normal case for a person.
|
|
702
|
+
}
|
|
703
|
+
tries.push(trimmed);
|
|
704
|
+
|
|
705
|
+
for (const one of tries) {
|
|
706
|
+
const checked = schema.safeParse(one);
|
|
707
|
+
if (checked.success) return { ok: true, value: checked.data };
|
|
708
|
+
}
|
|
709
|
+
return { ok: false };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** One line telling the person what kind of answer fits. */
|
|
713
|
+
function hint(schema: z.ZodType): string {
|
|
714
|
+
const shape = shapeOf(schema) as { type?: string; enum?: unknown[] };
|
|
715
|
+
if (shape.enum) return `(${shape.enum.join(", ")})`;
|
|
716
|
+
if (shape.type === "boolean") return "(yes or no)";
|
|
717
|
+
if (shape.type === "number" || shape.type === "integer") return "(a number)";
|
|
718
|
+
if (shape.type === "string") return "";
|
|
719
|
+
return `(as JSON: ${JSON.stringify(shape)})`;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** The answers that fit, when they can be listed. Each one is understood back by understand(). */
|
|
723
|
+
function choices(schema: z.ZodType): string[] | undefined {
|
|
724
|
+
const shape = shapeOf(schema) as { type?: string; enum?: unknown[] };
|
|
725
|
+
if (shape.enum && shape.enum.length <= 8) return shape.enum.map(String);
|
|
726
|
+
if (shape.type === "boolean") return ["yes", "no"];
|
|
727
|
+
return undefined;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function shapeOf(schema: z.ZodType): Record<string, unknown> {
|
|
731
|
+
const shape = z.toJSONSchema(schema, { io: "output" }) as Record<string, unknown>;
|
|
732
|
+
delete shape.$schema;
|
|
733
|
+
return shape;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Whatever a model wrapped its JSON in. */
|
|
737
|
+
function unfence(text: string): unknown {
|
|
738
|
+
const inside = text.trim().replace(/^```(?:json)?\s*/i, "").replace(/```$/, "").trim();
|
|
739
|
+
try {
|
|
740
|
+
return JSON.parse(inside);
|
|
741
|
+
} catch {
|
|
742
|
+
// A model that answered a string schema with a bare word still fits.
|
|
743
|
+
return inside;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function minutes(within: string): number {
|
|
748
|
+
const match = within.trim().match(/^(\d+)\s*(m|h|d)$/i);
|
|
749
|
+
if (!match) throw new Error(`${JSON.stringify(within)} is not a length of time. Write it as "30m", "4h" or "2d".`);
|
|
750
|
+
const size = Number(match[1]);
|
|
751
|
+
return match[2].toLowerCase() === "m" ? size : match[2].toLowerCase() === "h" ? size * 60 : size * 1440;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function starting(job: Job): Record<string, unknown> {
|
|
755
|
+
if (!job.state) return {};
|
|
756
|
+
const empty = job.state.safeParse({});
|
|
757
|
+
return empty.success ? (empty.data as Record<string, unknown>) : {};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* A run waiting on a person: what it asked, who it asked, and when the wait
|
|
762
|
+
* runs out. Read by the page and by the sweep.
|
|
763
|
+
*/
|
|
764
|
+
export interface ParkedRun {
|
|
765
|
+
id: string;
|
|
766
|
+
agent: string;
|
|
767
|
+
job: string;
|
|
768
|
+
who: string;
|
|
769
|
+
question: string;
|
|
770
|
+
asked: string;
|
|
771
|
+
expires: string;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** Every run waiting on a person right now. */
|
|
775
|
+
export function parkedRuns(): ParkedRun[] {
|
|
776
|
+
const rows = db.prepare("select id, agent, job, parked from runs where parked is not null").all() as {
|
|
777
|
+
id: string;
|
|
778
|
+
agent: string;
|
|
779
|
+
job: string;
|
|
780
|
+
parked: string;
|
|
781
|
+
}[];
|
|
782
|
+
return rows.map((row) => {
|
|
783
|
+
const parked = JSON.parse(row.parked) as Parked;
|
|
784
|
+
return {
|
|
785
|
+
id: row.id,
|
|
786
|
+
agent: row.agent,
|
|
787
|
+
job: row.job,
|
|
788
|
+
who: parked.who,
|
|
789
|
+
question: parked.question,
|
|
790
|
+
asked: parked.asked,
|
|
791
|
+
expires: parked.expires,
|
|
792
|
+
};
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** The oldest question this person has not answered, if there is one. Only that agent's, when it says which. */
|
|
797
|
+
export function waitingOn(who: string, agent = ""): ParkedRun | undefined {
|
|
798
|
+
return parkedRuns()
|
|
799
|
+
.filter((one) => one.who === who && (!agent || one.agent === agent))
|
|
800
|
+
.sort((a, b) => a.asked.localeCompare(b.asked))[0];
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/** Is this job already waiting on somebody? Then it does not start again. */
|
|
804
|
+
export function waitingFor(agent: string, job: string): boolean {
|
|
805
|
+
const row = db
|
|
806
|
+
.prepare("select 1 from runs where agent = ? and job = ? and parked is not null limit 1")
|
|
807
|
+
.get(agent, job);
|
|
808
|
+
return row !== undefined;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
/** Hand a person's answer to the run that was waiting for it. */
|
|
812
|
+
export async function answer(runId: string, reply: string, agents: Map<string, Agent>): Promise<Result> {
|
|
813
|
+
const row = db.prepare("select parked from runs where id = ?").get(runId) as { parked?: string } | undefined;
|
|
814
|
+
if (!row?.parked) throw new Error(`Run ${JSON.stringify(runId)} is not waiting for an answer.`);
|
|
815
|
+
const parked = { ...(JSON.parse(row.parked) as Parked), reply };
|
|
816
|
+
db.prepare("update runs set parked = ? where id = ?").run(JSON.stringify(parked), runId);
|
|
817
|
+
return resume(runId, agents);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Give up on questions nobody answered. Called on the clock's tick.
|
|
822
|
+
*
|
|
823
|
+
* A parked run holds its job, so a question left alone is a job that
|
|
824
|
+
* never runs again. This is what stops that.
|
|
825
|
+
*/
|
|
826
|
+
export async function sweep(agents: Map<string, Agent>): Promise<void> {
|
|
827
|
+
const now = new Date().toISOString();
|
|
828
|
+
for (const one of parkedRuns()) {
|
|
829
|
+
if (one.expires > now) continue;
|
|
830
|
+
const row = db.prepare("select parked from runs where id = ?").get(one.id) as { parked?: string } | undefined;
|
|
831
|
+
if (!row?.parked) continue;
|
|
832
|
+
const parked = { ...(JSON.parse(row.parked) as Parked), late: true };
|
|
833
|
+
db.prepare("update runs set parked = ? where id = ?").run(JSON.stringify(parked), one.id);
|
|
834
|
+
await resume(one.id, agents).catch((error: unknown) => {
|
|
835
|
+
console.error(`${one.agent}/${one.job}: giving up on an unanswered question failed`, error);
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
interface Row {
|
|
841
|
+
agent: string;
|
|
842
|
+
job: string;
|
|
843
|
+
model: string;
|
|
844
|
+
cost: number;
|
|
845
|
+
trace: string;
|
|
846
|
+
state?: string;
|
|
847
|
+
input?: string;
|
|
848
|
+
parked?: string;
|
|
849
|
+
owner?: string;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function save(ctx: Ctx): void {
|
|
853
|
+
db.prepare("update runs set steps = ?, cost = ?, trace = ?, state = ?, parked = ?, model = ? where id = ?").run(
|
|
854
|
+
ctx.lines.length,
|
|
855
|
+
ctx.cost,
|
|
856
|
+
JSON.stringify(ctx.lines),
|
|
857
|
+
JSON.stringify(ctx.state),
|
|
858
|
+
ctx.parked ? JSON.stringify(ctx.parked) : null,
|
|
859
|
+
ctx.model,
|
|
860
|
+
ctx.runId,
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function finish(ctx: Ctx, reply: string, summary: string | null): void {
|
|
865
|
+
save(ctx);
|
|
866
|
+
db.prepare("update runs set finished = ?, reply = ?, summary = ? where id = ?")
|
|
867
|
+
.run(new Date().toISOString(), reply, summary, ctx.runId);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* The job's own line, or the start of the string it returned. A summary that
|
|
872
|
+
* throws costs the run its line and nothing else: the work is already done.
|
|
873
|
+
*/
|
|
874
|
+
/** The job's own reply for a chat, or nothing when it has none or it throws. */
|
|
875
|
+
function chatReply(job: Job, value: unknown): string | undefined {
|
|
876
|
+
try {
|
|
877
|
+
return job.reply?.(value) || undefined;
|
|
878
|
+
} catch (error) {
|
|
879
|
+
console.error(`${job.agent}/${job.id}: its reply failed`, error);
|
|
880
|
+
return undefined;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
function summarise(job: Job, value: unknown): string | null {
|
|
885
|
+
try {
|
|
886
|
+
if (job.summary) return oneLineSummary(job.summary(value));
|
|
887
|
+
} catch (error) {
|
|
888
|
+
return `(its summary failed: ${error instanceof Error ? error.message : String(error)})`;
|
|
889
|
+
}
|
|
890
|
+
return typeof value === "string" ? oneLineSummary(value) : null;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function fail(ctx: Ctx, why: string): void {
|
|
894
|
+
save(ctx);
|
|
895
|
+
db.prepare("update runs set finished = ?, error = ? where id = ?").run(new Date().toISOString(), why, ctx.runId);
|
|
896
|
+
}
|