@sema-agent/core 5.2.0 → 5.4.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 (43) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/brain/anthropic.js +12 -2
  3. package/dist/brain/media-degrade.d.ts +14 -0
  4. package/dist/brain/media-degrade.js +30 -0
  5. package/dist/brain/openai.js +9 -11
  6. package/dist/brain/timeout.d.ts +14 -0
  7. package/dist/brain/timeout.js +114 -0
  8. package/dist/core/a2a-task-state.d.ts +15 -0
  9. package/dist/core/a2a-task-state.js +68 -0
  10. package/dist/core/a2a.d.ts +42 -0
  11. package/dist/core/a2a.js +651 -0
  12. package/dist/core/checkpoint-store.d.ts +6 -1
  13. package/dist/core/checkpoint-store.js +3 -1
  14. package/dist/core/exec-output-tail.d.ts +0 -1
  15. package/dist/core/exec-output-tail.js +0 -6
  16. package/dist/core/mcp.d.ts +11 -4
  17. package/dist/core/mcp.js +11 -31
  18. package/dist/core/protocol-naming.d.ts +7 -0
  19. package/dist/core/protocol-naming.js +32 -0
  20. package/dist/core/protocol-table.d.ts +3 -1
  21. package/dist/core/protocol-table.js +37 -32
  22. package/dist/core/runner/prepare-task.d.ts +9 -0
  23. package/dist/core/runner/prepare-task.js +52 -15
  24. package/dist/core/runner/runtask.js +34 -4
  25. package/dist/core/runner/tool-disclosure.d.ts +1 -1
  26. package/dist/core/runner/tool-disclosure.js +1 -1
  27. package/dist/core/runner/turn-attachments.d.ts +1 -2
  28. package/dist/core/runner/turn-attachments.js +1 -12
  29. package/dist/core/store-contracts/background-agent-store-contract.d.ts +5 -0
  30. package/dist/core/store-contracts/background-agent-store-contract.js +213 -0
  31. package/dist/core/task-registry-agent.d.ts +14 -1
  32. package/dist/core/task-registry-agent.js +2 -2
  33. package/dist/core/types.d.ts +18 -2
  34. package/dist/engine/execution-env/node-execution-env.d.ts +0 -1
  35. package/dist/engine/execution-env/node-execution-env.js +12 -30
  36. package/dist/index.d.ts +8 -3
  37. package/dist/index.js +6 -2
  38. package/dist/prompt-assembly/event-registry.js +1 -0
  39. package/dist/tools/fs/fs-read.js +5 -3
  40. package/dist/tools/fs/fs-shared.d.ts +1 -0
  41. package/dist/tools/fs/fs-shared.js +1 -0
  42. package/dist/tools/web.js +12 -4
  43. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,44 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.4.0 (2026-08-03)
4
+
5
+ _The external-audit dunning batch closes end-to-end (small cases + all three cleared A-group debts), and the engine grows its outer brain-call guardrail. All additive or louder; major stays locked at 5._
6
+
7
+ ### Fixed
8
+
9
+ - webfetch: the tool card follows the wired capability (a mount without a summarizer no longer advertises `prompt` in prose or schema), and an error response stops silently eating the caller's `prompt` (the error leg now carries the same "was NOT applied" disclosure family, naming the HTTP status). Error bodies are deliberately NOT fed to the summarizer (a 404 page dressed up as "the requested analysis" would be worse than the raw dump) — pinned as a negative control.
10
+ - Background-agent poll projection: stop-attribution rides whenever the ROW carries it — the stale-running reaper settles rows as `failed` with `stoppedBy:"system"`, and the previous killed-only gate dropped that fact, making a reaped row indistinguishable from a natural failure. A row without the field still projects nothing.
11
+ - Brains: an ERROR tool result now discloses the media it had to drop (`[N images omitted — …]`, both wire formats) instead of silently presenting an attachment-free error; the vision-degrade predicates and placeholder wording collapse into one construction point (`media-degrade.ts`).
12
+ - Image read cap: the refusal for an oversized image without a downsampler now names `sharp` (declared in `optionalDependencies`) and the 64MB ceiling it unlocks — the cap itself is unchanged.
13
+
14
+ - Background-command spool (RB-235 close-out): a LIVE writer's spool file is append-only — the reclaim truncate now waits for the writer to be terminal, closing the stat→truncate race window that silently destroyed whole buffered write blocks (external audit reproduced 8192-multiple losses on 5.3.0; the fix's own red-first run recorded 65536/720896/294912-byte ledger gaps). Cost, stated honestly: a live writer past the hard cap no longer has physical space reclaimed until it dies (the reader stays bounded and byte-accounted as before); the file is reclaimed wholesale at writer terminal. The live-rotation disclosure marker is retired with the race (structurally impossible now).
15
+
16
+ ### Added
17
+
18
+ - `STALE_RUNNING_REAP_ATTRIBUTION` (exported): the reaper's settle wording reaches the package root, so an out-of-repo store twin can import the attribution instead of value-copying it against the contract kit (downstream pickup request).
19
+ - `TaskSpec.limits.brainCallGuardrailMs` + `RunnerDeps.brainCallGuardrailMs` (RB-458): an OUTER per-call guardrail on how long the engine waits for one brain call — default 1 hour, `false`/`0` switches it off, invalid values refuse loudly at prepare. Independent of brain cooperation: expiry aborts the brain's signal AND stops the engine's own waiting at every await point, so a custom Brain that ignores `options.signal` can no longer wedge a run forever (previously the de-facto bound was an accidental property of the bundled fetch stack). A run stopped this way terminates loud with `TaskResult.errorCode = "brain.call_guardrail_timeout"`; walltime-budgeted runs keep their own (usually tighter) clocks — the two race and the earlier one wins.
20
+
21
+ ## 5.3.0 (2026-08-03)
22
+
23
+ _The A2A arc: client+server legs land end-to-end (all additive), the workflow-size notice lane closes its channel, and three naming/containment defects fix. Major stays locked at 5._
24
+
25
+ ### Fixed
26
+
27
+ - Protocol naming (ticket #10): a minted peer segment can no longer parse back as a DIFFERENT peer — adjacent out-of-charset characters used to normalize into the `__` separator (`"prod. db"` → `prod__db`, read back as peer `prod`), and a clamp could splice one in at the digest join. The peer leg folds `_` runs after the clamp (an all-separator peer degrades to a readable digest), the digest joins with `-`; only names for peers over the assembly budget change spelling. The tool segment is untouched (`repo__delete` round-trips as before).
28
+ - MCP tool `description` is neutralized (`sanitizeUntrustedText`) before it reaches the model-facing tool card (ticket #11) — it was the one remote text with no neutralization at all (instructions are sanitized+fenced, drop reasons inlined, error bodies fenced); the text is otherwise verbatim (no fence: that would poison every tool table for the honest majority).
29
+ - MCP refresh receipt `prefix` (ticket #9): the refresh leg recomputed the server prefix without the assembly budget, so a server name surviving normalization but not the 64-char budget produced a prefix matching none of the registered names — the splice domain then removed nothing and the fresh tools mounted alongside the stale ones under the same names. Minting (normalize + budget + clamp) is now single-sourced in `protocol-naming.ts`; the intake and the receipt call the same arithmetic.
30
+
31
+ ### Added
32
+
33
+ - `workflow_size_guideline_change` turn-boundary frame: a mid-run retune of `RunnerDeps.workflowLimits.sizeGuideline` now reaches the model at the next tool-calling boundary (the Workflow tool card is a per-mount snapshot and never re-renders). Same discipline as the `date_change`/`instructions_change` lanes: continuation-gated (a retune landing on a text-final turn never revives a finished run), fixed one-sentence frame above the bundle byte cap, comparison on the RESOLVED guideline (writing down the value already in force is not a change), armed only when the Workflow tool is mounted. `TaskEvent.steering_injected.source` gains the member (additive).
34
+ - Internal cleanup: the todo projection's dead `statusCounts` field is gone (computed on every TodoWrite, never consumed by any model-facing renderer).
35
+ - `toA2ATaskState` / `fromA2ATaskState` / `A2A_TASK_STATES` (exported): the pure projection between the durable background-agent row status and the A2A TaskState vocabulary. Forward is total (`killed` splits on `stoppedBy`: user/parent → `canceled`, anything else → `failed`); reverse is 9→5 and returns a discriminated `{lossy, status, note?}` — `lossy: false` holds exactly for the states that survive the round trip (pinned as a property test). Serving layers must still gate every row through `canAccessAgentRecord`.
36
+ - `queryBackgroundAgents` / `serveDurableAgentRowLane` / `buildAgentPollDetails` (+ their named input/result types) reach the package root — the row-serving primitives an A2A-style host endpoint composes, previously module-internal.
37
+ - `backgroundAgentStoreContract` + `backgroundAgentStoreScopesContract`: the contract kit the store family was missing — third-party `BackgroundAgentStore` backends can now run the same assertion set the bundled InMemory/File backends pass (rev-CAS, guarded updateIf, filter semantics, stale-running reap attribution; the optional `listScopes` layer is opt-in so an unimplemented method cannot skip silently).
38
+ - `TaskSpec.a2a` + `A2aServerSpec` + `materializeA2aTools` / `MaterializedA2a` (exported) + `onError` phase `"a2a"`: the A2A client leg — declared peers' agent cards are discovered (`agent-card.json` with legacy fallback, explicit `cardUrl` override), their skills mount as task-scoped tools (`a2a__<peer>__<skill>`; the protocol gives skills no input schema, so the engine synthesizes `{message, taskId?, contextId?}` and folds tags/examples into the sanitized, bounded description), calls ride JSON-RPC `message/send` with terminal-state polling under dual clocks, and every request declares `A2A-Version: 1.0`. Both legal reply shapes are honored (a direct Message and a polled Task); `input-required`/`auth-required` return immediately with the continuation keys instead of burning the ceiling. Default axes are `egress` + `write` (a remote agent is an external write; the caller's `toolAxes` is the only lowering channel), success bodies come back fenced as untrusted data (a deliberate divergence from the MCP success path — the sender is an agent, not a data source), and a `FilePart` uri is shown as text, never fetched. One unreachable peer never bricks its healthy sibling.
39
+ - `A2A_NAMESPACE` (exported): the protocol table's second entry — the `a2a__<peer>__<skill>` name shape, parse/makeName/displayGroupKey under the same destructuring-safe contract as MCP (both entries now built by one factory). The reserved-name and policy-audit messages now enumerate every table prefix instead of spelling `mcp__` by hand.
40
+ - `CheckpointError.detail` (additive, optional): structured discrimination where the code alone is ambiguous — `detail.field` names WHICH decision-action binding failed on `checkpoint.invalid_outcome` (`"boundCallId"`: the action you decided on was replaced, re-fetch the pending list; `"boundInputHash"`: the input you reviewed changed under the same action, re-review). `code` remains the only required discriminant.
41
+
3
42
  ## 5.2.0 (2026-08-03)
4
43
 
5
44
  _The v5.0.1..v5.1.0 full-window dual-lens review's repair batch (67 findings, all dispositioned; see docs/REVIEW-BACKLOG.md). Theme: the 5.1.0 retirements now refuse loudly EVERYWHERE they promised to, and the new disclosure seats reach the deployments they were built for._
@@ -5,6 +5,7 @@ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  import { emitBrainTelemetry } from "./status-sink.js";
8
+ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
9
  import { ANTHROPIC_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
9
10
  import { isThinkingLevel, reasoningBudgetShare, resolveEffort } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
@@ -37,7 +38,7 @@ function toUserBlocks(content, supportsVision = true) {
37
38
  blocks.push({ type: "image", source: { type: "base64", media_type: c.mimeType, data: c.data } });
38
39
  else {
39
40
  omitted++;
40
- blocks.push({ type: "text", text: "[image omitted: the serving model does not support image input]" });
41
+ blocks.push({ type: "text", text: IMAGE_OMITTED_NO_VISION });
41
42
  }
42
43
  }
43
44
  }
@@ -46,7 +47,7 @@ function toUserBlocks(content, supportsVision = true) {
46
47
  return blocks;
47
48
  }
48
49
  function toAnthropicMessages(ctx, model) {
49
- const supportsVision = model?.input === undefined ? true : model.input.includes("image");
50
+ const supportsVision = modelSupportsVision(model);
50
51
  const raw = [];
51
52
  let pendingDocs = [];
52
53
  const flushDocs = () => {
@@ -112,6 +113,15 @@ function toAnthropicMessages(ctx, model) {
112
113
  inner.push({ type: "text", text: note });
113
114
  }
114
115
  }
116
+ if (m.isError) {
117
+ const note = errorResultMediaNote({ images: sendableImages(Array.isArray(rest) ? rest : []).length, documents: docs.length });
118
+ if (note !== undefined) {
119
+ if (typeof inner === "string")
120
+ inner = inner ? `${inner}\n${note}` : note;
121
+ else
122
+ inner.push({ type: "text", text: note });
123
+ }
124
+ }
115
125
  if (typeof inner === "string" ? inner.length === 0 : inner.length === 0)
116
126
  inner = NO_CONTENT;
117
127
  raw.push({
@@ -0,0 +1,14 @@
1
+ import type { Model } from "../internal/llm.js";
2
+ export declare function modelSupportsVision(model?: Model): boolean;
3
+ export declare const IMAGE_OMITTED_NO_VISION = "[image omitted: the serving model does not support image input]";
4
+ export declare function imagesOmittedNoVisionNote(count: number): string;
5
+ export interface SendableImage {
6
+ type: "image";
7
+ data: string;
8
+ mimeType: string;
9
+ }
10
+ export declare function sendableImages(blocks: readonly unknown[]): SendableImage[];
11
+ export declare function errorResultMediaNote(counts: {
12
+ images: number;
13
+ documents: number;
14
+ }): string | undefined;
@@ -0,0 +1,30 @@
1
+ export function modelSupportsVision(model) {
2
+ return model?.input === undefined ? true : model.input.includes("image");
3
+ }
4
+ export const IMAGE_OMITTED_NO_VISION = "[image omitted: the serving model does not support image input]";
5
+ export function imagesOmittedNoVisionNote(count) {
6
+ return count > 1 ? `[${count} images omitted: the serving model does not support image input]` : IMAGE_OMITTED_NO_VISION;
7
+ }
8
+ export function sendableImages(blocks) {
9
+ const out = [];
10
+ for (const b of blocks) {
11
+ const c = b;
12
+ if (c.type === "image" && typeof c.data === "string" && typeof c.mimeType === "string") {
13
+ out.push({ type: "image", data: c.data, mimeType: c.mimeType });
14
+ }
15
+ }
16
+ return out;
17
+ }
18
+ function plural(count, noun) {
19
+ return `${count} ${noun}${count > 1 ? "s" : ""}`;
20
+ }
21
+ export function errorResultMediaNote(counts) {
22
+ const dropped = [];
23
+ if (counts.images > 0)
24
+ dropped.push(plural(counts.images, "image"));
25
+ if (counts.documents > 0)
26
+ dropped.push(plural(counts.documents, "document"));
27
+ if (dropped.length === 0)
28
+ return undefined;
29
+ return `[${dropped.join(" and ")} omitted — an error tool result must be text-only; re-run the tool successfully if you need the attachment]`;
30
+ }
@@ -5,6 +5,7 @@ import { createRepetitionPoll, createWalltimeGate, parseStreamedToolArgs } from
5
5
  import { mintFallbackToolCallId } from "./tool-call-id.js";
6
6
  import { WALLTIME_CUTOFF_MESSAGE } from "./walltime.js";
7
7
  import { emitBrainTelemetry } from "./status-sink.js";
8
+ import { errorResultMediaNote, IMAGE_OMITTED_NO_VISION, imagesOmittedNoVisionNote, modelSupportsVision, sendableImages } from "./media-degrade.js";
8
9
  import { OPENAI_RESERVED, applyExtraBody, stripAuthHeaders } from "./request-params.js";
9
10
  import { DEFAULT_EFFORT_LEVELS, isThinkingLevel, resolveEffort } from "./reasoning.js";
10
11
  import { runStreamingBrain } from "./stream-engine.js";
@@ -30,10 +31,6 @@ function textOf(content) {
30
31
  .map((c) => c.text)
31
32
  .join("");
32
33
  }
33
- function visionOf(model) {
34
- return model?.input === undefined ? true : model.input.includes("image");
35
- }
36
- const IMAGE_OMITTED = "[image omitted: the serving model does not support image input]";
37
34
  const NO_CONTENT = "(no content)";
38
35
  const DOCUMENT_OMITTED = "[PDF document omitted: PDF direct read requires an Anthropic-family model; re-Read the file (the engine will extract text/render pages when possible) or extract the text via Bash instead (e.g. `pdftotext`).]";
39
36
  function toUserContent(content, supportsVision = true) {
@@ -54,7 +51,7 @@ function toUserContent(content, supportsVision = true) {
54
51
  const n = content.filter((c) => c.type === "image").length;
55
52
  emitBrainTelemetry({ kind: "vision_placeholder", count: n });
56
53
  const base = textOf(content);
57
- const note = n > 1 ? `[${n} images omitted: the serving model does not support image input]` : IMAGE_OMITTED;
54
+ const note = imagesOmittedNoVisionNote(n);
58
55
  const withNote = base ? `${base}\n${note}` : note;
59
56
  return nDocs > 0 ? `${withNote}\n${DOCUMENT_OMITTED}` : withNote;
60
57
  }
@@ -139,7 +136,7 @@ function applyThinking(body, model, reasoning) {
139
136
  }
140
137
  }
141
138
  function toOpenAIMessages(ctx, replayThinking = true, model) {
142
- const supportsVision = visionOf(model);
139
+ const supportsVision = modelSupportsVision(model);
143
140
  const out = [];
144
141
  const needsEmptyReasoningField = !!model?.reasoning && thinkingCompat(model).requiresReasoningContentOnAssistantMessages === true;
145
142
  if (ctx.systemPrompt) {
@@ -194,9 +191,8 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
194
191
  out.push(msg);
195
192
  }
196
193
  else if (m.role === "toolResult") {
197
- const rawImages = !m.isError && Array.isArray(m.content)
198
- ? m.content.filter((c) => c.type === "image" && typeof c.data === "string" && typeof c.mimeType === "string")
199
- : [];
194
+ const mediaImages = sendableImages(Array.isArray(m.content) ? m.content : []);
195
+ const rawImages = m.isError ? [] : mediaImages;
200
196
  const images = supportsVision ? rawImages : [];
201
197
  if (!supportsVision && rawImages.length > 0)
202
198
  emitBrainTelemetry({ kind: "vision_placeholder", count: rawImages.length });
@@ -205,11 +201,13 @@ function toOpenAIMessages(ctx, replayThinking = true, model) {
205
201
  emitBrainTelemetry({ kind: "document_placeholder", count: nDocs });
206
202
  const baseText0 = textOf(m.content);
207
203
  const baseText = nDocs > 0 ? (baseText0 ? `${baseText0}\n${DOCUMENT_OMITTED}` : DOCUMENT_OMITTED) : baseText0;
208
- const text = !supportsVision && rawImages.length > 0 ? (baseText ? `${baseText}\n${IMAGE_OMITTED}` : IMAGE_OMITTED) : baseText;
204
+ const text = !supportsVision && rawImages.length > 0 ? (baseText ? `${baseText}\n${IMAGE_OMITTED_NO_VISION}` : IMAGE_OMITTED_NO_VISION) : baseText;
205
+ const errorNote = m.isError ? errorResultMediaNote({ images: mediaImages.length, documents: 0 }) : undefined;
206
+ const finalText = errorNote === undefined ? text : text ? `${text}\n${errorNote}` : errorNote;
209
207
  out.push({
210
208
  role: "tool",
211
209
  tool_call_id: m.toolCallId,
212
- content: toolResultText(text, images.length > 0, m.content),
210
+ content: toolResultText(finalText, images.length > 0, m.content),
213
211
  });
214
212
  for (const img of images)
215
213
  pendingImages.push({ toolCallId: m.toolCallId, data: img.data, mimeType: img.mimeType });
@@ -1,3 +1,4 @@
1
+ import type { StreamFn } from "../internal/llm.js";
1
2
  export interface BrainTimeoutConfig {
2
3
  connectTimeoutMs?: number;
3
4
  firstTokenTimeoutMs?: number;
@@ -10,3 +11,16 @@ export declare function createConnectController(connectTimeoutMs: number | undef
10
11
  clearTimer: () => void;
11
12
  dispose: () => void;
12
13
  };
14
+ export declare const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3600000;
15
+ export declare const BRAIN_CALL_GUARDRAIL_ERROR_CODE = "brain.call_guardrail_timeout";
16
+ export declare class BrainCallGuardrailTimeoutError extends Error {
17
+ readonly code = "brain.call_guardrail_timeout";
18
+ readonly limitMs: number;
19
+ constructor(limitMs: number);
20
+ }
21
+ export type BrainCallGuardrailKnob = number | false;
22
+ export declare function resolveBrainCallGuardrailMs(knob: BrainCallGuardrailKnob | undefined): number | undefined;
23
+ export interface BrainCallGuardrailRef {
24
+ timedOut?: BrainCallGuardrailTimeoutError;
25
+ }
26
+ export declare function withBrainCallGuardrail(streamFn: StreamFn, limitMs: number, ref: BrainCallGuardrailRef): StreamFn;
@@ -44,3 +44,117 @@ export function createConnectController(connectTimeoutMs, outerSignal, deadlineA
44
44
  },
45
45
  };
46
46
  }
47
+ export const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3_600_000;
48
+ export const BRAIN_CALL_GUARDRAIL_ERROR_CODE = "brain.call_guardrail_timeout";
49
+ export class BrainCallGuardrailTimeoutError extends Error {
50
+ code = BRAIN_CALL_GUARDRAIL_ERROR_CODE;
51
+ limitMs;
52
+ constructor(limitMs) {
53
+ super(`the engine stopped waiting for a brain call that ran past its ${limitMs}ms guardrail — the call was abandoned (a Brain that ignores options.signal may still be running)`);
54
+ this.name = "BrainCallGuardrailTimeoutError";
55
+ this.limitMs = limitMs;
56
+ }
57
+ }
58
+ export function resolveBrainCallGuardrailMs(knob) {
59
+ if (knob === undefined)
60
+ return BRAIN_CALL_GUARDRAIL_DEFAULT_MS;
61
+ if (knob === false || knob === 0)
62
+ return undefined;
63
+ if (typeof knob !== "number" || !Number.isFinite(knob) || knob < 0) {
64
+ const e = new Error(`brainCallGuardrailMs must be a positive number of milliseconds, 0, or false (got ${String(knob)})`);
65
+ e.code = "config.brain_call_guardrail_invalid";
66
+ throw e;
67
+ }
68
+ return knob;
69
+ }
70
+ function armCallGuardrail(limitMs, outerSignal, ref) {
71
+ const ac = new AbortController();
72
+ if (outerSignal !== undefined) {
73
+ if (outerSignal.aborted)
74
+ ac.abort(outerSignal.reason);
75
+ }
76
+ const forwardAbort = () => {
77
+ ac.abort(outerSignal === undefined ? undefined : outerSignal.reason);
78
+ };
79
+ if (outerSignal !== undefined && !outerSignal.aborted) {
80
+ outerSignal.addEventListener("abort", forwardAbort, { once: true });
81
+ }
82
+ let expired;
83
+ let rejectExpiry = () => undefined;
84
+ const expiry = new Promise((_resolve, reject) => {
85
+ rejectExpiry = reject;
86
+ });
87
+ expiry.catch(() => undefined);
88
+ const timer = setTimeout(() => {
89
+ const err = new BrainCallGuardrailTimeoutError(limitMs);
90
+ expired = err;
91
+ ref.timedOut ??= err;
92
+ ac.abort(err);
93
+ rejectExpiry(err);
94
+ }, limitMs);
95
+ return {
96
+ signal: ac.signal,
97
+ race: async (p) => {
98
+ if (expired !== undefined)
99
+ throw expired;
100
+ const value = await Promise.race([p, expiry]);
101
+ if (expired !== undefined)
102
+ throw expired;
103
+ return value;
104
+ },
105
+ disarm: () => {
106
+ clearTimeout(timer);
107
+ if (outerSignal !== undefined)
108
+ outerSignal.removeEventListener("abort", forwardAbort);
109
+ },
110
+ };
111
+ }
112
+ export function withBrainCallGuardrail(streamFn, limitMs, ref) {
113
+ return async (model, context, options) => {
114
+ const gate = armCallGuardrail(limitMs, options?.signal, ref);
115
+ let inner;
116
+ try {
117
+ inner = await gate.race(Promise.resolve(streamFn(model, context, { ...options, signal: gate.signal })));
118
+ }
119
+ catch (err) {
120
+ gate.disarm();
121
+ throw err;
122
+ }
123
+ return {
124
+ [Symbol.asyncIterator]() {
125
+ const it = inner[Symbol.asyncIterator]();
126
+ return {
127
+ next: async () => {
128
+ let step;
129
+ try {
130
+ step = await gate.race(Promise.resolve(it.next()));
131
+ }
132
+ catch (err) {
133
+ gate.disarm();
134
+ throw err;
135
+ }
136
+ if (step.done === true)
137
+ gate.disarm();
138
+ return step;
139
+ },
140
+ return: async () => {
141
+ gate.disarm();
142
+ const release = it.return;
143
+ if (release !== undefined) {
144
+ Promise.resolve(release.call(it)).catch(() => undefined);
145
+ }
146
+ return { value: undefined, done: true };
147
+ },
148
+ };
149
+ },
150
+ result: async () => {
151
+ try {
152
+ return await gate.race(Promise.resolve(inner.result()));
153
+ }
154
+ finally {
155
+ gate.disarm();
156
+ }
157
+ },
158
+ };
159
+ };
160
+ }
@@ -0,0 +1,15 @@
1
+ import type { BackgroundAgentRecord } from "./background-agent-store.js";
2
+ export declare const A2A_TASK_STATES: readonly ["submitted", "working", "input-required", "completed", "canceled", "failed", "rejected", "auth-required", "unknown"];
3
+ export type A2ATaskState = (typeof A2A_TASK_STATES)[number];
4
+ export declare function toA2ATaskState(record: Pick<BackgroundAgentRecord, "status" | "stoppedBy">): A2ATaskState;
5
+ export interface A2ATaskStateReversalFaithful {
6
+ lossy: false;
7
+ status: BackgroundAgentRecord["status"];
8
+ }
9
+ export interface A2ATaskStateReversalLossy {
10
+ lossy: true;
11
+ status: BackgroundAgentRecord["status"];
12
+ note: string;
13
+ }
14
+ export type A2ATaskStateReversal = A2ATaskStateReversalFaithful | A2ATaskStateReversalLossy;
15
+ export declare function fromA2ATaskState(state: A2ATaskState): A2ATaskStateReversal;
@@ -0,0 +1,68 @@
1
+ export const A2A_TASK_STATES = [
2
+ "submitted",
3
+ "working",
4
+ "input-required",
5
+ "completed",
6
+ "canceled",
7
+ "failed",
8
+ "rejected",
9
+ "auth-required",
10
+ "unknown",
11
+ ];
12
+ const CANCELLING_STOP_SOURCES = new Set(["user", "parent"]);
13
+ export function toA2ATaskState(record) {
14
+ switch (record.status) {
15
+ case "running":
16
+ return "working";
17
+ case "parked":
18
+ return "input-required";
19
+ case "completed":
20
+ return "completed";
21
+ case "failed":
22
+ return "failed";
23
+ case "killed":
24
+ return record.stoppedBy !== undefined && CANCELLING_STOP_SOURCES.has(record.stoppedBy) ? "canceled" : "failed";
25
+ }
26
+ }
27
+ export function fromA2ATaskState(state) {
28
+ switch (state) {
29
+ case "working":
30
+ return { lossy: false, status: "running" };
31
+ case "input-required":
32
+ return { lossy: false, status: "parked" };
33
+ case "completed":
34
+ return { lossy: false, status: "completed" };
35
+ case "failed":
36
+ return { lossy: false, status: "failed" };
37
+ case "submitted":
38
+ return {
39
+ lossy: true,
40
+ status: "running",
41
+ note: "the durable ledger has no accepted-but-not-yet-started state — a row exists only once its child is running, so the distinction between submitted and working is dropped.",
42
+ };
43
+ case "canceled":
44
+ return {
45
+ lossy: true,
46
+ status: "killed",
47
+ note: "the cancellation attribution lives in the row's stoppedBy field, which a state alone cannot carry: a killed row WITHOUT stoppedBy in (user, parent) projects back as failed, not canceled.",
48
+ };
49
+ case "rejected":
50
+ return {
51
+ lossy: true,
52
+ status: "failed",
53
+ note: "rejection happens before a row exists (admission-time), so it is recorded as a failed terminal — the fact that the task was never admitted at all is lost.",
54
+ };
55
+ case "auth-required":
56
+ return {
57
+ lossy: true,
58
+ status: "failed",
59
+ note: "auth-required is a resumable waiting state with no durable seat here (parked is bound to an approval-checkpoint token, which this state has none of), so it lands as a failed terminal and is NOT resumable through this mapping.",
60
+ };
61
+ case "unknown":
62
+ return {
63
+ lossy: true,
64
+ status: "failed",
65
+ note: "unknown is a read-projection state (an outcome this engine instance cannot see), not a durable outcome; writing it back asserts a terminal failure the row itself never recorded.",
66
+ };
67
+ }
68
+ }
@@ -0,0 +1,42 @@
1
+ import type { AgentTool } from "../internal/harness-types.js";
2
+ import type { A2aServerSpec, ToolEffect } from "./types.js";
3
+ export declare const A2A_SKILL_DESCRIPTION_MAX_CHARS = 2048;
4
+ export interface A2aToolAxis {
5
+ name: string;
6
+ irreversibility?: "always";
7
+ egress?: true;
8
+ effect?: ToolEffect;
9
+ }
10
+ export interface A2aPeerStatus {
11
+ name: string;
12
+ status: "ready" | "failed";
13
+ agentName?: string;
14
+ agentDescription?: string;
15
+ endpoint?: string;
16
+ toolNames?: string[];
17
+ error?: string;
18
+ }
19
+ export interface A2aRefreshResult {
20
+ peer: string;
21
+ prefix: string;
22
+ status: "refreshed" | "failed" | "disposed";
23
+ toolCount: number;
24
+ added: string[];
25
+ removed: string[];
26
+ tools?: AgentTool[];
27
+ axes?: A2aToolAxis[];
28
+ error?: string;
29
+ }
30
+ export interface MaterializedA2a {
31
+ tools: AgentTool[];
32
+ toolAxes: A2aToolAxis[];
33
+ warnings: Error[];
34
+ statuses: A2aPeerStatus[];
35
+ refresh: (peer?: string) => Promise<A2aRefreshResult[]>;
36
+ dispose: () => Promise<void>;
37
+ }
38
+ export declare class A2aRpcError extends Error {
39
+ readonly code: number;
40
+ constructor(peer: string, method: string, code: number, message: string);
41
+ }
42
+ export declare function materializeA2aTools(specs: readonly A2aServerSpec[], principal?: string, signal?: AbortSignal): Promise<MaterializedA2a>;