@sema-agent/core
Version:
Stateless, task-oriented AI agent core
183 lines (182 loc) • 14.3 kB
JavaScript
import { Type } from "typebox";
import { defineTool, errorResult } from "../core/tools.js";
import { envDeclaresRetain, hasBackgroundShell, retainsBackgroundProcesses } from "../core/background-shell.js";
import { MONITOR_DEFAULT_TIMEOUT_MS, MONITOR_MAX_TIMEOUT_MS, } from "../core/task-registry.js";
import { toolFace } from "../core/tool-catalog-entries.js";
import { isDeadCwd, diagnoseDeadCwd } from "./fs/dead-cwd.js";
import { thrownText } from "../core/thrown-value.js";
const MONITOR_DESCRIPTION = "Run a shell command in the background and watch its stdout as a stream of events.\n" +
"\n" +
"- Each line the command writes to stdout becomes a notification event delivered to you between turns; " +
"multiple lines arriving within a short window (~200ms) are batched into a single notification.\n" +
"- When the command exits, the watch ends and one final notification reports its exit code. A watch that " +
`exceeds its timeout is killed and reported (default ${MONITOR_DEFAULT_TIMEOUT_MS}ms, max ${MONITOR_MAX_TIMEOUT_MS}ms).\n` +
"- stderr does NOT trigger notifications, but it is captured — read it any time with TaskOutput(task_id), " +
"which also serves the full re-readable stdout spool.\n" +
"- A monitor that produces too many events is rate-limited: excess batches are suppressed (a notification tells you how many), and a watch that stays over the limit is stopped automatically.\n" +
"- Set persistent: true for a session-resident watch with no watch timeout of its own; it still ends when " +
"the execution environment's background time budget expires, when you stop it with TaskStop(task_id), or " +
"when the session is released.\n" +
"- After starting a monitor you do not need to poll — end your turn if nothing else is pending; events will " +
"reach you as notifications.\n" +
"- Use this for logs, dev servers, build watchers, or any wait-for-a-condition loop (e.g. `tail -f app.log`, " +
"or a script that prints a line only when the condition it checks changes).\n" +
"\n" +
"Pick by how many notifications you need:\n" +
"- One, when a command finishes: don't Monitor it — run it with Bash `run_in_background: true`; its completion notification is the single notification you need.\n" +
'- One, when a condition first becomes true: Monitor a BOUNDED command that exits once the condition holds (e.g. `until grep -qE "..." app.log; do sleep 5; done`) — the exit is your notification. Avoid piping a follow-mode tail into a match-and-quit grep: the shell keeps waiting on tail after the match (tail only notices the closed pipe on its next write), so on a quiet log that watch may never end.\n' +
"- Many, an ongoing stream (logs, dev servers, watchers): Monitor an open-ended command like `tail -f` and let the line events flow.\n" +
"**Don't use an unbounded command for a single notification.** An open-ended watch for a one-shot answer streams noise into the conversation and never tells you the answer arrived — bound the command so its exit IS the signal.\n" +
"\n" +
"**Coverage — silence is not success.** When watching a job or process for an outcome, your filter must match every terminal state, not just the happy path. A happy-path-only filter goes silent when the job crashes — and that silence is indistinguishable from \"still running\".\n" +
'- Wrong: `until grep -q "BUILD SUCCEEDED" build.log; do sleep 5; done` — if the build fails, this never exits and you wait until the watch times out.\n' +
'- Right: `until grep -qE "BUILD (SUCCEEDED|FAILED)" build.log; do sleep 5; done` — every terminal state exits.\n' +
"Before arming, ask: *if this process crashed right now, would my filter emit anything?* If not, widen it.";
function envBackgroundWallSec(caps, requestedSec) {
const positive = (v) => (typeof v === "number" && Number.isFinite(v) && v > 0 ? v : undefined);
const cap = positive(caps.maxBgTimeoutSec);
const base = requestedSec ?? positive(caps.defaultBgTimeoutSec);
if (base === undefined)
return cap;
if (cap === undefined)
return base;
return Math.min(base, cap);
}
export function createMonitorTool(env, opts) {
return defineTool({
name: "Monitor",
...toolFace("monitor"),
description: MONITOR_DESCRIPTION,
parameters: Type.Object({
command: Type.String({
description: "The shell script to run and watch. Each line it writes to stdout becomes an event; when it exits, the watch ends and the exit code is reported.",
}),
description: Type.String({
description: "Short human-readable label for this watch, shown in every notification it produces.",
}),
timeout_ms: Type.Optional(Type.Number({
description: `How long the watch may run before it is killed, in milliseconds (default ${MONITOR_DEFAULT_TIMEOUT_MS}, max ${MONITOR_MAX_TIMEOUT_MS}). Ignored when persistent is true.`,
})),
persistent: Type.Optional(Type.Boolean({
description: "Set to true for a session-resident monitor with no watch timeout of its own (default false). It keeps watching until the execution environment's background time budget expires, until you stop it with TaskStop, or until the session ends.",
})),
}),
execute: async (args, ctx) => {
const { command, description, timeout_ms, persistent } = args;
if (!hasBackgroundShell(env)) {
return errorResult("Error (Monitor): this environment does not support background processes.");
}
const isPersistent = persistent === true;
const timeoutMs = Math.min(MONITOR_MAX_TIMEOUT_MS, Math.max(1, Math.floor(timeout_ms ?? MONITOR_DEFAULT_TIMEOUT_MS)));
const spawnTimeoutSec = isPersistent ? undefined : Math.ceil(timeoutMs / 1000) + 5;
const refused = await opts.shellReadAdmission?.("Monitor", ctx.toolCallId, command, ctx.signal);
if (refused !== undefined)
return refused;
if (opts.cwdRef !== undefined && (await isDeadCwd(env, opts.cwdRef.current, ctx.signal))) {
const diagnosis = await diagnoseDeadCwd(env, "Monitor", opts.cwdRef.current, opts.rootCanonical, opts.cwdRef, ctx.signal);
if (diagnosis !== undefined)
return errorResult(diagnosis);
}
if (ctx.signal?.aborted === true)
return errorResult("Error (Monitor): the call was cancelled before the watch started.");
const spawn = await env.spawnBackground(command, {
...(opts.cwdRef !== undefined ? { cwd: opts.cwdRef.current } : {}),
...(spawnTimeoutSec !== undefined ? { timeout: spawnTimeoutSec } : {}),
});
if (!spawn.ok)
return errorResult(`Error (Monitor): ${spawn.error.message}`);
const sessionScoped = isPersistent && opts.sessionId !== undefined;
const owner = sessionScoped ? opts.sessionId : (ctx.taskId ?? opts.owner);
const scope = ctx.principal ?? opts.scope;
const onNotify = opts.onTaskNotification;
let taskId;
try {
taskId = opts.registry.registerMonitor({
env,
shellId: spawn.value.shellId,
description,
toolUseId: ctx.toolCallId,
...(owner !== undefined ? { owner } : {}),
...(scope !== undefined ? { scope } : {}),
persistent: isPersistent,
...(sessionScoped ? { sessionScoped: true } : {}),
...(isPersistent ? {} : { timeoutMs }),
...(opts.retainBackgroundProcesses !== undefined ? { retainBackgroundProcesses: opts.retainBackgroundProcesses } : {}),
...(onNotify !== undefined ? { onEvent: (n) => onNotify(n, { priority: "next" }) } : {}),
...(opts.timers !== undefined ? { timers: opts.timers } : {}),
...(opts.batchWindowMs !== undefined ? { batchWindowMs: opts.batchWindowMs } : {}),
...(opts.maxBatchesPerMinute !== undefined ? { maxBatchesPerMinute: opts.maxBatchesPerMinute } : {}),
...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
...(opts.toolResultStore !== undefined ? { toolResultStore: opts.toolResultStore } : {}),
});
}
catch (e) {
const why = thrownText(e);
let killDetail;
try {
const kill = await env.killBackground(spawn.value.shellId);
if (!kill.ok)
killDetail = `${kill.error.code}: ${kill.error.message}`;
}
catch (killErr) {
killDetail = thrownText(killErr);
}
return errorResult(killDetail === undefined
? `Error (Monitor): the watch process was started but could not be registered (${why}); it has been terminated.`
: `Error (Monitor): the watch process was started but could not be registered (${why}), and it could NOT be terminated either (${killDetail}). ` +
`It may still be running as background shell ${spawn.value.shellId}; no task id was minted, so TaskOutput/TaskStop cannot reach it — ` +
"stop it out of band (the environment's own background time budget and its teardown sweep are the remaining backstops).");
}
const bgTimeout = envBackgroundWallSec(env.backgroundCapabilities, spawnTimeoutSec);
const wasClamped = timeout_ms !== undefined && timeout_ms !== timeoutMs;
const clampNote = !isPersistent && wasClamped ? ` (clamped to ${timeoutMs}ms from your requested ${timeout_ms}ms)` : "";
const timeoutIgnoredNote = isPersistent && timeout_ms !== undefined ? ` (the timeout_ms you passed is ignored because persistent is true)` : "";
const envWallMs = bgTimeout !== undefined ? bgTimeout * 1000 : undefined;
const envWallWins = !isPersistent && envWallMs !== undefined && envWallMs < timeoutMs;
const persistentBudget = bgTimeout !== undefined
? `the execution environment's background time budget ends the watch after ${bgTimeout}s`
: `the execution environment's background time budget still applies`;
const retainedByEnv = envDeclaresRetain(env);
const retainedBySpec = opts.retainBackgroundProcesses === true;
const retained = retainsBackgroundProcesses({ env: retainedByEnv, task: retainedBySpec });
const persistentAnchor = sessionScoped
? retained
? ". This run retains background processes (environment capability or task spec), so the watch is retained and " +
"survives the session's release: only your TaskStop, the command's own exit, or that budget end it."
: "; it runs until then, until you stop it, or until the session ends."
: retained
? ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true; and because this " +
"run retains background processes (environment capability or task spec) it survives the session's release rather than being settled with the run — " +
"nothing but your TaskStop, the command's own exit, or that budget will end it, and no later turn can reach it " +
"(stop it explicitly with TaskStop before you finish)."
: ". This mount carries no session identity, so the watch is NOT session-resident despite persistent: true — " +
"it belongs to this task alone and the run teardown settles it, so do not count on it outliving this turn " +
"(stop it explicitly with TaskStop when you are done).";
const lifetime = isPersistent
? `It is persistent: no watch timeout of its own${timeoutIgnoredNote}, but ${persistentBudget}${persistentAnchor}`
: envWallWins
? `The watch may run up to ${envWallMs}ms — the execution environment's background time budget (${bgTimeout}s) is shorter than the ${timeoutMs}ms watch timeout${clampNote}, and the shorter of the two wins.`
: `The watch may run up to ${timeoutMs}ms${clampNote}.`;
const content = onNotify !== undefined
? `Monitoring in background; task_id=${taskId}. Each stdout line becomes a notification event (lines within ` +
`~200ms are batched); you'll get a final notification with the exit code when it ends — do not poll. ` +
`${lifetime} TaskOutput("${taskId}") reads the full spool (stdout+stderr); TaskStop("${taskId}") stops the watch.`
: `Monitoring in background; task_id=${taskId}. No notification channel is wired in this mount — read output with ` +
`TaskOutput("${taskId}") (re-readable spool, stdout+stderr) and stop with TaskStop("${taskId}"). ${lifetime}`;
return {
content,
details: {
type: "monitor-start",
taskId,
persistent: isPersistent,
...(bgTimeout !== undefined ? { envBudgetSec: bgTimeout } : {}),
...(envWallWins && envWallMs !== undefined ? { effectiveTimeoutMs: envWallMs } : {}),
...(isPersistent
?
{ timeoutIgnored: timeout_ms !== undefined, sessionScoped, ...(retainedByEnv ? { retainedByEnv: true } : {}), ...(retainedBySpec ? { retainedBySpec: true } : {}) }
: { timeoutMs, ...(timeout_ms !== undefined ? { requestedTimeoutMs: timeout_ms } : {}), wasClamped }),
},
};
},
});
}