@agentswf/engine 0.0.0-stage → 0.1.0-beta.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/package.json +39 -4
- package/src/accounting/format.ts +252 -0
- package/src/accounting/plans.ts +38 -0
- package/src/accounting/prices.ts +98 -0
- package/src/accounting/summary.ts +385 -0
- package/src/accounting/tokens.ts +34 -0
- package/src/agent-launcher.ts +127 -0
- package/src/allowance-command.ts +105 -0
- package/src/attempt-ending.ts +198 -0
- package/src/attempt-output.ts +132 -0
- package/src/attempt-view.ts +362 -0
- package/src/canonical-json.ts +12 -0
- package/src/control-plane.ts +424 -0
- package/src/deadlines.ts +106 -0
- package/src/decisions/directory.ts +432 -0
- package/src/decisions/fake.ts +60 -0
- package/src/decisions/openrouter.ts +161 -0
- package/src/decisions/seam.ts +70 -0
- package/src/display-path.ts +16 -0
- package/src/duration.ts +12 -0
- package/src/errors.ts +4 -0
- package/src/files.ts +78 -0
- package/src/herdr-placement.ts +182 -0
- package/src/herdr-run-session.ts +530 -0
- package/src/herdr-workspace-marks.ts +194 -0
- package/src/here.ts +289 -0
- package/src/index.ts +5 -0
- package/src/jsonl.ts +74 -0
- package/src/logs-command.ts +605 -0
- package/src/machine.ts +27 -0
- package/src/message-operation.ts +245 -0
- package/src/messaging/limits.ts +35 -0
- package/src/messaging/messaging.ts +1303 -0
- package/src/messaging/routes.ts +79 -0
- package/src/messaging/text.ts +251 -0
- package/src/operation-events.ts +182 -0
- package/src/operation-liveness.ts +674 -0
- package/src/operator-aliases.ts +10 -0
- package/src/operator-cli.ts +569 -0
- package/src/operator-runtime.ts +503 -0
- package/src/pane-layout.ts +125 -0
- package/src/progress-view.ts +568 -0
- package/src/result-slots.ts +325 -0
- package/src/result-validation.ts +47 -0
- package/src/run-command.ts +241 -0
- package/src/run-commands.ts +215 -0
- package/src/run-event-log.ts +72 -0
- package/src/run-prepare.ts +178 -0
- package/src/run-progress.ts +137 -0
- package/src/run-read.ts +425 -0
- package/src/run-state.ts +120 -0
- package/src/run-usage.ts +393 -0
- package/src/run-view.ts +225 -0
- package/src/runs-command.ts +356 -0
- package/src/runs.ts +645 -0
- package/src/sandbox-homes.ts +211 -0
- package/src/sandboxes.ts +516 -0
- package/src/skills/fetch.ts +305 -0
- package/src/skills/run-skills.ts +181 -0
- package/src/skills/sources.ts +87 -0
- package/src/skills/tree.ts +60 -0
- package/src/stage-ledger.ts +535 -0
- package/src/stage-plan.ts +127 -0
- package/src/stage-schema.ts +88 -0
- package/src/stopped.ts +36 -0
- package/src/test-command.ts +110 -0
- package/src/testing/operation-events.ts +89 -0
- package/src/testing.ts +164 -0
- package/src/workflow-loader.ts +113 -0
- package/src/workflow-runner.ts +3665 -0
- package/src/workflow-test-preload.ts +13 -0
- package/src/workflow-testing/decisions.ts +129 -0
- package/src/workflow-testing/host.ts +811 -0
- package/src/workflow-testing/index.ts +420 -0
- package/src/workflow-testing/script.ts +447 -0
- package/src/workflow-testing/workflow-testing.typecheck.ts +47 -0
- package/README.md +0 -3
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
import type { ReplyCode, SendCode } from "@agentswf/contract/wire";
|
|
2
|
+
import type { Effort, JsonValue, OperationCause, OutputSchema } from "@agentswf/contract/workflow";
|
|
3
|
+
import type Type from "typebox";
|
|
4
|
+
import { canonical } from "../canonical-json";
|
|
5
|
+
import { messageOf } from "../errors";
|
|
6
|
+
|
|
7
|
+
/** One turn of a scripted agent, as the workflow wrote it. */
|
|
8
|
+
export type Turn = {
|
|
9
|
+
/** The agent's key. */
|
|
10
|
+
agent: string;
|
|
11
|
+
/** 1-based in the agent's session. A nudge, or a delivery continuing the turn, shares its number. */
|
|
12
|
+
n: number;
|
|
13
|
+
nudge: boolean;
|
|
14
|
+
prompt: string;
|
|
15
|
+
/** Absent for a turn that asks for text. */
|
|
16
|
+
schema?: OutputSchema;
|
|
17
|
+
label?: string;
|
|
18
|
+
/** The workflow stage it runs in; absent between stages. */
|
|
19
|
+
stage?: string;
|
|
20
|
+
cwd: string;
|
|
21
|
+
/** The model the agent ran this turn at: as opened, then as its last `set` left it. */
|
|
22
|
+
model: string;
|
|
23
|
+
/** Its effort, likewise; absent, the harness's default. */
|
|
24
|
+
effort?: Effort;
|
|
25
|
+
/** Who woke the operation: absent for the workflow's own `run`. */
|
|
26
|
+
cause?: OperationCause;
|
|
27
|
+
/**
|
|
28
|
+
* The messages the agent was shown in this turn, in order; one delivered while the script awaits
|
|
29
|
+
* a reply is added, the reply included.
|
|
30
|
+
*/
|
|
31
|
+
readonly messages: readonly Message[];
|
|
32
|
+
/**
|
|
33
|
+
* `wf send`: resolves once admitted, `sent`; or, with `awaitReply`, with the reply, which arrives
|
|
34
|
+
* in a later turn of the same call. Returning a result without awaiting it drops the wait, and it
|
|
35
|
+
* resolves `failed` with reason `dropped`. While a reply is awaited the script awaits only the
|
|
36
|
+
* host's calls: once it is still, its turn ends.
|
|
37
|
+
*/
|
|
38
|
+
send(peer: string, text: string, options?: SendOptions): Promise<MessageOutcome>;
|
|
39
|
+
/** `wf reply`: answers the question `peer` asked. A result is refused while one is owed. */
|
|
40
|
+
reply(peer: string, text: string, options?: SendOptions): Promise<MessageOutcome>;
|
|
41
|
+
/** Fires when the engine cancels the turn. */
|
|
42
|
+
signal: AbortSignal;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** A message as the agent was shown it. */
|
|
46
|
+
export type Message = {
|
|
47
|
+
from: string;
|
|
48
|
+
text: string;
|
|
49
|
+
/** A question: the agent owes `from` a reply. */
|
|
50
|
+
asks?: true;
|
|
51
|
+
/** The reply to a question this agent asked. */
|
|
52
|
+
answers?: true;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type SendOptions = { awaitReply?: boolean; timeoutMs?: number };
|
|
56
|
+
|
|
57
|
+
/** How a send or reply ended; a refusal is a value to test, not a throw. */
|
|
58
|
+
export type MessageOutcome =
|
|
59
|
+
| { kind: "sent" }
|
|
60
|
+
| { kind: "replied"; text: string }
|
|
61
|
+
| { kind: "failed"; reason: string }
|
|
62
|
+
| { kind: "refused"; code: SendCode | ReplyCode };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A message that woke the agent, and what its script may do in that turn: `reply` and `send` as
|
|
66
|
+
* the turn's own, or end it with `silent` or `fail`. Returning ends the turn as `silent` does.
|
|
67
|
+
*/
|
|
68
|
+
export type ReceivedMessage = Message & {
|
|
69
|
+
/** The turn it woke: whether it is a reminder (`nudge`), and every message shown in it. */
|
|
70
|
+
turn: Turn;
|
|
71
|
+
reply(text: string, options?: SendOptions): Promise<MessageOutcome>;
|
|
72
|
+
send(peer: string, text: string, options?: SendOptions): Promise<MessageOutcome>;
|
|
73
|
+
/** Ends the turn without a reply; one owed is reminded once, then fails. */
|
|
74
|
+
silent(): Reply;
|
|
75
|
+
/** The harness failed: the message operation fails, and the agent stops. */
|
|
76
|
+
fail(reason?: string): Reply;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A message operation's script: called with the message that woke the agent, and again with the
|
|
81
|
+
* next one when held messages continue the operation, or with the same one, `turn.nudge` set, when
|
|
82
|
+
* it is reminded of a reply it owes.
|
|
83
|
+
*/
|
|
84
|
+
export type MessageScript = (
|
|
85
|
+
message: ReceivedMessage,
|
|
86
|
+
) => Reply | MessageOutcome | undefined | Promise<Reply | MessageOutcome | undefined>;
|
|
87
|
+
|
|
88
|
+
const ENTRY: unique symbol = Symbol("agentswf.script-entry");
|
|
89
|
+
|
|
90
|
+
type Respond = (turn: Turn) => JsonValue | Reply | Promise<JsonValue | Reply>;
|
|
91
|
+
|
|
92
|
+
/** An answer to a turn that asks for its schema, or for text. Made by `answer`. */
|
|
93
|
+
export type Answer = {
|
|
94
|
+
readonly [ENTRY]: { kind: "answer"; schema: OutputSchema | undefined; respond: Respond };
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
type Ending =
|
|
98
|
+
| { kind: "waiting"; reason: string; timeoutMs?: number }
|
|
99
|
+
| { kind: "silent" }
|
|
100
|
+
| { kind: "blocked" | "failed" | "timed-out"; reason: string }
|
|
101
|
+
| { kind: "needs-login"; provider?: string }
|
|
102
|
+
| { kind: "hang" }
|
|
103
|
+
| { kind: "interrupted" };
|
|
104
|
+
|
|
105
|
+
/** A turn ended without an answer. Made by `reply`. */
|
|
106
|
+
export type Reply = { readonly [ENTRY]: { kind: "reply"; ending: Ending } };
|
|
107
|
+
|
|
108
|
+
/** Every turn the agent is asked, alike; or a list, one entry per turn in order. */
|
|
109
|
+
export type Script = Answer | Reply | readonly (Answer | Reply)[];
|
|
110
|
+
|
|
111
|
+
/** What a schema's answer is: the carrier the author surface types with, else TypeBox's `Static`. */
|
|
112
|
+
export type AnswerOf<S> = S extends { readonly "~output"?: infer O }
|
|
113
|
+
? unknown extends O
|
|
114
|
+
? StaticOf<S>
|
|
115
|
+
: Exclude<O, undefined>
|
|
116
|
+
: StaticOf<S>;
|
|
117
|
+
type StaticOf<S> = S extends Type.TSchema ? Type.Static<S> : JsonValue;
|
|
118
|
+
|
|
119
|
+
type Answering<A> = A | ((turn: Turn) => A | Reply | Promise<A | Reply>);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* An answer to a turn that asks for `schema`: a value of its type, or a function of the turn that
|
|
123
|
+
* returns one or a `reply`. A function that returns a reply is asked again for the nudge, with
|
|
124
|
+
* `turn.nudge` set.
|
|
125
|
+
*/
|
|
126
|
+
export function answer<const S extends OutputSchema>(
|
|
127
|
+
schema: S,
|
|
128
|
+
value: NoInfer<Answering<AnswerOf<S>>>,
|
|
129
|
+
): Answer;
|
|
130
|
+
/** An answer to a turn that asks for no schema: text, or a function of the turn. */
|
|
131
|
+
export function answer(text: Answering<string>): Answer;
|
|
132
|
+
export function answer(...args: [unknown] | [unknown, unknown]): Answer {
|
|
133
|
+
const [schema, given] = args.length === 1 ? [undefined, args[0]] : args;
|
|
134
|
+
const respond: Respond =
|
|
135
|
+
typeof given === "function" ? (given as Respond) : () => given as JsonValue;
|
|
136
|
+
return { [ENTRY]: { kind: "answer", schema: schema as OutputSchema | undefined, respond } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const ending = (value: Ending): Reply => ({ [ENTRY]: { kind: "reply", ending: value } });
|
|
140
|
+
|
|
141
|
+
/** Ends a turn without an answer. A reply answers no question, so it takes no schema. */
|
|
142
|
+
export const reply = {
|
|
143
|
+
/** Reports a cooperative wait; later check-ins invoke the same answer function again. */
|
|
144
|
+
waiting: (reason: string, timeoutMs?: number): Reply =>
|
|
145
|
+
ending({ kind: "waiting", reason, ...(timeoutMs === undefined ? {} : { timeoutMs }) }),
|
|
146
|
+
/** Ends without a result; recovery follows the placement's production support. */
|
|
147
|
+
silent: (): Reply => ending({ kind: "silent" }),
|
|
148
|
+
/** The agent is stuck, as on a permission prompt. */
|
|
149
|
+
blocked: (reason = "waiting on a permission prompt"): Reply =>
|
|
150
|
+
ending({ kind: "blocked", reason }),
|
|
151
|
+
/** The harness failed. */
|
|
152
|
+
failed: (reason = "harness crashed"): Reply => ending({ kind: "failed", reason }),
|
|
153
|
+
/**
|
|
154
|
+
* The agent's harness cannot sign in, as with an expired login: the turn fails with `login` set,
|
|
155
|
+
* naming the agent's harness, `provider` where it logs in to several, and what to run.
|
|
156
|
+
*/
|
|
157
|
+
needsLogin: (provider?: string): Reply =>
|
|
158
|
+
ending({ kind: "needs-login", ...(provider === undefined ? {} : { provider }) }),
|
|
159
|
+
/** The harness reported the turn timed out; it ends at once. */
|
|
160
|
+
timedOut: (reason = "turn timed out"): Reply => ending({ kind: "timed-out", reason }),
|
|
161
|
+
/** Holds the turn until the engine cancels it. */
|
|
162
|
+
hang: (): Reply => ending({ kind: "hang" }),
|
|
163
|
+
/**
|
|
164
|
+
* The operator stopped the turn in their pane, as only the calling session's can be: it settles
|
|
165
|
+
* `cancelled`, is never nudged, and the session takes the next turn.
|
|
166
|
+
*/
|
|
167
|
+
interrupted: (): Reply => ending({ kind: "interrupted" }),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* How a turn ended, as `run.turns` keeps it: `awaiting-reply` when its script awaits a reply, which
|
|
172
|
+
* a later turn of the same call resumes it with.
|
|
173
|
+
*/
|
|
174
|
+
export type TurnOutcome = "answered" | Ending["kind"] | "cancelled" | "awaiting-reply";
|
|
175
|
+
|
|
176
|
+
export type Step =
|
|
177
|
+
| { kind: "answer"; value: JsonValue }
|
|
178
|
+
| { kind: "reply"; ending: Ending }
|
|
179
|
+
/** The script cannot meet this turn: the test's mistake, not the workflow's. */
|
|
180
|
+
| { kind: "script-error"; message: string };
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Each agent's entries by key or pattern: one for every use, or a list walked in order, one per
|
|
184
|
+
* use, that ends the test if run past. An exact key wins over a pattern; two patterns matching one
|
|
185
|
+
* key is the test's mistake.
|
|
186
|
+
*/
|
|
187
|
+
class Lists<E> {
|
|
188
|
+
readonly #lists: Readonly<Record<string, E | readonly E[]>>;
|
|
189
|
+
/** The furthest use each agent reached, by the key or pattern that met it. */
|
|
190
|
+
readonly #reached = new Map<string, { pattern: string; n: number }>();
|
|
191
|
+
readonly #of: Of;
|
|
192
|
+
|
|
193
|
+
constructor(
|
|
194
|
+
lists: Readonly<Record<string, E | readonly E[]>>,
|
|
195
|
+
of: Of,
|
|
196
|
+
valid: (entry: unknown) => boolean,
|
|
197
|
+
) {
|
|
198
|
+
this.#of = of;
|
|
199
|
+
for (const [key, list] of Object.entries(lists)) {
|
|
200
|
+
if (Array.isArray(list) && list.length === 0) {
|
|
201
|
+
throw new TypeError(
|
|
202
|
+
`${of.option}["${key}"] is an empty list; a list has an entry per ${of.noun}`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
for (const entry of Array.isArray(list) ? list : [list]) {
|
|
206
|
+
if (!valid(entry)) {
|
|
207
|
+
throw new TypeError(
|
|
208
|
+
`${of.option}["${key}"] must be ${of.expected} or a list of them, not ${describe(entry)}`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
this.#lists = lists;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Whether any entry, by key or pattern, is for this agent. */
|
|
217
|
+
has(agent: string): boolean {
|
|
218
|
+
return !("why" in findByKey(this.#lists, agent, "agent"));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The agent's entry for its `n`th use, or why there is none. */
|
|
222
|
+
protected entry(agent: string, n: number): { entry: E } | { why: string } {
|
|
223
|
+
const found = findByKey(this.#lists, agent, "agent");
|
|
224
|
+
if ("why" in found) return found;
|
|
225
|
+
const { pattern, value: list } = found;
|
|
226
|
+
const entry = isList(list) ? list[n - 1] : list;
|
|
227
|
+
if (entry === undefined) {
|
|
228
|
+
return {
|
|
229
|
+
why: `its ${this.#of.whose}${named(pattern, agent)} has ${count(list, this.#of.noun)}`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const reached = this.#reached.get(agent);
|
|
233
|
+
this.#reached.set(agent, { pattern, n: Math.max(reached?.n ?? 0, n) });
|
|
234
|
+
return { entry };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** What a list scripted and no use reached. */
|
|
238
|
+
leftovers(): string[] {
|
|
239
|
+
const { noun, whose } = this.#of;
|
|
240
|
+
const problems: string[] = [];
|
|
241
|
+
for (const [key, { pattern, n }] of this.#reached) {
|
|
242
|
+
const list = this.#lists[pattern]!;
|
|
243
|
+
if (isList(list) && n < list.length) {
|
|
244
|
+
problems.push(
|
|
245
|
+
`agent "${key}" was asked ${count(n, noun)}; its ${whose}${named(pattern, key)} has ${list.length}`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const met = new Set([...this.#reached.values()].map(({ pattern }) => pattern));
|
|
250
|
+
for (const [pattern, list] of Object.entries(this.#lists)) {
|
|
251
|
+
if (isList(list) && !met.has(pattern)) {
|
|
252
|
+
const who = pattern.includes("*")
|
|
253
|
+
? `no agent matching "${pattern}" was asked`
|
|
254
|
+
: `agent "${pattern}" was never asked`;
|
|
255
|
+
problems.push(`${who}; its ${whose} has ${count(list, noun)}`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
return problems;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** What a list's entries are for, as its messages name them. */
|
|
263
|
+
type Of = { noun: string; option: string; whose: string; expected: string };
|
|
264
|
+
|
|
265
|
+
/** Each agent's turn scripts, or its compactions'. */
|
|
266
|
+
export class Scripts extends Lists<Answer | Reply> {
|
|
267
|
+
readonly #noun: string;
|
|
268
|
+
|
|
269
|
+
constructor(
|
|
270
|
+
scripts: Readonly<Record<string, Script>> = {},
|
|
271
|
+
of: { noun: "turn" | "compaction"; option: string } = { noun: "turn", option: "agents" },
|
|
272
|
+
) {
|
|
273
|
+
super(
|
|
274
|
+
scripts,
|
|
275
|
+
{
|
|
276
|
+
...of,
|
|
277
|
+
whose: of.noun === "turn" ? "script" : `${of.noun} script`,
|
|
278
|
+
expected: "answer(…), reply.…()",
|
|
279
|
+
},
|
|
280
|
+
isEntry,
|
|
281
|
+
);
|
|
282
|
+
this.#noun = of.noun;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `index` is the turn's place among the workflow's turns of this agent, not the session's. */
|
|
286
|
+
async step(turn: Turn, index = turn.n): Promise<Step> {
|
|
287
|
+
const found = this.entry(turn.agent, index);
|
|
288
|
+
if ("why" in found) return this.error(turn, found.why);
|
|
289
|
+
const meant = found.entry[ENTRY];
|
|
290
|
+
if (meant.kind === "reply") return { kind: "reply", ending: meant.ending };
|
|
291
|
+
if (!sameSchema(meant.schema, turn.schema)) {
|
|
292
|
+
return this.error(
|
|
293
|
+
turn,
|
|
294
|
+
`it asks for ${describeSchema(turn.schema)}; its script answers ${describeSchema(meant.schema)}`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
let out: JsonValue | Reply | undefined;
|
|
298
|
+
try {
|
|
299
|
+
out = await meant.respond(turn);
|
|
300
|
+
} catch (error) {
|
|
301
|
+
return this.error(turn, `its script threw: ${messageOf(error)}`);
|
|
302
|
+
}
|
|
303
|
+
if (out === undefined) return this.error(turn, "its script returned nothing");
|
|
304
|
+
if (isEntry(out)) {
|
|
305
|
+
const given = out[ENTRY];
|
|
306
|
+
if (given.kind === "reply") return { kind: "reply", ending: given.ending };
|
|
307
|
+
return this.error(turn, "its script returned answer(…); a function returns the value itself");
|
|
308
|
+
}
|
|
309
|
+
return { kind: "answer", value: out };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
private error(turn: Turn, why: string): Step {
|
|
313
|
+
return {
|
|
314
|
+
kind: "script-error",
|
|
315
|
+
message: `agent "${turn.agent}" ${this.#noun} ${turn.n}${turn.nudge ? " (nudge)" : ""}: ${why}. It was asked: ${firstLine(turn.prompt)}`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Each agent's message scripts: one for all its message operations, or a list, one per operation. */
|
|
321
|
+
export class MessageScripts extends Lists<MessageScript> {
|
|
322
|
+
constructor(scripts: Readonly<Record<string, MessageScript | readonly MessageScript[]>> = {}) {
|
|
323
|
+
super(
|
|
324
|
+
scripts,
|
|
325
|
+
{
|
|
326
|
+
noun: "message operation",
|
|
327
|
+
option: "messages",
|
|
328
|
+
whose: "message script",
|
|
329
|
+
expected: "a function of the message",
|
|
330
|
+
},
|
|
331
|
+
(entry) => typeof entry === "function",
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Runs the script for the agent's `index`th message operation with `message`; what it returns
|
|
337
|
+
* ends the turn, as `silent` unless it is a `reply`.
|
|
338
|
+
*/
|
|
339
|
+
async step(message: ReceivedMessage, index: number): Promise<Step> {
|
|
340
|
+
const { turn } = message;
|
|
341
|
+
const where = `agent "${turn.agent}" turn ${turn.n}${turn.nudge ? " (reminder)" : ""}, woken by ${message.from}`;
|
|
342
|
+
const found = this.entry(turn.agent, index);
|
|
343
|
+
if ("why" in found) {
|
|
344
|
+
return {
|
|
345
|
+
kind: "script-error",
|
|
346
|
+
message: `${where}: ${found.why === "no agent is scripted" ? "no agent has a message script" : found.why}`,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
let out: unknown;
|
|
350
|
+
try {
|
|
351
|
+
out = await found.entry(message);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return {
|
|
354
|
+
kind: "script-error",
|
|
355
|
+
message: `${where}: its message script threw: ${messageOf(error)}`,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
if (isEntry(out)) {
|
|
359
|
+
const given = out[ENTRY];
|
|
360
|
+
if (given.kind === "reply") return { kind: "reply", ending: given.ending };
|
|
361
|
+
return {
|
|
362
|
+
kind: "script-error",
|
|
363
|
+
message: `${where}: a message script ends a turn, it answers no call`,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
return { kind: "reply", ending: { kind: "silent" } };
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** A script by key: the exact key, else the one pattern that matches it. */
|
|
371
|
+
export function findByKey<T>(
|
|
372
|
+
scripts: Readonly<Record<string, T>>,
|
|
373
|
+
key: string,
|
|
374
|
+
what: "agent" | "decision",
|
|
375
|
+
): { pattern: string; value: T } | { why: string } {
|
|
376
|
+
if (Object.hasOwn(scripts, key)) return { pattern: key, value: scripts[key]! };
|
|
377
|
+
const matching = Object.keys(scripts).filter(
|
|
378
|
+
(pattern) => pattern.includes("*") && globMatches(pattern, key),
|
|
379
|
+
);
|
|
380
|
+
if (matching.length === 1) return { pattern: matching[0]!, value: scripts[matching[0]!]! };
|
|
381
|
+
const scripted = Object.keys(scripts);
|
|
382
|
+
return {
|
|
383
|
+
why:
|
|
384
|
+
matching.length > 1
|
|
385
|
+
? `it matches ${matching.map(quote).join(" and ")}; give it a script of its own`
|
|
386
|
+
: scripted.length === 0
|
|
387
|
+
? `no ${what} is scripted`
|
|
388
|
+
: `no script for it; scripted: ${scripted.map(quote).join(", ")}`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function isEntry(value: unknown): value is Answer | Reply {
|
|
393
|
+
return typeof value === "object" && value !== null && ENTRY in value;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function isList<E>(list: E | readonly E[]): list is readonly E[] {
|
|
397
|
+
return Array.isArray(list);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function named(pattern: string, key: string): string {
|
|
401
|
+
return pattern === key ? "" : ` (${quote(pattern)})`;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function describe(entry: unknown): string {
|
|
405
|
+
return typeof entry === "function" ? "a function" : JSON.stringify(entry);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function quote(key: string): string {
|
|
409
|
+
return `"${key}"`;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function count(of: number | unknown, noun: string): string {
|
|
413
|
+
const n = typeof of === "number" ? of : Array.isArray(of) ? of.length : 1;
|
|
414
|
+
return `${n} ${noun}${n === 1 ? "" : "s"}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function globMatches(pattern: string, key: string): boolean {
|
|
418
|
+
const escaped = pattern.split("*").map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"));
|
|
419
|
+
return new RegExp(`^${escaped.join(".*")}$`).test(key);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function firstLine(text: string): string {
|
|
423
|
+
const line = text.split("\n", 1)[0]!;
|
|
424
|
+
return line.length > 100 ? `${line.slice(0, 100)}…` : line;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Equal as JSON Schema: object keys and `required` in any order, so a schema built by another
|
|
429
|
+
* call, with its properties in another order, matches.
|
|
430
|
+
*/
|
|
431
|
+
function sameSchema(a: OutputSchema | undefined, b: OutputSchema | undefined): boolean {
|
|
432
|
+
if (a === undefined || b === undefined) return a === b;
|
|
433
|
+
return canonicalSchema(a) === canonicalSchema(b);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function canonicalSchema(schema: OutputSchema): string {
|
|
437
|
+
const sortedRequired = JSON.stringify(schema, (key, value: unknown) =>
|
|
438
|
+
key === "required" && Array.isArray(value) ? [...value].sort() : value,
|
|
439
|
+
);
|
|
440
|
+
return JSON.stringify(canonical(JSON.parse(sortedRequired)));
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function describeSchema(schema: OutputSchema | undefined): string {
|
|
444
|
+
if (schema === undefined) return "text";
|
|
445
|
+
const text = canonicalSchema(schema);
|
|
446
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
447
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Checked by `tsc --noEmit`, never run: each wrong answer is refused where it is written.
|
|
2
|
+
import type { OutputSchema } from "@agentswf/contract/workflow";
|
|
3
|
+
import Type from "typebox";
|
|
4
|
+
import { answer, reply, type Script } from ".";
|
|
5
|
+
|
|
6
|
+
const VERDICT = Type.Union([
|
|
7
|
+
Type.Object({ kind: Type.Literal("ready"), summary: Type.String() }),
|
|
8
|
+
Type.Object({ kind: Type.Literal("changes-requested"), feedback: Type.Array(Type.String()) }),
|
|
9
|
+
]);
|
|
10
|
+
/** A schema from another library, typed by the author surface's carrier. */
|
|
11
|
+
const CARRIED = { type: "object" } as OutputSchema<{ count: number }>;
|
|
12
|
+
|
|
13
|
+
export const valid: Script[] = [
|
|
14
|
+
answer(VERDICT, { kind: "ready", summary: "ok" }),
|
|
15
|
+
answer(VERDICT, (turn) =>
|
|
16
|
+
turn.n === 1
|
|
17
|
+
? { kind: "changes-requested", feedback: ["more"] }
|
|
18
|
+
: { kind: "ready", summary: "ok" },
|
|
19
|
+
),
|
|
20
|
+
answer(VERDICT, async (turn) => (turn.nudge ? reply.failed() : { kind: "ready", summary: "" })),
|
|
21
|
+
answer(CARRIED, { count: 1 }),
|
|
22
|
+
answer(CARRIED, () => ({ count: 2 })),
|
|
23
|
+
answer("text"),
|
|
24
|
+
answer((turn) => `turn ${turn.n}`),
|
|
25
|
+
reply.silent(),
|
|
26
|
+
[answer(VERDICT, { kind: "ready", summary: "ok" }), reply.hang(), answer("done")],
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export const invalid: Script[] = [
|
|
30
|
+
// @ts-expect-error a missing field
|
|
31
|
+
answer(VERDICT, { kind: "ready" }),
|
|
32
|
+
// @ts-expect-error a kind the union doesn't have
|
|
33
|
+
answer(VERDICT, { kind: "approved", summary: "ok" }),
|
|
34
|
+
// @ts-expect-error an extra field
|
|
35
|
+
answer(VERDICT, { kind: "ready", summary: "ok", extra: 1 }),
|
|
36
|
+
// @ts-expect-error a function's literal must be one of the union's
|
|
37
|
+
answer(VERDICT, () => ({ kind: "approved", summary: "ok" })),
|
|
38
|
+
// @ts-expect-error the carrier's type
|
|
39
|
+
answer(CARRIED, { count: "one" }),
|
|
40
|
+
// @ts-expect-error a reply is a script entry of its own, not an answer's value
|
|
41
|
+
answer(VERDICT, reply.silent()),
|
|
42
|
+
// @ts-expect-error text answers a turn with no schema
|
|
43
|
+
answer(42),
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// @ts-expect-error raw JSON where a script expects answer(…)
|
|
47
|
+
export const raw: Script = { kind: "ready", summary: "ok" };
|
package/README.md
DELETED