ankka 0.10.0 → 0.12.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 +1 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
- package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
- package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +858 -2
- package/dist/_proto/ankka/protocol/v1/client_pb.js +184 -9
- package/dist/_proto/ankka/protocol/v1/consumer_pb.d.ts +10 -1
- package/dist/_proto/ankka/protocol/v1/consumer_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +373 -0
- package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +210 -0
- package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +35 -5
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.d.ts +86 -0
- package/dist/_proto/ankka/protocol/v1/event_sourced_pb.js +11 -1
- package/dist/_proto/ankka/protocol/v1/payload_pb.d.ts +61 -0
- package/dist/_proto/ankka/protocol/v1/payload_pb.js +18 -1
- package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +70 -1
- package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
- package/dist/_proto/ankka/protocol/v1/wasm_pb.d.ts +12 -0
- package/dist/_proto/ankka/protocol/v1/wasm_pb.js +1 -1
- package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
- package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
- package/dist/agent.d.ts +15 -1
- package/dist/agent.js +20 -0
- package/dist/approvals.d.ts +47 -0
- package/dist/approvals.js +57 -0
- package/dist/autonomous.d.ts +23 -1
- package/dist/autonomous.js +30 -1
- package/dist/client.d.ts +188 -7
- package/dist/client.js +490 -21
- package/dist/consumer.d.ts +42 -3
- package/dist/consumer.js +35 -1
- package/dist/context.d.ts +29 -1
- package/dist/context.js +5 -2
- package/dist/contract.d.ts +40 -0
- package/dist/contract.js +60 -0
- package/dist/effects/common.d.ts +7 -0
- package/dist/effects/common.js +9 -1
- package/dist/effects/eventSourced.d.ts +26 -1
- package/dist/effects/eventSourced.js +58 -5
- package/dist/effects/keyed.d.ts +33 -0
- package/dist/effects/keyed.js +41 -0
- package/dist/endpoint.d.ts +10 -1
- package/dist/endpoint.js +20 -0
- package/dist/eventSourcedEntity.d.ts +8 -3
- package/dist/graph.d.ts +24 -2
- package/dist/graph.js +27 -1
- package/dist/handlers.d.ts +10 -2
- package/dist/handlers.js +6 -2
- package/dist/index.d.ts +15 -6
- package/dist/index.js +12 -3
- package/dist/keyedView.d.ts +59 -0
- package/dist/keyedView.js +73 -0
- package/dist/kinds.js +2 -0
- package/dist/mcp.d.ts +57 -0
- package/dist/mcp.js +64 -0
- package/dist/routes.d.ts +44 -4
- package/dist/routes.js +43 -4
- package/dist/server/agent.js +12 -4
- package/dist/server/discovery.d.ts +17 -1
- package/dist/server/discovery.js +182 -1
- package/dist/server/eventSourced.d.ts +4 -1
- package/dist/server/eventSourced.js +24 -6
- package/dist/server/http.d.ts +2 -1
- package/dist/server/http.js +80 -4
- package/dist/server/stateless.js +42 -3
- package/dist/server/workflow.js +5 -1
- package/dist/service.d.ts +46 -2
- package/dist/service.js +235 -15
- package/dist/services.d.ts +137 -0
- package/dist/services.js +259 -0
- package/dist/socket.d.ts +15 -0
- package/dist/socket.js +25 -0
- package/dist/spec.d.ts +1 -1
- package/dist/spec.js +94 -15
- package/dist/standing.d.ts +18 -0
- package/dist/standing.js +26 -0
- package/dist/startFrom.d.ts +36 -0
- package/dist/startFrom.js +51 -0
- package/dist/testkit/index.d.ts +2 -2
- package/dist/testkit/index.js +1 -1
- package/dist/testkit/integration.js +5 -0
- package/dist/testkit/kinds.d.ts +50 -5
- package/dist/testkit/kinds.js +208 -35
- package/dist/testkit/unit.d.ts +41 -3
- package/dist/testkit/unit.js +87 -10
- package/dist/timedAction.d.ts +17 -2
- package/dist/timedAction.js +33 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/view.d.ts +48 -1
- package/dist/view.js +18 -1
- package/dist/workflow.d.ts +18 -1
- package/dist/workflow.js +39 -0
- package/package.json +3 -2
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// pulls it on first use — and an unreleased one (version `0.0.0`, a checkout of the ankka repository) uses
|
|
13
13
|
// `ankka-sidecar:latest`, the image `sbt sidecar/Docker/publishLocal` builds.
|
|
14
14
|
import { execFileSync } from "node:child_process";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
15
16
|
import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
16
17
|
import { tmpdir } from "node:os";
|
|
17
18
|
import { join } from "node:path";
|
|
@@ -220,6 +221,9 @@ export class AnkkaTestKit {
|
|
|
220
221
|
ANKKA_DB_NAME: "ankka",
|
|
221
222
|
ANKKA_DB_USER: "ankka",
|
|
222
223
|
ANKKA_DB_PASSWORD: "ankka",
|
|
224
|
+
// A fresh secret key per kit, so the secret store works with no setup; the caller's `env`
|
|
225
|
+
// replaces it, and `ANKKA_SECRET_KEY: ""` starts a service with none.
|
|
226
|
+
ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
|
|
223
227
|
...this.#env,
|
|
224
228
|
})
|
|
225
229
|
.withExposedPorts(HTTP_PORT, CALLBACK_PORT)
|
|
@@ -254,6 +258,7 @@ export class AnkkaTestKit {
|
|
|
254
258
|
ANKKA_DB_NAME: "ankka",
|
|
255
259
|
ANKKA_DB_USER: "ankka",
|
|
256
260
|
ANKKA_DB_PASSWORD: "ankka",
|
|
261
|
+
ANKKA_SECRET_KEY: randomBytes(32).toString("base64"),
|
|
257
262
|
...env,
|
|
258
263
|
})
|
|
259
264
|
.withExposedPorts(httpPort)
|
package/dist/testkit/kinds.d.ts
CHANGED
|
@@ -5,12 +5,17 @@ import type { ConsumerEffect, TimedActionEffect, ViewEffect } from "../effects/s
|
|
|
5
5
|
import type { KeyValueEntity, KeyValueEntityClass } from "../keyValueEntity.ts";
|
|
6
6
|
import type { Workflow, WorkflowClass } from "../workflow.ts";
|
|
7
7
|
import type { View, ViewClass } from "../view.ts";
|
|
8
|
+
import type { KeyedView, KeyedViewClass } from "../keyedView.ts";
|
|
9
|
+
import { type KeyedViewEffect } from "../effects/keyed.ts";
|
|
10
|
+
import type { ComponentRef } from "../client.ts";
|
|
8
11
|
import { type Consumer, type ConsumerClass } from "../consumer.ts";
|
|
9
12
|
import { type Delta, type GraphConsumerClass } from "../graph.ts";
|
|
10
13
|
import type { TimedAction, TimedActionClass } from "../timedAction.ts";
|
|
11
14
|
import type { Agent, AgentClass } from "../agent.ts";
|
|
12
15
|
import type { HandlerRef } from "../handlers.ts";
|
|
13
16
|
import { type MaterialisedKeyValue, type MaterialisedStep, type MaterialisedWorkflowCommand } from "../materialise.ts";
|
|
17
|
+
import { type AgentOutcome, type DecisionInput } from "../approvals.ts";
|
|
18
|
+
import type { Standing } from "../standing.ts";
|
|
14
19
|
/** Drives one key value entity instance through its handlers. */
|
|
15
20
|
export declare class KeyValueTestKit<S, C extends KeyValueEntity<S>> {
|
|
16
21
|
#private;
|
|
@@ -54,12 +59,37 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
|
|
|
54
59
|
readonly rows: Map<string, Row>;
|
|
55
60
|
private constructor();
|
|
56
61
|
static of<E, Row, C extends View<E, Row>>(cls: ViewClass<E, Row, C>, client?: ComponentClient): ViewTestKit<E, Row, C>;
|
|
57
|
-
/**
|
|
58
|
-
|
|
62
|
+
/**
|
|
63
|
+
* A change from source instance `key`: the event, round-tripped through the source's event codec.
|
|
64
|
+
* `standing` is a workflow's, for a view whose source is one.
|
|
65
|
+
*/
|
|
66
|
+
onChange(key: string, event: E, metadata?: Metadata, standing?: Standing): Promise<ViewEffect<Row>>;
|
|
59
67
|
/** The source instance `key` was deleted. */
|
|
60
68
|
onDelete(key: string, metadata?: Metadata): Promise<ViewEffect<Row>>;
|
|
61
69
|
get(key: string): Row | null;
|
|
62
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
|
|
73
|
+
* and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
|
|
74
|
+
* them. Rows round-trip through the view's row codec and events through the source's.
|
|
75
|
+
*
|
|
76
|
+
* A declared query is SQL and there is no database to run it, so the test says what each query
|
|
77
|
+
* answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
|
|
78
|
+
* no rows would pass a handler that updated nothing.
|
|
79
|
+
*/
|
|
80
|
+
export declare class KeyedViewTestKit<Row, C extends KeyedView<Row>> {
|
|
81
|
+
#private;
|
|
82
|
+
readonly rows: Map<string, Row>;
|
|
83
|
+
private constructor();
|
|
84
|
+
static of<Row, C extends KeyedView<Row>>(cls: KeyedViewClass<Row, C>, client?: ComponentClient): KeyedViewTestKit<Row, C>;
|
|
85
|
+
/** What the declared query `name` answers, given the values it is asked with. */
|
|
86
|
+
answering(name: string, answer: (values: Readonly<Record<string, string>>) => Row[]): this;
|
|
87
|
+
/** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
|
|
88
|
+
change<E>(entity: ComponentRef, key: string, event: E, standing?: Standing): Promise<KeyedViewEffect<Row>>;
|
|
89
|
+
/** The entity `key` of `entity` was deleted. */
|
|
90
|
+
deleted(entity: ComponentRef, key: string): Promise<KeyedViewEffect<Row>>;
|
|
91
|
+
get(key: string): Row | null;
|
|
92
|
+
}
|
|
63
93
|
/** Feeds a consumer messages and collects what it produced. */
|
|
64
94
|
export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
|
|
65
95
|
#private;
|
|
@@ -78,7 +108,7 @@ export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
|
|
|
78
108
|
* A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
|
|
79
109
|
* version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
|
|
80
110
|
*/
|
|
81
|
-
onMessage(message: M, subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
|
|
111
|
+
onMessage(message: M, subject?: string, metadata?: Metadata, standing?: Standing): Promise<ConsumerEffect<Out>>;
|
|
82
112
|
onDelete(subject?: string, metadata?: Metadata): Promise<ConsumerEffect<Out>>;
|
|
83
113
|
}
|
|
84
114
|
/**
|
|
@@ -141,9 +171,15 @@ export declare class ScriptedModel {
|
|
|
141
171
|
/** @internal */
|
|
142
172
|
_next(call: ModelCall): ModelResponse;
|
|
143
173
|
}
|
|
174
|
+
/** Scripted MCP servers: `{ server: { tool: (args) => text } }`, called by the model as `mcp__<server>__<tool>`. */
|
|
175
|
+
export type ScriptedMcp = Readonly<Record<string, Readonly<Record<string, (args: Record<string, unknown>) => string | Promise<string>>>>>;
|
|
144
176
|
/**
|
|
145
177
|
* Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
|
|
146
178
|
* (scripted), tool calls back into the agent, output guardrails, and the session's history.
|
|
179
|
+
*
|
|
180
|
+
* `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
|
|
181
|
+
* tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
|
|
182
|
+
* `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
|
|
147
183
|
*/
|
|
148
184
|
export declare class AgentTestKit<C extends Agent> {
|
|
149
185
|
#private;
|
|
@@ -154,9 +190,18 @@ export declare class AgentTestKit<C extends Agent> {
|
|
|
154
190
|
text: string;
|
|
155
191
|
}[];
|
|
156
192
|
private constructor();
|
|
157
|
-
static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient
|
|
158
|
-
|
|
193
|
+
static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient, options?: {
|
|
194
|
+
readonly mcp?: ScriptedMcp;
|
|
195
|
+
}): AgentTestKit<C>;
|
|
196
|
+
/** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
|
|
159
197
|
ask<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<string>;
|
|
198
|
+
/** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
|
|
199
|
+
outcome<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<AgentOutcome<string>>;
|
|
200
|
+
/**
|
|
201
|
+
* Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
|
|
202
|
+
* model is told its result; refused, the model is told who refused it and why.
|
|
203
|
+
*/
|
|
204
|
+
decide(approvalId: string, decision: DecisionInput): Promise<AgentOutcome<string>>;
|
|
160
205
|
/** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
|
|
161
206
|
stream<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): AsyncIterable<string>;
|
|
162
207
|
}
|
package/dist/testkit/kinds.js
CHANGED
|
@@ -5,12 +5,15 @@ import { ComponentClient, noClient } from "../client.js";
|
|
|
5
5
|
import { binaryCodecs, codecFor } from "../codec.js";
|
|
6
6
|
import { commandContext } from "../context.js";
|
|
7
7
|
import { CommandError, ErrorCode } from "../effects/common.js";
|
|
8
|
+
import { reduceRowChanges } from "../effects/keyed.js";
|
|
8
9
|
import { PROTOCOL_KEY, requireSeveralMessages } from "../consumer.js";
|
|
9
10
|
import { deltaRecords, readDelta } from "../graph.js";
|
|
10
11
|
import { PROTOCOL_VERSION } from "../spec.js";
|
|
11
12
|
import { materialiseKeyValue, materialiseStep, materialiseWorkflowCommand } from "../materialise.js";
|
|
12
13
|
import { Ankka } from "../service.js";
|
|
13
14
|
import { checkPlan, runTool } from "../server/agent.js";
|
|
15
|
+
import { ApprovalAwaited } from "../approvals.js";
|
|
16
|
+
import { TOOL_PREFIX, toolName } from "../mcp.js";
|
|
14
17
|
function roundTrip(codec, value) {
|
|
15
18
|
return codec.decode(codec.encode(value));
|
|
16
19
|
}
|
|
@@ -196,11 +199,11 @@ export class ViewTestKit {
|
|
|
196
199
|
static of(cls, client = noClient()) {
|
|
197
200
|
return new ViewTestKit(cls, client);
|
|
198
201
|
}
|
|
199
|
-
async #apply(key, fn, metadata) {
|
|
202
|
+
async #apply(key, fn, metadata, standing) {
|
|
200
203
|
const view = new this.#cls();
|
|
201
204
|
const rowCodec = this.#registered.rowCodec;
|
|
202
205
|
const current = this.rows.get(key);
|
|
203
|
-
view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata));
|
|
206
|
+
view._bind(current === undefined ? null : roundTrip(rowCodec, current), { "ce-subject": key, ...metadata }, this.#client.withMetadata(metadata), standing);
|
|
204
207
|
const effect = await fn(view);
|
|
205
208
|
switch (effect.kind) {
|
|
206
209
|
case "update-row":
|
|
@@ -214,10 +217,13 @@ export class ViewTestKit {
|
|
|
214
217
|
}
|
|
215
218
|
return effect;
|
|
216
219
|
}
|
|
217
|
-
/**
|
|
218
|
-
|
|
220
|
+
/**
|
|
221
|
+
* A change from source instance `key`: the event, round-tripped through the source's event codec.
|
|
222
|
+
* `standing` is a workflow's, for a view whose source is one.
|
|
223
|
+
*/
|
|
224
|
+
onChange(key, event, metadata = {}, standing) {
|
|
219
225
|
const wire = roundTrip(this.#registered.eventCodec, event);
|
|
220
|
-
return this.#apply(key, (v) => v.onChange(wire), metadata);
|
|
226
|
+
return this.#apply(key, (v) => v.onChange(wire), metadata, standing);
|
|
221
227
|
}
|
|
222
228
|
/** The source instance `key` was deleted. */
|
|
223
229
|
onDelete(key, metadata = {}) {
|
|
@@ -227,6 +233,86 @@ export class ViewTestKit {
|
|
|
227
233
|
return this.rows.get(key) ?? null;
|
|
228
234
|
}
|
|
229
235
|
}
|
|
236
|
+
/**
|
|
237
|
+
* Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
|
|
238
|
+
* and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
|
|
239
|
+
* them. Rows round-trip through the view's row codec and events through the source's.
|
|
240
|
+
*
|
|
241
|
+
* A declared query is SQL and there is no database to run it, so the test says what each query
|
|
242
|
+
* answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
|
|
243
|
+
* no rows would pass a handler that updated nothing.
|
|
244
|
+
*/
|
|
245
|
+
export class KeyedViewTestKit {
|
|
246
|
+
#registered;
|
|
247
|
+
#cls;
|
|
248
|
+
#client;
|
|
249
|
+
#answers = new Map();
|
|
250
|
+
#sequence = 0;
|
|
251
|
+
rows = new Map();
|
|
252
|
+
constructor(cls, client) {
|
|
253
|
+
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
254
|
+
this.#cls = cls;
|
|
255
|
+
this.#client = client;
|
|
256
|
+
}
|
|
257
|
+
static of(cls, client = noClient()) {
|
|
258
|
+
return new KeyedViewTestKit(cls, client);
|
|
259
|
+
}
|
|
260
|
+
/** What the declared query `name` answers, given the values it is asked with. */
|
|
261
|
+
answering(name, answer) {
|
|
262
|
+
this.#answers.set(name, answer);
|
|
263
|
+
return this;
|
|
264
|
+
}
|
|
265
|
+
#source(entity) {
|
|
266
|
+
const source = this.#registered.sources.get(entity.componentId);
|
|
267
|
+
if (!source)
|
|
268
|
+
throw new Error(`${this.#registered.id} reads no ${JSON.stringify(entity.componentId)}`);
|
|
269
|
+
return source;
|
|
270
|
+
}
|
|
271
|
+
async #apply(key, run, standing) {
|
|
272
|
+
const view = new this.#cls();
|
|
273
|
+
const rowCodec = this.#registered.rowCodec;
|
|
274
|
+
const id = this.#registered.id;
|
|
275
|
+
const rows = {
|
|
276
|
+
get: async (k) => {
|
|
277
|
+
const row = this.rows.get(k);
|
|
278
|
+
return row === undefined ? null : roundTrip(rowCodec, row);
|
|
279
|
+
},
|
|
280
|
+
ask: async (name, values = {}) => {
|
|
281
|
+
const answer = this.#answers.get(name);
|
|
282
|
+
if (!answer)
|
|
283
|
+
throw new Error(`${id} asked the query ${JSON.stringify(name)}, and the test has not said what it answers; say so with answering(${JSON.stringify(name)}, ...)`);
|
|
284
|
+
return answer(values).map((r) => roundTrip(rowCodec, r));
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
this.#sequence += 1;
|
|
288
|
+
const metadata = { "ce-subject": key, "ankka.sequence": String(this.#sequence) };
|
|
289
|
+
view._bind(metadata, this.#client.withMetadata(metadata), rows, standing);
|
|
290
|
+
const effect = ((await run(view)) ?? { kind: "rows", changes: [] });
|
|
291
|
+
if (effect.kind !== "rows")
|
|
292
|
+
throw new TypeError(`${id}'s handler returned something that is not a keyed view effect`);
|
|
293
|
+
for (const [k, row] of reduceRowChanges(effect.changes)) {
|
|
294
|
+
if (row === undefined)
|
|
295
|
+
this.rows.delete(k);
|
|
296
|
+
else
|
|
297
|
+
this.rows.set(k, roundTrip(rowCodec, row));
|
|
298
|
+
}
|
|
299
|
+
return effect;
|
|
300
|
+
}
|
|
301
|
+
/** A change of `key` of `entity`, round-tripped through that source's codec; `standing` a workflow's. */
|
|
302
|
+
change(entity, key, event, standing) {
|
|
303
|
+
const source = this.#source(entity);
|
|
304
|
+
const wire = roundTrip(source.eventCodec, event);
|
|
305
|
+
return this.#apply(key, (v) => source.onChange(v, wire), standing);
|
|
306
|
+
}
|
|
307
|
+
/** The entity `key` of `entity` was deleted. */
|
|
308
|
+
deleted(entity, key) {
|
|
309
|
+
const source = this.#source(entity);
|
|
310
|
+
return this.#apply(key, (v) => (source.deleted ? source.deleted(v) : undefined));
|
|
311
|
+
}
|
|
312
|
+
get(key) {
|
|
313
|
+
return this.rows.get(key) ?? null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
230
316
|
/** Feeds a consumer messages and collects what it produced. */
|
|
231
317
|
export class ConsumerTestKit {
|
|
232
318
|
#registered;
|
|
@@ -245,9 +331,9 @@ export class ConsumerTestKit {
|
|
|
245
331
|
static of(cls, client = noClient()) {
|
|
246
332
|
return new ConsumerTestKit(cls, client);
|
|
247
333
|
}
|
|
248
|
-
async #apply(fn, metadata) {
|
|
334
|
+
async #apply(fn, metadata, standing) {
|
|
249
335
|
const consumer = new this.#cls();
|
|
250
|
-
consumer._bind(metadata, this.#client.withMetadata(metadata));
|
|
336
|
+
consumer._bind(metadata, this.#client.withMetadata(metadata), standing);
|
|
251
337
|
const effect = await fn(consumer);
|
|
252
338
|
const outCodec = () => {
|
|
253
339
|
const codec = this.#registered.outCodec;
|
|
@@ -271,9 +357,9 @@ export class ConsumerTestKit {
|
|
|
271
357
|
* A message from source instance `subject`. The metadata says the runtime speaks this SDK's protocol
|
|
272
358
|
* version; pass `"ankka.protocol"` or `"ankka.sequence"` to say otherwise.
|
|
273
359
|
*/
|
|
274
|
-
onMessage(message, subject = "test", metadata = {}) {
|
|
360
|
+
onMessage(message, subject = "test", metadata = {}, standing) {
|
|
275
361
|
const wire = roundTrip(this.#registered.messageCodec, message);
|
|
276
|
-
return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
|
|
362
|
+
return this.#apply((c) => c.onMessage(wire), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata }, standing);
|
|
277
363
|
}
|
|
278
364
|
onDelete(subject = "test", metadata = {}) {
|
|
279
365
|
return this.#apply((c) => c.onDelete(), { "ce-subject": subject, [PROTOCOL_KEY]: PROTOCOL_VERSION, ...metadata });
|
|
@@ -372,32 +458,50 @@ export class ScriptedModel {
|
|
|
372
458
|
/**
|
|
373
459
|
* Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
|
|
374
460
|
* (scripted), tool calls back into the agent, output guardrails, and the session's history.
|
|
461
|
+
*
|
|
462
|
+
* `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
|
|
463
|
+
* tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
|
|
464
|
+
* `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
|
|
375
465
|
*/
|
|
376
466
|
export class AgentTestKit {
|
|
377
467
|
#registered;
|
|
378
468
|
#cls;
|
|
379
469
|
#client;
|
|
380
470
|
#model;
|
|
471
|
+
#mcp;
|
|
381
472
|
sessionId;
|
|
382
473
|
/** The session's memory: what the user said and the agent answered, turn after turn. */
|
|
383
474
|
history = [];
|
|
384
|
-
|
|
475
|
+
#turn;
|
|
476
|
+
#decided = new Set();
|
|
477
|
+
#nextId = 0;
|
|
478
|
+
constructor(cls, sessionId, model, client, mcp) {
|
|
385
479
|
this.#registered = registryFor(cls, client).component(cls.componentId);
|
|
386
480
|
this.#cls = cls;
|
|
387
481
|
this.#client = client;
|
|
388
482
|
this.#model = model;
|
|
483
|
+
this.#mcp = mcp;
|
|
389
484
|
this.sessionId = sessionId;
|
|
390
485
|
}
|
|
391
|
-
static of(cls, sessionId, model, client = noClient()) {
|
|
392
|
-
return new AgentTestKit(cls, sessionId, model, client);
|
|
486
|
+
static of(cls, sessionId, model, client = noClient(), options = {}) {
|
|
487
|
+
return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {});
|
|
393
488
|
}
|
|
394
489
|
#agent() {
|
|
395
490
|
const a = new this.#cls();
|
|
396
491
|
a._bind(this.sessionId, {}, this.#client);
|
|
397
492
|
return a;
|
|
398
493
|
}
|
|
399
|
-
/** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
|
|
494
|
+
/** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`; a turn that waits, with `ApprovalAwaited`. */
|
|
400
495
|
async ask(handler, input) {
|
|
496
|
+
const outcome = await this.outcome(handler, input);
|
|
497
|
+
if (outcome.kind === "awaiting-approval")
|
|
498
|
+
throw new ApprovalAwaited(outcome.requests);
|
|
499
|
+
return outcome.value;
|
|
500
|
+
}
|
|
501
|
+
/** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
|
|
502
|
+
async outcome(handler, input) {
|
|
503
|
+
if (this.#turn?.pending)
|
|
504
|
+
throw new CommandError({ message: `session '${this.sessionId}' has a turn awaiting a decision`, code: ErrorCode.Conflict });
|
|
401
505
|
const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
|
|
402
506
|
const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
|
|
403
507
|
const agent = this.#agent();
|
|
@@ -409,43 +513,112 @@ export class AgentTestKit {
|
|
|
409
513
|
throw new Error(problem);
|
|
410
514
|
if (plan.failure)
|
|
411
515
|
throw new CommandError(plan.failure);
|
|
412
|
-
const guard = async (stage, text) => {
|
|
413
|
-
for (const name of plan.guardrailNames) {
|
|
414
|
-
const reason = await this.#registered.guardrails.get(name).check(stage, text);
|
|
415
|
-
if (reason)
|
|
416
|
-
throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
|
|
417
|
-
}
|
|
418
|
-
};
|
|
419
516
|
const user = [...plan.context, plan.user ?? ""].filter((t) => t !== "").join("\n\n");
|
|
420
|
-
await guard("input", user);
|
|
517
|
+
await this.#guard(plan, "input", user);
|
|
421
518
|
const messages = plan.sessionMemory ? this.history.map((h) => ({ ...h })) : [];
|
|
422
519
|
messages.push({ role: "user", text: user });
|
|
423
|
-
|
|
424
|
-
|
|
520
|
+
this.#turn = { plan, messages, steps: 0 };
|
|
521
|
+
return this.#goOn(this.#turn);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
|
|
525
|
+
* model is told its result; refused, the model is told who refused it and why.
|
|
526
|
+
*/
|
|
527
|
+
async decide(approvalId, decision) {
|
|
528
|
+
if (decision.by.trim() === "")
|
|
529
|
+
throw new CommandError({ message: "a decision must name who made it", code: ErrorCode.BadRequest });
|
|
530
|
+
if (this.#decided.has(approvalId))
|
|
531
|
+
throw new CommandError({ message: `approval request '${approvalId}' is decided`, code: ErrorCode.Conflict });
|
|
532
|
+
const turn = this.#turn;
|
|
533
|
+
if (!turn?.pending || turn.pending.request.id !== approvalId) {
|
|
534
|
+
throw new CommandError({ message: `no approval request '${approvalId}' in session '${this.sessionId}'`, code: ErrorCode.NotFound });
|
|
535
|
+
}
|
|
536
|
+
const { tool, argumentsJson } = turn.pending;
|
|
537
|
+
turn.pending = undefined;
|
|
538
|
+
this.#decided.add(approvalId);
|
|
539
|
+
const result = decision.approved
|
|
540
|
+
? await this.#run(tool, argumentsJson)
|
|
541
|
+
: `A person (${decision.by}) refused this tool call; the tool did not run.${decision.note ? ` Their note: ${decision.note}` : ""}`;
|
|
542
|
+
turn.messages.push({ role: "tool", text: result });
|
|
543
|
+
return this.#goOn(turn);
|
|
544
|
+
}
|
|
545
|
+
async #guard(plan, stage, text) {
|
|
546
|
+
for (const name of plan.guardrailNames) {
|
|
547
|
+
const reason = await this.#registered.guardrails.get(name).check(stage, text);
|
|
548
|
+
if (reason)
|
|
549
|
+
throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
#mcpTool(name) {
|
|
553
|
+
if (!name.startsWith(TOOL_PREFIX))
|
|
554
|
+
return undefined;
|
|
555
|
+
const rest = name.slice(TOOL_PREFIX.length);
|
|
556
|
+
const at = rest.indexOf("__");
|
|
557
|
+
return at < 0 ? undefined : { server: rest.slice(0, at), tool: rest.slice(at + 2) };
|
|
558
|
+
}
|
|
559
|
+
#offered(plan) {
|
|
560
|
+
const mcp = [...this.#registered.mcpServers.keys()].flatMap((server) => Object.keys(this.#mcp[server] ?? {}).map((tool) => toolName(server, tool)));
|
|
561
|
+
return [...plan.toolNames, ...mcp];
|
|
562
|
+
}
|
|
563
|
+
#requiresApproval(name) {
|
|
564
|
+
const mcp = this.#mcpTool(name);
|
|
565
|
+
if (mcp)
|
|
566
|
+
return this.#registered.mcpServers.get(mcp.server)?.approval !== undefined;
|
|
567
|
+
return this.#registered.tools.get(name)?.approval !== undefined;
|
|
568
|
+
}
|
|
569
|
+
async #run(name, argumentsJson) {
|
|
570
|
+
const mcp = this.#mcpTool(name);
|
|
571
|
+
try {
|
|
572
|
+
if (!mcp)
|
|
573
|
+
return await runTool(this.#registered, this.#agent(), name, argumentsJson);
|
|
574
|
+
const text = await this.#mcp[mcp.server][mcp.tool](JSON.parse(argumentsJson || "{}"));
|
|
575
|
+
for (const g of [...this.#registered.resultGuardrails.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
576
|
+
const reason = await g.check(name, text);
|
|
577
|
+
if (reason)
|
|
578
|
+
return `A result guardrail refused what this tool answered, so it is not shown: ${g.name}: ${reason}`;
|
|
579
|
+
}
|
|
580
|
+
return text;
|
|
581
|
+
}
|
|
582
|
+
catch (e) {
|
|
583
|
+
return `error: ${e instanceof Error ? e.message : String(e)}`;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
async #goOn(turn) {
|
|
587
|
+
const offered = this.#offered(turn.plan);
|
|
588
|
+
while (turn.steps <= this.#registered.maxToolCallSteps) {
|
|
589
|
+
const answer = this.#model._next({ system: turn.plan.system, messages: [...turn.messages], tools: offered });
|
|
425
590
|
switch (answer.kind) {
|
|
426
591
|
case "refusal":
|
|
592
|
+
this.#turn = undefined;
|
|
427
593
|
throw new CommandError({ message: answer.message, code: ErrorCode.BadRequest });
|
|
428
594
|
case "tool-call": {
|
|
429
|
-
|
|
595
|
+
turn.steps++;
|
|
596
|
+
if (!offered.includes(answer.tool))
|
|
430
597
|
throw new Error(`the model called ${JSON.stringify(answer.tool)}, which the plan did not offer`);
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
598
|
+
turn.messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` });
|
|
599
|
+
if (this.#requiresApproval(answer.tool)) {
|
|
600
|
+
const request = Object.freeze({
|
|
601
|
+
id: `approval-${++this.#nextId}`,
|
|
602
|
+
tool: answer.tool,
|
|
603
|
+
arguments: JSON.parse(answer.argumentsJson || "{}"),
|
|
604
|
+
requestedAt: Date.now(),
|
|
605
|
+
});
|
|
606
|
+
turn.pending = { request, tool: answer.tool, argumentsJson: answer.argumentsJson };
|
|
607
|
+
return { kind: "awaiting-approval", requests: [request] };
|
|
437
608
|
}
|
|
438
|
-
messages.push({ role: "
|
|
609
|
+
turn.messages.push({ role: "tool", text: await this.#run(answer.tool, answer.argumentsJson) });
|
|
439
610
|
break;
|
|
440
611
|
}
|
|
441
612
|
case "text": {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
613
|
+
this.#turn = undefined;
|
|
614
|
+
await this.#guard(turn.plan, "output", answer.text);
|
|
615
|
+
if (turn.plan.sessionMemory)
|
|
616
|
+
this.history.push({ role: "user", text: turn.plan.user ?? "" }, { role: "assistant", text: answer.text });
|
|
617
|
+
return { kind: "answered", value: answer.text };
|
|
446
618
|
}
|
|
447
619
|
}
|
|
448
620
|
}
|
|
621
|
+
this.#turn = undefined;
|
|
449
622
|
throw new CommandError({ message: `the agent made more than ${this.#registered.maxToolCallSteps} tool calls`, code: ErrorCode.Internal });
|
|
450
623
|
}
|
|
451
624
|
/** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
|
package/dist/testkit/unit.d.ts
CHANGED
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import { ComponentClient } from "../client.ts";
|
|
2
2
|
import { type Principal } from "../context.ts";
|
|
3
|
-
import type
|
|
3
|
+
import { type ErrorDetail, type Metadata, type Retention } from "../effects/common.ts";
|
|
4
|
+
import type { ReplicationChange } from "../effects/eventSourced.ts";
|
|
4
5
|
import type { Endpoint, EndpointClass } from "../endpoint.ts";
|
|
5
6
|
import { EventSourcedEntity, type EventSourcedEntityClass } from "../eventSourcedEntity.ts";
|
|
6
7
|
import type { HandlerRef } from "../handlers.ts";
|
|
7
8
|
import { type Materialised } from "../materialise.ts";
|
|
8
9
|
export type { Materialised };
|
|
9
10
|
/** Drives one event sourced entity instance through its handlers, folding events as it goes. */
|
|
11
|
+
/** How a replicated entity's copy stands in the test kit (1.19); `installation` is the copy's own. */
|
|
12
|
+
export type ReplicationMode = {
|
|
13
|
+
readonly kind: "unreplicated";
|
|
14
|
+
} | {
|
|
15
|
+
readonly kind: "home";
|
|
16
|
+
readonly installation?: string;
|
|
17
|
+
} | {
|
|
18
|
+
readonly kind: "follower";
|
|
19
|
+
readonly home: string;
|
|
20
|
+
readonly installation?: string;
|
|
21
|
+
};
|
|
10
22
|
export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E>> {
|
|
11
23
|
#private;
|
|
12
24
|
readonly entityId: string;
|
|
25
|
+
/** The handover or change to the replication set the last call asked for, if any. */
|
|
26
|
+
lastReplication: ReplicationChange | undefined;
|
|
13
27
|
/** The state now, after every call so far. */
|
|
14
28
|
state: S;
|
|
15
29
|
/** The sequence number of the last event, `0n` before any. */
|
|
@@ -18,7 +32,18 @@ export declare class EventSourcedTestKit<S, E, C extends EventSourcedEntity<S, E
|
|
|
18
32
|
readonly allEvents: E[];
|
|
19
33
|
private constructor();
|
|
20
34
|
/** A kit for `cls` as instance `entityId`. Calls to other components reach `client`, which by default refuses them. */
|
|
21
|
-
static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient
|
|
35
|
+
static of<S, E, C extends EventSourcedEntity<S, E>>(cls: EventSourcedEntityClass<S, E, C>, entityId?: string, client?: ComponentClient,
|
|
36
|
+
/**
|
|
37
|
+
* How a replicated entity's copy stands (1.19): `{ kind: "home" }`, where every command runs, or
|
|
38
|
+
* `{ kind: "follower", home }`, where a command that may persist is refused `NOT_HOME` and a query is
|
|
39
|
+
* answered from what `replay` gave it. Not replicated by default.
|
|
40
|
+
*/
|
|
41
|
+
mode?: ReplicationMode): EventSourcedTestKit<S, E, C>;
|
|
42
|
+
/**
|
|
43
|
+
* Applies `events` as history the copy already holds, written at `writtenAt` (this installation by
|
|
44
|
+
* default): for a follower, what it has mirrored from its home.
|
|
45
|
+
*/
|
|
46
|
+
replay(events: readonly E[], writtenAt?: string | null): void;
|
|
22
47
|
/** Sends a command or query and returns what its effect amounts to. */
|
|
23
48
|
call<I, R>(handler: HandlerRef<C, I, R, any> | string, input?: I, metadata?: Metadata): Promise<Materialised<S, E, R>>;
|
|
24
49
|
}
|
|
@@ -34,15 +59,28 @@ export declare class Response {
|
|
|
34
59
|
export interface RequestOptions {
|
|
35
60
|
readonly query?: Readonly<Record<string, string | readonly string[]>>;
|
|
36
61
|
readonly headers?: Readonly<Record<string, string>>;
|
|
37
|
-
|
|
62
|
+
/** The verified caller, as the runtime would hand it over; `claims` and `issuer` may be left out. */
|
|
63
|
+
readonly principal?: Omit<Principal, "claims" | "issuer"> & Partial<Pick<Principal, "claims" | "issuer">>;
|
|
38
64
|
readonly metadata?: Metadata;
|
|
39
65
|
}
|
|
66
|
+
/** What a socket route's handler did with a scripted socket. */
|
|
67
|
+
export interface SocketRun {
|
|
68
|
+
readonly sent: readonly string[];
|
|
69
|
+
readonly ended: "finished" | "failed";
|
|
70
|
+
readonly error?: string;
|
|
71
|
+
}
|
|
40
72
|
/** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
|
|
41
73
|
export declare class EndpointTestKit<C extends Endpoint> {
|
|
42
74
|
#private;
|
|
43
75
|
private constructor();
|
|
44
76
|
static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
|
|
45
77
|
request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
|
|
78
|
+
/**
|
|
79
|
+
* Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
|
|
80
|
+
* socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
|
|
81
|
+
* order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
|
|
82
|
+
*/
|
|
83
|
+
socket(path: string, frames?: readonly string[], options?: RequestOptions): Promise<SocketRun>;
|
|
46
84
|
/** The frames of an SSE route, each string one frame. */
|
|
47
85
|
sse(path: string, options?: RequestOptions): Promise<string[]>;
|
|
48
86
|
get(path: string, options?: RequestOptions): Promise<Response>;
|