§ 00 · Source-level Observatory

rome agentic OS

Rome 把可安装 app、agent、action、skill、hook、workflow 和聊天运行时放进同一个 TypeScript monorepo。它不是一个单点 coding assistant,而是把用户自定义应用、运行时权限、密钥注入、子代理 trace 和 workflow 生成器组织成一个 agentic OS。

★ 377
GitHub Stars
87+
Changed Files
TS
Main Lang
ddb1711
Latest
guardian request
  ├─ app manifest: agents/actions/skills/hooks
  ├─ runtime loader: cache-busted module graph
  ├─ app keys: write-only env injection
  ├─ workflow studio: natural language → design-workflow chat
  └─ trace DTO: tool/subagent/result accounting
§ 00 · OVERVIEW

它解决什么,凭什么值得拆

Rome 的核心不是“再做一个聊天框”,而是把 app 作为一等运行单元管理。packages/api-types/src/apps.ts:57 到 packages/api-types/src/apps.ts:69 定义 agents、actions、skills、hooks 四类 artifact;packages/api-types/src/apps.ts:83 到 packages/api-types/src/apps.ts:119 又把安装状态、权限、前端入口、公开访问、来源和 channel binding 一次性投影到 dashboard。这个契约让用户看到的是一个可管理 app,而不是散落的 prompt 和脚本。

最新提交里最值得拆的是 app keys 和模块重载,因为它们把 agentic OS 的安全边界写进运行时:packages/core/src/api/routes/app-keys.ts:9 说明密钥值永不返回;packages/core/src/app-keys/injector.ts:16 管理 process.env 注入状态;packages/core/src/actions/module-loader.ts:121 到 packages/core/src/actions/module-loader.ts:138 用 env epoch 解决模块级缓存读旧环境的问题。加上 rome_apps/workflow-studio/src/web/App.tsx:19 启动 workflow chat、packages/api-types/src/trace-segments.ts:92 记录子代理生命周期,Rome 已经接近“可安装自动化系统”的形态。

▸核心数据

架构模式App artifact catalog + action runtime + web dashboard
核心抽象InstalledAppCard、Action、AppKeyInjector、TraceBlockDto
验证面API routes、UI section、module loader、reserved names 均有测试
风险面密钥进入全局 env,必须依赖命名保留、覆盖检测和运行时刷新
阅读方式 — 先看 app 契约,再看运行时装载和 app keys,最后看 Workflow Studio 如何把自然语言入口接到技能化构建流程。
§ 01 · ARCHITECTURE

App 契约把能力面结构化

App 契约从 source、bundle、appstore 三种安装来源开始,packages/api-types/src/apps.ts:9、packages/api-types/src/apps.ts:18 和 packages/api-types/src/apps.ts:23 分别约束编辑区、已打包 bundle 与商店版本。这个分层重要,因为本地开发、离线安装、云商店分发需要完全不同的信任边界,但 dashboard 仍要以统一卡片显示。

能力面集中在 packages/api-types/src/apps.ts:57 到 packages/api-types/src/apps.ts:69:agents、actions、skills、hooks 都是带 description 和 loadError 的 artifact summary。前端在 packages/web/src/pages/AppDetailPage.tsx:419 到 packages/web/src/pages/AppDetailPage.tsx:428 对 agents 额外展示“发起聊天”按钮,说明 agent 被当作可直接对话的能力,而 action/skill/hook 更像被运行时调用的资产。

▸系统拓扑

App source/bundle/store → AppSpec → InstalledAppCard
        │                  │
        ├─ capabilityDetails.agents/actions/skills/hooks
        ├─ dashboard management rows
        └─ startChatWithAgent(app, artifactName)

▸模块清单

packages/api-types/src/apps.ts:34 对应这条实现证据;packages/api-types/src/apps.ts:45 对应这条实现证据;packages/api-types/src/apps.ts:51 对应这条实现证据;packages/api-types/src/apps.ts:57 对应这条实现证据;packages/api-types/src/apps.ts:71 对应这条实现证据;packages/api-types/src/apps.ts:83 对应这条实现证据;packages/api-types/src/apps.ts:94 对应这条实现证据;packages/api-types/src/apps.ts:107 对应这条实现证据,这些行共同说明 Rome 的 app 不是单个前端页面,而是携带来源、状态、权限和能力清单的运行实体。

▸关键数据结构

packages/api-types/src/apps.ts · L57-120TypeScript
57export interface AppArtifactSummary {
58  name: string;
59  description: string;
60  /** Diagnostic for a declared artifact that failed to load. Consumers must surface it. */
61  loadError?: string;
62}
63
64export interface AppArtifactDetails {
65  agents: AppArtifactSummary[];
66  actions: AppArtifactSummary[];
67  skills: AppArtifactSummary[];
68  hooks: AppArtifactSummary[];
69}
70
71/** Non-binding routing hint from an app manifest. Channel contract: docs/architecture/channels.md. */
72export interface SuggestedChannelBinding {
73  channel: "discord" | "telegram" | "whatsapp" | "wechat" | "webchat" | "feishu";
74  agent: string;
75  reason?: string;
76}
77
78export interface AppSpec {
79  source: SpecSource;
80  enabled: boolean;
81}
82
83export interface InstalledAppCard {
84  id: string;
85  version: string;
86  description: string;
87  displayName: string;
88  status: RomeAppRuntimeStatus;
89  phase: AppViewState;
90  error?: string;
91  hasFrontend: boolean;
92  href: string | null;
93  fullHref: string | null;
94  capabilities: string[];
95  capabilityDetails: AppArtifactDetails;
96  isEnabled: boolean;
97  canToggle: boolean;
98  canUninstall: boolean;
99  /** Projection of the installed app.yaml flag. Missing means false. */
100  includeSource?: boolean;
101  /** UI capability hint only; the publish endpoint still enforces store policy. */
102  canPublish: boolean;
103  accessMode: AppAccessMode;
104  isPublic: boolean;
105  cloudAllowedEmails: string[];
106  canManagePublicAccess: boolean;
107  /** Authoritative installed source, suitable for a re-install or upgrade request. */
108  source: SpecSource;
109  /**
110   * Project-root-relative path of the app's editable source workspace. This is
111   * present only when the installed source is a workspace inside Rome's
112   * projects tree, and can be passed directly to the new-chat flow.
113   */
114  projectPath: string | null;
115  origin: AppOrigin;
116  /** Root-relative icon URL, or `null` when the manifest declares no icon. */
117  iconUrl: string | null;
118  /** Optional on the wire; consumers must default to `[]`. */
119  suggestedChannelBindings?: SuggestedChannelBinding[];
120}
深度阅读 · App keys 与密钥治理
§ 02 · CORE MODULES

运行时装载在缓存与安全之间取舍

▸ESM cache buster

Node ESM 的模块缓存会让 app 代码在 module scope 捕获旧环境。Rome 在 packages/core/src/actions/module-loader.ts:27 定义 salt query,在 packages/core/src/actions/module-loader.ts:34 注册 resolve/load hooks,并在 packages/core/src/actions/module-loader.ts:48 把父模块盐传播给相对导入;否则入口文件刷新了,helper 仍会读旧配置。

packages/core/src/actions/module-loader.ts · L27-65TypeScript
27const SALT_QUERY = /\?v=[^&]*$/;
28
29function isSaltableUrl(url: string): boolean {
30  const path = url.split("?")[0];
31  return path.endsWith(".js") || path.endsWith(".mjs");
32}
33
34registerHooks({
35  resolve(specifier, context, nextResolve) {
36    if (specifier.startsWith("file:") && SALT_QUERY.test(specifier) && isSaltableUrl(specifier)) {
37      return { url: specifier, format: "module", shortCircuit: true };
38    }
39    const result = nextResolve(specifier, context);
40    if (
41      (specifier.startsWith("./") || specifier.startsWith("../")) &&
42      context.parentURL !== undefined &&
43      SALT_QUERY.test(context.parentURL) &&
44      result.url.startsWith("file:") &&
45      !result.url.includes("?") &&
46      isSaltableUrl(result.url)
47    ) {
48      const salt = new URL(context.parentURL).searchParams.get("v");
49      if (salt) return { url: `${result.url}?v=${salt}`, format: "module", shortCircuit: true };
50    }
51    return result;
52  },
53  load(url, context, nextLoad) {
54    if (url.startsWith("file:") && SALT_QUERY.test(url) && isSaltableUrl(url)) {
55      const cleanUrl = new URL(url);
56      cleanUrl.search = "";
57      return {
58        format: "module",
59        source: readFileSync(fileURLToPath(cleanUrl)),
60        shortCircuit: true,
61      };
62    }
63    return nextLoad(url, context);
64  },
65});

▸Action factory 解析

Action 装载不是靠约定字符串硬猜。packages/core/src/actions/module-loader.ts:101 会先解析 entry 并拒绝逃逸 baseDir 的路径;packages/core/src/actions/module-loader.ts:140 到 packages/core/src/actions/module-loader.ts:168 按 createAction、具名 createXAction、default export、唯一工厂的顺序解析。这让用户 app 有自由度,但错误也能集中报出。

packages/core/src/actions/module-loader.ts · L101-138TypeScript
101export async function resolveModuleEntryPath(baseDir: string, entry?: string): Promise<string> {
102  const absoluteBaseDir = resolve(baseDir);
103  const specifier = entry ?? "./index";
104  const result = await sharedResolver.async(absoluteBaseDir, specifier);
105  if (!result.path) {
106    throw new Error(
107      `No entry module found for "${baseDir}" (resolving "${specifier}"): ${result.error ?? "no path returned"}`,
108    );
109  }
110  const [realBaseDir, realEntryPath] = await Promise.all([
111    realpath(absoluteBaseDir),
112    realpath(result.path),
113  ]);
114  const relativeEntryPath = relative(realBaseDir, realEntryPath);
115  if (relativeEntryPath.startsWith("..") || isAbsolute(relativeEntryPath)) {
116    throw new Error(`Invalid entry path "${specifier}" for ${baseDir}: escapes base dir`);
117  }
118  return result.path;
119}
120
121// App modules can read process.env at module scope, and the file-identity
122// buster below cannot see an environment change. Bumping this epoch salts the
123// cache key so every subsequent import re-evaluates module scope against the
124// current environment (app keys do this on save/delete). Each bump strands the
125// previously imported instances in the ESM cache — the same cost as an app
126// upgrade changing mtime — bounded by how often a guardian edits keys.
127let envEpoch = 0;
128
129export function bumpModuleEnvEpoch(): void {
130  envEpoch++;
131}
132
133export async function importModuleWithCacheBuster(path: string): Promise<Record<string, unknown>> {
134  const fileStat = await stat(path);
135  const cacheBuster = `${Math.trunc(fileStat.mtimeMs)}-${fileStat.size}-e${envEpoch}`;
136  const url = `${pathToFileURL(path).href}?v=${cacheBuster}`;
137  return await import(url);
138}

▸App action 注册

packages/core/src/actions/app-actions-wiring.ts:127 到 packages/core/src/actions/app-actions-wiring.ts:159 展示懒装载 wrapper,packages/core/src/actions/app-actions-wiring.ts:162 到 packages/core/src/actions/app-actions-wiring.ts:197 展示 eager 注册路径,packages/core/src/actions/app-actions-wiring.ts:229 到 packages/core/src/actions/app-actions-wiring.ts:251 则在 catalog 变化后重注册对应 app。这里能看到 Rome 的关键设计:catalog 是运行时真相源,action registry 是投影。

packages/core/src/actions/app-actions-wiring.ts · L127-159TypeScript
127function createLazyAppAction(
128  record: AppActionRecord,
129  catalog: AppCatalog,
130  deps: Record<string, unknown>,
131  services: AppActionServices,
132): Action {
133  let actionPromise: Promise<Action> | null = null;
134
135  const load = async (): Promise<Action> => {
136    if (!actionPromise) {
137      actionPromise = instantiateActionFromDirectory(
138        record.config,
139        record.directory,
140        createAppActionRuntimeDeps(record, catalog, deps, services),
141      ).catch((err) => {
142        actionPromise = null;
143        throw err;
144      });
145    }
146    return actionPromise;
147  };
148
149  return {
150    config: record.config,
151    async execute(args: Record<string, unknown>, context?: ActionExecutionContext) {
152      const action = await load();
153      return action.execute(args, context);
154    },
155    async preview(args: Record<string, unknown>) {
156      const action = await load();
157      return action.preview?.(args);
158    },
159  };
深度阅读 · 模块加载与运行时刷新
§ 03 · KEY FLOW

App keys 让密钥进入 app,但不回读

密钥路线很清楚:类型层在 packages/api-types/src/app-keys.ts:6 声明 value-free DTO;API 层在 packages/core/src/api/routes/app-keys.ts:16 列表只返回 name、label、updatedAt 和 overridden;写入层在 packages/core/src/api/routes/app-keys.ts:54 upsert 后调用 injector;删除层在 packages/core/src/api/routes/app-keys.ts:68 删除存储后按需刷新运行时。

▸写入与刷新流程

PUT /api/app-keys/:name
  ├─ validate appKeyNameError
  ├─ reject missing/oversized value
  ├─ repo.upsert(name,label,value)
  ├─ injector.apply(name,value)
  └─ refreshAppRuntime() only if live env changed

▸环境变量优先级

最关键的安全点在 packages/core/src/app-keys/injector.ts:5 到 packages/core/src/app-keys/injector.ts:15:真实 process.env 永远优先,dashboard 存储值不能覆盖 operator 预置环境。packages/core/src/app-keys/injector.ts:25 检测预置变量,packages/core/src/app-keys/injector.ts:32 只在安全时写入,packages/core/src/app-keys/injector.ts:39 到 packages/core/src/app-keys/injector.ts:44 只删除 injector 自己写过的键。

packages/core/src/app-keys/injector.ts · L5-50TypeScript
5/**
6 * Owns the stored-key slice of `process.env`. Two invariants:
7 *
8 * - The real process environment always wins. A name that already had a value
9 *   before injection is never overwritten — operator config (env files, CI,
10 *   container env) cannot be shadowed from the dashboard. Such keys are
11 *   reported as overridden instead of silently going live.
12 * - Only names this injector set are ever deleted or scrubbed. It tracks its
13 *   own writes, so removing a stored key can never unset an operator-set var
14 *   that happens to share the name.
15 */
16export class AppKeyInjector {
17  private injected = new Set<string>();
18  private overridden = new Set<string>();
19
20  constructor(private env: NodeJS.ProcessEnv = process.env) {}
21
22  /** Applies a stored key. Returns true when the value went live, false when a
23   * pre-existing environment value kept precedence. */
24  apply(name: string, value: string): boolean {
25    if (!this.injected.has(name) && this.env[name] !== undefined) {
26      this.overridden.add(name);
27      logger.warn(
28        `App key ${name} is overridden by the process environment; the stored value is not live`,
29      );
30      return false;
31    }
32    this.env[name] = value;
33    this.injected.add(name);
34    return true;
35  }
36
37  /** Removes a stored key from the environment, only if this injector set it.
38   * Returns true when the environment actually changed. */
39  remove(name: string): boolean {
40    this.overridden.delete(name);
41    if (!this.injected.has(name)) return false;
42    delete this.env[name];
43    this.injected.delete(name);
44    return true;
45  }
46
47  isOverridden(name: string): boolean {
48    return this.overridden.has(name);
49  }
50}

▸名称保留

packages/api-types/src/app-keys.ts:21 对应这条实现证据;packages/api-types/src/app-keys.ts:25 对应这条实现证据;packages/api-types/src/app-keys.ts:33 对应这条实现证据;packages/api-types/src/app-keys.ts:58 对应这条实现证据;packages/api-types/src/app-keys.ts:80 对应这条实现证据;packages/api-types/src/app-keys.ts:85 对应这条实现证据;packages/api-types/src/app-keys.ts:88 对应这条实现证据;packages/api-types/src/app-keys.ts:91 对应这条实现证据,这些规则防止用户把 PATH、DATABASE、ANTHROPIC、ROME 等平台级变量伪装成 app key。它是密钥功能能上线的前提,不是表单校验细节。

packages/api-types/src/app-keys.ts · L21-96TypeScript
21export const APP_KEY_MAX_NAME_LENGTH = 64;
22export const APP_KEY_MAX_LABEL_LENGTH = 120;
23export const APP_KEY_MAX_VALUE_LENGTH = 32768;
24
25const NAME_PATTERN = /^[A-Z][A-Z0-9_]*$/;
26
27// An app key lands in `process.env`, so a name that collides with platform
28// configuration could reroute the database, the API ports, or an upstream
29// credential. Config env names are only expressed as inline `env.X` reads in
30// core's `config.ts` (`envToRawConfig`) — there is no enumerable schema to
31// check against — so this list carries every prefix family the platform
32// consumes plus the ambient OS names a child process relies on.
33const RESERVED_PREFIXES = [
34  "ROME_",
35  "NODE_",
36  "NPM_",
37  "PNPM_",
38  "PANTHEON_",
39  "INTERNAL_API_",
40  "RELAY_",
41  "STATSIG_",
42  "FEATURE_GATE_",
43  "OTEL_",
44  "SQLITE_",
45  "POSTGRES_",
46  "DATABASE_",
47  "WEB_",
48  "LINKEDIN_",
49  "SENTINEL_",
50  "ANTHROPIC_",
51  "DOCKERHUB_",
52  "CLICKHOUSE_",
53  "AWS_",
54  "SSH_",
55  "NEXT_",
56];
57
58const RESERVED_NAMES = new Set([
59  "PATH",
60  "HOME",
61  "USER",
62  "LOGNAME",
63  "SHELL",
64  "TMPDIR",
65  "TMP",
66  "TEMP",
67  "PWD",
68  "LANG",
69  "LC_ALL",
70  "TZ",
71  "TERM",
72  "HOSTNAME",
73  "EDITOR",
74  "CI",
75  "LOG_LEVEL",
76  "UPDATES_DOMAIN",
77]);
78
79/** Returns a human-readable rejection reason, or null when the name is usable. */
80export function appKeyNameError(name: string): string | null {
81  if (name.length === 0) return "Name is required.";
82  if (name.length > APP_KEY_MAX_NAME_LENGTH) {
83    return `Name must be at most ${APP_KEY_MAX_NAME_LENGTH} characters.`;
84  }
85  if (!NAME_PATTERN.test(name)) {
86    return "Name must use only uppercase letters, digits, and underscores, and start with a letter.";
87  }
88  if (RESERVED_NAMES.has(name)) {
89    return `${name} is reserved by Rome. Pick a different name.`;
90  }
91  const prefix = RESERVED_PREFIXES.find((p) => name.startsWith(p));
92  if (prefix) {
93    return `Names starting with ${prefix} are reserved by Rome. Pick a different name.`;
94  }
95  return null;
96}
深度阅读 · 密钥 API、UI 与测试
§ 04 · TESTS & VALIDATION

Workflow Studio 是技能入口,不是表单生成器

Workflow Studio 的前端只有一个核心动作:rome_apps/workflow-studio/src/web/App.tsx:19 读取用户自然语言,rome_apps/workflow-studio/src/web/App.tsx:25 用 startChat 开启带 design-workflow skill 的新聊天。对应 skill 在 rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:14 到 rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:68 要求先塑形规格、获取批准,再构建并安装 workflow app。

▸前门组件

rome_apps/workflow-studio/src/web/App.tsx · L1-76TSX
1import "./styles.css";
2import { useCallback, useState } from "react";
3import { startChat, type RomeAppBootstrap } from "@rome-os/app-web-sdk";
4
5const EXAMPLE_PROMPTS = [
6  "Every morning, summarize my unread emails and post the digest to my chat.",
7  "When I save a link to my reading list, fetch the article and add a short summary.",
8];
9
10// The app is a front door: describing an automation opens a fresh guardian chat
11// with that message under the `design-workflow` skill, which shapes it into a
12// short spec, gets the guardian's approval, and builds the workflow — all in that
13// one conversation. There is no panel to mirror, so the page is just the launcher.
14export default function App({ bootstrap: _bootstrap }: { bootstrap: RomeAppBootstrap }) {
15  const [draft, setDraft] = useState("");
16  const [starting, setStarting] = useState(false);
17  const [error, setError] = useState<string | null>(null);
18
19  const start = useCallback(async () => {
20    const message = draft.trim();
21    if (!message || starting) return;
22    setStarting(true);
23    setError(null);
24    try {
25      await startChat({ message, skillName: "design-workflow" });
26      // startChat navigates to the new chat on success; no further work here.
27    } catch {
28      setStarting(false);
29      setError("Couldn't start a chat just now — try again.");
30    }
31  }, [draft, starting]);
32
33  return (
34    <div className="ws-root">
35      <div className="ws-frontdoor">
36        <h1>Build a workflow</h1>
37        <p className="ws-lede">
38          Describe an automation in plain language. Your guardian shapes it into a short spec you
39          approve, then builds it into a runnable workflow — all in one conversation.
40        </p>
41        <textarea
42          className="ws-input"
43          rows={3}
44          placeholder="e.g. Every morning, summarize my unread emails and post the digest to my chat."
45          value={draft}
46          onChange={(e) => setDraft(e.target.value)}
47          disabled={starting}
48        />
49        <div className="ws-cta-row">
50          <button
51            className="ws-cta"
52            onClick={() => void start()}
53            disabled={!draft.trim() || starting}
54          >
55            {starting ? "Starting…" : "Design this workflow"}
56          </button>
57        </div>
58        {error && <p className="ws-error-line">{error}</p>}
59        <div className="ws-examples">
60          <span className="ws-examples-label">Try one of these</span>
61          {EXAMPLE_PROMPTS.map((p) => (
62            <button
63              key={p}
64              type="button"
65              className="ws-example"
66              onClick={() => setDraft(p)}
67              disabled={starting}
68            >
69              {p}
70            </button>
71          ))}
72        </div>
73      </div>
74    </div>
75  );
76}

▸技能约束

rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:20 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:31 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:38 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:45 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:52 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:61 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:73 对应这条实现证据;rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:78 对应这条实现证据,这些行说明 Rome 把“自动化生成”约束为静态结构、明确交付物和批准后构建,而不是让 agent 无限规划。

rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md · L14-68Markdown
14## Phase 1 — Shape the idea into a spec
15
16Turn what the guardian said into a short, plain-language spec they can read and
17sign off on. The spec is the thing they approve, so write it for a non-technical
18reader — what the automation *does*, not how it's coded. Use this shape:
19
20> **Trigger** — what kicks it off (a schedule, an event, a "run now").
21> **Steps** — the ordered things it does, one short verb phrase each
22> ("fetch my unread emails", "summarize them", "post the digest to my chat").
23> **Decisions** — any branch ("if the total is over $5k, escalate; otherwise file
24> it"), stated as plain conditions.
25> **Per-item work** — anything done "for each" of a list ("for each ticket, rate
26> its severity").
27> **Delivers** — the final step that sends, posts, saves, or shows the result.
28
29Hold these disciplines while you shape it — they decide whether the spec is real:
30
31- **Ask the guardian for facts only they can supply; never invent them.** A
32  threshold amount, a recipient, a channel, which account, which event fires the
33  trigger — a made-up "$5,000" looks authoritative and gets approved by accident.
34  When a fact like this is missing, ask for it in your reply rather than guessing.
35- **Resolve design questions in conversation, not in the spec.** Anything that
36  changes the *shape* — whether to branch, what to do per item — gets settled with
37  the guardian before you present the spec, not parked as an open question in it.
38- **Every workflow must deliver.** A spec that only fetches and computes, with no
39  step that sends/posts/saves/shows a result, has no destination and is never
40  finished. If the guardian didn't say where the result goes, that's a fact to ask
41  for (see above).
42- **Don't let vague quantifiers pass.** "the top labs", "the latest news", "key
43  themes", "top outlets" each hide a fact — how many, which sources, what time
44  window. If it changes what the automation does, pin it down with the guardian.
45- **Keep it statically structured.** The full space of what it does must be
46  knowable before it runs: ordered steps, parallel work, a decision, or bounded
47  per-item fan-out. No "keep polling until…", no unbounded loops.
48
49Keep the spec itself conversational and free of technical artifacts — no JSON, no
50flow chart, no node/combinator vocabulary. Talk about what it will *do*.
51
52## Phase 2 — Get approval
53
54Present the spec and ask the guardian to approve it or tell you what to change.
55Make the ask explicit ("Want me to build this, or change anything first?"). If
56they ask for a change, revise the spec and ask again. Build **only** after a clear
57yes — don't start scaffolding on a maybe.
58
59## Phase 3 — Build it
60
61On approval, build the workflow into a new, runnable Rome app. **Load the
62`coding:workflow_creation` skill and follow it end to end** — it owns the real work:
63deriving the control flow, scaffolding the `workflow` template, authoring
64`src/workflow/definition.ts` in plain TypeScript (`await`/`if`/`for`/`Promise.all`),
65the `system:summon` discipline for generative steps, the `connector:connector_proxy` discipline for
66SaaS calls, the `ctx.dryRun` guard on every write, the display envelope, the web
67surface, and the install. Translate the approved spec into its control flow
68faithfully — don't redesign the automation while coding it.
§ 05 · HIGHLIGHTS

Trace DTO 让多代理执行可回放

▸块级事件流

Trace 不是日志字符串。packages/api-types/src/trace-segments.ts:27 到 packages/api-types/src/trace-segments.ts:67 定义 session、turn 和 text block;packages/api-types/src/trace-segments.ts:74 到 packages/api-types/src/trace-segments.ts:90 给 tool use/result 配对;packages/api-types/src/trace-segments.ts:92 到 packages/api-types/src/trace-segments.ts:122 明确子代理 start/result 的状态和错误形状。这个结构让 UI 可以按 turn、tool、subagent 重建执行过程。

packages/api-types/src/trace-segments.ts · L92-122TypeScript
92export interface SubagentStartBlock extends TraceBlockBase {
93  type: "subagent_start";
94  toolUseId: string;
95  agentName: string;
96  input: unknown;
97  sessionId: string;
98  turnId: string;
99  startedAt?: string;
100}
101
102export type SubagentResultBlock =
103  | (TraceBlockBase & {
104      type: "subagent_result";
105      toolUseId: string;
106      agentName: string;
107      sessionId: string;
108      turnId: string;
109      status: "completed";
110      output: unknown;
111      endedAt?: string;
112    })
113  | (TraceBlockBase & {
114      type: "subagent_result";
115      toolUseId: string;
116      agentName: string;
117      sessionId: string;
118      turnId: string;
119      status: "failed" | "cancelled";
120      error: { message: string; code?: string };
121      endedAt?: string;
122    });

▸使用量与子代理汇总

packages/api-types/src/trace-segments.ts:124 到 packages/api-types/src/trace-segments.ts:162 定义 token、上下文、模型和 descendant subagent 汇总;packages/api-types/src/trace-segments.ts:235 到 packages/api-types/src/trace-segments.ts:253 又把直接子代理和 trace summary 连接起来。对 agentic OS 来说,这决定了成本、耗时和工具调用能不能被解释给用户。

packages/api-types/src/trace-segments.ts · L124-162TypeScript
124export interface TraceTokenUsage {
125  cacheReadTokens: number;
126  cacheWriteTokens: number;
127  inputTokens: number;
128  outputTokens: number;
129}
130
131export interface TraceContextUsage {
132  usedTokens: number;
133  windowTokens?: number;
134  remainingTokens?: number;
135}
136
137/** Read-time usage grouped by the provider/model that produced it. */
138export interface TraceModelUsage {
139  provider: string;
140  model: string;
141  usage: TraceTokenUsage;
142  costUsd?: number;
143  runCount: number;
144}
145
146export interface TraceAccounting {
147  provider: string;
148  model: string;
149  usage: TraceTokenUsage;
150  context?: TraceContextUsage;
151  costUsd?: number;
152  numTurns?: number;
153  stopReason?: string;
154  durationMs?: number;
155  rawUsage?: Record<string, unknown>;
156  /** Number of descendant subagent runs included in `usage` and `costUsd`.
157   *  Derived only for trace reads; never persisted in canonical trace blocks. */
158  includedSubagentCount?: number;
159  /** Main + descendant usage grouped by provider/model. Like
160   *  `includedSubagentCount`, this is derived only on trace reads. */
161  usageByModel?: TraceModelUsage[];
162}

▸测试证据

模块 loader 测试在 packages/core/src/actions/module-loader.test.ts:19 到 packages/core/src/actions/module-loader.test.ts:75 用真实 Node 进程验证 epoch bump;app keys API 测试在 packages/core/src/api/routes/app-keys.test.ts:86 到 packages/core/src/api/routes/app-keys.test.ts:157 验证 worker pool 会因密钥变化重启。这些测试覆盖的是运行时边界,而不是简单 snapshot。

packages/core/src/actions/module-loader.test.ts · L19-75TypeScript
19  it("re-evaluates the relative-import graph only after an epoch bump", async () => {
20    const dir = await mkdtemp(join(tmpdir(), "module-loader-epoch-"));
21    try {
22      // The env read lives in an imported helper, not the entry: the resolve
23      // hook must propagate the entry's cache salt onto the relative import
24      // or the helper stays cached across epochs.
25      await writeFile(
26        join(dir, "config.js"),
27        `export const captured = process.env.${TEST_KEY} ?? "unset";\n`,
28        "utf-8",
29      );
30      await writeFile(join(dir, "entry.js"), `export { captured } from "./config.js";\n`, "utf-8");
31
32      const loaderUrl = pathToFileURL(
33        resolve(dirname(fileURLToPath(import.meta.url)), "module-loader.ts"),
34      ).href;
35      const probePath = join(dir, "probe.mjs");
36      await writeFile(
37        probePath,
38        `
39import { bumpModuleEnvEpoch, importModuleWithCacheBuster } from ${JSON.stringify(loaderUrl)};
40
41const entry = process.argv[2];
42const before = await importModuleWithCacheBuster(entry);
43process.env.${TEST_KEY} = "v1";
44const withoutBump = await importModuleWithCacheBuster(entry);
45bumpModuleEnvEpoch();
46const after = await importModuleWithCacheBuster(entry);
47console.log(JSON.stringify({
48  before: before.captured,
49  withoutBump: withoutBump.captured,
50  after: after.captured,
51}));
52`,
53        "utf-8",
54      );
55
56      // Run under tsx — both the `source` container mode and the forked action
57      // workers in dev use it, and it is the loader that normalizes queries
58      // away without the short-circuit hook. (Plain node, the `compiled` mode,
59      // exercises the same hooks minus the tsx chain.)
60      const coreRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
61      const { stdout } = await execFileAsync(
62        process.execPath,
63        ["--import", "tsx", probePath, join(dir, "entry.js")],
64        { cwd: coreRoot },
65      );
66      const lines = stdout.trim().split("\n");
67      expect(JSON.parse(lines[lines.length - 1])).toEqual({
68        before: "unset",
69        withoutBump: "unset",
70        after: "v1",
71      });
72    } finally {
73      await rm(dir, { recursive: true, force: true });
74    }
75  }, 30_000);
§ 06 · RISKS & GAPS

技术亮点之外,别忽略这些

▸密钥仍是全 app 可见

packages/web/src/components/connections/app-keys-section.tsx:224 的 UI consent 明确说所有安装 app 都能读取该值;packages/core/src/api/routes/app-keys.ts:55 的 injector.apply 会把值放进进程环境。这个设计简单可用,但不是细粒度 per-app secret permission,适合早期 OS,但未来需要更强隔离。

▸模块缓存换来可控热刷新

packages/core/src/actions/module-loader.ts:121 到 packages/core/src/actions/module-loader.ts:127 承认每次 bump 会把旧实例留在 ESM cache,这和 app upgrade 的 mtime 策略成本类似。频繁编辑密钥或高 churn app 可能累积缓存压力,需要后续通过 worker 边界或进程重启治理。

▸Workflow 生成仍依赖技能纪律

rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:88 到 rome_apps/workflow-studio/src/skills/design-workflow/SKILL.md:91 要求安装完成才算 done;这很好,但说明可靠性来自 skill discipline 与 runtime 共同约束。若 skill 过期或调用者绕开批准流程,系统仍可能生成不完整自动化。

结论 — Rome 的源码亮点在于把 agent 产品所需的运行时边界写进类型、API、UI 和测试,而不是停留在“agentic OS”的口号。
Generated · 2026-08-28Source · rome-os/rome