UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

436 lines (435 loc) • 26 kB
import { emitTrace } from "../trace.js"; import { StubExecutionEnv } from "../stub-env.js"; import { hasBackgroundShell } from "../background-shell.js"; import { defaultTaskRegistry } from "../task-registry.js"; import { bashReversibilityProbe, activeReadDenyBuiltinTiers, readDenyBuiltinConfigOf, compileReadDeny, compileWriteDeny, withRuleRowsWhereTheyLead, readDenySeat, createHandsToolkit, deploymentFaceClampNotice, pdfModelCapabilitiesOf, inheritSessionReadFileState, backgroundOutputReadExemption, resolveContainmentFace, seedReadFileStateFromContext, seedReadFileStateFromTranscript, } from "../../tools/fs/index.js"; import { resolveKey, sha256 } from "../../tools/fs/safety.js"; import { placeReadDenyAtHome, whereItsAnsweredPrefixLeads } from "../../tools/fs/read-deny-home.js"; import { createShellOverflowSpoolFence } from "../../tools/fs/fs-shared.js"; import { createShellReadStation } from "../../tools/fs/shell-read-check.js"; import { composeReadChecks, createFileReadStation } from "../../tools/fs/file-read-check.js"; import { readTargetToolNames } from "../tool-registry.js"; import { normalizeFileText } from "../../tools/fs/encoding.js"; import { wholeFileRecordsFromTranscript } from "./session-file-state-replay.js"; import { deliverEngineNotice } from "../types.js"; import { readRootGrantOver, rootAdmittingArtifactStore } from "./read-root-grant.js"; import { sessionSeatAnchorHolds } from "./session-read-file-states.js"; import { rebaseWorkspacePath } from "./workspace-path.js"; import { RosterBuilder } from "../tool-roster.js"; import { modePolicyOf } from "../mode-policy.js"; const faceClampAnnouncedSinks = { read: new WeakSet(), write: new WeakSet() }; const faceClampConsoleAnnounced = { read: false, write: false }; function announceDeploymentFaceClamp(kind, sink) { if (typeof sink === "function") { if (faceClampAnnouncedSinks[kind].has(sink)) return; faceClampAnnouncedSinks[kind].add(sink); } else { if (faceClampConsoleAnnounced[kind]) return; faceClampConsoleAnnounced[kind] = true; } deliverEngineNotice(sink, deploymentFaceClampNotice(kind)); } export function __resetReadFaceClampAnnouncement() { faceClampConsoleAnnounced.read = false; faceClampConsoleAnnounced.write = false; } function assertStringArraySeat(value, seat, entryLaw) { if (value === undefined) return; if (!Array.isArray(value)) { const e = new Error(`${seat} must be an array or absent (got ${value === null ? "null" : typeof value}) — a non-array here would be iterated character by character, so it refuses loudly instead.`); e.code = "config.read_face_seat"; throw e; } if (entryLaw !== "strings") return; const entries = value; for (const entry of entries) { if (typeof entry !== "string") { const e = new Error(`${seat} entries must be strings (got ${entry === null ? "null" : typeof entry}).`); e.code = "config.read_face_seat"; throw e; } } } function writeFaceReader(input, onDeploymentClamp) { const { spec, deps, resume, fullShellReachable, permissionMode } = input; const seats = { kind: "write", readOnlyMount: spec.handsReadOnly === true, orgGoverned: deps.permissionRuleStore?.partitions.org === true, fullShellReachable }; resolveContainmentFace({ ...seats, specFace: spec.writeFace, depsFace: deps.writeFace, ...(onDeploymentClamp !== undefined ? { onDeploymentClamp } : {}) }); const recorded = resume === undefined ? "live" : resume.seed.writeFace?.face; const folded = (word) => (recorded === "roots" ? "roots" : word === "open" && recorded !== "open" && recorded !== "live" ? undefined : word); const faceUnder = (modeFace) => resolveContainmentFace({ ...seats, specFace: folded(spec.writeFace), depsFace: folded(deps.writeFace), ...(modeFace !== undefined ? { modeFace } : {}) }); return { inForce: () => faceUnder(modePolicyOf(permissionMode.current).writeFace), fenced: faceUnder("open") === "roots" }; } export function resolveHandsLessReadFace(input) { const { resume, fullShellReachable, spec, deps } = input; assertStringArraySeat(deps.readDenyPatterns, "RunnerDeps.readDenyPatterns", "compiler-owned"); assertStringArraySeat(spec.readDenyPatterns, "TaskSpec.readDenyPatterns", "compiler-owned"); let handsLessResolvedFace; let handsLessResolvedWriteFace; let readDenyAdditionsNormalized = []; let readDenyBuiltinTiersActive = []; { const liveFace = resolveContainmentFace({ kind: "read", specFace: spec.readFace, depsFace: deps.readFace, readOnlyMount: spec.handsReadOnly === true, orgGoverned: deps.permissionRuleStore?.partitions.org === true, fullShellReachable, }); const seed = resume?.seed.readFace; handsLessResolvedFace = resume !== undefined && (seed === undefined || seed.face === "roots") ? "roots" : liveFace; handsLessResolvedWriteFace = writeFaceReader(input); const denyAdditions = [...(deps.readDenyPatterns ?? []), ...(spec.readDenyPatterns ?? []), ...(seed?.denyEntries ?? [])]; readDenyBuiltinTiersActive = activeReadDenyBuiltinTiers(readDenyBuiltinConfigOf({ tiers: deps.readDenyBuiltinTiers, exclude: deps.readDenyBuiltinExclude })); if (denyAdditions.length > 0) { readDenyAdditionsNormalized = compileReadDeny(denyAdditions, "readDenyPatterns", { tiers: [] }).entries; } } return { readFileStateForCheckpoint: undefined, seedContextFiles: undefined, handsCwdRef: undefined, workspaceStateSettle: undefined, wsSnapshot: undefined, rebaseWsPath: (p) => p, backgroundTaskToolsActive: false, shellReadCheck: undefined, shellReadStationArmed: false, shellReadAdmission: undefined, resolvedReadFace: undefined, resolvedWriteFace: undefined, readDenyAdditionsNormalized, readDenyBuiltinTiersActive, handsLessResolvedFace, handsLessResolvedWriteFace, additionalRootsCanonical: [], additionalReadRootsCanonical: [], grantReadRoot: undefined, artifactStoreRef: undefined, attachmentRootCanonical: undefined, readDenyMatcher: undefined, envHandToolNames: new Set(), shellGatedTools: new Set(), sealReadStateSeat: undefined, }; } export async function prepareHandsMount(input) { const envHandToolNames = new Set(); const { handsEnabled, executionEnv, taskRootCanonical, rebaseRestoredPath, resume, session, sessionId, hostTaskId, taskScope, fullShellReachable, effectiveShellGate, toolFaceSnapshot, model, spec, deps, internals, memoryWriteGate, advertisedWritableDirs, firstPartyOffload, roster, egressTools, irreversibilityTier, irreversibleTools, reversibilityProbes } = input; assertStringArraySeat(spec.additionalDirectories, "TaskSpec.additionalDirectories", "strings"); assertStringArraySeat(spec.additionalReadDirectories, "TaskSpec.additionalReadDirectories", "strings"); assertStringArraySeat(deps.readDenyPatterns, "RunnerDeps.readDenyPatterns", "compiler-owned"); assertStringArraySeat(spec.readDenyPatterns, "TaskSpec.readDenyPatterns", "compiler-owned"); let readFileStateForCheckpoint; let sealReadStateSeat; let seedContextFiles; let handsCwdRef; let workspaceStateSettle; let wsSnapshot; let rebaseWsPath = (p) => p; let backgroundTaskToolsActive = false; let shellReadStation; let shellReadTools = new Set(); let fileReadStation; let resolvedReadFace; let resolvedWriteFace; let readDenyAdditionsNormalized = []; let readDenyBuiltinTiersActive = []; let handsLessResolvedFace; const handsLessResolvedWriteFace = undefined; const shellGatedTools = new Set(); const additionalRootsCanonical = []; const additionalReadRootsCanonical = []; let grantReadRoot; const artifactStoreRef = { current: undefined }; let attachmentRootCanonical; let readDenyMatcher; if (handsEnabled) { const rootCanonical = taskRootCanonical; attachmentRootCanonical = rootCanonical; const canonicalizeExtraDirs = async (dirs, field, sink) => { for (const dir of dirs ?? []) { if (!dir || !dir.trim()) continue; const c = await (advertisedWritableDirs?.canonicalizeDeclaredRoot(dir) ?? executionEnv.canonicalPath(dir)); if (c.ok) { sink.push(c.value); } else { deps.onError?.(new Error(`${field} entry skipped (cannot canonicalize): ${dir}`), { phase: "config", sessionId, }); emitTrace(deps.tracer, () => ({ kind: "config.additional_directory_skipped", version: 1, taskId: hostTaskId, entry: dir, ...(field !== "additionalDirectories" ? { field } : {}), reason: `${c.error.code}: ${c.error.message}`, ts: Date.now(), })); } } }; await canonicalizeExtraDirs(spec.additionalDirectories, "additionalDirectories", additionalRootsCanonical); await canonicalizeExtraDirs(spec.additionalReadDirectories, "additionalReadDirectories", additionalReadRootsCanonical); const standingCanonical = []; await canonicalizeExtraDirs(input.persistedReadDirectories, "persistedReadRule", standingCanonical); artifactStoreRef.standing = { keys: standingCanonical.filter((k) => !additionalReadRootsCanonical.includes(k) && !additionalRootsCanonical.includes(k)), readRoots: additionalReadRootsCanonical }; additionalReadRootsCanonical.push(...standingCanonical); if (advertisedWritableDirs !== undefined) { if (spec.envFacts?.scratchpadDir) await advertisedWritableDirs.admit({ path: spec.envFacts.scratchpadDir, advertiser: "scratchpad", materializeMarker: ".sema-scratchpad" }); additionalRootsCanonical.push(...advertisedWritableDirs.roots); } for (const a of [additionalRootsCanonical, additionalReadRootsCanonical]) a.splice(0, a.length, ...new Set(a)); grantReadRoot = readRootGrantOver({ env: executionEnv, writeRoots: additionalRootsCanonical, readRoots: additionalReadRootsCanonical, artifactStore: artifactStoreRef }); const readFileState = new Map((resume?.seed.readFileState ?? []).map(([k, v]) => [rebaseRestoredPath(k), v])); readFileStateForCheckpoint = readFileState; seedContextFiles = async (files) => { for (const f of files) { const rk = await resolveKey(executionEnv, rootCanonical, f.path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, resolvedReadFace); if (rk.ok) seedReadFileStateFromContext(readFileState, rk.key, f.content); } }; const handsIncludeShell = !(executionEnv instanceof StubExecutionEnv); const handsReadOnly = spec.handsReadOnly === true; backgroundTaskToolsActive = handsIncludeShell && !handsReadOnly && hasBackgroundShell(executionEnv); wsSnapshot = resume !== undefined || !(handsIncludeShell && !handsReadOnly) ? undefined : await session.getWorkspaceState().catch(() => undefined); rebaseWsPath = (p) => (wsSnapshot === undefined || wsSnapshot.taskRoot === rootCanonical ? p : rebaseWorkspacePath(p, wsSnapshot.taskRoot, rootCanonical)); if (handsIncludeShell && !handsReadOnly) { handsCwdRef = { current: rootCanonical }; if (resume?.seed.handsCwd !== undefined) handsCwdRef.current = rebaseRestoredPath(resume.seed.handsCwd); else if (wsSnapshot?.handsCwd !== undefined) handsCwdRef.current = rebaseWsPath(wsSnapshot.handsCwd); workspaceStateSettle = { rootCanonical, restoredHandsCwd: handsCwdRef.current !== rootCanonical ? handsCwdRef.current : undefined, ...(resume !== undefined ? { baselineUnknown: true } : {}), }; } const seedReadFaceSection = resume?.seed.readFace; const readDenyAdditions = [...(deps.readDenyPatterns ?? []), ...(spec.readDenyPatterns ?? []), ...(seedReadFaceSection?.denyEntries ?? [])]; const readDenyBuiltinCfg = readDenyBuiltinConfigOf({ tiers: deps.readDenyBuiltinTiers, exclude: deps.readDenyBuiltinExclude }); const pathDenyRules = await withRuleRowsWhereTheyLead(input.pathDenyRules, (head) => whereItsAnsweredPrefixLeads(executionEnv, head)); const readDenyPlacement = await placeReadDenyAtHome(executionEnv, (homes, linked) => compileReadDeny(readDenyAdditions, "readDenyPatterns", readDenyBuiltinCfg, pathDenyRules, homes, linked)); readDenyMatcher = readDenyPlacement.matcher; readDenyBuiltinTiersActive = activeReadDenyBuiltinTiers(readDenyBuiltinCfg); readDenyAdditionsNormalized = compileReadDeny(readDenyAdditions, "readDenyPatterns", { tiers: [] }).entries; let liveReadFace = resolveContainmentFace({ kind: "read", specFace: spec.readFace, depsFace: deps.readFace, readOnlyMount: handsReadOnly, orgGoverned: deps.permissionRuleStore?.partitions.org === true, fullShellReachable, onDeploymentClamp: () => announceDeploymentFaceClamp("read", deps.onNotice), }); if (resume !== undefined && (seedReadFaceSection === undefined || seedReadFaceSection.face === "roots")) liveReadFace = "roots"; resolvedReadFace = liveReadFace; resolvedWriteFace = writeFaceReader(input, () => announceDeploymentFaceClamp("write", deps.onNotice)); const sessionSeats = input.internals?.sessionReadStates; let seatLeafId; try { seatLeafId = (await session.getStorage().getLeafId()) ?? null; } catch { seatLeafId = undefined; } const judgeKey = async (path) => { const rk = await resolveKey(executionEnv, rootCanonical, path, undefined, undefined, [...additionalRootsCanonical, ...additionalReadRootsCanonical], undefined, undefined, liveReadFace); return rk.ok ? rk.key : undefined; }; if (resume === undefined && spec.sessionId !== undefined) { const seatRow = spec.resumeAt === undefined && seatLeafId !== undefined ? sessionSeats?.get(sessionId) : undefined; const inheritedSeat = seatRow !== undefined && (await sessionSeatAnchorHolds((id) => session.getEntry(id), seatRow.anchorLeafId, seatLeafId)) ? seatRow.state : undefined; if (inheritedSeat !== undefined) { await inheritSessionReadFileState(readFileState, inheritedSeat, judgeKey); } else { const prior = await session.buildContext().catch(() => undefined); for (const rec of wholeFileRecordsFromTranscript(prior?.messages ?? [])) { const rk = await judgeKey(rec.path); if (rk !== undefined) seedReadFileStateFromTranscript(readFileState, rk, rec.content, rec.at); } } } if (input.restoredFilePaths !== undefined) { for (const abs of input.restoredFilePaths) { const k = await judgeKey(abs); if (k === undefined) continue; const entry = readFileState.get(k); if (entry === undefined || entry.seededFromContext === true) continue; const cur = await executionEnv.readTextFile(abs); if (cur.ok && entry.hash === sha256(normalizeFileText(cur.value))) continue; readFileState.delete(k); } } if (seatLeafId !== undefined) sessionSeats?.adopt(sessionId, readFileState, seatLeafId); sealReadStateSeat = sessionSeats !== undefined && seatLeafId !== undefined ? (ownCommittedTailEntryId) => { if (ownCommittedTailEntryId !== undefined) sessionSeats.reanchor(sessionId, readFileState, ownCommittedTailEntryId); } : undefined; const denySeat = readDenySeat(readDenyMatcher); const writeDeny = compileWriteDeny(pathDenyRules); const writeDenySeat = writeDeny.ruleRows.length > 0 ? { writeDenyMatch: (p) => writeDeny.matchPath(p)?.pattern ?? null } : {}; const shellReadBoundary = () => ({ roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical], ...(handsCwdRef?.current !== undefined ? { cwd: handsCwdRef.current } : {}), ...(executionEnv.homeDir !== undefined ? { homeDir: executionEnv.homeDir } : {}), ...denySeat, ...writeDenySeat, writeRoots: [rootCanonical, ...additionalRootsCanonical], ...((judge) => (judge !== undefined ? { writeProtectionJudge: judge } : {}))(input.writeGuards?.protection()), ...(input.writeGuards?.target !== undefined ? { writeTargetGuard: input.writeGuards.target } : {}), ...(resolvedReadFace !== undefined ? { face: resolvedReadFace } : {}), }); if (handsIncludeShell) { const spoolFence = handsReadOnly ? createShellOverflowSpoolFence(executionEnv) : undefined; const readonlyBoundary = () => spoolFence !== undefined ? { roots: [rootCanonical, ...additionalRootsCanonical, ...additionalReadRootsCanonical], cwd: rootCanonical, ...denySeat, ...writeDenySeat, alsoInside: spoolFence } : undefined; shellReadTools = new Set(handsReadOnly ? ["Bash"] : backgroundTaskToolsActive ? ["Bash", "Monitor"] : ["Bash"]); shellReadStation = createShellReadStation({ env: executionEnv, tools: shellReadTools, seatOf: () => (handsReadOnly ? "readonly" : "full_shell"), boundaryOf: () => readonlyBoundary() ?? shellReadBoundary(), rootsCanonical: true, homeViews: readDenyPlacement.matcher.homeViews, }); } if (!handsReadOnly) { const bgOutputExempt = backgroundOutputReadExemption(defaultTaskRegistry, { taskOwner: hostTaskId, taskScope, ...(sessionId !== undefined ? { sessionId } : {}) }, executionEnv); fileReadStation = createFileReadStation({ env: executionEnv, rootCanonical, roots: () => [...additionalRootsCanonical, ...additionalReadRootsCanonical], ...(readDenyMatcher !== undefined ? { readDeny: readDenyMatcher } : {}), ...(liveReadFace !== undefined ? { readFace: liveReadFace } : {}), ...("homeDir" in executionEnv && executionEnv.homeDir !== undefined ? { homeDir: executionEnv.homeDir } : {}), tools: readTargetToolNames(), excluded: (key) => artifactStoreRef.current !== undefined && rootAdmittingArtifactStore(artifactStoreRef.current, [key]) !== undefined, exempt: (key) => bgOutputExempt?.(key) === true, }); } const band = createHandsToolkit(executionEnv, readFileState, rootCanonical, { ...(additionalRootsCanonical.length > 0 ? { additionalRoots: additionalRootsCanonical } : {}), additionalReadRoots: additionalReadRootsCanonical, ...(readDenyAdditions.length > 0 ? { readDenyPatterns: readDenyAdditions } : {}), ...(pathDenyRules.length > 0 ? { pathDenyRules } : {}), readDenyHomes: readDenyPlacement.homes, readDenyHomeLinks: readDenyPlacement.linked, ...(readDenyBuiltinCfg.tiers !== undefined ? { readDenyBuiltinTiers: readDenyBuiltinCfg.tiers } : {}), ...(readDenyBuiltinCfg.exclude !== undefined ? { readDenyBuiltinExclude: readDenyBuiltinCfg.exclude } : {}), readFace: liveReadFace, writeFaceLive: resolvedWriteFace.inForce, reminderMark: input.reminderMark, reminderDisclosureCounts: input.reminderDisclosureCounts, ...(input.trackFileEdit !== undefined ? { trackFileEdit: input.trackFileEdit } : {}), ...(input.onFileEdited !== undefined ? { onFileEdited: input.onFileEdited } : {}), includeShell: handsIncludeShell, readOnly: handsReadOnly, ...(shellReadStation !== undefined ? { shellReadAdmission: shellReadStation.admit } : {}), ...(fileReadStation !== undefined ? { readAdmission: fileReadStation.admitted } : {}), ...(handsCwdRef ? { cwdRef: handsCwdRef } : {}), taskRegistry: defaultTaskRegistry, taskOwner: hostTaskId, taskScope, oneShot: spec.oneShot, ...(sessionId !== undefined ? { sessionId } : {}), retainBackgroundProcesses: spec.retainBackgroundProcesses === true, ...(internals?.onTaskNotification !== undefined ? { taskNotification: internals.onTaskNotification } : {}), ...(internals?.detachHub !== undefined ? { detachHub: internals.detachHub } : {}), onToolProgress: (body) => input.toolProgressRef.current?.emit(body), queuedHumanInput: () => input.harnessRef.current?.hasPendingUserInput() === true, ...(deps.onNotice !== undefined ? { onNotice: deps.onNotice } : {}), pdfModelCapabilities: pdfModelCapabilitiesOf(model), ...(deps.hands?.bashReadonlyAllow !== undefined ? { bashReadonlyAllow: deps.hands.bashReadonlyAllow } : {}), ...(deps.hands?.commitCoAuthor !== undefined ? { commitCoAuthor: deps.hands.commitCoAuthor } : {}), ...(deps.hands?.readImageDownsampler !== undefined ? { readImageDownsampler: deps.hands.readImageDownsampler } : {}), ...(deps.hands?.autoBackgroundOnTimeout !== undefined ? { autoBackgroundOnTimeout: deps.hands.autoBackgroundOnTimeout } : {}), beforeWrite: async (w) => { const deploymentGate = deps.hands?.beforeWrite; if (deploymentGate) { const verdict = await deploymentGate(w); if (verdict !== undefined && verdict.ok === false) return verdict; } return memoryWriteGate?.(w); }, mountBackgroundTaskTools: false, monitorToolActive: backgroundTaskToolsActive && !(toolFaceSnapshot.exclude?.includes("Monitor") ?? false), }); { const bandNames = new Set(band.flatMap((t) => [t.name, ...(t.aliases ?? [])])); const shadowed = (spec.tools ?? []).filter((t) => [t.name, ...(t.aliases ?? [])].some((n) => bandNames.has(n))); if (shadowed.length > 0) { const names = shadowed.map((t) => `"${t.name}"`).join(", "); deps.onError?.(new Error(`spec.tools ${shadowed.length > 1 ? "entries" : "entry"} ${names} collide${shadowed.length > 1 ? "" : "s"} with the built-in hands tool band — the built-in wins (registered later; the harness tool Map is last-write-wins) and the caller tool never dispatches this task. Rename the caller tool, or don't inject executionEnv if you intend to replace the band.`), { phase: "config", sessionId }); } } roster.mountAll(band.map((t) => (envHandToolNames.add(t.name), firstPartyOffload(t))), { source: "builtin", mountedBy: "hands" }); const shellFloor = effectiveShellGate; if (!(executionEnv instanceof StubExecutionEnv) && spec.handsReadOnly !== true) { const installShellSeat = (name) => { const tierBefore = irreversibilityTier.get(name); const seatOwned = !egressTools.has(name) && tierBefore !== "always" && tierBefore !== "maybe"; const tier = shellFloor === "always" || tierBefore === "always" ? "always" : "maybe"; irreversibilityTier.set(name, tier); irreversibleTools.add(name); if (seatOwned && tier === "maybe") reversibilityProbes.set(name, bashReversibilityProbe(undefined, shellReadBoundary)); if (seatOwned) shellGatedTools.add(name); }; installShellSeat("Bash"); if (backgroundTaskToolsActive) installShellSeat("Monitor"); } } return { readFileStateForCheckpoint, seedContextFiles, handsCwdRef, workspaceStateSettle, wsSnapshot, rebaseWsPath, backgroundTaskToolsActive, shellReadCheck: composeReadChecks([...(shellReadStation !== undefined ? [{ check: shellReadStation.check, tools: shellReadTools }] : []), ...(fileReadStation !== undefined ? [fileReadStation] : [])]), shellReadStationArmed: shellReadStation !== undefined, shellReadAdmission: shellReadStation?.admit, resolvedReadFace, resolvedWriteFace, handsLessResolvedWriteFace, readDenyAdditionsNormalized, readDenyBuiltinTiersActive, handsLessResolvedFace, additionalRootsCanonical, additionalReadRootsCanonical, grantReadRoot, artifactStoreRef, attachmentRootCanonical, readDenyMatcher, envHandToolNames, shellGatedTools, sealReadStateSeat, }; }