UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

187 lines (186 loc) • 14 kB
/** * design/105 — Scheduler seam(自唤醒/自调度)。「手」腿(`ExecutionEnv`)的**跨-task** 未来意图能力:一个 run 里的模型 * 把「未来要做的活」(cron 排程 / 延迟唤醒)持久化交给一个**常驻 backend**(TOC = shell daemon),由它在未来重起一个 * **新** task。 * * 🔴 与 {@link import("./background-shell.js").BackgroundShellCapability} 的根本区别(design/105 §3.1 D1): * background-shell 的进程 ⊆ task(在每条退出路径 dispose,不跨 suspend);scheduler 的意图必须 **SURVIVE task 结束** * (schedule 的全部意义就是当前 task 结束后 daemon 还能重起)——镜像反转,**绝不 dispose-on-exit**。 * * core 薄壳裁定:core 只出接口 + 工具壳 + `hasScheduler` 门控;**持久化存储 / cron 时钟 / idle 触发 / 起新 task 全在 * backend(daemon)**——core 是 request-driven 不常驻,绝不假装调度器。NodeExecutionEnv 不自带 daemon(不同于 * background-shell 自带 spawn),而是**委托一个注入的 backend**(由一个 shell daemon 实现本接口)。 */ import type { ExecutionEnv, Result } from "../internal/harness-types.js"; /** 一个已排未来意图的句柄。**backend-local、durable(daemon 持久化、重启存活)、对调用方不透明** —— 绝不暴露可枚举的 raw id。 */ export type ScheduledTaskId = string & { readonly __brand: "ScheduledTaskId"; }; /** * 类型化错误码(对齐 background-shell 风格)。方法 MUST NOT throw(返回 {@link Result})。 */ export type SchedulerErrorCode = "unsupported" | "not_found" | "limit_exceeded" | "invalid_schedule" | "io"; /** scheduler 操作的类型化错误。`code` 给程序分类,`message` 给模型自纠。 */ export declare class SchedulerError extends Error { readonly code: SchedulerErrorCode; readonly cause?: unknown | undefined; constructor(code: SchedulerErrorCode, message: string, cause?: unknown | undefined); } /** * 🔴 **模型可控面(不可信)**:纯意图,**无任何身份字段** —— 防模型经自排 prompt 权限抬升(design/62 同款红线:身份是 * Runner-held,非工具参数)。`prompt` = 未来 task 的不可信 `objective`(daemon 起 task 时经完整 prepare-task gate)。 */ export interface ScheduledIntent { /** 未来要跑的 prompt(agent 自排)。daemon 起新 task 时作 `objective`。 */ prompt: string; /** 何时触发。`cron`=重复(受 minCronIntervalSec 约束);`at`=一次性绝对时刻;`delay`=相对延迟(一次性 self re-wake 用)。 */ when: { kind: "cron"; expr: string; } | { kind: "at"; atMs: number; } | { kind: "delay"; delaySec: number; }; /** 可选短标签;与 `(scope, when)` 一起做 upsert 去重键(防工具重试产生重复 intent)。 */ label?: string; /** * [c209-D2] 生存期维度(durable 分层激活批 2026-07-16;缺省 `"durable"` = 既有行为,零破坏)。 * - `"durable"`:daemon 持久化、重启存活、跨会话继续触发——本 seam 的历史唯一语义(intent 不带本字段 * 即此义,老 backend 零迁移)。 * - `"session"`:会话级易失体(上游同名工具的缺省语义——「会话内过会儿跑一下」,不该活成永生任务)。 * 🔴 消费契约(daemon 半场,shell 仓实现):session intent MUST 随带 `sessionId` 的 * {@link SchedulerContext} 排入(工具壳保证);该会话终结(正常收束 / 超时 reap / 显式关闭)时 daemon * MUST 一并 reap 本 intent——绝不让它在会话死后继续触发。不支持本维度的 backend 不会收到 session * intent:工具壳按 {@link SchedulerCapability} 的 `supportsSessionLifetime` 能力位门控,不支持时对 * session 语义调用诚实拒绝并引导 `durable: true`——**绝不静默升级为永生任务**(本案的整个意义)。 */ lifetime?: "durable" | "session"; /** * [c209-D2] `when.kind === "cron"` 专用:`false` = 命中一次即删(上游 recurring:false 语义——触发一次后 * 等价一次性 at);缺省 `true` = 每次 cron 匹配都触发(既有行为,intent 不带本字段即此义)。 * 🔴 消费契约(daemon 半场,shell 仓实现):recurring:false 的 cron intent 首次触发后 daemon MUST 删除 * 该 intent,不再排下次。core 侧只把字段进 seam;delay/at kind 与 recurring 同给在工具层 fail-closed 拒 * (recurring 只属 cron),故 daemon 只会在 cron intent 上见到本字段。 */ recurring?: boolean; /** * 执行形态(CC ScheduleWakeup 对齐批 2026-07-11;缺省 `"task"` = 既有行为,全向后兼容)。 * - `"task"`:daemon 起一个**无上下文新 task**(`prompt` 作 objective,经完整 prepare-task gate)——CronCreate 语义。 * - `"session-wakeup"`:daemon **续 `ctx.sessionId` 指定的同一会话**(`prompt` 作该会话下一轮输入;同上下文、 * 同 prompt cache)——CC ScheduleWakeup 的 /loop dynamic 语义。🔴 契约:`mode:"session-wakeup"` 的 intent * MUST 随一个带 `sessionId` 的 {@link SchedulerContext} 排入(工具壳保证);daemon 触发时 MUST resume 该 * session 而非起新 task,且 principal 原样(不升权)。不支持续会话的 daemon MUST 拒(`invalid_schedule`), * 绝不静默降级成新 task(丢上下文=静默换语义)。 */ mode?: "task" | "session-wakeup"; } /** * 🔴 **Runner-held 可信面**(工具工厂闭包捕获,模型无法触及)。镜像 subagent 经 `ctx.principal` 继承的代码层强制 * (`agents/subagent.ts`)。`schedule` 的实现 MUST 把 `principal` 钉死进持久化 intent;daemon 触发时原样传 * `runTask({ principal })` —— **不升权、不省略**。 */ export interface SchedulerContext { /** * 越界隔离键(cancel/list 只能操作同 scope 排的)。由 Runner 填,且**按被操作 intent 的 lifetime 分叉** * (ruled 2026-08-03):`lifetime:"session"` 的意图用会话链(sessionId ?? principal ?? taskId ?? "default"); * durable 意图用跨会话稳定身份(principal ?? "default")——否则 durable job 会被钉死在排它的那个会话里, * 后续会话 cancel/list 一律 `not_found`(任务照常触发却再也删不掉)。backend 侧无需分辨腿别:照旧只按 * 收到的 `scope` 隔离即可;**同一 daemon 上两类键共存**,一次 `list` 只回本 scope 的行(工具壳自己并两腿)。 * ⚠️ 部署面:本分叉之前写下的 durable 行仍带旧的会话键,一次性重铸键归 backend 半场,engine 不做迁移。 */ scope: string; /** 排程者身份;daemon 触发时原样传给 runTask(防权限抬升)。 */ principal?: string; /** 可选:daemon 触发新 task 时复用的 session(chat-continuity;缺省=fresh-session)。⚠️ 复用需 durable sessionStore(design/105 §3.4)。 */ sessionId?: string; /** * 可选 opaque 起-task 配置(model/policy 提示)。v1 daemon 可忽略、用部署默认模板起 task(intent.prompt + 继承的 * principal 已足);未来精细化时由 daemon 解释。core 不定其 schema(最薄)。**不可由模型提供**(Runner-held)。 */ taskConfig?: unknown; } /** {@link SchedulerCapability.list} 的条目:足够 self-discovery + 防重复排,不含完整意图。 */ export interface ScheduledTaskSummary { id: ScheduledTaskId; /** 渲染用:cron expr / at 时刻 / delay 秒 的人读摘要。 */ when: string; label?: string; /** 下次触发的 epoch ms(cron/at 可算;daemon 提供)。 */ nextRunMs?: number; /** 执行形态回显({@link ScheduledIntent.mode})。ScheduleWakeup 的 stop:true 靠它定位要取消的 pending * wakeup(cron/task 意图不受 stop 影响——CC 206 D9 语义)。老 backend 不回 = undefined(工具壳以 * label 兜底过滤)。 */ mode?: "task" | "session-wakeup"; /** [c209-D2]:生命周期层级回显({@link ScheduledIntent.lifetime})——缺了它,任务创建后就再无 * 公开面能辨认自己是会话级还是持久级(CronCreate 的即时 details 是唯一一瞥)。老 backend 不回 = * undefined(层级未知,按既有 durable 直觉读)。 */ lifetime?: "durable" | "session"; /** [c209-D2]:one-shot cron 回显({@link ScheduledIntent.recurring});undefined = 老 backend * 或非 cron 意图。 */ recurring?: boolean; } /** * 「手」腿的跨-task 调度能力。一个具体 `ExecutionEnv` 可选附加它(交叉类型),经 {@link hasScheduler} 检测。不支持的 * env(无注入 daemon backend)= `schedulerCapabilities.supported=false`,四个 scheduler 工具自动不挂(INERT)。 */ export interface SchedulerCapability { /** 显式声明能力(像 background-shell 一样不猜)。`supported:false` → 工具不挂(design/105 §3.9 TOB / 无 daemon)。 */ readonly schedulerCapabilities: { readonly supported: boolean; /** 单 scope 存量 intent 上限(防自排炸弹;超限 `limit_exceeded`)。 */ readonly maxScheduledPerScope: number; /** delay/at 最小延迟秒(防 busy 自醒)。 */ readonly minDelaySec: number; /** cron 解析后实际触发间隔下限秒(`* * * * *` 占 1 slot 但无限触发 → 须有下限)。 */ readonly minCronIntervalSec: number; /** 最远排程地平线秒(fail-closed 有界,绝不无限未来)。 */ readonly maxScheduledHorizonSec: number; /** * [c209-D2] backend 是否支持 {@link ScheduledIntent.lifetime} `"session"`(intent 随排入会话终结被 * reap)。缺省 undefined/false = 不支持:工具壳收到 session 语义调用(CC 形 `durable` 缺省 false)时 * 诚实拒绝并引导 `durable: true`——**绝不静默落成永生任务**。声明 `true` 的 backend MUST 实现 session * intent 的 reap 消费契约(见 {@link ScheduledIntent.lifetime} 注释)。 * 🔴(TOCTOU 契约条款):本能力位 MUST 在 backend **实例生命周期内不可变**(`readonly` 快照, * 不得热翻)——工具壳的门控读与 `schedule()` 是两次操作,中途翻位会让 session intent 落到不会 reap 的 * backend 上。防线双置:声明面不可变 + `schedule()` 实现 MUST 自行拒绝它无法履行 reap 契约的 * `lifetime:"session"` intent(fail-closed 兜底,不依赖工具壳的先行探测)。 * * 「诚实拒绝」条款的适用边界:它约束**携带 session 语义字段的调用**(CronCreate 的 `durable` 形)。 * 一个没有 durable 逃生口的工具(ScheduleWakeup 形)对不支持位的 backend 走的是另一臂——发送 * 历史 durable intent 并在回执与工具描述上**披露**该 wakeup 不随会话终结(披露≠静默;拒绝会在 * 零收益下拿掉该宿主的整只工具)。该臂为既裁形(capability 位 opt-out 裁定),非本条款的违例。 */ readonly supportsSessionLifetime?: boolean; /** * TB 裁决 — 宿主是否有会兑现 `mode:"session-wakeup"` intent 的常驻唤醒腿。 * **opt-out 方向(与 supportsSessionLifetime 的 fail-closed 缺省相反,有意)**:session-wakeup * 是既有工作语义(daemon 宿主已在兑现),缺省关断=误伤现网——缺省/undefined=支持;headless 单发 * 宿主(如 `-p` print 路径:零 daemon,进程终答即退)**显式声明 false** ⇒ 工具壳对 ScheduleWakeup * 诚实拒绝(排入即失约=静默尸体,比拒绝更糟)。不可变纪律同 supportsSessionLifetime。 */ readonly supportsSessionWakeup?: boolean; }; /** * 持久化一个未来意图。`intent` = 模型可控(无身份);`ctx` = Runner-held 可信上下文(principal/scope/...)。返回不透明 * 句柄。🔴 实现 MUST 把 `ctx.principal` 钉死进持久化 intent(daemon 触发原样传 runTask,不升权);MUST 用 cron 库 * 解析 `when.expr` **禁 shell-exec**(RCE 红线);MUST 持久化(durable,daemon 重启存活)。upsert:同 * `(scope, when, label, lifetime)` 覆盖(幂等)——**lifetime 是身份的一部分**(RB-12/server 1.215 F1 案: * session 级与 durable 级、或不同会话的 session 级 job 撞 (when,label) 是正常用法,MUST NOT 互相覆盖; * session 级的身份还含 sessionId)。 */ schedule(intent: ScheduledIntent, ctx: SchedulerContext): Promise<Result<{ id: ScheduledTaskId; }, SchedulerError>>; /** 取消一个已排意图(幂等:取消不存在的是 ok no-op)。🔴 越界:`id` MUST 属本 scope;非本 scope 一律 `not_found`,不越界存在性。 */ cancel(id: ScheduledTaskId, ctx: SchedulerContext): Promise<Result<void, SchedulerError>>; /** 列**本 scope** 已排意图(self-discovery + 防重复排)。范围 = `ctx.scope`,非全局。 */ list(ctx: SchedulerContext): Promise<Result<ScheduledTaskSummary[], SchedulerError>>; } /** * 结构 + 语义检测:`env` 是否暴露 scheduler 能力且声明 `supported:true`。挂载门控用此(对齐 `hasBackgroundShell`)。 */ export declare function hasScheduler(env: ExecutionEnv): env is ExecutionEnv & SchedulerCapability; export declare function isValidCronExpr(expr: string): boolean;