@anteros/core 0.0.42 → 0.0.44

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 (55) hide show
  1. package/database/replication.ts +614 -26
  2. package/lib/agent.ts +279 -39
  3. package/lib/agentMemory.ts +103 -0
  4. package/lib/agents.ts +25 -2
  5. package/lib/define.ts +1 -1
  6. package/lib/mcpClient.ts +128 -0
  7. package/lib/providers.ts +169 -66
  8. package/lib/skills.ts +481 -0
  9. package/package.json +1 -1
  10. package/server/agents.ts +27 -6
  11. package/server/api.ts +74 -1
  12. package/tests/agent-mcp.test.ts +116 -0
  13. package/tests/agent-memory.test.ts +6 -6
  14. package/tests/agent-provider.test.ts +288 -0
  15. package/tests/agent-runtime.test.ts +369 -0
  16. package/tests/agent-skills.test.ts +177 -0
  17. package/tests/agent-summarize.test.ts +124 -0
  18. package/tests/agent-working-memory.test.ts +5 -3
  19. package/tests/agent.test.ts +18 -16
  20. package/tests/agents-api.test.ts +1 -1
  21. package/tests/api-private-fields.test.ts +196 -0
  22. package/tests/file-collection-replication.test.ts +2 -0
  23. package/tests/fixtures/agent-tenant/agents/support.agent.ts +1 -1
  24. package/tests/fixtures/agent-tenant/agents/weather.agent.ts +1 -1
  25. package/tests/fixtures/agents-api/agents/assistant.agent.ts +1 -1
  26. package/tests/fixtures/agents-api/agents/plain.agent.ts +1 -1
  27. package/tests/fixtures/agents-api/agents/private.agent.ts +1 -1
  28. package/tests/fixtures/repl-only/app.ts +3 -0
  29. package/tests/fixtures/replication-meta/agents/chat.agent.ts +1 -1
  30. package/tests/fixtures/skills-tenant/agents/docs.agent.ts +23 -0
  31. package/tests/fixtures/skills-tenant/agents/incomplete.agent.ts +14 -0
  32. package/tests/fixtures/skills-tenant/broken/bad-name.md +6 -0
  33. package/tests/fixtures/skills-tenant/broken/dup-a.md +6 -0
  34. package/tests/fixtures/skills-tenant/broken/dup-b.md +6 -0
  35. package/tests/fixtures/skills-tenant/broken/empty-body.md +4 -0
  36. package/tests/fixtures/skills-tenant/broken/long-compat.md +7 -0
  37. package/tests/fixtures/skills-tenant/broken/long-description.md +6 -0
  38. package/tests/fixtures/skills-tenant/broken/mismatch/SKILL.md +6 -0
  39. package/tests/fixtures/skills-tenant/broken/no-description.md +5 -0
  40. package/tests/fixtures/skills-tenant/broken/no-name.md +5 -0
  41. package/tests/fixtures/skills-tenant/broken/plain.md +4 -0
  42. package/tests/fixtures/skills-tenant/broken/unknown-key.md +9 -0
  43. package/tests/fixtures/skills-tenant/docs/pdf-forms.md +18 -0
  44. package/tests/fixtures/skills-tenant/guide/ml.md +9 -0
  45. package/tests/fixtures/skills-tenant/guides/pdf/SKILL.md +9 -0
  46. package/tests/fixtures/skills-tenant/runbooks/incident.md +9 -0
  47. package/tests/fixtures/skills-tenant/runbooks/notes.txt +2 -0
  48. package/tests/replication-change-stream.test.ts +240 -0
  49. package/tests/replication-control.test.ts +2 -0
  50. package/tests/replication-initial-sync.test.ts +2 -0
  51. package/tests/replication-meta.test.ts +3 -0
  52. package/tests/replication.test.ts +2 -0
  53. package/tests/vars-replication.test.ts +2 -0
  54. package/types/agent.d.ts +237 -7
  55. package/types/replication.d.ts +41 -4
package/lib/providers.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AppError } from "./error";
2
+ import { logger } from "../utils/logger";
2
3
  import type {
3
- AgentCompatible,
4
+ AgentProtocol,
4
5
  AgentMediaPart,
5
6
  AgentMessage,
6
7
  AgentProvider,
@@ -48,6 +49,12 @@ export type ModelChatRequest = {
48
49
  signal?: AbortSignal;
49
50
  /** Ask for a JSON object (OpenAI-compatible providers: `response_format`). */
50
51
  json?: boolean;
52
+ /**
53
+ * Ask for a JSON object **matching this schema** — what the provider can really
54
+ * enforce: `response_format.json_schema` on OpenAI-compatible gateways, a forced
55
+ * tool on Anthropic (which has no such field). Takes precedence over `json`.
56
+ */
57
+ jsonSchema?: { name: string; description?: string; schema: any };
51
58
  };
52
59
 
53
60
  export type ModelChatResponse = {
@@ -76,18 +83,19 @@ export type ModelStreamEvent =
76
83
  | { type: "finish"; finishReason: string };
77
84
 
78
85
  export type ModelAdapter = {
79
- compatible: AgentCompatible;
86
+ /** The wire protocol this adapter speaks. */
87
+ protocol: AgentProtocol;
80
88
  model: string;
81
89
  chat(req: ModelChatRequest): Promise<ModelChatResponse>;
82
90
  chatStream(req: ModelChatRequest): AsyncGenerator<ModelStreamEvent, void, unknown>;
83
91
  };
84
92
 
85
- const DEFAULT_BASE_URL: Record<AgentCompatible, string> = {
93
+ const DEFAULT_BASE_URL: Record<AgentProtocol, string> = {
86
94
  openai: "https://api.openai.com/v1",
87
95
  anthropic: "https://api.anthropic.com",
88
96
  };
89
97
 
90
- const ENV_API_KEY: Record<AgentCompatible, string[]> = {
98
+ const ENV_API_KEY: Record<AgentProtocol, string[]> = {
91
99
  openai: ["ANTEROS_AI_API_KEY", "OPENAI_API_KEY"],
92
100
  anthropic: ["ANTEROS_AI_API_KEY", "ANTHROPIC_API_KEY"],
93
101
  };
@@ -100,25 +108,33 @@ const EMPTY_USAGE: AgentUsage = { inputTokens: 0, outputTokens: 0, totalTokens:
100
108
 
101
109
  // ─── Configuration resolution ────────────────────────────────────────────
102
110
 
103
- function resolveCompatible(provider: AgentProvider): AgentCompatible {
104
- const compatible = provider.compatible ?? "openai";
105
- if (compatible !== "openai" && compatible !== "anthropic") {
106
- throw new AppError(`Unknown provider 'compatible': '${compatible}' — expected 'openai' or 'anthropic'`, {
111
+ /**
112
+ * The wire protocol to speak. `protocol` is canonical; `compatible` is the
113
+ * deprecated alias kept for definitions written before the rename — `protocol`
114
+ * wins when both are set, so an override never silently loses to the old field.
115
+ */
116
+ function resolveProtocol(provider: AgentProvider): AgentProtocol {
117
+ const protocol = provider.protocol ?? provider.compatible ?? "openai";
118
+ if (protocol !== "openai" && protocol !== "anthropic") {
119
+ throw new AppError(`Unknown provider 'protocol': '${protocol}' — expected 'openai' or 'anthropic'`, {
107
120
  status: 500,
108
121
  code: "AGENT_PROVIDER_INVALID",
109
122
  });
110
123
  }
111
- return compatible;
124
+ return protocol;
112
125
  }
113
126
 
114
- function resolveApiKey(provider: AgentProvider, compatible: AgentCompatible): string {
127
+ /** @deprecated Renamed to `resolveProtocol`. */
128
+ const resolveCompatible = resolveProtocol;
129
+
130
+ function resolveApiKey(provider: AgentProvider, protocol: AgentProtocol): string {
115
131
  if (provider.apiKey) return provider.apiKey;
116
- for (const name of ENV_API_KEY[compatible]) {
132
+ for (const name of ENV_API_KEY[protocol]) {
117
133
  const value = Bun.env?.[name] ?? process.env?.[name];
118
134
  if (value) return value;
119
135
  }
120
136
  throw new AppError(
121
- `No API key for provider '${provider.model}' (compatible: ${compatible}) — pass \`provider.apiKey\` or set ${ENV_API_KEY[compatible].join(" / ")}`,
137
+ `No API key for provider '${provider.model}' (protocol: ${protocol}) — pass \`provider.apiKey\` or set ${ENV_API_KEY[protocol].join(" / ")}`,
122
138
  { status: 500, code: "AGENT_PROVIDER_NO_API_KEY" },
123
139
  );
124
140
  }
@@ -128,10 +144,10 @@ function resolveApiKey(provider: AgentProvider, compatible: AgentCompatible): st
128
144
  * (`http://localhost:11434` → `http://localhost:11434/v1` for OpenAI-compatible
129
145
  * servers), an explicit path is used verbatim.
130
146
  */
131
- function resolveBaseUrl(provider: AgentProvider, compatible: AgentCompatible): string {
132
- const raw = provider.baseUrl ?? DEFAULT_BASE_URL[compatible];
147
+ function resolveBaseUrl(provider: AgentProvider, protocol: AgentProtocol): string {
148
+ const raw = provider.baseUrl ?? DEFAULT_BASE_URL[protocol];
133
149
  const trimmed = raw.replace(/\/+$/, "");
134
- if (compatible !== "openai") return trimmed;
150
+ if (protocol !== "openai") return trimmed;
135
151
  try {
136
152
  const url = new URL(trimmed);
137
153
  if (url.pathname === "" || url.pathname === "/") url.pathname = "/v1";
@@ -335,6 +351,14 @@ function thinkingOf(thinking?: AgentThinking): { effort: "low" | "medium" | "hig
335
351
  return { effort, budgetTokens };
336
352
  }
337
353
 
354
+ /** The maximum, forced-tool name an Anthropic run uses for structured output. */
355
+ const SCHEMA_TOOL = "structured_output";
356
+ /**
357
+ * A gateway that does not know `response_format.json_schema` refuses the request
358
+ * with a 400 that names it — that is the only case we downgrade for.
359
+ */
360
+ const SCHEMA_UNSUPPORTED = /response_format|json_schema|json mode/i;
361
+
338
362
  /** `tool_choice` — the one place the two vocabularies differ. */
339
363
  function openaiToolChoice(choice?: AgentToolChoice): any | undefined {
340
364
  if (!choice || choice === "auto") return undefined;
@@ -372,10 +396,10 @@ function normalizeFinishReason(reason: string | null | undefined): string {
372
396
  }
373
397
  }
374
398
 
375
- function mapUsage(raw: any, compatible: AgentCompatible): AgentUsage {
399
+ function mapUsage(raw: any, protocol: AgentProtocol): AgentUsage {
376
400
  if (!raw) return { ...EMPTY_USAGE };
377
- const input = compatible === "anthropic" ? raw.input_tokens : raw.prompt_tokens;
378
- const output = compatible === "anthropic" ? raw.output_tokens : raw.completion_tokens;
401
+ const input = protocol === "anthropic" ? raw.input_tokens : raw.prompt_tokens;
402
+ const output = protocol === "anthropic" ? raw.output_tokens : raw.completion_tokens;
379
403
  const inputTokens = Number(input ?? 0);
380
404
  const outputTokens = Number(output ?? 0);
381
405
  return {
@@ -407,7 +431,7 @@ async function fetchWithRetry(
407
431
  url: string,
408
432
  init: RequestInit,
409
433
  provider: AgentProvider,
410
- compatible: AgentCompatible,
434
+ protocol: AgentProtocol,
411
435
  ): Promise<Response> {
412
436
  const options = provider.options ?? {};
413
437
  const retries = options.retries ?? DEFAULT_RETRIES;
@@ -425,7 +449,7 @@ async function fetchWithRetry(
425
449
  lastError = new AppError(`Provider error (${response.status}): ${providerErrorMessage(body, response.status)}`, {
426
450
  status: 502,
427
451
  code: "AGENT_PROVIDER_ERROR",
428
- reason: { provider: compatible, status: response.status },
452
+ reason: { provider: protocol, status: response.status },
429
453
  });
430
454
  } else {
431
455
  return response;
@@ -487,30 +511,30 @@ async function* sseData(body: ReadableStream<Uint8Array>): AsyncGenerator<string
487
511
  }
488
512
 
489
513
  /** An error event sent by the provider in the middle of a stream. */
490
- function streamError(payload: any, compatible: AgentCompatible): AppError | null {
514
+ function streamError(payload: any, protocol: AgentProtocol): AppError | null {
491
515
  const message = payload?.error?.message ?? (typeof payload?.error === "string" ? payload.error : null);
492
516
  if (!message) return null;
493
517
  return new AppError(`Provider stream error: ${message}`, {
494
518
  status: 502,
495
519
  code: "AGENT_PROVIDER_ERROR",
496
- reason: { provider: compatible },
520
+ reason: { provider: protocol },
497
521
  });
498
522
  }
499
523
 
500
524
  // ─── OpenAI-compatible adapter ───────────────────────────────────────────
501
525
 
502
- function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): ModelAdapter {
503
- const baseUrl = resolveBaseUrl(provider, compatible);
526
+ function openaiAdapter(provider: AgentProvider, protocol: AgentProtocol): ModelAdapter {
527
+ const baseUrl = resolveBaseUrl(provider, protocol);
504
528
  const url = `${baseUrl}/chat/completions`;
505
529
  const options = provider.options ?? {};
506
530
 
507
531
  const headers = (): Record<string, string> => ({
508
532
  "Content-Type": "application/json",
509
- Authorization: `Bearer ${resolveApiKey(provider, compatible)}`,
533
+ Authorization: `Bearer ${resolveApiKey(provider, protocol)}`,
510
534
  ...(provider.headers ?? {}),
511
535
  });
512
536
 
513
- const body = (req: ModelChatRequest, stream: boolean): any => {
537
+ const body = (req: ModelChatRequest, stream: boolean, downgrade = false): any => {
514
538
  const payload: any = {
515
539
  model: provider.model,
516
540
  messages: openaiMessages(req.system, req.messages),
@@ -525,7 +549,20 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
525
549
  const temperature = req.temperature ?? options.temperature;
526
550
  if (temperature !== undefined) payload.temperature = temperature;
527
551
  if (options.topP !== undefined) payload.top_p = options.topP;
528
- if (req.json) payload.response_format = { type: "json_object" };
552
+ // `json_schema` is the real constraint (the gateway validates against it);
553
+ // `json_object` only says "an object" and leaves the shape to the prompt
554
+ if (req.jsonSchema && !downgrade) {
555
+ payload.response_format = {
556
+ type: "json_schema",
557
+ json_schema: {
558
+ name: req.jsonSchema.name,
559
+ ...(req.jsonSchema.description ? { description: req.jsonSchema.description } : {}),
560
+ schema: req.jsonSchema.schema,
561
+ },
562
+ };
563
+ } else if (req.json) {
564
+ payload.response_format = { type: "json_object" };
565
+ }
529
566
  if (stream) {
530
567
  payload.stream = true;
531
568
  payload.stream_options = { include_usage: true };
@@ -533,24 +570,49 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
533
570
  return payload;
534
571
  };
535
572
 
573
+ /**
574
+ * POST the request, downgrading a rejected `json_schema` **once**. OpenAI-compatible
575
+ * gateways are of very uneven quality: one that refuses the field still answers
576
+ * `json_object`, and the schema is then only enforced by the prompt and the repair
577
+ * turn — better than failing the run.
578
+ */
579
+ const post = async (req: ModelChatRequest, stream: boolean): Promise<Response> => {
580
+ const send = (downgrade: boolean) => fetchWithRetry(url, {
581
+ method: "POST",
582
+ headers: headers(),
583
+ body: JSON.stringify(body(req, stream, downgrade)),
584
+ signal: req.signal,
585
+ }, provider, protocol);
586
+
587
+ const response = await send(false);
588
+ if (response.ok || !req.jsonSchema || response.status !== 400) return response;
589
+
590
+ const text = await response.text().catch(() => "");
591
+ // Not about the schema — hand the error back as it came
592
+ if (!SCHEMA_UNSUPPORTED.test(text)) {
593
+ return new Response(text, { status: response.status, headers: { "Content-Type": "application/json" } });
594
+ }
595
+
596
+ logger.file("warn", "agent: provider refused json_schema, retrying with json_object", {
597
+ model: provider.model,
598
+ provider: protocol,
599
+ });
600
+ return send(true);
601
+ };
602
+
536
603
  return {
537
- compatible,
604
+ protocol,
538
605
  model: provider.model,
539
606
 
540
607
  async chat(req) {
541
- const response = await fetchWithRetry(url, {
542
- method: "POST",
543
- headers: headers(),
544
- body: JSON.stringify(body(req, false)),
545
- signal: req.signal,
546
- }, provider, compatible);
608
+ const response = await post(req, false);
547
609
 
548
610
  if (!response.ok) {
549
611
  const text = await response.text().catch(() => "");
550
612
  throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
551
613
  status: 502,
552
614
  code: "AGENT_PROVIDER_ERROR",
553
- reason: { provider: compatible, status: response.status },
615
+ reason: { provider: protocol, status: response.status },
554
616
  });
555
617
  }
556
618
 
@@ -571,25 +633,20 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
571
633
  : {}),
572
634
  toolCalls,
573
635
  finishReason: normalizeFinishReason(choice?.finish_reason),
574
- usage: mapUsage(raw?.usage, compatible),
636
+ usage: mapUsage(raw?.usage, protocol),
575
637
  raw,
576
638
  };
577
639
  },
578
640
 
579
641
  async *chatStream(req) {
580
- const response = await fetchWithRetry(url, {
581
- method: "POST",
582
- headers: headers(),
583
- body: JSON.stringify(body(req, true)),
584
- signal: req.signal,
585
- }, provider, compatible);
642
+ const response = await post(req, true);
586
643
 
587
644
  if (!response.ok || !response.body) {
588
645
  const text = await response.text().catch(() => "");
589
646
  throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
590
647
  status: 502,
591
648
  code: "AGENT_PROVIDER_ERROR",
592
- reason: { provider: compatible, status: response.status },
649
+ reason: { provider: protocol, status: response.status },
593
650
  });
594
651
  }
595
652
 
@@ -601,7 +658,7 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
601
658
  } catch {
602
659
  continue;
603
660
  }
604
- const error = streamError(payload, compatible);
661
+ const error = streamError(payload, protocol);
605
662
  if (error) throw error;
606
663
 
607
664
  const choice = payload?.choices?.[0];
@@ -625,7 +682,7 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
625
682
  };
626
683
  }
627
684
  }
628
- if (payload?.usage) yield { type: "usage", usage: mapUsage(payload.usage, compatible) };
685
+ if (payload?.usage) yield { type: "usage", usage: mapUsage(payload.usage, protocol) };
629
686
  if (choice?.finish_reason) yield { type: "finish", finishReason: normalizeFinishReason(choice.finish_reason) };
630
687
  }
631
688
  },
@@ -634,18 +691,25 @@ function openaiAdapter(provider: AgentProvider, compatible: AgentCompatible): Mo
634
691
 
635
692
  // ─── Anthropic adapter ───────────────────────────────────────────────────
636
693
 
637
- function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible): ModelAdapter {
638
- const baseUrl = resolveBaseUrl(provider, compatible);
694
+ function anthropicAdapter(provider: AgentProvider, protocol: AgentProtocol): ModelAdapter {
695
+ const baseUrl = resolveBaseUrl(provider, protocol);
639
696
  const url = `${baseUrl}/v1/messages`;
640
697
  const options = provider.options ?? {};
641
698
 
642
699
  const headers = (): Record<string, string> => ({
643
700
  "Content-Type": "application/json",
644
- "x-api-key": resolveApiKey(provider, compatible),
701
+ "x-api-key": resolveApiKey(provider, protocol),
645
702
  "anthropic-version": "2023-06-01",
646
703
  ...(provider.headers ?? {}),
647
704
  });
648
705
 
706
+ /**
707
+ * Does this run ask for a schema, and can Anthropic honour it? A forced tool is
708
+ * incompatible with extended thinking (the API refuses `tool_choice` then) — the
709
+ * schema is still asked for in the prompt, and the repair turn still validates.
710
+ */
711
+ const wantsSchema = (req: ModelChatRequest): boolean => !!req.jsonSchema && !thinkingOf(req.thinking);
712
+
649
713
  const body = (req: ModelChatRequest, stream: boolean): any => {
650
714
  const converted = anthropicMessages(req.messages);
651
715
  const system = [req.system, converted.system].filter(Boolean).join("\n\n");
@@ -661,10 +725,27 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
661
725
  ? thinking.budgetTokens + 1_024
662
726
  : maxTokens,
663
727
  };
664
- if (system) payload.system = system;
665
- if (req.tools?.length) {
666
- payload.tools = anthropicToolSpecs(req.tools);
667
- const toolChoice = anthropicToolChoice(req.toolChoice);
728
+ if (system) {
729
+ // A prompt-cache breakpoint: everything up to here (instructions, skills,
730
+ // working memory, tools) is billed once instead of on every run
731
+ payload.system = options.cache
732
+ ? [{ type: "text", text: system, cache_control: { type: "ephemeral" } }]
733
+ : system;
734
+ }
735
+
736
+ // The Messages API has no `response_format`: the schema is carried by a tool
737
+ // the model is **forced** to call, and the adapter turns that call into text
738
+ const schemaTool = wantsSchema(req)
739
+ ? [{ name: SCHEMA_TOOL, description: "Return the answer as a JSON object matching the schema.", input_schema: req.jsonSchema!.schema }]
740
+ : [];
741
+ const specs = [...(req.tools?.length ? anthropicToolSpecs(req.tools) : []), ...schemaTool];
742
+ if (specs.length) {
743
+ payload.tools = options.cache
744
+ ? [...specs.slice(0, -1), { ...specs[specs.length - 1]!, cache_control: { type: "ephemeral" } }]
745
+ : specs;
746
+ const toolChoice = schemaTool.length
747
+ ? { type: "tool", name: SCHEMA_TOOL }
748
+ : anthropicToolChoice(req.toolChoice);
668
749
  if (toolChoice) payload.tool_choice = toolChoice;
669
750
  }
670
751
  if (thinking) payload.thinking = { type: "enabled", budget_tokens: thinking.budgetTokens };
@@ -677,7 +758,7 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
677
758
  };
678
759
 
679
760
  return {
680
- compatible,
761
+ protocol,
681
762
  model: provider.model,
682
763
 
683
764
  async chat(req) {
@@ -686,20 +767,20 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
686
767
  headers: headers(),
687
768
  body: JSON.stringify(body(req, false)),
688
769
  signal: req.signal,
689
- }, provider, compatible);
770
+ }, provider, protocol);
690
771
 
691
772
  if (!response.ok) {
692
773
  const text = await response.text().catch(() => "");
693
774
  throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
694
775
  status: 502,
695
776
  code: "AGENT_PROVIDER_ERROR",
696
- reason: { provider: compatible, status: response.status },
777
+ reason: { provider: protocol, status: response.status },
697
778
  });
698
779
  }
699
780
 
700
781
  const raw: any = await response.json();
701
782
  const blocks: any[] = Array.isArray(raw?.content) ? raw.content : [];
702
- const toolCalls = blocks
783
+ const calls = blocks
703
784
  .filter((block) => block?.type === "tool_use")
704
785
  .map((block: any, index: number) => ({
705
786
  id: block?.id ?? `toolu_${index}`,
@@ -707,8 +788,15 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
707
788
  argsText: JSON.stringify(block?.input ?? {}),
708
789
  }));
709
790
 
791
+ // The forced schema tool **is** the answer: it becomes the text, so the
792
+ // runtime never sees a tool call it would try to execute
793
+ const forced = wantsSchema(req) ? calls.find((call) => call.name === SCHEMA_TOOL) : undefined;
794
+ const toolCalls = forced ? calls.filter((call) => call !== forced) : calls;
795
+
710
796
  return {
711
- text: blocks.filter((block) => block?.type === "text").map((block) => block.text).join(""),
797
+ text: forced
798
+ ? forced.argsText
799
+ : blocks.filter((block) => block?.type === "text").map((block) => block.text).join(""),
712
800
  toolCalls,
713
801
  // The raw blocks are kept: a tool round must echo the **signed** ones back
714
802
  ...(blocks.some((block) => block?.type === "thinking" || block?.type === "redacted_thinking")
@@ -722,7 +810,7 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
722
810
  }
723
811
  : {}),
724
812
  finishReason: normalizeFinishReason(raw?.stop_reason),
725
- usage: mapUsage(raw?.usage, compatible),
813
+ usage: mapUsage(raw?.usage, protocol),
726
814
  raw,
727
815
  };
728
816
  },
@@ -731,20 +819,22 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
731
819
  // Thinking blocks, accumulated by index — signatures included, so the run
732
820
  // can echo them back if the model then asks for a tool
733
821
  const thinking = new Map<number, any>();
822
+ // Index of the forced schema tool, whose deltas are the answer itself
823
+ let schemaBlock: number | null = null;
734
824
 
735
825
  const response = await fetchWithRetry(url, {
736
826
  method: "POST",
737
827
  headers: headers(),
738
828
  body: JSON.stringify(body(req, true)),
739
829
  signal: req.signal,
740
- }, provider, compatible);
830
+ }, provider, protocol);
741
831
 
742
832
  if (!response.ok || !response.body) {
743
833
  const text = await response.text().catch(() => "");
744
834
  throw new AppError(`Provider error (${response.status}): ${providerErrorMessage(text, response.status)}`, {
745
835
  status: 502,
746
836
  code: "AGENT_PROVIDER_ERROR",
747
- reason: { provider: compatible, status: response.status },
837
+ reason: { provider: protocol, status: response.status },
748
838
  });
749
839
  }
750
840
 
@@ -755,7 +845,7 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
755
845
  } catch {
756
846
  continue;
757
847
  }
758
- const error = streamError(payload, compatible);
848
+ const error = streamError(payload, protocol);
759
849
  if (error) throw error;
760
850
 
761
851
  switch (payload?.type) {
@@ -770,6 +860,12 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
770
860
  const block = payload?.content_block;
771
861
  const index = payload?.index ?? 0;
772
862
  if (block?.type === "tool_use") {
863
+ // A forced schema call streams its JSON like any tool call — and
864
+ // is surfaced as text instead, see `chat()`
865
+ if (wantsSchema(req) && block.name === SCHEMA_TOOL) {
866
+ schemaBlock = index;
867
+ break;
868
+ }
773
869
  yield {
774
870
  type: "tool_call",
775
871
  index,
@@ -799,6 +895,11 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
799
895
  block.signature = delta.signature;
800
896
  thinking.set(index, block);
801
897
  } else if (delta?.type === "input_json_delta" && delta.partial_json) {
898
+ // The schema tool's JSON is the answer, token by token
899
+ if (index === schemaBlock) {
900
+ yield { type: "text", text: delta.partial_json };
901
+ break;
902
+ }
802
903
  yield { type: "tool_call", index, argsText: delta.partial_json };
803
904
  }
804
905
  break;
@@ -816,7 +917,7 @@ function anthropicAdapter(provider: AgentProvider, compatible: AgentCompatible):
816
917
  throw new AppError(`Provider stream error: ${payload?.error?.message ?? "unknown"}`, {
817
918
  status: 502,
818
919
  code: "AGENT_PROVIDER_ERROR",
819
- reason: { provider: compatible },
920
+ reason: { provider: protocol },
820
921
  });
821
922
  }
822
923
  }
@@ -842,15 +943,17 @@ function resolveProvider(provider: AgentProvider): ModelAdapter {
842
943
  if (!provider?.model) {
843
944
  throw new AppError("`provider.model` is required", { status: 500, code: "AGENT_PROVIDER_INVALID" });
844
945
  }
845
- const compatible = resolveCompatible(provider);
846
- return compatible === "anthropic"
847
- ? anthropicAdapter(provider, compatible)
848
- : openaiAdapter(provider, compatible);
946
+ const protocol = resolveProtocol(provider);
947
+ return protocol === "anthropic"
948
+ ? anthropicAdapter(provider, protocol)
949
+ : openaiAdapter(provider, protocol);
849
950
  }
850
951
 
851
952
  export {
852
953
  EMPTY_USAGE,
853
954
  DEFAULT_MAX_TOKENS,
955
+ resolveProtocol,
956
+ /** @deprecated Renamed to `resolveProtocol`. */
854
957
  resolveCompatible,
855
958
  resolveApiKey,
856
959
  resolveBaseUrl,