@opengeni/runtime 4.6.0 → 4.7.0-canary.37090756480001

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 (72) hide show
  1. package/dist/agent-instructions/modules/media.d.ts +3 -0
  2. package/dist/anthropic-messages.d.ts +13 -3
  3. package/dist/anthropic-request-error.d.ts +10 -0
  4. package/dist/assets/codemode-client.json +1 -1
  5. package/dist/{chunk-A22BKN4P.js → chunk-LJNCGUMB.js} +3 -3
  6. package/dist/chunk-LJNCGUMB.js.map +1 -0
  7. package/dist/{chunk-GTWKWJ67.js → chunk-P2OVGKDG.js} +505 -100
  8. package/dist/chunk-P2OVGKDG.js.map +1 -0
  9. package/dist/{chunk-D5DPVA2C.js → chunk-R25L27T3.js} +2047 -1275
  10. package/dist/chunk-R25L27T3.js.map +1 -0
  11. package/dist/claude-subscription-usage.d.ts +14 -4
  12. package/dist/context-compaction.d.ts +7 -7
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.js +13 -3
  15. package/dist/interaction-tools.d.ts +1 -1
  16. package/dist/mcp-network.js +1 -1
  17. package/dist/runtime-skills.d.ts +1 -0
  18. package/dist/sandbox/channel-a.d.ts +3 -0
  19. package/dist/sandbox/index.d.ts +3 -1
  20. package/dist/sandbox/index.js +7 -1
  21. package/dist/sandbox/provider-command-session.d.ts +21 -0
  22. package/dist/sandbox/providers/modal-command-argv.d.ts +4 -0
  23. package/dist/sandbox/providers/modal-command-control.d.ts +17 -5
  24. package/dist/sandbox/providers/modal-command-observation-errors.d.ts +3 -0
  25. package/dist/sandbox/providers/modal-command-raw-page.d.ts +100 -0
  26. package/dist/sandbox/providers/modal-command-router-wire.d.ts +17 -0
  27. package/dist/sandbox/routing/routing-session.d.ts +10 -0
  28. package/dist/sandbox/turn-tool-cancellation.d.ts +4 -0
  29. package/dist/workspace-tool-gateway.js +3 -3
  30. package/package.json +12 -12
  31. package/src/agent-instructions/PROMPT_CHANGELOG.md +23 -1
  32. package/src/agent-instructions/compose.ts +2 -0
  33. package/src/agent-instructions/modules/media.ts +20 -0
  34. package/src/agent-instructions/runtime-mechanics.ts +8 -0
  35. package/src/anthropic-messages.ts +223 -31
  36. package/src/anthropic-request-error.ts +45 -0
  37. package/src/bundled_default_skills/opengeni-client/SKILL.md +9 -7
  38. package/src/bundled_default_skills/opengeni-client/references/agent-recipes.md +1 -1
  39. package/src/bundled_default_skills/opengeni-client/references/api-workflows.md +61 -0
  40. package/src/bundled_default_skills/opengeni-client/references/compatibility-and-troubleshooting.md +86 -0
  41. package/src/bundled_default_skills/opengeni-client/references/data-tools-and-credentials.md +82 -0
  42. package/src/bundled_default_skills/opengeni-client/references/discovery-and-autonomy.md +32 -25
  43. package/src/bundled_default_skills/opengeni-client/references/external-users-and-connect.md +17 -0
  44. package/src/bundled_default_skills/opengeni-client/references/product-shapes-and-ui.md +156 -0
  45. package/src/bundled_schedule_skills/opengeni-schedules/SKILL.md +56 -0
  46. package/src/claude-subscription-usage.ts +55 -9
  47. package/src/context-compaction.ts +16 -9
  48. package/src/index.ts +85 -18
  49. package/src/interaction-tools.ts +113 -3
  50. package/src/lazy-tool-transport.ts +26 -10
  51. package/src/mcp-network.ts +4 -2
  52. package/src/model-provider-client.ts +11 -3
  53. package/src/model-provider-routing.ts +4 -0
  54. package/src/runtime-skills.ts +8 -0
  55. package/src/sandbox/channel-a.ts +3 -0
  56. package/src/sandbox/index.ts +11 -1
  57. package/src/sandbox/provider-command-session.ts +58 -0
  58. package/src/sandbox/provider-errors.ts +43 -9
  59. package/src/sandbox/providers/modal-command-argv.ts +20 -0
  60. package/src/sandbox/providers/modal-command-control.ts +422 -116
  61. package/src/sandbox/providers/modal-command-observation-errors.ts +65 -0
  62. package/src/sandbox/providers/modal-command-raw-page.ts +147 -0
  63. package/src/sandbox/providers/modal-command-router-wire.ts +104 -12
  64. package/src/sandbox/providers/modal-command-session.ts +26 -4
  65. package/src/sandbox/providers/modal-legacy-command-control.ts +2 -9
  66. package/src/sandbox/providers/modal-materialization-verification.ts +70 -6
  67. package/src/sandbox/routing/routing-session.ts +140 -41
  68. package/src/sandbox/run-credentials.ts +62 -19
  69. package/src/sandbox/turn-tool-cancellation.ts +159 -42
  70. package/dist/chunk-A22BKN4P.js.map +0 -1
  71. package/dist/chunk-D5DPVA2C.js.map +0 -1
  72. package/dist/chunk-GTWKWJ67.js.map +0 -1
@@ -43,7 +43,7 @@ Composition order (each part separated by a blank line):
43
43
  waiting and `wait_for_input`, compaction, background commands.
44
44
  - Conditional modules, in this order: `renderer_markdown`, `sandbox`,
45
45
  `connected_machine`, `repositories`, `workspace_environment`, `rig`,
46
- `artifacts`, `goals`, `subagents`, `knowledge`, `skills`, `admin`,
46
+ `artifacts`, `media`, `goals`, `subagents`, `knowledge`, `skills`, `admin`,
47
47
  `attachments` (last because it varies per turn).
48
48
  3. Attempt directives, unchanged text: Codemode, code search, Git credential
49
49
  bindings.
@@ -82,6 +82,28 @@ Headings that moved or were added (layout only, not diffed):
82
82
 
83
83
  ## Sentence edits (modular "all" vs legacy)
84
84
 
85
+ Tool discovery and media. Deferred schemas must remain behind the existing
86
+ search router, while modular agents need to distinguish missing disclosure
87
+ from missing execution authority. Runtime mechanics explains ranked discovery
88
+ and literal-prefix recovery; the new `media` capability module directs image
89
+ and video requests to runtime/provider tools rather than integration setup.
90
+ Disabled media removes that module. The legacy prompt remains unchanged.
91
+
92
+ ```diff
93
+ + Deferred tool schemas are omitted from the first request; absence there does not prove a tool is unavailable.
94
+ + When deferred tools are attached, use `tool_search` for one focused capability at a time; broad searches with small limits can omit a relevant tool.
95
+ + If a search misses, use `tool_list` and follow `nextCursor` until the relevant authorized names are covered, then load exact names with `tool_search`.
96
+ + `namePrefix` is a literal tool-name prefix, not a capability keyword; an empty filtered page does not prove the capability is unavailable.
97
+ + Discovery never grants authority, and remembered tool names must still resolve against the current authorized catalog.
98
+ + Use an attached image-generation tool when the user requests generated images or edits.
99
+ + If it is deferred, use a focused image-generation query with `tool_search`, or request the exact `generate_image` name before concluding no image tool exists.
100
+ + `generate_image` is a runtime tool; integration catalogs and sandbox CLI lists do not enumerate it.
101
+ + A hosted `image_generation` tool may instead be visible directly on supported provider routes.
102
+ + The media capability permits discovery, but does not prove a provider adapter is attached; establish availability from the current authorized tool catalog.
103
+ + Use the returned tool schema for references and output controls; do not invent model names or provider-specific options.
104
+ + For video, discover `get_video_generation_capabilities` and `generate_video`, read current capabilities before selecting a model, and follow the available video-generation Skill.
105
+ ```
106
+
85
107
  Identity. The legacy text opened with two identity statements (the contract's
86
108
  generic line and the template's OpenGeni line). The default identity keeps the
87
109
  more specific one and adds the contract's collaboration and voice sentences,
@@ -13,6 +13,7 @@ import { rigModule, workspaceEnvironmentModule } from "./modules/environment";
13
13
  import { goalsModule } from "./modules/goals";
14
14
  import { knowledgeModule } from "./modules/knowledge";
15
15
  import { connectedMachineModule } from "./modules/machines";
16
+ import { mediaModule } from "./modules/media";
16
17
  import { rendererMarkdownModule } from "./modules/renderer-markdown";
17
18
  import { repositoriesModule } from "./modules/repositories";
18
19
  import { sandboxModule } from "./modules/sandbox";
@@ -33,6 +34,7 @@ export const AGENT_PROMPT_MODULES: readonly AgentPromptModule[] = [
33
34
  workspaceEnvironmentModule,
34
35
  rigModule,
35
36
  artifactsModule,
37
+ mediaModule,
36
38
  goalsModule,
37
39
  subagentsModule,
38
40
  knowledgeModule,
@@ -0,0 +1,20 @@
1
+ import { blocks, sentences, type AgentPromptModule } from "../types";
2
+
3
+ /** Media authorization is separate from provider attachment and tool disclosure. */
4
+ export const mediaModule: AgentPromptModule = {
5
+ id: "media",
6
+ applies: (context) => context.capabilities.media,
7
+ render: () =>
8
+ blocks(
9
+ "# Images and video",
10
+ sentences(
11
+ "Use an attached image-generation tool when the user requests generated images or edits.",
12
+ "If it is deferred, use a focused image-generation query with `tool_search`, or request the exact `generate_image` name before concluding no image tool exists.",
13
+ "`generate_image` is a runtime tool; integration catalogs and sandbox CLI lists do not enumerate it.",
14
+ "A hosted `image_generation` tool may instead be visible directly on supported provider routes.",
15
+ "The media capability permits discovery, but does not prove a provider adapter is attached; establish availability from the current authorized tool catalog.",
16
+ "Use the returned tool schema for references and output controls; do not invent model names or provider-specific options.",
17
+ "For video, discover `get_video_generation_capabilities` and `generate_video`, read current capabilities before selecting a model, and follow the available video-generation Skill.",
18
+ ),
19
+ ),
20
+ };
@@ -43,6 +43,14 @@ Command completion alone resumes you only while you have an explicit \`wait_for_
43
43
  export function renderRuntimeMechanics(context: AgentPromptContext): string {
44
44
  return blocks(
45
45
  "# Runtime mechanics",
46
+ "## Tool discovery",
47
+ sentences(
48
+ "Deferred tool schemas are omitted from the first request; absence there does not prove a tool is unavailable.",
49
+ "When deferred tools are attached, use `tool_search` for one focused capability at a time; broad searches with small limits can omit a relevant tool.",
50
+ "If a search misses, use `tool_list` and follow `nextCursor` until the relevant authorized names are covered, then load exact names with `tool_search`.",
51
+ "`namePrefix` is a literal tool-name prefix, not a capability keyword; an empty filtered page does not prove the capability is unavailable.",
52
+ "Discovery never grants authority, and remembered tool names must still resolve against the current authorized catalog.",
53
+ ),
46
54
  "## New messages while you work",
47
55
  newMessages(context),
48
56
  "## Waiting",
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { applyClaudeCodeIdentity } from "./claude-code-identity";
3
+ import { AnthropicRequestError } from "./anthropic-request-error";
3
4
  import {
4
5
  protocol,
5
6
  Usage,
@@ -8,7 +9,8 @@ import {
8
9
  type ModelResponse,
9
10
  type ResponseStreamEvent,
10
11
  } from "@openai/agents";
11
- import type { ResolvedModelProvider } from "@opengeni/config";
12
+ import { claudeNativeModelProfile, type ResolvedModelProvider } from "@opengeni/config";
13
+ import { withClaudeModelRequest } from "./claude-subscription-usage";
12
14
 
13
15
  type Json = Record<string, any>;
14
16
  type Message = { role: "user" | "assistant" | "system"; content: Json[] };
@@ -17,6 +19,75 @@ export class AnthropicProtocolError extends Error {
17
19
  readonly code = "anthropic_protocol_error";
18
20
  }
19
21
 
22
+ /** Closed provider rejections carry authored copy, never arbitrary response text. */
23
+ export class AnthropicProviderRejection extends Error {
24
+ readonly name = "AnthropicProviderRejection";
25
+ constructor(
26
+ readonly code:
27
+ | "anthropic_model_access_suspended"
28
+ | "anthropic_permission_denied"
29
+ | "content_policy_violation",
30
+ readonly status: number,
31
+ readonly request_id?: string,
32
+ readonly suspendedUntil?: string,
33
+ readonly headers: Record<string, string> = {},
34
+ ) {
35
+ super(
36
+ code === "anthropic_model_access_suspended"
37
+ ? suspendedUntil
38
+ ? `Claude suspended access to this model for the connected account until ${suspendedUntil.replace("T", " ").replace(/(?:\.000)?Z$/, " UTC")}. Try again after that time.`
39
+ : "Claude suspended access to this model for the connected account. Signing in again will not lift this restriction."
40
+ : code === "content_policy_violation"
41
+ ? "Claude blocked this request through its safety systems. Automatic retries stopped."
42
+ : "Claude denied this request (HTTP 403). Check the connected account's permissions.",
43
+ );
44
+ }
45
+ }
46
+
47
+ function suspensionDeadline(error: Json): string | undefined {
48
+ if (typeof error.message !== "string") return undefined;
49
+ const raw =
50
+ /^model: "[A-Za-z0-9._:/-]{1,128}" is suspended for this organization until (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)$/.exec(
51
+ error.message,
52
+ )?.[1];
53
+ if (!raw) return undefined;
54
+ const date = new Date(raw);
55
+ if (!Number.isFinite(date.getTime())) return undefined;
56
+ const normalized = date.toISOString();
57
+ return normalized.replace(/\.000Z$/, "Z") === raw.replace(/\.000Z$/, "Z")
58
+ ? normalized
59
+ : undefined;
60
+ }
61
+
62
+ function providerRejection(
63
+ status: number,
64
+ detail: unknown,
65
+ responseHeaders: Headers,
66
+ ): AnthropicProviderRejection | undefined {
67
+ if (status !== 403) return undefined;
68
+ const requestId = responseHeaders.get("request-id") ?? undefined;
69
+ const headers = responseHeaders.has("retry-after")
70
+ ? { "retry-after": responseHeaders.get("retry-after")! }
71
+ : {};
72
+ const error =
73
+ detail && typeof detail === "object" && !Array.isArray(detail) ? (detail as Json) : {};
74
+ if (error.type === "permission_error" && error.details?.error_code === "model_access_suspended")
75
+ return new AnthropicProviderRejection(
76
+ "anthropic_model_access_suspended",
77
+ status,
78
+ requestId,
79
+ suspensionDeadline(error),
80
+ headers,
81
+ );
82
+ return new AnthropicProviderRejection(
83
+ "anthropic_permission_denied",
84
+ status,
85
+ requestId,
86
+ undefined,
87
+ headers,
88
+ );
89
+ }
90
+
20
91
  function object(value: unknown): Json {
21
92
  if (!value || typeof value !== "object" || Array.isArray(value)) {
22
93
  throw new AnthropicProtocolError("Expected an object in the Claude protocol");
@@ -232,10 +303,51 @@ export function anthropicMessages(input: ModelRequest["input"]): Message[] {
232
303
  return messages;
233
304
  }
234
305
 
306
+ /**
307
+ * Compaction retains user/system inputs but removes their intervening assistant
308
+ * replies. Anthropic's system beta requires a system message after a user and
309
+ * before an assistant (or at the end), not user -> system -> user. Coalesce each
310
+ * user phase and its system blocks at that boundary, retaining their own roles,
311
+ * exact content and relative order. A machine-only continuation after an
312
+ * assistant needs a request-local input anchor; it is not a new human message.
313
+ * Never move a system across an assistant.
314
+ */
315
+ function placeConversationSystems(messages: Message[]): Message[] {
316
+ const result: Message[] = [];
317
+ let systems: Json[] = [];
318
+ const flush = () => {
319
+ if (!systems.length) return;
320
+ if (result.at(-1)?.role !== "user")
321
+ result.push({
322
+ role: "user",
323
+ content: [
324
+ {
325
+ type: "text",
326
+ text: "OpenGeni continuation (machine-origin input; no new human message).",
327
+ },
328
+ ],
329
+ });
330
+ result.push({ role: "system", content: systems });
331
+ systems = [];
332
+ };
333
+ for (const message of messages) {
334
+ if (message.role === "system") {
335
+ systems.push(...message.content);
336
+ continue;
337
+ }
338
+ if (message.role === "assistant") flush();
339
+ const previous = result.at(-1);
340
+ if (previous?.role === message.role) previous.content.push(...message.content);
341
+ else result.push(message);
342
+ }
343
+ flush();
344
+ return result;
345
+ }
346
+
235
347
  export function buildAnthropicRequest(
236
348
  request: ModelRequest,
237
349
  model: string,
238
- provider: Pick<ResolvedModelProvider, "anthropic">,
350
+ provider: Pick<ResolvedModelProvider, "anthropic"> & Partial<Pick<ResolvedModelProvider, "kind">>,
239
351
  stream: boolean,
240
352
  ): Json {
241
353
  if (request.previousResponseId || request.conversationId)
@@ -245,6 +357,13 @@ export function buildAnthropicRequest(
245
357
  if (request.prompt)
246
358
  throw new AnthropicProtocolError("OpenAI prompt templates cannot be used with Claude");
247
359
  const settings = request.modelSettings;
360
+ const profile = claudeNativeModelProfile(model);
361
+ const managed =
362
+ provider.kind === "anthropic-workspace" ||
363
+ provider.kind === "anthropic-organization" ||
364
+ provider.kind === "claude-subscription-workspace" ||
365
+ provider.kind === "claude-subscription-organization";
366
+ const outputLimit = profile?.maxOutputTokens ?? (managed ? 32_000 : undefined);
248
367
  const names = toolNames(request);
249
368
  const forcedName = (name: string): string => {
250
369
  const matches = [...names].filter(([, identity]) => identity.name === name);
@@ -254,16 +373,16 @@ export function buildAnthropicRequest(
254
373
  );
255
374
  return matches[0]![0];
256
375
  };
257
- const messages = anthropicMessages(request.input);
376
+ let messages = anthropicMessages(request.input);
258
377
  if (!messages.length) throw new AnthropicProtocolError("Claude requires at least one message");
259
378
  const system = request.systemInstructions
260
379
  ? ([{ type: "text", text: request.systemInstructions }] as Json[])
261
380
  : [];
262
- // Even with mid-conversation-system enabled, initial system/developer
263
- // instructions belong in the top-level system field. Keep later system
264
- // messages at their original history position.
381
+ // Initial system/developer instructions belong in the top-level field. Later
382
+ // systems keep their authority within the same assistant-delimited phase.
265
383
  while (messages[0]?.role === "system") system.push(...messages.shift()!.content);
266
384
  if (!messages.length) throw new AnthropicProtocolError("Claude requires a conversation message");
385
+ messages = placeConversationSystems(messages);
267
386
  const tools: Json[] = request.tools.map((tool) => {
268
387
  if (tool.type !== "function")
269
388
  throw new AnthropicProtocolError(`Claude does not support the ${tool.type} tool transport`);
@@ -308,7 +427,13 @@ export function buildAnthropicRequest(
308
427
  }
309
428
  const body: Json = {
310
429
  model,
311
- max_tokens: settings.maxTokens ?? provider.anthropic?.maxOutputTokens ?? 32000,
430
+ max_tokens: Math.min(
431
+ settings.maxTokens ??
432
+ provider.anthropic?.maxOutputTokens ??
433
+ profile?.maxOutputTokens ??
434
+ 32000,
435
+ outputLimit ?? Infinity,
436
+ ),
312
437
  messages,
313
438
  stream,
314
439
  };
@@ -326,13 +451,20 @@ export function buildAnthropicRequest(
326
451
  ...(body.tool_choice ?? { type: "auto" }),
327
452
  disable_parallel_tool_use: true,
328
453
  };
329
- const effort = settings.reasoning?.effort;
454
+ const requestedEffort = settings.reasoning?.effort;
455
+ // Preserve actual native levels; do not silently turn Extra into High.
456
+ const effort = requestedEffort === "minimal" ? "low" : requestedEffort;
457
+ const supportsThinking = profile ? profile.efforts.length > 0 : !managed;
330
458
  // Anthropic forbids forced tool selection together with thinking.
331
459
  const forcedTool = body.tool_choice?.type === "any" || body.tool_choice?.type === "tool";
332
- if (effort && effort !== "none" && !forcedTool) {
460
+ if (supportsThinking && effort && effort !== "none" && !forcedTool) {
461
+ if (profile && !profile.efforts.includes(effort))
462
+ throw new AnthropicProtocolError(
463
+ "The selected Claude model does not support this reasoning effort",
464
+ );
333
465
  body.thinking = { type: "adaptive" };
334
466
  body.output_config = {
335
- effort: effort === "minimal" ? "low" : effort === "xhigh" ? "high" : effort,
467
+ effort,
336
468
  };
337
469
  } else if (settings.temperature !== undefined) body.temperature = settings.temperature;
338
470
  if (settings.topP !== undefined && !body.thinking) body.top_p = settings.topP;
@@ -375,11 +507,11 @@ export function anthropicResponse(
375
507
  requestId?: string,
376
508
  names = new Map<string, { name: string; namespace?: string }>(),
377
509
  ): ModelResponse {
378
- if (
379
- !["end_turn", "tool_use", "stop_sequence", "max_tokens", "refusal"].includes(
380
- message.stop_reason,
381
- )
382
- )
510
+ // A refused response can be HTTP 200 with no content blocks. It must not
511
+ // become an empty successful response or admit a tool call from partial output.
512
+ if (message.stop_reason === "refusal")
513
+ throw new AnthropicProviderRejection("content_policy_violation", 200, requestId);
514
+ if (!["end_turn", "tool_use", "stop_sequence", "max_tokens"].includes(message.stop_reason))
383
515
  throw new AnthropicProtocolError(`Claude response did not finish: ${message.stop_reason}`);
384
516
  text(message.id, "message ID");
385
517
  if (!Array.isArray(message.content))
@@ -461,6 +593,7 @@ export class AnthropicMessagesModel implements Model {
461
593
  ) {}
462
594
 
463
595
  private async send(request: ModelRequest, stream: boolean): Promise<Response> {
596
+ request.signal?.throwIfAborted();
464
597
  const body = buildAnthropicRequest(request, this.model, this.provider, stream);
465
598
  const base = this.provider.baseUrl ?? "https://api.anthropic.com/v1";
466
599
  const url = new URL(`${base.replace(/\/$/, "")}/messages`);
@@ -470,6 +603,11 @@ export class AnthropicMessagesModel implements Model {
470
603
  headers.set("content-type", "application/json");
471
604
  headers.set("accept", stream ? "text/event-stream" : "application/json");
472
605
  headers.set("anthropic-version", "2023-06-01");
606
+ if ((claudeNativeModelProfile(this.model)?.contextWindowTokens ?? 0) > 200_000) {
607
+ const betas = new Set((headers.get("anthropic-beta") ?? "").split(",").filter(Boolean));
608
+ betas.add("context-1m-2025-08-07");
609
+ headers.set("anthropic-beta", [...betas].join(","));
610
+ }
473
611
  if (body.messages.some((message: Message) => message.role === "system")) {
474
612
  const betas = new Set((headers.get("anthropic-beta") ?? "").split(",").filter(Boolean));
475
613
  betas.add("mid-conversation-system-2026-04-07");
@@ -506,12 +644,18 @@ export class AnthropicMessagesModel implements Model {
506
644
  previousRequestId: this.previousRequestId,
507
645
  });
508
646
  }
509
- const response = await this.fetch(url, {
510
- method: "POST",
511
- headers,
512
- body: JSON.stringify(body),
513
- ...(request.signal ? { signal: request.signal } : {}),
514
- });
647
+ const response = await withClaudeModelRequest(this.model, () =>
648
+ this.fetch(url, {
649
+ method: "POST",
650
+ headers,
651
+ body: JSON.stringify(body),
652
+ ...(request.signal ? { signal: request.signal } : {}),
653
+ }),
654
+ );
655
+ if (request.signal?.aborted) {
656
+ void response.body?.cancel().catch(() => undefined);
657
+ request.signal.throwIfAborted();
658
+ }
515
659
  if (!response.ok) {
516
660
  const detail = await readErrorDetail(
517
661
  response.body,
@@ -521,20 +665,33 @@ export class AnthropicMessagesModel implements Model {
521
665
  const contextExceeded =
522
666
  response.status === 400 &&
523
667
  /prompt is too long|context_length_exceeded|exceeds.*context window/i.test(detail);
668
+ let providerError: unknown;
669
+ try {
670
+ providerError = JSON.parse(detail).error;
671
+ } catch {
672
+ /* Diagnostics can be truncated. */
673
+ }
674
+ const rejection = providerRejection(response.status, providerError, response.headers);
675
+ if (rejection) throw rejection;
524
676
  // Classify the bounded provider detail without leaking echoed prompts or credentials.
525
677
  const message = contextExceeded
526
678
  ? "Claude context window exceeded"
527
679
  : response.status === 401
528
680
  ? "Claude credentials expired or were revoked. Replace the key or setup token in Models."
529
681
  : "Claude request failed (HTTP " + response.status + ")";
530
- throw Object.assign(new Error(message), {
531
- status: response.status,
532
- request_id: response.headers.get("request-id"),
533
- headers: response.headers.has("retry-after")
534
- ? { "retry-after": response.headers.get("retry-after")! }
535
- : {},
536
- code: contextExceeded ? "context_length_exceeded" : "anthropic_http_error",
537
- });
682
+ let source: unknown;
683
+ try {
684
+ source = JSON.parse(detail)?.error;
685
+ } catch {
686
+ // Malformed, truncated or non-JSON bodies retain structural status only.
687
+ }
688
+ throw new AnthropicRequestError(
689
+ message,
690
+ response.status,
691
+ contextExceeded ? "context_length_exceeded" : "anthropic_http_error",
692
+ source,
693
+ response.headers,
694
+ );
538
695
  }
539
696
  this.previousRequestId = response.headers.get("request-id") ?? undefined;
540
697
  return response;
@@ -558,6 +715,7 @@ export class AnthropicMessagesModel implements Model {
558
715
  for await (const event of anthropicSse(
559
716
  response.body,
560
717
  this.provider.anthropic?.streamIdleTimeoutMs ?? 600000,
718
+ request.signal,
561
719
  )) {
562
720
  request.signal?.throwIfAborted();
563
721
  if (event.type === "error") {
@@ -574,6 +732,8 @@ export class AnthropicMessagesModel implements Model {
574
732
  overloaded_error: 529,
575
733
  };
576
734
  const status = typeof kind === "string" ? (statuses[kind] ?? 502) : 502;
735
+ const rejection = providerRejection(status, event.error, response.headers);
736
+ if (rejection) throw rejection;
577
737
  throw Object.assign(new Error(`Claude stream failed (HTTP ${status})`), {
578
738
  status,
579
739
  code: status === 429 ? "rate_limit_exceeded" : "anthropic_stream_error",
@@ -581,6 +741,15 @@ export class AnthropicMessagesModel implements Model {
581
741
  headers: response.headers.has("retry-after")
582
742
  ? { "retry-after": response.headers.get("retry-after")! }
583
743
  : {},
744
+ // Keep the structural stream wrapper stable for provider rejection
745
+ // guards while the typed cause keeps provider text private.
746
+ cause: new AnthropicRequestError(
747
+ `Claude stream failed (HTTP ${status})`,
748
+ status,
749
+ status === 429 ? "rate_limit_exceeded" : "anthropic_stream_error",
750
+ event.error,
751
+ response.headers,
752
+ ),
584
753
  });
585
754
  }
586
755
  switch (event.type) {
@@ -641,6 +810,12 @@ export class AnthropicMessagesModel implements Model {
641
810
  if (!message) throw new AnthropicProtocolError("Claude message_delta without a message");
642
811
  Object.assign(message, event.delta);
643
812
  message.usage = { ...message.usage, ...event.usage };
813
+ if (message.stop_reason === "refusal")
814
+ throw new AnthropicProviderRejection(
815
+ "content_policy_violation",
816
+ 200,
817
+ response.headers.get("request-id") ?? undefined,
818
+ );
644
819
  finalDelta = true;
645
820
  break;
646
821
  case "message_stop": {
@@ -670,23 +845,39 @@ export class AnthropicMessagesModel implements Model {
670
845
  export async function* anthropicSse(
671
846
  body: ReadableStream<Uint8Array>,
672
847
  idleTimeoutMs = 600000,
848
+ signal?: AbortSignal,
673
849
  ): AsyncGenerator<Json> {
850
+ if (signal?.aborted) void body.cancel().catch(() => undefined);
851
+ signal?.throwIfAborted();
674
852
  const reader = body.getReader();
675
853
  const decoder = new TextDecoder();
676
854
  let buffer = "";
677
855
  let data: string[] = [];
678
856
  try {
679
857
  while (true) {
858
+ signal?.throwIfAborted();
680
859
  let timer: ReturnType<typeof setTimeout> | undefined;
860
+ let onAbort: (() => void) | undefined;
861
+ const aborted = new Promise<never>((_, reject) => {
862
+ if (signal) {
863
+ onAbort = () => reject(signal.reason);
864
+ signal.addEventListener("abort", onAbort, { once: true });
865
+ if (signal.aborted) onAbort();
866
+ }
867
+ });
681
868
  const chunk = await Promise.race([
682
869
  reader.read(),
870
+ aborted,
683
871
  new Promise<never>((_, reject) => {
684
872
  timer = setTimeout(
685
873
  () => reject(new AnthropicProtocolError("Claude stream timed out waiting for data")),
686
874
  idleTimeoutMs,
687
875
  );
688
876
  }),
689
- ]).finally(() => clearTimeout(timer));
877
+ ]).finally(() => {
878
+ clearTimeout(timer);
879
+ if (signal && onAbort) signal.removeEventListener("abort", onAbort);
880
+ });
690
881
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });
691
882
  let newline: number;
692
883
  while ((newline = buffer.indexOf("\n")) >= 0) {
@@ -700,7 +891,8 @@ export async function* anthropicSse(
700
891
  if (chunk.done) break;
701
892
  }
702
893
  } finally {
703
- await reader.cancel().catch(() => undefined);
894
+ // A terminal rejection must not wait on untrusted transport cleanup.
895
+ void reader.cancel().catch(() => undefined);
704
896
  reader.releaseLock();
705
897
  }
706
898
  }
@@ -0,0 +1,45 @@
1
+ import { Buffer } from "node:buffer";
2
+
3
+ function bounded(value: unknown, maxBytes: number): string | undefined {
4
+ if (typeof value !== "string" || !value) return undefined;
5
+ const bytes = Buffer.from(value);
6
+ if (bytes.length <= maxBytes) return value;
7
+ const marker = "… [truncated]";
8
+ let end = maxBytes - Buffer.byteLength(marker);
9
+ while (end > 0 && (bytes[end]! & 0xc0) === 0x80) end -= 1;
10
+ return bytes.subarray(0, end).toString("utf8") + marker;
11
+ }
12
+
13
+ /** Structural log message; only the durable failure payload reads provider detail. */
14
+ export class AnthropicRequestError extends Error {
15
+ readonly request_id: string | null;
16
+ readonly headers: Record<string, string>;
17
+ #detail: string | undefined;
18
+
19
+ constructor(
20
+ message: string,
21
+ readonly status: number,
22
+ readonly code: string,
23
+ source: unknown,
24
+ headers: Headers,
25
+ ) {
26
+ super(message);
27
+ this.name = "AnthropicRequestError";
28
+ const error =
29
+ source && typeof source === "object" && !Array.isArray(source)
30
+ ? (source as Record<string, unknown>)
31
+ : undefined;
32
+ const type = bounded(error?.type, 256);
33
+ const detail = bounded(error?.message, 4096);
34
+ // Select only error.type/message, never the raw body, echoed request fields,
35
+ // cookies, or outgoing content. Keep diagnostics out of generic serialization.
36
+ this.#detail = bounded([type, detail].filter(Boolean).join(": "), 4096);
37
+ this.request_id = bounded(headers.get("request-id"), 256) ?? null;
38
+ const retryAfter = bounded(headers.get("retry-after"), 256);
39
+ this.headers = retryAfter ? { "retry-after": retryAfter } : {};
40
+ }
41
+
42
+ get detail(): string | undefined {
43
+ return this.#detail;
44
+ }
45
+ }
@@ -63,13 +63,15 @@ Embedded products may narrow bundled guidance with `bundledSkillIds`.
63
63
  Continue useful discovery without requesting broad credentials or pretending
64
64
  missing access is configured. Read
65
65
  [Discovery and autonomy](references/discovery-and-autonomy.md) for that workflow.
66
- - Four choices belong to the user: who shares what (`chats`: private, shared
67
- or isolated), when things run (schedule and time zone), where outputs land
68
- (which screen, record, or channel), and whether the agent may write. If the
69
- request or repository does not settle any of them, ask ONE bundled question
70
- (the structured question UI when available) with a recommended answer for
71
- each, before building. This is the expected step, not an option: asking once
72
- is cheap, rebuilding is not. Never ask what the repository answers. See
66
+ - Two choices belong to the user: who can see a chat (`chats`: private, shared
67
+ or isolated) and whether the agent may change data. If the request or
68
+ repository does not settle one, ask ONE short, plain-language question with a
69
+ recommended answer, before building. Never ask what the repository answers.
70
+ - Do not ask about background work, schedules, session length or credential
71
+ lifetime up front. Long agent sessions just work. Only when the requested
72
+ feature itself is scheduled or runs in the background (for example "email me
73
+ a weekly report"), ask for the missing details (day, time, time zone, where
74
+ results appear) and say in one sentence why you need them. See
73
75
  [Discovery and autonomy](references/discovery-and-autonomy.md).
74
76
  - Use a reversible, clearly stated default only for choices outside those four,
75
77
  or when the user explicitly said not to ask. A busy user is not that signal.
@@ -77,7 +77,7 @@ session a short-lived per-user bearer and rotate it on every message:
77
77
  never send credential updates itself.
78
78
  4. The MCP server validates the token and enforces the user's own permissions.
79
79
 
80
- Make the token outlive one turn (agents can work for many minutes). Header
80
+ Make the token outlive long agent work by default (hours, refreshed on every message); this is a default, never a question for the user. Header
81
81
  rotation cannot change the server's URL or tools. Scheduled tasks cannot carry
82
82
  inline `mcpServers`; background agents use a workspace MCP connection or
83
83
  OpenAPI Integration instead.
@@ -203,6 +203,67 @@ Beyond `initialMessage`/`tools`/`resources`, the create body (`POST /v1/workspac
203
203
  - Session creation exposes a workspace-scoped `idempotencyKey` (distinct from the per-call `clientEventId`): forward a stable value so concurrent/retried creates of the same logical session collapse to a single session. Without it every create is independent, so a blind retry can double-create — keep sending a stable key when you retry.
204
204
  - Treat unknown event types as extensible timeline entries, not client crashes.
205
205
 
206
+ ### Read a settled result without caching progress or losing text
207
+
208
+ `agent.message.completed` means one message ended, not that the turn completed.
209
+ `phase: "commentary"` is progress; even `final_answer` text precedes canonical
210
+ settlement. For a text-result cache, wait for the expected logical turn's
211
+ `turn.completed` and use its original `payload.output`. Empty output or
212
+ `emptyFinalReply` is not a successful report; a wait-ended turn may contain
213
+ `reply` for human attention without a result. Handle failure, cancellation,
214
+ approval and human-input states separately. Do not mark the whole session/goal
215
+ complete merely because one turn settled.
216
+
217
+ REST `listEvents` defaults to a bounded monitoring/summary projection. A compact
218
+ latest result is also a projection and can truncate long text. For an exact
219
+ saved result, use bounded `listEventPage` reads of original events, check
220
+ `forensicExact`, and follow the returned cursor, not a guessed sequence:
221
+
222
+ ```js
223
+ async function readOriginalResultPage(client, workspaceId, sessionId, after) {
224
+ const page = await client.listEventPage(workspaceId, sessionId, {
225
+ mode: "forensic", payloadMode: "full", direction: "after", after, limit: 50,
226
+ includeTypes: ["agent.message.completed", "turn.completed", "turn.failed",
227
+ "turn.cancelled", "session.requiresAction", "session.humanInput.requested"],
228
+ });
229
+ if (!page.forensicExact ||
230
+ (page.hasMore && (!Number.isSafeInteger(page.nextAfter) || page.nextAfter <= after))) {
231
+ throw new Error("An exact result page is not available.");
232
+ }
233
+ return page; // truncated/hasMore may mean more exact events, not truncated text
234
+ }
235
+
236
+ function settledTurnResult(event, expectedTurnId) {
237
+ if (event.turnId !== expectedTurnId ||
238
+ ["late_rejected", "duplicate"].includes(event.turnAssociation)) return null;
239
+ if (event.type === "turn.failed" || event.type === "turn.cancelled") {
240
+ return { state: event.type === "turn.failed" ? "failed" : "cancelled", text: null };
241
+ }
242
+ if (event.type !== "turn.completed") return null;
243
+ const payload = event.payload ?? {};
244
+ if (payload.emptyFinalReply === true || typeof payload.output !== "string" || !payload.output.trim()) {
245
+ return { state: "settled_without_result", text: null };
246
+ }
247
+ return { state: "completed", text: payload.output };
248
+ }
249
+ ```
250
+
251
+ Keep the newest candidate plus its original event ID/sequence while following
252
+ `hasMore`; publish/cache only after reaching the current page boundary. Preserve
253
+ the original full text rather than stripping a summary's truncation marker.
254
+ If exact retrieval is unavailable, report that explicitly; do not cache a
255
+ snippet as the final answer. Render only authorized result fields, never raw
256
+ forensic payloads. The existing UI/facade projections are preferable when they
257
+ already fit; this is a narrow backend text-result example, not a new timeline.
258
+
259
+ Use a background job or nonblocking bounded status ticks when a turn's tools
260
+ call back into the same host. Holding every scarce synchronous request worker
261
+ in a blocking wait can starve those callbacks under concurrency; it is a
262
+ deployment-dependent risk, not an inevitable failure. Bound each read/deadline,
263
+ retain callback capacity, and resume from the saved cursor. For static pages,
264
+ initialize enhancement scripts after their DOM targets exist (`defer`, modules
265
+ or `DOMContentLoaded`); retain the native form/POST fallback.
266
+
206
267
  ## Files
207
268
 
208
269
  The usual flow is: