@sema-agent/core 2.3.0 → 2.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 (61) hide show
  1. package/dist/agents/send-message-tool.d.ts +4 -0
  2. package/dist/agents/send-message-tool.js +37 -24
  3. package/dist/agents/subagent.js +275 -127
  4. package/dist/brain/errors.d.ts +1 -0
  5. package/dist/brain/errors.js +14 -0
  6. package/dist/brain/stream-engine.js +3 -3
  7. package/dist/core/context-edit.js +2 -1
  8. package/dist/core/runner/prepare-task.js +20 -1
  9. package/dist/core/runner/tool-output-projection.js +2 -1
  10. package/dist/core/store-contracts/checkpoint-store-contract.d.ts +37 -0
  11. package/dist/core/store-contracts/checkpoint-store-contract.js +195 -0
  12. package/dist/core/store-contracts/contract-harness.d.ts +6 -0
  13. package/dist/core/store-contracts/contract-harness.js +16 -0
  14. package/dist/core/store-contracts/contract-kit-version.d.ts +1 -0
  15. package/dist/core/store-contracts/contract-kit-version.js +2 -0
  16. package/dist/core/store-contracts/file-snapshot-store-contract.d.ts +3 -0
  17. package/dist/core/store-contracts/file-snapshot-store-contract.js +126 -0
  18. package/dist/core/store-contracts/mailbox-store-contract.d.ts +6 -0
  19. package/dist/core/store-contracts/mailbox-store-contract.js +193 -0
  20. package/dist/core/store-contracts/session-repo-contract.d.ts +3 -0
  21. package/dist/core/store-contracts/session-repo-contract.js +36 -0
  22. package/dist/core/store-contracts/tool-result-store-contract.d.ts +3 -0
  23. package/dist/core/store-contracts/tool-result-store-contract.js +35 -0
  24. package/dist/core/task-notification.d.ts +2 -0
  25. package/dist/core/task-registry-agent.d.ts +4 -0
  26. package/dist/core/task-registry-agent.js +13 -0
  27. package/dist/core/task-registry-monitor.js +6 -6
  28. package/dist/core/task-registry-shared.d.ts +8 -2
  29. package/dist/core/task-registry-shared.js +1 -1
  30. package/dist/core/task-registry.d.ts +6 -0
  31. package/dist/core/task-registry.js +48 -4
  32. package/dist/core/tool-result-store.d.ts +3 -2
  33. package/dist/core/tool-result-store.js +12 -4
  34. package/dist/core/trace.d.ts +7 -0
  35. package/dist/engine/lsp/node-lsp-manager.d.ts +2 -0
  36. package/dist/engine/lsp/node-lsp-manager.js +16 -0
  37. package/dist/index.d.ts +1 -0
  38. package/dist/index.js +1 -0
  39. package/dist/orchestration/builtin-workflows.d.ts +1 -1
  40. package/dist/orchestration/builtin-workflows.js +11 -2
  41. package/dist/orchestration/workflow-governance.d.ts +6 -1
  42. package/dist/orchestration/workflow-governance.js +24 -4
  43. package/dist/orchestration/workflow-primitives.js +7 -1
  44. package/dist/orchestration/workflow.d.ts +1 -0
  45. package/dist/orchestration/workflow.js +31 -2
  46. package/dist/tools/fs/fs-bash.d.ts +7 -1
  47. package/dist/tools/fs/fs-bash.js +51 -20
  48. package/dist/tools/fs/fs-read.js +22 -11
  49. package/dist/tools/fs/fs-search-tools.js +3 -3
  50. package/dist/tools/fs/fs-shared.d.ts +20 -7
  51. package/dist/tools/fs/fs-shared.js +17 -3
  52. package/dist/tools/fs/fs-write.js +4 -4
  53. package/dist/tools/fs/index.d.ts +2 -0
  54. package/dist/tools/fs/index.js +7 -1
  55. package/dist/tools/fs/repo-map.js +2 -2
  56. package/dist/tools/fs/safety.d.ts +10 -0
  57. package/dist/tools/fs/safety.js +15 -1
  58. package/dist/tools/monitor.js +18 -4
  59. package/dist/tools/web.js +6 -2
  60. package/dist/tools/worktree.js +46 -25
  61. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { Type } from "typebox";
3
3
  import { defineTool, errorResult } from "./tools.js";
4
+ import { TOOL_SEARCH_NAME } from "./runner/tool-disclosure.js";
4
5
  export function assertSafeToolResultRef(ref) {
5
6
  const bad = ref === "" ||
6
7
  ref === "." ||
@@ -92,6 +93,13 @@ export function isVolatileOffloadStore(store) {
92
93
  return store instanceof InMemoryToolResultStore;
93
94
  }
94
95
  export const OFFLOAD_TOOL_NAME = "ReadToolResult";
96
+ export function offloadPagebackHint(ref, form, reachableTools) {
97
+ const activate = reachableTools !== undefined && !reachableTools.has(OFFLOAD_TOOL_NAME)
98
+ ? `${OFFLOAD_TOOL_NAME} is not active yet — call ${TOOL_SEARCH_NAME} with {"query":"select:${OFFLOAD_TOOL_NAME}"} to activate it, then `
99
+ : "";
100
+ const core = `${activate === "" && form === "preview" ? "Call" : `${activate}call`} ${OFFLOAD_TOOL_NAME} with ref`;
101
+ return form === "preview" ? `${core}="${ref}" (offset, limit) to read more.` : `full text persisted; ${core} "${ref}" to read it back`;
102
+ }
95
103
  export const PERSISTED_OUTPUT_PREFIX = "<persisted-output ref=";
96
104
  export const DEFAULT_TOOL_RESULT_THRESHOLD_CHARS = 20_000;
97
105
  export function firstPartyOffloadPolicy(toolName) {
@@ -122,7 +130,7 @@ function totalTextChars(content) {
122
130
  n += b.text.length;
123
131
  return n;
124
132
  }
125
- export function buildPreview(full, ref, sizes) {
133
+ export function buildPreview(full, ref, sizes, reachableTools) {
126
134
  const total = full.length;
127
135
  const headChars = sizes?.head ?? PREVIEW_HEAD_CHARS;
128
136
  const tailChars = sizes?.tail ?? PREVIEW_TAIL_CHARS;
@@ -131,11 +139,11 @@ export function buildPreview(full, ref, sizes) {
131
139
  const tail = full.slice(tailStart);
132
140
  return (`${PERSISTED_OUTPUT_PREFIX}"${ref}" chars="${total}">\n` +
133
141
  `${head}\n` +
134
- `…[truncated — ${total} chars total. Call ${OFFLOAD_TOOL_NAME} with ref="${ref}" (offset, limit) to read more.]\n` +
142
+ `…[truncated — ${total} chars total. ${offloadPagebackHint(ref, "preview", reachableTools)}]\n` +
135
143
  `${tail}\n` +
136
144
  `</persisted-output>`);
137
145
  }
138
- export function withToolResultOffload(tool, store, thresholdChars, sessionId) {
146
+ export function withToolResultOffload(tool, store, thresholdChars, sessionId, reachableTools) {
139
147
  const wrappedExecute = async (toolCallId, params, signal, onUpdate) => {
140
148
  const res = await tool.execute(toolCallId, params, signal, onUpdate);
141
149
  if (totalTextChars(res.content) <= thresholdChars)
@@ -149,7 +157,7 @@ export function withToolResultOffload(tool, store, thresholdChars, sessionId) {
149
157
  const ref = buildToolResultRef(sessionId, toolCallId);
150
158
  await store.put(ref, full);
151
159
  const images = res.content.filter((b) => b.type !== "text");
152
- return { ...res, content: [{ type: "text", text: buildPreview(full, ref) }, ...images] };
160
+ return { ...res, content: [{ type: "text", text: buildPreview(full, ref, undefined, reachableTools?.()) }, ...images] };
153
161
  };
154
162
  return { ...tool, execute: wrappedExecute };
155
163
  }
@@ -104,6 +104,13 @@ export type TraceEvent = {
104
104
  }>;
105
105
  overrideReasons?: Record<string, string>;
106
106
  ts: number;
107
+ } | {
108
+ kind: "config.additional_directory_skipped";
109
+ version: 1;
110
+ taskId: string;
111
+ entry: string;
112
+ reason: string;
113
+ ts: number;
107
114
  } | {
108
115
  kind: "task.end";
109
116
  version: 1;
@@ -28,6 +28,7 @@ export declare class NodeLspManager implements LspServerManager {
28
28
  private sweepTimer?;
29
29
  private readonly cache;
30
30
  private readonly opening;
31
+ private readonly failed;
31
32
  private disposed;
32
33
  constructor(opts?: NodeLspManagerOptions);
33
34
  private sweepIdle;
@@ -35,6 +36,7 @@ export declare class NodeLspManager implements LspServerManager {
35
36
  private open;
36
37
  private evictOverCap;
37
38
  evict(root: string): Promise<void>;
39
+ clearFailed(root?: string): void;
38
40
  private openSession;
39
41
  private serverCmd;
40
42
  dispose(): Promise<void>;
@@ -59,6 +59,7 @@ export class NodeLspManager {
59
59
  sweepTimer;
60
60
  cache = new Map();
61
61
  opening = new Map();
62
+ failed = new Map();
62
63
  disposed = false;
63
64
  constructor(opts = {}) {
64
65
  this.servers = { ...DEFAULT_LSP_SERVERS, ...opts.servers };
@@ -96,6 +97,11 @@ export class NodeLspManager {
96
97
  cached.lastUsed = Date.now();
97
98
  return cached.session;
98
99
  }
100
+ const neg = this.failed.get(key);
101
+ if (neg !== undefined) {
102
+ neg.attempts += 1;
103
+ return undefined;
104
+ }
99
105
  if (cached) {
100
106
  this.cache.delete(key);
101
107
  const carry = cached.session.openedFiles();
@@ -163,6 +169,14 @@ export class NodeLspManager {
163
169
  }
164
170
  await Promise.all(closing);
165
171
  }
172
+ clearFailed(root) {
173
+ for (const [key, e] of [...this.failed]) {
174
+ if (root !== undefined && e.root !== root)
175
+ continue;
176
+ this.failed.delete(key);
177
+ this.log("lsp_negative_cache_cleared", { key, attempts: e.attempts });
178
+ }
179
+ }
166
180
  async openSession(language, root, signal, _env) {
167
181
  const cmd = this.serverCmd(language);
168
182
  if (!cmd)
@@ -171,6 +185,8 @@ export class NodeLspManager {
171
185
  const [command, ...args] = tokens.length > 0 ? tokens : [""];
172
186
  const child = await this.spawnFn(command, args, root, signal).catch(() => undefined);
173
187
  if (!child) {
188
+ if (signal?.aborted !== true)
189
+ this.failed.set(`${language} ${root}`, { root, attempts: 1 });
174
190
  this.log("lsp_spawn_degraded", { language, root, command });
175
191
  return undefined;
176
192
  }
package/dist/index.d.ts CHANGED
@@ -66,6 +66,7 @@ export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core
66
66
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
67
67
  export type { InvariantKind, FunctionContract, Invariant, InvariantViolation, CheckResult, } from "./core/property-harness.js";
68
68
  export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
69
+ export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
69
70
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, type ToolResultStore, type ToolResultSlice, } from "./core/tool-result-store.js";
70
71
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, WALLTIME_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingWalltimeMs, winnerFromOutcome, validatePendingSteer, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, type RiskDescriptor, type CheckpointStore, type CheckpointSummary, type Checkpoint, type CheckpointToken, type CheckpointGate, type CheckpointState, type SerializedCheckpointState, type CheckpointFaultMode, type PendingAction, type ResumeOutcome, type ResolvedOutcome, type ReopenReason, type ResolveExpectation, type SafetyAxis, type ResourceLedger, type ResourceLimitReason, } from "./core/checkpoint-store.js";
71
72
  export { InMemoryFileSnapshotStore, DEFAULT_SNAPSHOT_BOUNDS } from "./core/file-snapshot-store.js";
package/dist/index.js CHANGED
@@ -57,6 +57,7 @@ export { runExecGate } from "./core/exec-gate.js";
57
57
  export { sanitizeUntrustedText, delimitUntrusted, inlineUntrusted } from "./core/untrusted-text.js";
58
58
  export { deriveInvariants, checkInvariants } from "./core/property-harness.js";
59
59
  export { HAND_TOOL_EFFECTS, bashReversibilityProbe, BASH_READONLY_DEFAULT_ALLOW, parseLeadingCommandName, classifyCompoundReadonly, MAX_EDIT_BYTES } from "./tools/fs/index.js";
60
+ export { resolveBashTimeoutCaps } from "./tools/fs/index.js";
60
61
  export { InMemoryToolResultStore, OFFLOAD_TOOL_NAME, DEFAULT_TOOL_RESULT_THRESHOLD_CHARS, } from "./core/tool-result-store.js";
61
62
  export { InMemoryCheckpointStore, CheckpointError, mintCheckpointToken, checkpointVersionOf, CURRENT_CHECKPOINT_VERSION, MAX_SUPPORTED_CHECKPOINT_VERSION, RESOURCE_CHECKPOINT_VERSION, WALLTIME_CHECKPOINT_VERSION, debitLedger, remainingBudgetMicroUsd, remainingWalltimeMs, winnerFromOutcome, validatePendingSteer, riskSeverity, buildRiskDescriptor, summarizeCheckpoint, } from "./core/checkpoint-store.js";
62
63
  export { InMemoryFileSnapshotStore, DEFAULT_SNAPSHOT_BOUNDS } from "./core/file-snapshot-store.js";
@@ -1,6 +1,6 @@
1
1
  import type { NamedWorkflowListing } from "./workflow-script-store.js";
2
2
  export declare const TEAM_DISCUSSION_WORKFLOW_NAME = "team-discussion";
3
- export declare const TEAM_DISCUSSION_SCRIPT = "export const meta = {\n name: \"team-discussion\",\n description: \"Round-based team discussion: configurable members debate a topic across rounds (each member sees the transcript so far), then a finalizer synthesizes a structured verdict.\",\n whenToUse: \"Use for a genuinely contested question that benefits from several perspectives arguing across rounds - design trade-offs, plan or risk reviews, adversarial critique of a proposal. Do NOT use it for a single factual question, a task with one obvious answer, or a budget-sensitive run: every round costs one agent call per member, so a discussion is never cheaper than asking once. args (all optional): { topic, members?: [{ role, prompt?, model? }], rounds?, finalizer?: { prompt?, model? } }.\",\n phases: [\n { title: \"Discussion\" },\n { title: \"Synthesis\" },\n ],\n};\n// Zero-config runnable (design/140 \u00A76 1c): every arg has an opinionated fallback.\nconst raw = args;\nconst a = raw !== null && typeof raw === \"object\" && !Array.isArray(raw) ? raw : {};\nconst topic =\n typeof a.topic === \"string\" && a.topic.trim() !== \"\"\n ? a.topic\n : typeof raw === \"string\" && raw.trim() !== \"\"\n ? raw // ergonomic form: a bare string args IS the topic\n : \"No topic was provided. Discuss: what information should a caller supply to make a team discussion like this productive, and when should they NOT convene one?\";\nconst defaultMembers = [\n { role: \"advocate\", prompt: \"Make the strongest constructive case. Propose concrete options and argue their benefits with specifics.\" },\n { role: \"skeptic\", prompt: \"Stress-test every claim made so far. Surface risks, hidden costs, failure modes, and cheaper alternatives.\" },\n];\nconst rawMembers = Array.isArray(a.members) && a.members.length > 0 ? a.members : defaultMembers;\nconst members = rawMembers.slice(0, 6).map((m, i) => {\n const mm = m !== null && typeof m === \"object\" ? m : {};\n const member = {\n role: typeof mm.role === \"string\" && mm.role.trim() !== \"\" ? mm.role : \"member-\" + (i + 1),\n prompt: typeof mm.prompt === \"string\" && mm.prompt.trim() !== \"\" ? mm.prompt : \"Contribute your own distinct perspective: be concrete, give reasons, and engage with what others said.\",\n };\n if (typeof mm.model === \"string\" && mm.model.trim() !== \"\") member.model = mm.model;\n // Slot-tools carrier (design/140 \u2461-3 + F4): a member may BE a registered agent type ({agent:\"reviewer\"}) \u2014\n // persona/tools/model then come from the deployment's AgentDefinition (role library), args stay thin.\n if (typeof mm.agent === \"string\" && mm.agent.trim() !== \"\") member.agent = mm.agent;\n return member;\n});\n// Deterministic budget truncation (design/140 \u00A71 \u9884\u7B97 row): a HARD rounds ceiling + member cap \u2014 never an\n// evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.\nconst requestedRounds = Math.floor(Number(a.rounds));\nconst rounds = Math.min(Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2, 5);\nconst fin = a.finalizer !== null && typeof a.finalizer === \"object\" && !Array.isArray(a.finalizer) ? a.finalizer : {};\nconst finalizerPrompt = typeof fin.prompt === \"string\" && fin.prompt.trim() !== \"\"\n ? fin.prompt\n : \"You are the synthesis lead. Read the full discussion transcript and produce the final verdict: the decision/answer, the key supporting points, and the strongest unresolved dissent (if any). Do not introduce new arguments of your own.\";\nconst clip = (s) => { const t = String(s); return t.length > 4000 ? t.slice(0, 4000) + \" ...[truncated]\" : t; };\nconst isBudgetStop = (e) => e !== null && typeof e === \"object\" && e.code === \"workflow.budget_exceeded\";\n\nphase(\"Discussion\");\nconst transcript = [];\nlet truncated = null;\nfor (let r = 1; r <= rounds && truncated === null; r++) {\n // Deterministic early stop on an exhausted budget (a live read of the engine budget; the engine's\n // hard WorkflowBudgetExceededError remains the backstop if a member call itself crosses the line).\n if (budget.total !== null && budget.remaining() <= 0) { truncated = \"budget exhausted before round \" + r; break; }\n for (const m of members) {\n const history = transcript.length === 0 ? \"(none yet - you open the discussion)\" : transcript.join(\"\\n\\n\");\n const spec = {\n objective:\n \"Team discussion on: \" + topic + \"\\n\\n\" +\n 'You are \"' + m.role + '\" in round ' + r + \" of \" + rounds + \".\\n\" +\n \"Your brief: \" + m.prompt + \"\\n\\n\" +\n \"Transcript so far:\\n\" + history + \"\\n\\n\" +\n \"Respond to the strongest points others made (do not repeat yourself), then advance your own position. Be concise: a few tight paragraphs at most.\",\n };\n if (m.model !== undefined) spec.modelName = m.model;\n let res;\n try {\n res = await agent(spec, m.agent !== undefined ? { label: m.role + \"-r\" + r, phase: \"Discussion\", agentType: m.agent } : { label: m.role + \"-r\" + r, phase: \"Discussion\" });\n } catch (e) {\n // The engine's budget hard stop: keep what the discussion already produced instead of failing the run.\n if (isBudgetStop(e)) { truncated = \"budget exhausted at \" + m.role + \", round \" + r; break; }\n throw e;\n }\n const text = res && res.status === \"completed\" ? clip(res.result) : \"(no contribution - agent ended \" + (res ? res.status : \"unknown\") + \")\";\n transcript.push(m.role + \" (round \" + r + \"): \" + text);\n }\n}\n\nphase(\"Synthesis\");\nconst finalSpec = {\n objective:\n finalizerPrompt + \"\\n\\nTopic: \" + topic + \"\\n\\nFull transcript:\\n\" +\n (transcript.length === 0 ? \"(the discussion produced no contributions)\" : transcript.join(\"\\n\\n\")) +\n (truncated ? \"\\n\\nNote: the discussion was cut short (\" + truncated + \").\" : \"\"),\n};\nif (typeof fin.model === \"string\" && fin.model.trim() !== \"\") finalSpec.modelName = fin.model;\nlet verdict = null;\ntry {\n verdict = await agent(finalSpec, {\n label: \"finalizer\",\n phase: \"Synthesis\",\n schema: {\n type: \"object\",\n properties: {\n decision: { type: \"string\", description: \"The final answer/decision, one paragraph.\" },\n keyPoints: { type: \"array\", items: { type: \"string\" }, description: \"The strongest supporting points from the discussion.\" },\n dissent: { type: \"string\", description: \"The strongest unresolved counter-position, if any.\" },\n },\n required: [\"decision\", \"keyPoints\"],\n },\n });\n} catch (e) {\n // Budget died before synthesis: return the transcript honestly rather than failing the whole run.\n if (!isBudgetStop(e)) throw e;\n truncated = truncated === null ? \"budget exhausted before synthesis\" : truncated;\n}\n\nreturn {\n topic,\n rounds,\n members: members.map((m) => m.role),\n ...(truncated ? { truncated } : {}),\n transcript,\n verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),\n};\n";
3
+ export declare const TEAM_DISCUSSION_SCRIPT = "export const meta = {\n name: \"team-discussion\",\n description: \"Round-based team discussion: configurable members debate a topic across rounds (each member sees the transcript so far), then a finalizer synthesizes a structured verdict.\",\n whenToUse: \"Use for a genuinely contested question that benefits from several perspectives arguing across rounds - design trade-offs, plan or risk reviews, adversarial critique of a proposal. Do NOT use it for a single factual question, a task with one obvious answer, or a budget-sensitive run: every round costs one agent call per member, so a discussion is never cheaper than asking once. args (all optional): { topic, members?: [{ role, prompt?, model? }], rounds?, finalizer?: { prompt?, model? } }. Hard ceilings: members is capped at 6 and rounds is capped at 5 regardless of what you pass; the run reports it via log() and a capped field on the result when a request exceeds either.\",\n phases: [\n { title: \"Discussion\" },\n { title: \"Synthesis\" },\n ],\n};\n// Zero-config runnable (design/140 \u00A76 1c): every arg has an opinionated fallback.\nconst raw = args;\nconst a = raw !== null && typeof raw === \"object\" && !Array.isArray(raw) ? raw : {};\nconst topic =\n typeof a.topic === \"string\" && a.topic.trim() !== \"\"\n ? a.topic\n : typeof raw === \"string\" && raw.trim() !== \"\"\n ? raw // ergonomic form: a bare string args IS the topic\n : \"No topic was provided. Discuss: what information should a caller supply to make a team discussion like this productive, and when should they NOT convene one?\";\nconst defaultMembers = [\n { role: \"advocate\", prompt: \"Make the strongest constructive case. Propose concrete options and argue their benefits with specifics.\" },\n { role: \"skeptic\", prompt: \"Stress-test every claim made so far. Surface risks, hidden costs, failure modes, and cheaper alternatives.\" },\n];\nconst rawMembers = Array.isArray(a.members) && a.members.length > 0 ? a.members : defaultMembers;\nconst members = rawMembers.slice(0, 6).map((m, i) => {\n const mm = m !== null && typeof m === \"object\" ? m : {};\n const member = {\n role: typeof mm.role === \"string\" && mm.role.trim() !== \"\" ? mm.role : \"member-\" + (i + 1),\n prompt: typeof mm.prompt === \"string\" && mm.prompt.trim() !== \"\" ? mm.prompt : \"Contribute your own distinct perspective: be concrete, give reasons, and engage with what others said.\",\n };\n if (typeof mm.model === \"string\" && mm.model.trim() !== \"\") member.model = mm.model;\n // Slot-tools carrier (design/140 \u2461-3 + F4): a member may BE a registered agent type ({agent:\"reviewer\"}) \u2014\n // persona/tools/model then come from the deployment's AgentDefinition (role library), args stay thin.\n if (typeof mm.agent === \"string\" && mm.agent.trim() !== \"\") member.agent = mm.agent;\n return member;\n});\n// Deterministic budget truncation (design/140 \u00A71 \u9884\u7B97 row): a HARD rounds ceiling + member cap \u2014 never an\n// evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.\nconst requestedRounds = Math.floor(Number(a.rounds));\nconst normalizedRounds = Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2;\nconst rounds = Math.min(normalizedRounds, 5);\n// RB-380 disclosure: the member/round slices above are silent by construction (Array.prototype.slice /\n// Math.min just drop the excess) \u2014 record + surface it instead of a caller finding out only by counting\n// transcript entries. Fires only when a request actually exceeded a ceiling (never on the common path).\nconst capNotes = [];\nif (rawMembers.length > 6) capNotes.push(\"requested \" + rawMembers.length + \" members, capped at 6\");\nif (normalizedRounds > 5) capNotes.push(\"requested \" + normalizedRounds + \" rounds, capped at 5\");\nfor (const note of capNotes) log(\"team-discussion: \" + note);\nconst fin = a.finalizer !== null && typeof a.finalizer === \"object\" && !Array.isArray(a.finalizer) ? a.finalizer : {};\nconst finalizerPrompt = typeof fin.prompt === \"string\" && fin.prompt.trim() !== \"\"\n ? fin.prompt\n : \"You are the synthesis lead. Read the full discussion transcript and produce the final verdict: the decision/answer, the key supporting points, and the strongest unresolved dissent (if any). Do not introduce new arguments of your own.\";\nconst clip = (s) => { const t = String(s); return t.length > 4000 ? t.slice(0, 4000) + \" ...[truncated]\" : t; };\nconst isBudgetStop = (e) => e !== null && typeof e === \"object\" && e.code === \"workflow.budget_exceeded\";\n\nphase(\"Discussion\");\nconst transcript = [];\nlet truncated = null;\nfor (let r = 1; r <= rounds && truncated === null; r++) {\n // Deterministic early stop on an exhausted budget (a live read of the engine budget; the engine's\n // hard WorkflowBudgetExceededError remains the backstop if a member call itself crosses the line).\n if (budget.total !== null && budget.remaining() <= 0) { truncated = \"budget exhausted before round \" + r; break; }\n for (const m of members) {\n const history = transcript.length === 0 ? \"(none yet - you open the discussion)\" : transcript.join(\"\\n\\n\");\n const spec = {\n objective:\n \"Team discussion on: \" + topic + \"\\n\\n\" +\n 'You are \"' + m.role + '\" in round ' + r + \" of \" + rounds + \".\\n\" +\n \"Your brief: \" + m.prompt + \"\\n\\n\" +\n \"Transcript so far:\\n\" + history + \"\\n\\n\" +\n \"Respond to the strongest points others made (do not repeat yourself), then advance your own position. Be concise: a few tight paragraphs at most.\",\n };\n if (m.model !== undefined) spec.modelName = m.model;\n let res;\n try {\n res = await agent(spec, m.agent !== undefined ? { label: m.role + \"-r\" + r, phase: \"Discussion\", agentType: m.agent } : { label: m.role + \"-r\" + r, phase: \"Discussion\" });\n } catch (e) {\n // The engine's budget hard stop: keep what the discussion already produced instead of failing the run.\n if (isBudgetStop(e)) { truncated = \"budget exhausted at \" + m.role + \", round \" + r; break; }\n throw e;\n }\n const text = res && res.status === \"completed\" ? clip(res.result) : \"(no contribution - agent ended \" + (res ? res.status : \"unknown\") + \")\";\n transcript.push(m.role + \" (round \" + r + \"): \" + text);\n }\n}\n\nphase(\"Synthesis\");\nconst finalSpec = {\n objective:\n finalizerPrompt + \"\\n\\nTopic: \" + topic + \"\\n\\nFull transcript:\\n\" +\n (transcript.length === 0 ? \"(the discussion produced no contributions)\" : transcript.join(\"\\n\\n\")) +\n (truncated ? \"\\n\\nNote: the discussion was cut short (\" + truncated + \").\" : \"\"),\n};\nif (typeof fin.model === \"string\" && fin.model.trim() !== \"\") finalSpec.modelName = fin.model;\nlet verdict = null;\ntry {\n verdict = await agent(finalSpec, {\n label: \"finalizer\",\n phase: \"Synthesis\",\n schema: {\n type: \"object\",\n properties: {\n decision: { type: \"string\", description: \"The final answer/decision, one paragraph.\" },\n keyPoints: { type: \"array\", items: { type: \"string\" }, description: \"The strongest supporting points from the discussion.\" },\n dissent: { type: \"string\", description: \"The strongest unresolved counter-position, if any.\" },\n },\n required: [\"decision\", \"keyPoints\"],\n },\n });\n} catch (e) {\n // Budget died before synthesis: return the transcript honestly rather than failing the whole run.\n if (!isBudgetStop(e)) throw e;\n truncated = truncated === null ? \"budget exhausted before synthesis\" : truncated;\n}\n\nreturn {\n topic,\n rounds,\n members: members.map((m) => m.role),\n ...(capNotes.length > 0 ? { capped: capNotes } : {}),\n ...(truncated ? { truncated } : {}),\n transcript,\n verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),\n};\n";
4
4
  export interface BuiltinWorkflowDefinition {
5
5
  name: string;
6
6
  script: string;
@@ -3,7 +3,7 @@ export const TEAM_DISCUSSION_WORKFLOW_NAME = "team-discussion";
3
3
  export const TEAM_DISCUSSION_SCRIPT = `export const meta = {
4
4
  name: "team-discussion",
5
5
  description: "Round-based team discussion: configurable members debate a topic across rounds (each member sees the transcript so far), then a finalizer synthesizes a structured verdict.",
6
- whenToUse: "Use for a genuinely contested question that benefits from several perspectives arguing across rounds - design trade-offs, plan or risk reviews, adversarial critique of a proposal. Do NOT use it for a single factual question, a task with one obvious answer, or a budget-sensitive run: every round costs one agent call per member, so a discussion is never cheaper than asking once. args (all optional): { topic, members?: [{ role, prompt?, model? }], rounds?, finalizer?: { prompt?, model? } }.",
6
+ whenToUse: "Use for a genuinely contested question that benefits from several perspectives arguing across rounds - design trade-offs, plan or risk reviews, adversarial critique of a proposal. Do NOT use it for a single factual question, a task with one obvious answer, or a budget-sensitive run: every round costs one agent call per member, so a discussion is never cheaper than asking once. args (all optional): { topic, members?: [{ role, prompt?, model? }], rounds?, finalizer?: { prompt?, model? } }. Hard ceilings: members is capped at 6 and rounds is capped at 5 regardless of what you pass; the run reports it via log() and a capped field on the result when a request exceeds either.",
7
7
  phases: [
8
8
  { title: "Discussion" },
9
9
  { title: "Synthesis" },
@@ -38,7 +38,15 @@ const members = rawMembers.slice(0, 6).map((m, i) => {
38
38
  // Deterministic budget truncation (design/140 §1 预算 row): a HARD rounds ceiling + member cap — never an
39
39
  // evaluator agent. The engine's budget/maxAgents hard stops remain the backstop.
40
40
  const requestedRounds = Math.floor(Number(a.rounds));
41
- const rounds = Math.min(Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2, 5);
41
+ const normalizedRounds = Number.isFinite(requestedRounds) && requestedRounds >= 1 ? requestedRounds : 2;
42
+ const rounds = Math.min(normalizedRounds, 5);
43
+ // RB-380 disclosure: the member/round slices above are silent by construction (Array.prototype.slice /
44
+ // Math.min just drop the excess) — record + surface it instead of a caller finding out only by counting
45
+ // transcript entries. Fires only when a request actually exceeded a ceiling (never on the common path).
46
+ const capNotes = [];
47
+ if (rawMembers.length > 6) capNotes.push("requested " + rawMembers.length + " members, capped at 6");
48
+ if (normalizedRounds > 5) capNotes.push("requested " + normalizedRounds + " rounds, capped at 5");
49
+ for (const note of capNotes) log("team-discussion: " + note);
42
50
  const fin = a.finalizer !== null && typeof a.finalizer === "object" && !Array.isArray(a.finalizer) ? a.finalizer : {};
43
51
  const finalizerPrompt = typeof fin.prompt === "string" && fin.prompt.trim() !== ""
44
52
  ? fin.prompt
@@ -110,6 +118,7 @@ return {
110
118
  topic,
111
119
  rounds,
112
120
  members: members.map((m) => m.role),
121
+ ...(capNotes.length > 0 ? { capped: capNotes } : {}),
113
122
  ...(truncated ? { truncated } : {}),
114
123
  transcript,
115
124
  verdict: verdict && verdict.structuredOutput !== undefined ? verdict.structuredOutput : (verdict ? verdict.result : null),
@@ -25,5 +25,10 @@ export interface WorkflowChildCaps {
25
25
  childMaxTokens?: number;
26
26
  childMaxTurns?: number;
27
27
  }
28
+ export interface ResourceClampNote {
29
+ field: "maxCostUsd" | "maxTokens" | "timeoutSec" | "maxTurns";
30
+ requested: number | undefined;
31
+ applied: number;
32
+ }
28
33
  export declare function resolveModelName(name: string, allowlist: string[] | undefined, models: Record<string, Model> | undefined): Model;
29
- export declare function buildGovernedChildSpec(scriptSpec: unknown, baseline: WorkflowGovernanceBaseline, models: Record<string, Model> | undefined, caps?: WorkflowChildCaps): TaskSpec;
34
+ export declare function buildGovernedChildSpec(scriptSpec: unknown, baseline: WorkflowGovernanceBaseline, models: Record<string, Model> | undefined, caps?: WorkflowChildCaps, onResourceClamp?: (notes: ResourceClampNote[]) => void): TaskSpec;
@@ -99,26 +99,46 @@ function pickWhitelist(scriptSpec) {
99
99
  return { safe, modelName };
100
100
  }
101
101
  function clampResourceLimits(safe, base, caps) {
102
+ const notes = [];
103
+ const requestedCost = safe.maxCostUsd;
102
104
  const cost = minDefined(safe.maxCostUsd, base.maxCostUsd, caps?.childMaxCostUsd);
103
- if (cost !== undefined)
105
+ if (cost !== undefined) {
106
+ if (cost !== requestedCost)
107
+ notes.push({ field: "maxCostUsd", requested: requestedCost, applied: cost });
104
108
  safe.maxCostUsd = cost;
109
+ }
110
+ const requestedTokens = safe.maxTokens;
105
111
  const tokens = minDefined(safe.maxTokens, base.maxTokens, caps?.childMaxTokens);
106
- if (tokens !== undefined)
112
+ if (tokens !== undefined) {
113
+ if (tokens !== requestedTokens)
114
+ notes.push({ field: "maxTokens", requested: requestedTokens, applied: tokens });
107
115
  safe.maxTokens = tokens;
116
+ }
117
+ const requestedTimeoutSec = safe.limits?.timeoutSec;
118
+ const requestedMaxTurns = safe.limits?.maxTurns;
108
119
  const timeoutSec = minDefined(safe.limits?.timeoutSec, base.limits?.timeoutSec, caps?.perAgentTimeoutSec);
109
120
  const maxTurns = minDefined(safe.limits?.maxTurns, base.limits?.maxTurns, caps?.childMaxTurns);
121
+ if (timeoutSec !== undefined && timeoutSec !== requestedTimeoutSec) {
122
+ notes.push({ field: "timeoutSec", requested: requestedTimeoutSec, applied: timeoutSec });
123
+ }
124
+ if (maxTurns !== undefined && maxTurns !== requestedMaxTurns) {
125
+ notes.push({ field: "maxTurns", requested: requestedMaxTurns, applied: maxTurns });
126
+ }
110
127
  if (timeoutSec !== undefined || maxTurns !== undefined) {
111
128
  safe.limits = {
112
129
  ...(maxTurns !== undefined ? { maxTurns } : {}),
113
130
  ...(timeoutSec !== undefined ? { timeoutSec } : {}),
114
131
  };
115
132
  }
133
+ return notes;
116
134
  }
117
- export function buildGovernedChildSpec(scriptSpec, baseline, models, caps) {
135
+ export function buildGovernedChildSpec(scriptSpec, baseline, models, caps, onResourceClamp) {
118
136
  const { safe, modelName } = pickWhitelist(scriptSpec);
119
137
  if (modelName !== undefined) {
120
138
  safe.model = resolveModelName(modelName, baseline.workflowModelAllowlist, models);
121
139
  }
122
- clampResourceLimits(safe, baseline.base, caps);
140
+ const clampNotes = clampResourceLimits(safe, baseline.base, caps);
141
+ if (clampNotes.length > 0 && onResourceClamp)
142
+ onResourceClamp(clampNotes);
123
143
  return tightenTaskSpec(baseline.base, safe);
124
144
  }
@@ -1,3 +1,4 @@
1
+ import { assertSupportedAgentIsolation } from "./workflow.js";
1
2
  import { buildGovernedChildSpec } from "./workflow-governance.js";
2
3
  function safeAgentOptions(opts) {
3
4
  if (typeof opts !== "object" || opts === null)
@@ -12,10 +13,15 @@ function safeAgentOptions(opts) {
12
13
  out.schema = o.schema;
13
14
  if (typeof o.agentType === "string")
14
15
  out.agentType = o.agentType;
16
+ assertSupportedAgentIsolation(o.isolation);
15
17
  if (o.isolation === "worktree")
16
18
  out.isolation = "worktree";
17
19
  return out;
18
20
  }
21
+ function formatResourceClampNote(notes) {
22
+ const parts = notes.map((n) => `${n.field}: requested ${n.requested === undefined ? "unset" : n.requested} → applied ${n.applied}`);
23
+ return `workflow governance tightened this agent's resource limits (${parts.join("; ")})`;
24
+ }
19
25
  export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThinking, parentPrincipal) {
20
26
  const agent = (spec, opts) => {
21
27
  if (typeof spec === "string")
@@ -23,7 +29,7 @@ export function buildWorkflowPrimitives(ctx, governance, onAgentSpawn, parentThi
23
29
  const agentOpts = safeAgentOptions(opts);
24
30
  const effectiveBaseline = (b) => agentOpts.isolation === "worktree" && b.worktreeBase !== undefined ? { ...b, base: { ...b.base, ...b.worktreeBase } } : b;
25
31
  const childSpec = governance
26
- ? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps)
32
+ ? buildGovernedChildSpec(spec, effectiveBaseline(governance.baseline), governance.models, governance.caps, (notes) => ctx.log(formatResourceClampNote(notes)))
27
33
  : { ...spec };
28
34
  if (childSpec.thinking === undefined && parentThinking) {
29
35
  const inherited = parentThinking();
@@ -18,6 +18,7 @@ export declare function workflowAgentCallKey(ordinal: number, spec: TaskSpec, op
18
18
  schema?: TSchema;
19
19
  isolation?: "worktree";
20
20
  }): string;
21
+ export declare function assertSupportedAgentIsolation(isolation: unknown): asserts isolation is "worktree" | undefined;
21
22
  export interface WorkflowFanOutSlotError {
22
23
  index: number;
23
24
  kind: string;
@@ -104,6 +104,21 @@ export function workflowAgentCallKey(ordinal, spec, opts) {
104
104
  };
105
105
  return `${ordinal}:${boundInputHashOf(identity)}`;
106
106
  }
107
+ function resolveChildSessionIdAtSpawn(spec) {
108
+ if (spec.sessionId)
109
+ return spec.sessionId;
110
+ if (spec.requireExistingSession === true || spec.resumeAt !== undefined)
111
+ return undefined;
112
+ return randomUUID();
113
+ }
114
+ export function assertSupportedAgentIsolation(isolation) {
115
+ if (isolation !== undefined && isolation !== "worktree") {
116
+ const shown = typeof isolation === "string" ? JSON.stringify(isolation) : String(isolation);
117
+ const e = new Error(`isolation ${shown} is not supported in workflow agents — only "worktree" (omit the option to run in the shared working tree). The agent was not started (fail-closed: an unrecognized isolation value must never silently run in the shared working tree).`);
118
+ e.code = "isolation.invalid";
119
+ throw e;
120
+ }
121
+ }
107
122
  function rethrowIfMaxAgents(e) {
108
123
  if (e instanceof WorkflowMaxAgentsError)
109
124
  throw e;
@@ -700,6 +715,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
700
715
  if ("error" in compiled)
701
716
  throw new TypeError(`agent({schema}) received an invalid JSON Schema: ${compiled.error}`);
702
717
  }
718
+ assertSupportedAgentIsolation(agentOpts.isolation);
703
719
  const label = agentOpts.label ?? `agent-${run.agents.length + 1}`;
704
720
  const phase = agentOpts.phase ?? currentPhase?.title;
705
721
  const phaseInstance = resolveAgentPhase(agentOpts.phase);
@@ -828,7 +844,12 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
828
844
  const onCallerAbort = () => attemptCtl.abort(new Error("workflow aborted"));
829
845
  effectiveSignal?.addEventListener("abort", onCallerAbort, { once: true });
830
846
  armWatchdog();
831
- const attemptSpec = { ...runSpec, signal: attemptCtl.signal };
847
+ const attemptSessionId = resolveChildSessionIdAtSpawn(runSpec);
848
+ const attemptSpec = attemptSessionId !== undefined ? { ...runSpec, sessionId: attemptSessionId, signal: attemptCtl.signal } : { ...runSpec, signal: attemptCtl.signal };
849
+ if (attemptSessionId !== undefined && !finalized) {
850
+ rec.sessionId = attemptSessionId;
851
+ void persist("update");
852
+ }
832
853
  const attemptInternals = {
833
854
  ...baseInternals,
834
855
  ...(bceSink !== undefined
@@ -1071,6 +1092,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1071
1092
  if ("error" in compiled)
1072
1093
  throw new TypeError(`agent({schema}) received an invalid JSON Schema: ${compiled.error}`);
1073
1094
  }
1095
+ assertSupportedAgentIsolation(agentOpts.isolation);
1074
1096
  const label = agentOpts.label ?? `agent-${run.agents.length + 1}`;
1075
1097
  const phase = agentOpts.phase ?? currentPhase?.title;
1076
1098
  const phaseInstance = resolveAgentPhase(agentOpts.phase);
@@ -1115,14 +1137,17 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1115
1137
  rec.startedAt = now();
1116
1138
  bceSpawn(callKey, label, agentOpts.agentType, false);
1117
1139
  let stream;
1140
+ let childSessionId;
1118
1141
  try {
1119
1142
  const typedSpec0 = applyWorkflowAgentType(spec, agentOpts.agentType, agentRegistry);
1120
1143
  const typedSpec = typedSpec0.model === undefined && inheritedModelSnap !== undefined ? { ...typedSpec0, model: inheritedModelSnap } : typedSpec0;
1121
1144
  const framedSpec = withWorkflowChildPersona(typedSpec, agentOpts.schema ?? typedSpec.outputSchema);
1122
1145
  const authInherit = framedSpec.getApiKeyAndHeaders === undefined && opts.defaultGetApiKeyAndHeaders !== undefined ? { getApiKeyAndHeaders: opts.defaultGetApiKeyAndHeaders } : {};
1123
- const runSpec = agentOpts.schema
1146
+ const baseRunSpec = agentOpts.schema
1124
1147
  ? { ...framedSpec, ...authInherit, signal: effectiveSignal, outputSchema: agentOpts.schema }
1125
1148
  : { ...framedSpec, ...authInherit, signal: effectiveSignal };
1149
+ childSessionId = resolveChildSessionIdAtSpawn(baseRunSpec);
1150
+ const runSpec = childSessionId !== undefined ? { ...baseRunSpec, sessionId: childSessionId } : baseRunSpec;
1126
1151
  const enrichedForwardS = opts.onForwardEvent !== undefined
1127
1152
  ? (e) => {
1128
1153
  opts.onForwardEvent(e.type === "task_progress" ? { ...e, workflowRunId: runId, workflowAgentLabel: label } : e);
@@ -1157,6 +1182,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1157
1182
  bceTerminal(callKey, "failed", err instanceof Error ? err.message : String(err), undefined, undefined);
1158
1183
  throw err;
1159
1184
  }
1185
+ if (childSessionId !== undefined && !finalized) {
1186
+ rec.sessionId = childSessionId;
1187
+ void persist("update");
1188
+ }
1160
1189
  const steer = async (content) => {
1161
1190
  const marker = `steer-${++steerMarkerSeq}`;
1162
1191
  const framed = `[operator steer ${marker}] An operator/leader sent guidance for your task. Take it into account on your NEXT step. ` +
@@ -26,7 +26,13 @@ export declare function createBashTool(env: ExecutionEnv, rootCanonical: string,
26
26
  execClamp?: ExecClampOption;
27
27
  autoBackgroundOnTimeout?: boolean;
28
28
  oneShot?: boolean;
29
+ additionalRoots?: readonly string[];
30
+ bashDefaultTimeoutMs?: number;
31
+ bashMaxTimeoutMs?: number;
32
+ }): AgentTool;
33
+ export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption, timeoutOpts?: {
34
+ bashDefaultTimeoutMs?: number;
35
+ bashMaxTimeoutMs?: number;
29
36
  }): AgentTool;
30
- export declare function createBashReadonlyTool(env: ExecutionEnv, rootCanonical: string, allow: ReadonlySet<string>, execClamp?: ExecClampOption): AgentTool;
31
37
  export declare function createEnvTaskOutputTool(env: ExecutionEnv): AgentTool;
32
38
  export declare function createEnvTaskStopTool(env: ExecutionEnv, registry?: TaskRegistry): AgentTool;