For AI agents: the complete documentation index is available at https://agentia-web.pages.dev/llms.txt, and the full documentation bundle (the single-source usage guide in plain text) is available at https://agentia-web.pages.dev/llms-full.txt.

DOCS · @migor/agentia

使用指南

从三分钟跑通到上线硬化:四类能力、目录装配、中间件、触发方式、长上下文、可观测与成本硬管控。 所有 API 与命令都与 docs/usage-guide.md 逐字一致;导出清单速查见 公共 API 参考。

快速开始

Agentia 是声明式 agent 服务框架:装饰器声明四类能力,主 agent 作为路由器自主编排;一次 run 交付结构化结果与完整调用树(runId == traceId),token / 成本 / 指标一路记账 —— 可回放、可审计。三分钟跑起来:

terminal
npm i -g @migor/cli
agentia create my-app        # 脚手架:四分类目录 + src/registry.ts + .env
cd my-app && npm install
agentia g subagent doc-reviewer  # 生成能力并自动登记
# 把 ANTHROPIC_API_KEY 填进脚手架生成的 .env(.gitignore 已挡住它)
npm run dev -- "帮我审查 docs/report.md"

装配与启动是分开的:装配工厂在 src/app.ts,两个入口共用它 —— 默认入口 src/main.ts 起的是 HTTP 服务(npm run build 之后 npm start),一次性批处理在 src/batch.ts(npm run start:batch -- "你的问题")。

src/
app.ts       # createAgentApp() 装配工厂:能力选择 / 工作目录 / .env(dev 环与两个入口共用)
main.ts      # 默认入口:npm start → HTTP 服务(/healthz · POST /run · POST /tasks · drain)
batch.ts     # 一次性:npm run start:batch -- "你的问题"
registry.ts  # 显式注册表(agentia g 自动维护)

场景指南

按「要做的事」找配方:五张卡各给最小骨架与一行深入链接,所有 API 与命令都与 docs/usage-guide.md 逐字一致。

上线一个 HTTP agent 服务

脚手架已经给到这个:agentia create 产出的 src/main.ts 就是它 —— npm run build 之后 npm start 直接起。下面这份是从零接一遍的宿主版本,换装配 / 自己接宿主时看它。

一份 handler 同时给同步 POST /run(带 Accept: text/event-stream 则 SSE)、异步 POST /tasks(幂等键去重)、GET /healthz 探针;鉴权走 authenticate 缝(拦在读 body 之前),停机走 drain —— 未完成的任务留在 store,重启 resumePending() 续跑。

server.ts
import { createServer } from 'node:http';
import { createApp, createHttpHandler, AsyncRunner, SqliteTaskStore, HttpException } from '@migor/agentia';

const app = await createApp({ name: 'my-app', providers, system });
const runner = new AsyncRunner(app, { store: new SqliteTaskStore('./tasks.db') });
await runner.resumePending(); // 重启续跑 queued/running,不是丢弃

const handler = createHttpHandler(app, {
  runner,
  authenticate: async (req) => {
    if (!verify(req.headers.authorization)) throw new HttpException(401, { error: '无效凭据' });
  },
});
createServer(handler).listen(8080);
// POST /run(同步 / SSE)  POST /tasks(异步)  GET /tasks/:id  GET /healthz
process.on('SIGTERM', () => handler.drain({ timeoutMs: 15_000 })); // 信号处理是宿主的

详见「触发方式」「宿主硬化」;完整可跑示例(四类能力 + 三种触发 + 鉴权 + 全观测栈 + Dockerfile)见 examples/complete。

想再加一个 gRPC 入口:这是宿主侧的事,框架不内置(判别规则只有一条 —— 客户端是不是标准库:MCP 连接器能用 spawn + fetch 做到零新增依赖,gRPC 要引 @grpc/grpc-js)。配方见 docs/usage-guide.md §6.2,可直接抄的宿主 + 客户端 + 端到端在 examples/grpc-host —— 那里把四处最容易漏的语义逐条写明:deadline / 取消 → signal、metadata traceparent → run 根 link、框架错误 → gRPC 状态码、trace → sink。

给 run 上监控

指标与 trace 走同一条缝:metricsSink() 满足 TraceSink,接进 sinks 即得 agentia_* 指标族(run / 能力 / 模型 / 评分四级,评分来自 attachScore 的 agentia_score / agentia_score_total);createOtlpExporter 导出时对齐 OTel GenAI semconv(gen_ai.* 键)。

observe.ts
import { metricsSink, createOtlpExporter } from '@migor/agentia';

const metrics = metricsSink();
const app = await createApp({
  name: 'my-app', providers, system,
  sinks: [metrics, createOtlpExporter({ endpoint: 'http://localhost:4318' })],
});
createHttpHandler(app, { metrics }); // 内建 GET /metrics(不鉴权,停机中也回)

Grafana 看板 JSON 随仓库发(examples/observability/grafana-dashboard.json,对着 metricsSink 缺省前缀做的,导入即用)。详见「可观测」「生态与观测」。

离线评测与线上回流

defineEval + scriptedClient 离线跑 CI:脚本化模型往返,对 trace 写断言;每个用例的结论自动 attachScore 到它的 trace(value 0|1、source 是 eval 名),下游 metricsSink 可直接聚合成通过率。线上事故用 agentia harvest 回流成 eval 用例骨架。

evals/doc-review.ts
import { defineEval, scriptedClient } from '@migor/agentia';

const report = await defineEval({
  name: 'doc-review',
  app: () => app,
  cases: [{ input: '总结这份文档', client: scriptedClient([searchMsg, submitMsg]) }],
  expect: (r, { trace }) => { /* 断言 stopReason / 工具序列 / result.typed */ },
}).run();
if (!report.ok) process.exit(1); // 失败 case 带完整 trace,进 CI 只看 report.ok
terminal
agentia harvest trace.jsonl --failed --limit 5 --out evals/harvested.ts  # 线上 trace → eval 用例骨架

骨架不是成品:trace 不记 assistant 文本(脚本里是占位),预填的 expect 是抄录的实际轨迹(发生过 ≠ 应该发生)—— 人工核对后再进 CI。详见「生态与观测」与「CLI 命令参考」。

prompt / 模型 A/B

同一份输入跑两条 run(换模型、换 SystemPrompt 版本都行),diffTraces 比出结构与成本差;不落代码就 agentia diff 直比两份落盘 trace,有差异退出码 1,可直接进 CI 挡「换 prompt / 模型后轨迹漂移」。想「从第 N 回合换个问法重跑」用 forkMessages —— 它产出的是一份喂回 app.run 的 messages,跑的是新 run,不是续跑。

ab.ts
import { diffTraces, forkMessages } from '@migor/agentia';

const a = await app.run(input, { model: 'claude-sonnet-5' });
const b = await app.run(input, { model: 'claude-opus-5' });
const diff = diffTraces(a.result.trace, b.result.trace); // run 级 summary + 逐 span 字段差

// 分叉重放:主循环第 3 回合(0-based)之前截断,换个问法继续
const messages = forkMessages(a.result.trace, {
  atTurn: 3,
  append: [{ role: 'user', content: '换个思路:先给结论,再补证据。' }],
});
const c = await app.run(messages); // 起一条新 run,沿分叉点前的真实 tool 历史继续
terminal
agentia diff a.jsonl b.jsonl   # 直比两份落盘 trace(输入形态同 agentia report),有差异退出码 1

详见「CLI 命令参考」;有损边界(trace 不记 assistant 文本与原始输入)见 已知边界 表与 /llms-full.txt 的 A/B 一节。

人工闸门(HITL)

中间件可以 await 人的决策再放行,引擎会等工具结果:放行=next();拒绝=不调 next()(短路,副作用不发生);拒绝并让模型改道=抛错(该条结果记 is_error,run 不中断)。

approval.ts
import type { CapabilityMiddleware } from '@migor/agentia';

const requireApproval: CapabilityMiddleware = async (call, next) => {
  if (!DANGEROUS.has(call.capability.name)) return next();
  const ok = await askHuman(call.capability.name, call.input); // run 就地停着等人
  if (!ok) throw new Error(`未获批准:${call.capability.name}`); // → is_error,模型换路
  return next();
};
createApp({ system, providers, middleware: [requireApproval] });

跨进程挂起 / 续跑已支持:@Tool({ approval: 'required' }) 声明后,模型每次调用都把任务挂起为 suspended(消息历史与待决清单随任务落库,重启不丢),POST /tasks/:id/approve 批准/拒绝后从断点恢复 —— 详见「横切配方」与 API 参考。

四类能力

能力是服务的组成部分。四类能力对主 agent 都是菜单里的可调用项,共用命名空间,装配期统一查重。

@Tool — 函数调用

类方法即工具。入参按 JSON Schema 解析,先校验再执行;失败回 is_error,run 不中断。

src/tools/weather/index.ts
import { Tool } from '@migor/agentia';

export default class Weather {
  @Tool({
    description: '查询城市天气',
    schema: {
      type: 'object',
      properties: { city: { type: 'string' } },
      required: ['city'],
      additionalProperties: false,
    },
    strict: true,
  })
  get_weather(input: { city: string }) {
    return `上海 72°F sunny(${input.city})`;
  }
}

@Skill — 代码控制的流程

方法体是确定性脚本:要不要调模型、调几次、结果怎么加工,全由代码决定。模型调用只在显式 ctx.llm() 时发生,在 skill 自己的 capability span 下开 llm.turn 记账。

src/skills/weekly-report/index.ts
import { Skill } from '@migor/agentia';
import type { SkillContext } from '@migor/agentia';

export default class WeeklyReport {
  @Skill({
    description: '就给定主题调用 LLM 产出要点',
    schema: {
      type: 'object',
      properties: { topic: { type: 'string' } },
      required: ['topic'],
      additionalProperties: false,
    },
  })
  async weekly_report(input: { topic: string }, ctx: SkillContext) {
    const r = await ctx.llm({ prompt: `就「${input.topic}」给出三个要点` });
    return r.text; // 返回值即产物,以 tool_result 交回主 agent
  }
}

@SubAgent — 隔离的子代理

独立循环 + 裁剪上下文。方法体不会执行——框架按 system 另起 agent 跑一轮,中间过程不外泄,只有最终报告回流主上下文。

src/subagents/doc-reviewer/index.ts
import { SubAgent, asset } from '@migor/agentia';

export default class DocReviewer {
  @SubAgent({
    description: '按 system.md 的角色设定独立审查文档',
    schema: {
      type: 'object',
      properties: { task: { type: 'string' } },
      required: ['task'],
      additionalProperties: false,
    },
    system: asset(import.meta.url, './system.md'),
  })
  // 方法体不会执行:只读取方法名与装饰器元数据
  doc_reviewer(_input: { task: string }): void {}
}

@Prompt — 纯文本资产

模板与 playbook 资产,编译成菜单里一个无副作用的拉取型工具:模型判定需要时调用,文本以 tool_result 注入上下文。长文本放同目录 .md。

src/prompts/style-guide/index.ts
import { Prompt, asset } from '@migor/agentia';

export default class StyleGuide {
  @Prompt({ description: '写作规范(描述何时该拉取)' })
  style_guide(): string {
    return asset(import.meta.url, './asset.md');
  }
}

目录约定与装配

四分类目录,一能力一文件夹,长文本放 .md;每个能力是一个 default export 的类,用装饰器声明能力。

project layout
my-app/
├─ src/
│  ├─ app.ts                     # createAgentApp 装配工厂(dev 环与两个入口共用)
│  ├─ main.ts                    # 默认入口:npm start → HTTP 服务
│  ├─ batch.ts                   # 一次性入口:npm run start:batch
│  ├─ registry.ts                # 显式注册表(agentia g 自动维护)
│  ├─ tools/weather/index.ts     # @Tool
│  ├─ skills/weekly-report/      # @Skill
│  ├─ prompts/style-guide/       # @Prompt(含 asset.md)
│  └─ subagents/doc-reviewer/    # @SubAgent(含 system.md)
└─ tsconfig.json                 # include: ['src'],一个 src 全覆盖

两条装配路线

  • 目录扫描:createApp({ discover: ['src/tools', …] }) 给一个目录或一组目录(顺序即装配顺序),启动期扫各目录下的 <name>/index.ts,default export 为类时以文件夹名为 DI token 注册(因动态 import,带 discover 的 createApp 返回 Promise)。
  • 显式注册表:createApp({ providers, system }),providers 来自 src/registry.ts(由 agentia g 自动维护)。

两条路线二选一或混用;同 token 后者覆盖前者,不会重复收集菜单。装配期做静态校验:菜单查重、引用存在性、DI 循环依赖——在启动时失败,而不是在线上。

中间件(CapabilityMiddleware)

挂在每一次能力(tool / skill / subagent / prompt)调用的前后:鉴权、限流、结果缓存、审计日志、超时包装等横切关注点都走这里,不进业务能力。

签名
type CapabilityMiddleware = (call: CapabilityCall, next: CapabilityNext) => unknown;

interface CapabilityCall {
  readonly capability: AgentTool;   // name / description / inputSchema 可读
  readonly input: unknown;   // schema 校验已过的入参
  readonly ctx?: ToolRunContext;
}
// 放行到下一层;next(newInput) 可改写入参,缺省沿用当前 input
type CapabilityNext = (input?: unknown) => unknown;

洋葱模型

  • 链序 = 注册顺序:先注册的最外层,像洋葱一样层层包裹;
  • next() 放行,next(newInput) 改写入参;不调 next 即短路(结果缓存等);
  • 抛错按能力失败处理:engine 包成 is_error 回给模型,run 不中断;
  • 装配期包裹(AgentApp 构造函数),对 engine 零侵入;链为空时原样返回,零开销。
src/main.ts — 注册
const app = await createApp({
  name: 'my-app',
  discover: ['src/tools', 'src/skills', 'src/prompts', 'src/subagents'],
  middleware: [auth, cached], // auth 在最外层
  system,
});

例:鉴权

middleware/auth.ts
import type { CapabilityMiddleware } from '@migor/agentia';

// 敏感能力要求执行上下文里带授权标记,否则按能力失败抛错
export const auth: CapabilityMiddleware = (call, next) => {
  if (call.capability.name.startsWith('admin:') && !call.ctx) {
    throw new Error('forbidden: 缺少执行上下文');
  }
  return next();
};

例:结果缓存

middleware/cache.ts
import type { CapabilityMiddleware } from '@migor/agentia';

const store = new Map<string, unknown>();

// 命中即短路(不调 next),未命中放行后回写
export const cached: CapabilityMiddleware = async (call, next) => {
  const key = `${call.capability.name}:${JSON.stringify(call.input)}`;
  if (store.has(key)) return store.get(key);
  const out = await next();
  store.set(key, out);
  return out;
};

触发方式

同步 RPC、异步任务、定时调度共用一份输入契约;换宿主不换语义。

同步 RPC

await app.run(messages) 直接拿结果与 trace,适合请求-响应式宿主。

异步任务

AsyncRunner 提交即返回任务记录,后台执行;FileTaskStore 按 JSONL 一行一快照落盘,宿主重启后 resumePending() 续跑 queued/running。

async submit
const task = await runner.submit(
  [{ role: 'user', content: '生成上周运营周报' }],
  { idempotencyKey: 'weekly-report:2026-W36' },
);

定时调度

Scheduler 支持 every(间隔)与 at(定点)两种计划,到点转为异步任务提交。

幂等

异步宿主是 at-least-once 语义:同一 idempotencyKey 重复 submit,若上一任务未失败则直接返回既有记录(last-wins 去重);失败的同键允许重提产生新任务。

长上下文策略

createBudgetPolicy 提供预算驱动的上下文护栏,规则带滞回,避免每回合反复压缩:

  • 估算整组消息 token,≤ 预算 → 原样放行(快路径);
  • 超预算先 context editing:丢旧工具对,不额外调模型;
  • 仍超预算且有 summarize → compaction:旧前缀做成摘要,只留最近 keepRecent 条;距上次压缩不足 compactEvery 个回合则跳过(防抖);
  • 框架不替你造 token:缺省 token 估算是 CJK 感知启发式,真机可注入按 /count_tokens 的 estimate。
  • 估算只算新增消息:历史只追加,前缀的估算值被复用,所以每回合的估算开销与「本轮新增」成正比,而不是与整段历史成正比。
policy
import { createBudgetPolicy } from '@migor/agentia';

const policy = createBudgetPolicy({
  budgetTokens: 60_000,   // 预算(估算 input tokens)
  keepRecent: 20,          // compaction 保留的最近消息条数
  editBeforeCompact: true,  // 先编辑再压缩
  compactEvery: 1,          // 压缩滞回(回合数)
  // summarize: (historyText) => ...,  // 提供才允许 compaction
});

结构化结果

给 run 一个 resultSchema,模型完成工作后通过隐藏的 submit_result 工具提交结果,框架校验后挂在 result.typed 上——不再从纯文本里猜 JSON。

typed.ts
const { result } = await app.run(messages, {
  resultSchema: {
    type: 'object',
    properties: { pass: { type: 'boolean' }, reason: { type: 'string' } },
    required: ['pass', 'reason'],
    additionalProperties: false,
  },
});
console.log(result.typed); // { pass: true, reason: '…' }

schema 也可以来自 zod(peer 可选):fromZod(z.toJSONSchema(S), S),校验走 zod,错误路径原样回给模型自我修正。

可观测:trace 一等公民

一次 run == 一条 trace(traceId === runId),Turn 0 起内建 —— 不是外挂的第三方追踪 SDK 集成。这是 Agentia 与「能跑通就交差」的 agent demo 的分界线:四类能力决定它能做什么,trace 决定你敢不敢上线。

  • 每步记账:每个 span 带 model、input / output / cache tokens、成本估计、状态与错误类型。口径明确 —— totalUsage 只累加 llm.turn,能力 span 的 usage 是子孙聚合,不重复计数。
  • 出口是一条缝:TraceSink { export(trace) } —— run 收尾的成功与失败两条路径都投递,且 sink 抛错不影响 run。落库检索 / 采样 / 脱敏都在缝外用 sink 组合,框架不替你做策略(实码见 examples/observability/,说明见 docs/observability.md)。
  • 成本可管:priceOverrides 给价目表打补丁;模型不在表里时显式发 usage.unpriced 事件(不静默按 0 算);硬管控见下面「成本硬管控」。
  • 可回放:traceToMessages(trace) 把已完成的 trace 线性化喂回模型,作为调试与「改完 prompt 再跑一遍」的基底。
  • 可导出:createOtlpExporter({ endpoint }) 推 OTLP,导出时对齐 OTel GenAI semconv(追加 gen_ai.* 键:run 根 invoke_agent、llm.turn 的 gen_ai.request.model、score 事件译为 gen_ai.evaluation.result,既有 usage.* 键保留);metricsSink() 让指标也走同一条缝(Prometheus 文本或 OTLP metrics),全程零依赖。接法见「场景指南 · 给 run 上监控」。
  • 本地就能看:agentia dev 起本地 inspector 面板,与 @migor/trace-view 共用同一份渲染器 —— 官网 Playground 用的也是同一份,三处一套代码,不漂移。
observe.ts
import { createOtlpExporter } from '@migor/agentia';

const { result } = await app.run(messages);

result.trace.spans;       // 调用树:llm.turn / 能力 span / 事件
result.trace.totalUsage;  // token 汇总(只累加 llm.turn)

// 出口是一条缝:落库 / 采样 / 脱敏 / 指标都在缝外组合,框架不内建策略
await createOtlpExporter({ endpoint: 'http://localhost:4318' }).export(result.trace);

指标与调优报告(「哪个能力慢 / 贵 / 爱失败」)见下面「生态与观测」。

宿主与导出

下面这份 server.ts 把 HTTP 宿主、SQLite 任务存储与 OTLP 导出接起来。

server.ts
import { createServer } from 'node:http';
import { createHttpHandler, SqliteTaskStore, AsyncRunner, createOtlpExporter } from '@migor/agentia';

const runner = new AsyncRunner(app, { store: new SqliteTaskStore('./tasks.db') });
createServer(createHttpHandler(app, { runner })).listen(8080);
// POST /run(同步 / SSE)  POST /tasks(异步)  GET /tasks/:id(轮询)  GET /healthz(探针)

const otlp = createOtlpExporter({ endpoint: 'http://localhost:4318' });
await otlp.export(result.trace);

多模型与记忆

engine 只依赖 ModelClient 结构面:Anthropic 天然满足,OpenAI 兼容端点(含 DeepSeek 等)用 createOpenAIClient 一行接入;memory 选项在 run 间水合/回写 blackboard。

models.ts
import { createOpenAIClient, InMemoryMemoryStore } from '@migor/agentia';

const { result } = await app.run(messages, {
  client: createOpenAIClient({ baseURL: 'https://api.deepseek.com' }),
  model: 'deepseek-chat',
  memory: { store: new InMemoryMemoryStore(), keys: ['profile'] },
});

稳定性与流式

取消、重试、流式三件事共用同一份单次调用参数,从入口贯穿到模型请求。

  • 取消传播:signal 一路传到 ModelClient.messages.stream(内置 Anthropic / OpenAI 适配器都转发);中断不冒泡异常,run 以 stopReason='aborted' 收尾。HTTP 宿主内置「客户端断开即中止」,AsyncRunner.runTimeoutMs 到点会中止在飞 run。
  • 重试与退避:缺省开启(maxAttempts=3、指数退避 + 抖动),只重试「本次尝试尚未产出任何文本」的失败 —— 已流出的字无法撤回。每次尝试开独立的 llm.turn span。
  • SSE 流式:POST /run 带 Accept: text/event-stream → text.delta / run.end / error 逐帧下发;不带该头仍回一元 JSON。带背压保护:下游积压(res.writableLength)超过 sseMaxBufferedBytes(缺省 8 MiB)即收口该流并中止对应 run —— 否则一个连得上但不读的客户端会把内存吃光。
run.ts
const { result } = await app.run(messages, {
  signal: controller.signal,                        // 取消传播到在飞的模型请求
  retry: { maxAttempts: 3, baseDelayMs: 500 },   // 缺省就开;retry: false 关闭
  onText: (delta) => process.stdout.write(delta),  // 流式文本增量
});

宿主硬化

框架不实现 token / JWT 鉴权,也不订阅进程信号 —— 那是宿主或反向代理的事。

  • authenticate 拦在入口、读 body 之前(含未知路径,不暴露路径是否存在);抛 HttpException 按其 status / body 回,抛其它错回 401 且原文只进服务端日志。
  • drain({ timeoutMs }):拒新单 → 等在飞收尾 → 超时强制收口仍开着的 SSE 流;未完成的任务留在 store 里,下次启动 resumePending() 续跑(不是丢弃)。
  • GET /healthz → { ok, inFlight, uptimeMs, draining }:不鉴权(探针带不了凭据)、停机中也回 200;inFlight = 在飞同步 run + queued/running 的异步任务。
server.ts
const handler = createHttpHandler(app, {
  authenticate: async (req) => {
    if (!verify(req.headers.authorization)) throw new HttpException(401, { error: '无效凭据' });
  },
});
createServer(handler).listen(8080);
process.on('SIGTERM', () => handler.drain());  // 信号处理是宿主的

成本硬管控

与长上下文策略是互补的两件事:上下文策略在发送前改 messages(防 400 / 过早压缩),这里在记账后改 run 的结局 —— 目的是控制花钱。

  • maxTotalTokens / maxCostUsd:每回合记账后判断,超限以 stopReason='budget_exceeded' 收尾(算失败)并在 run 根记一条 budget.exceeded 事件。口径是整条 run(含子 agent)。
  • 两处刻意的语义:模型自然收尾的那一回合超限不改判失败(那次任务其实做完了);超限时连带不执行本回合的工具(不继续产生副作用)。
  • toolTimeoutMs:超时不杀 run,该条 tool_result 记 is_error 让模型换路;maxToolConcurrency 给同回合的并行工具设上限。⚠️ 超时是「放弃等待」而非取消,AgentTool.run 收不到 signal。它是唯一裁判:工具自带的超时(MCP 桥的 timeoutMs)在设了本项时不参与判定。
  • priceOverrides:给价格表打补丁 —— 覆盖内置型号单价,或给非 Anthropic 端点(DeepSeek / 自建)定价。它是 maxCostUsd 真正生效的前提:模型不在表里时成本恒 0、护栏不触发,而框架会通过 usage.unpriced 事件、model_unpriced_turns_total 指标与 onUnpricedModel 回调把这件事显式说出来,不静默。
guards.ts
const { result } = await app.run(messages, {
  maxTotalTokens: 200_000,  // 累计 token 上限(先到先算)
  maxCostUsd: 0.5,         // 累成本上限(依赖模型在价格表内)
  toolTimeoutMs: 10_000,     // 单工具超时 → is_error,不杀 run
  maxToolConcurrency: 4,     // 同回合并行工具上限
});

生态与观测

三件事都走既有扩展点:MCP 工具就是一个 AgentTool,指标就是一个 TraceSink,evals 就是对 trace 写断言。

  • MCP:mcpTools(client, { server }) 把 server 的 tools/list 映射成菜单项。连接器出厂自带(createStdioMcpConnector / createStreamableHttpMcpConnector,只用 node:child_process + 全局 fetch);框架不 import MCP SDK,McpClientLike 这条缝仍在(接官方 SDK / 远程 server / 自研传输照旧)。名字里对 LLM 不友好的字符会归一化成 _,原名按菜单名记进 mcp.tool.<菜单名> attribute(每次一条,并行不覆盖)供审计 / 回放。超时只有一个裁判:引擎设了 toolTimeoutMs 时桥的 timeoutMs 不参与判定(连接器自带的 timeoutMs 只管装配期的握手与 tools/list),两条路径同判定、同账(errorKind='timeout')。
  • evals:scriptedClient([...]) 脚本化模型往返 + defineEval({ app, cases, expect }) 对 trace 写断言 —— 改 prompt / 换模型 / 加工具之后有没有回归,靠断言而不是人眼。失败 case 带完整 trace。
  • 指标:metricsSink() 天然满足 TraceSink,三个维度都从 trace 派生、不用埋点 —— run 级,以及能力级(每个 tool / skill / subagent 的调用数、失败数、耗时、token、成本:工具读 turn 上的 tool.output 事件,skill / subagent 读 capability span)与模型级(按模型归因,另出「算不出成本的 turn 数」)。时长同时给可跨实例聚合的 histogram 与窗口内精确分位两种口径;render() 出 Prometheus 文本,createHttpHandler({ metrics }) 直接内建 GET /metrics;也可以 export:'otlp' 推给 OTel 采集端(零依赖手写 JSON)。评分级指标:attachScore 写到 run 根的 score 事件聚合成 agentia_score{name,source}(gauge,最近一次值)与 agentia_score_total(counter,条数)—— eval 结论 / LLM-judge / 人工标注同走这一路;现成 Grafana 看板随仓库发(examples/observability/grafana-dashboard.json)。注意 createHttpHandler({ metrics }) 只管渲染 —— 数字要累计须把同一个 sink 接进 createApp({ sinks: [metricsSink()] }),否则恒 0 且不报错。
  • 调优报告:buildRunReport(trace) 把一条 trace 变成「哪个能力慢 / 贵 / 爱失败」的排行(mergeRunReports 跨 run 汇总),CLI 一行 agentia report traces.jsonl 出表(那个 jsonl 要自己落:接一个 TraceSink 追加 JSON 行,或用 FileTaskStore 的导出文件);agentia dev 面板里也有一份同样的排行。面对一堆旋钮时,这是「该拧哪个」的依据。
  • 提示词版本:new SystemPrompt({ version }) 把版本号写进 run 根 system.version,trace 里能查「这个结果是哪个版本的提示词产出的」。多租户配额:middleware(拦)+ TraceSink(记账)+ BudgetGuard(单次上限)组合即可。
ecosystem.ts
import { createApp, createStdioMcpConnector, mcpTools, metricsSink, defineEval, scriptedClient } from '@migor/agentia';

// 1) MCP:server 的工具变菜单项(连接器出厂自带;也可换成任意实现 listTools/callTool 的对象)
const mcp = createStdioMcpConnector(['uvx', 'mcp-server-time']);
const tools = await mcpTools(mcp, { server: 'time' });
const app = createApp({ system, providers, tools, sinks: [metricsSink()] });

// 2) evals:脚本化往返 + 断言 trace
const report = await defineEval({
  name: 'time-flow',
  app: () => app,
  cases: [{ input: '上海现在几点?', client: scriptedClient([toolCallTurn, finalTurn]) }],
  expect: (r, { trace }) => { /* 断言 stopReason / 调了哪些工具 / typed */ },
}).run();
if (!report.ok) console.error(report.cases.filter((c) => !c.ok));

// 3) 指标:Prometheus 文本,接个路由回 metricsSink().render() 即可

审批、护栏与代码隔离

护栏与代码隔离,框架只给缝、不做子系统(策略差异太大,硬编码必错);审批除了闸门缝之外还有内建的挂起 / 恢复(HITL)。配方都只用既有扩展点,完整边界见 已知边界 表与 /llms-full.txt。

审批闸门

中间件可以 await 人的决策再放行 —— 引擎会等工具结果。放行=next();拒绝=不调 next()(短路,副作用不发生);拒绝并让模型改道=抛错(该条结果记 is_error,run 不中断)。要跨进程 / 跨重启的耐久审批,用 @Tool({ approval: 'required' }):run 挂起为 suspended(消息历史落库),POST /tasks/:id/approve 批准后带着决定恢复执行 —— 见 API 参考的 AsyncRunner.approve 与 ApprovalDecision。

approval.ts
import { createApp } from '@migor/agentia';
import type { CapabilityMiddleware } from '@migor/agentia';

const DANGEROUS = new Set(['send_email', 'deploy', 'delete_records']);

const requireApproval: CapabilityMiddleware = async (call, next) => {
  if (!DANGEROUS.has(call.capability.name)) return next();
  const ok = await askHuman(call.capability.name, call.input); // 就地等人(run 停在这里)
  if (!ok) throw new Error(`未获批准:${call.capability.name}`); // → is_error,模型换路
  return next();                                     // 放行
};

createApp({ system, providers, middleware: [requireApproval] });

内容护栏

入参(包一层 app.run;HTTP 侧用 createHttpHandler({ authenticate }),拦在读 body 之前)、工具调用前(middleware)、出参(包返回值,或挂 sinks)。

guard.ts
const callable = {
  name: app.name,
  async run(msgs, opts) {
    const out = await app.run(redactInput(msgs), opts);   // 入参护栏
    if (flagged(out.result.finalText)) throw new Error('输出被护栏拦下'); // 出参护栏
    return out;
  },
};

代码执行隔离

框架从不执行模型生成的代码 —— @Skill 跑的是你写的方法体、@Tool 是你写的函数,模型输出只会变成文本 / tool_result。要隔离就在工具实现内部做(Docker / 子进程 / 微 VM 随你),框架不参与 —— AgentTool.run(input) → output 这个契约把隔离整个挡在实现里。唯一沾边的一条:工具起了子进程,取消时要自己 kill(框架的超时是「不等了」,不是取消)。

已知边界(如实标注)

框架不保证什么 —— 下表逐条说明,是判断「我能不能用它」的依据。内容在构建期从仓库单源 docs/usage-guide.md §7 注入,与站点别处(/llms-full.txt)逐字一致。

边界说明
方法入参要自己标注TS 不会从 JSON Schema 反向推断方法形参;strict 下不标注会报隐式 any
裸 schema 不校验签名只给 schema: {...} 时,schema 与方法签名互不关联(要护栏就用 fromZod<T>)
黑板键默认无类型不合并 Blackboard 就是裸 string + unknown;动态键需 as BlackboardKey
没有「能力清单」类型能力是运行时从装饰器注册表收集的,所以 app.my_tool() 这种写法不存在
strict 只是透传框架不校验 schema 的合规性(是否 additionalProperties:false 等)
schema 校验是子集只覆盖 type/properties/required/additionalProperties/enum/items;format/minimum/oneOf 一律放行
历史畸形就放弃裁剪trimToolPairs 遇到非严格交替历史会整体放弃(宁可少裁,也不切出孤立 tool_use 让请求 400)
同键去重的能力边界idempotencyKey 的去重分三档:同步 store 下 submit 当场返回既有记录;异步 store(Redis / SQLite 等)下 submit 是同步门面、无法 await,去重靠进程内认领表,只覆盖「同进程内并发提交」(2026-09-21 修复前这里会两次都执行);跨进程并发与终态之后重提同键仍是 at-least-once —— store 的 idem 索引是 last-wins(重提即新任务,redisStore / fsStore / sqliteStore 头注释同口径)。要严格一次,请让副作用自身幂等(或在 store 层做唯一约束)
增量出口与 sink 是两条缝onTraceEvent 是「运行期逐笔」,sinks 是「收尾拿整棵」——消费者与保证都不同:sink 的抛错被吞但仍会投递(有兜底语义),onTraceEvent 不保证送达(宿主自己的流断了就断了,没有重试/重放)。要「一条都不能少」用 sinks;要「现在就看到」用 onTraceEvent
任务进度流的边界(内存 / 跨进程)GET /tasks/:id/stream 的事件缓冲在跑任务的进程内存里:每任务最近 500 条(可用 AsyncRunner 的 streamBufferEvents 调,须为正整数,坏值构造期抛错),超限丢最旧并先发一帧 stream.truncated;终态流只留最近 16 条。没跑完的流另有一道缓冲配额(streamNonTerminalBuffers,缺省 32 条流;0 = 不留):超出即从最旧、没有订阅者的流开始回收回放历史 —— 表项与序号都留着,连上来的读者先收一帧 stream.truncated 再转实时(不会变成 stream.unavailable,那是对别的进程说的话);正在被订阅的流不受配额约束(不抽走正在读的数据)。跨进程(队列消费者在别的进程)时没有实时流:发一帧 stream.unavailable 后立即收口 —— 任务已终态则补 task.end(随后关连接);非终态则补一帧 stream.closed(流级收尾,不是伪造终态)并关连接,客户端此后应转去轮询 GET /tasks/:id。要跨进程实时请用 onTraceEvent 把事件转发到宿主自己的总线(Redis Streams / Kafka)
异步任务的排队段边界(内存)concurrency 管「同时在跑几个」,不管「跑不上、排在后面」那一段 —— 缺省排队段无上限:submit 总是收单、POST /tasks 总是 202,灌多少就在内存里排多少(每条 = 一条记录 + 一棵悬挂的 #execute promise + 一个槽位等待者)。要背压就配 AsyncRunner({ maxQueued }):超限 submit 抛 TaskQueueFullError、HTTP 回 503 + Retry-After(只在 submit 判 —— 恢复路径推进的是已受理任务,拦下来等于把它搁死在 store 里;幂等键命中的重复提交也不吃 503)。runner.queued 是实时读数(与闸同一判据)。⚠️ 进程内拒单不等于集群级背压:多副本各算各的,要全局就得上队列
traceLimits 与 maxEventChars 各管一头maxEventChars 管单个事件正文多长(既有),traceLimits.maxEvents 管整条 trace 记多少条记录事件(本版)。两者正交、都「不设 = 不限」;超限即停止记账,并在 run 根留两笔 trace.truncated:起点标记 {limit}(断点在这里)+ 收尾摘要 {droppedEvents, limit}(丢了多少)—— 两笔同时进交付的 trace 与 onTraceEvent 增量流(⇒ 按 seq 折回仍逐字等于收尾那份;两笔簿记不占 maxEvents 配额)。⚠️ 实测 maxEventChars: false + 大出参会让 trace 放大 13.7×(npm run bench:trace 可复现)—— 先收长度再谈采样,收益顺序比反过来大
回给模型的 tool_result 不截断、也没有大小闸maxEventChars / traceContent 管的是记账侧(trace 记多长、记不记正文)—— 回给模型的工具结果永远完整,框架里没有「单条工具结果超过多大就裁」的那条缝。一次 cat 大文件、一次刷屏回显就能让下一回合的输入爆掉上下文窗口,以 api 错误收尾(长跑里尤其容易撞)。要设输出上限请在工具实现内部做(按行数 / 字节截断并附「已截断」标记)—— 框架不替你兜
采样是导出决策,不是记账决策采样在 sink 外做(配方见 docs/observability.md 2.3):被采样掉的 trace 在框架内仍然完整记账,只是没发给下游。所以别拿「有采样」当「可以少记账」;也正因如此,出站 traceparent 的 flags 恒 00(记录/导出决策发生在收尾之后,运行期不可知——不替下游声明)
菜单漂移只标记、不判失败续跑时未决 tool_use 的工具不在当前菜单 ⇒ 三处信号(menu.drift 事件 / 父 span attribute / console.warn),run 照常收尾(那些 tool_use 以 unknown tool 回给模型)。射程如实标注:判据只看名字 —— 「工具还在、schema 变了」能被 tools.menuHash 比出来,但不会在续跑时报 menu.drift;只改 description 连签名都不变(有意:润色文案不算漂移)。严格模式(带具名 error 的收尾)与「结果级字段」记在 spec §11 开放项
approve 只收待决的 id(多一个 → 400 整批拒)decisions 的键必须都在该任务的 pendingApprovals 里(调用方从记录的 pendingApprovals 读)。多出任何一个 ⇒ 400 + 整批拒 + 记录一字不动。不校验的代价(已修,2026-09-28 外部深评):多出来的键会写进 TaskRecord.approvals,随每次 save 全文重写、且终态也不清 ⇒ 一个认证调用方单次请求就能把记录撑大(实证:一次 5000 个无关键 = +419 KB),反复调用可无限叠加
TaskRecord.approvals 有界但不清单次 approve 能写的键被钉在当前 pendingApprovals 里 ⇒ 单次输入无法放大体积;跨多轮挂起会累计历次待决的并集(每轮都对应真跑出来的模型回合),增长只随真实运行发生 ⇒ 天然有界,不需要常量上限;但终态也不清(审批记录是审计的一部分)—— 想减负请换 store 的清理/TTL 策略
缺省内存 store 不淘汰长跑宿主请设 InMemoryTaskStore({ maxRecords }) 或换 FileTaskStore / SqliteTaskStore
能力引用两种粒度tools 写 provider token = 整片能力菜单;写 '<token>/<能力名>' = 只引单个能力(@Tool/@Skill/@SubAgent/@Prompt 都可点名,装配期校验,名字不存在即抛错并列出可用名单)
能力引用成环 ⇒ 装配期抛错(2026-09-28)@SubAgent / @Skill 的 tools 不得绕回自己(直接自引用,或经另一个 provider 绕回来都算)。为什么必须要拦:subagentToTool 的引用解析是延迟求值的,环只在运行期成立,而 maxIterations 只限一回合的宽度、深度没有任何闸 ⇒ 模型一次自我调用就无限递归(树按 宽度^深度 炸开)。抛错信息给出环上的节点序列。没有「深度上限」可以配 —— 这是装配错误,不是运行参数
能力名有格式校验装饰器能力名(name 或缺省的方法名)必须匹配 ^[A-Za-z0-9_-]{1,64}$(与 MCP 桥同口径),非法名在 createApp 装配期即抛错 —— 含空格/点/中文的名字会让模型 API 400,宁可在启动期拦住
discover 入口会回落能力目录里源码与编译产物并存(index.ts + index.js)时,首选 .ts 加载失败会回落 .js 并 warn —— 命中的可能是陈旧编译产物(刚改过源码时注意);全部候选都失败才抛错并列出各自原因
asset() 的 rel 必须是相对路径带 scheme(file: / https: …)的 rel 会让 new URL(rel, base) 整个忽略 base(「以为读了能力目录、实际读了别处」),显式抛错;../ 越出能力目录是有意放行(共享资产如 ../../shared/x.md 是合法用法)
取消要传进客户端才有效传 signal 后框架会 abort 在飞请求(内置 Anthropic / OpenAI 适配器都转发);不转发 signal 的自定义 ModelClient 只能「放弃等待」(请求在后台跑完、产物丢弃)
默认 client 的真端点验证范围e2e:live 跑在 DeepSeek 的 Anthropic 兼容端点上;官方 Anthropic 端点的行为差异(thinking 细节、cache TTL 语义、新块型)目前只有本地假端点测试在守 —— mock 全绿发现不了厂商真实行为 —— 接入官方端点前自己跑一遍 npm run e2e:live
thinking 块「能收、不主动请求」框架从不在请求里开 extended thinking;默认 client 能收拼 thinking 块(signature_delta 会累积),redacted_thinking 与未知块型原样透传不丢 —— 但官方 API 的 thinking 回灌要求带合法 signature,自定义 client 开 thinking 时自己验证这条链
工具阶段的 abort 有盲区abort 只在三处被观察:回合边界 / 在飞模型请求 / 重试退避 sleep。没设 toolTimeoutMs 且工具挂死时,abort 之后 run 也不会返回(工具的 Promise 永不 settle)—— 挂死的工具要么设超时,要么自己读 ToolRunContext.signal
观测失败被吞sink 抛错不影响 run(观测是辅助动作);但吞之前落一条 console.warn(含「trace sink」字样)—— sink 天天挂不再零信号。同理记忆水合/回写失败也不击穿 run
框架不自动读 .env除 AGENTIA_MODEL(缺省模型覆盖)与 OPENAI_API_KEY(OpenAI 适配器)外,框架自己不去翻环境变量,也不读 .env;要读就在启动代码里调 loadEnvFile()(脚手架已内置那行),真实环境变量优先于文件
鉴权只是缝框架不实现 token / JWT / 签名策略,也不碰凭据 env —— authenticate 只承诺「拦在入口、读 body 之前」;策略是宿主或反代的事
运行时:Node 承诺,Bun / Deno 实测按 Node ≥ 18 设计与测试(engines 写明;CI 覆盖 18 / 20 / 24 —— import-floor 矩阵跑下限、其余 job 跟当前 LTS);Bun 1.4 / Deno 2.9 已实测可跑(四步判定 + 各宿主最小代码见 docs/frameworks.md),但不在 engines 承诺内;edge 未验证。SqliteTaskStore 需 Node ≥ 22.5(node:sqlite),未提供时构造期抛可读报错
停机不由框架触发框架给 drain() 但不订阅 SIGTERM/SIGINT(不做进程级决策);信号处理是宿主的
停机可能切断 SSEdrain() 收口时会强制关闭仍开着的 SSE 流,/run 的流被收口时其 run 以 stopReason='aborted' 收尾(客户端应把断流当作可重试)。没有 timeoutMs 时也收口(否则等待没有终点,见上);只关旁观者长连(/tasks/:id/stream)不损失工作,那种收口不影响 drain() 的返回值
多进程共库:续跑认领靠租约,不靠时间(2026-09-28)resumePending({ staleAfterMs }) 的 staleAfterMs 不是租约而是兜底启发式。> 0 时按两档判他进程的记录:① ownerId 里的主机名是本机 ⇒ 问那个 pid 还在不在(process.kill(pid, 0))——在的绝不抢(不管记录多老)、不在的立刻可抢(崩溃孤儿不等保鲜期);② 判不了(异主机 / 升级前写下的旧格式 ownerId / 自定义串)⇒ 才用 staleAfterMs(「记录看起来还新」)。缺省 0 = 不问他进程,与升级前逐字一致
ownerId 是内部标识,别解析它形状 p<pid>@<host>-<8 位十六进制>(2026-09-28 起带主机名,此前没有)。混版本部署安全:旧进程写下的无 @host 记录一律走「判不了 ⇒ 新鲜度」,行为与升级前一致。宿主如果需要自己的任务归属标记,请写进 spec.options,别依赖这个字段的格式
租约判定的两个残留风险:只晚捡、不抢错(2026-09-28)PID 复用与僵尸进程(父进程未收尸,pid 仍在进程表里)都会被判成「主人在」⇒ 那条孤儿记录要等那个 pid 真的消失才可捡。框架刻意不加时间硬上限兜它们 —— 加上限 = 把租约换回新鲜度启发式,「长跑被抢」那个 bug 原样回来。取舍一句话:宁可偶尔晚捡,不可偶尔抢跑
beforeTurn 抛错 ⇒ 本回合降级,不判死 run(2026-09-28)ContextPolicy.beforeTurn 里可能走网络(compaction 的 summarize 是模型调用)⇒ 一次 429 / 超时原先会让整条 run 以「请求失败」收尾。现在降级为「本回合原样放行」,且出声:run 根记 context.policy_failed + 一条 console.warn。⚠️ 代价:降级只是「这一回合不压」,不是「不再压」——历史偏长时先看有没有这条事件
压缩失败不烧滞回额度(2026-09-28)compactEvery 的语义是「距上次成功压缩至少隔几个回合」——失败不记账、下一回合重试(原先失败也记,于是摘要器抖一下会让后面几个回合都不再尝试)。代价说清:摘要器持续失败时每回合都会再调一次(多一次失败的模型往返),要抑制请在 summarize 里自己退避
drain() 之后本进程不再推进任何任务(单向闩)置位后没有复位路径,且 false(排空超时)同样意味着宿主必须退出:停机窗口里已落库的记录(running/queued + 本进程 ownerId)在本进程内没有自愈路径 —— resumePending 按 own-process 跳过、submit 已整体关闭 ⇒ 只能等下一次启动认领(ownerId 含 pid)。宿主若不退出,那些记录会一直停在 running(awaitTask 不返回、GET /tasks/:id/stream 还会说 not-in-this-process)。首次被拒时有 [agentia] 停机中:… 的 console.warn(可 grep),别忽略
鉴权失败即断连未通过鉴权时在读到 body 之前就回响应,连接不可复用(显式 connection: close);这是「不收body省资源」的代价
预算护栏不是硬实时一回合记账完才判,实际用量可能超上限一个回合的量;并行子循环(一回合多个子 agent)各自过闸,超支上限是「每个在飞分支各一个回合」而非「总共一个回合」;模型自然收尾的那回合超限不算失败(只留 budget.exceeded 事件)
maxCostUsd 依赖价格表模型不在价格表内(且未用 priceOverrides 覆盖)时成本恒为 0,这条护栏不触发 —— 要无条件兜底用 maxTotalTokens。失效会响:turn 上会记 usage.unpriced 事件、指标有 model_unpriced_turns_total、可回调 onUnpricedModel
价格表按模型名精确匹配别名与带日期快照是两个不同的键:内置表收了 claude-haiku-4-5,那么 claude-haiku-4-5-20251001 就是未定价(成本算不出、maxCostUsd 不触发,会留 usage.unpriced)。照抄官方文档里的带日期 id 最容易踩这一条 —— 要么改用不带日期的别名,要么 priceOverrides 里按你实际传的那个 id 补一条
缓存写的缺省乘数只对 5 分钟 档正确官方是 5m 写 1.25×、1h 写 2×、读 0.1×。而 Usage 是四项聚合的,分不出这一回合的缓存写走的是哪个 TTL(SDK 的 Usage.cache_creation 有拆分,框架没把它带上)⇒ 你在块上用了 cache_control: { ttl: '1h' } 就必须在 priceOverrides 里写 cacheWrite: 2,否则成本低估 37.5%、maxCostUsd 会迟触发。框架自己的调用点都不设 ttl,所以不改也对
图片块的 token 只能估、且按上界估块里没有宽高(source 只有 base64 的 data 或 url),而官方图片计费按尺寸(28×28 像素 = 1 visual token),与文件字节数无关 —— 一张 200KB 纯色 PNG 和一张 200KB 细节图的成本能差两个数量级。框架取「长边缩到 1568px」的上界 3136 token/块:宁可高估(maxTotalTokens 提前拦)也不低估(护栏迟触发)。渲染 / 摘要侧则只给 [image …] 占位,不展开 base64
工具超时不强制取消工具AgentTool.run 没有 signal 参数,超时首先是「不等了」;副作用可能已发生。想真停:监听 ToolRunContext.abandoned(引擎放弃等待时 abort 它)自行收尾。框架自带的 @SubAgent / @Skill 已这么做 —— 超时即中止子循环(在飞请求被掐、不再后台烧 token),capability span 立刻以 error 收尾
会话只存对话轮次SessionStore 存「用户输入 + 最终回复」,run 内部的 tool 往返不进历史(要完整过程用 traceToMessages);且只有跑成功的轮次才回写
同 session 并发 run 要自行串行化SessionStore 是 append-only:并发写不互相覆盖、不丢数据,但不保证角色交替 —— 两个并发 run 共用同一 sessionId 时,各自追加的轮次可能交错成「连续两条 user」,下一轮 load 出来撞角色交替校验(400)。同一 session 的并发 run 请调用方自行串行化(每 session 一把锁 / 一条队列)
OpenAI 适配器听端点的话请求发 stream:true,但按响应形态解析:端点回 JSON 就退回一次性(没有打字机效果),回 event-stream 才逐 token
OpenAI 流式的上游故障按失败处理三种形态都抛错按失败处理:流中 error 分片(上游把故障塞进 200 的流;按 type/code 反推 status,限流能被引擎重试认出);未收到 [DONE] 也无 finish_reason(流被上游/代理截断 —— 哪怕已吐出半句、有累积文本,也按不完整响应抛错,不报 end_turn);正常终止却无任何文本与工具调用(与非流式空 choices 同一守卫)。例外:finish_reason=content_filter 的空流是合法 refusal,不抛 —— 与非流式路径同一个响应同一个结论
OpenAI 兼容端点回 legacy function_call 形态时不支持适配器只认现代 tool_calls(请求侧也只发这个形态)。收到 finish_reason=function_call 会响亮失败(400,落 api/不可重试),不按 end_turn 收尾 —— 那种回法里的调用在 message.function_call 里、读不出来,报成正常收尾会让「模型要调工具、工具却没执行」记成成功。换支持 tool_calls 的端点或模型即可(legacy functions 形态 OpenAI 2023 已废弃)
MCP 只做 toolssampling(server 反向请求模型)/ resources / prompts 原语不做;出厂连接器同样只做 tools/list + tools/call
MCP 反向桥不校验会话(无状态 server)createMcpServer 的 http 模式:initialize 铸 mcp-session-id 头但后续请求不带也服务(宽容是有意的);GET → 405、DELETE → 200;鉴权只有 auth 钩子一道(stdio 信任父进程)。范围同样只到 tools(与正向桥同一张 YAGNI 清单)
MCP 的协议层错误框架看不见isError: true 只有连接器能看见 —— 它必须转成抛错,否则模型收到的是一条「成功」的结果(出厂连接器已代你处理)
MCP 超时同样是「不等了」桥的 timeoutMs 取消不了 server 侧执行(拿不到取消句柄);它只是兜底 —— 引擎设了 toolTimeoutMs 时不参与判定(一次调用只有一个裁判;显式 toolTimeoutMs: 0 也算设了 —— 那是引擎表态「不限」,桥不会再自作主张判 60s),两条路径同判定、同账(errorKind='timeout')
已中止的 MCP 调用不发请求信号在发送前就已中止 ⇒ 立刻以 AbortError 收场,请求不出门(2026-09-21 前是「照样 write、Promise 永不 settle」:副作用真送达、调用方永久挂起)。发送之后才中止的,请求已在路上、取消不了 server 侧的执行,但返回的 Promise 同样立刻以 AbortError 收场、簿记同步清掉 —— 直接 await 连接器 API 的宿主不会永久挂起(2026-09-22 前是「只删簿记不 reject」:Promise 永不 settle,与 HTTP 连接器的在途中止行为不一致);取消不了的只是 server 侧执行,这点与 toolTimeoutMs 同口径
MCP 连接器的超时只管装配期连接器自带的 timeoutMs 只作用于握手 + tools/list(那两步没有任何别的裁判 —— server 卡住会让 createApp 永久挂起);callTool 仍是引擎 / 桥那一个裁判
MCP 连接的 close() 保证子进程已终止stdio 先 SIGTERM、MCP_CLOSE_GRACE_MS(2000 ms)后 SIGKILL,然后等真正的 'exit' —— 返回即代表进程已被回收(此前到点即返回,会留孤儿进程而调用方无从知晓);HTTP 尽力 DELETE 会话(server 不认也无所谓)
StreamableHTTP 会话过期自愈带会话 id 收到 404 = 会话已终止、该请求未被 server 执行 ⇒ 丢会话 → 重新握手 → 把这一次重试一次(只一次,不再循环)。自愈本身是静默的 ⇒ 用 onSessionExpired 去计数 / 告警,否则它和「静默失效」在监控上看不出区别。404 之外的失败仍按 classifyError 分流抛出,不重试
MCP 名字可能被归一化原名含 - / . / 空格 → 进菜单时变成 _;回调 server 用的仍是原名(mcp.tool.<菜单名> attribute 逐次可查;mcp.tool 是最近一次)
MCP 工具不能进 DI 容器它没有 provider token,也不能被别的能力的 tools 引用(两种引用粒度都要先有 token)
指标分位是窗口内精确值*_last{quantile=...} 只反映最近 windowSize(缺省 1024)条样本;要跨实例聚合请用直方图(*_bucket / _sum / _count,累积语义)
指标是进程内累加不做分布式聚合与持久化:多实例各算各的(直方图可相加),重启即清零。要长期保留请把 render() 抓走或用 export:'otlp' 推给采集端
OTLP metrics 只推当前累计按 intervalMs 周期导出累积值(CUMULATIVE),不做增量/背压;导出失败按 onExportError 处理(缺省每次失败落一条 console.warn,不重试、不阻塞 run)。reset() 语义是开启新窗口:计数清零且数据点起点前移(同一 startTime 下 counter 只能单调不减,只清零会让后端算出负增量或丢样本)
OTLP trace 导出只发 JSON(直打 Phoenix 会 415)createOtlpExporter 的 content-type 写死 application/json,OtlpExporterOptions 没有编码选项;只收 application/x-protobuf 的接收端(Phoenix 的 traces 路由对别的 content-type 回 415,源码级核实 2026-10-12)直打不进去 —— OTLP 规范对接收端只是 SHOULD 双收,Phoenix 未遵循推荐但不违规。处置:中间加一层 OTLP Collector 收 JSON、转 binary(最小配置见 docs/observability.md §7)。不内建 protobuf 编码器:零运行时依赖是硬约定,手写编码器几百行进核心不值
GET /metrics 不鉴权与 /healthz 同档(拉取端在集群内网)。要保护请放反代之后,或不传 metrics 选项自行在外层挂路由
工具没有 token/成本指标工具是你的代码、本身不消耗 token,所以只产出调用数/失败数/耗时;token 与成本只对 skill/subagent(有 capability span)与模型维度产出
@Prompt 没有能力指标资产类能力不建 span、无独立耗时,故不出现在能力排行里(这是刻意的:硬凑一个假耗时会误导调优)
能力标签有基数上限labelMode:'capability'(缺省)+ maxCapabilities(缺省 200),超出的能力归入 capability="__other__";snapshot().droppedCapabilities 给出被归并的能力个数。要完整明细请用 buildRunReport(不设上限)
模型 / 评分维度也有基数上限maxModels(缺省 50)/ maxScores(缺省 200):model 与评分键(name@source)都可能是无界键(上游把版本号拼进模型 id、eval 名带时间戳),而每个模型键都持一份时长窗口 —— 光是能力封顶不够。折叠只丢标签粒度,__other__ 桶照常累加,总数仍对得上;被折叠的不同键数见 droppedModels / droppedScores
提示词版本只是标记框架不存版本库、不回滚:version 只落 run 根 attribute;system 传已拼好的 SystemParam 时无版本可记
agentia harvest 的产物是轨迹骨架trace 不记 assistant 文本(llm.turn 只记 usage/事件),故 harvest 用例脚本里的 text 块是占位、预填 expect 是从原 trace 抄录的实际轨迹 —— 脚手架不是成品,人工核对后再进 CI(见 §6「线上 trace 回流」)
分叉重放不是续跑forkMessages 与 traceToMessages / harvest 同源有损:trace 缺省不记 assistant 文本(opt-in 例外:traceContent: 'full',见 §4)与 run 原始输入(重放里 assistant 是标注占位、首尾 user 是合成),也不记 blackboard(分叉种子经 RunInvocationOptions.blackboard 自带);它产出喂回 app.run 的 messages、起的是新 run,不是接着原 run 的循环位置跑
评分来自 run 之外Score 走 run 根 score 事件而非 span 字段(评分通常在 run 跑完后才产生);attachScore 找不到根 span 时静默忽略,多次调用即多条事件(不同维度各记各的)
链路关联:入站自动、出站默认只给读取器(MCP 桥除外)traceContext / traceparent 头把上游接进来(run 根的 links);出站方向给 currentTraceparent()(当前 span 的 W3C 串,回合 / 能力调用粒度),但框架不替你做注入 —— 它不创建出站请求,那一行由宿主的 fetch / metadata 自己写。唯一的例外是 MCP 正向桥:tools/call 的请求由桥自己发,故 run 内经 mcpTools 的调用自动带 params._meta.traceparent(SEP-414)。两个边界:① run 根 span 由 runAgent 打开 ⇒ 更早的 contextInit / 记忆水合取到 undefined(那时确实没有 span);② flags 恒 00(本框架不采样)。id 宽度经同一份投影压到 16 位(与 OTLP 导出共用,单一真源)—— 故下游收到的 span id 与 collector 里的是同一个数。另:link 只落在 run 根(子 span 不散),且一进程内不跨进程自动传播 —— 队列场景要自己把 traceContext 传下去(HTTP 头带走,或随 TaskRecord.spec.options 落库)
配额不是框架子系统只给缝(middleware + TraceSink + BudgetGuard),计数放哪(内存 / Redis / DB)与超限怎么办都是你的策略
人工审批两种形态进程内闸门用 middleware(await 决策再放行);跨进程耐久审批用 @Tool({ approval: 'required' }) + POST /tasks/:id/approve(挂起/恢复,见 §6.6「人工审批」)
审批超时是惰性判定approvalTimeoutMs 不起定时器:approve / poll / resumePending 读到过期挂起任务时才自动全拒并重派 —— 没人读的任务不会自己超时(要定期扫就调 resumePending())
挂起段与恢复段是两棵 trace每段执行一棵独立的树(traceId == runId),恢复段经根 span 的 links 挂到上一段;指标按段计(挂起段算一次 ok 的 run —— 「等人」不算失败,区分看 stop_reason attribute)
审批是 at-least-once崩溃发生在「批准后、恢复执行中」时,副作用工具会重执行(与 resumePending 续跑同口径)—— 副作用工具自己保证幂等
挂起/恢复间预算重新起算maxTotalTokens / maxCostUsd 在恢复段从 0 重新计(新树新账,与 resumePending 续跑同口径)
恢复段的会话回写是进程内快照带 sessionId 的任务挂起时,「本轮用户输入」快照只存进程内存(恢复段由 AsyncRunner 自己补写「用户输入 + 最终回复」,不再经 run 层重复拼历史)—— 进程崩在「挂起 → 重启 → approve」之间会丢这一次会话回写(会话少一轮,但绝不写进坏历史;审批决定本身已落库)
嵌套能力内的审批不支持挂起@SubAgent / @Skill 子循环里的 approval: 'required' 工具无法把整个 run 挂起 —— 子循环挂起会以 is_error 交回主 agent(要审批的能力请放主菜单)
同步 /run 撞上审批没人可批同步 RPC 会带着 stopReason: 'suspended' 收尾返回 —— 但响应体(toHttpBody)不含 suspendedMessages,也没有任务记录可审批(待决清单只能去 trace 的 approval.requested 事件里看)。要审批请走 POST /tasks 异步宿主
Scheduler 调度表不落库every / at 的调度本身只在内存:已 submit 的任务记录能经 resumePending 续跑,但「未来某刻再触发」的调度在重启后不存在(远期单发由宿主自己的 cron 驱动)。另:drain() 不停 Scheduler —— 停机窗口内到点的 tick 会打一条触发失败日志(无害但吵),介意就 scheduler.stop() 先行
file store 的撕裂写只在启动时自愈写入中途失败(磁盘满等)留下的残行由 healTail 在构造期修复;同进程内继续 append 会把新记录粘在残行尾部、下次启动时一起丢弃 —— 磁盘满告警后先恢复写入能力再继续依赖它
终态落库失败 ⇒ 重启会重跑AsyncRunner 终态 save 失败不遮罩主流程(「不击穿业务」的代价):store 抖动时任务可能永远停在 running,重启后 resumePending 会重跑一个实际已成功(副作用已发生)的任务 —— 所以副作用工具必须自身幂等。但失败本身不再静默:new AsyncRunner(app, { onPersistError }) 会收到 { record, error, phase }(phase: 'initial' | 'outcome',后者就是这条)。⚠️ 框架修不了它(写不进去就是写不进去)—— 出口的职责是让你能对账、让「记录无声丢失」不再是默认行为(与 createOtlpExporter({ onExportError }) 同因同形)
缺省不裁剪上下文(长跑每回合重发全量历史 ⇒ 成本随回合数平方增长)contextPolicy 不注入就一个回合都不裁:每回合把整段历史重发给模型,于是回合数翻倍、总输入约翻两番。框架给了缝与一份参考实现(createBudgetPolicy),但不替你决定 —— 裁剪有损(截在哪、留什么会影响答案),是宿主的策略。长任务(几十回合以上)请在 createApp 里显式注入 contextPolicy
contextPolicy 不进子循环应用级 contextPolicy / onText 只对主循环生效:@SubAgent / @Skill 的子运行不做上下文裁剪(长跑子 agent 撞上下文上限会以 api 错误收尾)。预算护栏(maxTotalTokens / maxCostUsd)正常透传
记忆没有删除语义MemoryStore 只有 load/save:run 内 ctx.delete 掉的键回写时不会从 store 移除(下一轮水合会复活)。要真删请直接操作 store 实现
同 keys 的并发回写不合并CAS(loadWithRev + saveIfRev)只做到「冲突时不写 + 出声」:框架不合并、不重试(合并语义是 store 与宿主的决定,配方见 §6.5)。只有 load / save 的 store 连冲突都看不见 —— 版本号是唯一依据,没有它就检测不了覆盖;InMemoryMemoryStore 的版本粒度是整店一个计数(宁可多报冲突,不漏报真实覆盖)
内容护栏不给实现同「配额」:只给缝(入参包 app.run / 工具前 middleware / 出参包返回值或 sinks),策略(正则 / 分类器 / 外部 API)是你的
框架不执行模型生成的代码无沙箱可言:@Skill 跑你写的方法、@Tool 是你写的函数,模型输出只成文本 / tool_result;代码执行工具的隔离是工具实现内部的事;工具起的子进程取消时要自己 kill

CLI 命令参考

全局安装 npm i -g @migor/cli 后使用 agentia 命令。

命令作用
agentia create <name>脚手架新项目:四分类目录(含 .gitkeep)、src/registry.ts 注册表、src/app.ts(装配工厂)+ src/main.ts(默认入口 —— HTTP 服务)+ src/batch.ts(一次性入口)+ src/dev.config.ts、tsconfig。
agentia g <type> <name>生成能力到对应的分类目录(tool → src/tools/、skill → src/skills/、prompt → src/prompts/、subagent → src/subagents/),并自动登记 src/registry.ts;长文本资产(system.md / asset.md)一并生成。
agentia dev起本地 inspector 面板:在面板上输入 prompt 驱动一次真 run(带 ↑/↓ 历史),可多选能力(收窄菜单)、选工作目录、开关多轮,再在 trace-view 里看调用树。CLI 自己管文件监视(允许清单含 .md —— 改文本资产不用重启)。只绑 127.0.0.1,带 Origin 校验 + 每次启动一次性 token。
agentia doctor装配体检:未登记/悬空能力、命名规范、重复条目。
agentia report <trace.jsonl>从 trace 落盘文件生成调优报告:能力耗时 / 成本 / 错误率排行(跨行按能力合并)。每行一个 JSON —— 裸 Trace,或含 result.trace 的 TaskRecord。文件要自己落:接一个 TraceSink 追加 JSON 行,或用 FileTaskStore 的导出文件。
agentia harvest <trace.jsonl>把线上 trace 翻成 eval 用例脚手架(--out <file.ts> / --failed / --limit N):scriptedClient 脚本按主循环 llm.turn 重建,expect 预填工具序列断言。脚手架不是成品 —— assistant 文本是占位(trace 不记文本),人工核对后再进 CI。
agentia diff <a.jsonl> <b.jsonl>两条 trace 落盘文件的调用树 A/B 比对(输入形态同 agentia report):打印 run 级 summary + 逐 span 差异,有差异时退出码 1 —— 可直接进 CI 挡「换 prompt / 模型后轨迹漂移」。代码内同款是 diffTraces。
agentia add <pkg>安装第三方能力包(defineModule 能力包)并登记到注册表。

环境变量

变量说明
ANTHROPIC_API_KEY必填。Anthropic API 密钥(亦支持 ANTHROPIC_AUTH_TOKEN / ANTHROPIC_BASE_URL 兼容端点)。
.env(loadEnvFile())框架不自动读 .env:脚手架 src/app.ts 的 loadEnvFile() 把它读进 process.env(放在装配模块,agentia dev 环与两个入口(npm start / npm run start:batch)才都会读到)。真实环境变量优先 —— 已定义(含空串)的键不被文件覆盖,除非显式 { override: true }。
AGENTIA_MODEL可选。全局缺省模型覆盖,无则回落 claude-opus-5;显式传参优先级最高。
OPENAI_API_KEY可选。OpenAI 兼容 provider 的密钥,多 provider 场景使用。

版本与稳定性承诺

0.x 的含义:版本号按 SemVer,但 0.x 期间 minor 版本可以包含破坏性变更。规矩只有一条 —— 破坏性变更必须留痕: CHANGELOG.md 对应的那个版本里要有一段专门写它(小节标题含「迁移」或「破坏性变更」), 写清「你要改什么」。

数一数
# 列出所有「要你做动作」的小节(本页刻意不写死这个数字 —— 写死的读数会腐烂)
grep -nE '^#{3,4} .*(迁移|破坏性)' CHANGELOG.md

⚠️ 如实标注:这条纪律是逐步收紧的 —— 早期的 0.7.1 (类型面变更)把动作写在版本条目开头的引用块里、没有独立小节。⇒ 那条命令是下限而非全集, 要读全就通读该版本的条目。

面承诺不承诺
公共 API@migor/agentia 的具名导出(真源 src/index.ts,官网 API 页有反向全覆盖守卫)+ @migor/cli 的命令行面深路径导入(dist/**)、内部模块路径、packages/*、examples/* —— 这条边界由 package.json 的 exports 字段封着(tests/architecture/package-exports.test.ts 守)
行为该版本条目里写明的迁移动作没写进迁移说明的任何行为变化
运行环境engines.node 声明的区间;CI 在该区间内每个版本上跑完整条链edge runtime(未验证);已 EOL 的 Node(会随 minor 去掉)。Bun / Deno 已实测可跑,但不在 engines 承诺内(结论表与最小代码见 docs/frameworks.md)
依赖运行时零第三方依赖(tests/architecture/no-runtime-deps.test.ts 守着,非口头承诺)devDependencies 与 examples/* 的依赖面

1.0 的门槛

三条都是可核验的,不是口号:

  • docs/spec.md §11「开放项」里每一条要么落地、要么被明确划掉(写清为什么不阻塞);
  • docs/guards.md §2「待守」为空 —— 已声明的边界都由机器守着,而不是靠人记得;
  • 公共导出面连续 3 个 minor 无破坏性变更。

性能量级

框架自带五个基准(scripts/bench-*.ts)。它们回答的是形状问题 —— 哪笔钱在什么条件下付、换实现能省掉的是什么 —— 而不是一个 SLA。

你想问的本机读数(量级)形状结论(不随机器变)复跑
装配一次 app 多贵纯装配 0.4 ms;发现+装配 8.1 ms;整进程启动+装配 4040 ms(其中冷装配 ≈2963 ms,整进程启动本身就 1077 ms)常驻 runner 里换能力选择只付 S2(≈8 ms),不付 S6b(≈4 s)⇒ 差 ~500×;那 4 s 里 ~1.1 s 是 tsx 启动,与框架无关npm run bench:assembly
MCP 连接器什么时候付钱冷 279.5 ms;热(复用连接器)0.3 ms只有重建连接器才付冷的那一笔 ⇒ 差 ~900×npm run bench:assembly
trace 记不记内容,差多少大出参(≈37 KB/次)5 次调用:缺省 17.2 KB / 不截断 233.9 KB(13.6×)/ 截断 200 6.2 KB缺省已经截断;traceContent:'full' 是按出参字节付费的开关(小出参档实测只差 1.0×)PAYLOAD_ROWS=1000 CALLS='[5,20]' npm run bench:trace
指标快照 / OTLP flush 的成本snapshot() 6.56 ms;buildOtlpPayload() 0.073 msflush 的成本几乎全在 snapshot() ⇒ 省掉它 ≈ 6.5 ms/次,与 flush 频率成正比npm run bench:otlp
挂起任务的到期扫描sqlite N=10000:全表 list() 61.9 ms / 到期索引 listDue 0.1 ms有没有索引是结构性的 ⇒ 差 ~600×,不是调优问题npx tsx scripts/bench-resume-scan.ts 10000
redis store 的 list() 形状N=10000:往返 10001 次、解析 42.1 MB、纯 CPU 104.1 ms;有辅助 ZSET ⇒ 往返 1001往返次数是结构量(换实现能省掉的就是它);延迟只做外推(本机无 redis 实例)npx tsx scripts/bench-redis-due.ts 10000

⚠️ 毫秒不是承诺:它们随机器 / Node 版本 / 负载而变,抄走当 SLA 会错。 真正恒定的是形状(差多少倍、往返几次、哪一笔跟什么成正比)。 五个基准刻意不进 CI(计时类基准在 CI 机器上只会制造抖动) ⇒ 这些数没有守卫盯着,要看就按「复跑」那一列自己跑一遍。