@superdurable/dex-agents 0.5.0 → 0.6.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 (41) hide show
  1. package/README.md +110 -3
  2. package/dist/src/agent-approval-display.d.ts +135 -0
  3. package/dist/src/agent-approval-display.js +536 -0
  4. package/dist/src/agent-approval-display.js.map +1 -0
  5. package/dist/src/agent-framework-runner.d.ts +39 -2
  6. package/dist/src/agent-framework-runner.js.map +1 -1
  7. package/dist/src/agent-session-codecs.d.ts +3 -1
  8. package/dist/src/agent-session-codecs.js +31 -0
  9. package/dist/src/agent-session-codecs.js.map +1 -1
  10. package/dist/src/agent-session-flow.d.ts +23 -0
  11. package/dist/src/agent-session-flow.js +11 -0
  12. package/dist/src/agent-session-flow.js.map +1 -1
  13. package/dist/src/agent-session-persistence.d.ts +33 -0
  14. package/dist/src/agent-session-persistence.js +22 -1
  15. package/dist/src/agent-session-persistence.js.map +1 -1
  16. package/dist/src/agent-session-steps.js +22 -2
  17. package/dist/src/agent-session-steps.js.map +1 -1
  18. package/dist/src/agent-session-summary.d.ts +138 -0
  19. package/dist/src/agent-session-summary.js +151 -0
  20. package/dist/src/agent-session-summary.js.map +1 -0
  21. package/dist/src/agent-session-transitions.d.ts +9 -3
  22. package/dist/src/agent-session-transitions.js +37 -5
  23. package/dist/src/agent-session-transitions.js.map +1 -1
  24. package/dist/src/agent-session-values.d.ts +103 -1
  25. package/dist/src/agent-session-values.js.map +1 -1
  26. package/dist/src/builtin-loop/builtin-agent-session-flow.d.ts +9 -1
  27. package/dist/src/builtin-loop/builtin-agent-session-flow.js +5 -1
  28. package/dist/src/builtin-loop/builtin-agent-session-flow.js.map +1 -1
  29. package/dist/src/connectors/connector-tool-source.d.ts +2 -1
  30. package/dist/src/connectors/connector-tool-source.js +13 -1
  31. package/dist/src/connectors/connector-tool-source.js.map +1 -1
  32. package/dist/src/index.d.ts +3 -1
  33. package/dist/src/index.js +2 -0
  34. package/dist/src/index.js.map +1 -1
  35. package/dist/src/mcp/mcp-tool-source.d.ts +27 -1
  36. package/dist/src/mcp/mcp-tool-source.js +52 -13
  37. package/dist/src/mcp/mcp-tool-source.js.map +1 -1
  38. package/dist/src/testing/private-dex-engine.d.ts +8 -0
  39. package/dist/src/testing/private-dex-engine.js +17 -1
  40. package/dist/src/testing/private-dex-engine.js.map +1 -1
  41. package/package.json +2 -2
package/README.md CHANGED
@@ -487,7 +487,8 @@ if (snapshot.plan?.status === "draft") {
487
487
  never runs. It marks the plan `active`, writes `plan_execution_started`, and
488
488
  starts an `executing` turn with every tool, whose user message is `[Dex] The
489
489
  user approved plan revision <n>. Execute it and keep the task statuses
490
- current with write_todos.`.
490
+ current with write_todos.`. The stored message has `origin`
491
+ `plan_execution`; see [User messages the session writes](#user-messages-the-session-writes).
491
492
  - `buildPlanNote(plan, interactionMode)` returns the note that shows the model
492
493
  the plan. The built-in loop adds it as user text before the turn's messages in every
493
494
  model call, so the model always sees the latest plan.
@@ -1286,6 +1287,107 @@ does not turn this on; pass `configOverride: { activeStepSearchMode: agentSessio
1286
1287
  `Client.updateFlowConfig` for a running session. See
1287
1288
  [Find sessions that need a person](https://docs.superdurable.io/reference/agents/approvals#find-sessions).
1288
1289
 
1290
+ ## Showing what an approval acts on
1291
+
1292
+ A tool approval can say what the call acts on and what it does. Its tool source returns an
1293
+ `AgentInterruptDisplay` `{ target, summary, preview? }` in `DeferredToolCall.approvalDisplay`, and the session
1294
+ stores it in `AgentInterrupt.display`, on the call's record as `approvalDisplay`, and in the `interrupt_raised`
1295
+ event. A queued ordered write raises its approval later with the display its source built when the model asked.
1296
+
1297
+ ```ts
1298
+ const snapshot = await client.invokeRPC(flow.getAgentSnapshot, sessionId);
1299
+ for (const interrupt of snapshot.pendingInterrupts) {
1300
+ console.log(interrupt.display?.summary ?? interrupt.arguments);
1301
+ }
1302
+ ```
1303
+
1304
+ - `ConnectorToolSource` and the CLI's connector tools build it with `buildConnectorToolApprovalDisplay`: the
1305
+ target is `<connector>/<connection>`, and the summary names the operation, the connection, and up to five short
1306
+ arguments, such as `Calls slack postChannelMessage with connection support-slack; channel: "#eng".`
1307
+ - The CLI's local file writes name the workspace path and carry a unified diff against the file as it is when the
1308
+ model asks; its header lines say so. Its shell commands name the working directory and the command with its
1309
+ inline credentials redacted and its control characters escaped, cut at 2 KiB; the full command is in the call's
1310
+ arguments.
1311
+ - A display never says how often the call runs or what it achieves, and never uses a tool's description as its
1312
+ effect.
1313
+ - Redaction in a display is best-effort hygiene, not a security boundary. The display's text comes from the call's
1314
+ arguments, which the session stores in full as before, and an agent can read a workspace file with its own tools.
1315
+ A credential-like argument (`isCredentialLikeName`) shows `<redacted>`, and `redactInlineCredentials` replaces
1316
+ common credential shapes: the value after a credential-like name, read as a shell word, a triple-quoted string, or
1317
+ a YAML block; `Authorization` and `Cookie` header values; URL passwords; `sshpass -p`; and common token shapes.
1318
+ It reads `\uXXXX`, `\xXX`, and `%XX` escapes to find credentials but changes no other text: escapes stay as
1319
+ written, and only the characters of each credential become `<redacted>`. It is conservative, so a generic
1320
+ `key=value` is redacted too, and the CLI leaves out a file preview whose text it changes. It finds common forms
1321
+ only, and Unicode lookalike letters are out of scope; `agents/conformance/approval_display.json` lists the cases
1322
+ both cores share.
1323
+ - The session cuts a target over `maxApprovalTargetBytes` (1 KiB) and a summary over `maxApprovalSummaryBytes`
1324
+ (2 KiB), ending them with `…`, and drops a preview over `maxApprovalPreviewBytes` (16 KiB). A tool without a
1325
+ display, and every approval a Worker before 0.6.0 raised, has `display` `null`: show the concrete arguments, not a
1326
+ guessed effect.
1327
+
1328
+ ## User messages the session writes
1329
+
1330
+ Since 0.6.0, a stored `user` message that the session wrote itself, not a person, carries
1331
+ `AgentMessage.origin`. Show such a message as a session note, or hide it, never as text the user typed.
1332
+
1333
+ | `origin` | Message |
1334
+ | --- | --- |
1335
+ | `plan_execution` | The message with which `execute_plan` starts the turn, `buildPlanExecutionMessage(revision)` |
1336
+ | `turn_failure_note` | The note `RecordTurnFailure` stores for the model after a failed turn whose tools ran |
1337
+ | `turn_steered_note` | The note that lists the tool calls that ran before `steer_user_message` ended the turn |
1338
+ | `turn_cancelled_note` | The note that lists the tool calls that ran before `cancel_turn` ended the turn |
1339
+
1340
+ ```ts
1341
+ const page = await client.invokeRPC(flow.listAgentMessages, sessionId, { beforeSequence: null, limit: 50 });
1342
+ for (const message of page.messages) {
1343
+ if (message.origin === "plan_execution") {
1344
+ console.log("Running the approved plan");
1345
+ } else if (message.origin === undefined && message.role === "user") {
1346
+ console.log(`> ${message.text ?? ""}`);
1347
+ }
1348
+ }
1349
+ ```
1350
+
1351
+ - Only the session sets `origin`. `send_user_message`, the start input's initial message, and a runner's messages,
1352
+ such as a `decideTurnContinuation` message, have none.
1353
+ - The wire field is `origin`, `null` when absent; a decoded message omits it. Messages stored before 0.6.0 have
1354
+ none. A Worker before 0.6.0 ignores the field, and drops it when it moves the message into an archive chunk.
1355
+ - A later release may add values. The decoder keeps a value it does not know, so treat any `origin` as written by
1356
+ the session.
1357
+
1358
+ ## Listing sessions
1359
+
1360
+ Since 0.6.0, a session can keep a summary that a session list needs: its title, the first 80 Unicode code points of
1361
+ the first user message; the time its latest turn started or ended; and its turn count. Turn it on with
1362
+ `summaryAttributesIndexed: true`; the adapter factories, such as `createBuiltinAgentSessionFlow`, take the same option.
1363
+ It is off by default, and `flow.options.summaryAttributesIndexed` tells whether a Flow keeps it. `listAgentSessions` reads one page of sessions in one `Client.searchFlows` call, without a snapshot
1364
+ per session and without a running Worker:
1365
+
1366
+ ```ts
1367
+ const flow = createAgentSessionFlow(runner, "SupportAgent", { summaryAttributesIndexed: true });
1368
+ const page = await listAgentSessions(client, flow.getFlowType(), { pageSize: 50, titleSearch: "refund" });
1369
+ for (const session of sortAgentSessionsByLastActivity(page.sessions)) {
1370
+ console.log(session.sessionId, session.title ?? "—", session.lastActivityAt?.toISOString() ?? "—", session.turnCount);
1371
+ }
1372
+ ```
1373
+
1374
+ - Dex returns running sessions first, then the most recently closed and started ones. It cannot sort by last
1375
+ activity, so `sortAgentSessionsByLastActivity` sorts the sessions you loaded.
1376
+ - `titleSearch` matches whole words of the title, without case: `flaky` finds "Fix the flaky test", `flak` does not.
1377
+ Pass the same `titleSearch` with each `nextPageToken`.
1378
+ - A session without the option, or one that an earlier release started, lists without the summary, and a title search
1379
+ never finds it. An earlier session gains the last activity time and the turn count at its next turn on a Worker with
1380
+ the option. Show each missing value as a dash. Until a Worker with the option has started on the server, a title
1381
+ search throws `SessionTitleSearchUnavailableError`.
1382
+ - A session that fails because its state would exceed the server's limit can keep the values of its last turn start.
1383
+ - Privacy: the title is the user's own text, and anyone who can search the namespace's Flows reads it. Turn the option
1384
+ on only when the first message may be stored in the search index.
1385
+ - The three Attributes are indexed under `text1` (FULL_TEXT), `datetime1` (DATETIME), and `int1` (INT), the generic
1386
+ keys that every Flow type of a namespace shares. A Worker with the option registers them when it starts. With
1387
+ PostgreSQL search, a namespace has three keys of each of these types; the Worker fails to start with
1388
+ `FAILED_PRECONDITION` when the namespace registered one of these keys with another type, or when all three keys of
1389
+ the type are taken by other names. Search queries of other Flow types must filter on `FlowType`.
1390
+
1289
1391
  ## Showing sessions in Dex Web
1290
1392
 
1291
1393
  Dex Web draws a session and offers its Actions from the session Flow
@@ -1418,6 +1520,9 @@ Do not repeat these tool calls unless the user asks again.
1418
1520
  | `PlanExecutions` | ChannelMap, key = `planExecutionKey(revision)` (decimal) | `PlanExecutionRequest { revision }` |
1419
1521
  | `PendingPlanExecution` | Attribute | `PlanExecutionRequest` that `execute_plan` accepted and `AwaitUserMessage` has not consumed |
1420
1522
  | `ContextSummary` | Attribute | `ContextSummary`, set once the session compacts |
1523
+ | `AgentSessionTitle` | Attribute, indexed FULL_TEXT `text1`; only with `summaryAttributesIndexed` | string, the first user message's title; set by the first turn only |
1524
+ | `AgentSessionLastActivityAt` | Attribute, indexed DATETIME `datetime1`; only with `summaryAttributesIndexed` | string, RFC 3339 time the latest turn started or ended |
1525
+ | `AgentSessionTurnCount` | Attribute, indexed INT `int1`; only with `summaryAttributesIndexed` | int64, `AgentState.turnCount` as of the latest turn start or end |
1421
1526
  | `AgentEvents` | Stream | `AgentEvent` |
1422
1527
 
1423
1528
  `AgentState.status` is `waiting_for_user_message`, `running_model`,
@@ -1457,7 +1562,7 @@ IDs are `turn-<n>`, where `n` is `turn_count + 1` when the turn starts.
1457
1562
  | `tool_call_deferred` | `{"tool_call_id","tool_name","effect","requires_approval"}`; the model asked for a call, which runs in its own Step |
1458
1563
  | `tool_call_started` | `{"tool_call_id","tool_name"}` |
1459
1564
  | `tool_call_finished` | `{"tool_call_id","tool_name","status"}`; the record became final, including a rejection |
1460
- | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text"}` |
1565
+ | `interrupt_raised` | `{"interrupt_id","kind","tool_call_id","tool_name","deadline","question","choices","allows_free_text","display"}`; `display` is `{"target","summary","preview"}` or `null` |
1461
1566
  | `interrupt_resolved` | `{"interrupt_id","kind","approved","answered","rejection_message","cancellation_reason"}`; `answered` is `true` only for an answered question, and `cancellation_reason` is `"steered"` or `"turn_cancelled"` when steering or cancellation removed the interrupt |
1462
1567
  | `timer_started` | `{"tool_call_id","wait_until","reason"}` for a `durable_wait` call |
1463
1568
  | `plan_updated` | `{"revision","status","task_count"}` after `write_todos` replaced the plan, or when an approval marks a draft `active` |
@@ -1551,7 +1656,9 @@ server is listed again in the background while its earlier tools are
1551
1656
  returned. A server that cannot be listed is left out with a warning and is not
1552
1657
  tried again for `listingFailureBackoffMs` (30 seconds), then in the
1553
1658
  background, so one bad server never hides the others' tools or slows every
1554
- model call. One listing per server runs at a time. `listTools({ refresh: true
1659
+ model call. The warnings go to `console.warn` unless `logWarning` sends them
1660
+ elsewhere, such as a log file; a `logWarning` that throws falls back to
1661
+ `console.warn`. One listing per server runs at a time. `listTools({ refresh: true
1555
1662
  })` lists every server again now. `deferredToolCall` stores the
1556
1663
  server, the tool's MCP name, and its task support on the call's record, so the
1557
1664
  tool Step calls that server directly, without a listing; `callTool` finds the
@@ -0,0 +1,135 @@
1
+ import type { AgentInterruptDisplay } from "./agent-session-values.js";
2
+ /**
3
+ * Largest {@link AgentInterruptDisplay.preview} the session stores, in UTF-8 bytes: 16 KiB. The session drops a
4
+ * longer preview and keeps the target and summary, because the display is stored on the call's record, on its
5
+ * interrupt, and in its `interrupt_raised` event.
6
+ * @since 0.6.0
7
+ */
8
+ export declare const maxApprovalPreviewBytes = 16384;
9
+ /**
10
+ * Largest {@link AgentInterruptDisplay.target} the session stores, in UTF-8 bytes: 1 KiB. The session cuts a longer
11
+ * target and ends it with `…`.
12
+ * @since 0.6.0
13
+ */
14
+ export declare const maxApprovalTargetBytes = 1024;
15
+ /**
16
+ * Largest {@link AgentInterruptDisplay.summary} the session stores, in UTF-8 bytes: 2 KiB. The session cuts a longer
17
+ * summary and ends it with `…`.
18
+ * @since 0.6.0
19
+ */
20
+ export declare const maxApprovalSummaryBytes = 2048;
21
+ /**
22
+ * The text an {@link AgentInterruptDisplay} shows instead of a credential.
23
+ * @since 0.6.0
24
+ */
25
+ export declare const redactedArgumentValue = "<redacted>";
26
+ /**
27
+ * Whether an argument, header, field, or variable name looks like it holds a credential, so a display shows its
28
+ * value as {@link redactedArgumentValue}.
29
+ *
30
+ * The name is split into words at `_`, `-`, `.`, and spaces, and again at camelCase boundaries, ignoring case, so
31
+ * `pAsSwOrD` is one word. It is a credential
32
+ * name when a word is one of `apikey`, `auth`, `authorization`, `bearer`, `cookie`, `cred`, `credential`, `dsn`,
33
+ * `jwt`, `key`, `otp`, `pass`, `passphrase`, `passwd`, `password`, `privatekey`, `pwd`, `secret`, `session`,
34
+ * `sessionid`, `signature`, or `token` (and the plural forms `apikeys`, `cookies`, `creds`, `credentials`,
35
+ * `passwords`, `secrets`, and `tokens`), when a word ends in `apikey`, `credential`, `credentials`, `passwd`,
36
+ * `password`, `secret`, `secrets`, `token`, or `tokens`, as in `PGPASSWORD`, or when it holds the words
37
+ * `connection string` or `conn str`. So
38
+ * `X-Api-Key`, `clientSecret`, `private_key`, `credentials`, and `DATABASE_CONNECTION_STRING` all match, and
39
+ * `channel`, `monkey`, `tokenizer`, and `author` do not. The Python core applies the same rule; the shared cases
40
+ * are in `agents/conformance/approval_display.json`.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * isCredentialLikeName("clientSecret"); // true
45
+ * isCredentialLikeName("channel"); // false
46
+ * ```
47
+ * @param name - The name to check.
48
+ * @returns `true` when the name looks like it holds a credential.
49
+ * @since 0.6.0
50
+ */
51
+ export declare function isCredentialLikeName(name: string): boolean;
52
+ /**
53
+ * Replaces common credential shapes written inline in a text, such as a shell command or a file, with
54
+ * {@link redactedArgumentValue}, and leaves every other character as it is.
55
+ *
56
+ * It is best-effort hygiene that keeps common credentials out of an approval display, not a security boundary: a
57
+ * credential in a form it does not know, such as one spelled with Unicode lookalike letters, stays as it is, and the
58
+ * call's arguments still hold the full text.
59
+ *
60
+ * It reads `\uXXXX`, `\xXX`, and `%XX` escapes as the characters they stand for, so an escaped name such as
61
+ * `pass\u0077ord` or `%70assword` is found, but it returns the text with the escapes as written: only the characters
62
+ * of each credential found are replaced. It replaces private key blocks; the password of a URL such as
63
+ * `https://user:pass@host`; the password of `-u user:pass`, `--user`, `--proxy-user`, and `sshpass -p`; and the value
64
+ * after a name that {@link isCredentialLikeName} matches, in a `name=value` assignment, a `name: value` header or
65
+ * field, a JSON member, or a `--name value` flag, which covers query parameters such as `?token=…`. A value runs as a
66
+ * shell word: backslash escapes, line continuations, and adjacent quoted parts join it, escaped quotes stay inside a
67
+ * quoted part, and it ends at whitespace, `&`, `;`, `,`, `}`, `]`, or `)`; an unclosed quote runs to the end of its
68
+ * line. A triple-quoted value runs to its closing quotes, or to the end of the text, and a YAML block scalar after
69
+ * `name: |` or `name: >` covers its indented lines. The value of an `Authorization` or `Cookie` header runs to the end
70
+ * of its line, with folded and continued lines, and its scheme, such as `Bearer`, stays. A value that is exactly
71
+ * {@link redactedArgumentValue} stays. Outside a header, a token-shaped word after `Bearer`, `Basic`, or `Digest` is
72
+ * replaced, and so are common token shapes, such as AWS access key IDs and GitHub, Slack, GitLab, and OpenAI-style
73
+ * keys and JWTs. The rules are conservative: a generic `key=value` is replaced too. The Python core applies the same
74
+ * rules; the shared cases are in `agents/conformance/approval_display.json`.
75
+ *
76
+ * @example
77
+ * ```ts
78
+ * redactInlineCredentials('curl -H "Authorization: Bearer t1" https://api.example.com?token=t1');
79
+ * // 'curl -H "Authorization: Bearer <redacted>" https://api.example.com?token=<redacted>'
80
+ * redactInlineCredentials("printf '\\x41'"); // "printf '\\x41'", unchanged
81
+ * ```
82
+ * @param text - The text.
83
+ * @returns The text with each credential found replaced and nothing else changed.
84
+ * @since 0.6.0
85
+ */
86
+ export declare function redactInlineCredentials(text: string): string;
87
+ /**
88
+ * The connector, connection, operation, and arguments of one connector tool call, for
89
+ * {@link buildConnectorToolApprovalDisplay}.
90
+ * @since 0.6.0
91
+ */
92
+ export interface ConnectorToolApprovalDisplayInput {
93
+ /** The Connector ID, such as `slack`. */
94
+ readonly connectorId: string;
95
+ /** The connection's name, such as `support-slack`. */
96
+ readonly connectionName: string;
97
+ /** The operation the call runs, such as `postChannelMessage`. */
98
+ readonly operation: string;
99
+ /** The call's arguments as JSON text. */
100
+ readonly arguments: string;
101
+ }
102
+ /**
103
+ * Builds the approval display of one connector tool call from its operation, its connection, and its short
104
+ * arguments.
105
+ *
106
+ * The target is `<connectorId>/<connectionName>`. The summary names the operation and the connection and lists up to
107
+ * five top-level arguments in argument order: each string of at most 80 characters on one line, number, and boolean,
108
+ * as JSON. A credential-like argument (see {@link isCredentialLikeName}) shows {@link redactedArgumentValue} instead
109
+ * of its value, and string values and the connector, connection, and operation names have their inline credentials
110
+ * replaced (see {@link redactInlineCredentials}), as best-effort hygiene. Longer
111
+ * text, objects, and lists are left out; the approval UI shows them from the arguments. The summary never uses the
112
+ * operation's description, which says what the operation is for, not what this call does. `ConnectorToolSource` and
113
+ * the CLI's connector tools use it; a source of other connector tools may too.
114
+ *
115
+ * @example
116
+ * ```ts
117
+ * const display = buildConnectorToolApprovalDisplay({
118
+ * connectorId: "slack",
119
+ * connectionName: "support-slack",
120
+ * operation: "postChannelMessage",
121
+ * arguments: '{"channel":"#eng","text":"Deploy is done"}',
122
+ * });
123
+ * // display.summary: 'Calls slack postChannelMessage with connection support-slack; channel: "#eng", text: "Deploy is done".'
124
+ * ```
125
+ * @param input - The call's connector, connection, operation, and arguments.
126
+ * @returns The display, with no preview.
127
+ * @since 0.6.0
128
+ */
129
+ export declare function buildConnectorToolApprovalDisplay(input: ConnectorToolApprovalDisplayInput): AgentInterruptDisplay;
130
+ /**
131
+ * Returns the display the session stores for a call: `null` for none or one that is not a display; otherwise the
132
+ * target and summary cut to {@link maxApprovalTargetBytes} and {@link maxApprovalSummaryBytes}, and the preview, or
133
+ * `null` when it is over {@link maxApprovalPreviewBytes}.
134
+ */
135
+ export declare function boundApprovalDisplay(display: unknown): AgentInterruptDisplay | null;