@relayflows/sdk 2.0.36 → 2.0.37

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 (143) hide show
  1. package/dist/authored-flow-executor.d.ts +2 -0
  2. package/dist/authored-flow-executor.d.ts.map +1 -1
  3. package/dist/authored-flow-executor.js +2 -1
  4. package/dist/authored-flow-executor.js.map +1 -1
  5. package/dist/authored-helper-effect.d.ts.map +1 -1
  6. package/dist/authored-helper-effect.js +3 -2
  7. package/dist/authored-helper-effect.js.map +1 -1
  8. package/dist/authored-mcp.d.ts.map +1 -1
  9. package/dist/authored-mcp.js +2 -0
  10. package/dist/authored-mcp.js.map +1 -1
  11. package/dist/authored-node-entry.js +6 -0
  12. package/dist/authored-node-entry.js.map +1 -1
  13. package/dist/authored-node-runner.d.ts.map +1 -1
  14. package/dist/authored-node-runner.js +2 -1
  15. package/dist/authored-node-runner.js.map +1 -1
  16. package/dist/authored-plugin-effect.d.ts.map +1 -1
  17. package/dist/authored-plugin-effect.js +2 -0
  18. package/dist/authored-plugin-effect.js.map +1 -1
  19. package/dist/authored-preflight.d.ts +1 -1
  20. package/dist/authored-preflight.d.ts.map +1 -1
  21. package/dist/authored-preflight.js +2 -2
  22. package/dist/authored-preflight.js.map +1 -1
  23. package/dist/authored-root.d.ts +2 -0
  24. package/dist/authored-root.d.ts.map +1 -1
  25. package/dist/authored-root.js +5 -0
  26. package/dist/authored-root.js.map +1 -1
  27. package/dist/authored-verdict.d.ts +2 -0
  28. package/dist/authored-verdict.d.ts.map +1 -1
  29. package/dist/authored-verdict.js +2 -1
  30. package/dist/authored-verdict.js.map +1 -1
  31. package/dist/authored-worker-step.d.ts +1 -1
  32. package/dist/authored-worker-step.d.ts.map +1 -1
  33. package/dist/authored-worker-step.js +2 -2
  34. package/dist/authored-worker-step.js.map +1 -1
  35. package/dist/cli/check.d.ts +2 -0
  36. package/dist/cli/check.d.ts.map +1 -1
  37. package/dist/cli/check.js +3 -3
  38. package/dist/cli/check.js.map +1 -1
  39. package/dist/cli/cli-probe.d.ts +2 -2
  40. package/dist/cli/cli-probe.d.ts.map +1 -1
  41. package/dist/cli/cli-probe.js +8 -7
  42. package/dist/cli/cli-probe.js.map +1 -1
  43. package/dist/cli/direct-run.d.ts.map +1 -1
  44. package/dist/cli/direct-run.js +7 -2
  45. package/dist/cli/direct-run.js.map +1 -1
  46. package/dist/cli/run.d.ts.map +1 -1
  47. package/dist/cli/run.js +15 -9
  48. package/dist/cli/run.js.map +1 -1
  49. package/dist/communication/local.d.ts +1 -1
  50. package/dist/communication/local.d.ts.map +1 -1
  51. package/dist/communication/local.js +2 -2
  52. package/dist/communication/local.js.map +1 -1
  53. package/dist/communication/worker.d.ts +1 -1
  54. package/dist/communication/worker.d.ts.map +1 -1
  55. package/dist/communication/worker.js +4 -4
  56. package/dist/communication/worker.js.map +1 -1
  57. package/dist/decimal-dollars.d.ts +12 -0
  58. package/dist/decimal-dollars.d.ts.map +1 -0
  59. package/dist/decimal-dollars.js +39 -0
  60. package/dist/decimal-dollars.js.map +1 -0
  61. package/dist/effect-channel.d.ts.map +1 -1
  62. package/dist/effect-channel.js +2 -0
  63. package/dist/effect-channel.js.map +1 -1
  64. package/dist/helper-instruction.d.ts +3 -0
  65. package/dist/helper-instruction.d.ts.map +1 -0
  66. package/dist/helper-instruction.js +3 -0
  67. package/dist/helper-instruction.js.map +1 -0
  68. package/dist/journal-client.d.ts +5 -2
  69. package/dist/journal-client.d.ts.map +1 -1
  70. package/dist/journal-client.js +12 -4
  71. package/dist/journal-client.js.map +1 -1
  72. package/dist/llm-worker.d.ts +2 -1
  73. package/dist/llm-worker.d.ts.map +1 -1
  74. package/dist/llm-worker.js +7 -2
  75. package/dist/llm-worker.js.map +1 -1
  76. package/dist/local-agent-environment.d.ts +13 -0
  77. package/dist/local-agent-environment.d.ts.map +1 -0
  78. package/dist/local-agent-environment.js +104 -0
  79. package/dist/local-agent-environment.js.map +1 -0
  80. package/dist/local-agent.d.ts +1 -1
  81. package/dist/local-agent.d.ts.map +1 -1
  82. package/dist/local-agent.js +2 -2
  83. package/dist/local-agent.js.map +1 -1
  84. package/dist/protocol.d.ts +21 -0
  85. package/dist/protocol.d.ts.map +1 -1
  86. package/dist/reported-cost-total.d.ts +41 -0
  87. package/dist/reported-cost-total.d.ts.map +1 -0
  88. package/dist/reported-cost-total.js +63 -0
  89. package/dist/reported-cost-total.js.map +1 -0
  90. package/dist/reported-cost.d.ts +24 -0
  91. package/dist/reported-cost.d.ts.map +1 -0
  92. package/dist/reported-cost.js +49 -0
  93. package/dist/reported-cost.js.map +1 -0
  94. package/dist/run-state.d.ts +9 -11
  95. package/dist/run-state.d.ts.map +1 -1
  96. package/dist/run-state.js +15 -38
  97. package/dist/run-state.js.map +1 -1
  98. package/dist/worker-cli.d.ts +1 -1
  99. package/dist/worker-cli.d.ts.map +1 -1
  100. package/dist/worker-cli.js +3 -3
  101. package/dist/worker-cli.js.map +1 -1
  102. package/dist/worker.d.ts +2 -0
  103. package/dist/worker.d.ts.map +1 -1
  104. package/dist/worker.js +5 -2
  105. package/dist/worker.js.map +1 -1
  106. package/dist/yaml-helper-effect.d.ts.map +1 -1
  107. package/dist/yaml-helper-effect.js +2 -1
  108. package/dist/yaml-helper-effect.js.map +1 -1
  109. package/dist/yaml-helpers.d.ts.map +1 -1
  110. package/dist/yaml-helpers.js +2 -1
  111. package/dist/yaml-helpers.js.map +1 -1
  112. package/package.json +2 -2
  113. package/src/authored-flow-executor.ts +4 -1
  114. package/src/authored-helper-effect.ts +3 -2
  115. package/src/authored-mcp.ts +2 -0
  116. package/src/authored-node-entry.ts +8 -1
  117. package/src/authored-node-runner.ts +2 -1
  118. package/src/authored-plugin-effect.ts +2 -0
  119. package/src/authored-preflight.ts +2 -2
  120. package/src/authored-root.ts +7 -0
  121. package/src/authored-verdict.ts +2 -1
  122. package/src/authored-worker-step.ts +2 -1
  123. package/src/cli/check.ts +6 -3
  124. package/src/cli/cli-probe.ts +8 -6
  125. package/src/cli/direct-run.ts +7 -1
  126. package/src/cli/run.ts +15 -7
  127. package/src/communication/local.ts +3 -2
  128. package/src/communication/worker.ts +7 -4
  129. package/src/decimal-dollars.ts +38 -0
  130. package/src/effect-channel.ts +2 -0
  131. package/src/helper-instruction.ts +2 -0
  132. package/src/journal-client.ts +14 -5
  133. package/src/llm-worker.ts +6 -1
  134. package/src/local-agent-environment.ts +92 -0
  135. package/src/local-agent.ts +2 -1
  136. package/src/protocol.ts +22 -0
  137. package/src/reported-cost-total.ts +82 -0
  138. package/src/reported-cost.ts +51 -0
  139. package/src/run-state.ts +25 -37
  140. package/src/worker-cli.ts +3 -2
  141. package/src/worker.ts +9 -2
  142. package/src/yaml-helper-effect.ts +2 -1
  143. package/src/yaml-helpers.ts +2 -1
@@ -0,0 +1,38 @@
1
+ // Exact decimal-dollar arithmetic shared by the journal folds.
2
+
3
+ /**
4
+ * Sum two decimal dollar strings exactly, at whatever scale they carry — the
5
+ * kernel's budget fold (`relayflowd-core/src/state/budget.rs`) aligns every
6
+ * fractional digit before adding, and it accepts arbitrary precision. Scaling
7
+ * to a fixed six fractional digits dropped a charge of `0.0000001` to zero, so
8
+ * `flows status` underreported spend the kernel had totalled exactly.
9
+ *
10
+ * A value that is not a decimal number is left out of the sum rather than
11
+ * counted as zero, and the caller is told by `malformed`.
12
+ */
13
+ export function addDollars(left: string, right: string): string {
14
+ const parsed = [left, right].map((value) => /^\d+(?:\.\d+)?$/.test(value) ? value : null);
15
+ const usable = parsed.filter((value): value is string => value !== null);
16
+ if (usable.length === 0) return '0';
17
+ if (usable.length === 1) return normalizeDollars(usable[0]!);
18
+ const scale = Math.max(...usable.map((value) => (value.split('.')[1] ?? '').length));
19
+ const scaled = usable.map((value) => {
20
+ const [whole, fraction = ''] = value.split('.');
21
+ return BigInt(`${whole}${fraction.padEnd(scale, '0')}`);
22
+ });
23
+ return fromScaled(scaled[0]! + scaled[1]!, scale);
24
+ }
25
+
26
+ /** `12.3400` -> `12.34`, `0.000` -> `0`; the journal's own spelling otherwise. */
27
+ function normalizeDollars(value: string): string {
28
+ const [whole, fraction = ''] = value.split('.');
29
+ return fromScaled(BigInt(`${whole}${fraction}`), fraction.length);
30
+ }
31
+
32
+ function fromScaled(total: bigint, scale: number): string {
33
+ if (scale === 0) return String(total);
34
+ const digits = String(total).padStart(scale + 1, '0');
35
+ const whole = digits.slice(0, digits.length - scale);
36
+ const fraction = digits.slice(digits.length - scale).replace(/0+$/, '');
37
+ return fraction.length === 0 ? whole : `${whole}.${fraction}`;
38
+ }
@@ -4,6 +4,7 @@ import type { JournalClient } from './journal-client.js';
4
4
  import type { StepDispatchEvent } from './protocol.js';
5
5
  import { SPEC_SCHEMA_VERSION } from './spec.js';
6
6
  import { withWorkerLease } from './worker-lease.js';
7
+ import { NO_MODEL_COST } from './reported-cost.js';
7
8
  import { snapshotJsonValue } from './json-value.js';
8
9
 
9
10
  /** Structural subset of Agent Relay's messages.dm; credentials stay in the client. */
@@ -93,5 +94,6 @@ export async function completeChannelPost(
93
94
  messageId, channel: call.channel, to: call.to, text: call.text },
94
95
  started_pins: dispatch.pins, end_pins: dispatch.pins,
95
96
  effects: [{ surface_path: surfacePath, idempotency_key: dispatch.idempotency_key }],
97
+ reported_cost: NO_MODEL_COST,
96
98
  });
97
99
  }
@@ -0,0 +1,2 @@
1
+ /** The instruction prefix a lowered provider helper step carries (`yaml-helpers.ts`). */
2
+ export const HELPER_INSTRUCTION_PREFIX = 'relayflows:helper:v1\n';
@@ -13,7 +13,7 @@ import { EventEmitter } from 'node:events';
13
13
  export { walkJournal, JournalReadError, type JournalEvent, type JournalReadFailure } from './journal-reader.js';
14
14
  import { randomUUID } from 'node:crypto';
15
15
  import { createConnection, type Socket } from 'node:net';
16
- import type { VerbContract, EventEmitParams, EventSubmitParams } from './protocol.js';
16
+ import type { VerbContract, EventEmitParams, EventSubmitParams, ReportedCost } from './protocol.js';
17
17
  import {
18
18
  PROTOCOL_VERSION,
19
19
  type CompletionReason,
@@ -61,6 +61,8 @@ export class JournalProtocolError extends Error {
61
61
  }
62
62
 
63
63
  export class JournalClient extends EventEmitter {
64
+ /** Additive request capabilities the daemon advertised at `hello`; none until then. */
65
+ private features: ReadonlySet<string> = new Set();
64
66
  private socket: Socket | null = null;
65
67
  /** Why the connection ended, so a later "not connected" names its cause rather than hiding it. */
66
68
  private disconnectCause: Error | undefined;
@@ -215,9 +217,11 @@ export class JournalClient extends EventEmitter {
215
217
 
216
218
  // --- Typed verb methods (gate 1 minimal set, kernel DESIGN.md §5) --------
217
219
 
218
- /** Handshake; version mismatch is a hard error. */
219
- hello(client: string): Promise<VerbContract['hello']['result']> {
220
- return this.request('hello', { protocol: PROTOCOL_VERSION, client });
220
+ /** Handshake; version mismatch is a hard error. Records the daemon's additive features. */
221
+ async hello(client: string): Promise<VerbContract['hello']['result']> {
222
+ const result = await this.request('hello', { protocol: PROTOCOL_VERSION, client });
223
+ this.features = new Set(Array.isArray(result.features) ? result.features.filter(f => typeof f === 'string') : []);
224
+ return result;
221
225
  }
222
226
 
223
227
  /**
@@ -403,15 +407,20 @@ export class JournalClient extends EventEmitter {
403
407
  effects?: EffectRef[];
404
408
  trajectory_tail?: unknown;
405
409
  human_intervention?: boolean;
410
+ reported_cost?: ReportedCost;
406
411
  } = {},
407
412
  ): Promise<VerbContract['step.complete']['result']> {
413
+ // `reported_cost` is display-only, and a daemon that predates it refuses
414
+ // unknown completion fields: drop it rather than fail the completion.
415
+ const { reported_cost: reportedCost, ...rest } = extra;
408
416
  return this.request('step.complete', {
409
417
  run_id: runId,
410
418
  step_id: stepId,
411
419
  attempt,
412
420
  idempotency_key: idempotencyKey,
413
421
  completionReason,
414
- ...extra,
422
+ ...rest,
423
+ ...(reportedCost !== undefined && this.features.has('reported_cost') ? { reported_cost: reportedCost } : {}),
415
424
  }, null).catch(error => {
416
425
  if (error instanceof JournalProtocolError) error.verb = 'step.complete';
417
426
  throw error;
package/src/llm-worker.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { workerSpend } from './worker-spend.js';
2
+ import { reportedCost } from './reported-cost.js';
2
3
  import { DEFAULT_LOCAL_AGENT_CAPACITY } from './worker-slots.js';
3
4
  import type { WorkerCliResult } from './worker-cli.js';
4
5
  import { EventEmitter } from 'node:events';
@@ -22,6 +23,7 @@ export class LlmWorker extends EventEmitter {
22
23
  private readonly client: JournalClient,
23
24
  private readonly workerId: string,
24
25
  private readonly capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
26
+ private readonly environment?: NodeJS.ProcessEnv,
25
27
  ) {
26
28
  super();
27
29
  }
@@ -59,9 +61,11 @@ export class LlmWorker extends EventEmitter {
59
61
  const effectiveModel = typeof spec.cli === 'string' ? resolveCliModel(spec.cli, spec.model) : spec.model;
60
62
  const completed: WorkerCliResult = await withWorkerLease(this.client, dispatch, signal =>
61
63
  typeof spec.cli === 'string' && typeof spec.prompt === 'string'
62
- ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm')
64
+ ? runAgentCli(spec.cli, workerInstruction(prompt, dispatch), dispatch.wake_context, effectiveModel, undefined, signal, 'llm',
65
+ undefined, undefined, 'direct', undefined, this.environment)
63
66
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'llm step has no declared CLI' }));
64
67
  const { result, usage } = workerSpend(completed, effectiveModel);
68
+ const cost = reportedCost(completed, effectiveModel);
65
69
  let reason: CompletionReason = result.exit_code === 0 ? 'success' : 'worker_error';
66
70
  let output: unknown = result.stdout_tail;
67
71
  let detail = result.stderr_tail;
@@ -94,6 +98,7 @@ export class LlmWorker extends EventEmitter {
94
98
  dispatch.idempotency_key, reason, {
95
99
  output,
96
100
  ...(usage !== undefined ? { usage } : {}),
101
+ ...(cost === undefined ? {} : { reported_cost: cost }),
97
102
  ...(Object.keys(trajectoryTail).length === 0 ? {} : { trajectory_tail: trajectoryTail }),
98
103
  });
99
104
  }
@@ -0,0 +1,92 @@
1
+ import { closeSync, readSync } from 'node:fs';
2
+
3
+ /**
4
+ * Inherited descriptor carrying environment values that only local agent/LLM
5
+ * subprocesses may receive. The flows process consumes and closes it before
6
+ * loading authored code, so that code cannot inherit or read the credentials.
7
+ */
8
+ export const LOCAL_AGENT_ENV_FD = 'FLOWS_LOCAL_AGENT_ENV_FD';
9
+
10
+ const MAX_BYTES = 64 * 1024;
11
+ const AGENT_CREDENTIAL_NAMES = new Set([
12
+ 'ANTHROPIC_API_KEY',
13
+ 'ANTHROPIC_BASE_URL',
14
+ 'OPENAI_API_KEY',
15
+ 'OPENAI_BASE_URL',
16
+ ]);
17
+
18
+ export function localAgentEnvironment(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv | undefined {
19
+ const environment = { ...base };
20
+ const rawFd = environment[LOCAL_AGENT_ENV_FD];
21
+ delete environment[LOCAL_AGENT_ENV_FD];
22
+ // Without the descriptor, provider launches keep their historical live
23
+ // process.env behavior instead of freezing an early snapshot.
24
+ if (rawFd === undefined) return undefined;
25
+ // Every later child — especially the authored runtime — inherits process.env.
26
+ // Remove the capability marker before any authored module can be imported.
27
+ if (base === process.env) delete process.env[LOCAL_AGENT_ENV_FD];
28
+
29
+ if (!/^[0-9]+$/.test(rawFd)) throw new Error(`${LOCAL_AGENT_ENV_FD} must name an inherited file descriptor`);
30
+ const fd = Number(rawFd);
31
+ if (!Number.isSafeInteger(fd) || fd < 3 || fd > 255) {
32
+ throw new Error(`${LOCAL_AGENT_ENV_FD} must name an inherited file descriptor`);
33
+ }
34
+
35
+ const chunks: Buffer[] = [];
36
+ let bytes = 0;
37
+ try {
38
+ for (;;) {
39
+ const chunk = Buffer.allocUnsafe(Math.min(8 * 1024, MAX_BYTES + 1 - bytes));
40
+ const read = readSync(fd, chunk, 0, chunk.length, null);
41
+ if (read === 0) break;
42
+ bytes += read;
43
+ if (bytes > MAX_BYTES) throw new Error('local agent environment payload exceeds 64 KiB');
44
+ chunks.push(chunk.subarray(0, read));
45
+ }
46
+ } catch (error) {
47
+ if (error instanceof Error && error.message === 'local agent environment payload exceeds 64 KiB') throw error;
48
+ throw new Error('local agent environment payload could not be read');
49
+ } finally {
50
+ try { closeSync(fd); } catch { /* The descriptor is unusable either way. */ }
51
+ }
52
+
53
+ let parsed: unknown;
54
+ try { parsed = JSON.parse(Buffer.concat(chunks, bytes).toString('utf8')); }
55
+ catch { throw new Error('local agent environment payload is not valid JSON'); }
56
+ if (!plainObject(parsed)) throw new Error('local agent environment payload must be an object of strings');
57
+ const agentEnvironment: NodeJS.ProcessEnv = {};
58
+ for (const [key, value] of Object.entries(parsed)) {
59
+ if (!AGENT_CREDENTIAL_NAMES.has(key) || typeof value !== 'string' || value.includes('\0')) {
60
+ throw new Error('local agent environment payload must be an object of valid environment strings');
61
+ }
62
+ if (key !== LOCAL_AGENT_ENV_FD) agentEnvironment[key] = value;
63
+ }
64
+ return { ...environment, ...agentEnvironment };
65
+ }
66
+
67
+ /** Reduce a composed provider environment to the credential overlay that is
68
+ * safe to serialize across the authored Node runtime boundary. */
69
+ export function localAgentCredentialEnvironment(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
70
+ return Object.fromEntries([...AGENT_CREDENTIAL_NAMES]
71
+ .flatMap(key => environment[key] === undefined ? [] : [[key, environment[key]!]]));
72
+ }
73
+
74
+ /** Validate the parent-to-authored-runtime handoff without touching a descriptor. */
75
+ export function validatedLocalAgentEnvironment(value: unknown): NodeJS.ProcessEnv | undefined {
76
+ if (value === undefined) return undefined;
77
+ if (!plainObject(value)) throw new Error('local agent environment payload must be an object of strings');
78
+ const environment: NodeJS.ProcessEnv = {};
79
+ for (const [key, entry] of Object.entries(value)) {
80
+ if (!AGENT_CREDENTIAL_NAMES.has(key) || typeof entry !== 'string' || entry.includes('\0')) {
81
+ throw new Error('local agent environment payload must be an object of valid environment strings');
82
+ }
83
+ environment[key] = entry;
84
+ }
85
+ return environment;
86
+ }
87
+
88
+ function plainObject(value: unknown): value is Record<string, unknown> {
89
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
90
+ const prototype = Object.getPrototypeOf(value) as unknown;
91
+ return prototype === Object.prototype || prototype === null;
92
+ }
@@ -15,6 +15,7 @@ export async function attachLocalAgent(
15
15
  capacity: number = DEFAULT_LOCAL_AGENT_CAPACITY,
16
16
  runRoot?: string,
17
17
  declaredStreams: readonly string[] = [],
18
+ environment?: NodeJS.ProcessEnv,
18
19
  ): Promise<{
19
20
  stream: string;
20
21
  readonly failure: unknown;
@@ -31,7 +32,7 @@ export async function attachLocalAgent(
31
32
  // second parking behind the first. Authored bodies size their admission to
32
33
  // this same number (worker-slots.ts), so they never ask for more.
33
34
  capacity,
34
- dataDir, onPtyReady, runRoot,
35
+ dataDir, onPtyReady, runRoot, environment,
35
36
  pins: { workspace: [], streams: [...new Set([stream, ...declaredStreams])]
36
37
  .map(stream => ({ stream, read_offset: 0 })) },
37
38
  });
package/src/protocol.ts CHANGED
@@ -79,6 +79,11 @@ export interface HelloParams {
79
79
  export interface HelloResult {
80
80
  protocol: 0;
81
81
  server: string;
82
+ /**
83
+ * Additive request capabilities within protocol 0. Absent from daemons that
84
+ * predate it, which then accept none of the optional fields it gates.
85
+ */
86
+ features?: string[];
82
87
  }
83
88
 
84
89
  export interface StepSpend {
@@ -97,6 +102,21 @@ export interface StepSpend {
97
102
  * `dollars_unmetered: true` and no dollar amount, so unknown cost is never
98
103
  * journaled as a measured $0. See `workerSpend` for the full contract.
99
104
  */
105
+ /**
106
+ * A step attempt's actual cost (`reported-cost.ts`), journaled beside the
107
+ * metered `usage` as `reported_cost`. Display only: the kernel never charges it
108
+ * against `maxDollars`.
109
+ */
110
+ export interface ReportedCost {
111
+ /** Non-negative decimal string, six places. */
112
+ dollars: string;
113
+ /**
114
+ * `cli`: the CLI's own reported total. `priced`: estimated from full token
115
+ * usage. `no_model`: an internal effect worker completed the step; $0.
116
+ */
117
+ source: 'cli' | 'priced' | 'no_model';
118
+ }
119
+
100
120
  export type StepUsage =
101
121
  | { tokens_in: number; tokens_out: number; dollars: string; dollars_unmetered?: never }
102
122
  | { tokens_in: number; tokens_out: number; dollars_unmetered: true; dollars?: never };
@@ -342,6 +362,8 @@ export interface StepCompleteParams {
342
362
  effects?: EffectRef[];
343
363
  /** `inspect` evidence for the next attempt. Rejected over 16 KiB of JSON. */
344
364
  trajectory_tail?: unknown;
365
+ /** Display-only actual cost; never charged to the budget. */
366
+ reported_cost?: ReportedCost;
345
367
  }
346
368
  export type StepCompleteResult = RunOutcome;
347
369
 
@@ -0,0 +1,82 @@
1
+ // The display-only actual-cost fold beside run-state's metered spend.
2
+ // Nothing here is enforcement: the kernel charges `budget`, never `reported_cost`.
3
+
4
+ import { declaresAuthoredRoot } from './authored-verdict.js';
5
+ import { addDollars } from './decimal-dollars.js';
6
+ import { HELPER_INSTRUCTION_PREFIX } from './helper-instruction.js';
7
+ import type { JournalEvent } from './journal-reader.js';
8
+
9
+ type Payload = Record<string, unknown>;
10
+
11
+ /**
12
+ * What steps actually cost, summed from each attempt's journaled
13
+ * `reported_cost` (the CLI's own total, or a full-usage estimate). Display
14
+ * only: the kernel charges `spend` against `maxDollars`, never this. `complete`
15
+ * is false when some completed model attempt reported no cost (e.g. Codex, or
16
+ * an older runtime), so `dollars` is then a lower bound. Segment rollover keeps
17
+ * earlier completions in the journal, so an epoch summary does not lose any.
18
+ */
19
+ export interface ReportedCostTotal {
20
+ /** Decimal string. */
21
+ dollars: string;
22
+ complete: boolean;
23
+ /** Where the summed figures came from; `mixed` when attempts differ. */
24
+ source: 'cli' | 'priced' | 'mixed' | null;
25
+ }
26
+
27
+ /**
28
+ * Steps known to run no model, read from the spawned spec. Deliberately
29
+ * narrow: a crash-recovered or cancelled model attempt journals a default zero
30
+ * budget although the model ran, so metering nothing proves nothing. A step
31
+ * not listed here that reports no cost stays unknown, and the total reads as a
32
+ * lower bound rather than a false complete figure.
33
+ * - a deterministic step runs a command;
34
+ * - a provider helper (`relayflows:helper:v1`) performs a relayfile write;
35
+ * - the authored root runs the flow body, whose model calls are child runs with
36
+ * journals of their own. Only a spec carrying the authored-root discriminator
37
+ * counts: an ordinary flow may name a step `authored-root`.
38
+ * A memoized reuse is free too, per completion (`reused_from`): the source run
39
+ * paid for the model.
40
+ */
41
+ export function modelFreeSteps(spawned: JournalEvent): Set<string> {
42
+ const payload = spawned.payload !== null && typeof spawned.payload === 'object' ? spawned.payload as Payload : {};
43
+ const spec = payload['spec'] !== null && typeof payload['spec'] === 'object' ? payload['spec'] as Payload : {};
44
+ const steps = Array.isArray(spec['steps']) ? spec['steps'] as unknown[] : [];
45
+ const authoredRoot = declaresAuthoredRoot(spawned);
46
+ const free = new Set<string>();
47
+ for (const entry of steps) {
48
+ const step = entry !== null && typeof entry === 'object' ? entry as Payload : {};
49
+ if (typeof step['id'] !== 'string') continue;
50
+ const helper = step['type'] === 'agent' && typeof step['instruction'] === 'string'
51
+ && step['instruction'].startsWith(HELPER_INSTRUCTION_PREFIX);
52
+ if (step['type'] === 'deterministic' || helper || (authoredRoot && step['id'] === 'authored-root')) free.add(step['id']);
53
+ }
54
+ return free;
55
+ }
56
+
57
+ export const NO_REPORTED_COST: ReportedCostTotal = { dollars: '0', complete: true, source: null };
58
+
59
+
60
+ /**
61
+ * Add one completed attempt's journaled `reported_cost` to a total. A model
62
+ * attempt without one (older runtime, Codex, unpriced) leaves the sum unchanged
63
+ * and marks it incomplete rather than counting an unknown cost as zero.
64
+ */
65
+ export function addReportedCost(total: ReportedCostTotal, completion: Payload, modelFree: boolean): ReportedCostTotal {
66
+ const charge = completion['reported_cost'];
67
+ const cost = charge !== null && typeof charge === 'object' && !Array.isArray(charge) ? charge as Payload : null;
68
+ const reused = completion['reused_from'] !== undefined && completion['reused_from'] !== null;
69
+ if (cost === null && (modelFree || reused)) return total;
70
+ const dollars = cost === null ? null : cost['dollars'];
71
+ const source = cost === null ? null : cost['source'];
72
+ // An internal effect worker ran no model: a known $0 that names no cost source.
73
+ if (source === 'no_model') return total;
74
+ if (typeof dollars !== 'string' || !/^\d+(?:\.\d+)?$/.test(dollars) || (source !== 'cli' && source !== 'priced')) {
75
+ return { ...total, complete: false };
76
+ }
77
+ return {
78
+ dollars: addDollars(total.dollars, dollars),
79
+ complete: total.complete,
80
+ source: total.source === null || total.source === source ? source : 'mixed',
81
+ };
82
+ }
@@ -0,0 +1,51 @@
1
+ import { MODEL_PRICING } from './model-pricing.js';
2
+ import type { ReportedCost } from './protocol.js';
3
+ import type { WorkerCliResult } from './worker-cli.js';
4
+
5
+ /**
6
+ * What a step attempt actually cost, for display. Never enforcement.
7
+ *
8
+ * The metered `usage` (`workerSpend`) is what the kernel charges against
9
+ * `maxDollars`, and it prices only plain input and output tokens. That
10
+ * undercounts cache-heavy agents badly. This is the real figure a reader wants
11
+ * to see, and it rides beside the metered charge as `reported_cost`: the kernel
12
+ * journals it and never folds it into the budget. Changing which charges count
13
+ * toward a cap is a separate decision, and this module takes no part in it.
14
+ *
15
+ * Sources, in order:
16
+ * - `cli`: the CLI's own reported total (Claude `total_cost_usd`);
17
+ * - `priced`: an estimate from the digest's full token usage at the frozen
18
+ * `MODEL_PRICING` rates, with cache writes at 1.25x and cache reads at 0.1x
19
+ * the input rate (Codex's input total already includes its cached tokens, so
20
+ * they are split out, not added). Only for a model that already has a price.
21
+ * Otherwise undefined: an unknown cost is left unknown, never shown as $0.
22
+ */
23
+ export function reportedCost(result: WorkerCliResult, model: string | undefined): ReportedCost | undefined {
24
+ const digest = result.transcript?.result;
25
+ const cli = digest?.total_cost_usd;
26
+ if (typeof cli === 'number' && Number.isFinite(cli) && cli >= 0) {
27
+ return { dollars: microDollars(BigInt(Math.round(cli * 1_000_000))), source: 'cli' };
28
+ }
29
+ const usage = digest?.usage;
30
+ const priceModel = digest?.model ?? model;
31
+ if (usage === undefined || priceModel === undefined || !Object.hasOwn(MODEL_PRICING, priceModel)) return undefined;
32
+ const counts = [usage.input, usage.output, usage.cache_read, usage.cache_creation].map(n => n ?? 0);
33
+ if (!counts.every(n => Number.isSafeInteger(n) && n >= 0)) return undefined;
34
+ const [reportedInput, output, cacheRead, cacheCreation] = counts.map(BigInt) as [bigint, bigint, bigint, bigint];
35
+ // Claude reports cache tokens beside `input_tokens`; Codex's `input_tokens`
36
+ // already includes its `cached_input_tokens`. Charge each token once.
37
+ const input = digest?.provider === 'codex'
38
+ ? (reportedInput > cacheRead ? reportedInput - cacheRead : 0n) : reportedInput;
39
+ const price = MODEL_PRICING[priceModel]!;
40
+ const inRate = BigInt(price.input);
41
+ const micro = input * inRate + output * BigInt(price.output)
42
+ + (cacheCreation * inRate * 125n) / 100n + (cacheRead * inRate * 10n) / 100n;
43
+ return { dollars: microDollars(micro), source: 'priced' };
44
+ }
45
+
46
+ /** What an internal effect worker (provider helper, plugin, MCP) reports: no model ran. */
47
+ export const NO_MODEL_COST: ReportedCost = Object.freeze({ dollars: '0.000000', source: 'no_model' });
48
+
49
+ function microDollars(micro: bigint): string {
50
+ return `${micro / 1_000_000n}.${String(micro % 1_000_000n).padStart(6, '0')}`;
51
+ }
package/src/run-state.ts CHANGED
@@ -10,8 +10,13 @@
10
10
  // No I/O, no clock: the caller passes `now_ms`, so a test folds a hand-built
11
11
  // journal against a fixed instant and asserts exact elapsed and overdue values.
12
12
 
13
+ import { addDollars } from './decimal-dollars.js';
14
+ import { addReportedCost, modelFreeSteps, NO_REPORTED_COST, type ReportedCostTotal } from './reported-cost-total.js';
13
15
  import type { JournalEvent } from './journal-reader.js';
14
16
 
17
+ export { addDollars } from './decimal-dollars.js';
18
+ export type { ReportedCostTotal } from './reported-cost-total.js';
19
+
15
20
  export type RunStatus = 'running' | 'parked' | 'completed' | 'failed' | 'cancelling' | 'cancelled';
16
21
 
17
22
  /** `StepState` variant names from state.rs, snake_case. */
@@ -25,6 +30,7 @@ export interface Spend {
25
30
  dollars_unmetered: boolean;
26
31
  }
27
32
 
33
+
28
34
  export interface StepCounts {
29
35
  total: number;
30
36
  done: number;
@@ -91,6 +97,10 @@ export interface StepView {
91
97
  completion_reason: string | null;
92
98
  /** Paths the worker journaled for the last attempt, when its output carried them. */
93
99
  artifacts: { paths: string[]; journaled: boolean };
100
+ /** Metered charges of this step's attempts: what counted toward the budget. */
101
+ spend: Spend;
102
+ /** Actual cost of this step's attempts. Null until an attempt completes. */
103
+ reported_cost: ReportedCostTotal | null;
94
104
  }
95
105
 
96
106
  export interface RunView {
@@ -101,6 +111,8 @@ export interface RunView {
101
111
  spawned_at_ms: number;
102
112
  now_ms: number;
103
113
  spend: Spend;
114
+ /** Actual cost of this run's own attempts; see {@link ReportedCostTotal}. */
115
+ reported_cost: ReportedCostTotal;
104
116
  counts: StepCounts;
105
117
  steps: StepView[];
106
118
  }
@@ -127,42 +139,6 @@ function text(value: unknown, fallback = ''): string {
127
139
  return typeof value === 'string' ? value : fallback;
128
140
  }
129
141
 
130
- /**
131
- * Sum two decimal dollar strings exactly, at whatever scale they carry — the
132
- * kernel's budget fold (`relayflowd-core/src/state/budget.rs`) aligns every
133
- * fractional digit before adding, and it accepts arbitrary precision. Scaling
134
- * to a fixed six fractional digits dropped a charge of `0.0000001` to zero, so
135
- * `flows status` underreported spend the kernel had totalled exactly.
136
- *
137
- * A value that is not a decimal number is left out of the sum rather than
138
- * counted as zero, and the caller is told by `malformed`.
139
- */
140
- export function addDollars(left: string, right: string): string {
141
- const parsed = [left, right].map((value) => /^\d+(?:\.\d+)?$/.test(value) ? value : null);
142
- const usable = parsed.filter((value): value is string => value !== null);
143
- if (usable.length === 0) return '0';
144
- if (usable.length === 1) return normalizeDollars(usable[0]!);
145
- const scale = Math.max(...usable.map((value) => (value.split('.')[1] ?? '').length));
146
- const scaled = usable.map((value) => {
147
- const [whole, fraction = ''] = value.split('.');
148
- return BigInt(`${whole}${fraction.padEnd(scale, '0')}`);
149
- });
150
- return fromScaled(scaled[0]! + scaled[1]!, scale);
151
- }
152
-
153
- /** `12.3400` -> `12.34`, `0.000` -> `0`; the journal's own spelling otherwise. */
154
- function normalizeDollars(value: string): string {
155
- const [whole, fraction = ''] = value.split('.');
156
- return fromScaled(BigInt(`${whole}${fraction}`), fraction.length);
157
- }
158
-
159
- function fromScaled(total: bigint, scale: number): string {
160
- if (scale === 0) return String(total);
161
- const digits = String(total).padStart(scale + 1, '0');
162
- const whole = digits.slice(0, digits.length - scale);
163
- const fraction = digits.slice(digits.length - scale).replace(/0+$/, '');
164
- return fraction.length === 0 ? whole : `${whole}.${fraction}`;
165
- }
166
142
 
167
143
  /**
168
144
  * Reduce `trajectory_tail.transcript` (the digest flows#491 journals) to the
@@ -209,6 +185,7 @@ function addSpend(total: Spend, charge: unknown): Spend {
209
185
 
210
186
  const ZERO_SPEND: Spend = { tokens_in: 0, tokens_out: 0, dollars: '0', dollars_unmetered: false };
211
187
 
188
+
212
189
  interface StepFold {
213
190
  view: StepView;
214
191
  depends_on: string[];
@@ -221,6 +198,7 @@ function freshStep(id: string, type: string, maxIterations: number | null, depen
221
198
  id, type, state: 'pending', attempt: 0, max_iterations: maxIterations,
222
199
  started_at_ms: null, elapsed_ms: null, lease: null, backoff_until_ms: null, wait: null,
223
200
  last_attempt: null, completion_reason: null, artifacts: { paths: [], journaled: false },
201
+ spend: ZERO_SPEND, reported_cost: null,
224
202
  },
225
203
  };
226
204
  }
@@ -268,6 +246,8 @@ export function foldRunState(events: readonly JournalEvent[], now_ms: number): R
268
246
  // An authored child run carries its parent's totals so ceilings hold across
269
247
  // the family (state.rs `prior_spend`); the view reports the same running total.
270
248
  let spend = addSpend(ZERO_SPEND, budget['prior_spend']);
249
+ let reportedCost = NO_REPORTED_COST;
250
+ const modelFree = modelFreeSteps(first);
271
251
  let completion: string | null = null;
272
252
  let cancelRequested = false;
273
253
 
@@ -297,6 +277,9 @@ export function foldRunState(events: readonly JournalEvent[], now_ms: number): R
297
277
  const attempt = event.attempt ?? view.attempt;
298
278
  view.attempt = Math.max(view.attempt, attempt);
299
279
  spend = addSpend(spend, payload['budget']);
280
+ view.spend = addSpend(view.spend, payload['budget']);
281
+ reportedCost = addReportedCost(reportedCost, payload, modelFree.has(view.id));
282
+ view.reported_cost = addReportedCost(view.reported_cost ?? NO_REPORTED_COST, payload, modelFree.has(view.id));
300
283
  const verification = payload['verification'] !== null && typeof payload['verification'] === 'object'
301
284
  ? payload['verification'] as Payload : undefined;
302
285
  view.last_attempt = {
@@ -362,8 +345,12 @@ export function foldRunState(events: readonly JournalEvent[], now_ms: number): R
362
345
  // A new epoch restarts every step (state.rs `apply_epoch`); the
363
346
  // summary then restores the ones it carries forward.
364
347
  for (const step of steps.values()) {
365
- const { id, type, max_iterations } = step.view;
348
+ const { id, type, max_iterations, spend: stepSpend, reported_cost: stepCost } = step.view;
366
349
  step.view = freshStep(id, type, max_iterations, step.depends_on).view;
350
+ // Rollover keeps the earlier segments' completions in the journal,
351
+ // and this fold has already read them: what the step cost stays.
352
+ step.view.spend = stepSpend;
353
+ step.view.reported_cost = stepCost;
367
354
  }
368
355
  spend = addSpend(ZERO_SPEND, payload['budget_spent']);
369
356
  const done = payload['steps_done'] !== null && typeof payload['steps_done'] === 'object' ? payload['steps_done'] as Payload : {};
@@ -462,6 +449,7 @@ export function foldRunState(events: readonly JournalEvent[], now_ms: number): R
462
449
  spawned_at_ms: first.at_ms,
463
450
  now_ms,
464
451
  spend,
452
+ reported_cost: reportedCost,
465
453
  counts,
466
454
  steps: views,
467
455
  };
package/src/worker-cli.ts CHANGED
@@ -91,6 +91,7 @@ export async function runAgentCli(
91
91
  cwd?: string,
92
92
  transport: AgentTransport = 'direct',
93
93
  relayContext?: AgentRelayContext,
94
+ processEnvironment: NodeJS.ProcessEnv = process.env,
94
95
  ): Promise<WorkerCliResult> {
95
96
  signal?.throwIfAborted();
96
97
  if (signal !== undefined && process.platform === 'win32') {
@@ -134,7 +135,7 @@ export async function runAgentCli(
134
135
  if (kind === 'relayflows-wrapper-v1') {
135
136
  // The closed allowlist admits no ambient RELAYFLOW_* value; the four
136
137
  // discovery names are set from this dispatch, exactly as for a direct spawn.
137
- const wrapperEnv = wrapperEnvironment(process.env);
138
+ const wrapperEnv = wrapperEnvironment(processEnvironment);
138
139
  applyStepEnvironment(wrapperEnv, sidechannel);
139
140
  return requirePricedUsage(decodeWrapperResult(await runWrapperSession(
140
141
  cli,
@@ -148,7 +149,7 @@ export async function runAgentCli(
148
149
  )), effectiveModel);
149
150
  }
150
151
 
151
- const env: NodeJS.ProcessEnv = { ...process.env };
152
+ const env: NodeJS.ProcessEnv = { ...processEnvironment };
152
153
  delete env[WAKE_CONTEXT_ENV];
153
154
  delete env[MODEL_ENV];
154
155
  // Where this attempt's journal is, so the agent can run `flows status` on
package/src/worker.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { communicationInstruction } from './communication/spec.js';
2
2
  import { workerSpend } from './worker-spend.js';
3
+ import { reportedCost } from './reported-cost.js';
3
4
  import type { WorkerCliResult } from './worker-cli.js';
4
5
  import { EventEmitter } from 'node:events';
5
6
  import type { JournalClient } from './journal-client.js';
@@ -31,6 +32,8 @@ export interface AgentWorkerOptions {
31
32
  * the root a dispatch is measured against is a value, not an assumption.
32
33
  */
33
34
  runRoot?: string;
35
+ /** Environment inherited by provider CLIs, including agent-only secrets. */
36
+ environment?: NodeJS.ProcessEnv;
34
37
  }
35
38
 
36
39
  /**
@@ -122,7 +125,8 @@ export class AgentWorker extends EventEmitter {
122
125
  if (communication) {
123
126
  if (!this.options.dataDir) throw new Error('Agent communication requires a worker data directory');
124
127
  const { completeCommunicationDispatch } = await import('./communication/worker.js');
125
- await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir, this.options.runRoot);
128
+ await completeCommunicationDispatch(this.client, dispatch, communication, this.options.dataDir,
129
+ this.options.runRoot, this.options.environment);
126
130
  return;
127
131
  }
128
132
  let humanIntervention = false;
@@ -142,9 +146,11 @@ export class AgentWorker extends EventEmitter {
142
146
  }, cwd.directory ?? (spec.transport === 'relay' ? undefined : this.options.runRoot),
143
147
  spec.transport === 'relay' ? 'relay' : 'direct',
144
148
  { runId: dispatch.run_id, stepId: dispatch.step_id, idempotencyKey: dispatch.idempotency_key,
145
- dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema })
149
+ dataDir: this.options.dataDir, resultSchema: spec.verification?.json_schema },
150
+ this.options.environment)
146
151
  : Promise.resolve({ exit_code: null, stdout_tail: '', stderr_tail: 'agent step has no declared CLI' }));
147
152
  const { result, usage } = workerSpend(completed, effectiveModel);
153
+ const cost = reportedCost(completed, effectiveModel);
148
154
  const completionReason = result.exit_code === 0 ? 'success' : 'worker_error';
149
155
 
150
156
  // Output shape: if the CLI's stdout parses as JSON, promote THAT
@@ -195,6 +201,7 @@ export class AgentWorker extends EventEmitter {
195
201
  ...(Object.keys(trajectoryTail).length === 0 ? {} : { trajectory_tail: trajectoryTail }),
196
202
  ...(humanIntervention ? { human_intervention: true } : {}),
197
203
  ...(usage !== undefined ? { usage } : {}),
204
+ ...(cost === undefined ? {} : { reported_cost: cost }),
198
205
  started_pins: dispatch.pins,
199
206
  end_pins: dispatch.pins,
200
207
  },