@mono-agent/agent-runtime 0.16.0 → 0.17.1

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.
package/MIGRATION.md CHANGED
@@ -63,10 +63,23 @@ the configuration schema.
63
63
  authoritative and the runtime emits a bounded
64
64
  `live_input_callback_failed` warning.
65
65
 
66
- ## 0.16.x baseline
66
+ ## 0.17.x baseline
67
67
 
68
- This is the current published baseline for the detailed pre-1.0 reference
69
- below. It carries the whole 0.15.x contract forward and adds:
68
+ This is the current published baseline. It carries the 0.16.x contract forward
69
+ and adds a host-only, request-scoped `toolEnvironment` boundary. Hosts may pass
70
+ validated values and PATH prefixes through the request, harness, and runtime;
71
+ the runtime applies them only when Bash, Exec, or a nested subagent process is
72
+ spawned. It does not mutate `process.env` or persist the values in prompts,
73
+ metadata, history, traces, or long-lived tool context.
74
+
75
+ - **0.17.1 Pi auth classification:** Pi 0.83 may report a missing credential as
76
+ `Provider is not configured: <provider>`. The runtime now classifies that
77
+ exact signature as `provider_auth`, allowing a configured fallback route to
78
+ run while avoiding a same-route retry that cannot repair absent credentials.
79
+
80
+ ## 0.16.x
81
+
82
+ This baseline carries the whole 0.15.x contract forward and adds:
70
83
 
71
84
  - `skills` and `skillsRoot` on the run options. `skills` is the disclosed
72
85
  `{name, description}` set for a run; a non-empty value makes `supports_skills`
@@ -394,7 +407,7 @@ a compatibility subpath.
394
407
 
395
408
  ## Version
396
409
 
397
- This guide describes the published `0.16.x` package contract. Keep
410
+ This guide describes the published `0.17.x` package contract. Keep
398
411
  `@mono-agent/agent-runtime`, `@mono-agent/runtime-adapter`, and other
399
412
  `@mono-agent/*` packages on the same lockstep version when upgrading. The paired
400
413
  runtime adapter no longer exposes `piReasoningSummary` in its run-options type.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mono-agent/agent-runtime",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "description": "Agent runtime supporting Claude SDK/CLI, Codex, OpenCode, and Pi SDK bridges out of the box",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-only",
@@ -110,8 +110,21 @@ function toolDescription(subagents, definitions, ceiling) {
110
110
  return `${DESCRIPTION_BASE}${parallel}${named}${shapes}${inline}`;
111
111
  }
112
112
 
113
+ /** @param {*} value @returns {number} */
114
+ function numberOrZero(value) {
115
+ const numeric = Number(value);
116
+ return Number.isFinite(numeric) ? numeric : 0;
117
+ }
118
+
119
+ /** @returns {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}} */
120
+ function emptyUsage() {
121
+ return { costUsd: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
122
+ }
123
+
113
124
  /**
114
- * Per-logical-run call and byte budget, shared across router attempts.
125
+ * Per-logical-run call, byte and subagent-usage budget, shared across router
126
+ * attempts. `usage` rides the same entry so delegated spend inherits the
127
+ * existing per-run keying and eviction instead of needing a second store.
115
128
  * @param {*} subagents The run-scoped options object, stable across attempts.
116
129
  * @param {string|undefined} parentRunId
117
130
  */
@@ -129,7 +142,7 @@ function budgetForRun(subagents, parentRunId) {
129
142
  if (oldest.done) break;
130
143
  store.delete(oldest.value);
131
144
  }
132
- const fresh = { total: 0, bytes: 0, warnedQueued: false };
145
+ const fresh = { total: 0, bytes: 0, warnedQueued: false, usage: emptyUsage() };
133
146
  store.set(key, fresh);
134
147
  return fresh;
135
148
  }
@@ -155,6 +168,32 @@ export function subagentInvocationCount(subagents, parentRunId) {
155
168
  return Number.isInteger(entry?.total) ? entry.total : 0;
156
169
  }
157
170
 
171
+ /**
172
+ * What this logical run's subagents spent, summed across every delegation.
173
+ *
174
+ * A delegation is work the run asked for, so its cost belongs to the run's
175
+ * total — a provider folds this into its own usage before reporting, which is
176
+ * what makes the console's cost, the TUI status bar and the exported metrics
177
+ * agree with the bill. Same read-only-accessor contract as
178
+ * `subagentInvocationCount`: `__budgets` stays private. All zeroes when nothing
179
+ * delegated, which is the truthful answer for a run that never used the tool.
180
+ *
181
+ * @param {*} subagents The run-scoped options object, or undefined.
182
+ * @param {string|undefined} parentRunId
183
+ * @returns {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}}
184
+ */
185
+ export function subagentUsageForRun(subagents, parentRunId) {
186
+ const store = subagents?.__budgets;
187
+ const usage = store instanceof Map ? store.get(parentRunId ?? "unkeyed")?.usage : undefined;
188
+ return {
189
+ costUsd: numberOrZero(usage?.costUsd),
190
+ input: numberOrZero(usage?.input),
191
+ output: numberOrZero(usage?.output),
192
+ cacheRead: numberOrZero(usage?.cacheRead),
193
+ cacheWrite: numberOrZero(usage?.cacheWrite),
194
+ };
195
+ }
196
+
158
197
  /** @param {*} value @param {number} fallback @returns {number} */
159
198
  function positiveInt(value, fallback) {
160
199
  return Number.isInteger(value) && value > 0 ? value : fallback;
@@ -164,7 +203,7 @@ function positiveInt(value, fallback) {
164
203
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
165
204
  *
166
205
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
167
- * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, onEvent?: (event: *) => void}} [context]
206
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, toolEnvironment?: *, onEvent?: (event: *) => void}} [context]
168
207
  * @returns {*|null}
169
208
  */
170
209
  export function createAgentTool(subagents, context = {}) {
@@ -336,6 +375,15 @@ export function createAgentTool(subagents, context = {}) {
336
375
  callIndex,
337
376
  ...(params.description === undefined ? {} : { label: params.description }),
338
377
  ...(context.onEvent === undefined ? {} : { emit: context.onEvent }),
378
+ // Summed, not replaced: a turn can delegate a dozen times and the run
379
+ // owns all of it. Lives on the run budget so router attempts share it.
380
+ recordUsage: (spent) => {
381
+ budget.usage.costUsd += spent.costUsd;
382
+ budget.usage.input += spent.input;
383
+ budget.usage.output += spent.output;
384
+ budget.usage.cacheRead += spent.cacheRead;
385
+ budget.usage.cacheWrite += spent.cacheWrite;
386
+ },
339
387
  });
340
388
  collector.started();
341
389
  const startedAt = Date.now();
@@ -361,6 +409,7 @@ export function createAgentTool(subagents, context = {}) {
361
409
  // the child's resolved route and deny lists.
362
410
  ...(context.skills === undefined ? {} : { skills: context.skills }),
363
411
  ...(context.skillsRoot === undefined ? {} : { skillsRoot: context.skillsRoot }),
412
+ ...(context.toolEnvironment === undefined ? {} : { toolEnvironment: context.toolEnvironment }),
364
413
  abortSignal: controller.signal,
365
414
  maxTurns,
366
415
  callId: toolCallId,
@@ -553,13 +602,15 @@ const WIRE_CONTENT_MAX_CHARS = 2_000;
553
602
  * answer body, so forwarding them would splice a subagent's prose into the
554
603
  * main agent's reply. Its text reaches the parent through the tool result.
555
604
  *
556
- * @param {{callId: string, profileName: string, callIndex: number, label?: string, emit?: (event: *) => void}} options
605
+ * @param {{callId: string, profileName: string, callIndex: number, label?: string, emit?: (event: *) => void, recordUsage?: (usage: {costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}) => void}} options
557
606
  */
558
- function createActivityCollector({ callId, profileName, callIndex, label, emit }) {
607
+ function createActivityCollector({ callId, profileName, callIndex, label, emit, recordUsage }) {
559
608
  /** @type {Map<string, {name: string, args: unknown, startedAt: number, ms?: number}>} */
560
609
  const open = new Map();
561
610
  /** @type {Array<{name: string, args: unknown, ms?: number, isError: boolean}>} */
562
611
  const done = [];
612
+ /** What the child reported spending, so the parent run can own it. */
613
+ const usage = emptyUsage();
563
614
  const subagent = { id: callId, name: profileName, callIndex, ...(label === undefined ? {} : { label }) };
564
615
 
565
616
  /** @param {*} event */
@@ -585,10 +636,17 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit }
585
636
  },
586
637
  /** @param {{status: string, durationMs: number}} outcome */
587
638
  finished({ status, durationMs }) {
639
+ // Before the bookend, so the run's own usage report can already include
640
+ // it, and so an abandoned child still hands over whatever it spent.
641
+ recordUsage?.(usage);
588
642
  publish({
589
643
  phase: "agent_completed",
590
644
  id: `agent:${callId}`,
591
645
  name: `Agent(${profileName})`,
646
+ // The one place the child's price is knowable per delegation: operator
647
+ // surfaces show it on the row so an expensive one is identifiable, not
648
+ // just visible in the run total it disappears into.
649
+ ...(usage.costUsd > 0 ? { subagent: { ...subagent, costUsd: usage.costUsd } } : {}),
592
650
  isError: status !== "ok",
593
651
  executionMs: durationMs,
594
652
  content: `${status} · ${done.length} tool call${done.length === 1 ? "" : "s"}`,
@@ -653,8 +711,25 @@ function createActivityCollector({ callId, profileName, callIndex, label, emit }
653
711
  }
654
712
  return;
655
713
  }
714
+ if (type === "cost_accumulated") {
715
+ // Read as a running total, not a delta — the same rule
716
+ // `ai/observer.js` applies to the parent's own events, because a bridge
717
+ // emits one of these per completed provider run carrying that run's
718
+ // totals. Keeping one rule means a child that failed over undercounts
719
+ // exactly as its parent does today rather than inventing a second.
720
+ // Not republished on the parent stream: consumers treat `usage_update`
721
+ // as the run's cumulative figure, and a child's smaller total arriving
722
+ // last would read as the run getting cheaper.
723
+ const tokens = event.tokens && typeof event.tokens === "object" ? event.tokens : {};
724
+ usage.costUsd = numberOrZero(event.cumulativeUsd);
725
+ usage.input = numberOrZero(tokens.input);
726
+ usage.output = numberOrZero(tokens.output);
727
+ usage.cacheRead = numberOrZero(tokens.cacheReadTokens);
728
+ usage.cacheWrite = numberOrZero(tokens.cacheCreationTokens);
729
+ return;
730
+ }
656
731
  // Child warnings are worth surfacing; everything else (context usage,
657
- // partial tool output, cost) stays inside the subagent for now.
732
+ // partial tool output) stays inside the subagent for now.
658
733
  if (type === "runtime_warning" && emit !== undefined) {
659
734
  try {
660
735
  emit({ ...event, subagentId: callId });
@@ -15,7 +15,7 @@ import {
15
15
  runPreparedProcess,
16
16
  } from "./shared/process-runner.js";
17
17
  import { readToolRuntime } from "./shared/runtime-context.js";
18
- import { resolveSandboxPolicy } from "./shared/tool-context.js";
18
+ import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
19
19
 
20
20
  const DEFAULT_BASH_TIMEOUT_MS = 120_000;
21
21
  const BASH_STARTUP_ENV_KEYS = new Set([
@@ -120,7 +120,7 @@ export async function bashToolRun(
120
120
  command: "/bin/bash",
121
121
  args: ["--noprofile", "--norc", "-c", command],
122
122
  cwd,
123
- env: cleanBashEnvironment(),
123
+ env: requestToolProcessEnvironment(resolvedCtx, cleanBashEnvironment()),
124
124
  },
125
125
  });
126
126
  } catch (error) {
@@ -16,7 +16,7 @@ import {
16
16
  runPreparedProcess,
17
17
  } from "./shared/process-runner.js";
18
18
  import { readToolRuntime } from "./shared/runtime-context.js";
19
- import { resolveSandboxPolicy } from "./shared/tool-context.js";
19
+ import { requestToolProcessEnvironment, resolveSandboxPolicy } from "./shared/tool-context.js";
20
20
 
21
21
  const DEFAULT_EXEC_TIMEOUT_MS = 120_000;
22
22
  const MAX_EXEC_ARGS = 256;
@@ -75,6 +75,7 @@ export async function execToolRun(
75
75
  const maxChars = positiveInteger(max_output_chars, DEFAULT_MAX_BASH_OUTPUT_CHARS);
76
76
  let prepared;
77
77
  try {
78
+ const requestEnvironment = requestToolProcessEnvironment(resolvedCtx);
78
79
  prepared = await sandbox.prepareCommand({
79
80
  policy,
80
81
  engine: sandboxEngine ?? resolvedCtx.sandboxEngine ?? undefined,
@@ -82,6 +83,7 @@ export async function execToolRun(
82
83
  command: executable,
83
84
  args: [...args],
84
85
  cwd,
86
+ ...(requestEnvironment === undefined ? {} : { env: requestEnvironment }),
85
87
  },
86
88
  });
87
89
  } catch (error) {
@@ -38,6 +38,8 @@
38
38
 
39
39
  // @ts-check
40
40
 
41
+ import { delimiter } from "node:path";
42
+
41
43
  import { passthroughSandbox } from "../../sandbox-seam.js";
42
44
  import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-brand.js";
43
45
 
@@ -58,6 +60,7 @@ import { DEFAULT_RUNTIME_BRAND, resolveRuntimeBrand } from "../../../runtime-bra
58
60
  * @property {RuntimeSandboxEngine} [sandboxEngine]
59
61
  * @property {RuntimeSandbox} sandbox
60
62
  * @property {RuntimeBrand} runtimeBrand
63
+ * @property {{schema: 1, values: Readonly<Record<string, string>>, pathPrepend?: readonly string[]}} [toolEnvironment]
61
64
  */
62
65
 
63
66
  // The data keys (everything except the always-resolved runtimeBrand). A fixed
@@ -155,3 +158,22 @@ export function resolveSandboxPolicy(ctx, requestPolicy = undefined) {
155
158
  const merged = sandbox.mergePolicies(ctx?.sandboxPolicy ?? undefined, requestPolicy ?? undefined);
156
159
  return merged && merged.mode !== "off" ? merged : undefined;
157
160
  }
161
+
162
+ /**
163
+ * Compose the process environment overlay for Bash/Exec. The caller-supplied
164
+ * base carries each tool's own hardening values; request values are applied
165
+ * only at this final process boundary. No global environment is mutated.
166
+ *
167
+ * @param {ToolContext|undefined} ctx
168
+ * @param {Record<string, string|undefined>} [base]
169
+ * @returns {Record<string, string|undefined>|undefined}
170
+ */
171
+ export function requestToolProcessEnvironment(ctx, base = undefined) {
172
+ const request = ctx?.toolEnvironment;
173
+ if (request === undefined) return base;
174
+ const env = { ...(base ?? {}), ...request.values };
175
+ if (Array.isArray(request.pathPrepend) && request.pathPrepend.length > 0) {
176
+ env.PATH = [...request.pathPrepend, process.env.PATH].filter(Boolean).join(delimiter);
177
+ }
178
+ return env;
179
+ }
package/src/ai/failure.js CHANGED
@@ -72,7 +72,11 @@ export const FAILURE_KINDS = [
72
72
 
73
73
  const CONTEXT_LIMIT_RE = /(?:context[_ ](?:length|window|budget)|token[_ ]limit|(?:input|prompt)(?:[_ ]tokens?)?[_ ](?:is[_ ])?too[_ ]long|(?:input|prompt|request)(?:[_ ]tokens?)?[_ ]exceeds?[_ ](?:the[_ ])?(?:context|maximum|max|limit|allowed[_ ]size)|too[_ ]many[_ ](?:input[_ ])?tokens?|tokens?[_ ]exceed(?:s|ed)?[_ ](?:the[_ ])?(?:context|maximum|max|(?:model[_ ])?limit))/i;
74
74
  const USAGE_LIMIT_RE = /(rate limit|usage limit|max(?:imum)?(?:[_ ]output)?[_ ]tokens?|max turns)/i;
75
- const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid api key|incorrect api key|authentication|authorization|not authorized|forbidden|oauth (?:refresh|auth|authentication|token).*failed|credential store (?:read|modify) failed|401|403)/i;
75
+ // Pi's Models layer emits the exact `Provider is not configured: <id>` message
76
+ // only after it has found the provider but cannot resolve that provider's auth.
77
+ // Treating it as availability left credential fallbacks pinned to the dead
78
+ // route because the router only advances on provider_auth or retryable outages.
79
+ const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid api key|incorrect api key|provider is not configured:|authentication|authorization|not authorized|forbidden|oauth (?:refresh|auth|authentication|token).*failed|credential store (?:read|modify) failed|401|403)/i;
76
80
  // Mirrors the conservative connection-error/refused/failed alternation added to
77
81
  // RETRYABLE_PROVIDER_RE / retryableProviderSubkind below for pi 0.80's terse
78
82
  // "Connection error." — without it, classifyFailure (used directly by hosts
@@ -81,7 +85,7 @@ const PROVIDER_AUTH_RE = /(no api key|missing api key|api key required|invalid a
81
85
  // "provider_unavailable".
82
86
  const PROVIDER_UNAVAILABLE_RE = /(econn|enotfound|etimedout|timed? ?out|service unavailable|503|502|gateway|fetch failed|network|websocket|\bconnection (?:error|refused|failed)\b|\bcould not connect\b)/i;
83
87
  const TOOL_FAILURE_RE = /(tool .* failed|mcp tool|permission denied|EACCES|read-only file system)/i;
84
- const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api key|missing api key|api key required|invalid api key|incorrect api key|authentication|authorization|not authorized|forbidden|billing|insufficient[_ ]quota|quota exceeded|model[_ ]not[_ ]found|unsupported model|permission denied|bad request|401|403|404)/i;
88
+ const NON_RETRYABLE_PROVIDER_RE = /(invalid[_ ]request|unknown parameter|no api key|missing api key|api key required|invalid api key|incorrect api key|provider is not configured:|authentication|authorization|not authorized|forbidden|billing|insufficient[_ ]quota|quota exceeded|model[_ ]not[_ ]found|unsupported model|permission denied|bad request|401|403|404)/i;
85
89
  // pi 0.80's openai-client-style bridge collapses a connection-refused/unreachable
86
90
  // provider down to a terse "Connection error." with no cause text (no ECONNREFUSED,
87
91
  // no fetch failed) — the `\bconnection (?:error|refused|failed)\b|\bcould not connect\b`
@@ -32,6 +32,38 @@ export function usageFromMessages(messages = []) {
32
32
  return usage;
33
33
  }
34
34
 
35
+ /**
36
+ * Add what this run's subagents spent to the run's own usage.
37
+ *
38
+ * A delegation is work the run asked for and is billed to the same account, but
39
+ * `usageFromMessages` only ever sees this agent's transcript — a subagent keeps
40
+ * its own — so every consumer of the run's usage was short by the whole cost of
41
+ * every subagent. Folding it in here rather than publishing a separate event
42
+ * makes the console's cost, the TUI status bar and the exported metrics correct
43
+ * with no change at any of them. The trade is attribution: a subagent on
44
+ * another model has its spend reported under this run's model, which is the
45
+ * right answer for a run total and the wrong one for a per-model breakdown.
46
+ *
47
+ * `estimatedCost` is this agent's own fallback price, used when the provider
48
+ * priced nothing (subscription auth). Adding to the raw `cost` alone in that
49
+ * case would have replaced this agent's cost with the subagent's rather than
50
+ * summing them.
51
+ *
52
+ * @param {{input: number, output: number, cacheRead: number, cacheWrite: number, cost: number}} usage
53
+ * @param {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}} delegated
54
+ * @param {number} estimatedCost
55
+ * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number, cost: number}}
56
+ */
57
+ export function withSubagentUsage(usage, delegated, estimatedCost) {
58
+ return {
59
+ input: usage.input + delegated.input,
60
+ output: usage.output + delegated.output,
61
+ cacheRead: usage.cacheRead + delegated.cacheRead,
62
+ cacheWrite: usage.cacheWrite + delegated.cacheWrite,
63
+ cost: delegated.costUsd > 0 ? (usage.cost || estimatedCost) + delegated.costUsd : usage.cost,
64
+ };
65
+ }
66
+
35
67
  /**
36
68
  * Normalize one provider request's usage into an exact context snapshot.
37
69
  * Unlike usageFromMessages(), this deliberately does not aggregate earlier
@@ -141,6 +141,7 @@ export async function buildTurnTools(runState, {
141
141
  // them is the host's decision, not this layer's.
142
142
  skills: options.skills,
143
143
  skillsRoot: options.skillsRoot,
144
+ toolEnvironment: options.toolEnvironment,
144
145
  },
145
146
  ctx: runCtx,
146
147
  }));
@@ -31,7 +31,7 @@ import {
31
31
  resolveAgentCompactionPolicy,
32
32
  resolveRuntimePolicyInputs,
33
33
  } from "../../agent/compaction.js";
34
- import { subagentInvocationCount } from "../../agent/tools/agent-tool.js";
34
+ import { subagentInvocationCount, subagentUsageForRun } from "../../agent/tools/agent-tool.js";
35
35
  import { closePiMcpClients } from "../../agent/tools/pi-bridge.js";
36
36
  import { readToolRuntime } from "../../agent/tools/shared/runtime-context.js";
37
37
  import { createApprovalManager } from "../../agent/approval.js";
@@ -57,6 +57,7 @@ import {
57
57
  emitCapabilitiesResolved,
58
58
  emitUsageCostEvents,
59
59
  usageFromMessages,
60
+ withSubagentUsage,
60
61
  } from "./pi-native/result-builder.js";
61
62
  import {
62
63
  cleanupSessionOnThrow,
@@ -646,15 +647,24 @@ export async function generatePiNativeResponse(systemPrompt, options = {}) {
646
647
  const { runTranscript, lastAssistant, stopReason, finalText, finalThinking } = state;
647
648
  const runAssistantCount = state.assistantMessages.length;
648
649
 
649
- const usage = usageFromMessages(runTranscript);
650
+ const ownUsage = usageFromMessages(runTranscript);
651
+ // Priced from this agent's own tokens: it is the fallback for a run the
652
+ // provider did not price, and only these tokens are this model's.
650
653
  const estimatedCost = estimateCost({
651
654
  resolveCustomPricing: options.resolveCustomPricing,
652
655
  model: reference,
653
- inputTokens: usage.input,
654
- outputTokens: usage.output,
655
- cachedTokens: usage.cacheRead,
656
- cacheWriteTokens: usage.cacheWrite,
656
+ inputTokens: ownUsage.input,
657
+ outputTokens: ownUsage.output,
658
+ cachedTokens: ownUsage.cacheRead,
659
+ cacheWriteTokens: ownUsage.cacheWrite,
657
660
  });
661
+ // Keyed by the same parentRunId the Agent tool stamps its budget under, the
662
+ // way `subagentInvoked` below reads its count.
663
+ const usage = withSubagentUsage(
664
+ ownUsage,
665
+ subagentUsageForRun(options.subagents, (options.toolContext ?? readToolRuntime())?.runId),
666
+ estimatedCost,
667
+ );
658
668
  emitUsageCostEvents({
659
669
  onEvent,
660
670
  resolved,
@@ -13,6 +13,7 @@ export const COMMON_CAPABILITIES = {
13
13
  supports_builtin_tools: true,
14
14
  supports_live_input: true,
15
15
  supports_native_subagents: true,
16
+ supports_request_tool_environment: false,
16
17
  supports_fast_mode: false,
17
18
  tool_policy: TOOL_POLICY_PROJECTED,
18
19
  };
@@ -34,6 +35,7 @@ export const RUNTIME_CAPABILITIES = {
34
35
  // The pi-native bridge does not (yet) wire native subagents / an AskAgent
35
36
  // tool, so advertise no support rather than letting callers expect it.
36
37
  supports_native_subagents: false,
38
+ supports_request_tool_environment: true,
37
39
  },
38
40
  codex: {
39
41
  runtime: "cli",
@@ -98,7 +98,7 @@ const RESOLVER_PROTECTED_OPTION_KEYS = new Set([
98
98
  "sessionId", "providerSessionId", "sessionKeepAlive", "sessionIdleTimeoutMs",
99
99
  "diagnosticsSeed", "systemPromptPrefix", "sandboxPolicy", "sandboxEngine", "sandbox",
100
100
  "allowedTools", "disallowedTools", "permissionMode", "mcpServers", "skills",
101
- "outputSchema", "nativeSubagents", "liveInput", "fastMode",
101
+ "outputSchema", "nativeSubagents", "liveInput", "fastMode", "toolEnvironment",
102
102
  ]);
103
103
 
104
104
  /**
@@ -1046,6 +1046,9 @@ function entrySatisfiesRequirements(entry, options) {
1046
1046
  if (options.liveInput) {
1047
1047
  effectiveRequires.supports_live_input = true;
1048
1048
  }
1049
+ if (options.toolEnvironment !== undefined) {
1050
+ effectiveRequires.supports_request_tool_environment = true;
1051
+ }
1049
1052
  if (options.fastMode === true) {
1050
1053
  effectiveRequires.supports_fast_mode = true;
1051
1054
  }
package/src/ai/types.js CHANGED
@@ -156,6 +156,7 @@
156
156
  * @property {Object} [outputSchema]
157
157
  * @property {string} [runArtifactDir]
158
158
  * @property {AbortSignal} [abortSignal]
159
+ * @property {{schema: 1, values: Readonly<Record<string, string>>, pathPrepend?: readonly string[]}} [toolEnvironment] Host-only environment for Bash, Exec, and nested subagents in this run.
159
160
  * @property {import('../agent/sandbox-seam.js').SandboxPolicy} [sandboxPolicy] Per-run sandbox policy; merged monotonically with the host policy (see resolveSandboxPolicy, agent/tools/shared/tool-context.js).
160
161
  * @property {import('../agent/sandbox-seam.js').RuntimeSandboxEngine} [sandboxEngine] Per-run concrete sandbox engine handed to the active sandbox implementation.
161
162
  * @property {import('../agent/sandbox-seam.js').RuntimeSandbox} [sandbox] Per-run sandbox IMPLEMENTATION override; when set it enforces this run's tools instead of the host/ToolContext impl (precedence run > host > passthrough). Policy DATA still merges monotonically (I13); this overrides only the enforcing code.
@@ -279,6 +280,7 @@
279
280
  * @property {boolean} [supports_builtin_tools]
280
281
  * @property {boolean} [supports_live_input]
281
282
  * @property {boolean} [supports_native_subagents]
283
+ * @property {boolean} [supports_request_tool_environment]
282
284
  * @property {boolean} [supports_fast_mode]
283
285
  * @property {"projected"|"allow_all_only"} [tool_policy] Whether the bridge can
284
286
  * project named allow/deny policies or accepts only a semantically unrestricted
package/src/runtime.js CHANGED
@@ -172,6 +172,7 @@ export function createRuntime(host = {}) {
172
172
  // list of its own to consult, so it forwards what it was given.
173
173
  ...(request.skills === undefined ? {} : { skills: request.skills }),
174
174
  ...(request.skillsRoot === undefined ? {} : { skillsRoot: request.skillsRoot }),
175
+ ...(request.toolEnvironment === undefined ? {} : { toolEnvironment: request.toolEnvironment }),
175
176
  ...(request.executionMode === undefined ? {} : { executionMode: request.executionMode }),
176
177
  ...(request.cwd === undefined ? {} : { cwd: request.cwd }),
177
178
  // A profile that pins effort — declared or authored at call time — means it
@@ -210,6 +211,12 @@ export function createRuntime(host = {}) {
210
211
  });
211
212
  const liveInput = instrumentLiveInputAppliedEvents(options.liveInput, hub.emit);
212
213
  const prompts = resolvePrompts(host.prompts, options.prompts);
214
+ // A request-scoped environment must never mutate the long-lived runtime's
215
+ // shared ToolContext. Clone only for this call, preserving configureTools
216
+ // updates while keeping credentials isolated between concurrent turns.
217
+ const runToolContext = options.toolEnvironment === undefined
218
+ ? toolContext
219
+ : { ...toolContext, toolEnvironment: options.toolEnvironment };
213
220
  // Default the nested-run callback so the Agent built-in is usable without
214
221
  // host wiring; the depth field is left exactly as the caller set it, since
215
222
  // defaultSubagentRun is what increments it for the child.
@@ -226,7 +233,7 @@ export function createRuntime(host = {}) {
226
233
  model: options.model,
227
234
  executionMode,
228
235
  runtimeBrand,
229
- toolContext,
236
+ toolContext: runToolContext,
230
237
  observerHub: hub,
231
238
  onEvent: hub.emit,
232
239
  ...(liveInput === undefined ? {} : { liveInput }),
@@ -13,11 +13,32 @@
13
13
  * @returns {number}
14
14
  */
15
15
  export function subagentInvocationCount(subagents: any, parentRunId: string | undefined): number;
16
+ /**
17
+ * What this logical run's subagents spent, summed across every delegation.
18
+ *
19
+ * A delegation is work the run asked for, so its cost belongs to the run's
20
+ * total — a provider folds this into its own usage before reporting, which is
21
+ * what makes the console's cost, the TUI status bar and the exported metrics
22
+ * agree with the bill. Same read-only-accessor contract as
23
+ * `subagentInvocationCount`: `__budgets` stays private. All zeroes when nothing
24
+ * delegated, which is the truthful answer for a run that never used the tool.
25
+ *
26
+ * @param {*} subagents The run-scoped options object, or undefined.
27
+ * @param {string|undefined} parentRunId
28
+ * @returns {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}}
29
+ */
30
+ export function subagentUsageForRun(subagents: any, parentRunId: string | undefined): {
31
+ costUsd: number;
32
+ input: number;
33
+ output: number;
34
+ cacheRead: number;
35
+ cacheWrite: number;
36
+ };
16
37
  /**
17
38
  * Build the `Agent` tool, or null when subagents are unavailable for this run.
18
39
  *
19
40
  * @param {RuntimeSubagentsOptions|null|undefined} subagents
20
- * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, onEvent?: (event: *) => void}} [context]
41
+ * @param {{model?: *, executionMode?: string, cwd?: string, parentRunId?: string, sandboxPolicy?: *, sandboxEngine?: *, skills?: {name: string, description?: string}[], skillsRoot?: string, toolEnvironment?: *, onEvent?: (event: *) => void}} [context]
21
42
  * @returns {*|null}
22
43
  */
23
44
  export function createAgentTool(subagents: RuntimeSubagentsOptions | null | undefined, context?: {
@@ -32,6 +53,7 @@ export function createAgentTool(subagents: RuntimeSubagentsOptions | null | unde
32
53
  description?: string;
33
54
  }[];
34
55
  skillsRoot?: string;
56
+ toolEnvironment?: any;
35
57
  onEvent?: (event: any) => void;
36
58
  }): any | null;
37
59
  /**
@@ -30,6 +30,16 @@ export function resetToolContext(ctx: ToolContext): ToolContext;
30
30
  * @returns {SandboxPolicy|undefined}
31
31
  */
32
32
  export function resolveSandboxPolicy(ctx: ToolContext | undefined, requestPolicy?: SandboxPolicy): SandboxPolicy | undefined;
33
+ /**
34
+ * Compose the process environment overlay for Bash/Exec. The caller-supplied
35
+ * base carries each tool's own hardening values; request values are applied
36
+ * only at this final process boundary. No global environment is mutated.
37
+ *
38
+ * @param {ToolContext|undefined} ctx
39
+ * @param {Record<string, string|undefined>} [base]
40
+ * @returns {Record<string, string|undefined>|undefined}
41
+ */
42
+ export function requestToolProcessEnvironment(ctx: ToolContext | undefined, base?: Record<string, string | undefined>): Record<string, string | undefined> | undefined;
33
43
  export type RuntimeBrand = import("../../../runtime-brand.js").RuntimeBrand;
34
44
  export type SandboxPolicy = import("../../sandbox-seam.js").SandboxPolicy;
35
45
  export type RuntimeSandboxEngine = import("../../sandbox-seam.js").RuntimeSandboxEngine;
@@ -45,4 +55,9 @@ export type ToolContext = {
45
55
  sandboxEngine?: RuntimeSandboxEngine;
46
56
  sandbox: RuntimeSandbox;
47
57
  runtimeBrand: RuntimeBrand;
58
+ toolEnvironment?: {
59
+ schema: 1;
60
+ values: Readonly<Record<string, string>>;
61
+ pathPrepend?: readonly string[];
62
+ };
48
63
  };
@@ -10,6 +10,47 @@ export function usageFromMessages(messages?: Array<any>): {
10
10
  cacheWrite: number;
11
11
  cost: number;
12
12
  };
13
+ /**
14
+ * Add what this run's subagents spent to the run's own usage.
15
+ *
16
+ * A delegation is work the run asked for and is billed to the same account, but
17
+ * `usageFromMessages` only ever sees this agent's transcript — a subagent keeps
18
+ * its own — so every consumer of the run's usage was short by the whole cost of
19
+ * every subagent. Folding it in here rather than publishing a separate event
20
+ * makes the console's cost, the TUI status bar and the exported metrics correct
21
+ * with no change at any of them. The trade is attribution: a subagent on
22
+ * another model has its spend reported under this run's model, which is the
23
+ * right answer for a run total and the wrong one for a per-model breakdown.
24
+ *
25
+ * `estimatedCost` is this agent's own fallback price, used when the provider
26
+ * priced nothing (subscription auth). Adding to the raw `cost` alone in that
27
+ * case would have replaced this agent's cost with the subagent's rather than
28
+ * summing them.
29
+ *
30
+ * @param {{input: number, output: number, cacheRead: number, cacheWrite: number, cost: number}} usage
31
+ * @param {{costUsd: number, input: number, output: number, cacheRead: number, cacheWrite: number}} delegated
32
+ * @param {number} estimatedCost
33
+ * @returns {{input: number, output: number, cacheRead: number, cacheWrite: number, cost: number}}
34
+ */
35
+ export function withSubagentUsage(usage: {
36
+ input: number;
37
+ output: number;
38
+ cacheRead: number;
39
+ cacheWrite: number;
40
+ cost: number;
41
+ }, delegated: {
42
+ costUsd: number;
43
+ input: number;
44
+ output: number;
45
+ cacheRead: number;
46
+ cacheWrite: number;
47
+ }, estimatedCost: number): {
48
+ input: number;
49
+ output: number;
50
+ cacheRead: number;
51
+ cacheWrite: number;
52
+ cost: number;
53
+ };
13
54
  /**
14
55
  * Normalize one provider request's usage into an exact context snapshot.
15
56
  * Unlike usageFromMessages(), this deliberately does not aggregate earlier
@@ -9,6 +9,7 @@ export namespace COMMON_CAPABILITIES {
9
9
  export let supports_builtin_tools: boolean;
10
10
  export let supports_live_input: boolean;
11
11
  export let supports_native_subagents: boolean;
12
+ export let supports_request_tool_environment: boolean;
12
13
  export let supports_fast_mode: boolean;
13
14
  export { TOOL_POLICY_PROJECTED as tool_policy };
14
15
  }
@@ -23,6 +24,8 @@ export namespace RUNTIME_CAPABILITIES {
23
24
  export { supports_session_resume_2 as supports_session_resume };
24
25
  let supports_native_subagents_1: boolean;
25
26
  export { supports_native_subagents_1 as supports_native_subagents };
27
+ let supports_request_tool_environment_1: boolean;
28
+ export { supports_request_tool_environment_1 as supports_request_tool_environment };
26
29
  let runtime_1: string;
27
30
  export { runtime_1 as runtime };
28
31
  }
@@ -127,6 +127,7 @@
127
127
  * @property {Object} [outputSchema]
128
128
  * @property {string} [runArtifactDir]
129
129
  * @property {AbortSignal} [abortSignal]
130
+ * @property {{schema: 1, values: Readonly<Record<string, string>>, pathPrepend?: readonly string[]}} [toolEnvironment] Host-only environment for Bash, Exec, and nested subagents in this run.
130
131
  * @property {import('../agent/sandbox-seam.js').SandboxPolicy} [sandboxPolicy] Per-run sandbox policy; merged monotonically with the host policy (see resolveSandboxPolicy, agent/tools/shared/tool-context.js).
131
132
  * @property {import('../agent/sandbox-seam.js').RuntimeSandboxEngine} [sandboxEngine] Per-run concrete sandbox engine handed to the active sandbox implementation.
132
133
  * @property {import('../agent/sandbox-seam.js').RuntimeSandbox} [sandbox] Per-run sandbox IMPLEMENTATION override; when set it enforces this run's tools instead of the host/ToolContext impl (precedence run > host > passthrough). Policy DATA still merges monotonically (I13); this overrides only the enforcing code.
@@ -243,6 +244,7 @@
243
244
  * @property {boolean} [supports_builtin_tools]
244
245
  * @property {boolean} [supports_live_input]
245
246
  * @property {boolean} [supports_native_subagents]
247
+ * @property {boolean} [supports_request_tool_environment]
246
248
  * @property {boolean} [supports_fast_mode]
247
249
  * @property {"projected"|"allow_all_only"} [tool_policy] Whether the bridge can
248
250
  * project named allow/deny policies or accepts only a semantically unrestricted
@@ -596,6 +598,14 @@ export type RuntimeRunOptions = {
596
598
  outputSchema?: any;
597
599
  runArtifactDir?: string;
598
600
  abortSignal?: AbortSignal;
601
+ /**
602
+ * Host-only environment for Bash, Exec, and nested subagents in this run.
603
+ */
604
+ toolEnvironment?: {
605
+ schema: 1;
606
+ values: Readonly<Record<string, string>>;
607
+ pathPrepend?: readonly string[];
608
+ };
599
609
  /**
600
610
  * Per-run sandbox policy; merged monotonically with the host policy (see resolveSandboxPolicy, agent/tools/shared/tool-context.js).
601
611
  */
@@ -837,6 +847,7 @@ export type RuntimeCapabilities = {
837
847
  supports_builtin_tools?: boolean;
838
848
  supports_live_input?: boolean;
839
849
  supports_native_subagents?: boolean;
850
+ supports_request_tool_environment?: boolean;
840
851
  supports_fast_mode?: boolean;
841
852
  /**
842
853
  * Whether the bridge can