全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Gateway Layer
- 2 · Identity and Session Routing
- 3 · The Main Loop ★
- 3.1 The Loop Entry: A Triple Budget Gate
- 3.2 Per-Turn Preparation: build_turn_context
- 3.3 Mid-Turn Interjection: /steer
- 3.4 The Wall-Clock Budget Wrap-Up Reminder
- 3.5 The Interrupt Check
- 3.6 The Step Callback: An Observation Point for the Gateway
- 3.7 Other State Inside the Loop
- 3.8 Why the Loop Exited
- 3.9 An Optional Bypass: The Codex App Server Runtime
- 4 · The Tool System
- 4.1 The Most Important Design Decision: Implementation Separated from Exposure
- 4.2 Composing and Resolving Toolsets
- 4.3 Central Tool Dispatch
- 4.4 The Argument Coercion Layer
- 4.5 Sanitizing Tool Error Messages
- 4.6 Observing Tool Results, and Hooks
- 4.7 Recognizing a Delegation Context
- 4.8 Caching Tool Definitions
- 5 · Approval and Safety Red Lines ★
- 5.1 The Overall Structure
- 5.2 The 12 Hard Red Lines
- 5.3 The Real Difficulty: Telling “Command” from “Data”
- 5.4 Quote Masking: But Leave a Way In for “the Part That Really Executes”
- 5.5 Quoting Is Not a Bypass
- 5.6 Sensitive Paths and Write Targets
- 5.7 The sudo Stdin Guard
- 5.8 Performance: Why Precompile
- 5.9 Retaining Blocked Commands
- 5.10 Smart Approval: An Optional Model Judgment
- 5.11 Context Awareness: Different Situations, Different Policies
- 5.12 Where This Layer Sits: Outermost, and Thinnest
- 6 · Execution Environments
- 7 · The Context Engine ★
- 7.1 What It Defines
- 7.2 Lifecycle
- 7.3 The Three Methods You Must Implement
- 7.4 The Sharpest Design Decision: select and compress Are Two Orthogonal Verbs
- 7.5 The Post-Turn Observation Hook
- 7.6 Other Optional Hooks
- 7.7 Default Parameter Values
- 7.8 Control Over User-Visible Status
- 7.9 Size Comparison of the Built-in Implementation
- 8 · The Memory System ★
- 8.1 The Memory Provider Interface
- 8.2 Lifecycle and Hooks
- 8.3 The Interface's Versioned Contract
- 8.4 The Trivial-Prompt Filter
- 8.5 The Memory-Usage Indicator
- 8.6 The Built-in Holographic Memory
- 8.7 The Storage Layer and Trust Scores
- 8.8 The SQLite State Layer
- 8.9 Summing Up the Division of Labor Among Three Kinds of Memory
- 9 · The Plugin System
- 9.1 Three Discovery Sources
- 9.2 What a Plugin Can Provide
- 9.3 The Most Important Design Decision: “Stackable Capabilities” vs. “Mutually Exclusive Strategies”
- 9.4 Plugin Storage
- 9.5 How Plugins Tie into Toolsets
- 9.6 Plugin Hooks
- 9.7 MCP: The Other Extension Path
- 9.8 The Overall Shape of This Extension System
- 10 · Delegation and Multi-Agent
- 11 · Model Providers and the Credential Pool
- 12 · Scheduled Tasks (Cron)
- 12.1 What “cron” Is
- 12.2 Scenarios for Scheduled Agents
- 12.3 The Single Most Important Class: CronPromptInjectionBlocked
- 12.4 Narrowing the Toolset for Scheduled Tasks
- 12.5 Failure Handling
- 12.6 Preventing Duplicate Runs
- 12.7 Why scheduler.py Is 367 KB
- 12.8 Where Scheduled Tasks Sit in the Overall Architecture
- 13 · The Skill System
- 13.1 What a Skill Is
- 13.2 The Front Matter, Field by Field
- 13.3 Progressive Disclosure: The Core Mechanism of the Skill System
- 13.4 The Infrastructure Around Skills
- 13.5 Skills vs. Tools vs. Plugins
- 13.6 An Implicit Design in the Skill System: Composability
- 13.7 The 15 Skill Categories
- 13.8 Looking Back Across the Book: The Overall Shape of Hermes
12 · 定时任务
cron/,14 个文件,其中 scheduler.py 有 367 KB。这一章讲让智能体在没有人的时候自己跑起来 —— 以及这件事带来的一整类新问题。
12.1 「cron」是什么
cron 是 Unix 系统里的定时任务工具,名字来自希腊语 chronos(时间)。它的核心是一个表达式格式:
0 9 * * 1-5 每周一到周五的早上 9:00
*/15 * * * * 每 15 分钟
0 0 1 * * 每月 1 号午夜
字段顺序:分钟 小时 日 月 星期
12.2 定时智能体的场景
| 场景 | 做什么 |
|---|---|
| 每日简报 | 早上 8 点扫一遍邮件、日历、待办,生成摘要发到聊天工具 |
| 持续监控 | 每 15 分钟检查服务健康度,异常时告警 |
| 定期维护 | 每周清理日志、更新依赖、跑安全扫描 |
| 长任务 | 把一个需要几小时的任务拆成多次执行 |
「定时智能体」和「定时脚本」的本质区别:
定时脚本做的事是固定的 —— 同样的输入产生同样的行为。
定时智能体会读取外部内容并据此决定做什么。它读的邮件、网页、日志,都可能包含恶意指令。这就把「定时任务」变成了一个安全问题。
12.3 最重要的一个类:CronPromptInjectionBlocked
class CronPromptInjectionBlocked(...)
「提示词注入」(prompt injection)是智能体系统最核心的安全威胁。先用一个具体例子说清楚它是什么。
为什么定时场景特别危险
| 交互式会话 | 定时任务 | |
|---|---|---|
| 人在不在 | 在。看着屏幕 | 不在。凌晨 3 点 |
| 异常行为 | 用户立刻发现「它怎么在读我的 SSH 密钥?」 | 没人看到 |
| 审批 | 弹出确认框,用户拒绝 | 自动批准或自动拒绝(第 10 章的困境) |
| 发现时间 | 当场 | 可能几天后,或者永远不会 |
所以定时任务必须有一道专门的注入防线,而不是复用交互式会话的那一套。CronPromptInjectionBlocked 这个异常类型的存在,说明系统在这一层做了显式的检测和拦截 —— 检测到疑似注入时,直接中止整个定时任务,而不是「警告一下继续跑」。
这是「fail-closed」(失败即关闭)的选择:不确定的时候,宁可任务不执行,也不执行一个可能被劫持的任务。
12.4 定时任务的工具集收窄
def _resolve_cron_disabled_toolsets(...)
呼应第 4 章:定时任务运行时,某些工具集被禁用。
这是防御的第二层。即使注入检测被绕过了,被劫持的智能体也无法执行最危险的操作 —— 因为那些工具根本不在它的工具列表里。
三层防御在定时场景下的组合:
① 注入检测 —— 尽量识别恶意内容,识别到就中止
② 工具收窄 —— 就算没识别到,也没有危险工具可用
③ 执行环境(第 6 章)—— 就算工具被滥用,破坏也被限制在容器内
没有任何单独一层是可靠的。注入检测必然有漏网(因为它本质是在猜「这段文字是数据还是指令」);工具收窄会限制功能;容器隔离有性能代价。三层叠加才能达到可接受的风险水平。
12.5 失败处理
def _failure_streak_nudge(...) # 连续失败提醒
def _upsert_incident_for_failure(...) # 为失败创建/更新事件记录
_failure_streak_nudge:连续失败的提醒
「streak」(连续)这个词是关键:它统计的是连续失败次数,成功一次就重置。这样偶发失败不会累积成告警,而持续性故障会很快达到阈值。
_upsert_incident_for_failure:事件记录的去重
「upsert」= update + insert,意思是「有就更新,没有就插入」。
这是运维告警系统的标准做法,叫「告警聚合」或「事件去重」。
判断两次失败是否属于「同一个事件」的依据通常是:任务 ID + 错误类型 + 是否还未解决。
没有去重的告警系统,最终会因为噪声太大而被所有人关掉。而一个被关掉的告警系统,等于没有告警系统。
12.6 防重复执行
def try_register_running_job(...)
函数名里的 try_ 是关键:「尝试注册」—— 如果已经有一个同样的任务在跑,注册失败,这次就跳过。
这是定时任务系统里最经典的一个坑,几乎每个团队都踩过一次。
症状很有迷惑性:系统平时好好的,某天突然雪崩。因为触发条件是「单次执行时间 > 调度间隔」,这个条件在数据量小的时候永远不成立。
try_register_running_job 就是解法:每次执行前先声明「我要跑了」,如果发现已经有人在跑,就安静地跳过这一次。
还有一个细节:这个注册记录必须是持久化的、带过期时间的。
- 持久化 —— 因为调度器可能是多进程的,内存里的标记别的进程看不到
- 带过期 —— 如果任务进程崩溃了,注册记录没被清理,那这个任务就再也不会执行了。必须有一个超时让锁自动释放(和第 10 章看板的心跳是同一个问题)
12.7 为什么 scheduler.py 有 367 KB
「按时间跑任务」听起来是一个 while True: sleep(); run() 的事情。367 KB 在做什么?
| 类别 | 内容 |
|---|---|
| 时间计算 | cron 表达式解析、时区处理、夏令时切换(这一天可能有 23 或 25 小时)、闰秒 |
| 错过的执行 | 机器关机了 8 小时,错过的 96 次执行怎么办?全补跑?只跑最后一次?跳过? |
| 并发控制 | 防重复执行、多任务并发上限、任务之间的依赖 |
| 失败处理 | 重试策略、退避、连续失败告警、事件去重 |
| 安全 | 注入检测、工具收窄、审批策略 |
| 状态管理 | 任务的启用/禁用、暂停/恢复、动态增删 |
| 可观测 | 每次执行的耗时、成本、结果、日志 |
| 结果投递 | 跑完了结果发到哪儿?发失败了怎么办?(呼应第 1 章的投递台账) |
12.8 定时任务在整个架构里的位置
这张图是整个 Hermes 架构的一个浓缩:
核心循环只有一个(不为每种触发方式写一套逻辑),但安全策略是按触发源分层的(不用同一套权限对待所有来源)。
这两句话看起来矛盾 —— 统一 vs 分化。实际上它们分别作用在不同的维度:「怎么做」统一,「能做什么」分化。
如果反过来(每个触发源一套循环逻辑,但共享同一套权限),你会得到一个既难维护、又不安全的系统。
12 · Scheduled Tasks (Cron)
cron/, 14 files, of which scheduler.py is 367 KB. This chapter covers letting the agent run on its own when nobody is around — and the whole new class of problems that brings.
12.1 What “cron” Is
cron is the Unix scheduled-task tool; the name comes from the Greek chronos (time). At its heart is an expression format:
0 9 * * 1-5 9:00 a.m., Monday through Friday
*/15 * * * * every 15 minutes
0 0 1 * * midnight on the 1st of every month
Field order: minute hour day-of-month month day-of-week
12.2 Scenarios for Scheduled Agents
| Scenario | What it does |
|---|---|
| Daily briefing | At 8 a.m., sweep email, calendar, and to-dos, generate a summary, and post it to the chat app |
| Continuous monitoring | Check service health every 15 minutes; alert on anomalies |
| Routine maintenance | Weekly: clean up logs, update dependencies, run security scans |
| Long-running work | Split a job that takes hours into multiple runs |
The essential difference between a “scheduled agent” and a “scheduled script”:
A scheduled script does a fixed thing — the same input produces the same behavior.
A scheduled agent reads external content and decides what to do based on it. The email, web pages, and logs it reads may all contain malicious instructions. That turns “scheduled tasks” into a security problem.
12.3 The Single Most Important Class: CronPromptInjectionBlocked
class CronPromptInjectionBlocked(...)
“Prompt injection” is the central security threat to agent systems. Let's pin down what it is with a concrete example.
Why the scheduled setting is especially dangerous
| Interactive session | Scheduled task | |
|---|---|---|
| Is a human present | Yes. Watching the screen | No. It's 3 a.m. |
| Abnormal behavior | The user notices immediately: “why is it reading my SSH key?” | Nobody sees it |
| Approval | A confirmation dialog pops up; the user declines | Auto-approve or auto-deny (the dilemma from chapter 10) |
| Time to discovery | On the spot | Possibly days later, or never |
So scheduled tasks need a dedicated line of defense against injection, not a reuse of the interactive session's setup. The existence of the CronPromptInjectionBlocked exception type shows the system does explicit detection and interception at this layer — when suspected injection is detected, it aborts the entire scheduled task outright, rather than “warn and keep running.”
This is the “fail-closed” choice: when in doubt, better that the task not run at all than that a possibly hijacked task runs.
12.4 Narrowing the Toolset for Scheduled Tasks
def _resolve_cron_disabled_toolsets(...)
Echoing chapter 4: when a scheduled task runs, certain toolsets are disabled.
This is the second layer of defense. Even if injection detection is bypassed, a hijacked agent can't perform the most dangerous operations — because those tools simply aren't on its tool list.
How the three layers of defense combine in the scheduled setting:
① Injection detection — recognize malicious content as best you can; abort when you do
② Toolset narrowing — even if you don't recognize it, no dangerous tools are available
③ Execution environment (chapter 6) — even if a tool is abused, the damage is confined to the container
No single layer is reliable on its own. Injection detection inevitably lets things slip through (at bottom it is guessing “is this text data or an instruction”); toolset narrowing limits functionality; container isolation has a performance cost. Only stacked together do the three reach an acceptable level of risk.
12.5 Failure Handling
def _failure_streak_nudge(...) # nudge on consecutive failures
def _upsert_incident_for_failure(...) # create/update an incident record for a failure
_failure_streak_nudge: the nudge on consecutive failures
The word “streak” is the key: it counts consecutive failures, and one success resets it. That way sporadic failures never accumulate into an alert, while a persistent fault hits the threshold quickly.
_upsert_incident_for_failure: deduplicating incident records
“upsert” = update + insert, meaning “update if it exists, insert if it doesn't.”
This is standard practice in operations alerting, called “alert aggregation” or “incident deduplication.”
The usual basis for deciding whether two failures belong to “the same incident” is: task ID + error type + whether it is still unresolved.
An alerting system without deduplication eventually gets switched off by everyone because of the noise. And an alerting system that's switched off is the same as no alerting system.
12.6 Preventing Duplicate Runs
def try_register_running_job(...)
The try_ in the function name is the key: “try to register” — if an identical job is already running, registration fails and this run is skipped.
This is the most classic trap in scheduled-task systems; nearly every team has fallen into it once.
The symptom is deceptive: the system is fine day to day, then one day it suddenly avalanches. Because the trigger condition is “a single run takes longer than the scheduling interval,” and that condition never holds while the data is small.
try_register_running_job is the fix: before each run, declare “I'm about to run,” and if someone is already running, quietly skip this one.
One more detail: this registration record must be persistent and carry an expiry.
- Persistent — because the scheduler may be multi-process, and an in-memory flag is invisible to other processes
- With an expiry — if the task process crashes and the registration record isn't cleaned up, that task will never run again. There has to be a timeout that releases the lock automatically (the same problem as the kanban heartbeat in chapter 10)
12.7 Why scheduler.py Is 367 KB
“Run tasks on a schedule” sounds like a while True: sleep(); run() affair. What are 367 KB doing?
| Category | Contents |
|---|---|
| Time arithmetic | Parsing cron expressions, time zones, daylight-saving transitions (that day may have 23 or 25 hours), leap seconds |
| Missed runs | The machine was off for 8 hours; what about the 96 runs that were missed? Run them all? Only the last one? Skip them? |
| Concurrency control | Preventing duplicate runs, caps on concurrent tasks, dependencies between tasks |
| Failure handling | Retry policy, backoff, consecutive-failure alerts, incident deduplication |
| Security | Injection detection, toolset narrowing, approval policy |
| State management | Enabling/disabling tasks, pausing/resuming, adding and removing them dynamically |
| Observability | Elapsed time, cost, result, and logs for every run |
| Result delivery | Where does the result go when the run finishes? What if delivery fails? (echoing the delivery ledger in chapter 1) |
12.8 Where Scheduled Tasks Sit in the Overall Architecture
This diagram is the whole Hermes architecture in miniature:
There is only one core loop (no separate logic per trigger type), but the security policy is tiered by trigger source (no single set of permissions for every origin).
Those two statements look contradictory — unification vs. differentiation. In fact they act on different dimensions: “how it's done” is unified; “what it may do” is differentiated.
Do it the other way round (one loop per trigger source, but one shared set of permissions) and you get a system that is both hard to maintain and insecure.