pi-codemcp 1.2.2 → 1.3.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.
@@ -24,6 +24,7 @@ from .json_types import (
24
24
  JsonValue,
25
25
  )
26
26
  from .models import SearchDetail, ToolSchemaView
27
+ from .sandbox_api import STUB_PRELUDE
27
28
 
28
29
  if TYPE_CHECKING:
29
30
  from collections.abc import Iterable
@@ -32,15 +33,6 @@ if TYPE_CHECKING:
32
33
 
33
34
  SEARCH_SCORE_CUTOFF = 20.0
34
35
  MIN_PLURAL_TOKEN_LENGTH = 4
35
- STUB_IMPORTS = "from typing import Literal, Never, NotRequired, TypeAlias, TypedDict"
36
- JSON_TYPE_STUBS = (
37
- "JsonScalar: TypeAlias = bool | int | float | str | None",
38
- 'JsonValue: TypeAlias = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"]',
39
- )
40
- INSPECT_JSON_STUB = (
41
- "def inspect_json(value: JsonValue, *, samples: int = 2, max_depth: int = 3) -> JsonValue: ..."
42
- )
43
- STUB_PRELUDE = "\n\n".join([STUB_IMPORTS, *JSON_TYPE_STUBS, INSPECT_JSON_STUB])
44
36
 
45
37
 
46
38
  class ToolSpec(BaseModel):
@@ -1055,42 +1047,67 @@ def _resolve_ref(ref: str, root: JsonSchema) -> JsonObject:
1055
1047
  def _merge_all_of(
1056
1048
  schema: JsonObject,
1057
1049
  root: JsonSchema,
1058
- ) -> JsonObject | None:
1059
- merged_properties: JsonObject = {}
1060
- merged_required: list[JsonValue] = []
1061
- additional_properties: JsonValue = True
1062
- has_additional_properties = False
1050
+ ) -> JsonSchema | None:
1063
1051
  raw_members = schema.get("allOf")
1064
1052
  if not isinstance(raw_members, list):
1065
1053
  return None
1054
+ # Sibling constraints (type, properties, ...) apply on top of allOf members.
1055
+ parts: list[JsonObject] = [{key: value for key, value in schema.items() if key != "allOf"}]
1066
1056
  for member in raw_members:
1067
1057
  if not isinstance(member, dict):
1068
1058
  return None
1069
1059
  ref = member.get("$ref")
1070
- resolved = _resolve_ref(ref, root) if isinstance(ref, str) else member
1071
- if resolved.get("type") not in {None, "object"}:
1072
- return None
1073
- raw_properties = resolved.get("properties")
1060
+ parts.append(_resolve_ref(ref, root) if isinstance(ref, str) else member)
1061
+
1062
+ declared_types: set[str] = set()
1063
+ for part in parts:
1064
+ part_type = part.get("type")
1065
+ if isinstance(part_type, str):
1066
+ declared_types.add(part_type)
1067
+ if len(declared_types) > 1:
1068
+ return None
1069
+ merged_type = declared_types.pop() if declared_types else None
1070
+
1071
+ if merged_type == "object" or (
1072
+ merged_type is None and any("properties" in part for part in parts)
1073
+ ):
1074
+ return _merge_object_parts(parts)
1075
+
1076
+ if merged_type is None:
1077
+ # Annotation-only allOf (descriptions, patterns, ...): no type information.
1078
+ return True
1079
+ merged: JsonObject = {"type": merged_type}
1080
+ for part in parts:
1081
+ if "items" in part and "items" not in merged:
1082
+ merged["items"] = part["items"]
1083
+ return merged
1084
+
1085
+
1086
+ def _merge_object_parts(parts: list[JsonObject]) -> JsonObject:
1087
+ merged_properties: JsonObject = {}
1088
+ merged_required: list[JsonValue] = []
1089
+ additional_properties: JsonValue = True
1090
+ has_additional_properties = False
1091
+ for part in parts:
1092
+ raw_properties = part.get("properties")
1074
1093
  if isinstance(raw_properties, dict):
1075
1094
  merged_properties.update(raw_properties)
1076
- raw_required = resolved.get("required")
1095
+ raw_required = part.get("required")
1077
1096
  if isinstance(raw_required, list):
1078
1097
  for required in raw_required:
1079
1098
  if isinstance(required, str) and required not in merged_required:
1080
1099
  merged_required.append(required)
1081
- if "additionalProperties" in resolved:
1082
- additional_properties = JSON_VALUE_ADAPTER.validate_python(
1083
- resolved["additionalProperties"]
1084
- )
1100
+ if "additionalProperties" in part:
1101
+ additional_properties = JSON_VALUE_ADAPTER.validate_python(part["additionalProperties"])
1085
1102
  has_additional_properties = True
1086
- merged: JsonObject = {
1103
+ merged_object: JsonObject = {
1087
1104
  "type": "object",
1088
1105
  "properties": merged_properties,
1089
1106
  "required": merged_required,
1090
1107
  }
1091
1108
  if has_additional_properties:
1092
- merged["additionalProperties"] = additional_properties
1093
- return merged
1109
+ merged_object["additionalProperties"] = additional_properties
1110
+ return merged_object
1094
1111
 
1095
1112
 
1096
1113
  def _dedupe(blocks: Iterable[str]) -> list[str]:
package/src/chains.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { existsSync, readdirSync, readFileSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { type ExtensionAPI, highlightCode, type Theme } from "@earendil-works/pi-coding-agent";
@@ -67,6 +68,10 @@ interface LoadedChains {
67
68
 
68
69
  const CHAIN_NAME = /^[a-z][a-z0-9_]{0,63}$/;
69
70
 
71
+ export function newCodeMcpTraceId(source: string): string {
72
+ return `${source}:${randomUUID()}`;
73
+ }
74
+
70
75
  export class SavedChainManager {
71
76
  readonly startupErrors: string[] = [];
72
77
  private readonly manifests = new Map<string, ScopedSavedChain>();
@@ -94,7 +99,11 @@ export class SavedChainManager {
94
99
  }
95
100
  }
96
101
 
97
- async save(input: SaveChainInput, signal?: AbortSignal): Promise<SavedChainView> {
102
+ async save(
103
+ input: SaveChainInput,
104
+ traceId: string,
105
+ signal?: AbortSignal,
106
+ ): Promise<SavedChainView> {
98
107
  assertChainName(input.name);
99
108
  this.assertToolNameAvailable(input.name);
100
109
  const result = await this.lifecycle.request(
@@ -106,6 +115,7 @@ export class SavedChainManager {
106
115
  code: input.code,
107
116
  input_schema: input.inputSchema,
108
117
  output_schema: input.outputSchema,
118
+ trace_id: traceId,
109
119
  },
110
120
  signal,
111
121
  );
@@ -116,8 +126,8 @@ export class SavedChainManager {
116
126
  return view;
117
127
  }
118
128
 
119
- async list(signal?: AbortSignal): Promise<SavedChainView[]> {
120
- const result = await this.lifecycle.request("list_chains", {}, signal);
129
+ async list(traceId: string, signal?: AbortSignal): Promise<SavedChainView[]> {
130
+ const result = await this.lifecycle.request("list_chains", { trace_id: traceId }, signal);
121
131
  const views = parseViewList(result.chains, "list_chains.chains");
122
132
  this.synchronizeViews(views);
123
133
  return views;
@@ -127,11 +137,12 @@ export class SavedChainManager {
127
137
  name: string,
128
138
  scope: ChainScope,
129
139
  enabled: boolean,
140
+ traceId: string,
130
141
  signal?: AbortSignal,
131
142
  ): Promise<SavedChainView> {
132
143
  const result = await this.lifecycle.request(
133
144
  "set_chain_enabled",
134
- { name, scope, enabled },
145
+ { name, scope, enabled, trace_id: traceId },
135
146
  signal,
136
147
  );
137
148
  const view = parseSavedChainView(result, "set_chain_enabled");
@@ -140,16 +151,34 @@ export class SavedChainManager {
140
151
  return view;
141
152
  }
142
153
 
143
- async revalidate(name: string, scope: ChainScope, signal?: AbortSignal): Promise<SavedChainView> {
144
- const result = await this.lifecycle.request("revalidate_chain", { name, scope }, signal);
154
+ async revalidate(
155
+ name: string,
156
+ scope: ChainScope,
157
+ traceId: string,
158
+ signal?: AbortSignal,
159
+ ): Promise<SavedChainView> {
160
+ const result = await this.lifecycle.request(
161
+ "revalidate_chain",
162
+ { name, scope, trace_id: traceId },
163
+ signal,
164
+ );
145
165
  const view = parseSavedChainView(result, "revalidate_chain");
146
166
  this.upsertView(view);
147
167
  this.refreshNativeTools();
148
168
  return view;
149
169
  }
150
170
 
151
- async delete(name: string, scope: ChainScope, signal?: AbortSignal): Promise<SavedChainView[]> {
152
- const result = await this.lifecycle.request("delete_chain", { name, scope }, signal);
171
+ async delete(
172
+ name: string,
173
+ scope: ChainScope,
174
+ traceId: string,
175
+ signal?: AbortSignal,
176
+ ): Promise<SavedChainView[]> {
177
+ const result = await this.lifecycle.request(
178
+ "delete_chain",
179
+ { name, scope, trace_id: traceId },
180
+ signal,
181
+ );
153
182
  const views = parseViewList(result.chains, "delete_chain.chains");
154
183
  this.synchronizeViews(views);
155
184
  return views;
@@ -163,7 +192,7 @@ export class SavedChainManager {
163
192
  label: chain.name,
164
193
  description: chain.description,
165
194
  parameters: chain.inputSchema,
166
- async execute(_toolCallId, params, signal, onUpdate) {
195
+ async execute(toolCallId, params, signal, onUpdate) {
167
196
  onUpdate?.({
168
197
  content: [{ type: "text", text: `Running saved MCP chain ${chain.name}...` }],
169
198
  details: undefined,
@@ -171,13 +200,24 @@ export class SavedChainManager {
171
200
  const arguments_ = requireRecord(params, `${chain.name} arguments`);
172
201
  const result = await manager.lifecycle.request(
173
202
  "execute_chain",
174
- { name: chain.name, arguments: arguments_ },
203
+ { name: chain.name, arguments: arguments_, trace_id: toolCallId },
175
204
  signal,
176
205
  );
177
206
  if (result.ok !== true) {
178
- const error =
179
- typeof result.error === "string" ? result.error : `Saved chain ${chain.name} failed`;
180
- throw new Error(error);
207
+ throw new Error(
208
+ JSON.stringify({
209
+ failure_stage: result.failure_stage,
210
+ error:
211
+ typeof result.error === "string"
212
+ ? result.error
213
+ : `Saved chain ${chain.name} failed`,
214
+ failure: result.failure,
215
+ result_ref: result.result_ref,
216
+ expires_in_seconds: result.expires_in_seconds,
217
+ calls_made: result.calls_made,
218
+ chain_calls: result.chain_calls,
219
+ }),
220
+ );
181
221
  }
182
222
  const settings = manager.lifecycle.loadSettings();
183
223
  const output = formatCodeMcpOutput(result.result, {
package/src/mcp-client.ts CHANGED
@@ -99,6 +99,7 @@ export class SidecarClient {
99
99
  ...definedProcessEnvironment(),
100
100
  ...(options.environment ?? {}),
101
101
  PI_CODEMCP_AGENT_DIR: this.agentDir,
102
+ PI_CODEMCP_PACKAGE_VERSION: this.packageVersion,
102
103
  ...(this.projectChainsDirectory === undefined
103
104
  ? {}
104
105
  : { PI_CODEMCP_PROJECT_CHAINS_DIR: this.projectChainsDirectory }),
package/src/modal.ts CHANGED
@@ -72,6 +72,7 @@ export interface StatsModalState {
72
72
  p95Ms: number;
73
73
  maxMs: number;
74
74
  }>;
75
+ outcomes: Array<{ name: string; count: number }>;
75
76
  failures: Array<{ stage: string; count: number }>;
76
77
  upstreamOutputBytes: number;
77
78
  cacheHits: number;
@@ -258,6 +259,10 @@ export function statsStateFromSnapshot(snapshot: Record<string, unknown>): Stats
258
259
  },
259
260
  ];
260
261
  });
262
+ const rawOutcomes = isRecord(snapshot.outcomes) ? snapshot.outcomes : {};
263
+ const outcomes = Object.entries(rawOutcomes)
264
+ .flatMap(([name, count]) => (typeof count === "number" && count >= 0 ? [{ name, count }] : []))
265
+ .sort((left, right) => right.count - left.count || left.name.localeCompare(right.name));
261
266
  const rawFailures = isRecord(snapshot.failures) ? snapshot.failures : {};
262
267
  const failures = Object.entries(rawFailures)
263
268
  .flatMap(([stage, count]) =>
@@ -278,6 +283,7 @@ export function statsStateFromSnapshot(snapshot: Record<string, unknown>): Stats
278
283
  recent,
279
284
  operations,
280
285
  phases,
286
+ outcomes,
281
287
  failures,
282
288
  upstreamOutputBytes,
283
289
  cacheHits: numberField(cache, "hits"),
@@ -755,6 +761,11 @@ class ServerManagerModal implements Component, Focusable {
755
761
  ),
756
762
  );
757
763
  }
764
+ lines.push("", this.theme.fg("dim", this.theme.bold("OUTCOMES")));
765
+ if (stats.outcomes.length === 0) lines.push(this.theme.fg("muted", "No outcomes yet"));
766
+ for (const outcome of stats.outcomes) {
767
+ lines.push(`${outcome.name.padEnd(22)} ${outcome.count.toLocaleString().padStart(8)}`);
768
+ }
758
769
  lines.push("", this.theme.fg("dim", this.theme.bold("FAILURE STAGES")));
759
770
  if (stats.failures.length === 0) lines.push(this.theme.fg("muted", "No failures yet"));
760
771
  for (const failure of stats.failures) {
package/src/prompts.ts CHANGED
@@ -8,10 +8,10 @@ export const INSPECT_PROMPT_GUIDELINES = [
8
8
  ] as const;
9
9
 
10
10
  export const EXECUTE_PROMPT_GUIDELINES = [
11
- "Use programmatic execution for a bounded workflow when code can deterministically filter, join, aggregate, deduplicate, validate, or reduce intermediate results.",
11
+ "Use bounded execution to deterministically filter, aggregate, sample, join, or reduce upstream data in the sandbox; do not return raw payloads.",
12
12
  "Keep a model turn between calls when an intermediate result changes the semantic decision or user approval is required.",
13
- "Return the smallest result that answers the request; oversized results fail explicitly with bounded structural inspection data.",
14
- "SDK facades are prebound globals and must not be imported. Import supported stdlib normally (for example, `import asyncio`); class declarations and `__import__` are unsupported.",
13
+ "Return the smallest answer. If an oversized result provides result_ref, refine it via inputRef instead of repeating upstream calls.",
14
+ "SDK facades are prebound globals and must not be imported. Use `import asyncio` for `asyncio.gather`; other imports, classes, `asyncio.create_task`, and `__import__` are unsupported.",
15
15
  ] as const;
16
16
 
17
17
  export const SAVE_CHAIN_PROMPT_GUIDELINES = [
package/src/tools.ts CHANGED
@@ -144,6 +144,12 @@ const ExecuteParameters = Type.Object({
144
144
  description:
145
145
  "Sandboxed Python body. Call typed SDK methods such as await linear.list_issues(arguments) and return a compact final value.",
146
146
  }),
147
+ inputRef: Type.Optional(
148
+ Type.String({
149
+ minLength: 1,
150
+ description: "Opaque retained-result reference exposed to sandbox code as input",
151
+ }),
152
+ ),
147
153
  });
148
154
 
149
155
  export function registerCodeMcpTools(
@@ -159,7 +165,7 @@ export function registerCodeMcpTools(
159
165
  promptSnippet: "Discover compact MCP capabilities or inventory",
160
166
  promptGuidelines: [...SEARCH_PROMPT_GUIDELINES],
161
167
  parameters: SearchParameters,
162
- async execute(_toolCallId, params, signal, onUpdate) {
168
+ async execute(toolCallId, params, signal, onUpdate) {
163
169
  onUpdate?.({
164
170
  content: [{ type: "text", text: "Searching MCP tools..." }],
165
171
  details: undefined,
@@ -173,6 +179,7 @@ export function registerCodeMcpTools(
173
179
  limit: params.limit ?? 5,
174
180
  cursor: params.cursor ?? 0,
175
181
  ...(params.server === undefined ? {} : { server: params.server }),
182
+ trace_id: toolCallId,
176
183
  },
177
184
  signal,
178
185
  );
@@ -247,12 +254,16 @@ export function registerCodeMcpTools(
247
254
  promptSnippet: "Load exact typed contracts for selected MCP calls",
248
255
  promptGuidelines: [...INSPECT_PROMPT_GUIDELINES],
249
256
  parameters: InspectParameters,
250
- async execute(_toolCallId, params, signal, onUpdate) {
257
+ async execute(toolCallId, params, signal, onUpdate) {
251
258
  onUpdate?.({
252
259
  content: [{ type: "text", text: "Inspecting MCP tool contracts..." }],
253
260
  details: undefined,
254
261
  });
255
- const result = await lifecycle.request("inspect", { calls: params.calls }, signal);
262
+ const result = await lifecycle.request(
263
+ "inspect",
264
+ { calls: params.calls, trace_id: toolCallId },
265
+ signal,
266
+ );
256
267
  const output = formatCodeMcpOutput(result, outputLimits(lifecycle));
257
268
  const results = Array.isArray(result.results) ? result.results : [];
258
269
  return {
@@ -292,23 +303,34 @@ export function registerCodeMcpTools(
292
303
  name: "codemcp_execute",
293
304
  label: "MCP Execute",
294
305
  description:
295
- "Type-check and execute one bounded sandboxed Python MCP call graph. Use it when code can deterministically process intermediate results; preserve a model turn for semantic decisions or approvals. The sandbox has no host filesystem, environment, network, or subprocess access. Oversized results fail with bounded shape, size, and sample diagnostics.",
306
+ "Type-check and execute one bounded sandboxed Python MCP call graph. Filter, aggregate, or sample upstream data inside the sandbox instead of returning raw payloads; an optional inputRef exposes a retained oversized value as input without repeating upstream calls. Preserve a model turn for semantic decisions or approvals. The sandbox has no host filesystem, environment, network, or subprocess access. Oversized results fail with bounded shape, size, and sample diagnostics.",
296
307
  promptSnippet: "Run a bounded typed MCP workflow and return a compact result",
297
308
  promptGuidelines: [...EXECUTE_PROMPT_GUIDELINES],
298
309
  parameters: ExecuteParameters,
299
- async execute(_toolCallId, params, signal, onUpdate) {
310
+ async execute(toolCallId, params, signal, onUpdate) {
300
311
  onUpdate?.({
301
312
  content: [{ type: "text", text: "Type-checking MCP chain..." }],
302
313
  details: undefined,
303
314
  });
304
- const result = await lifecycle.request("execute", { code: params.code }, signal);
315
+ const result = await lifecycle.request(
316
+ "execute",
317
+ {
318
+ code: params.code,
319
+ trace_id: toolCallId,
320
+ ...(params.inputRef === undefined ? {} : { input_ref: params.inputRef }),
321
+ },
322
+ signal,
323
+ );
305
324
  const ok = result.ok === true;
306
325
  const modelValue = ok
307
326
  ? result.result
308
327
  : {
309
328
  failure_stage: result.failure_stage,
310
329
  error: result.error,
330
+ failure: result.failure,
311
331
  shape: result.shape,
332
+ result_ref: result.result_ref,
333
+ expires_in_seconds: result.expires_in_seconds,
312
334
  calls_made: result.calls_made,
313
335
  chain_calls: result.chain_calls,
314
336
  };
@@ -365,7 +387,7 @@ export function registerCodeMcpTools(
365
387
  promptSnippet: "Save a repeated MCP execution as a typed reusable native tool",
366
388
  promptGuidelines: [...SAVE_CHAIN_PROMPT_GUIDELINES],
367
389
  parameters: SaveChainParameters,
368
- async execute(_toolCallId, params, signal, onUpdate) {
390
+ async execute(toolCallId, params, signal, onUpdate) {
369
391
  const scope = requireChainScope(params.scope ?? "project");
370
392
  if (scope === "project" && lifecycle.projectChainsPath === undefined) {
371
393
  throw new Error(
@@ -385,6 +407,7 @@ export function registerCodeMcpTools(
385
407
  inputSchema: params.inputSchema,
386
408
  outputSchema: params.outputSchema,
387
409
  },
410
+ toolCallId,
388
411
  signal,
389
412
  );
390
413
  const result = {
@@ -455,11 +478,11 @@ export function registerCodeMcpTools(
455
478
  promptSnippet: "List or explicitly manage saved MCP chains",
456
479
  promptGuidelines: [...MANAGE_CHAIN_PROMPT_GUIDELINES],
457
480
  parameters: ManageChainsParameters,
458
- async execute(_toolCallId, params, signal, onUpdate) {
481
+ async execute(toolCallId, params, signal, onUpdate) {
459
482
  const action = params.action;
460
483
  let views: SavedChainView[];
461
484
  if (action === "list") {
462
- views = await chains.list(signal);
485
+ views = await chains.list(toolCallId, signal);
463
486
  } else {
464
487
  if (params.confirmedByUser !== true) {
465
488
  throw new Error(`${action} requires confirmedByUser=true after explicit user approval`);
@@ -476,13 +499,13 @@ export function registerCodeMcpTools(
476
499
  details: undefined,
477
500
  });
478
501
  if (action === "enable" || action === "disable") {
479
- await chains.setEnabled(params.name, scope, action === "enable", signal);
480
- views = await chains.list(signal);
502
+ await chains.setEnabled(params.name, scope, action === "enable", toolCallId, signal);
503
+ views = await chains.list(toolCallId, signal);
481
504
  } else if (action === "revalidate") {
482
- await chains.revalidate(params.name, scope, signal);
483
- views = await chains.list(signal);
505
+ await chains.revalidate(params.name, scope, toolCallId, signal);
506
+ views = await chains.list(toolCallId, signal);
484
507
  } else {
485
- views = await chains.delete(params.name, scope, signal);
508
+ views = await chains.delete(params.name, scope, toolCallId, signal);
486
509
  }
487
510
  }
488
511
  const result = {