# dsh-graph-runtime

[English](README.md) | 中文

DeepSeek Harness（DSH）的 graph 运行时能力集，以独立 Cordis 插件/bundle 形式发布。

当前能力：

- **图定义与自动挂载**：以 `defineGraph` 定义"扩展 StateGraph"——图工厂 +
  注册声明（是否成为 DSH tool、是否登记注册表）+ 工具入参校验钩子。模块被
  加载即完成注册：插件启动时自动挂载已定义的图，其后定义的即时挂载，开发者
  无需任何 service 调用。工具名/描述自动发现（`compile({ name, description })`
  或结构合成），编译惰性且记忆化（注册路径零编译副作用），参数校验、取消转发
  与结果渲染都由本插件承担。
- **GraphRoutingAgent**：graph 驱动的路由 Agent。把 graphTools 注册表（可
  过滤）交给 LLM，要求必须选择一个工具；对返回的工具调用做存在性、JSON、
  schema 与作者 `validate` 校验，不通过带原因重试（默认 3 次，可配置），
  通过后执行对应 graph。每次被选中工具的执行前后有 `graphTools/pre-execute` /
  `graphTools/post-execute` 两个 waterfall 扩展点（门禁、审计、结果加工）。

## 安装与接入

```sh
# 发布后
npm install dsh-graph-runtime
# 或本地路径（例如在 yolo-agent 仓库内）
npm install file:../dsh-graph-runtime
```

接入方式与其他 DSH bundle 一致，二选一或同时：

- 根组合 `cordis.yml` 增加一行（本包自带 `cordis.patch.yml`，web profile 的
  `dsh.profile.bundles` 加入包名后同样自动挂载）：

  ```yaml
  - id: graph-runtime
    name: dsh-graph-runtime
  ```

## 用法

图作者只需 `defineGraph`——定义即注册，不需要任何 mount 或 service 调用：

```ts
// graphs/echo.ts
import { defineGraph } from 'dsh-graph-runtime'
import { Annotation, END, START, StateGraph } from '@langchain/langgraph'

const State = Annotation.Root({
  topic: Annotation<string>,
  log: Annotation<string[]>({
    reducer: (left, right) => [...left, ...right],
    default: () => [],
  }),
})

export const echoGraph = defineGraph({
  name: 'echo_graph',
  description: 'Echo the given topic.',
  build: () =>
    new StateGraph(State)
      .addNode('echo', async (state) => ({ log: [`seen:${state.topic}`] }))
      .addEdge(START, 'echo')
      .addEdge('echo', END),
  asTool: true, // 声明为模型可见的 DSH tool；默认 false
  // asGraphTool: true, // 登记进 graphTools 注册表供发现；默认 true，可省
  // validate: (input) => Boolean((input as { topic?: string }).topic), // 入参校验钩子，缺省恒通过
})
```

唯一的接线要求：graph 模块需要在某个 bundle 入口被 import（barrel 一行
`import './graphs'` 即可）。插件 `apply` 时自动挂载已定义的全部图，其后
定义的即时挂载；单个图挂载失败（重名、非法声明）告警跳过，不阻断启动。

`asTool: true` 的工具注册在插件的全局层：所有 agent（含默认 agent）可见。
需要 scope 私有或运行时动态构造的场景，使用下述命令式入口。

## GraphRoutingAgent

参考 dsh `ReactLoopAgent` 裁剪为单步：一次路由 = LLM 选工具 → 校验 →
（可选重试）→ 执行 graph。

```ts
import { GraphRoutingAgent } from 'dsh-graph-runtime'

const router = new GraphRoutingAgent(ctx, {
  provider: 'deepseek',
  model: 'deepseek-chat',
  filter: { allow: ['echo_graph', 'search_graph'] }, // 同 tools.restrict 的 allow/deny
  maxRetries: 3, // 校验不通过时重新调用 LLM 的最大次数，默认 3
  // fallback: myFallbackTool, // 可选：覆盖内置兜底 tool
})

const outcome = await router.route({ input: '帮我回显一下 hello' })
// outcome: { tool: 'echo_graph', args: {...}, result: <graph 最终 state> }
```

行为细节：

- **必须选工具**：dsh-llm 的 `GenerateOptions` 没有 toolChoice 字段，
  "required" 语义由循环兜底——模型未选择任何工具（纯文本回复）时不重试，
  直接执行兜底 tool 并在结果上标记 `fallback: true`。缺省兜底是内置的
  `graph_routing_fallback`（什么都不做：空入参、返回 `{}`）；可通过
  `fallback` 传入 `ToolDefinition` 或 `GraphDefinition` 覆盖，覆盖者自行
  保证空入参 `{}` 能通过其 schema（如 `parameters: {}`）。
- **校验链**（任一不通过即带原因重试）：工具存在于（过滤后的）注册表；
  参数是合法 JSON；执行期 schema 校验（`ToolArgsError`）；作者 `validate`
  钩子（`GraphValidationError`，经路由时携带 `context.request` 可做语义
  拒绝）。graph 自身的运行失败不是路由失败，原样上抛。
- **过滤**：`filter` 的 allow/deny 语义同 `tools.restrict`（可同给取交集）；
  名单含未知工具或过滤后为空视为配置错误。
- 重试用尽后以最后一次失败原因收场。

### 路由扩展点

被路由的执行不走 dsh 的 `ctx.tools` 管线（那是另一套注册面），管线
形态的扩展点因此落在路由循环自身：以 cordis waterfall 事件提供，每个
被选中工具的尝试在 graph 执行前经过 `graphTools/pre-execute`、在执行
落定后经过 `graphTools/post-execute`。审计日志、指标、策略门禁、结果
加工都挂在这两个事件上：

```ts
// 执行前门禁：deny 的原因会反馈给模型重试，与 validate 拒绝完全一致。
ctx.on('graphTools/pre-execute', async (execution, next) => {
  if (disabledTools.has(execution.tool)) {
    return { kind: 'deny', reason: `"${execution.tool}" 已被策略禁用` }
  }
  return next()
})

// 观察或改写落定结果；成功与失败都会经过。
ctx.on('graphTools/post-execute', async (execution, outcome, next) => {
  if (execution.tool === 'search_graph' && !('error' in outcome)) {
    return { kind: 'accept', result: redact(outcome.result) } // 替换返回值
  }
  return next()
})
```

语义：

- `execution` 携带 `tool` / `args` / `request`（原始路由文本）/
  `signal`；同一次尝试的两个事件收到同一个 execution 对象。
- waterfall 顺序：监听器按注册顺序执行；`next()` 表示放行并委托后续，
  不调 `next()` 直接作答则否决余下监听器——第一个决策生效。
- `deny`（pre）与 `block`（post）并入常规重试循环：原因反馈给模型，
  重试用尽后抛标准错误。`block` 拒绝的是任何已落定的结果——也可以把
  本会上抛的 graph 错误转化为一次带原因的重试。
- `accept` 维持落定结果；携带 `result` 时替换返回给调用方的值（各监听
  器观察到的仍是原值）。
- 兜底路径（模型未选工具）不经过这两个事件；如需在兜底上加钩子，请
  自行包装 fallback 工具的 `execute`。监听器抛错会原样上抛。监听器用
  普通 `ctx.on` 注册（与任何 cordis 事件一致）；监听器内 `this` 是
  Agent 所在的 ctx。

## 注册声明

| 字段 | 说明 |
| --- | --- |
| `asTool` | 是否注册为模型可见的 DSH tool（`ctx.tools`）。默认 `false`；自动挂载路径注册在插件全局层（所有 agent 可见），命令式 `mount` 路径注册在传入 ctx 的层。 |
| `asGraphTool` | 是否登记进 graphTools 注册表供发现（`get`/`list`、GraphRoutingAgent）。默认 `true`。 |

## `defineGraph` 字段

| 字段 | 说明 |
| --- | --- |
| `name` | 图名，即注册后的 tool 名；全局唯一，禁用保留名 `run_code`。 |
| `description` | 可选。面向模型的工具描述；缺省时取 `build()` 产物上 `compile({ description })` 写入的描述（作者自行编译时），或由节点与 state 键合成的结构描述。 |
| `build` | 图工厂：返回未编译 builder（插件在首次调用时 plain `compile()`，不传任何选项），或作者自行编译的实例。checkpointer 等编译选项完全归作者所有——要 state 记忆就返回 `builder.compile({ checkpointer: new MemorySaver() })`。 |
| `validate` | 可选的工具入参校验钩子：graph 执行前收到 `(input, context)`，`context.tool` 是工具名，经 GraphRoutingAgent 路由时 `context.request` 携带原始请求文本（直连调用时无此字段）。返回 `false` 或抛错即拒绝（抛错 message 进入拒绝原因）；缺省恒通过。据此可实现"这个请求不属于我"的语义拒绝，路由循环会带原因重试。 |
| `parameters` | 可选的 DSH `ParameterSchemaSpec`。提供时整个参数对象作为 graph 输入；缺省时工具暴露唯一的必填 `input` JSON 参数（其描述会拼上结构发现的 state 键名，但值本身不透明）。模型可见的图应始终声明，见下节。 |
| `threadId` | 可选的线程键；仅透传为 graph config 的 `thread_id`。是否形成 state 记忆由 graph 自带的 checkpointer 决定（插件不代管）。 |
| `configurable` | 可选的 `configurable` 附加键透传（`thread_id` 以 `threadId` 为准）。 |
| `timeoutMs` | 可选的协作式超时预算；graph 必须响应取消信号并收敛。 |

## 参数 schema 质量（模型可见的图必读）

`parameters` 原样透传为 DSH `ParameterSchemaSpec` 并在执行前强制校验
（越界枚举、类型不符、缺 required 都会被 `ToolArgsError` 拦下）。给模型
看的图，请保证每个参数**类型明确、描述具体、枚举列全**：

```ts
defineGraph({
  name: 'greet_graph',
  build: () => buildGreetGraph(),
  parameters: {
    name: { type: 'string', required: true, description: 'The name to greet.' },
    style: {
      type: 'string',
      required: true,
      enum: ['formal', 'casual'],
      description: 'Greeting style.',
    },
    times: { type: 'integer', description: 'Repeat count.', default: 1 },
    tags: { type: 'array', items: { type: 'string' }, description: 'Extra tags.' },
  },
})
```

支持的能力：`type` 取 `string / number / integer / boolean / null / array /
object / json`，联合用 `oneOf`；每个键可带 `description / title / default /
examples`；枚举用与类型匹配的 `enum`（如字符串数组），单值约束用 `const`；
嵌套对象用 `object` + `properties` + `additionalProperties`，数组用 `items`；
逐键 `required: true` 标注必填。

为什么不自动派生：langgraph 在编译时即丢弃 `Annotation` 的类型信息，
运行时结构只剩键名与聚合语义——伪造类型或 required 的 schema 会误导模型
拼出被校验层拒绝的参数，比诚实的 `input` 更糟。因此未声明 `parameters`
的兜底是单一必填 `input`（json），结构发现的 state 键会拼进它的描述
（如 `Expected state keys: topic, log (accumulated).`）；`asTool: true`
且未声明参数时，挂载路径会经 `ctx.logger` 打一条 warn 提醒作者。

## 命令式入口（动态/低层场景）

```ts
// 运行时动态构造的图：createGraphDefinition 只构造不发布（defineGraph
// 会自动挂载，插件活跃时再显式 mount 会双重注册），配合显式 mount；
// ctx 是 fiber 归属与 tools 注册落点。
const dynamicGraph = createGraphDefinition({
  name: 'dynamic_flow',
  build: () => buildDynamicGraph(),
  asTool: true,
})
const mounted = ctx.graphTools.mount(dynamicGraph, ctx)

// 发现侧：ctx.graphTools.get('echo_graph') / ctx.graphTools.list()

// 手里已是编译产物、或只想一次性转换：
const entry = ctx.graphTools.register(compiledGraph, ctx)
const tool = ctx.graphTools.create(compiledGraph)
```

也可以直接 `import { createGraphTool } from 'dsh-graph-runtime'` 做纯转换，
效果等价。向 `register` 传图定义会得到指向 `mount` 的错误——图定义自带
注册声明。`defineGraph` 面向图作者（声明即发布、自动挂载）；
`createGraphDefinition` 面向运行时动态场景（构造归构造，挂载归挂载）。

## 行为契约

- 定义即注册：`defineGraph` 把图定义发布到包级静态队列；插件 `apply` 时
  接管并清空积压，其后定义的图即时挂载。单个图挂载失败（重名、非法声明）
  经 `ctx.logger` 告警跳过，不阻断启动；插件释放后 defineGraph 重新入队，
  可随插件重载再次挂载。
- 注册只是注册：`defineGraph`/`mount`/`register` 全程不调用 langgraph 的
  `compile()`；未编译 builder 在该 tool 首次实际调用时才 plain `compile()`
  一次并记忆化（结构发现只读取 builder 的 nodes/channels，同样不触发编译）。
- checkpointer 归 graph：插件从不注入或代管编译选项；`threadId` 仅是
  `thread_id` 透传，记忆是否生效取决于作者在 `build()` 里返回的实例。
- 每次工具调用即一次 `graph.invoke`；工具入参先经 DSH 参数 schema 校验与
  作者 `validate` 钩子，再进入 graph；调用方的 `AbortSignal` 原样转发。
- graph 最终 state 必须是 lossless JSON，否则以明确错误拒绝；工具结果以
  pretty JSON 文本块返回给模型。
- 与 `ctx.tools` 的交互只发生在声明 `asTool: true` 时：自动挂载路径注册在
  插件的全局层（所有 agent 含默认 agent 可见）；命令式 `mount(definition,
  ctx)` 注册在传入 ctx 的层（agent scoped ctx 上则仅该 agent 可见并 shadow
  全局同名工具）。`graphTools` 注册表是独立状态：登记名唯一、`unregister`
  幂等、随挂载路径的 fiber 释放自动移出。
- 自动发现的边界：`Annotation` 的类型信息与 `addNode` 的 `description` 在
  langgraph 编译时即被丢弃，参数 schema 无法自动派生。自定义 `GraphInvocable`
  缺少运行时字段时发现安全降级，但名字必须显式提供。
