@sema-agent/core 1.433.0 → 1.435.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.
@@ -7,6 +7,7 @@ import { summarizeWorkflowRun } from "./workflow-run-store.js";
7
7
  import { canAccessAgentRecord, BackgroundAgentStoreError, } from "./background-agent-store.js";
8
8
  import { delimitUntrusted } from "./untrusted-text.js";
9
9
  import { boundedRedactedSummary } from "./untrusted-egress.js";
10
+ import { clipWithFilePointer } from "./tool-errors.js";
10
11
  const defaultMonitorTimers = {
11
12
  setInterval: (fn, ms) => {
12
13
  const h = setInterval(() => void fn(), ms);
@@ -52,8 +53,6 @@ const TASK_OUTPUT_DEFAULT_CHARS = 32_000;
52
53
  const TASK_OUTPUT_MAX_CHARS = 160_000;
53
54
  const TASK_OUTPUT_ALIASES = ["BashOutput", "AgentOutputTool", "BashOutputTool", "AgentOutput", "WorkflowStatus"];
54
55
  const TASK_STOP_ALIASES = ["KillShell", "KillBash"];
55
- const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
56
- const isLowSurrogate = (c) => c >= 0xdc00 && c <= 0xdfff;
57
56
  const TASK_OUTPUT_MIN_CHARS = 512;
58
57
  function taskMaxOutputChars() {
59
58
  const raw = process.env.TASK_MAX_OUTPUT_LENGTH;
@@ -67,24 +66,7 @@ function taskMaxOutputChars() {
67
66
  return Math.min(Math.max(n, TASK_OUTPUT_MIN_CHARS), TASK_OUTPUT_MAX_CHARS);
68
67
  }
69
68
  export function clipTaskOutput(s, fullOutputPath) {
70
- const limit = taskMaxOutputChars();
71
- if (s.length <= limit)
72
- return s;
73
- if (fullOutputPath !== undefined) {
74
- const header = `[Truncated. Full output: ${fullOutputPath}]\n\n`;
75
- const room = limit - header.length;
76
- return room > 0 ? header + s.slice(-room) : header.trimEnd().slice(0, Math.min(limit, s.length));
77
- }
78
- const omittedCount = (kept) => s.length - kept;
79
- const markerFor = (kept) => `\n…[${s.length} chars total, ${omittedCount(kept)} omitted from the middle]…\n`;
80
- const budget = limit - markerFor(0).length;
81
- if (budget < 2)
82
- return s.slice(0, limit);
83
- const half = Math.floor(budget / 2);
84
- const headEnd = half > 0 && isHighSurrogate(s.charCodeAt(half - 1)) ? half - 1 : half;
85
- const tailStart = half > 0 && isLowSurrogate(s.charCodeAt(s.length - half)) ? s.length - half + 1 : s.length - half;
86
- const kept = headEnd + (s.length - tailStart);
87
- return `${s.slice(0, headEnd)}${markerFor(kept)}${s.slice(tailStart)}`;
69
+ return clipWithFilePointer(s, taskMaxOutputChars(), fullOutputPath);
88
70
  }
89
71
  function firstString(...values) {
90
72
  for (const value of values) {
@@ -1,5 +1,6 @@
1
1
  import type { TSchema } from "typebox";
2
2
  export declare function truncateError(s: string): string;
3
+ export declare function clipWithFilePointer(s: string, limit: number, fullOutputPath?: string): string;
3
4
  export declare function formatToolError(error: unknown): string;
4
5
  export type WorkerErrorClass = "budget" | "limit" | "output" | "suspend" | "review" | "unexpected" | "oracle" | "resume" | "config" | "conflict" | "brain" | "aborted" | "unknown";
5
6
  export declare function errorClassOf(errorCode: string | undefined): WorkerErrorClass;
@@ -7,6 +7,27 @@ export function truncateError(s) {
7
7
  const removed = s.length - MAX_ERROR_CHARS;
8
8
  return `${s.slice(0, HALF)}\n\n... [${removed} characters truncated] ...\n\n${s.slice(s.length - HALF)}`;
9
9
  }
10
+ const isHighSurrogate = (c) => c >= 0xd800 && c <= 0xdbff;
11
+ const isLowSurrogate = (c) => c >= 0xdc00 && c <= 0xdfff;
12
+ export function clipWithFilePointer(s, limit, fullOutputPath) {
13
+ if (s.length <= limit)
14
+ return s;
15
+ if (fullOutputPath !== undefined) {
16
+ const header = `[Truncated. Full output: ${fullOutputPath}]\n\n`;
17
+ const room = limit - header.length;
18
+ return room > 0 ? header + s.slice(-room) : header.trimEnd().slice(0, Math.min(limit, s.length));
19
+ }
20
+ const omittedCount = (kept) => s.length - kept;
21
+ const markerFor = (kept) => `\n…[${s.length} chars total, ${omittedCount(kept)} omitted from the middle]…\n`;
22
+ const budget = limit - markerFor(0).length;
23
+ if (budget < 2)
24
+ return s.slice(0, limit);
25
+ const half = Math.floor(budget / 2);
26
+ const headEnd = half > 0 && isHighSurrogate(s.charCodeAt(half - 1)) ? half - 1 : half;
27
+ const tailStart = half > 0 && isLowSurrogate(s.charCodeAt(s.length - half)) ? s.length - half + 1 : s.length - half;
28
+ const kept = headEnd + (s.length - tailStart);
29
+ return `${s.slice(0, headEnd)}${markerFor(kept)}${s.slice(tailStart)}`;
30
+ }
10
31
  function isShellLike(e) {
11
32
  return typeof e === "object" && e !== null && ("exitCode" in e || "stderr" in e || "stdout" in e);
12
33
  }
@@ -1,4 +1,4 @@
1
- import { spawn } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  const DEFAULT_GRACE_MS = 3000;
@@ -25,8 +25,18 @@ export function killProcessTree(pid, opts) {
25
25
  return;
26
26
  }
27
27
  const graceMs = normalizeGraceMs(opts?.graceMs);
28
- signalProcessTreeUnix(pid, "SIGTERM", useGroupKill);
28
+ const termDescendants = signalProcessTreeUnix(pid, "SIGTERM", useGroupKill);
29
29
  setTimeout(() => {
30
+ const rediscovered = enumerateDescendantsSync(pid);
31
+ const verifiedCarriedForward = new Map();
32
+ for (const [p, originalEtime] of termDescendants) {
33
+ const currentEtime = rediscovered.etimeOf.get(p);
34
+ if (currentEtime !== undefined && verifyStillSameProcess(originalEtime, currentEtime)) {
35
+ verifiedCarriedForward.set(p, currentEtime);
36
+ }
37
+ }
38
+ const knownDescendants = new Map([...verifiedCarriedForward, ...rediscovered.descendants]);
39
+ killEach(knownDescendants.keys(), "SIGKILL");
30
40
  if (escalationCancelled(opts?.stillRunning)) {
31
41
  if (useGroupKill && isProcessAlive(-pid)) {
32
42
  try {
@@ -43,7 +53,19 @@ export function killProcessTree(pid, opts) {
43
53
  if (!stillAlive) {
44
54
  return;
45
55
  }
46
- signalProcessTreeUnix(pid, "SIGKILL", useGroupKill);
56
+ if (useGroupKill) {
57
+ try {
58
+ process.kill(-pid, "SIGKILL");
59
+ return;
60
+ }
61
+ catch {
62
+ }
63
+ }
64
+ try {
65
+ process.kill(pid, "SIGKILL");
66
+ }
67
+ catch {
68
+ }
47
69
  }, graceMs).unref();
48
70
  }
49
71
  function escalationCancelled(stillRunning) {
@@ -82,19 +104,107 @@ function isProcessAlive(pid) {
82
104
  }
83
105
  }
84
106
  function signalProcessTreeUnix(pid, signal, useGroupKill) {
107
+ const descendants = enumerateDescendantsSync(pid).descendants;
85
108
  if (useGroupKill) {
86
109
  try {
87
110
  process.kill(-pid, signal);
88
- return;
89
111
  }
90
112
  catch {
113
+ try {
114
+ process.kill(pid, signal);
115
+ }
116
+ catch {
117
+ }
91
118
  }
119
+ killEach(descendants.keys(), signal);
120
+ return descendants;
92
121
  }
93
122
  try {
94
123
  process.kill(pid, signal);
95
124
  }
96
125
  catch {
97
126
  }
127
+ killEach(descendants.keys(), signal);
128
+ return descendants;
129
+ }
130
+ function killEach(pids, signal) {
131
+ for (const pid of pids) {
132
+ try {
133
+ process.kill(pid, signal);
134
+ }
135
+ catch {
136
+ }
137
+ }
138
+ }
139
+ const PS_ENUMERATION_TIMEOUT_MS = 500;
140
+ const EMPTY_SNAPSHOT = { descendants: new Map(), etimeOf: new Map() };
141
+ function enumerateDescendantsSync(rootPid) {
142
+ if (process.platform === "win32")
143
+ return EMPTY_SNAPSHOT;
144
+ let out;
145
+ try {
146
+ const result = spawnSync("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "etime="], {
147
+ timeout: PS_ENUMERATION_TIMEOUT_MS,
148
+ encoding: "utf8",
149
+ windowsHide: true,
150
+ });
151
+ if (result.error || typeof result.stdout !== "string")
152
+ return EMPTY_SNAPSHOT;
153
+ out = result.stdout;
154
+ }
155
+ catch {
156
+ return EMPTY_SNAPSHOT;
157
+ }
158
+ const childrenOf = new Map();
159
+ const etimeOf = new Map();
160
+ for (const line of out.split("\n")) {
161
+ const m = /^\s*(\d+)\s+(\d+)\s+(\S+)\s*$/.exec(line);
162
+ if (!m)
163
+ continue;
164
+ const p = Number(m[1]);
165
+ const pp = Number(m[2]);
166
+ etimeOf.set(p, m[3]);
167
+ const list = childrenOf.get(pp);
168
+ if (list)
169
+ list.push(p);
170
+ else
171
+ childrenOf.set(pp, [p]);
172
+ }
173
+ const seen = new Set();
174
+ const queue = [rootPid];
175
+ while (queue.length > 0) {
176
+ const cur = queue.shift();
177
+ for (const kid of childrenOf.get(cur) ?? []) {
178
+ if (kid <= 1 || kid === rootPid || seen.has(kid))
179
+ continue;
180
+ seen.add(kid);
181
+ queue.push(kid);
182
+ }
183
+ }
184
+ const descendants = new Map();
185
+ for (const p of seen) {
186
+ const et = etimeOf.get(p);
187
+ if (et !== undefined)
188
+ descendants.set(p, et);
189
+ }
190
+ return { descendants, etimeOf };
191
+ }
192
+ function verifyStillSameProcess(originalEtime, currentEtime) {
193
+ const orig = parseEtimeSeconds(originalEtime);
194
+ const cur = parseEtimeSeconds(currentEtime);
195
+ if (orig === undefined || cur === undefined)
196
+ return false;
197
+ return cur >= orig;
198
+ }
199
+ function parseEtimeSeconds(etime) {
200
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(etime.trim());
201
+ if (!m)
202
+ return undefined;
203
+ const days = m[1] !== undefined ? Number(m[1]) : 0;
204
+ const hours = m[2] !== undefined ? Number(m[2]) : 0;
205
+ const minutes = Number(m[3]);
206
+ const seconds = Number(m[4]);
207
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
98
208
  }
99
209
  function runTaskkill(args) {
100
210
  try {
@@ -4,9 +4,10 @@ import { defineTool } from "../../core/tools.js";
4
4
  import { createTaskOutputTool, createTaskStopTool, defaultTaskRegistry } from "../../core/task-registry.js";
5
5
  import { hasBackgroundShell } from "../../core/background-shell.js";
6
6
  import { delimitUntrusted } from "../../core/untrusted-text.js";
7
+ import { clipWithFilePointer } from "../../core/tool-errors.js";
7
8
  import { sha256, resolveKey, violationText, requireRead, checkStale, checkEditMatch, checkNoChange, hasBinaryExtension, isBinaryContent, fileArgPath, imageMimeForRead, imageMagicMatches, similarNameSuggestion, resolveQuoteMatch, adaptNewStringQuotes, deletionOldString, countOccurrences, OVERSIZE_READ_ESCAPE_HINT, isBlockedDevicePath, normalizeAbsPathLexically, } from "./safety.js";
8
9
  import { decodeTextBytes, encodeTextForFile, normalizeEditText } from "./encoding.js";
9
- import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern } from "./search.js";
10
+ import { runGrepDetailed, runGlobDetailed, splitAbsoluteGlobPattern, shellQuote } from "./search.js";
10
11
  import { makeRepoMapTool } from "./repo-map.js";
11
12
  import { ghRateLimitHint } from "./gh-rate-limit.js";
12
13
  import { MCP_IMAGE_MAX_BASE64, IMAGE_TARGET_RAW_SIZE, sharpImageDownsampler } from "../../core/mcp.js";
@@ -61,12 +62,23 @@ const FILE_PATH_PARAMS = {
61
62
  path: Type.Optional(Type.String({ description: "Deprecated alias for `file_path` (back-compat; prefer file_path)." })),
62
63
  };
63
64
  function clipShellOutput(s) {
64
- const max = bashMaxOutputChars();
65
- if (s.length <= max)
66
- return s;
67
- const half = Math.floor(max / 2);
68
- const omitted = s.length - 2 * half;
69
- return `${s.slice(0, half)}\n…[${s.length} chars total, ${omitted} omitted from the middle]…\n${s.slice(s.length - half)}`;
65
+ return clipWithFilePointer(s, bashMaxOutputChars());
66
+ }
67
+ async function writeShellOverflowFile(env, stdout, stderr) {
68
+ const tf = await env.createTempFile({ prefix: "bash-output-", suffix: ".log" });
69
+ if (!tf.ok)
70
+ return undefined;
71
+ const body = stderr.length > 0 ? `${stdout}${stdout.length > 0 && !stdout.endsWith("\n") ? "\n" : ""}--- stderr ---\n${stderr}` : stdout;
72
+ const w = await env.writeFile(tf.value, body);
73
+ if (!w.ok)
74
+ return undefined;
75
+ const canon = await env.canonicalPath(tf.value);
76
+ return canon.ok ? canon.value : tf.value;
77
+ }
78
+ function shellRecoveryHint(path, readOnly) {
79
+ const quoted = shellQuote(path);
80
+ const example = readOnly ? `tail -c 50000 ${quoted}` : `sed -n 'START,ENDp' ${quoted}`;
81
+ return `\n(captured output preserved at ${path} — inspect with bash, e.g. \`${example}\`)`;
70
82
  }
71
83
  const FILE_STATE_TRAILER = " (file state is current in your context — no need to Read it back)";
72
84
  const CWD_SENTINEL = "__cc_cwd_9f2c1b__";
@@ -1446,7 +1458,7 @@ export function canAutoBackground(command) {
1446
1458
  return false;
1447
1459
  return true;
1448
1460
  }
1449
- async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef, detach, execClamp, toolCallId) {
1461
+ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef, detach, execClamp, toolCallId, readOnly) {
1450
1462
  let timeout = Math.min(BASH_MAX_TIMEOUT_SEC, Math.max(1, Math.floor(timeoutSec ?? BASH_DEFAULT_TIMEOUT_SEC)));
1451
1463
  const requestedSec = timeout;
1452
1464
  let clampedByDeadline = false;
@@ -1509,9 +1521,13 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
1509
1521
  return `Error (${toolName}): connection to the execution environment was lost — the command's outcome is unknown and it may still be running. Verify its effects before retrying; retry only if the command is idempotent. (${res.error.message})`;
1510
1522
  }
1511
1523
  if (res.error.code === "timeout" || res.error.code === "aborted" || res.error.code === "callback_error") {
1512
- const clip = clipShellOutput;
1513
- const pStdout = clip(res.error.partialStdout ?? "");
1514
- const pStderr = clip(res.error.partialStderr ?? "");
1524
+ const rawStdout = res.error.partialStdout ?? "";
1525
+ const rawStderr = res.error.partialStderr ?? "";
1526
+ const cutOverflowFile = rawStdout.length > bashMaxOutputChars() || rawStderr.length > bashMaxOutputChars()
1527
+ ? await writeShellOverflowFile(env, rawStdout, rawStderr)
1528
+ : undefined;
1529
+ const pStdout = clipShellOutput(rawStdout);
1530
+ const pStderr = clipShellOutput(rawStderr);
1515
1531
  const headline = res.error.code === "timeout"
1516
1532
  ? clampedByDeadline
1517
1533
  ? deadlineExhausted
@@ -1526,13 +1542,15 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
1526
1542
  const body = captured.length > 0
1527
1543
  ? `\n${delimitUntrusted("partial command output", captured)}`
1528
1544
  : `\n(no output was produced before the cutoff)`;
1545
+ const overflowNote = cutOverflowFile !== undefined ? shellRecoveryHint(cutOverflowFile, readOnly) : "";
1529
1546
  return {
1530
- content: `Error (${toolName}): ${headline}${body}`,
1547
+ content: `Error (${toolName}): ${headline}${body}${overflowNote}`,
1531
1548
  details: {
1532
1549
  type: "bash",
1533
1550
  stdout: pStdout,
1534
1551
  stderr: pStderr,
1535
1552
  exitCode: null,
1553
+ ...(cutOverflowFile !== undefined ? { output_file: cutOverflowFile } : {}),
1536
1554
  ...(res.error.code === "timeout"
1537
1555
  ? {
1538
1556
  timedOut: true,
@@ -1567,26 +1585,38 @@ async function runShell(env, cwd, toolName, command, timeoutSec, signal, cwdRef,
1567
1585
  cwdRef.current = captured;
1568
1586
  }
1569
1587
  }
1570
- const clip = clipShellOutput;
1571
- const clippedStdout = clip(stdout);
1572
- const clippedStderr = clip(stderr);
1588
+ const clippedStdout = clipShellOutput(stdout);
1589
+ const clippedStderr = clipShellOutput(stderr);
1573
1590
  const stdoutImage = dataUriImageFromStdout(stdout);
1574
1591
  if (stdoutImage) {
1592
+ const imgOverflowFile = stderr.length > bashMaxOutputChars() ? await writeShellOverflowFile(env, "", stderr) : undefined;
1593
+ const imgOverflowNote = imgOverflowFile !== undefined ? shellRecoveryHint(imgOverflowFile, readOnly) : "";
1575
1594
  return {
1576
1595
  content: [
1577
1596
  { type: "image", data: stdoutImage.data, mimeType: stdoutImage.mime },
1578
- { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}` },
1597
+ { type: "text", text: `exit code: ${exitCode}\n[Image data detected in stdout and shown above]${stderr.trim() !== "" ? `\n--- stderr ---\n${clippedStderr}` : ""}${imgOverflowNote}` },
1579
1598
  ],
1580
- details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true },
1599
+ details: { type: "bash", stdout: "[image data]", stderr: clippedStderr, exitCode, isImage: true, ...(imgOverflowFile !== undefined ? { output_file: imgOverflowFile } : {}) },
1581
1600
  };
1582
1601
  }
1602
+ const cleanOverflowFile = stdout.length > bashMaxOutputChars() || stderr.length > bashMaxOutputChars() ? await writeShellOverflowFile(env, stdout, stderr) : undefined;
1603
+ const overflowNote = cleanOverflowFile !== undefined ? shellRecoveryHint(cleanOverflowFile, readOnly) : "";
1583
1604
  const exit1Note = exitCode === 1 ? bashExitOneInterpretation(command) : undefined;
1584
1605
  const parts = [`exit code: ${exitCode}${exit1Note ? ` (${exit1Note} — exit 1 from this command is not an error)` : ""}`, `--- stdout ---\n${clippedStdout || "(empty)"}`];
1585
1606
  if (stderr.trim() !== "")
1586
1607
  parts.push(`--- stderr ---\n${clippedStderr}`);
1608
+ if (overflowNote)
1609
+ parts.push(overflowNote.trim());
1587
1610
  return {
1588
1611
  content: parts.join("\n"),
1589
- details: { type: "bash", stdout: clippedStdout, stderr: clippedStderr, exitCode, ...(exit1Note ? { returnCodeInterpretation: exit1Note } : {}) },
1612
+ details: {
1613
+ type: "bash",
1614
+ stdout: clippedStdout,
1615
+ stderr: clippedStderr,
1616
+ exitCode,
1617
+ ...(exit1Note ? { returnCodeInterpretation: exit1Note } : {}),
1618
+ ...(cleanOverflowFile !== undefined ? { output_file: cleanOverflowFile } : {}),
1619
+ },
1590
1620
  };
1591
1621
  }
1592
1622
  function bashDescription(coAuthor, bgNotifies = false, bgRetained = false) {
@@ -1828,7 +1858,7 @@ export function makeBashTool(env, rootCanonical, coAuthor = false, cwdRef = { cu
1828
1858
  : undefined;
1829
1859
  let res;
1830
1860
  try {
1831
- res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId);
1861
+ res = await runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, cwdRef, detachChain, taskOpts.execClamp, ctx.toolCallId, false);
1832
1862
  }
1833
1863
  finally {
1834
1864
  taskOpts.detachHub?.gc(ctx.toolCallId);
@@ -1863,7 +1893,7 @@ export function makeBashReadonlyTool(env, rootCanonical, allow, execClamp) {
1863
1893
  const reason = coarseReadonlyCheck(command, allow);
1864
1894
  if (reason)
1865
1895
  return `Error (Bash): ${reason}`;
1866
- return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId);
1896
+ return runShell(env, rootCanonical, "Bash", command, msTimeoutToSec(timeout), ctx.signal, undefined, undefined, execClamp, ctx.toolCallId, true);
1867
1897
  },
1868
1898
  });
1869
1899
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "1.433.0",
3
+ "version": "1.435.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",