@sema-agent/core 2.13.0 → 4.0.0

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 (184) hide show
  1. package/CHANGELOG.md +2345 -0
  2. package/dist/agents/cascade.d.ts +6 -1
  3. package/dist/agents/cascade.js +76 -32
  4. package/dist/agents/cumulative-stats.d.ts +29 -0
  5. package/dist/agents/cumulative-stats.js +29 -0
  6. package/dist/agents/observer.d.ts +4 -0
  7. package/dist/agents/observer.js +11 -10
  8. package/dist/agents/repair-loop.d.ts +2 -0
  9. package/dist/agents/repair-loop.js +110 -3
  10. package/dist/agents/retain-ledger.d.ts +20 -2
  11. package/dist/agents/retain-ledger.js +38 -8
  12. package/dist/agents/roster-store.d.ts +4 -0
  13. package/dist/agents/roster-store.js +20 -1
  14. package/dist/agents/subagent.js +66 -64
  15. package/dist/agents/teacher.d.ts +1 -9
  16. package/dist/agents/teacher.js +23 -24
  17. package/dist/agents/team.js +7 -4
  18. package/dist/agents/verify.d.ts +15 -2
  19. package/dist/agents/verify.js +39 -3
  20. package/dist/bin/sema-tb.js +2 -2
  21. package/dist/brain/anthropic.js +24 -21
  22. package/dist/brain/circuit-breaker.d.ts +1 -0
  23. package/dist/brain/circuit-breaker.js +26 -9
  24. package/dist/brain/degrading.js +13 -1
  25. package/dist/brain/failover.js +2 -0
  26. package/dist/brain/openai.js +24 -21
  27. package/dist/brain/repetition.js +10 -1
  28. package/dist/brain/routing.js +5 -1
  29. package/dist/brain/status-sink.js +5 -1
  30. package/dist/brain/stream-engine.js +5 -2
  31. package/dist/brain/stream-shared.d.ts +1 -0
  32. package/dist/brain/stream-shared.js +14 -0
  33. package/dist/brain/terminal-cause.d.ts +4 -0
  34. package/dist/brain/terminal-cause.js +12 -0
  35. package/dist/brain/tool-call-id.d.ts +1 -0
  36. package/dist/brain/tool-call-id.js +3 -0
  37. package/dist/brain/tool-call-repair.js +34 -15
  38. package/dist/core/auto-compaction.js +33 -32
  39. package/dist/core/background-agent-store.d.ts +22 -22
  40. package/dist/core/background-agent-store.js +19 -23
  41. package/dist/core/checkpoint-store.d.ts +7 -13
  42. package/dist/core/checkpoint-store.js +39 -15
  43. package/dist/core/consolidate-scope.js +4 -3
  44. package/dist/core/context-edit.js +3 -0
  45. package/dist/core/context-guard.js +3 -0
  46. package/dist/core/file-snapshot-store.d.ts +8 -0
  47. package/dist/core/file-snapshot-store.js +4 -3
  48. package/dist/core/git-worktree-env.d.ts +6 -1
  49. package/dist/core/git-worktree-env.js +12 -1
  50. package/dist/core/hooks.d.ts +4 -1
  51. package/dist/core/hooks.js +35 -7
  52. package/dist/core/lsp-diagnostics.js +13 -7
  53. package/dist/core/lsp-protocol.d.ts +1 -1
  54. package/dist/core/lsp-protocol.js +20 -8
  55. package/dist/core/lsp-session.d.ts +12 -2
  56. package/dist/core/lsp-session.js +114 -49
  57. package/dist/core/lsp.d.ts +13 -1
  58. package/dist/core/lsp.js +65 -9
  59. package/dist/core/mailbox-store.d.ts +7 -10
  60. package/dist/core/mcp.d.ts +7 -10
  61. package/dist/core/mcp.js +7 -3
  62. package/dist/core/memory-engine/data-plane.js +4 -0
  63. package/dist/core/memory-engine/engine.d.ts +2 -2
  64. package/dist/core/memory-engine/engine.js +96 -31
  65. package/dist/core/memory-engine/file-backend.d.ts +2 -2
  66. package/dist/core/memory-engine/file-backend.js +38 -39
  67. package/dist/core/memory-engine/layout.d.ts +8 -0
  68. package/dist/core/memory-engine/layout.js +71 -4
  69. package/dist/core/memory-engine/scope-contract.js +9 -3
  70. package/dist/core/memory-engine/types.d.ts +1 -1
  71. package/dist/core/memory.js +3 -0
  72. package/dist/core/permission-rules.d.ts +2 -1
  73. package/dist/core/permission-rules.js +24 -3
  74. package/dist/core/runner/active-skill-scope.d.ts +0 -1
  75. package/dist/core/runner/active-skill-scope.js +1 -15
  76. package/dist/core/runner/assemble-result.d.ts +3 -8
  77. package/dist/core/runner/assemble-result.js +3 -5
  78. package/dist/core/runner/compaction-call-options.d.ts +7 -0
  79. package/dist/core/runner/compaction-call-options.js +30 -0
  80. package/dist/core/runner/prepare-task.d.ts +6 -23
  81. package/dist/core/runner/prepare-task.js +31 -53
  82. package/dist/core/runner/runtask.d.ts +1 -0
  83. package/dist/core/runner/runtask.js +86 -178
  84. package/dist/core/runner/session-rule-policy.js +4 -1
  85. package/dist/core/runner/teardown-bounded.d.ts +7 -0
  86. package/dist/core/runner/teardown-bounded.js +36 -0
  87. package/dist/core/runner/tool-disclosure.d.ts +2 -5
  88. package/dist/core/runner/turn-attachments.d.ts +5 -15
  89. package/dist/core/runner/turn-attachments.js +1 -1
  90. package/dist/core/runner/usage-accounting.d.ts +6 -0
  91. package/dist/core/runner/usage-accounting.js +9 -8
  92. package/dist/core/safe-notify.d.ts +17 -0
  93. package/dist/core/safe-notify.js +59 -0
  94. package/dist/core/secret-env.js +5 -1
  95. package/dist/core/sensitive-path-policy.js +11 -4
  96. package/dist/core/store-contracts/checkpoint-store-contract.js +1 -1
  97. package/dist/core/store-contracts/session-repo-contract.js +40 -0
  98. package/dist/core/stub-env.d.ts +2 -6
  99. package/dist/core/surrogate-safe-slice.d.ts +4 -0
  100. package/dist/core/surrogate-safe-slice.js +18 -0
  101. package/dist/core/task-registry-monitor.js +15 -14
  102. package/dist/core/task-registry-shared.d.ts +22 -0
  103. package/dist/core/task-registry-shared.js +16 -0
  104. package/dist/core/task-registry.d.ts +1 -0
  105. package/dist/core/task-registry.js +40 -57
  106. package/dist/core/tool-errors.js +5 -4
  107. package/dist/core/tool-name-aliases.js +2 -1
  108. package/dist/core/tool-policy.js +72 -19
  109. package/dist/core/tool-result-store.d.ts +1 -0
  110. package/dist/core/tool-result-store.js +17 -1
  111. package/dist/core/trace.d.ts +3 -9
  112. package/dist/core/types.d.ts +16 -12
  113. package/dist/core/untrusted-egress.js +7 -5
  114. package/dist/core/workflow-journal-store.d.ts +9 -20
  115. package/dist/core/workflow-run-store.d.ts +11 -10
  116. package/dist/core/workflow-run-store.js +22 -16
  117. package/dist/engine/compaction/compaction.d.ts +1 -0
  118. package/dist/engine/compaction/compaction.js +14 -5
  119. package/dist/engine/compaction/utils.js +4 -2
  120. package/dist/engine/execution-env/node-execution-env.d.ts +2 -24
  121. package/dist/engine/harness/agent-harness.d.ts +1 -0
  122. package/dist/engine/harness/agent-harness.js +13 -33
  123. package/dist/engine/harness/types.d.ts +29 -67
  124. package/dist/engine/llm/types.d.ts +1 -1
  125. package/dist/engine/loop/agent-loop.js +11 -4
  126. package/dist/engine/loop/types.d.ts +11 -9
  127. package/dist/engine/lsp/frame-decoder.js +6 -3
  128. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  129. package/dist/engine/lsp/node-lsp-manager.js +103 -26
  130. package/dist/engine/lsp/stdio-lsp-transport.js +16 -7
  131. package/dist/engine/session/memory-repo.d.ts +2 -6
  132. package/dist/engine/session/session.d.ts +5 -30
  133. package/dist/index.d.ts +3 -1
  134. package/dist/index.js +2 -1
  135. package/dist/internal/harness-types.d.ts +3 -1
  136. package/dist/internal/harness.d.ts +1 -1
  137. package/dist/internal/harness.js +1 -1
  138. package/dist/orchestration/run-spec.js +7 -4
  139. package/dist/orchestration/run-workflow-tool.js +3 -35
  140. package/dist/orchestration/workflow-governance.js +43 -9
  141. package/dist/orchestration/workflow-sandbox-conformance.js +85 -51
  142. package/dist/orchestration/workflow-script-store.js +34 -4
  143. package/dist/orchestration/workflow.d.ts +3 -11
  144. package/dist/orchestration/workflow.js +164 -204
  145. package/dist/prompt-assembly/artifact-store.d.ts +2 -0
  146. package/dist/prompt-assembly/artifact-store.js +39 -24
  147. package/dist/prompt-assembly/assemble.js +38 -7
  148. package/dist/prompt-assembly/epoch.js +19 -2
  149. package/dist/prompt-assembly/event-registry.js +3 -3
  150. package/dist/prompt-assembly/explain.js +1 -1
  151. package/dist/prompt-assembly/tool-catalog.d.ts +10 -9
  152. package/dist/prompt-assembly/tool-catalog.js +16 -1
  153. package/dist/prompt-assembly/turn-snapshot.d.ts +2 -5
  154. package/dist/prompt-assembly/turn-snapshot.js +4 -1
  155. package/dist/stores/file/background-agent-store.d.ts +3 -12
  156. package/dist/stores/file/background-agent-store.js +3 -41
  157. package/dist/stores/file/checkpoint-store.js +6 -6
  158. package/dist/stores/file/fs-atomic.d.ts +3 -0
  159. package/dist/stores/file/fs-atomic.js +53 -8
  160. package/dist/stores/file/index.d.ts +1 -0
  161. package/dist/stores/file/index.js +7 -0
  162. package/dist/stores/file/mailbox-store.d.ts +2 -6
  163. package/dist/stores/file/session-policy-store.js +15 -1
  164. package/dist/stores/file/session-store.d.ts +4 -6
  165. package/dist/stores/file/session-store.js +28 -1
  166. package/dist/stores/file/workflow-journal-store.d.ts +3 -11
  167. package/dist/stores/file/workflow-run-store.d.ts +3 -7
  168. package/dist/stores/file/workflow-run-store.js +4 -17
  169. package/dist/tools/fs/bash-readonly-classifier.d.ts +2 -0
  170. package/dist/tools/fs/bash-readonly-classifier.js +73 -21
  171. package/dist/tools/fs/fs-bash.d.ts +1 -1
  172. package/dist/tools/fs/fs-bash.js +407 -91
  173. package/dist/tools/fs/fs-pdf.d.ts +2 -12
  174. package/dist/tools/fs/fs-shared.js +2 -2
  175. package/dist/tools/fs/notebook.d.ts +2 -5
  176. package/dist/tools/fs/safety.d.ts +3 -0
  177. package/dist/tools/fs/safety.js +43 -14
  178. package/dist/tools/fs/search.js +5 -5
  179. package/dist/tools/loop-tick.js +1 -1
  180. package/dist/tools/monitor.d.ts +1 -0
  181. package/dist/tools/monitor.js +57 -9
  182. package/dist/tools/worktree.d.ts +4 -7
  183. package/dist/tools/worktree.js +235 -143
  184. package/package.json +9 -2
@@ -1,6 +1,6 @@
1
1
  import { Type, type Static } from "typebox";
2
2
  import type { Runner, ResumeTaskConfig } from "../core/runner/runtask.js";
3
- import type { CheckpointToken, ResumeOutcome } from "../core/checkpoint-store.js";
3
+ import type { CheckpointGate, CheckpointToken, ResumeOutcome } from "../core/checkpoint-store.js";
4
4
  import type { ModelRef, TaskResult, TaskSpec, ToolSpec } from "../core/types.js";
5
5
  export declare const VERIFICATION_PROMPT = "You are a verification specialist. Your job is NOT to confirm the implementation works \u2014 it is to try to BREAK it.\n\nYou have two documented failure patterns. First, verification avoidance: faced with a check, you find reasons not to run it \u2014 you read code, narrate what you would test, declare \"PASS,\" and move on. Second, being seduced by the first 80%: a polished result or a passing test suite makes you inclined to pass it, not noticing the edge that crashes, the state that vanishes, the bad input that is unhandled. The first 80% is the easy part. Your entire value is in finding the last 20%.\n\n## Hard boundary \u2014 do not modify the project\nYou are STRICTLY a verifier. Do NOT create, modify, or delete project files; do NOT install packages; do NOT run version-control write operations. Use only the read/probe/execute tools available to you. (If you need a scratch file, use a temp directory, and clean up.)\n\n## Evidence is mandatory\nReading code is NOT verification. Every check must actually run something \u2014 execute the code, hit the endpoint, run the build/tests \u2014 and record the command and its real output. A \"PASS\" with no command output is a skip, not a pass.\n\n## Strategy (adapt to what changed)\n- Build/lib changes: build it, run the full test suite, exercise the public API as a consumer would.\n- Backend/API: start it, call endpoints, check response *shapes* (not just status codes), test error paths.\n- CLI/script: run with representative AND edge inputs (empty, malformed, boundary); check stdout/stderr/exit codes.\n- Bug fix: reproduce the original bug first, verify the fix, then check for regressions and side effects.\n- Refactor (no behavior change): the existing suite must pass unchanged; diff the public surface; same inputs \u2192 same outputs.\nRun the project's own build/tests/linters as a baseline, then apply the type-specific checks. Test results are context, not proof \u2014 the implementer is an LLM too; its tests may be happy-path or circular.\n\n## Adversarial probes (pick the ones that fit)\nBoundary values (0, -1, empty, very long, unicode, max), idempotency (same mutating call twice), orphan operations (ids that don't exist), concurrency (parallel create-if-not-exists). Your verdict must include at least one adversarial probe you actually ran and its result \u2014 even if it was handled correctly.\n\n## Before you FAIL\nCheck you haven't missed why it's actually fine: defensive code elsewhere, intentional behavior documented in comments/specs, or an unfixable external-contract limitation (note that as an observation, not a FAIL). Don't wave away real issues, but don't FAIL on intentional behavior.\n\n## Verdict\nSubmit exactly one verdict via the provided output tool:\n- PASS \u2014 you ran real checks (including \u22651 adversarial probe) and it holds up. Put the commands + observed output in `evidence`.\n- FAIL \u2014 something is broken. Put each concrete problem (with how to reproduce) in `findings`.\n- PARTIAL \u2014 environmental limitation only (no test framework, a tool/server unavailable). Not for \"I'm unsure\": if you can run the check, decide PASS or FAIL. Note what you couldn't verify and why in `findings`.";
6
6
  export declare const STATIC_VERIFICATION_PROMPT = "You are a verification judge. Your job is NOT to confirm the change works \u2014 it is to find where it BREAKS.\n\nYou are READ-ONLY by design: the build and tests have ALREADY been run by a separate mechanical gate. Their results and the code change (a diff) are usually supplied to you as evidence; but if little or no diff/results are supplied this round (e.g. a re-verification AFTER a fix), judge the CURRENT working tree directly \u2014 do NOT return PARTIAL merely because a diff is absent. Do NOT try to execute code, run tests, or invoke a runtime \u2014 the environment will refuse it, and that is expected, not a limitation. Judge from any supplied diff/results plus read-only inspection of the working tree (read files, search, list).\n\nYou have two documented failure patterns. First, being seduced by the first 80%: a clean diff or a green test run makes you inclined to pass it, not noticing the edge that crashes, the state that vanishes, the bad input that is unhandled, the cross-module assumption that breaks. Second, hiding behind PARTIAL because you couldn't run something \u2014 that is NOT what PARTIAL is for here; execution was the mechanical gate's job. Your entire value is finding the last 20% by READING.\n\n## What to scrutinize (adapt to the diff)\n- Boundary/edge cases the tests likely miss: 0, -1, empty, very long, unicode, max, malformed input, idempotency, orphan ids, off-by-one, negative numbers, EOF/empty fields.\n- Semantic correctness vs the task spec: does the change actually do what was asked, including cases the tests don't cover (the title()/CSV/base62 class of defect)?\n- Cross-module/integration hazards in the diff: a changed signature/export/contract/default a caller elsewhere still assumes; a deleted helper something depends on.\n- If the provided test results show failures, that is a concrete FAIL with the failing output as evidence.\n\n## Verdict\nSubmit exactly one verdict via the provided output tool:\n- PASS \u2014 you read the diff + results, looked for the edges above, and it holds. Cite the specific things you checked in `evidence`.\n- FAIL \u2014 you found a concrete defect. Put each problem (with the diff location / input that breaks it) in `findings`.\n- PARTIAL \u2014 ONLY when the evidence itself is genuinely insufficient to judge (e.g. the diff is empty or unrelated to the task, no results supplied). NOT for \"I couldn't execute it.\" Say what's missing in `findings`.";
@@ -24,8 +24,15 @@ export interface VerifyConfig {
24
24
  verdict: VerificationOutcome["verdict"];
25
25
  findings: string[];
26
26
  }) => void;
27
+ onVerifierPause?: (info: {
28
+ round: number;
29
+ status: TaskResult["status"];
30
+ sessionId: string;
31
+ checkpointToken?: CheckpointToken;
32
+ checkpointGate?: CheckpointGate;
33
+ }) => void;
27
34
  }
28
- export type UnverifiedReason = "suspended" | "needs_review" | "no_verdict" | "opted_out" | "impl_incomplete" | "could_not_verify";
35
+ export type UnverifiedReason = "suspended" | "needs_review" | "no_verdict" | "opted_out" | "impl_incomplete" | "could_not_verify" | "verifier_paused";
29
36
  export interface VerificationOutcome {
30
37
  verdict: "PASS" | "FAIL" | "PARTIAL" | "unverified";
31
38
  unverifiedReason?: UnverifiedReason;
@@ -33,6 +40,12 @@ export interface VerificationOutcome {
33
40
  findings: string[];
34
41
  evidence?: string;
35
42
  verifierCost?: number;
43
+ verifierPause?: {
44
+ status: TaskResult["status"];
45
+ sessionId: string;
46
+ checkpointToken?: CheckpointToken;
47
+ checkpointGate?: CheckpointGate;
48
+ };
36
49
  }
37
50
  export interface VerificationResult extends TaskResult {
38
51
  verification: VerificationOutcome;
@@ -3,6 +3,7 @@ import { CODE_AGENT_PROMPT } from "../prompts/default.js";
3
3
  import { releaseSession } from "./session-util.js";
4
4
  import { mapNestedSuspend, isDurablePause } from "./suspend-guard.js";
5
5
  import { delimitUntrusted, sanitizeUntrustedText } from "../core/untrusted-text.js";
6
+ import { createSafeNotifier } from "../core/safe-notify.js";
6
7
  export const VERIFICATION_PROMPT = `You are a verification specialist. Your job is NOT to confirm the implementation works — it is to try to BREAK it.
7
8
 
8
9
  You have two documented failure patterns. First, verification avoidance: faced with a check, you find reasons not to run it — you read code, narrate what you would test, declare "PASS," and move on. Second, being seduced by the first 80%: a polished result or a passing test suite makes you inclined to pass it, not noticing the edge that crashes, the state that vanishes, the bad input that is unhandled. The first 80% is the easy part. Your entire value is in finding the last 20%.
@@ -58,6 +59,8 @@ export const VerdictSchema = Type.Object({
58
59
  }),
59
60
  evidence: Type.Optional(Type.String({ description: "Commands run + observed output backing the verdict." })),
60
61
  });
62
+ const VERIFY_ON_ROUND_SITE = "verify.onRound";
63
+ const VERIFY_ON_VERIFIER_PAUSE_SITE = "verify.onVerifierPause";
61
64
  function fixObjective(findings) {
62
65
  const list = findings.length
63
66
  ? findings.map((f) => `- ${sanitizeUntrustedText(f)}`).join("\n")
@@ -82,7 +85,8 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
82
85
  if (result.status !== "completed") {
83
86
  return { ...result, verification: { verdict: "unverified", unverifiedReason: "impl_incomplete", rounds: 0, findings: [] } };
84
87
  }
85
- const maxRounds = Math.max(1, Math.floor(config.maxRounds ?? 2));
88
+ const maxRounds = Number.isFinite(config.maxRounds) ? Math.max(1, Math.floor(config.maxRounds)) : 2;
89
+ const notifier = createSafeNotifier();
86
90
  const verifierTools = config.verifierTools ?? (specBase.tools ?? []).filter((t) => t.effect === "read");
87
91
  const evidenceMode = config.evidence != null && config.evidence.trim() !== "";
88
92
  const verifierPrompt = config.verifierPrompt ?? (evidenceMode ? STATIC_VERIFICATION_PROMPT : VERIFICATION_PROMPT);
@@ -102,7 +106,8 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
102
106
  signal: specBase.signal,
103
107
  });
104
108
  try {
105
- return { verdict: v.structuredOutput, cost: (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0) };
109
+ const cost = (v.stats.costMicroUsd ?? 0) + (v.stats.nested?.costMicroUsd ?? 0);
110
+ return { verdict: v.structuredOutput, cost, ...(isDurablePause(v.status) ? { paused: v } : {}) };
106
111
  }
107
112
  finally {
108
113
  if (!isDurablePause(v.status))
@@ -115,9 +120,40 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
115
120
  let spend = 0;
116
121
  let verifierSpend = 0;
117
122
  for (let round = 1; round <= maxRounds; round++) {
123
+ if (round > 1) {
124
+ if (config.totalTimeoutMs != null && Date.now() - startedAt >= config.totalTimeoutMs)
125
+ break;
126
+ if (config.costCeilingMicroUsd != null && spend >= config.costCeilingMicroUsd)
127
+ break;
128
+ }
118
129
  const v = await runVerifier(current, round);
119
130
  spend += v.cost;
120
131
  verifierSpend += v.cost;
132
+ if (v.paused) {
133
+ const paused = v.paused;
134
+ outcome = {
135
+ verdict: "unverified",
136
+ unverifiedReason: "verifier_paused",
137
+ rounds: round,
138
+ findings: [],
139
+ verifierCost: verifierSpend,
140
+ verifierPause: {
141
+ status: v.paused.status,
142
+ sessionId: v.paused.sessionId,
143
+ ...(v.paused.checkpointToken ? { checkpointToken: v.paused.checkpointToken } : {}),
144
+ ...(v.paused.checkpointGate ? { checkpointGate: v.paused.checkpointGate } : {}),
145
+ },
146
+ };
147
+ notifier.notify(() => config.onVerifierPause?.({
148
+ round,
149
+ status: paused.status,
150
+ sessionId: paused.sessionId,
151
+ ...(paused.checkpointToken ? { checkpointToken: paused.checkpointToken } : {}),
152
+ ...(paused.checkpointGate ? { checkpointGate: paused.checkpointGate } : {}),
153
+ }), VERIFY_ON_VERIFIER_PAUSE_SITE);
154
+ notifier.notify(() => config.onRound?.({ round, verdict: outcome.verdict, findings: outcome.findings }), VERIFY_ON_ROUND_SITE);
155
+ break;
156
+ }
121
157
  const rawVerdict = v.verdict?.verdict ?? "unverified";
122
158
  const verdict = rawVerdict === "PARTIAL" ? "unverified" : rawVerdict;
123
159
  const unverifiedReason = !v.verdict ? "no_verdict" : rawVerdict === "PARTIAL" ? "could_not_verify" : undefined;
@@ -129,7 +165,7 @@ export async function verifyCompleted(runner, result, specBase, objective, confi
129
165
  evidence: v.verdict?.evidence,
130
166
  verifierCost: verifierSpend,
131
167
  };
132
- config.onRound?.({ round, verdict: outcome.verdict, findings: outcome.findings });
168
+ notifier.notify(() => config.onRound?.({ round, verdict: outcome.verdict, findings: outcome.findings }), VERIFY_ON_ROUND_SITE);
133
169
  if (outcome.verdict !== "FAIL")
134
170
  break;
135
171
  if (round >= maxRounds)
@@ -289,7 +289,7 @@ async function main() {
289
289
  switch (e.kind) {
290
290
  case "brain.call":
291
291
  lastServedModel = e.model;
292
- line(`[call] model=${e.model} in=${e.promptTokens} out=${e.completionTokens} cache=${e.cacheRead} latMs=${e.latencyMs}` +
292
+ line(`[call] model=${e.model} in=${e.promptTokens} tot=${e.totalInputTokens} out=${e.completionTokens} cache=${e.cacheRead} latMs=${e.latencyMs}` +
293
293
  (e.firstTokenMs !== undefined ? ` ttftMs=${e.firstTokenMs}` : "") +
294
294
  (e.stopReason !== undefined ? ` stop=${e.stopReason}` : "") +
295
295
  (e.callCap !== undefined ? ` cap=${e.callCap}${e.capThinkingSkipped ? "(think-skip)" : ""}` : "") +
@@ -403,7 +403,7 @@ async function main() {
403
403
  reason.flush();
404
404
  turns++;
405
405
  const u = ev.usage;
406
- line(`[turn] ${turns}${u ? ` in=${u.inputTokens} out=${u.outputTokens} cache=${u.cacheReadTokens}` : ""}` +
406
+ line(`[turn] ${turns}${u ? ` in=${u.inputTokens} tot=${u.totalInputTokens} out=${u.outputTokens} cache=${u.cacheReadTokens}` : ""}` +
407
407
  (ev.usageMissing ? " usageMissing=1" : "") +
408
408
  (ev.stopReason !== undefined ? ` stop=${ev.stopReason}` : ""));
409
409
  break;
@@ -1,7 +1,8 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { BrainError } from "./errors.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { createRepetitionPoll, createWalltimeGate } from "./stream-shared.js";
4
+ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from "./stream-shared.js";
5
+ import { mintFallbackToolCallId } from "./tool-call-id.js";
5
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
6
7
  import { emitBrainTelemetry } from "./status-sink.js";
7
8
  import { ANTHROPIC_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
@@ -201,23 +202,11 @@ function closeToolUseBlock(acc) {
201
202
  return acc.closedTc;
202
203
  if (!acc.toolName)
203
204
  return undefined;
204
- let args;
205
- if (acc.toolJson) {
206
- try {
207
- const parsed = JSON.parse(acc.toolJson);
208
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
209
- return undefined;
210
- args = parsed;
211
- }
212
- catch {
213
- return undefined;
214
- }
215
- }
216
- else {
217
- args = {};
218
- }
205
+ const args = parseStreamedToolArgs(acc.toolJson);
206
+ if (args === undefined)
207
+ return undefined;
219
208
  if (!acc.toolId)
220
- acc.toolId = `toolu_${Math.random().toString(36).slice(2)}`;
209
+ acc.toolId = mintFallbackToolCallId("toolu_");
221
210
  acc.closedTc = { type: "toolCall", id: acc.toolId, name: acc.toolName, arguments: args };
222
211
  return acc.closedTc;
223
212
  }
@@ -339,6 +328,8 @@ export function createAnthropicBrain(config = {}) {
339
328
  let stopReason = null;
340
329
  let stopDetails;
341
330
  let nextContentIndex = 0;
331
+ let malformedFrames = 0;
332
+ let malformedSample;
342
333
  let degenerate = false;
343
334
  let degenTextBlock;
344
335
  const rep = createRepetitionPoll();
@@ -512,6 +503,8 @@ export function createAnthropicBrain(config = {}) {
512
503
  evt = JSON.parse(data);
513
504
  }
514
505
  catch {
506
+ malformedFrames++;
507
+ malformedSample ??= data.slice(0, 160);
515
508
  return;
516
509
  }
517
510
  handleEvent(evt);
@@ -563,6 +556,12 @@ export function createAnthropicBrain(config = {}) {
563
556
  text: `\n[note: ${malformed.length} tool call(s) were truncated (stop_reason="${stopReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
564
557
  });
565
558
  }
559
+ if (malformedFrames > 0) {
560
+ finalContent.push({
561
+ type: "text",
562
+ text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
563
+ });
564
+ }
566
565
  const truncatedEmpty = stopReason === "max_tokens" && noUsableContent && !toolError;
567
566
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
568
567
  const staticReasoningCut = truncatedEmpty && !dynamicCut && reasoningSeen && options?.staticReasoningCutDowngrade === true;
@@ -586,7 +585,7 @@ export function createAnthropicBrain(config = {}) {
586
585
  ? `[length_empty] response truncated at max_tokens with no answer text (${maxTokensNote}) — the model emitted reasoning although no thinking was requested (a budget-ignoring gateway?) and it consumed the entire output budget; raise max_tokens`
587
586
  : `[length_empty] response truncated at max_tokens with no answer text (${maxTokensNote}); raise max_tokens`
588
587
  : emptyNoFinish
589
- ? `[stream_torn] model stream ended with no content and no stop_reason — the response was lost (a torn stream or an in-band provider error frame)`
588
+ ? `[stream_torn] model stream ended with no content and no stop_reason — the response was lost (a torn stream or an in-band provider error frame)${malformedFrames > 0 ? ` — ${malformedFrames} unparseable data frame(s) were dropped on this stream (first sample: ${JSON.stringify(malformedSample)})` : ""}`
590
589
  : undefined));
591
590
  const errored = walltime.cut ||
592
591
  degenerate ||
@@ -595,9 +594,13 @@ export function createAnthropicBrain(config = {}) {
595
594
  emptyNoFinish ||
596
595
  (toolError !== undefined && noUsableContent) ||
597
596
  (truncatedEmpty && !dynamicCut && !staticReasoningCut);
598
- const errorKind = !walltime.cut && !degenerate && !safetyCut && toolError === undefined && truncatedEmpty && !dynamicCut && !staticReasoningCut
599
- ? "length_empty"
600
- : undefined;
597
+ const errorKind = walltime.cut
598
+ ? "walltime_cutoff"
599
+ : degenerate
600
+ ? "degenerate"
601
+ : !safetyCut && toolError === undefined && truncatedEmpty && !dynamicCut && !staticReasoningCut
602
+ ? "length_empty"
603
+ : undefined;
601
604
  const finalMessage = {
602
605
  ...partial,
603
606
  content: finalContent.length > 0 ? finalContent : [{ type: "text", text: "" }],
@@ -8,6 +8,7 @@ export interface BreakerSnapshot {
8
8
  failures: number;
9
9
  openedAt?: number;
10
10
  halfOpenInFlight?: number;
11
+ halfOpenAt?: number;
11
12
  }
12
13
  export interface BreakerState {
13
14
  get(key: string): BreakerSnapshot | undefined;
@@ -11,14 +11,14 @@ class InMemoryBreakerState {
11
11
  this.m.set(k, s);
12
12
  }
13
13
  }
14
- function errorAssistantMessage(model, code, detail) {
14
+ function errorAssistantMessage(model, code, detail, stopReason = "error") {
15
15
  return {
16
16
  role: "assistant",
17
17
  content: [{ type: "text", text: "" }],
18
18
  api: model.api,
19
19
  provider: model.provider,
20
20
  model: model.id,
21
- stopReason: "error",
21
+ stopReason,
22
22
  errorMessage: `[${code}] ${detail}`,
23
23
  usage: ZERO_USAGE,
24
24
  timestamp: Date.now(),
@@ -44,21 +44,33 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
44
44
  if (s.phase === "closed")
45
45
  return { pass: true };
46
46
  if (s.phase === "open") {
47
- if (s.openedAt === undefined)
48
- return { pass: false };
47
+ if (s.openedAt === undefined) {
48
+ state.set(key, { ...s, openedAt: now() });
49
+ return { pass: false, retryInMs: cooldownMs };
50
+ }
49
51
  const elapsed = now() - s.openedAt;
50
52
  if (elapsed >= cooldownMs) {
51
- setState(key, s.phase, { ...s, phase: "half-open", halfOpenInFlight: 1 });
53
+ setState(key, s.phase, { ...s, phase: "half-open", halfOpenInFlight: 1, halfOpenAt: now() });
52
54
  return { pass: true };
53
55
  }
54
56
  return { pass: false, retryInMs: cooldownMs - elapsed };
55
57
  }
56
58
  const inFlight = s.halfOpenInFlight ?? 0;
57
59
  if (inFlight < halfOpenProbes) {
58
- state.set(key, { ...s, halfOpenInFlight: inFlight + 1 });
60
+ state.set(key, { ...s, halfOpenInFlight: inFlight + 1, halfOpenAt: s.halfOpenAt ?? now() });
59
61
  return { pass: true };
60
62
  }
61
- return { pass: false };
63
+ const leaseStart = s.halfOpenAt;
64
+ if (leaseStart === undefined) {
65
+ state.set(key, { ...s, halfOpenAt: now() });
66
+ return { pass: false, retryInMs: cooldownMs };
67
+ }
68
+ const held = now() - leaseStart;
69
+ if (held >= cooldownMs) {
70
+ state.set(key, { ...s, halfOpenInFlight: 1, halfOpenAt: now() });
71
+ return { pass: true };
72
+ }
73
+ return { pass: false, retryInMs: cooldownMs - held };
62
74
  };
63
75
  const record = (key, failedCode, viaProbe = true) => {
64
76
  const s = snap(key);
@@ -118,9 +130,14 @@ export function createCircuitBreakerBrain(inner, opts = {}) {
118
130
  }
119
131
  }
120
132
  catch (e) {
133
+ const aborted = options?.signal?.aborted === true || (e instanceof Error && e.name === "AbortError");
121
134
  const code = e instanceof BrainError ? e.code : "network";
122
- record(key, code, !bypass);
123
- out.push({ type: "error", reason: "error", error: errorAssistantMessage(model, code, e instanceof Error ? e.message : String(e)) });
135
+ record(key, aborted ? "http" : code, !bypass);
136
+ out.push({
137
+ type: "error",
138
+ reason: aborted ? "aborted" : "error",
139
+ error: errorAssistantMessage(model, code, e instanceof Error ? e.message : String(e), aborted ? "aborted" : "error"),
140
+ });
124
141
  }
125
142
  })();
126
143
  return out;
@@ -91,8 +91,20 @@ export function passthroughStream(brain, model, context, options) {
91
91
  void (async () => {
92
92
  try {
93
93
  const inner = await brain.stream(model, context, options);
94
- for await (const ev of inner)
94
+ let sawTerminal = false;
95
+ for await (const ev of inner) {
96
+ if (ev.type === "done" || ev.type === "error")
97
+ sawTerminal = true;
95
98
  out.push(ev);
99
+ }
100
+ if (!sawTerminal) {
101
+ await inner.result();
102
+ out.push({
103
+ type: "error",
104
+ reason: "error",
105
+ error: errorMessageOf(model, new Error("brain stream ended without a terminal event"), false),
106
+ });
107
+ }
96
108
  }
97
109
  catch (e) {
98
110
  const aborted = options?.signal?.aborted === true ||
@@ -53,6 +53,8 @@ export function createFailoverBrain(brains) {
53
53
  ...(lastFailedCode !== undefined ? { errorCode: lastFailedCode } : {}),
54
54
  });
55
55
  }
56
+ if (!sawContent && heldStart)
57
+ out.push(heldStart);
56
58
  out.push(terminal ?? {
57
59
  type: "done",
58
60
  reason: message.stopReason,
@@ -1,7 +1,8 @@
1
1
  import {} from "../internal/llm.js";
2
2
  import { repairTextToolCalls } from "./tool-call-repair.js";
3
3
  import { DEGENERATE_MESSAGE, trimDegenerateTail } from "./repetition.js";
4
- import { createRepetitionPoll, createWalltimeGate } from "./stream-shared.js";
4
+ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from "./stream-shared.js";
5
+ import { mintFallbackToolCallId } from "./tool-call-id.js";
5
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
6
7
  import { emitBrainTelemetry } from "./status-sink.js";
7
8
  import { OPENAI_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
@@ -12,23 +13,11 @@ function closeToolCallAccum(acc) {
12
13
  return acc.closedTc;
13
14
  if (!acc.name)
14
15
  return undefined;
15
- let args;
16
- if (acc.args) {
17
- try {
18
- const parsed = JSON.parse(acc.args);
19
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
20
- return undefined;
21
- args = parsed;
22
- }
23
- catch {
24
- return undefined;
25
- }
26
- }
27
- else {
28
- args = {};
29
- }
16
+ const args = parseStreamedToolArgs(acc.args);
17
+ if (args === undefined)
18
+ return undefined;
30
19
  if (!acc.id)
31
- acc.id = `call_${Math.random().toString(36).slice(2)}`;
20
+ acc.id = mintFallbackToolCallId("call_");
32
21
  acc.closedTc = { type: "toolCall", id: acc.id, name: acc.name, arguments: args };
33
22
  return acc.closedTc;
34
23
  }
@@ -356,6 +345,8 @@ export function createOpenAIBrain(config = {}) {
356
345
  const toolAccum = new Map();
357
346
  let finishReason = null;
358
347
  let usageRaw;
348
+ let malformedFrames = 0;
349
+ let malformedSample;
359
350
  const thinkingBlock = { type: "thinking", thinking: "" };
360
351
  const textBlock = { type: "text", text: "" };
361
352
  const pushReasoningDelta = (delta) => {
@@ -438,6 +429,8 @@ export function createOpenAIBrain(config = {}) {
438
429
  chunk = JSON.parse(data);
439
430
  }
440
431
  catch {
432
+ malformedFrames++;
433
+ malformedSample ??= data.slice(0, 160);
441
434
  return;
442
435
  }
443
436
  if (chunk.usage)
@@ -535,6 +528,12 @@ export function createOpenAIBrain(config = {}) {
535
528
  text: `\n[note: ${malformed.length} tool call(s) were truncated (finish_reason="${finishReason ?? "?"}") and dropped — re-issue them next turn: ${malformed.join("; ")}]`,
536
529
  });
537
530
  }
531
+ if (malformedFrames > 0) {
532
+ finalContent.push({
533
+ type: "text",
534
+ text: `\n[note: ${malformedFrames} provider stream frame(s) were not valid JSON and were dropped — the streamed output may be missing content (first sample: ${JSON.stringify(malformedSample)})]`,
535
+ });
536
+ }
538
537
  const truncatedEmpty = finishReason === "length" && noUsableContent && !toolError;
539
538
  const dynamicCut = truncatedEmpty && options?.maxTokensDynamic === true;
540
539
  const safetyCut = finishReason === "content_filter";
@@ -557,7 +556,7 @@ export function createOpenAIBrain(config = {}) {
557
556
  ? `[length_empty] response truncated at max_tokens (finish_reason="length", ${maxTokensNote}) with no answer text — the model spent the whole budget on reasoning (${accumReasoning.length} reasoning chars). Raise max_tokens, or disable thinking for this task.`
558
557
  : `[length_empty] response truncated at max_tokens (finish_reason="length", ${maxTokensNote}) with no answer text — raise max_tokens`
559
558
  : emptyNoFinish
560
- ? `[stream_torn] model stream ended with no content and no finish_reason — the response was lost (a torn stream or an in-band provider error frame)`
559
+ ? `[stream_torn] model stream ended with no content and no finish_reason — the response was lost (a torn stream or an in-band provider error frame)${malformedFrames > 0 ? ` — ${malformedFrames} unparseable data frame(s) were dropped on this stream (first sample: ${JSON.stringify(malformedSample)})` : ""}`
561
560
  : undefined));
562
561
  const errored = walltime.cut ||
563
562
  degenerate ||
@@ -566,9 +565,13 @@ export function createOpenAIBrain(config = {}) {
566
565
  emptyNoFinish ||
567
566
  (toolError !== undefined && noUsableContent) ||
568
567
  (truncatedEmpty && !dynamicCut);
569
- const errorKind = !walltime.cut && !degenerate && !safetyCut && !refused && toolError === undefined && truncatedEmpty && !dynamicCut
570
- ? "length_empty"
571
- : undefined;
568
+ const errorKind = walltime.cut
569
+ ? "walltime_cutoff"
570
+ : degenerate
571
+ ? "degenerate"
572
+ : !safetyCut && !refused && toolError === undefined && truncatedEmpty && !dynamicCut
573
+ ? "length_empty"
574
+ : undefined;
572
575
  const finalMessage = {
573
576
  ...partial,
574
577
  content: finalContent.length > 0 ? finalContent : [{ type: "text", text: "" }],
@@ -11,6 +11,15 @@ const SEGMENT_MAX = 120;
11
11
  function clipSegment(s) {
12
12
  return s.length > SEGMENT_MAX ? s.slice(0, SEGMENT_MAX) : s;
13
13
  }
14
+ function trailingRepeats(text, unit, cap) {
15
+ let n = 0;
16
+ let end = text.length;
17
+ while (n < cap && end - unit.length >= 0 && text.startsWith(unit, end - unit.length)) {
18
+ n++;
19
+ end -= unit.length;
20
+ }
21
+ return n;
22
+ }
14
23
  function looksLikeCodeLineUnit(unit) {
15
24
  if (!unit.includes("\n")) {
16
25
  return false;
@@ -59,7 +68,7 @@ export function inspectDegenerate(text) {
59
68
  if (!looksLikeCodeLineUnit(unit)) {
60
69
  return { degenerate: true, cut: event, spared };
61
70
  }
62
- if (t === WINDOW && reps >= SATURATION_REPS && reps * p >= t - p) {
71
+ if (t === WINDOW && reps * p >= t - p && trailingRepeats(text, unit, SATURATION_REPS) >= SATURATION_REPS) {
63
72
  return { degenerate: true, cut: event, spared };
64
73
  }
65
74
  if (!sparedUnitLoop) {
@@ -17,7 +17,11 @@ export function createRoutingBrain(routes, opts = {}) {
17
17
  const by = opts.by ?? "provider";
18
18
  const stream = (model, context, options) => {
19
19
  const key = by === "api" ? model.api : model.provider;
20
- const target = routes[key] ?? opts.fallback;
20
+ const routed = Object.hasOwn(routes, key) ? routes[key] : undefined;
21
+ const matched = routed !== null && typeof routed === "object" && typeof routed.stream === "function"
22
+ ? routed
23
+ : undefined;
24
+ const target = matched ?? opts.fallback;
21
25
  if (!target) {
22
26
  const out = createAssistantMessageEventStream();
23
27
  out.push({
@@ -4,7 +4,11 @@ export function runWithStatusSink(emit, fn) {
4
4
  return statusSinkStore.run({ emit }, fn);
5
5
  }
6
6
  export function emitBrainStatus(status) {
7
- statusSinkStore.getStore()?.emit(status);
7
+ try {
8
+ statusSinkStore.getStore()?.emit(status);
9
+ }
10
+ catch {
11
+ }
8
12
  }
9
13
  const telemetrySinkStore = new AsyncLocalStorage();
10
14
  export function runWithBrainTelemetry(emit, fn) {
@@ -8,7 +8,7 @@ const DEFAULT_MAX_RETRIES = 10;
8
8
  const MAX_RETRIES_ENV_CEILING = 15;
9
9
  const MAX_RETRIES_ENV = "SEMA_MAX_RETRIES";
10
10
  export function resolveMaxRetries(configured) {
11
- if (configured !== undefined)
11
+ if (configured !== undefined && Number.isFinite(configured))
12
12
  return configured;
13
13
  const raw = process.env[MAX_RETRIES_ENV];
14
14
  if (raw !== undefined && raw.trim() !== "") {
@@ -88,10 +88,13 @@ export function runStreamingBrain(args) {
88
88
  .catch((err) => {
89
89
  const aborted = signal?.aborted === true || isAbortError(err);
90
90
  terminalRetryPhase = "gave_up";
91
- terminalRetryDetail = aborted ? "cancelled while retrying" : isWalltimeCutoff(err) ? "wall-clock deadline reached while retrying" : "retries exhausted";
91
+ const walltimeCut = isWalltimeCutoff(err);
92
+ terminalRetryDetail = aborted ? "cancelled while retrying" : walltimeCut ? "wall-clock deadline reached while retrying" : "retries exhausted";
92
93
  const errorMsg = emptyAssistant(model);
93
94
  errorMsg.stopReason = aborted ? "aborted" : "error";
94
95
  errorMsg.errorMessage = err instanceof Error ? err.message : String(err);
96
+ if (walltimeCut)
97
+ errorMsg.errorKind = "walltime_cutoff";
95
98
  errorMsg.usageMissing = true;
96
99
  out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errorMsg });
97
100
  })
@@ -1,4 +1,5 @@
1
1
  import { type RepetitionEvent } from "./repetition.js";
2
+ export declare function parseStreamedToolArgs(json: string): Record<string, unknown> | undefined;
2
3
  export interface RepetitionPoll {
3
4
  poll(text: string): boolean;
4
5
  readonly cut: RepetitionEvent | undefined;
@@ -1,4 +1,18 @@
1
1
  import { inspectDegenerate } from "./repetition.js";
2
+ export function parseStreamedToolArgs(json) {
3
+ if (!json)
4
+ return {};
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(json);
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
13
+ return undefined;
14
+ return parsed;
15
+ }
2
16
  export function createRepetitionPoll() {
3
17
  let repCut;
4
18
  const repSpared = new Map();
@@ -0,0 +1,4 @@
1
+ import type { AssistantMessage } from "../internal/llm.js";
2
+ export type TerminalCauseCarrier = Pick<AssistantMessage, "errorKind" | "errorMessage">;
3
+ export declare function isDegenerateCutMessage(message: TerminalCauseCarrier): boolean;
4
+ export declare function isWalltimeCutMessage(message: TerminalCauseCarrier): boolean;
@@ -0,0 +1,12 @@
1
+ import { DEGENERATE_MESSAGE } from "./repetition.js";
2
+ import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
3
+ export function isDegenerateCutMessage(message) {
4
+ if (message.errorKind === "degenerate")
5
+ return true;
6
+ return message.errorMessage === DEGENERATE_MESSAGE;
7
+ }
8
+ export function isWalltimeCutMessage(message) {
9
+ if (message.errorKind === "walltime_cutoff")
10
+ return true;
11
+ return message.errorMessage === WALLTIME_CUTOFF_MESSAGE;
12
+ }
@@ -0,0 +1 @@
1
+ export declare function mintFallbackToolCallId(prefix: string): string;
@@ -0,0 +1,3 @@
1
+ export function mintFallbackToolCallId(prefix) {
2
+ return `${prefix}${Math.random().toString(36).slice(2)}`;
3
+ }