@superdurable/dex-agents 0.2.0 → 0.4.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 (92) hide show
  1. package/README.md +143 -6
  2. package/dist/src/agent-context-compaction.d.ts +150 -4
  3. package/dist/src/agent-context-compaction.js +253 -5
  4. package/dist/src/agent-context-compaction.js.map +1 -1
  5. package/dist/src/agent-framework-runner.d.ts +89 -0
  6. package/dist/src/agent-framework-runner.js +26 -0
  7. package/dist/src/agent-framework-runner.js.map +1 -1
  8. package/dist/src/agent-session-codecs.js +9 -0
  9. package/dist/src/agent-session-codecs.js.map +1 -1
  10. package/dist/src/agent-session-failure.d.ts +58 -0
  11. package/dist/src/agent-session-failure.js +126 -0
  12. package/dist/src/agent-session-failure.js.map +1 -0
  13. package/dist/src/agent-session-flow-graph-cli.d.ts +2 -0
  14. package/dist/src/agent-session-flow-graph-cli.js +47 -0
  15. package/dist/src/agent-session-flow-graph-cli.js.map +1 -0
  16. package/dist/src/agent-session-flow-graph.d.ts +38 -0
  17. package/dist/src/agent-session-flow-graph.js +54 -0
  18. package/dist/src/agent-session-flow-graph.js.map +1 -0
  19. package/dist/src/agent-session-flow-graph.json +2204 -0
  20. package/dist/src/agent-session-flow.d.ts +23 -2
  21. package/dist/src/agent-session-flow.js +5 -0
  22. package/dist/src/agent-session-flow.js.map +1 -1
  23. package/dist/src/agent-session-spend-limit.d.ts +156 -0
  24. package/dist/src/agent-session-spend-limit.js +42 -0
  25. package/dist/src/agent-session-spend-limit.js.map +1 -0
  26. package/dist/src/agent-session-start.d.ts +49 -0
  27. package/dist/src/agent-session-start.js +67 -0
  28. package/dist/src/agent-session-start.js.map +1 -0
  29. package/dist/src/agent-session-step-options.d.ts +52 -1
  30. package/dist/src/agent-session-step-options.js +34 -2
  31. package/dist/src/agent-session-step-options.js.map +1 -1
  32. package/dist/src/agent-session-steps.d.ts +9 -0
  33. package/dist/src/agent-session-steps.js +247 -50
  34. package/dist/src/agent-session-steps.js.map +1 -1
  35. package/dist/src/agent-session-transitions.d.ts +14 -1
  36. package/dist/src/agent-session-transitions.js +61 -1
  37. package/dist/src/agent-session-transitions.js.map +1 -1
  38. package/dist/src/agent-session-values.d.ts +43 -2
  39. package/dist/src/agent-session-values.js +6 -0
  40. package/dist/src/agent-session-values.js.map +1 -1
  41. package/dist/src/agent-session-wait-steps.js +3 -3
  42. package/dist/src/agent-session-wait-steps.js.map +1 -1
  43. package/dist/src/connectors/connector-input-schema.d.ts +19 -0
  44. package/dist/src/connectors/connector-input-schema.js +228 -0
  45. package/dist/src/connectors/connector-input-schema.js.map +1 -0
  46. package/dist/src/connectors/connector-step-context.d.ts +77 -0
  47. package/dist/src/connectors/connector-step-context.js +124 -0
  48. package/dist/src/connectors/connector-step-context.js.map +1 -0
  49. package/dist/src/connectors/connector-tool-source.d.ts +353 -0
  50. package/dist/src/connectors/connector-tool-source.js +541 -0
  51. package/dist/src/connectors/connector-tool-source.js.map +1 -0
  52. package/dist/src/connectors/index.d.ts +3 -0
  53. package/dist/src/connectors/index.js +11 -0
  54. package/dist/src/connectors/index.js.map +1 -0
  55. package/dist/src/index.d.ts +10 -3
  56. package/dist/src/index.js +8 -2
  57. package/dist/src/index.js.map +1 -1
  58. package/dist/src/stepwise/agent-definition.d.ts +8 -0
  59. package/dist/src/stepwise/index.d.ts +2 -1
  60. package/dist/src/stepwise/index.js +2 -1
  61. package/dist/src/stepwise/index.js.map +1 -1
  62. package/dist/src/stepwise/stepwise-agent-runner.d.ts +15 -32
  63. package/dist/src/stepwise/stepwise-agent-runner.js +37 -200
  64. package/dist/src/stepwise/stepwise-agent-runner.js.map +1 -1
  65. package/dist/src/stepwise/stepwise-agent-session-flow.js +1 -0
  66. package/dist/src/stepwise/stepwise-agent-session-flow.js.map +1 -1
  67. package/dist/src/stepwise/tool-source.d.ts +11 -1
  68. package/dist/src/testing/agent-session-test-client.js +2 -5
  69. package/dist/src/testing/agent-session-test-client.js.map +1 -1
  70. package/dist/src/testing/context-window.d.ts +194 -0
  71. package/dist/src/testing/context-window.js +360 -0
  72. package/dist/src/testing/context-window.js.map +1 -0
  73. package/dist/src/testing/index.d.ts +1 -0
  74. package/dist/src/testing/index.js +1 -0
  75. package/dist/src/testing/index.js.map +1 -1
  76. package/dist/src/tool-source-router.d.ts +211 -0
  77. package/dist/src/tool-source-router.js +380 -0
  78. package/dist/src/tool-source-router.js.map +1 -0
  79. package/dist/src/ui-stream/agent-event-sse.d.ts +3 -2
  80. package/dist/src/ui-stream/agent-event-sse.js +11 -3
  81. package/dist/src/ui-stream/agent-event-sse.js.map +1 -1
  82. package/dist/src/ui-stream/agent-session-record-reader.d.ts +2 -0
  83. package/dist/src/ui-stream/agent-session-record-reader.js +5 -0
  84. package/dist/src/ui-stream/agent-session-record-reader.js.map +1 -1
  85. package/dist/src/ui-stream/agent-ui-message-stream-responses.js +3 -2
  86. package/dist/src/ui-stream/agent-ui-message-stream-responses.js.map +1 -1
  87. package/dist/src/ui-stream/agent-ui-message-stream.js +17 -3
  88. package/dist/src/ui-stream/agent-ui-message-stream.js.map +1 -1
  89. package/dist/src/ui-stream/agent-ui-messages.d.ts +2 -0
  90. package/dist/src/ui-stream/agent-ui-messages.js +5 -3
  91. package/dist/src/ui-stream/agent-ui-messages.js.map +1 -1
  92. package/package.json +15 -3
package/README.md CHANGED
@@ -27,10 +27,10 @@ Applications usually install an adapter next to this package. To build a
27
27
  runner of your own, install the core with the Dex SDK it supports:
28
28
 
29
29
  ```bash
30
- npm i @superdurable/dex-agents @superdurable/dex@1.35.0
30
+ npm i @superdurable/dex-agents @superdurable/dex@1.38.0
31
31
  ```
32
32
 
33
- `@superdurable/dex` 1.35.0 is a peer dependency, so the application, the core,
33
+ `@superdurable/dex` 1.38.0 is a peer dependency, so the application, the core,
34
34
  and every adapter share one copy. The Dex Registry matches Flow classes and
35
35
  RPC registrations by identity, so a second copy makes it reject the Flow
36
36
  (`steps must be a StepList`). Adapters list this package as a peer dependency
@@ -52,6 +52,7 @@ the published core. It is for tests only; it needs `DEXCLI` and
52
52
  | `@superdurable/dex-agents/stepwise` | Stepwise mode: `createStepwiseAgentSessionFlow`, `functionTool`, `ToolSource`, `ModelClient`, `ScriptedModelClient` | none |
53
53
  | `@superdurable/dex-agents/ai-sdk` | `createAiSdkModelClient` for any AI SDK `LanguageModel` | optional peer `ai@^7.0.159`, the Vercel AI SDK adapter's range |
54
54
  | `@superdurable/dex-agents/mcp` | MCP servers' tools as agent tools; see [MCP tools](#mcp-tools-superdurabledex-agentsmcp) | optional peer `@modelcontextprotocol/sdk@^1.32.2` |
55
+ | `@superdurable/dex-agents/connectors` | Reviewed Connector operations as agent tools; see [Connector tools](#connector-tools-superdurabledex-agentsconnectors) | optional peer `@superdurable/connectors-sdk` |
55
56
  | `@superdurable/dex-agents/ui-stream` | Browser streams of live sessions; see [UI streams](#ui-streams) | none |
56
57
  | `@superdurable/dex-agents/testing` | Test engine, Worker processes, waits, and `ScriptedModelClient` (the same class as the stepwise entry point's) | none |
57
58
 
@@ -77,7 +78,7 @@ agents packages share one version line; see the
77
78
 
78
79
  ```ts
79
80
  import { Client, Registry, Worker, openBlobCache } from "@superdurable/dex";
80
- import { createAgentSessionFlow } from "@superdurable/dex-agents";
81
+ import { createAgentSessionFlow, startAgentSession } from "@superdurable/dex-agents";
81
82
 
82
83
  const flow = createAgentSessionFlow(new MyFrameworkRunner(agent), "SupportAgent", {
83
84
  toolPolicies: { search_orders: { effect: "read" } },
@@ -90,7 +91,7 @@ await worker.start();
90
91
  await worker.waitPullRegistered({ timeoutMs: 10_000 });
91
92
  const client = new Client(registry, blobCache, { serverAddress: "127.0.0.1:8801" });
92
93
 
93
- await client.startFlow(flow, "session-42", { initialMessage: "Where is order 42?" });
94
+ await startAgentSession(client, flow, "session-42", { initialMessage: "Where is order 42?" });
94
95
  const snapshot = await client.invokeRPC(flow.getAgentSnapshot, "session-42");
95
96
  for (const interrupt of snapshot.pendingInterrupts) {
96
97
  await client.invokeRPC(flow.resolveInterrupt, "session-42", {
@@ -533,6 +534,21 @@ it. The AI SDK UI message stream sends
533
534
  the compaction events and the summary call's `usage_recorded` as event data,
534
535
  outside the assistant message's steps.
535
536
 
537
+ A framework adapter cannot measure or rebuild the framework's own request, so
538
+ the core exports one shared rule. `estimateStoredInputTokens(turn,
539
+ fixedInputTokens)` counts each stored `frameworkItem` with `estimateTextTokens`,
540
+ three UTF-8 bytes per token, which errs high for English text.
541
+ `selectTurnBoundaryCutoff(turn, retainedTokenBudget)` cuts only between turns,
542
+ never inside the running turn, and returns `null` while `turn.frameworkRunState`
543
+ is set, because a resumed call continues from that state.
544
+ `buildContextSummaryPrompt(request)` writes the range as one plain text prompt,
545
+ starting with `contextSummaryTranscriptHeader`, that fits `inputTokenBudget`,
546
+ byte for byte as the Python core writes it. `CompactionPolicy.reservedInputTokens`
547
+ adds what an adapter's estimate cannot see, such as a framework's system prompt.
548
+ The adapters of this repository use them; see each adapter's README.
549
+ `ContextWindowSessionScenario` from `/testing` runs the same session past a
550
+ small context window for any adapter.
551
+
536
552
  ## Session settings
537
553
 
538
554
  Session settings let one Flow type serve sessions that differ, such as by
@@ -556,7 +572,7 @@ const flow = createStepwiseAgentSessionFlow(
556
572
  );
557
573
 
558
574
  validateSessionSettings({ model: "gpt-5" });
559
- await client.startFlow(flow, sessionId, { initialMessage: "Hello", sessionSettings: { model: "gpt-5" } });
575
+ await startAgentSession(client, flow, sessionId, { initialMessage: "Hello", sessionSettings: { model: "gpt-5" } });
560
576
  await client.invokeRPC(flow.updateSessionSettings, sessionId, { sessionSettings: { model: "gpt-5-mini" } });
561
577
  ```
562
578
 
@@ -978,6 +994,29 @@ adapter-specific events a prefix, such as `"vercel_ai.step_finished"`. The
978
994
  writer coalesces consecutive deltas of one type for up to 100 ms or 1,024
979
995
  characters, and drops writes after the handler returns.
980
996
 
997
+ ## Finding sessions that need a person
998
+
999
+ `startAgentSession` makes a session searchable by the Steps it waits in, so one query finds every session waiting
1000
+ for a person, across agents:
1001
+
1002
+ ```ts
1003
+ await startAgentSession(client, flow, "session-42", { initialMessage: "Refund order 7" });
1004
+ const waiting = await client.searchFlows('ActiveStepTypes = "AwaitToolApproval"', 50);
1005
+ ```
1006
+
1007
+ `AwaitQuestionAnswer` finds questions the same way. A session leaves the search when it moves on. `Client.startFlow`
1008
+ does not turn this on; pass `configOverride: { activeStepSearchMode: agentSessionActiveStepSearchMode }` there, or use
1009
+ `Client.updateFlowConfig` for a running session. See
1010
+ [Find sessions that need a person](https://docs.superdurable.io/agent-frameworks/deploy-agents#find-sessions).
1011
+
1012
+ ## Spend limits
1013
+
1014
+ `ModelSpendLimit` is an application's spend limit, usually per account or API key
1015
+ across many sessions. The session asks it before each model call and reports what
1016
+ the call used. Counters and limits belong to the application. Without one, the
1017
+ session makes no checks. See the interface's documentation and
1018
+ [Spend limits](https://docs.superdurable.io/agent-frameworks/deploy-agents#spend-limits).
1019
+
981
1020
  ## Flow shape
982
1021
 
983
1022
  ```text
@@ -1157,7 +1196,7 @@ It needs `@modelcontextprotocol/sdk` 1.x, an optional peer dependency; the main
1157
1196
  entry point never imports it:
1158
1197
 
1159
1198
  ```bash
1160
- npm i @superdurable/dex-agents @superdurable/dex@1.35.0 @modelcontextprotocol/sdk zod
1199
+ npm i @superdurable/dex-agents @superdurable/dex@1.38.0 @modelcontextprotocol/sdk zod
1161
1200
  ```
1162
1201
 
1163
1202
  Configure the servers on the Worker, never from Flow input, a user message, or
@@ -1361,6 +1400,104 @@ Steps; use the source there for the shared idempotency key and annotation
1361
1400
  handling. The OpenAI Agents SDK runs its own MCP servers inline in `RunModel`,
1362
1401
  so a wrapper is its only path to tool Steps.
1363
1402
 
1403
+ ## Connector tools (`@superdurable/dex-agents/connectors`)
1404
+
1405
+ `@superdurable/dex-agents/connectors` offers reviewed Connector operations to a
1406
+ Stepwise agent as tools. `ConnectorToolSource` implements `ToolSource`, like
1407
+ `McpToolSource`. Each call runs one operation in its own tool Step through the
1408
+ Connector SDK's `runQuery` or `runMutation`, so the connector's Call ID,
1409
+ idempotency key, credential handling, and single-dispatch fence apply. Install
1410
+ the Connector SDK, an optional peer dependency, and each Connector package the
1411
+ agent uses:
1412
+
1413
+ ```bash
1414
+ npm i @superdurable/connectors-sdk @superdurable/connector-stripe
1415
+ ```
1416
+
1417
+ The subpath needs Connector SDK 0.9.0, the first release that exports
1418
+ `throwDexRetry`, and Connector packages that export `./dex-connector.json`.
1419
+
1420
+ ```ts
1421
+ import manifest from "@superdurable/connector-stripe/dex-connector.json" with { type: "json" };
1422
+ import { CreateRefundInputSchema, newProjectConnection } from "@superdurable/connector-stripe";
1423
+ import { ConnectorToolSource, connectorTool } from "@superdurable/dex-agents/connectors";
1424
+
1425
+ const stripe = newProjectConnection(project, "billing-stripe");
1426
+ const source = new ConnectorToolSource([
1427
+ connectorTool(stripe.operations.createRefund(), stripe.reference, {
1428
+ manifest,
1429
+ inputValidator: CreateRefundInputSchema,
1430
+ requiresApproval: (args) => Number(args["amount"]) > 5000,
1431
+ }),
1432
+ ]);
1433
+ ```
1434
+
1435
+ Nothing is exposed by default. `connectorTool` takes the package's parsed
1436
+ `dex-connector.json` and the input validator the package exports, and refuses
1437
+ an operation whose entry lacks `agent.exposed: true`, an `agent` value it does
1438
+ not know, an `agent.branches` key that is not an optional branch (or an
1439
+ `unknown` one on a Query), or an `inputSchema` keyword it cannot check. The
1440
+ tool name defaults to `<connectorId>_<operation in snake case>`; the
1441
+ description and argument schema come from the manifest. A Mutation is any
1442
+ operation with an `idempotencyKey` method.
1443
+
1444
+ | Operation | Effect | Tool Step | Retries |
1445
+ | --- | --- | --- | --- |
1446
+ | Query | `read` | `CallTool` | The operation's Execute retry policy, at most three attempts |
1447
+ | Mutation with `agent.retry: safe` | `idempotent_write` | `CallTool` | The same |
1448
+ | Any other Mutation | `non_idempotent_write` | `CallSingleAttemptTool` | None |
1449
+
1450
+ The source gives each call's tool Step the operation's `executeMethodTimeoutMs`
1451
+ and retry policy through `toolCallStepOptions`, a movement-scoped option. A
1452
+ session `toolPolicies` entry for a connector tool changes only its approval;
1453
+ the effect always comes from the manifest. Approval follows
1454
+ `agent.approval: recommended`; `requiresApproval` overrides it with a boolean
1455
+ or a per-call predicate over the decoded arguments, and a predicate that throws
1456
+ asks for approval.
1457
+
1458
+ A call checks the arguments against `inputSchema`, then decodes them with the
1459
+ input validator; either failure is a `ToolExecutionError` the model reads, and
1460
+ the operation never runs. The Call ID comes from
1461
+ `deriveCallID(flowId, stepExecutionId, operation)` over
1462
+ `DeferredToolExecution.context`, the same on every attempt. The operation sees
1463
+ a `ConnectorStepContext`, which stores each heartbeat value, such as the
1464
+ fence's marker, as the tool Step's text checkpoint in the Python core's JSON
1465
+ form, so the session's heartbeats repeat it and the next attempt reads it
1466
+ back. A `RetryError` goes through `throwDexRetry`, except for a single-attempt write
1467
+ whose fence saved `notSent` or `canResend` in this attempt: that records `failed` with
1468
+ `connectorNotSentRetryLater` instead of `outcome_unknown`.
1469
+
1470
+ | Branch | Record | The model reads |
1471
+ | --- | --- | --- |
1472
+ | A required (success) branch | `succeeded` | The value as compact JSON, keys sorted |
1473
+ | Listed `rejected` under `agent.branches` | `failed` (`ToolExecutionError`) | `<connector> <operation> returned <branch>: <kind>: <message>` |
1474
+ | `defect` | `failed` | The same text |
1475
+ | A Query branch not listed | `failed` | The same text |
1476
+ | `uncertain`, listed `unknown`, or a Mutation branch not listed | `outcome_unknown` (`ToolOutcomeUnknownError`) | `outcomeUnknownToolResult` |
1477
+
1478
+ A result over `maxResultBytes` (default `defaultConnectorResultMaxBytes`,
1479
+ 64 KiB of UTF-8 text, set per source or per tool) is cut, followed by
1480
+ `connectorResultTruncatedNote`, and `AgentToolCall.truncatedOutputBytes` holds
1481
+ the full size.
1482
+
1483
+ Writes are ordered: the source marks every connector write
1484
+ `DeferredToolCall.ordered`, so the writes of one model response run one at a
1485
+ time in the model's order while reads run in parallel. `RunModel` starts the
1486
+ next queued write (`AgentToolCall.writeQueued`) before any model call and raises
1487
+ its approval only then. Once a write is not `succeeded`, the response's later
1488
+ writes record `failed` with `skipped: true` and the error
1489
+ `` `${toolCallNotRunErrorPrefix}: ${toolCallSkippedAfterWriteDetail}` ``.
1490
+ Steering and `cancel_turn` cancel queued writes as they cancel unstarted calls.
1491
+
1492
+ The Vercel AI, OpenAI Agents, and Mastra adapters take tool sources, such as
1493
+ `ConnectorToolSource`, in their own `toolSources` option; see each adapter's
1494
+ README. An adapter serves them through `ToolSourceRouter`, exported from the
1495
+ package root: it lists the sources in each `RunModel`, refuses name
1496
+ collisions, lets the source build each deferred call, so the effect, the
1497
+ approval, and the write order come from the source, and routes the tool Step,
1498
+ its Step options, and polls back to that source. The Stepwise runner uses it
1499
+ too.
1500
+
1364
1501
  ## UI streams
1365
1502
 
1366
1503
  `@superdurable/dex-agents/ui-stream` lets a browser render a live session
@@ -1,4 +1,4 @@
1
- import type { AgentEventWriter, AgentFrameworkRunner, DeferredToolOutcome, ModelCallMetadata } from "./agent-framework-runner.js";
1
+ import type { AgentEventWriter, AgentFrameworkRunner, AgentTurnContext, DeferredToolOutcome, ModelCallMetadata } from "./agent-framework-runner.js";
2
2
  import type { AgentMessage, AgentUsage, ContextSummary, SessionSettings } from "./agent-session-values.js";
3
3
  /**
4
4
  * When a session summarizes its earlier messages so the model input stays under the context window, passed
@@ -27,6 +27,13 @@ export interface CompactionPolicy {
27
27
  readonly triggerRatio?: number;
28
28
  /** Share of the window that the newest messages may keep unsummarized. Defaults to 0.3. */
29
29
  readonly retainedRatio?: number;
30
+ /**
31
+ * Tokens of every model input that the runner's estimate cannot see, such as a system prompt that a
32
+ * framework builds, a dynamic instruction, or tools listed at run time. The session adds them to the
33
+ * estimate. Defaults to 0.
34
+ * @since 0.3.0
35
+ */
36
+ readonly reservedInputTokens?: number;
30
37
  }
31
38
  /** A {@link CompactionPolicy} with every default filled in. */
32
39
  export interface ResolvedCompactionPolicy {
@@ -36,7 +43,143 @@ export interface ResolvedCompactionPolicy {
36
43
  readonly triggerRatio: number;
37
44
  /** Share of the window that the newest messages may keep. */
38
45
  readonly retainedRatio: number;
46
+ /**
47
+ * Tokens added to every estimate. `undefined` reads as 0.
48
+ * @since 0.3.0
49
+ */
50
+ readonly reservedInputTokens?: number;
39
51
  }
52
+ /** First line of the user text that carries the {@link ContextSummary} into the model input. */
53
+ export declare const contextSummaryNoteHeader = "[Dex] Summary of the earlier conversation:";
54
+ /** Instructions of the model call that writes a {@link ContextSummary}. */
55
+ export declare const contextSummaryInstructions: string;
56
+ /** Last user text of the model call that writes a {@link ContextSummary}. */
57
+ export declare const contextSummaryRequest = "[Dex] Summarize the conversation above. Reply with the summary only.";
58
+ /**
59
+ * First line of the transcript in the prompt of {@link buildContextSummaryPrompt}.
60
+ * @since 0.3.0
61
+ */
62
+ export declare const contextSummaryTranscriptHeader = "[Dex] The conversation to summarize:";
63
+ /**
64
+ * The most tokens {@link buildContextSummaryPrompt} leaves free for the summary it asks for.
65
+ * @since 0.3.0
66
+ */
67
+ export declare const contextSummaryOutputTokens = 4096;
68
+ /**
69
+ * Returns the user text that says how many of the oldest messages a summary request left out.
70
+ * @param omittedCount - Messages left out, at least 1.
71
+ * @returns `[Dex] <omittedCount> earlier messages did not fit in this request and are left out.`, or
72
+ * `[Dex] 1 earlier message did not fit in this request and is left out.` for one message.
73
+ */
74
+ export declare function buildContextSummaryOmissionNote(omittedCount: number): string;
75
+ /**
76
+ * Returns the user text that puts a summary before the newer messages in a model input.
77
+ * @param summary - The session's summary.
78
+ * @returns {@link contextSummaryNoteHeader}, a newline, and the summary text.
79
+ */
80
+ export declare function buildContextSummaryNote(summary: ContextSummary): string;
81
+ /**
82
+ * Estimates the tokens of a text without a tokenizer, rounding up.
83
+ *
84
+ * Use it where a framework assembles the prompt and offers no token counter. It counts one token per three UTF-8
85
+ * bytes. That is more than a provider counts for English text, and about one token per CJK character, so the
86
+ * estimate errs toward compacting early.
87
+ *
88
+ * @example
89
+ * ```ts
90
+ * estimateTextTokens("twelve bytes"); // 4
91
+ * ```
92
+ * @param text - The text to measure.
93
+ * @returns The estimated tokens; 0 for an empty text.
94
+ * @since 0.3.0
95
+ */
96
+ export declare function estimateTextTokens(text: string): number;
97
+ /**
98
+ * Estimates a model input that a framework builds from the stored items.
99
+ *
100
+ * Call it from `estimateInputTokens` of a framework adapter. It measures each `frameworkItem` as stored, so JSON
101
+ * syntax and opaque provider items count too, plus the summary and any `frameworkRunState`.
102
+ *
103
+ * @example
104
+ * ```ts
105
+ * public estimateInputTokens(turn: AgentTurnContext): number {
106
+ * return estimateStoredInputTokens(turn, estimateTextTokens(this.instructions));
107
+ * }
108
+ * ```
109
+ * @param turn - The context of the next model call.
110
+ * @param fixedInputTokens - Tokens of what every request repeats, such as instructions and tool definitions.
111
+ * Measure what the framework shows; `CompactionPolicy.reservedInputTokens` covers the rest. Defaults to 0.
112
+ * @returns The estimated input tokens.
113
+ * @since 0.3.0
114
+ */
115
+ export declare function estimateStoredInputTokens(turn: AgentTurnContext, fixedInputTokens?: number): number;
116
+ /**
117
+ * Chooses the last message to summarize, cutting only between turns.
118
+ *
119
+ * Call it from `selectCompactionCutoff` of a framework adapter. A turn's messages are stored together, so a cut
120
+ * between turns never separates a tool call from its result and never splits a model response, whatever
121
+ * framework items the turn holds. The running turn is always kept, because the adapter reads its opening user
122
+ * message from `turn.messages`.
123
+ *
124
+ * It returns `null` while `turn.frameworkRunState` is set. A resumed model call continues from that value, which
125
+ * may already hold the older messages, so the summary could not replace them. Compaction waits for the next turn.
126
+ *
127
+ * @example
128
+ * ```ts
129
+ * public selectCompactionCutoff(turn: AgentTurnContext, retainedTokenBudget: number): number | null {
130
+ * return selectTurnBoundaryCutoff(turn, retainedTokenBudget);
131
+ * }
132
+ * ```
133
+ * @param turn - The context of the model call that crossed the threshold.
134
+ * @param retainedTokenBudget - Tokens that the kept messages may use.
135
+ * @returns The sequence of the last message to summarize: the earliest turn boundary whose kept messages fit the
136
+ * budget, else the boundary before the running turn. `null` when the turn is resuming, every message already
137
+ * fits, or no earlier turn is left to summarize.
138
+ * @since 0.3.0
139
+ */
140
+ export declare function selectTurnBoundaryCutoff(turn: AgentTurnContext, retainedTokenBudget: number): number | null;
141
+ /**
142
+ * Returns a stored message as the plain text a summary prompt shows.
143
+ *
144
+ * Wrap it in the `describeMessage` of {@link buildContextSummaryPrompt} to leave out items that only a provider can
145
+ * read.
146
+ *
147
+ * @param message - The stored message.
148
+ * @returns Its role and text, tool arguments, and tool output. A message with none of them returns its role and
149
+ * `frameworkItem`, such as a model response with several tool calls.
150
+ * @since 0.3.0
151
+ */
152
+ export declare function describeStoredMessage(message: AgentMessage): string;
153
+ /**
154
+ * Builds the one user prompt of a summary call for a framework adapter.
155
+ *
156
+ * Call it from `summarizeContext` and send the result with {@link contextSummaryInstructions} and no tools. The
157
+ * prompt holds the previous summary, the messages as plain text, and {@link contextSummaryRequest}. Plain text
158
+ * works with any framework: it needs no tool definitions, and no provider sees an item out of its place.
159
+ *
160
+ * The prompt fits `request.inputTokenBudget`, less room for the summary, as {@link estimateTextTokens} counts.
161
+ * When the range is too large, it leaves out the oldest messages and says so with
162
+ * {@link buildContextSummaryOmissionNote}. It shortens a single message only when that message alone is too
163
+ * large.
164
+ *
165
+ * @example
166
+ * ```ts
167
+ * public async summarizeContext(request: ContextSummaryRequest): Promise<ContextSummaryResult> {
168
+ * const result = await generateText({
169
+ * model,
170
+ * system: contextSummaryInstructions,
171
+ * prompt: buildContextSummaryPrompt(request),
172
+ * });
173
+ * return { text: result.text, usage: toAgentUsage(result.usage) };
174
+ * }
175
+ * ```
176
+ * @param request - The previous summary and the messages to summarize.
177
+ * @param describeMessage - Returns a message's text for the prompt, or `null` to leave it out, such as an
178
+ * encrypted reasoning item. Defaults to {@link describeStoredMessage}.
179
+ * @returns The prompt text.
180
+ * @since 0.3.0
181
+ */
182
+ export declare function buildContextSummaryPrompt(request: ContextSummaryRequest, describeMessage?: (message: AgentMessage) => string | null): string;
40
183
  /** What {@link AgentFrameworkRunner.summarizeContext} receives: the messages to fold into a new summary. */
41
184
  export interface ContextSummaryRequest {
42
185
  /** Flow ID, which is the session ID. */
@@ -98,11 +241,14 @@ export type ContextCompactionRunner = AgentFrameworkRunner<DeferredToolOutcome>
98
241
  export declare function isContextCompactionRunner(runner: AgentFrameworkRunner<DeferredToolOutcome>): runner is ContextCompactionRunner;
99
242
  /**
100
243
  * Fills in a compaction policy's defaults and checks it.
101
- * @throws {RangeError} When `contextWindowTokens` is not a positive integer, or unless
102
- * `0 < retainedRatio < triggerRatio <= 1`.
244
+ * @throws {RangeError} When `contextWindowTokens` is not a positive integer, `reservedInputTokens` is not an
245
+ * integer from 0 to below the window, or unless `0 < retainedRatio < triggerRatio <= 1`.
103
246
  */
104
247
  export declare function resolveCompactionPolicy(policy: CompactionPolicy): ResolvedCompactionPolicy;
105
- /** Reports whether a model input of this size makes the session compact before the model call. */
248
+ /**
249
+ * Reports whether a model input of this size makes the session compact before the model call: the estimate
250
+ * plus `reservedInputTokens` reaches `triggerRatio` of the window.
251
+ */
106
252
  export declare function isCompactionTriggered(policy: ResolvedCompactionPolicy, estimatedInputTokens: number): boolean;
107
253
  /** Tokens that the newest messages may keep after compaction: the retained share of the window, rounded down. */
108
254
  export declare function readRetainedTokenBudget(policy: ResolvedCompactionPolicy): number;
@@ -5,6 +5,245 @@
5
5
  // See the LICENSE file in the repository root.
6
6
  //
7
7
  // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0
8
+ /** First line of the user text that carries the {@link ContextSummary} into the model input. */
9
+ export const contextSummaryNoteHeader = "[Dex] Summary of the earlier conversation:";
10
+ /** Instructions of the model call that writes a {@link ContextSummary}. */
11
+ export const contextSummaryInstructions = "You summarize the earlier part of an agent conversation. The agent continues from your " +
12
+ "summary and the messages after it, so keep every fact, decision, user request and " +
13
+ "preference, open task, and tool result that it still needs, including what an earlier " +
14
+ "summary says. Write plain text.";
15
+ /** Last user text of the model call that writes a {@link ContextSummary}. */
16
+ export const contextSummaryRequest = "[Dex] Summarize the conversation above. Reply with the summary only.";
17
+ /**
18
+ * First line of the transcript in the prompt of {@link buildContextSummaryPrompt}.
19
+ * @since 0.3.0
20
+ */
21
+ export const contextSummaryTranscriptHeader = "[Dex] The conversation to summarize:";
22
+ /**
23
+ * The most tokens {@link buildContextSummaryPrompt} leaves free for the summary it asks for.
24
+ * @since 0.3.0
25
+ */
26
+ export const contextSummaryOutputTokens = 4096;
27
+ // Bytes per estimated token. Below the 4 of English text, and 1 token per CJK character.
28
+ const estimatedBytesPerToken = 3;
29
+ const textEncoder = new TextEncoder();
30
+ /**
31
+ * Returns the user text that says how many of the oldest messages a summary request left out.
32
+ * @param omittedCount - Messages left out, at least 1.
33
+ * @returns `[Dex] <omittedCount> earlier messages did not fit in this request and are left out.`, or
34
+ * `[Dex] 1 earlier message did not fit in this request and is left out.` for one message.
35
+ */
36
+ export function buildContextSummaryOmissionNote(omittedCount) {
37
+ if (omittedCount === 1) {
38
+ return "[Dex] 1 earlier message did not fit in this request and is left out.";
39
+ }
40
+ return `[Dex] ${omittedCount} earlier messages did not fit in this request and are left out.`;
41
+ }
42
+ /**
43
+ * Returns the user text that puts a summary before the newer messages in a model input.
44
+ * @param summary - The session's summary.
45
+ * @returns {@link contextSummaryNoteHeader}, a newline, and the summary text.
46
+ */
47
+ export function buildContextSummaryNote(summary) {
48
+ return `${contextSummaryNoteHeader}\n${summary.text}`;
49
+ }
50
+ /**
51
+ * Estimates the tokens of a text without a tokenizer, rounding up.
52
+ *
53
+ * Use it where a framework assembles the prompt and offers no token counter. It counts one token per three UTF-8
54
+ * bytes. That is more than a provider counts for English text, and about one token per CJK character, so the
55
+ * estimate errs toward compacting early.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * estimateTextTokens("twelve bytes"); // 4
60
+ * ```
61
+ * @param text - The text to measure.
62
+ * @returns The estimated tokens; 0 for an empty text.
63
+ * @since 0.3.0
64
+ */
65
+ export function estimateTextTokens(text) {
66
+ return Math.ceil(textEncoder.encode(text).length / estimatedBytesPerToken);
67
+ }
68
+ /**
69
+ * Estimates a model input that a framework builds from the stored items.
70
+ *
71
+ * Call it from `estimateInputTokens` of a framework adapter. It measures each `frameworkItem` as stored, so JSON
72
+ * syntax and opaque provider items count too, plus the summary and any `frameworkRunState`.
73
+ *
74
+ * @example
75
+ * ```ts
76
+ * public estimateInputTokens(turn: AgentTurnContext): number {
77
+ * return estimateStoredInputTokens(turn, estimateTextTokens(this.instructions));
78
+ * }
79
+ * ```
80
+ * @param turn - The context of the next model call.
81
+ * @param fixedInputTokens - Tokens of what every request repeats, such as instructions and tool definitions.
82
+ * Measure what the framework shows; `CompactionPolicy.reservedInputTokens` covers the rest. Defaults to 0.
83
+ * @returns The estimated input tokens.
84
+ * @since 0.3.0
85
+ */
86
+ export function estimateStoredInputTokens(turn, fixedInputTokens = 0) {
87
+ let tokens = fixedInputTokens;
88
+ for (const message of turn.messages) {
89
+ tokens += estimateTextTokens(message.frameworkItem);
90
+ }
91
+ if (turn.contextSummary) {
92
+ tokens += estimateTextTokens(buildContextSummaryNote(turn.contextSummary));
93
+ }
94
+ if (turn.frameworkRunState !== null) {
95
+ tokens += estimateTextTokens(turn.frameworkRunState);
96
+ }
97
+ return tokens;
98
+ }
99
+ /**
100
+ * Chooses the last message to summarize, cutting only between turns.
101
+ *
102
+ * Call it from `selectCompactionCutoff` of a framework adapter. A turn's messages are stored together, so a cut
103
+ * between turns never separates a tool call from its result and never splits a model response, whatever
104
+ * framework items the turn holds. The running turn is always kept, because the adapter reads its opening user
105
+ * message from `turn.messages`.
106
+ *
107
+ * It returns `null` while `turn.frameworkRunState` is set. A resumed model call continues from that value, which
108
+ * may already hold the older messages, so the summary could not replace them. Compaction waits for the next turn.
109
+ *
110
+ * @example
111
+ * ```ts
112
+ * public selectCompactionCutoff(turn: AgentTurnContext, retainedTokenBudget: number): number | null {
113
+ * return selectTurnBoundaryCutoff(turn, retainedTokenBudget);
114
+ * }
115
+ * ```
116
+ * @param turn - The context of the model call that crossed the threshold.
117
+ * @param retainedTokenBudget - Tokens that the kept messages may use.
118
+ * @returns The sequence of the last message to summarize: the earliest turn boundary whose kept messages fit the
119
+ * budget, else the boundary before the running turn. `null` when the turn is resuming, every message already
120
+ * fits, or no earlier turn is left to summarize.
121
+ * @since 0.3.0
122
+ */
123
+ export function selectTurnBoundaryCutoff(turn, retainedTokenBudget) {
124
+ if (turn.frameworkRunState !== null) {
125
+ return null;
126
+ }
127
+ const messages = turn.messages;
128
+ const foundRunningTurnIndex = messages.findIndex((message) => message.turnId === turn.turnId);
129
+ const runningTurnIndex = foundRunningTurnIndex === -1 ? messages.length : foundRunningTurnIndex;
130
+ const cuts = [];
131
+ for (let index = 1; index < Math.min(runningTurnIndex + 1, messages.length); index += 1) {
132
+ if (messages[index]?.turnId !== messages[index - 1]?.turnId) {
133
+ cuts.push(index);
134
+ }
135
+ }
136
+ if (cuts.length === 0) {
137
+ return null;
138
+ }
139
+ const keptTokens = new Array(messages.length + 1).fill(0);
140
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
141
+ keptTokens[index] = (keptTokens[index + 1] ?? 0) + estimateTextTokens(messages[index]?.frameworkItem ?? "");
142
+ }
143
+ if ((keptTokens[0] ?? 0) <= retainedTokenBudget) {
144
+ return null;
145
+ }
146
+ const cut = cuts.find((candidate) => (keptTokens[candidate] ?? 0) <= retainedTokenBudget) ?? cuts.at(-1);
147
+ return cut === undefined ? null : (messages[cut - 1]?.sequence ?? null);
148
+ }
149
+ /**
150
+ * Returns a stored message as the plain text a summary prompt shows.
151
+ *
152
+ * Wrap it in the `describeMessage` of {@link buildContextSummaryPrompt} to leave out items that only a provider can
153
+ * read.
154
+ *
155
+ * @param message - The stored message.
156
+ * @returns Its role and text, tool arguments, and tool output. A message with none of them returns its role and
157
+ * `frameworkItem`, such as a model response with several tool calls.
158
+ * @since 0.3.0
159
+ */
160
+ export function describeStoredMessage(message) {
161
+ const lines = [];
162
+ if (message.text) {
163
+ lines.push(`${message.role}: ${message.text}`);
164
+ }
165
+ if (message.toolArguments !== null) {
166
+ lines.push(`tool call ${message.toolName ?? ""}: ${message.toolArguments}`);
167
+ }
168
+ if (message.toolOutput !== null) {
169
+ lines.push(`tool result ${message.toolName ?? ""}: ${message.toolOutput}`);
170
+ }
171
+ return lines.length > 0 ? lines.join("\n") : `${message.role}: ${message.frameworkItem}`;
172
+ }
173
+ /**
174
+ * Builds the one user prompt of a summary call for a framework adapter.
175
+ *
176
+ * Call it from `summarizeContext` and send the result with {@link contextSummaryInstructions} and no tools. The
177
+ * prompt holds the previous summary, the messages as plain text, and {@link contextSummaryRequest}. Plain text
178
+ * works with any framework: it needs no tool definitions, and no provider sees an item out of its place.
179
+ *
180
+ * The prompt fits `request.inputTokenBudget`, less room for the summary, as {@link estimateTextTokens} counts.
181
+ * When the range is too large, it leaves out the oldest messages and says so with
182
+ * {@link buildContextSummaryOmissionNote}. It shortens a single message only when that message alone is too
183
+ * large.
184
+ *
185
+ * @example
186
+ * ```ts
187
+ * public async summarizeContext(request: ContextSummaryRequest): Promise<ContextSummaryResult> {
188
+ * const result = await generateText({
189
+ * model,
190
+ * system: contextSummaryInstructions,
191
+ * prompt: buildContextSummaryPrompt(request),
192
+ * });
193
+ * return { text: result.text, usage: toAgentUsage(result.usage) };
194
+ * }
195
+ * ```
196
+ * @param request - The previous summary and the messages to summarize.
197
+ * @param describeMessage - Returns a message's text for the prompt, or `null` to leave it out, such as an
198
+ * encrypted reasoning item. Defaults to {@link describeStoredMessage}.
199
+ * @returns The prompt text.
200
+ * @since 0.3.0
201
+ */
202
+ export function buildContextSummaryPrompt(request, describeMessage = describeStoredMessage) {
203
+ const opening = request.previousSummary === null ? [] : [buildContextSummaryNote(request.previousSummary)];
204
+ const closing = [contextSummaryTranscriptHeader, contextSummaryRequest];
205
+ const descriptions = request.messages.flatMap((message) => {
206
+ const description = describeMessage(message);
207
+ return description === null ? [] : [description];
208
+ });
209
+ let remainingTokens;
210
+ if (request.inputTokenBudget !== undefined) {
211
+ const fixedTokens = estimateTextTokens([contextSummaryInstructions, ...opening, ...closing].join("\n\n"));
212
+ const roomForTranscript = request.inputTokenBudget - fixedTokens;
213
+ remainingTokens =
214
+ roomForTranscript - Math.min(contextSummaryOutputTokens, Math.floor(Math.max(roomForTranscript, 0) / 4));
215
+ }
216
+ const kept = [];
217
+ for (const description of [...descriptions].reverse()) {
218
+ if (remainingTokens === undefined) {
219
+ kept.push(description);
220
+ continue;
221
+ }
222
+ // Two newlines separate messages; they count toward the estimate.
223
+ const tokens = estimateTextTokens(`${description}\n\n`);
224
+ if (tokens <= remainingTokens) {
225
+ kept.push(description);
226
+ remainingTokens -= tokens;
227
+ continue;
228
+ }
229
+ if (kept.length === 0 && remainingTokens > 0) {
230
+ kept.push(shortenToTokens(description, remainingTokens));
231
+ }
232
+ break;
233
+ }
234
+ kept.reverse();
235
+ const omittedCount = descriptions.length - kept.length;
236
+ if (omittedCount > 0) {
237
+ opening.push(buildContextSummaryOmissionNote(omittedCount));
238
+ }
239
+ return [...opening, contextSummaryTranscriptHeader, ...kept, contextSummaryRequest].join("\n\n");
240
+ }
241
+ function shortenToTokens(text, tokens) {
242
+ const marker = "[Dex] The rest of this message is left out.";
243
+ const keptBytes = Math.max(tokens - estimateTextTokens(marker) - 1, 0) * estimatedBytesPerToken;
244
+ const kept = new TextDecoder().decode(textEncoder.encode(text).slice(0, keptBytes)).replace(/\uFFFD$/u, "");
245
+ return `${kept}\n${marker}`;
246
+ }
8
247
  /** Reports whether a runner implements every compaction method. */
9
248
  export function isContextCompactionRunner(runner) {
10
249
  return (typeof runner.estimateInputTokens === "function" &&
@@ -13,8 +252,8 @@ export function isContextCompactionRunner(runner) {
13
252
  }
14
253
  /**
15
254
  * Fills in a compaction policy's defaults and checks it.
16
- * @throws {RangeError} When `contextWindowTokens` is not a positive integer, or unless
17
- * `0 < retainedRatio < triggerRatio <= 1`.
255
+ * @throws {RangeError} When `contextWindowTokens` is not a positive integer, `reservedInputTokens` is not an
256
+ * integer from 0 to below the window, or unless `0 < retainedRatio < triggerRatio <= 1`.
18
257
  */
19
258
  export function resolveCompactionPolicy(policy) {
20
259
  const contextWindowTokens = policy.contextWindowTokens;
@@ -26,11 +265,20 @@ export function resolveCompactionPolicy(policy) {
26
265
  if (!(retainedRatio > 0 && retainedRatio < triggerRatio && triggerRatio <= 1)) {
27
266
  throw new RangeError("compaction needs 0 < retainedRatio < triggerRatio <= 1");
28
267
  }
29
- return Object.freeze({ contextWindowTokens, triggerRatio, retainedRatio });
268
+ const reservedInputTokens = policy.reservedInputTokens ?? 0;
269
+ const isReservationValid = Number.isSafeInteger(reservedInputTokens) && reservedInputTokens >= 0 && reservedInputTokens < contextWindowTokens;
270
+ if (!isReservationValid) {
271
+ throw new RangeError("compaction.reservedInputTokens must be an integer from 0 to below contextWindowTokens");
272
+ }
273
+ return Object.freeze({ contextWindowTokens, triggerRatio, retainedRatio, reservedInputTokens });
30
274
  }
31
- /** Reports whether a model input of this size makes the session compact before the model call. */
275
+ /**
276
+ * Reports whether a model input of this size makes the session compact before the model call: the estimate
277
+ * plus `reservedInputTokens` reaches `triggerRatio` of the window.
278
+ */
32
279
  export function isCompactionTriggered(policy, estimatedInputTokens) {
33
- return estimatedInputTokens >= policy.triggerRatio * policy.contextWindowTokens;
280
+ const estimatedTokens = estimatedInputTokens + (policy.reservedInputTokens ?? 0);
281
+ return estimatedTokens >= policy.triggerRatio * policy.contextWindowTokens;
34
282
  }
35
283
  /** Tokens that the newest messages may keep after compaction: the retained share of the window, rounded down. */
36
284
  export function readRetainedTokenBudget(policy) {