@inflexa-ai/harness 0.36.4 → 0.38.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 (96) hide show
  1. package/README.md +1 -2
  2. package/dist/auth/local-run-authorizer.js +5 -4
  3. package/dist/auth/types.d.ts +28 -9
  4. package/dist/auth/types.js +5 -3
  5. package/dist/billing/noop-run-charge.js +3 -2
  6. package/dist/billing/noop-usage-recorder.js +2 -1
  7. package/dist/billing/run-charge.d.ts +18 -5
  8. package/dist/billing/run-charge.js +5 -1
  9. package/dist/billing/usage-recorder.d.ts +11 -9
  10. package/dist/billing/usage-recorder.js +2 -3
  11. package/dist/contracts/chat-events.d.ts +4 -1
  12. package/dist/contracts/chat-parts.d.ts +1 -1
  13. package/dist/contracts/message.d.ts +15 -0
  14. package/dist/contracts/schemas/chat-parts.d.ts +4 -4
  15. package/dist/execution/artifact-registration.d.ts +6 -1
  16. package/dist/execution/artifact-registration.js +12 -4
  17. package/dist/execution/artifact-registry.d.ts +10 -3
  18. package/dist/execution/noop-artifact-registry.js +3 -4
  19. package/dist/execution/post-step-pipeline.d.ts +14 -3
  20. package/dist/execution/post-step-pipeline.js +21 -8
  21. package/dist/execution/run-authorizer.d.ts +5 -3
  22. package/dist/execution/run-canceler.js +4 -20
  23. package/dist/index.d.ts +13 -7
  24. package/dist/index.js +12 -6
  25. package/dist/lib/hooks.d.ts +49 -0
  26. package/dist/lib/hooks.js +39 -0
  27. package/dist/lib/result.d.ts +22 -11
  28. package/dist/lib/result.js +24 -14
  29. package/dist/loop/run-agent.d.ts +2 -2
  30. package/dist/loop/run-agent.js +5 -4
  31. package/dist/memory/conversation-display-replay.js +14 -1
  32. package/dist/memory/thread-history.d.ts +24 -0
  33. package/dist/memory/thread-history.js +19 -6
  34. package/dist/provenance/seam.d.ts +1 -1
  35. package/dist/providers/ai-sdk.d.ts +43 -5
  36. package/dist/providers/ai-sdk.js +55 -52
  37. package/dist/providers/anthropic.d.ts +2 -3
  38. package/dist/providers/anthropic.js +2 -2
  39. package/dist/providers/embedding.d.ts +13 -7
  40. package/dist/providers/embedding.js +26 -6
  41. package/dist/providers/errors.d.ts +67 -37
  42. package/dist/providers/errors.js +57 -58
  43. package/dist/providers/request-headers.d.ts +18 -0
  44. package/dist/providers/request-headers.js +22 -0
  45. package/dist/report-model/production-resolver.d.ts +5 -2
  46. package/dist/report-model/production-resolver.js +12 -2
  47. package/dist/runtime/assemble.d.ts +3 -2
  48. package/dist/runtime/dbos.d.ts +4 -3
  49. package/dist/runtime/dbos.js +18 -3
  50. package/dist/runtime/result-serialization.d.ts +29 -0
  51. package/dist/runtime/result-serialization.js +45 -0
  52. package/dist/sandbox/client.d.ts +11 -3
  53. package/dist/sandbox/create-sandbox.d.ts +5 -2
  54. package/dist/sandbox/create-sandbox.js +51 -25
  55. package/dist/sandbox/docker-client.d.ts +4 -3
  56. package/dist/sandbox/docker-client.js +23 -31
  57. package/dist/sandbox/k8s-client.d.ts +4 -13
  58. package/dist/sandbox/k8s-client.js +28 -61
  59. package/dist/sandbox/labels.d.ts +34 -0
  60. package/dist/sandbox/labels.js +47 -0
  61. package/dist/sandbox/mount-plan.d.ts +3 -1
  62. package/dist/sandbox/mount-plan.js +9 -0
  63. package/dist/sandbox/sandbox-error.d.ts +36 -13
  64. package/dist/sandbox/sandbox-error.js +32 -17
  65. package/dist/sandbox/types.d.ts +13 -9
  66. package/dist/schemas/validate-plan.js +13 -0
  67. package/dist/schemas/workflow-state.d.ts +4 -4
  68. package/dist/state/analyses.d.ts +4 -4
  69. package/dist/state/analyses.js +4 -4
  70. package/dist/state/init.js +11 -0
  71. package/dist/state/schema.d.ts +5 -5
  72. package/dist/tasks/data-profile.d.ts +5 -8
  73. package/dist/tasks/data-profile.js +88 -43
  74. package/dist/tasks/derive-table-exec.d.ts +10 -6
  75. package/dist/tasks/derive-table-exec.js +17 -12
  76. package/dist/tasks/extract-values.d.ts +16 -10
  77. package/dist/tasks/extract-values.js +34 -18
  78. package/dist/tools/execute-analysis.js +22 -15
  79. package/dist/tools/report-session/derive-table.d.ts +9 -1
  80. package/dist/tools/report-session/derive-table.js +25 -22
  81. package/dist/tools/research/inspect-run.d.ts +1 -1
  82. package/dist/workflows/execute-analysis.d.ts +25 -16
  83. package/dist/workflows/execute-analysis.js +223 -216
  84. package/dist/workflows/metrics.d.ts +4 -3
  85. package/dist/workflows/metrics.js +4 -3
  86. package/dist/workflows/sandbox-step.d.ts +20 -29
  87. package/dist/workflows/sandbox-step.js +71 -127
  88. package/dist/workflows/suspension.d.ts +32 -0
  89. package/dist/workflows/suspension.js +38 -0
  90. package/package.json +1 -1
  91. package/dist/billing/noop-resolver.d.ts +0 -6
  92. package/dist/billing/noop-resolver.js +0 -7
  93. package/dist/billing/resolver.d.ts +0 -64
  94. package/dist/billing/resolver.js +0 -24
  95. package/dist/loop/budget-exceeded.d.ts +0 -38
  96. package/dist/loop/budget-exceeded.js +0 -88
@@ -14,7 +14,9 @@
14
14
  * Mirrors the `SessionPagePublisher` seam: a narrow interface in the harness, the managed
15
15
  * realization injected at the composition root.
16
16
  */
17
+ import type { ResultAsync } from "neverthrow";
17
18
  import type { AuthContext, Provenance, RunFrame, RunSession, Scope } from "../auth/types.js";
19
+ import type { GateFailure, NoticeFailure } from "../lib/hooks.js";
18
20
  /**
19
21
  * Locates a run credential when no `RunAuthorization` exists: the persisted
20
22
  * jti from `cortex_runs` plus the revoking caller's opaque `auth` — the sole
@@ -48,8 +50,8 @@ export interface RunAuthorization {
48
50
  * path (a no-op for reused or local authorizations).
49
51
  */
50
52
  export interface RunAuthorizer {
51
- authorize(input: AuthorizeRunInput): Promise<RunAuthorization>;
52
- revoke(authorization: RunAuthorization, reason: string): Promise<void>;
53
+ authorize(input: AuthorizeRunInput): ResultAsync<RunAuthorization, GateFailure>;
54
+ revoke(authorization: RunAuthorization, reason: string): ResultAsync<void, NoticeFailure>;
53
55
  /**
54
56
  * Out-of-band revocation for paths holding no `RunAuthorization`. Normal
55
57
  * terminal paths use `revoke(authorization, reason)` under the run
@@ -57,5 +59,5 @@ export interface RunAuthorizer {
57
59
  * credential's JWT was never persisted and only its jti survives on
58
60
  * `cortex_runs`. Local/OSS authorizers no-op — they mint no jti.
59
61
  */
60
- revokeByJti(ref: RevokeByJtiRef, reason: string): Promise<void>;
62
+ revokeByJti(ref: RevokeByJtiRef, reason: string): ResultAsync<void, NoticeFailure>;
61
63
  }
@@ -18,6 +18,7 @@
18
18
  * terminal frame read-side.
19
19
  */
20
20
  import { createNoopLogger } from "../lib/console-logger.js";
21
+ import { deliverNotice } from "../lib/hooks.js";
21
22
  import { elapsedSinceIso, recordRunCompleted, recordStepCompleted } from "../lib/metrics.js";
22
23
  import { unwrapOrThrow } from "../lib/result.js";
23
24
  import { markRunCanceledIfActive, queryRun } from "../state/runs.js";
@@ -113,26 +114,9 @@ export function createRunCanceler(deps) {
113
114
  catch (err) {
114
115
  logger.error("pending-step sweep failed", { runId, ...logger.errorFields(err) });
115
116
  }
116
- // Best-effort: the billing authority self-heals (defensive open + stale
117
- // reaper), so a failed close loses attribution only.
118
- let charge = false;
119
- try {
120
- await runCharge.close({ analysisId: row.analysisId, runId, reason: "canceled", session });
121
- charge = true;
122
- }
123
- catch (err) {
124
- logger.error("running-charge close failed", { runId, ...logger.errorFields(err) });
125
- }
126
- let mandate = row.mandateJti === null;
127
- if (row.mandateJti !== null) {
128
- try {
129
- await runAuthorizer.revokeByJti({ jti: row.mandateJti, auth: session.auth }, EXTERNAL_CANCEL_REASON);
130
- mandate = true;
131
- }
132
- catch (err) {
133
- logger.error("mandate revoke failed", { runId, jti: row.mandateJti, ...logger.errorFields(err) });
134
- }
135
- }
117
+ const charge = await deliverNotice(logger.with({ runId }), "RunCharge.close", runCharge.close({ analysisId: row.analysisId, runId, outcome: { kind: "canceled" }, session }));
118
+ const mandate = row.mandateJti === null ||
119
+ (await deliverNotice(logger.with({ runId, jti: row.mandateJti }), "RunAuthorizer.revokeByJti", runAuthorizer.revokeByJti({ jti: row.mandateJti, auth: session.auth }, EXTERNAL_CANCEL_REASON)));
136
120
  // No transition means the run reached a terminal state on its own (or the
137
121
  // write failed) — report the row as it stands rather than claiming `canceled`.
138
122
  let finalStatus = "canceled";
package/dist/index.d.ts CHANGED
@@ -24,10 +24,10 @@ export type { ConversationAgentDeps } from "./agents/conversation-agent.js";
24
24
  export type { BioToolKeys } from "./tools/bio/keys.js";
25
25
  export { createLocalRunAuthorizer } from "./auth/local-run-authorizer.js";
26
26
  export type { RunAuthorizer, AuthorizeRunInput, RunAuthorization, RevokeByJtiRef } from "./execution/run-authorizer.js";
27
- export { createNoopBillingResolver } from "./billing/noop-resolver.js";
28
- export type { ResolveBilling, ResolvableSession, BillingMap, BillingHeaders, BillingFetchResult } from "./billing/resolver.js";
27
+ export type { GateFailure, NoticeFailure } from "./lib/hooks.js";
28
+ export type { RequestHeaders, ResolveRequestHeaders } from "./providers/request-headers.js";
29
29
  export { createNoopRunCharge } from "./billing/noop-run-charge.js";
30
- export type { RunCharge } from "./billing/run-charge.js";
30
+ export type { RunCharge, RunChargeOutcome } from "./billing/run-charge.js";
31
31
  export { createNoopUsageRecorder } from "./billing/noop-usage-recorder.js";
32
32
  export type { UsageRecorder, LlmUsageRecord } from "./billing/usage-recorder.js";
33
33
  export { createNoopArtifactRegistry } from "./execution/noop-artifact-registry.js";
@@ -63,12 +63,12 @@ export type { RunAgentOptions, RunAgentResult, AgentFinish } from "./loop/run-ag
63
63
  export type { AgentDefinition, RunStep, EmitFn, EmitEvent, EventSource } from "./loop/types.js";
64
64
  export { passthroughStep } from "./loop/run-step.js";
65
65
  export { forStep, forSubAgent, scopeResource, scopeWorkloadId } from "./auth/types.js";
66
- export type { AgentSession, RequestSession, RunSession, Identity, Scope, Credential, Provenance, RunFrame, AuthContext, ResourceCoordinates, } from "./auth/types.js";
66
+ export type { AgentSession, RequestSession, RunSession, Identity, Scope, Credential, Provenance, RunFrame, AuthContext, ResourceCoordinates, HookSessionView, ResolvableSession, } from "./auth/types.js";
67
67
  export { makeLocalAuth } from "./auth/local-auth-context.js";
68
68
  export { createAnthropicProvider } from "./providers/anthropic.js";
69
69
  export type { AnthropicProviderDeps } from "./providers/anthropic.js";
70
70
  export { createConfiguredAiSdkProvider, DEFAULT_MAX_OUTPUT_TOKENS } from "./providers/ai-sdk.js";
71
- export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps } from "./providers/ai-sdk.js";
71
+ export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps, ProviderHostPolicy } from "./providers/ai-sdk.js";
72
72
  export { createEmbeddingProvider } from "./providers/embedding.js";
73
73
  export type { EmbeddingProviderDeps } from "./providers/embedding.js";
74
74
  export type { AgentChat, ChatProvider, EmbeddingProvider, ChatRequest, ChatResponse, ChatStreamEvent, ChatUsage, ModelMessage, PromptCachePolicy, ReasoningPolicy, } from "./providers/types.js";
@@ -76,6 +76,10 @@ export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions, withPromptCacheBreakp
76
76
  export { DEFAULT_REASONING } from "./providers/reasoning.js";
77
77
  export { toProviderError, isProviderError } from "./providers/errors.js";
78
78
  export type { ProviderError } from "./providers/errors.js";
79
+ export { DEFAULT_SUSPEND_ON } from "./providers/errors.js";
80
+ export type { SuspendOn } from "./providers/errors.js";
81
+ export { suspensionOfFailure } from "./workflows/suspension.js";
82
+ export type { Suspension } from "./workflows/suspension.js";
79
83
  export { createStreamingChat } from "./providers/streaming-chat.js";
80
84
  export { prepareChatTurn } from "./app/chat-turn.js";
81
85
  export type { PrepareChatTurnDeps, PrepareChatTurnParams, PrepareChatTurnResult } from "./app/chat-turn.js";
@@ -137,8 +141,10 @@ export type { ResolveWorkspaceRoot } from "./workspace/paths.js";
137
141
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
138
142
  export type { CreateSandboxClientConfig, SandboxBackendConfig } from "./sandbox/create-sandbox.js";
139
143
  export type { SandboxClient } from "./sandbox/client.js";
140
- export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
141
- export type { SandboxError } from "./sandbox/sandbox-error.js";
144
+ export type { ResolveSandboxLabels, SandboxLabels, SandboxSpec } from "./sandbox/types.js";
145
+ export type { SpawnSession } from "./auth/types.js";
146
+ export { describeSandboxError, keepSuspendingRefusal, SandboxFailure } from "./sandbox/sandbox-error.js";
147
+ export type { SandboxError, SuspendingRefusal } from "./sandbox/sandbox-error.js";
142
148
  export type { ExtendAnalysisFarm, FarmLocation, FarmResolution, FarmSource, PackageRequestOutcome, ResolveAnalysisFarm, ToolchainSource, } from "./sandbox/types.js";
143
149
  export { formatQuery, identityAddress, identityKey, identityOf, parseIdentityKey, parseQuery, pythonIdentity, resolveQuery, rIdentity, } from "./sandbox/package-identity.js";
144
150
  export type { PackageIdentity, PackageQuery, ParseQueryError, PoolIndex, QueryResolution, Track } from "./sandbox/package-identity.js";
package/dist/index.js CHANGED
@@ -29,13 +29,11 @@ export { bootHarness } from "./runtime/boot.js";
29
29
  export { createConversationAgent } from "./agents/conversation-agent.js";
30
30
  // Seam: run authorization.
31
31
  export { createLocalRunAuthorizer } from "./auth/local-run-authorizer.js";
32
- // Seam: billing resolution.
33
- export { createNoopBillingResolver } from "./billing/noop-resolver.js";
34
32
  // Seam: run-level billing bracket.
35
33
  export { createNoopRunCharge } from "./billing/noop-run-charge.js";
36
- // Seam: per-call LLM usage accounting. `record` is fire-and-forget — the loop
37
- // neither awaits nor guards it, so a realization must not throw and must not
38
- // block. Wired as the optional `usageRecorder` of `assembleCoreRuntime`;
34
+ // Seam: per-call LLM usage accounting. `record` is a notice: the loop does not
35
+ // wait for its result, and it logs the reason of an err when the result
36
+ // arrives. Wired as the optional `usageRecorder` of `assembleCoreRuntime`;
39
37
  // unwired, every call is dropped by `createNoopUsageRecorder`.
40
38
  export { createNoopUsageRecorder } from "./billing/noop-usage-recorder.js";
41
39
  // Seam: artifact registration.
@@ -135,6 +133,12 @@ export { DEFAULT_REASONING } from "./providers/reasoning.js";
135
133
  // directly needs: `chatStream` throws a `ProviderError` value (a plain object,
136
134
  // not an `Error`), so a `catch` recognizes it by shape, not `instanceof`.
137
135
  export { toProviderError, isProviderError } from "./providers/errors.js";
136
+ // The suspend map of a provider: an HTTP status to the suspend reason of the
137
+ // host. A map from the host replaces `DEFAULT_SUSPEND_ON`, which holds `402`.
138
+ export { DEFAULT_SUSPEND_ON } from "./providers/errors.js";
139
+ // The suspension of work. A host reads the suspend reason of a failed chat turn
140
+ // with `suspensionOfFailure`, by the kind of the error, never by its text.
141
+ export { suspensionOfFailure } from "./workflows/suspension.js";
138
142
  // `createStreamingChat` wraps a `ChatProvider` as a streaming `AgentChat` (the
139
143
  // type `runAgent`'s `provider` option takes): its `chat` drives the provider's
140
144
  // `chatStream` and forwards each text delta to `onText`, so a same-process host
@@ -248,7 +252,9 @@ export { computeInputSignature } from "./execution/input-signature.js";
248
252
  export { createSandboxClient } from "./sandbox/create-sandbox.js";
249
253
  // The throw of the client seam. An embedder matches the class and reads the
250
254
  // typed variant on `error`, instead of a string match on the message.
251
- export { describeSandboxError, SandboxFailure } from "./sandbox/sandbox-error.js";
255
+ // `createSandbox` gives each failure as an `err`, and `keepSuspendingRefusal`
256
+ // keeps a refusal of the label hook with the suspend flag as a value and throws each other failure.
257
+ export { describeSandboxError, keepSuspendingRefusal, SandboxFailure } from "./sandbox/sandbox-error.js";
252
258
  // The identity of a package, the query that asks for one, and the resolution
253
259
  // between the two. An embedder reads `by_name` of the dependency graph with
254
260
  // these, thus the host lookup, the census, and the graph key never disagree on
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The two kinds of host hook: a gate and a notice.
3
+ *
4
+ * A gate gives a value or a permission that the harness must have before it
5
+ * can continue an operation. A notice reports a fact after the operation. The
6
+ * type of each hook shows its kind:
7
+ *
8
+ * gate: (input) => ResultAsync<T, GateFailure>
9
+ * notice: (input) => ResultAsync<void, NoticeFailure>
10
+ *
11
+ * The harness calls each hook through `passGate` or `deliverNotice`, thus no
12
+ * call site selects its own failure policy, and no call site puts a `try` or a
13
+ * `catch` around a hook. A hook gives a failure as an `err`. A hook whose
14
+ * promise rejects has a defect, and the rejection passes through the helpers
15
+ * with no change.
16
+ *
17
+ * The reason of a failure is the text of the host. The harness carries it and
18
+ * never reads it. The flag `suspend` is part of the contract, thus the harness
19
+ * branches on it.
20
+ */
21
+ import { ResultAsync } from "neverthrow";
22
+ import type { Logger } from "./logger.js";
23
+ export interface GateFailure {
24
+ readonly reason: string;
25
+ /** True when the host asks the harness to suspend the work in place of a failure. */
26
+ readonly suspend: boolean;
27
+ }
28
+ export interface NoticeFailure {
29
+ readonly reason: string;
30
+ }
31
+ export type GateName = "RunAuthorizer.authorize" | "RunCharge.open" | "ArtifactRegistry.register" | "resolveRequestHeaders" | "resolveSandboxLabels";
32
+ export type NoticeName = "RunAuthorizer.revoke" | "RunAuthorizer.revokeByJti" | "RunCharge.close" | "ArtifactRegistry.sync" | "UsageRecorder.record";
33
+ export type GateRefusal = {
34
+ readonly kind: "failed";
35
+ readonly gate: GateName;
36
+ readonly reason: string;
37
+ } | {
38
+ readonly kind: "suspended";
39
+ readonly gate: GateName;
40
+ readonly reason: string;
41
+ };
42
+ /**
43
+ * The host can build its `Result` with its own copy of neverthrow. The helper
44
+ * gives the outcome again as a `Result` of the copy of the harness. Thus the
45
+ * checkpoint recipes (`runtime/result-serialization.ts`) recognize the class,
46
+ * and a replay reads the same outcome.
47
+ */
48
+ export declare function passGate<T>(gate: GateName, result: ResultAsync<T, GateFailure>): ResultAsync<T, GateRefusal>;
49
+ export declare function deliverNotice(log: Logger, notice: NoticeName, result: ResultAsync<void, NoticeFailure>): Promise<boolean>;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The two kinds of host hook: a gate and a notice.
3
+ *
4
+ * A gate gives a value or a permission that the harness must have before it
5
+ * can continue an operation. A notice reports a fact after the operation. The
6
+ * type of each hook shows its kind:
7
+ *
8
+ * gate: (input) => ResultAsync<T, GateFailure>
9
+ * notice: (input) => ResultAsync<void, NoticeFailure>
10
+ *
11
+ * The harness calls each hook through `passGate` or `deliverNotice`, thus no
12
+ * call site selects its own failure policy, and no call site puts a `try` or a
13
+ * `catch` around a hook. A hook gives a failure as an `err`. A hook whose
14
+ * promise rejects has a defect, and the rejection passes through the helpers
15
+ * with no change.
16
+ *
17
+ * The reason of a failure is the text of the host. The harness carries it and
18
+ * never reads it. The flag `suspend` is part of the contract, thus the harness
19
+ * branches on it.
20
+ */
21
+ import { err, ok, ResultAsync } from "neverthrow";
22
+ /**
23
+ * The host can build its `Result` with its own copy of neverthrow. The helper
24
+ * gives the outcome again as a `Result` of the copy of the harness. Thus the
25
+ * checkpoint recipes (`runtime/result-serialization.ts`) recognize the class,
26
+ * and a replay reads the same outcome.
27
+ */
28
+ export function passGate(gate, result) {
29
+ return new ResultAsync(Promise.resolve(result).then((settled) => settled.isOk()
30
+ ? ok(settled.value)
31
+ : err({ kind: settled.error.suspend ? "suspended" : "failed", gate, reason: settled.error.reason })));
32
+ }
33
+ export async function deliverNotice(log, notice, result) {
34
+ const delivered = await result;
35
+ if (delivered.isOk())
36
+ return true;
37
+ log.error("a notice of the host failed", { notice, reason: delivered.error.reason });
38
+ return false;
39
+ }
@@ -4,20 +4,31 @@
4
4
  * The agent loop and execution boundary model failure as values, not throws.
5
5
  * The house rules:
6
6
  *
7
- * 1. `try/catch` lives ONLY inside the thin wrappers at calls to code
8
- * external to ours — a third-party SDK, the `pg` driver, `fs`, the DBOS
9
- * runtime. Those wrappers turn a throw into an `err(DomainError)`.
10
- * Everything above them flows `Result` / `ResultAsync`.
7
+ * 1. `try/catch` lives ONLY at a boundary:
8
+ * - the thin wrappers at calls to code external to ours — a third-party
9
+ * SDK, the `pg` driver, `fs`, the DBOS runtime. Those wrappers turn a
10
+ * throw into an `err(DomainError)`.
11
+ * - an API entry point, where a caller outside the harness gets the
12
+ * outcome.
13
+ * - a workflow body that gets the throw of a failed step or a failed
14
+ * child workflow, and runs its failure path.
15
+ * - the dispatch catch of the loop.
16
+ * Everything else flows `Result` / `ResultAsync`. A host hook has no
17
+ * `try/catch` around it: `passGate` / `deliverNotice` (`lib/hooks.ts`)
18
+ * consume its `Result`.
11
19
  * 2. Absence is NOT an error. A "not found" stays in the ok channel as a
12
20
  * data variant (`ok({ found: false })`), never an `err`. `err` is
13
21
  * reserved for what used to be `throw`n — an unexpected upstream failure.
14
22
  * 3. A `Result` is unwrapped at exactly three edges, each speaking its own
15
23
  * dialect of failure:
16
24
  * - HTTP routes `.match(...)` a `Result` into a response.
17
- * - The DBOS step boundary throws on `err` (`unwrapOrThrow` /
18
- * `resultStep` in `loop/run-step.ts`): durability records a step as
19
- * failed — and retries / fails fast — ONLY on a thrown exception, so
20
- * an `err` crossing `DBOS.runStep` MUST become a throw.
25
+ * - The DBOS step boundary throws on an `err` that must fail or retry
26
+ * the step (`unwrapOrThrow` / `resultStep` in `loop/run-step.ts`):
27
+ * durability records a step as failed — and retries / fails fast —
28
+ * ONLY on a thrown exception. A step can also return a `Result` as
29
+ * its value: `runtime/result-serialization.ts` keeps the `Ok` / `Err`
30
+ * class across the checkpoint, DBOS records the step as a success,
31
+ * and the body MUST use the `err`.
21
32
  * - Tool dispatch maps `err` into an `is_error` `tool_result`
22
33
  * (`loop/run-agent.ts`).
23
34
  *
@@ -43,9 +54,9 @@ export interface DomainError {
43
54
  /**
44
55
  * A thrown wrapper for a structured error value that is not already an
45
56
  * `Error`. The original value rides on `.cause`, so the cause-walking
46
- * classifiers (`classifyProviderError`, `isBudgetExceeded`) still reach the
47
- * `status` / `code` signals they look for after a `Result` is rethrown at a
48
- * boundary.
57
+ * classifiers (`classifyProviderError`, `findSuspendError`) still reach the
58
+ * `status` / `code` / `type` signals they look for after a `Result` is
59
+ * rethrown at a boundary.
49
60
  */
50
61
  export declare class ResultError extends Error {
51
62
  readonly value: unknown;
@@ -4,20 +4,31 @@
4
4
  * The agent loop and execution boundary model failure as values, not throws.
5
5
  * The house rules:
6
6
  *
7
- * 1. `try/catch` lives ONLY inside the thin wrappers at calls to code
8
- * external to ours — a third-party SDK, the `pg` driver, `fs`, the DBOS
9
- * runtime. Those wrappers turn a throw into an `err(DomainError)`.
10
- * Everything above them flows `Result` / `ResultAsync`.
7
+ * 1. `try/catch` lives ONLY at a boundary:
8
+ * - the thin wrappers at calls to code external to ours — a third-party
9
+ * SDK, the `pg` driver, `fs`, the DBOS runtime. Those wrappers turn a
10
+ * throw into an `err(DomainError)`.
11
+ * - an API entry point, where a caller outside the harness gets the
12
+ * outcome.
13
+ * - a workflow body that gets the throw of a failed step or a failed
14
+ * child workflow, and runs its failure path.
15
+ * - the dispatch catch of the loop.
16
+ * Everything else flows `Result` / `ResultAsync`. A host hook has no
17
+ * `try/catch` around it: `passGate` / `deliverNotice` (`lib/hooks.ts`)
18
+ * consume its `Result`.
11
19
  * 2. Absence is NOT an error. A "not found" stays in the ok channel as a
12
20
  * data variant (`ok({ found: false })`), never an `err`. `err` is
13
21
  * reserved for what used to be `throw`n — an unexpected upstream failure.
14
22
  * 3. A `Result` is unwrapped at exactly three edges, each speaking its own
15
23
  * dialect of failure:
16
24
  * - HTTP routes `.match(...)` a `Result` into a response.
17
- * - The DBOS step boundary throws on `err` (`unwrapOrThrow` /
18
- * `resultStep` in `loop/run-step.ts`): durability records a step as
19
- * failed — and retries / fails fast — ONLY on a thrown exception, so
20
- * an `err` crossing `DBOS.runStep` MUST become a throw.
25
+ * - The DBOS step boundary throws on an `err` that must fail or retry
26
+ * the step (`unwrapOrThrow` / `resultStep` in `loop/run-step.ts`):
27
+ * durability records a step as failed — and retries / fails fast —
28
+ * ONLY on a thrown exception. A step can also return a `Result` as
29
+ * its value: `runtime/result-serialization.ts` keeps the `Ok` / `Err`
30
+ * class across the checkpoint, DBOS records the step as a success,
31
+ * and the body MUST use the `err`.
21
32
  * - Tool dispatch maps `err` into an `is_error` `tool_result`
22
33
  * (`loop/run-agent.ts`).
23
34
  *
@@ -32,9 +43,9 @@ import { Err, Ok } from "neverthrow";
32
43
  /**
33
44
  * A thrown wrapper for a structured error value that is not already an
34
45
  * `Error`. The original value rides on `.cause`, so the cause-walking
35
- * classifiers (`classifyProviderError`, `isBudgetExceeded`) still reach the
36
- * `status` / `code` signals they look for after a `Result` is rethrown at a
37
- * boundary.
46
+ * classifiers (`classifyProviderError`, `findSuspendError`) still reach the
47
+ * `status` / `code` / `type` signals they look for after a `Result` is
48
+ * rethrown at a boundary.
38
49
  */
39
50
  export class ResultError extends Error {
40
51
  value;
@@ -49,9 +60,8 @@ function describe(value) {
49
60
  return value.message;
50
61
  if (value && typeof value === "object" && "type" in value) {
51
62
  // Prefer a human `message` field when the structured error carries one
52
- // (e.g. a `ProviderError`) so the wrapped `Error.message` stays
53
- // pattern-matchable (`isBudgetExceeded`'s message backstop); fall back
54
- // to the `type` discriminant otherwise.
63
+ // (e.g. a `ProviderError`) so the wrapped `Error.message` names the
64
+ // failure; fall back to the `type` discriminant otherwise.
55
65
  const message = value.message;
56
66
  if (typeof message === "string" && message.length > 0)
57
67
  return message;
@@ -100,8 +100,8 @@ export interface RunAgentOptions {
100
100
  readonly logger?: Logger;
101
101
  /**
102
102
  * Per-call LLM usage-accounting seam. Omitted falls back to the no-op
103
- * recorder. Delivery is fire-and-forget by contract — the loop neither
104
- * awaits `record` nor guards it, so a realization must not throw or block.
103
+ * recorder. `record` is a notice: the loop does not wait for its result,
104
+ * and it logs the reason of an `err` when the result arrives.
105
105
  */
106
106
  readonly usageRecorder?: UsageRecorder;
107
107
  /**
@@ -3,6 +3,7 @@ import { jsonSchema, tool as aiTool, } from "ai";
3
3
  import { createNoopUsageRecorder } from "../billing/noop-usage-recorder.js";
4
4
  import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
+ import { deliverNotice } from "../lib/hooks.js";
6
7
  import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/otel-spans.js";
7
8
  import { ResultError } from "../lib/result.js";
8
9
  import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
@@ -150,8 +151,8 @@ async function runAgentLoop(agent, initial, session, opts) {
150
151
  * reported nothing to account for, so it is folded (to no effect) and left
151
152
  * out of the ledger rather than entered as an all-absent record.
152
153
  *
153
- * Delivery is bare — no `await`, no `try` — because the `UsageRecorder`
154
- * contract forbids `record` to throw or block.
154
+ * `record` is a notice. The loop does not wait for its result, thus a
155
+ * recorder that blocks does not make the run slower.
155
156
  */
156
157
  const accountForCall = (reply, stepName) => {
157
158
  addChatUsage(usage, reply.usage);
@@ -159,7 +160,7 @@ async function runAgentLoop(agent, initial, session, opts) {
159
160
  const reported = reply.usage;
160
161
  if (reported === undefined || !hasReportedUsage(reported))
161
162
  return;
162
- usageRecorder.record({
163
+ void deliverNotice(log, "UsageRecorder.record", usageRecorder.record({
163
164
  recordKey: recordKeyFor(session, opts.invocationId, stepName),
164
165
  agentId: source.agentId,
165
166
  callPath: source.callPath,
@@ -169,7 +170,7 @@ async function runAgentLoop(agent, initial, session, opts) {
169
170
  ...(reply.requestedModelId === undefined ? {} : { requestedModelId: reply.requestedModelId }),
170
171
  ...(reply.servedModelId === undefined ? {} : { servedModelId: reply.servedModelId }),
171
172
  usage: reported,
172
- });
173
+ }));
173
174
  };
174
175
  /** The rollups this loop stamps on its finish — each absent when nothing reported. */
175
176
  const finishUsage = () => ({
@@ -23,7 +23,20 @@ export function storedMessagesToCortex(messages) {
23
23
  let append = null;
24
24
  for (const row of messages) {
25
25
  if (row.displayEnvelope) {
26
- append = conversationUIToCortexMessages(row.displayEnvelope.messages);
26
+ // The author and the creation time are facts about the ROW, the same as
27
+ // the rollup below, and the projection holds neither. Both ride the row
28
+ // that opens the append, and the author reaches the `user` messages
29
+ // alone, because `role` already names the sender of a reply. Spread
30
+ // conditionally: an absent value must leave no key, or a consumer that
31
+ // spreads the message acquires one that overwrites a real value. `Date`
32
+ // does not survive the JSON crossing typed, hence the ISO string here.
33
+ const createdAt = row.createdAt === undefined ? {} : { createdAt: row.createdAt.toISOString() };
34
+ const author = row.author === undefined ? {} : { author: row.author };
35
+ append = conversationUIToCortexMessages(row.displayEnvelope.messages).map((message) => ({
36
+ ...message,
37
+ ...createdAt,
38
+ ...(message.role === "user" ? author : {}),
39
+ }));
27
40
  out.push(...append);
28
41
  }
29
42
  // The reported rollup of the turn and its duration both ride the model row that
@@ -55,6 +55,20 @@ export interface StoredMessage {
55
55
  * reported no quantity still reads back with the duration that it took.
56
56
  */
57
57
  readonly durationMs?: number;
58
+ /**
59
+ * Who sent the user message of the append this row OPENED — present only on a
60
+ * genuine-user-start row of a turn appended with one. A row written before the
61
+ * column existed carries none, because no sender was ever recorded for it.
62
+ */
63
+ readonly author?: string;
64
+ /**
65
+ * When the store wrote this row: the START time of the append transaction,
66
+ * thus every row of one append holds the same value, and `seq` alone orders
67
+ * rows. The read sets it on every row, because the column is `NOT NULL`. It is
68
+ * optional because a fake or a fixture builds a `StoredMessage` with no row
69
+ * behind it.
70
+ */
71
+ readonly createdAt?: Date;
58
72
  }
59
73
  /**
60
74
  * One atomic append: the exact provider-facing history, the display projection of
@@ -86,6 +100,16 @@ export interface ConversationTurn {
86
100
  * reported no quantity still keeps the duration that it took.
87
101
  */
88
102
  readonly turnDurationMs?: number;
103
+ /**
104
+ * Who sent the user message of this turn — the identity the host holds at the
105
+ * append site. Stored on the FIRST row of the append, and only when that row
106
+ * is a genuine user start: that row also carries the display projection, thus
107
+ * the write and the transcript replay read one row under one rule. A record
108
+ * append opens on a synthetic row, so it stores no author however a caller
109
+ * spreads one in. The value is NUL-stripped on the way in, the same as the
110
+ * envelope, because a `text` parameter carrying 0x00 fails the statement.
111
+ */
112
+ readonly author?: string;
89
113
  }
90
114
  /**
91
115
  * The append a host uses to record out-of-band work in a thread — an analysis
@@ -181,7 +181,7 @@ export const EVICTION_BLOCK_TURNS = 4;
181
181
  */
182
182
  export function createThreadHistory(pool, logger) {
183
183
  function appendTurn(threadId, turn) {
184
- const { modelMessages: messages, displayMessages, turnUsage, turnDurationMs } = turn;
184
+ const { modelMessages: messages, displayMessages, turnUsage, turnDurationMs, author } = turn;
185
185
  if (messages.length === 0)
186
186
  return okVoid();
187
187
  // The display projection rides the append's FIRST row, so one SELECT of a
@@ -206,6 +206,13 @@ export function createThreadHistory(pool, logger) {
206
206
  // the turn. -1 (a turn that persisted no reply — an abort before any output)
207
207
  // matches no index below, so nothing is written and the append still succeeds.
208
208
  const lastAssistantRow = messages.reduce((last, m, i) => (m.role === "assistant" ? i : last), -1);
209
+ // The author rides the row the display projection rides, and only when that
210
+ // row is a message a person actually sent — an assistant row, a tool row,
211
+ // and the synthetic opening row of a record append are not. NUL is dropped
212
+ // rather than refused: a 0x00 byte in a `text` parameter fails the
213
+ // statement, and one byte in a label would roll the whole turn back.
214
+ const turnAuthor = author === undefined ? null : stripNulCharacters(author);
215
+ const authorRow = messages[0] !== undefined && isGenuineUserStart(messages[0]) ? 0 : -1;
209
216
  return withTransaction(pool, "thread-history.appendTurn", (client) =>
210
217
  // Serialize concurrent appends on this thread — without the lock, two
211
218
  // transactions can both read the same MAX(seq) and collide on the
@@ -224,14 +231,15 @@ export function createThreadHistory(pool, logger) {
224
231
  // `message_envelope::json`, never `::jsonb`; `display_envelope` and
225
232
  // `reported_usage::jsonb`, never `::json` — see the column comments
226
233
  // in the state-init DDL.
227
- `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms)
228
- VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7)
234
+ `INSERT INTO messages (thread_id, seq, message_envelope, display_envelope, tokens, reported_usage, turn_duration_ms, author)
235
+ VALUES ($1, $2, $3::json, $4::jsonb, $5, $6::jsonb, $7, $8)
229
236
  ON CONFLICT (thread_id, seq) DO UPDATE
230
237
  SET message_envelope = EXCLUDED.message_envelope,
231
238
  display_envelope = EXCLUDED.display_envelope,
232
239
  tokens = EXCLUDED.tokens,
233
240
  reported_usage = EXCLUDED.reported_usage,
234
- turn_duration_ms = EXCLUDED.turn_duration_ms`, [
241
+ turn_duration_ms = EXCLUDED.turn_duration_ms,
242
+ author = EXCLUDED.author`, [
235
243
  threadId,
236
244
  startSeq + i,
237
245
  serializeEnvelope(message),
@@ -239,6 +247,7 @@ export function createThreadHistory(pool, logger) {
239
247
  countTokens(message.content),
240
248
  i === lastAssistantRow ? rollup : null,
241
249
  i === lastAssistantRow ? duration : null,
250
+ i === authorRow ? turnAuthor : null,
242
251
  ])).map(() => undefined)), okVoid()))
243
252
  .andThen(() =>
244
253
  // Thread listings sort on `cortex_analysis_threads.updated_at`, and
@@ -368,12 +377,14 @@ export function createThreadHistory(pool, logger) {
368
377
  // `seq::text AS seq` output alias (Postgres resolves an unqualified
369
378
  // ORDER BY name to the output column), sorting the bigint as text:
370
379
  // "10" before "2". The qualified name forces the bigint column.
371
- `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage, turn_duration_ms::text AS turn_duration_ms
380
+ `SELECT seq::text AS seq, message_envelope, display_envelope, reported_usage, turn_duration_ms::text AS turn_duration_ms, author, created_at
372
381
  FROM messages WHERE thread_id = $1
373
382
  ORDER BY messages.seq ASC`, [threadId]);
374
383
  // Spread rather than `usage: r.reported_usage ?? undefined`, so a row with
375
384
  // no rollup carries no `usage` KEY at all — absent, not present-and-undefined.
376
- // The duration obeys the same rule, and it reads from its own column.
385
+ // The duration and the author obey the same rule, each from its own column.
386
+ // The creation time is unconditional: its column is NOT NULL, thus it has no
387
+ // absence to represent.
377
388
  const stored = await Promise.all(rows.map(async (r) => {
378
389
  const envelope = parseStoredMessageEnvelope(r.message_envelope, `${threadId}/${r.seq}`);
379
390
  const displayEnvelope = r.display_envelope == null ? undefined : await parseStoredDisplayEnvelope(r.display_envelope, `${threadId}/${r.seq}/display`, logger);
@@ -384,6 +395,8 @@ export function createThreadHistory(pool, logger) {
384
395
  ...(displayEnvelope ? { displayEnvelope } : {}),
385
396
  ...(r.reported_usage === null ? {} : { usage: r.reported_usage }),
386
397
  ...(r.turn_duration_ms === null ? {} : { durationMs: Number(r.turn_duration_ms) }),
398
+ ...(r.author === null ? {} : { author: r.author }),
399
+ createdAt: r.created_at,
387
400
  };
388
401
  }));
389
402
  return groupTurns(stored, (row) => isGenuineUserStart(row.message));
@@ -69,7 +69,7 @@ export type RunProvenanceEvent = {
69
69
  runId: string;
70
70
  /**
71
71
  * The body's terminal status. Both boundary sites resolve it through `deriveFinalStatus`,
72
- * which records a budget pause as `"canceled"` — so `"suspended_insufficient_funds"` (a
72
+ * which records a suspension as `"canceled"` — so `"suspended_insufficient_funds"` (a
73
73
  * `RunStatus` member) is never emitted here and is deliberately absent from this narrower
74
74
  * `ExecuteAnalysisFinalStatus`-minus-`"running"` set.
75
75
  */