ankka 0.10.0 → 0.11.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.
Files changed (74) hide show
  1. package/dist/_proto/ankka/protocol/v1/agent_pb.d.ts +35 -0
  2. package/dist/_proto/ankka/protocol/v1/agent_pb.js +8 -1
  3. package/dist/_proto/ankka/protocol/v1/client_pb.d.ts +542 -2
  4. package/dist/_proto/ankka/protocol/v1/client_pb.js +107 -6
  5. package/dist/_proto/ankka/protocol/v1/discovery_pb.d.ts +330 -0
  6. package/dist/_proto/ankka/protocol/v1/discovery_pb.js +81 -16
  7. package/dist/_proto/ankka/protocol/v1/endpoint_pb.d.ts +184 -0
  8. package/dist/_proto/ankka/protocol/v1/endpoint_pb.js +26 -1
  9. package/dist/_proto/ankka/protocol/v1/view_pb.d.ts +63 -0
  10. package/dist/_proto/ankka/protocol/v1/view_pb.js +11 -1
  11. package/dist/_proto/ankka/protocol/v1/workflow_pb.d.ts +7 -0
  12. package/dist/_proto/ankka/protocol/v1/workflow_pb.js +1 -1
  13. package/dist/agent.d.ts +15 -1
  14. package/dist/agent.js +20 -0
  15. package/dist/approvals.d.ts +47 -0
  16. package/dist/approvals.js +57 -0
  17. package/dist/autonomous.d.ts +23 -1
  18. package/dist/autonomous.js +30 -1
  19. package/dist/client.d.ts +92 -4
  20. package/dist/client.js +262 -15
  21. package/dist/consumer.d.ts +24 -2
  22. package/dist/consumer.js +20 -0
  23. package/dist/context.d.ts +4 -0
  24. package/dist/contract.d.ts +36 -0
  25. package/dist/contract.js +58 -0
  26. package/dist/effects/keyed.d.ts +33 -0
  27. package/dist/effects/keyed.js +41 -0
  28. package/dist/endpoint.d.ts +10 -1
  29. package/dist/endpoint.js +20 -0
  30. package/dist/graph.d.ts +15 -1
  31. package/dist/graph.js +20 -0
  32. package/dist/handlers.d.ts +10 -2
  33. package/dist/handlers.js +6 -2
  34. package/dist/index.d.ts +12 -4
  35. package/dist/index.js +11 -3
  36. package/dist/keyedView.d.ts +56 -0
  37. package/dist/keyedView.js +67 -0
  38. package/dist/mcp.d.ts +57 -0
  39. package/dist/mcp.js +64 -0
  40. package/dist/routes.d.ts +23 -3
  41. package/dist/routes.js +16 -3
  42. package/dist/server/agent.js +12 -4
  43. package/dist/server/discovery.d.ts +12 -1
  44. package/dist/server/discovery.js +95 -1
  45. package/dist/server/http.d.ts +2 -1
  46. package/dist/server/http.js +78 -4
  47. package/dist/server/stateless.js +38 -0
  48. package/dist/server/workflow.js +5 -1
  49. package/dist/service.d.ts +40 -2
  50. package/dist/service.js +209 -14
  51. package/dist/services.d.ts +137 -0
  52. package/dist/services.js +259 -0
  53. package/dist/socket.d.ts +15 -0
  54. package/dist/socket.js +25 -0
  55. package/dist/spec.d.ts +1 -1
  56. package/dist/spec.js +87 -14
  57. package/dist/startFrom.d.ts +32 -0
  58. package/dist/startFrom.js +44 -0
  59. package/dist/testkit/index.d.ts +2 -2
  60. package/dist/testkit/index.js +1 -1
  61. package/dist/testkit/integration.js +5 -0
  62. package/dist/testkit/kinds.d.ts +43 -2
  63. package/dist/testkit/kinds.js +196 -26
  64. package/dist/testkit/unit.d.ts +14 -1
  65. package/dist/testkit/unit.js +40 -3
  66. package/dist/timedAction.d.ts +17 -2
  67. package/dist/timedAction.js +33 -1
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/dist/view.d.ts +32 -0
  71. package/dist/view.js +8 -0
  72. package/dist/workflow.d.ts +18 -1
  73. package/dist/workflow.js +39 -0
  74. package/package.json +3 -2
@@ -5,12 +5,16 @@ 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";
14
18
  /** Drives one key value entity instance through its handlers. */
15
19
  export declare class KeyValueTestKit<S, C extends KeyValueEntity<S>> {
16
20
  #private;
@@ -60,6 +64,28 @@ export declare class ViewTestKit<E, Row, C extends View<E, Row>> {
60
64
  onDelete(key: string, metadata?: Metadata): Promise<ViewEffect<Row>>;
61
65
  get(key: string): Row | null;
62
66
  }
67
+ /**
68
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
69
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
70
+ * them. Rows round-trip through the view's row codec and events through the source's.
71
+ *
72
+ * A declared query is SQL and there is no database to run it, so the test says what each query
73
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
74
+ * no rows would pass a handler that updated nothing.
75
+ */
76
+ export declare class KeyedViewTestKit<Row, C extends KeyedView<Row>> {
77
+ #private;
78
+ readonly rows: Map<string, Row>;
79
+ private constructor();
80
+ static of<Row, C extends KeyedView<Row>>(cls: KeyedViewClass<Row, C>, client?: ComponentClient): KeyedViewTestKit<Row, C>;
81
+ /** What the declared query `name` answers, given the values it is asked with. */
82
+ answering(name: string, answer: (values: Readonly<Record<string, string>>) => Row[]): this;
83
+ /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
84
+ change<E>(entity: ComponentRef, key: string, event: E): Promise<KeyedViewEffect<Row>>;
85
+ /** The entity `key` of `entity` was deleted. */
86
+ deleted(entity: ComponentRef, key: string): Promise<KeyedViewEffect<Row>>;
87
+ get(key: string): Row | null;
88
+ }
63
89
  /** Feeds a consumer messages and collects what it produced. */
64
90
  export declare class ConsumerTestKit<M, Out, C extends Consumer<M, Out>> {
65
91
  #private;
@@ -141,9 +167,15 @@ export declare class ScriptedModel {
141
167
  /** @internal */
142
168
  _next(call: ModelCall): ModelResponse;
143
169
  }
170
+ /** Scripted MCP servers: `{ server: { tool: (args) => text } }`, called by the model as `mcp__<server>__<tool>`. */
171
+ export type ScriptedMcp = Readonly<Record<string, Readonly<Record<string, (args: Record<string, unknown>) => string | Promise<string>>>>>;
144
172
  /**
145
173
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
146
174
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
175
+ *
176
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
177
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
178
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
147
179
  */
148
180
  export declare class AgentTestKit<C extends Agent> {
149
181
  #private;
@@ -154,9 +186,18 @@ export declare class AgentTestKit<C extends Agent> {
154
186
  text: string;
155
187
  }[];
156
188
  private constructor();
157
- static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient): AgentTestKit<C>;
158
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
189
+ static of<C extends Agent>(cls: AgentClass<C>, sessionId: string, model: ScriptedModel, client?: ComponentClient, options?: {
190
+ readonly mcp?: ScriptedMcp;
191
+ }): AgentTestKit<C>;
192
+ /** 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
193
  ask<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<string>;
194
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
195
+ outcome<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): Promise<AgentOutcome<string>>;
196
+ /**
197
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
198
+ * model is told its result; refused, the model is told who refused it and why.
199
+ */
200
+ decide(approvalId: string, decision: DecisionInput): Promise<AgentOutcome<string>>;
160
201
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
161
202
  stream<I>(handler: HandlerRef<C, I, any, any> | string, input?: I): AsyncIterable<string>;
162
203
  }
@@ -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
  }
@@ -227,6 +230,86 @@ export class ViewTestKit {
227
230
  return this.rows.get(key) ?? null;
228
231
  }
229
232
  }
233
+ /**
234
+ * Drives one keyed view with no sidecar: a change of one of its sources goes to that source's handler,
235
+ * and the rows it names are written to `rows`, a later change to a key winning, as the runtime writes
236
+ * them. Rows round-trip through the view's row codec and events through the source's.
237
+ *
238
+ * A declared query is SQL and there is no database to run it, so the test says what each query
239
+ * answers. A handler that asks a query the test has not answered throws, naming it: a kit that answered
240
+ * no rows would pass a handler that updated nothing.
241
+ */
242
+ export class KeyedViewTestKit {
243
+ #registered;
244
+ #cls;
245
+ #client;
246
+ #answers = new Map();
247
+ #sequence = 0;
248
+ rows = new Map();
249
+ constructor(cls, client) {
250
+ this.#registered = registryFor(cls, client).component(cls.componentId);
251
+ this.#cls = cls;
252
+ this.#client = client;
253
+ }
254
+ static of(cls, client = noClient()) {
255
+ return new KeyedViewTestKit(cls, client);
256
+ }
257
+ /** What the declared query `name` answers, given the values it is asked with. */
258
+ answering(name, answer) {
259
+ this.#answers.set(name, answer);
260
+ return this;
261
+ }
262
+ #source(entity) {
263
+ const source = this.#registered.sources.get(entity.componentId);
264
+ if (!source)
265
+ throw new Error(`${this.#registered.id} reads no ${JSON.stringify(entity.componentId)}`);
266
+ return source;
267
+ }
268
+ async #apply(key, run) {
269
+ const view = new this.#cls();
270
+ const rowCodec = this.#registered.rowCodec;
271
+ const id = this.#registered.id;
272
+ const rows = {
273
+ get: async (k) => {
274
+ const row = this.rows.get(k);
275
+ return row === undefined ? null : roundTrip(rowCodec, row);
276
+ },
277
+ ask: async (name, values = {}) => {
278
+ const answer = this.#answers.get(name);
279
+ if (!answer)
280
+ 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)}, ...)`);
281
+ return answer(values).map((r) => roundTrip(rowCodec, r));
282
+ },
283
+ };
284
+ this.#sequence += 1;
285
+ const metadata = { "ce-subject": key, "ankka.sequence": String(this.#sequence) };
286
+ view._bind(metadata, this.#client.withMetadata(metadata), rows);
287
+ const effect = ((await run(view)) ?? { kind: "rows", changes: [] });
288
+ if (effect.kind !== "rows")
289
+ throw new TypeError(`${id}'s handler returned something that is not a keyed view effect`);
290
+ for (const [k, row] of reduceRowChanges(effect.changes)) {
291
+ if (row === undefined)
292
+ this.rows.delete(k);
293
+ else
294
+ this.rows.set(k, roundTrip(rowCodec, row));
295
+ }
296
+ return effect;
297
+ }
298
+ /** A change of the entity `key` of `entity`, round-tripped through that source's codec. */
299
+ change(entity, key, event) {
300
+ const source = this.#source(entity);
301
+ const wire = roundTrip(source.eventCodec, event);
302
+ return this.#apply(key, (v) => source.onChange(v, wire));
303
+ }
304
+ /** The entity `key` of `entity` was deleted. */
305
+ deleted(entity, key) {
306
+ const source = this.#source(entity);
307
+ return this.#apply(key, (v) => (source.deleted ? source.deleted(v) : undefined));
308
+ }
309
+ get(key) {
310
+ return this.rows.get(key) ?? null;
311
+ }
312
+ }
230
313
  /** Feeds a consumer messages and collects what it produced. */
231
314
  export class ConsumerTestKit {
232
315
  #registered;
@@ -372,32 +455,50 @@ export class ScriptedModel {
372
455
  /**
373
456
  * Runs an agent's turn the way the sidecar does, in process: the plan, input guardrails, the model
374
457
  * (scripted), tool calls back into the agent, output guardrails, and the session's history.
458
+ *
459
+ * `mcp` scripts the agent's MCP servers, and its result guardrails check what they answer. A call to a
460
+ * tool that requires approval — the agent's own, or any of a server listed with `approval` — is not run:
461
+ * `outcome` answers with its request, `ask` rejects with `ApprovalAwaited`, and `decide` goes on.
375
462
  */
376
463
  export class AgentTestKit {
377
464
  #registered;
378
465
  #cls;
379
466
  #client;
380
467
  #model;
468
+ #mcp;
381
469
  sessionId;
382
470
  /** The session's memory: what the user said and the agent answered, turn after turn. */
383
471
  history = [];
384
- constructor(cls, sessionId, model, client) {
472
+ #turn;
473
+ #decided = new Set();
474
+ #nextId = 0;
475
+ constructor(cls, sessionId, model, client, mcp) {
385
476
  this.#registered = registryFor(cls, client).component(cls.componentId);
386
477
  this.#cls = cls;
387
478
  this.#client = client;
388
479
  this.#model = model;
480
+ this.#mcp = mcp;
389
481
  this.sessionId = sessionId;
390
482
  }
391
- static of(cls, sessionId, model, client = noClient()) {
392
- return new AgentTestKit(cls, sessionId, model, client);
483
+ static of(cls, sessionId, model, client = noClient(), options = {}) {
484
+ return new AgentTestKit(cls, sessionId, model, client, options.mcp ?? {});
393
485
  }
394
486
  #agent() {
395
487
  const a = new this.#cls();
396
488
  a._bind(this.sessionId, {}, this.#client);
397
489
  return a;
398
490
  }
399
- /** One turn: returns the reply text (JSON text for a `thenReplyJson` plan). A refusal or a blocked guardrail rejects with `CommandError`. */
491
+ /** 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
492
  async ask(handler, input) {
493
+ const outcome = await this.outcome(handler, input);
494
+ if (outcome.kind === "awaiting-approval")
495
+ throw new ApprovalAwaited(outcome.requests);
496
+ return outcome.value;
497
+ }
498
+ /** One turn, answered with its outcome: the reply text, or the approval requests the turn waits on. */
499
+ async outcome(handler, input) {
500
+ if (this.#turn?.pending)
501
+ throw new CommandError({ message: `session '${this.sessionId}' has a turn awaiting a decision`, code: ErrorCode.Conflict });
401
502
  const ref = findHandler(this.#registered.handlers, handler, this.#registered.cls.name);
402
503
  const wireInput = ref.input ? roundTrip(codecFor(ref.input), input) : undefined;
403
504
  const agent = this.#agent();
@@ -409,43 +510,112 @@ export class AgentTestKit {
409
510
  throw new Error(problem);
410
511
  if (plan.failure)
411
512
  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
513
  const user = [...plan.context, plan.user ?? ""].filter((t) => t !== "").join("\n\n");
420
- await guard("input", user);
514
+ await this.#guard(plan, "input", user);
421
515
  const messages = plan.sessionMemory ? this.history.map((h) => ({ ...h })) : [];
422
516
  messages.push({ role: "user", text: user });
423
- for (let step = 0; step <= this.#registered.maxToolCallSteps; step++) {
424
- const answer = this.#model._next({ system: plan.system, messages: [...messages], tools: [...plan.toolNames] });
517
+ this.#turn = { plan, messages, steps: 0 };
518
+ return this.#goOn(this.#turn);
519
+ }
520
+ /**
521
+ * Decides the request the turn waits on, and goes on as the sidecar would: approved, the tool runs and the
522
+ * model is told its result; refused, the model is told who refused it and why.
523
+ */
524
+ async decide(approvalId, decision) {
525
+ if (decision.by.trim() === "")
526
+ throw new CommandError({ message: "a decision must name who made it", code: ErrorCode.BadRequest });
527
+ if (this.#decided.has(approvalId))
528
+ throw new CommandError({ message: `approval request '${approvalId}' is decided`, code: ErrorCode.Conflict });
529
+ const turn = this.#turn;
530
+ if (!turn?.pending || turn.pending.request.id !== approvalId) {
531
+ throw new CommandError({ message: `no approval request '${approvalId}' in session '${this.sessionId}'`, code: ErrorCode.NotFound });
532
+ }
533
+ const { tool, argumentsJson } = turn.pending;
534
+ turn.pending = undefined;
535
+ this.#decided.add(approvalId);
536
+ const result = decision.approved
537
+ ? await this.#run(tool, argumentsJson)
538
+ : `A person (${decision.by}) refused this tool call; the tool did not run.${decision.note ? ` Their note: ${decision.note}` : ""}`;
539
+ turn.messages.push({ role: "tool", text: result });
540
+ return this.#goOn(turn);
541
+ }
542
+ async #guard(plan, stage, text) {
543
+ for (const name of plan.guardrailNames) {
544
+ const reason = await this.#registered.guardrails.get(name).check(stage, text);
545
+ if (reason)
546
+ throw new CommandError({ message: `${name}: ${reason}`, code: ErrorCode.Forbidden });
547
+ }
548
+ }
549
+ #mcpTool(name) {
550
+ if (!name.startsWith(TOOL_PREFIX))
551
+ return undefined;
552
+ const rest = name.slice(TOOL_PREFIX.length);
553
+ const at = rest.indexOf("__");
554
+ return at < 0 ? undefined : { server: rest.slice(0, at), tool: rest.slice(at + 2) };
555
+ }
556
+ #offered(plan) {
557
+ const mcp = [...this.#registered.mcpServers.keys()].flatMap((server) => Object.keys(this.#mcp[server] ?? {}).map((tool) => toolName(server, tool)));
558
+ return [...plan.toolNames, ...mcp];
559
+ }
560
+ #requiresApproval(name) {
561
+ const mcp = this.#mcpTool(name);
562
+ if (mcp)
563
+ return this.#registered.mcpServers.get(mcp.server)?.approval !== undefined;
564
+ return this.#registered.tools.get(name)?.approval !== undefined;
565
+ }
566
+ async #run(name, argumentsJson) {
567
+ const mcp = this.#mcpTool(name);
568
+ try {
569
+ if (!mcp)
570
+ return await runTool(this.#registered, this.#agent(), name, argumentsJson);
571
+ const text = await this.#mcp[mcp.server][mcp.tool](JSON.parse(argumentsJson || "{}"));
572
+ for (const g of [...this.#registered.resultGuardrails.values()].sort((a, b) => a.name.localeCompare(b.name))) {
573
+ const reason = await g.check(name, text);
574
+ if (reason)
575
+ return `A result guardrail refused what this tool answered, so it is not shown: ${g.name}: ${reason}`;
576
+ }
577
+ return text;
578
+ }
579
+ catch (e) {
580
+ return `error: ${e instanceof Error ? e.message : String(e)}`;
581
+ }
582
+ }
583
+ async #goOn(turn) {
584
+ const offered = this.#offered(turn.plan);
585
+ while (turn.steps <= this.#registered.maxToolCallSteps) {
586
+ const answer = this.#model._next({ system: turn.plan.system, messages: [...turn.messages], tools: offered });
425
587
  switch (answer.kind) {
426
588
  case "refusal":
589
+ this.#turn = undefined;
427
590
  throw new CommandError({ message: answer.message, code: ErrorCode.BadRequest });
428
591
  case "tool-call": {
429
- if (!plan.toolNames.includes(answer.tool))
592
+ turn.steps++;
593
+ if (!offered.includes(answer.tool))
430
594
  throw new Error(`the model called ${JSON.stringify(answer.tool)}, which the plan did not offer`);
431
- let result;
432
- try {
433
- result = await runTool(this.#registered, this.#agent(), answer.tool, answer.argumentsJson);
434
- }
435
- catch (e) {
436
- result = `error: ${e instanceof Error ? e.message : String(e)}`;
595
+ turn.messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` });
596
+ if (this.#requiresApproval(answer.tool)) {
597
+ const request = Object.freeze({
598
+ id: `approval-${++this.#nextId}`,
599
+ tool: answer.tool,
600
+ arguments: JSON.parse(answer.argumentsJson || "{}"),
601
+ requestedAt: Date.now(),
602
+ });
603
+ turn.pending = { request, tool: answer.tool, argumentsJson: answer.argumentsJson };
604
+ return { kind: "awaiting-approval", requests: [request] };
437
605
  }
438
- messages.push({ role: "assistant", text: `[tool call ${answer.tool} ${answer.argumentsJson}]` }, { role: "tool", text: result });
606
+ turn.messages.push({ role: "tool", text: await this.#run(answer.tool, answer.argumentsJson) });
439
607
  break;
440
608
  }
441
609
  case "text": {
442
- await guard("output", answer.text);
443
- if (plan.sessionMemory)
444
- this.history.push({ role: "user", text: plan.user ?? "" }, { role: "assistant", text: answer.text });
445
- return answer.text;
610
+ this.#turn = undefined;
611
+ await this.#guard(turn.plan, "output", answer.text);
612
+ if (turn.plan.sessionMemory)
613
+ this.history.push({ role: "user", text: turn.plan.user ?? "" }, { role: "assistant", text: answer.text });
614
+ return { kind: "answered", value: answer.text };
446
615
  }
447
616
  }
448
617
  }
618
+ this.#turn = undefined;
449
619
  throw new CommandError({ message: `the agent made more than ${this.#registered.maxToolCallSteps} tool calls`, code: ErrorCode.Internal });
450
620
  }
451
621
  /** A streaming handler's turn, token by token: the reply split on spaces, as a model streams words. */
@@ -34,15 +34,28 @@ export declare class Response {
34
34
  export interface RequestOptions {
35
35
  readonly query?: Readonly<Record<string, string | readonly string[]>>;
36
36
  readonly headers?: Readonly<Record<string, string>>;
37
- readonly principal?: Principal;
37
+ /** The verified caller, as the runtime would hand it over; `claims` and `issuer` may be left out. */
38
+ readonly principal?: Omit<Principal, "claims" | "issuer"> & Partial<Pick<Principal, "claims" | "issuer">>;
38
39
  readonly metadata?: Metadata;
39
40
  }
41
+ /** What a socket route's handler did with a scripted socket. */
42
+ export interface SocketRun {
43
+ readonly sent: readonly string[];
44
+ readonly ended: "finished" | "failed";
45
+ readonly error?: string;
46
+ }
40
47
  /** Calls an endpoint's routes by path, matching them as the sidecar's router does: literal segments outrank parameters. */
41
48
  export declare class EndpointTestKit<C extends Endpoint> {
42
49
  #private;
43
50
  private constructor();
44
51
  static of<C extends Endpoint>(cls: EndpointClass<C>, client?: ComponentClient): EndpointTestKit<C>;
45
52
  request(method: string, path: string, body?: unknown, options?: RequestOptions): Promise<Response>;
53
+ /**
54
+ * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
55
+ * socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
56
+ * order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
57
+ */
58
+ socket(path: string, frames?: readonly string[], options?: RequestOptions): Promise<SocketRun>;
46
59
  /** The frames of an SSE route, each string one frame. */
47
60
  sse(path: string, options?: RequestOptions): Promise<string[]>;
48
61
  get(path: string, options?: RequestOptions): Promise<Response>;
@@ -4,7 +4,7 @@
4
4
  // reduction is the server's (`materialise.ts`), so a test cannot pass on something the sidecar would
5
5
  // not do.
6
6
  import { create } from "@bufbuild/protobuf";
7
- import { HttpRequestSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
7
+ import { HttpRequestSchema, SocketInSchema } from "../_proto/ankka/protocol/v1/endpoint_pb.js";
8
8
  import { ComponentClient, noClient } from "../client.js";
9
9
  import { binaryCodecs, codecFor } from "../codec.js";
10
10
  import { commandContext } from "../context.js";
@@ -119,7 +119,8 @@ export class EndpointTestKit {
119
119
  const rest = withoutQuery.startsWith(prefix) ? withoutQuery.slice(prefix.length) || "/" : withoutQuery;
120
120
  const candidates = [];
121
121
  for (const [id, route] of Object.entries(this.#cls.routes)) {
122
- if (route.method !== method.toUpperCase())
122
+ // A socket route is opened through `socket`, never answered as a GET.
123
+ if ((route.socket ? "SOCKET" : route.method) !== method.toUpperCase())
123
124
  continue;
124
125
  const pattern = new RegExp("^" + route.template.replace(/[.*+?^$()|[\]\\]/g, "\\$&").replace(PLACEHOLDER, "([^/]+)") + "$");
125
126
  const m = pattern.exec(rest);
@@ -156,7 +157,15 @@ export class EndpointTestKit {
156
157
  }
157
158
  const headers = Object.entries(options.headers ?? {}).map(([name, value]) => ({ name, value }));
158
159
  const principal = options.principal
159
- ? { subject: options.principal.subject, name: options.principal.name ?? undefined, email: options.principal.email ?? undefined, emailVerified: options.principal.emailVerified, roles: [...options.principal.roles] }
160
+ ? {
161
+ subject: options.principal.subject,
162
+ name: options.principal.name ?? undefined,
163
+ email: options.principal.email ?? undefined,
164
+ emailVerified: options.principal.emailVerified,
165
+ roles: [...options.principal.roles],
166
+ claims: { ...(options.principal.claims ?? {}) },
167
+ issuer: options.principal.issuer ?? undefined,
168
+ }
160
169
  : undefined;
161
170
  const req = create(HttpRequestSchema, {
162
171
  endpointId: this.#cls.name,
@@ -192,6 +201,34 @@ export class EndpointTestKit {
192
201
  }
193
202
  return new Response(500, "text/plain", new TextEncoder().encode(reply.message.case === "failure" ? reply.message.value.error?.message ?? "failure" : "no reply"));
194
203
  }
204
+ /**
205
+ * Opens a socket route with no runtime: the handler is given `frames` one at a time and then told the
206
+ * socket is closed, as when the client closes it after sending them. Answers what the handler sent, in
207
+ * order, and how it ended: `finished` when it returned, `failed` (with `error`) when it threw.
208
+ */
209
+ async socket(path, frames = [], options = {}) {
210
+ const prepared = this.#request("SOCKET", path, undefined, options);
211
+ if (!prepared)
212
+ throw new Error(`no socket route at ${path}`);
213
+ const requests = [
214
+ create(SocketInSchema, { message: { case: "open", value: prepared.req } }),
215
+ ...frames.map((text) => create(SocketInSchema, { message: { case: "frame", value: { kind: { case: "text", value: text } } } })),
216
+ create(SocketInSchema, { message: { case: "closed", value: { reason: "client" } } }),
217
+ ];
218
+ async function* script() {
219
+ yield* requests;
220
+ }
221
+ const sent = [];
222
+ for await (const out of this.#dispatcher.handleSocket(script())) {
223
+ if (out.message.case === "frame" && out.message.value.kind.case === "text")
224
+ sent.push(out.message.value.kind.value);
225
+ if (out.message.case === "completed")
226
+ return { sent, ended: "finished" };
227
+ if (out.message.case === "failed")
228
+ return { sent, ended: "failed", error: out.message.value.message };
229
+ }
230
+ return { sent, ended: "finished" };
231
+ }
195
232
  /** The frames of an SSE route, each string one frame. */
196
233
  async sse(path, options = {}) {
197
234
  const prepared = this.#request("GET", path, undefined, options);
@@ -1,13 +1,28 @@
1
1
  import type { Metadata } from "./effects/common.ts";
2
- import type { ComponentClient } from "./client.ts";
2
+ import { type ComponentClient, type Secrets } from "./client.ts";
3
3
  import type { HandlerTable } from "./handlers.ts";
4
4
  import { TimedActionEffects } from "./effects/stateless.ts";
5
+ import { type Services } from "./services.ts";
5
6
  export declare abstract class TimedAction {
6
7
  #private;
7
8
  readonly effects: TimedActionEffects;
8
- /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired. */
9
+ /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired, `ankka.due` the due time in epoch milliseconds. */
9
10
  get metadata(): Metadata;
11
+ /**
12
+ * The due time this run is for, from `ankka.due`: the same on every retry of one due time, and for a
13
+ * recurring timer a whole number of periods after the one before. `undefined` on a runtime older
14
+ * than protocol 1.12, which does not send it.
15
+ */
16
+ get dueTime(): Date | undefined;
10
17
  get client(): ComponentClient;
18
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
19
+ get secrets(): Secrets;
20
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
21
+ set secrets(store: Secrets);
22
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
23
+ get services(): Services;
24
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
25
+ set services(services: Services);
11
26
  /** @internal */
12
27
  get _kind(): "timed-action";
13
28
  /** @internal */
@@ -2,20 +2,52 @@
2
2
  // sweeper delivers it here, retrying a failure with backoff.
3
3
  //
4
4
  // static readonly actions = { remind: action("remind", s.string, (t: Reminder, id) => t.remind(id)) }
5
+ import { secretsFor } from "./client.js";
5
6
  import { TimedActionEffects } from "./effects/stateless.js";
7
+ import { servicesFor } from "./services.js";
6
8
  export class TimedAction {
7
9
  effects = new TimedActionEffects();
8
10
  #metadata = {};
9
11
  #client;
10
- /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired. */
12
+ /** `ankka.timer` is the timer's id, `ankka.attempts` how many times it has fired, `ankka.due` the due time in epoch milliseconds. */
11
13
  get metadata() {
12
14
  return this.#metadata;
13
15
  }
16
+ /**
17
+ * The due time this run is for, from `ankka.due`: the same on every retry of one due time, and for a
18
+ * recurring timer a whole number of periods after the one before. `undefined` on a runtime older
19
+ * than protocol 1.12, which does not send it.
20
+ */
21
+ get dueTime() {
22
+ const raw = this.#metadata["ankka.due"];
23
+ if (raw === undefined || !/^-?\d+$/.test(raw.trim()))
24
+ return undefined;
25
+ const date = new Date(Number(raw.trim()));
26
+ return Number.isNaN(date.getTime()) ? undefined : date;
27
+ }
14
28
  get client() {
15
29
  if (!this.#client)
16
30
  throw new Error("client is only available inside an action");
17
31
  return this.#client;
18
32
  }
33
+ #secrets;
34
+ /** The service's secret store: values kept encrypted in the service's own database, never in a journal or a view. */
35
+ get secrets() {
36
+ return this.#secrets ?? secretsFor(this.client);
37
+ }
38
+ /** A unit test's store in place of the runtime's: `component.secrets = new InMemorySecrets()`. */
39
+ set secrets(store) {
40
+ this.#secrets = store;
41
+ }
42
+ #services;
43
+ /** Other services, called as this one, through the runtime: `this.services.service("orders")`. */
44
+ get services() {
45
+ return this.#services ?? servicesFor(this.client);
46
+ }
47
+ /** A unit test's in place of the runtime's: `component.services = new ScriptedServices()`. */
48
+ set services(services) {
49
+ this.#services = services;
50
+ }
19
51
  /** @internal */
20
52
  get _kind() {
21
53
  return "timed-action";
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.10.0";
1
+ export declare const VERSION = "0.11.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Written by scripts/proto.ts from package.json; do not edit. 0.0.0 in the tree, the tag's version in a release.
2
- export const VERSION = "0.10.0";
2
+ export const VERSION = "0.11.0";
package/dist/view.d.ts CHANGED
@@ -1,9 +1,26 @@
1
+ import type { StartFrom } from "./startFrom.ts";
2
+ import type { Contract } from "./contract.ts";
1
3
  import type { Shape } from "./codec.ts";
2
4
  import type { Metadata } from "./effects/common.ts";
3
5
  import type { ComponentClient } from "./client.ts";
4
6
  import type { ComponentRef } from "./client.ts";
5
7
  import { ViewEffects, type ViewEffect } from "./effects/stateless.ts";
6
8
  type MaybePromise<T> = T | Promise<T>;
9
+ /**
10
+ * A question a view can be asked by name: one SQL statement over the view's own table, whose values
11
+ * are the `:name`s it holds. The platform checks the statement when the service starts — one that is
12
+ * not a single read of the view's own table stops the service, naming the view and the query — and
13
+ * binds each value as a parameter, never as part of the text. The answer is rows of the view's row
14
+ * shape, read from the statement's `payload` column.
15
+ */
16
+ export interface DeclaredQuery {
17
+ readonly name: string;
18
+ readonly statement: string;
19
+ }
20
+ /** Declares a query for a view's `static declared`. The SDK sends the statement as written. */
21
+ export declare function declaredQuery(name: string, statement: string): DeclaredQuery;
22
+ /** The table a view's rows are kept in, for a declared query's statement to name. */
23
+ export declare function tableOf(componentId: string): string;
7
24
  export declare abstract class View<E, Row> {
8
25
  #private;
9
26
  readonly effects: ViewEffects<Row>;
@@ -29,9 +46,24 @@ export interface ViewClass<E = unknown, Row = unknown, C extends View<E, Row> =
29
46
  /** The component whose changes feed the view, or `topic`. */
30
47
  readonly source?: ComponentRef;
31
48
  readonly topic?: string;
49
+ /** Where a topic source starts the first time its group reads the topic. */
50
+ readonly startFrom?: StartFrom;
51
+ /**
52
+ * Raised to have the view emptied and read again: a topic from its start position, under a group of
53
+ * its own, as far back as the broker retains; an entity from its first event or state. Absent is 1.
54
+ */
55
+ readonly version?: number;
56
+ /** The contract the topic read is expected to carry, checked at start against the project's declaration. */
57
+ readonly contract?: Contract;
58
+ /** The declared broker the topic read is on; absent is the installation's. */
59
+ readonly broker?: string;
60
+ /** Whether the partitions an instance holds are handled at once, each in order. */
61
+ readonly parallel?: boolean;
32
62
  readonly events: Shape<E>;
33
63
  readonly row: Shape<Row>;
34
64
  /** The query names the view answers; `get` and `all` by default. */
35
65
  readonly queries?: readonly string[];
66
+ /** The queries the view can be asked by name, each a statement over its own table (protocol 1.13). */
67
+ readonly declared?: readonly DeclaredQuery[];
36
68
  }
37
69
  export {};
package/dist/view.js CHANGED
@@ -9,6 +9,14 @@
9
9
  // onChange(event) { ... return this.effects.updateRow(...) }
10
10
  // }
11
11
  import { ViewEffects } from "./effects/stateless.js";
12
+ /** Declares a query for a view's `static declared`. The SDK sends the statement as written. */
13
+ export function declaredQuery(name, statement) {
14
+ return Object.freeze({ name, statement });
15
+ }
16
+ /** The table a view's rows are kept in, for a declared query's statement to name. */
17
+ export function tableOf(componentId) {
18
+ return "ankka_view_" + componentId.replace(/[^A-Za-z0-9]/g, "_");
19
+ }
12
20
  export class View {
13
21
  effects = new ViewEffects();
14
22
  #row = null;