pi-roundtable-sandbox 0.7.13 → 0.7.15

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/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.15
4
+
5
+ - The model broker forwards a mid-conversation `role: "system"` message, which Claude Code 2.1.284 sends with each turn's environment, instead of refusing it. Like every other part of the request it is rebuilt, not passed on: its content may be only text (a string, or `text` blocks with `cache_control`), it keeps `clear_at`, and its per-message `output_config.effort` is capped at the host-judged level as the top-level effort is. The guest already writes the top-level system prompt and every user turn, so this text gives it no more reach. A tool change inside one (`tool_addition`, `tool_removal`, `tool_definition`) is still refused, with a 400 worded as the API words it, so Claude Code resends its tools whole.
6
+
7
+ ## 0.7.14
8
+
9
+ - A model request the broker refuses (an unknown role, a native tool, remote media, a remote schema) is answered with a 400 in the Anthropic API's error shape (`invalid_request_error`) instead of a 502, and logged as `sandbox upstream call failed`. Claude Code 2.1.284 (agent SDK 0.3.284) sends a mid-conversation `role: "system"` message when MCP tools arrive late; the broker refused it with a 502, which Claude Code retried about ten times over several minutes before the turn failed. The refusal of a role now reads `messages.N: Unexpected role "system": the input message role must be "user" or "assistant"`, which Claude Code recognizes: it resends without system messages for the rest of that conversation. The broker still never forwards a system message.
10
+
3
11
  ## 0.7.13
4
12
 
5
13
  - `precheckScriptRunner` forwards only the tools approved with the script (pi-roundtable 0.7.13's `PrecheckScriptContext.tools`) among those `grant` allows, and takes `toolName(server, tool)`, the name the host's hold rules know a tool by (default the tool's own name).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-roundtable-sandbox",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
4
4
  "description": "Sealed guest channels and an allow-listed credential broker for pi-roundtable",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "@biomejs/biome": "2.5.15",
53
53
  "@types/bun": "1.4.2",
54
54
  "discord.js": "14.27.0",
55
- "pi-roundtable": "0.7.13",
55
+ "pi-roundtable": "0.7.15",
56
56
  "typebox": "1.3.34",
57
57
  "typescript": "7.0.2"
58
58
  }
package/src/pi-broker.ts CHANGED
@@ -616,13 +616,29 @@ export class PiSandboxBroker {
616
616
  if (value && value.length <= 256) headers.set(name, value);
617
617
  }
618
618
  } else {
619
- body = piModelInput(
620
- input,
621
- this.#options.model,
622
- url.pathname.endsWith("/count_tokens"),
623
- this.#options.maxOutputTokens ?? 128_000,
624
- turn.context.thinking,
625
- );
619
+ try {
620
+ body = piModelInput(
621
+ input,
622
+ this.#options.model,
623
+ url.pathname.endsWith("/count_tokens"),
624
+ this.#options.maxOutputTokens ?? 128_000,
625
+ turn.context.thinking,
626
+ );
627
+ } catch (error) {
628
+ // The request itself is refused: a 400 the client does not retry unchanged, in the
629
+ // API's error shape so it can resend without what was refused. A 502 would be retried.
630
+ failed(secret, { status: 400, error });
631
+ return Response.json(
632
+ {
633
+ type: "error",
634
+ error: {
635
+ type: "invalid_request_error",
636
+ message: error instanceof Error ? error.message : String(error),
637
+ },
638
+ },
639
+ { status: 400 },
640
+ );
641
+ }
626
642
  target = `${this.#options.upstream ?? "https://api.anthropic.com"}${url.pathname.slice("/anthropic".length)}${url.search}`;
627
643
  secret = await this.#options.oauthToken();
628
644
  for (const name of HEADERS) {
@@ -84,6 +84,28 @@ function blocks(raw: unknown, nested = false): unknown[] | string {
84
84
  throw new Error("Native tools or remote media refused");
85
85
  });
86
86
  }
87
+ /**
88
+ * A mid-conversation system message's content: text only. The guest already writes the top-level
89
+ * system prompt and every user turn, so its text here carries no more authority. Its tool changes
90
+ * are refused in the API's wording, so Claude Code resends the tools whole instead.
91
+ */
92
+ function systemContent(raw: unknown, index: number): unknown[] | string {
93
+ if (typeof raw === "string") return raw;
94
+ if (!Array.isArray(raw) || raw.length > 256)
95
+ throw new Error("Invalid content");
96
+ return raw.map((value: unknown, block) => {
97
+ if (
98
+ isRecord(value) &&
99
+ value.type === "text" &&
100
+ typeof value.text === "string"
101
+ )
102
+ return { type: "text", text: value.text, ...cache(value) };
103
+ const type = isRecord(value) ? String(value.type) : typeof value;
104
+ throw new Error(
105
+ `messages.${index}.content.${block}: Input tag '${type}' found using 'type' does not match any of the expected tags: 'text'`,
106
+ );
107
+ });
108
+ }
87
109
  const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
88
110
  /** Highest effort and extended-thinking budget the host-judged level allows. */
89
111
  const CEILINGS: Record<PiThinkingLevel, { effort: number; budget: number }> = {
@@ -104,14 +126,38 @@ export function piModelInput(
104
126
  ): Record<string, unknown> {
105
127
  if (!Array.isArray(input.messages) || input.messages.length > 2000)
106
128
  throw new Error("Invalid messages");
129
+ const ceiling = CEILINGS[thinking];
130
+ /** An effort the guest asks for, at most the host-judged level allows; -1 when none. */
131
+ const cappedEffort = (config: unknown): number => {
132
+ const asked = isRecord(config)
133
+ ? EFFORTS.indexOf(config.effort as (typeof EFFORTS)[number])
134
+ : -1;
135
+ return asked >= 0 ? Math.min(asked, ceiling.effort) : -1;
136
+ };
107
137
  const body: Record<string, unknown> = {
108
138
  model,
109
- messages: input.messages.map((message: unknown) => {
110
- if (
111
- !isRecord(message) ||
112
- !["user", "assistant"].includes(String(message.role))
113
- )
114
- throw new Error("Invalid role");
139
+ messages: input.messages.map((message: unknown, index) => {
140
+ if (!isRecord(message)) throw new Error("Invalid message");
141
+ // Claude Code sends each turn's environment as a mid-conversation system message.
142
+ if (message.role === "system") {
143
+ const effort = cappedEffort(message.output_config);
144
+ return {
145
+ role: "system",
146
+ content: systemContent(message.content, index),
147
+ ...(message.clear_at === "next_user_message" ||
148
+ message.clear_at === "never"
149
+ ? { clear_at: message.clear_at }
150
+ : {}),
151
+ ...(effort >= 0
152
+ ? { output_config: { effort: EFFORTS[effort] } }
153
+ : {}),
154
+ };
155
+ }
156
+ // Worded as the Anthropic API words it, so a client recognizes the refusal.
157
+ if (!["user", "assistant"].includes(String(message.role)))
158
+ throw new Error(
159
+ `messages.${index}: Unexpected role ${JSON.stringify(String(message.role))}: the input message role must be "user", "assistant" or "system"`,
160
+ );
115
161
  return { role: message.role, content: blocks(message.content) };
116
162
  }),
117
163
  };
@@ -153,7 +199,6 @@ export function piModelInput(
153
199
  : {}),
154
200
  };
155
201
  }
156
- const ceiling = CEILINGS[thinking];
157
202
  if (isRecord(input.thinking)) {
158
203
  if (
159
204
  input.thinking.type === "adaptive" ||
@@ -175,13 +220,11 @@ export function piModelInput(
175
220
  };
176
221
  else throw new Error("Invalid thinking");
177
222
  }
178
- const asked = isRecord(input.output_config)
179
- ? EFFORTS.indexOf(input.output_config.effort as (typeof EFFORTS)[number])
180
- : -1;
223
+ const asked = cappedEffort(input.output_config);
181
224
  // Adaptive thinking without an explicit effort runs at the model's default, so the host caps it too.
182
225
  const effort =
183
226
  asked >= 0
184
- ? Math.min(asked, ceiling.effort)
227
+ ? asked
185
228
  : isRecord(body.thinking) &&
186
229
  body.thinking.type === "adaptive" &&
187
230
  ceiling.effort < 2