Bundle
dsh-graph-runtime
Graph runtime capabilities for the DeepSeek Harness: register LangGraph compiled graphs as DSH tools, plus a graph-driven routing agent.
- Source
- snail30xx
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 17 hours ago
Readme
# dsh-graph-runtime
English | [中文](README.zh.md)
Graph runtime capabilities for the DeepSeek Harness (DSH), published as a
standalone Cordis plugin/bundle.
Current capabilities:
- **Graph definition and auto-mounting**: define an "extended StateGraph"
with `defineGraph` — a graph factory plus a registration declaration
(whether it becomes a DSH tool, whether it joins the registry) plus a
tool-argument validation hook. Loading the module completes registration:
at plugin startup the already-defined graphs auto-mount, and later
definitions mount immediately; developers make no service calls at all.
Tool name/description are auto-discovered (`compile({ name, description })`
or structural synthesis), compilation is lazy and memoized (the
registration path has zero compile side effects), and parameter
validation, cancellation forwarding, and result rendering are all owned by
this plugin.
- **GraphRoutingAgent**: a graph-driven routing agent. It hands the
graphTools registry (optionally filtered) to the LLM and requires a tool
to be chosen; the returned tool call goes through existence, JSON,
schema, and author `validate` checks, retrying with the rejection reason
on failure (default 3, configurable); once it passes, the corresponding
graph executes. Every chosen-tool execution is wrapped by the
`graphTools/pre-execute` / `graphTools/post-execute` waterfall extension
points (gating, auditing, result transforms).
## Installation and wiring
```sh
# After publishing
npm install dsh-graph-runtime
# Or from a local path (e.g. inside the yolo-agent repo)
npm install file:../dsh-graph-runtime
```
Wiring follows the same steps as any other DSH bundle, either or both:
- Add one entry to the root composition `cordis.yml` (this package ships
its own `cordis.patch.yml`; adding the package name to the web profile's
`dsh.profile.bundles` auto-mounts it the same way):
```yaml
- id: graph-runtime
name: dsh-graph-runtime
```
## Usage
Graph authors only need `defineGraph` — defining is registering; no mount
or service call is required:
```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, // Declared as a model-visible DSH tool; default false
// asGraphTool: true, // Also listed in the graphTools registry for discovery; default true, can be omitted
// validate: (input) => Boolean((input as { topic?: string }).topic), // Argument-validation hook; defaults to always passing
})
```
The only wiring requirement: a graph module must be imported by some bundle
entry (a single barrel line `import './graphs'` suffices). At plugin
`apply`, every already-defined graph auto-mounts, and later definitions
mount immediately; a single graph failing to mount (duplicate name, invalid
declaration) warns and is skipped without blocking startup.
Tools declared `asTool: true` register at the plugin's global layer: visible
to every agent (including the default agent). For scope-private or
runtime-constructed cases, use the imperative entries below.
## GraphRoutingAgent
Trimmed from dsh's `ReactLoopAgent` to a single routing step: one routing
round = the LLM picks a tool → validation → (optional retry) → the graph
executes.
```ts
import { GraphRoutingAgent } from 'dsh-graph-runtime'
const router = new GraphRoutingAgent(ctx, {
provider: 'deepseek',
model: 'deepseek-chat',
filter: { allow: ['echo_graph', 'search_graph'] }, // allow/deny, same as tools.restrict
maxRetries: 3, // Maximum LLM re-calls after a failed validation; default 3
// fallback: myFallbackTool, // Optional: overrides the built-in fallback tool
})
const outcome = await router.route({ input: 'echo hello for me' })
// outcome: { tool: 'echo_graph', args: {...}, result: <the graph's final state> }
```
Behavior details:
- **A tool must be chosen**: dsh-llm's `GenerateOptions` has no toolChoice
field, so the "required" semantics are enforced by the loop — when the
model picks no tool (a plain-text reply) there is no retry; the fallback
tool runs instead and the outcome is marked `fallback: true`. The default
fallback is the built-in `graph_routing_fallback` (a no-op: empty input,
returns `{}`); override it through `fallback` with a `ToolDefinition` or a
`GraphDefinition`, and the overrider is responsible for keeping empty
input `{}` valid under its schema (e.g. `parameters: {}`).
- **The validation chain** (any failure retries with the reason): the tool
exists in the (filtered) registry; the arguments are valid JSON;
execution-time schema validation (`ToolArgsError`); the author's
`validate` hook (`GraphValidationError`, with `context.request` available
during routing for semantic rejection). A graph's own runtime failure is
not a routing failure and propagates as-is.
- **Filtering**: `filter` follows the same allow/deny semantics as
`tools.restrict` (both may be given; they intersect); a list naming
unknown tools or an empty result after filtering is a configuration
error.
- Exhausted retries end with the last failure reason.
### Routing extension points
Routed executions do not pass through dsh's `ctx.tools` pipeline (the
registry there is a separate surface), so the pipeline-shaped extension
points live on the routing loop itself as cordis waterfall events: every
chosen-tool attempt traverses `graphTools/pre-execute` before the graph
runs and `graphTools/post-execute` after it settles. Audit logging,
metrics, policy gating, and result transforms all hang off them:
```ts
// Gate before execution: a deny feeds the reason back to the model for a
// retry, exactly like a validate rejection.
ctx.on('graphTools/pre-execute', async (execution, next) => {
if (disabledTools.has(execution.tool)) {
return { kind: 'deny', reason: `"${execution.tool}" is disabled by policy` }
}
return next()
})
// Observe or reshape the settled outcome; fires on successes and failures alike.
ctx.on('graphTools/post-execute', async (execution, outcome, next) => {
if (execution.tool === 'search_graph' && !('error' in outcome)) {
return { kind: 'accept', result: redact(outcome.result) } // replace the returned value
}
return next()
})
```
Semantics:
- `execution` carries `tool` / `args` / `request` (the original routing
text) / `signal`; the same object reaches both events of one attempt.
- Waterfall order: listeners run in registration order; `next()` delegates,
and answering without `next()` vetoes the rest — the first decision wins.
- `deny` (pre) and `block` (post) join the ordinary retry loop: the reason
is fed back to the model and retries exhaust into the standard error. A
`block` rejects any settled outcome — it can also convert a propagating
graph error into a retried choice instead of an error.
- `accept` keeps the settled outcome; with `result` it replaces the value
returned to the caller (listeners still observed the original).
- The fallback path (no tool chosen) traverses neither event; if you need
hooks there, wrap your fallback tool's `execute`. A throwing listener
propagates as-is. Listeners register with plain `ctx.on` like any cordis
event; inside a listener, `this` is the agent's ctx.
## Registration declaration
| Field | Meaning |
| --- | --- |
| `asTool` | Whether to register as a model-visible DSH tool (`ctx.tools`). Default `false`; the auto-mount path registers at the plugin's global layer (visible to all agents), the imperative `mount` path registers at the layer of the ctx passed in. |
| `asGraphTool` | Whether to list in the graphTools registry for discovery (`get`/`list`, GraphRoutingAgent). Default `true`. |
## `defineGraph` fields
| Field | Meaning |
| --- | --- |
| `name` | The graph name, which becomes the registered tool name; globally unique; the reserved name `run_code` is rejected. |
| `description` | Optional. The model-facing tool description; defaults to the description written by `compile({ description })` on the `build()` product (when the author compiled it), or a structural description synthesized from nodes and state keys. |
| `build` | Graph factory: returns an uncompiled builder (the plugin plain-`compile()`s it at first invocation, passing no options), or an author-compiled instance. Checkpointer and all compile options belong entirely to the author — for state memory, return `builder.compile({ checkpointer: new MemorySaver() })`. |
| `validate` | Optional argument-validation hook: receives `(input, context)` before the graph runs; `context.tool` is the tool name, and when routed through GraphRoutingAgent `context.request` carries the original request text (absent on direct calls). Returning false or throwing rejects (a thrown message becomes the rejection reason); defaults to always passing. This enables "this request is not mine" semantic rejection, which the routing loop retries with the reason. |
| `parameters` | Optional DSH `ParameterSchemaSpec`. When provided, the whole argument object becomes the graph input; when omitted, the tool exposes a single required `input` JSON parameter (its description is appended with the discovered state key names, but the value itself stays opaque). Model-visible graphs should always declare it — see the next section. |
| `threadId` | Optional thread key; forwarded as-is into the graph config's `thread_id`. Whether it produces state memory is decided by the graph's own checkpointer (the plugin manages none). |
| `configurable` | Optional extra `configurable` keys forwarded as-is (`thread_id` is governed by `threadId`). |
| `timeoutMs` | Optional cooperative timeout budget; the graph must respond to the cancellation signal and converge. |
## Parameter schema quality (required reading for model-visible graphs)
`parameters` is forwarded as-is as a DSH `ParameterSchemaSpec` and enforced
before execution (out-of-range enums, type mismatches, and missing requireds
are all stopped by `ToolArgsError`). For graphs a model will see, keep every
parameter **explicitly typed, concretely described, and fully enumerated**:
```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.' },
},
})
```
Supported capabilities: `type` is one of `string / number / integer /
boolean / null / array / object / json`, unions use `oneOf`; every key may
carry `description / title / default / examples`; enums use an `enum`
matching the type (e.g. a string array), single-value constraints use
`const`; nested objects use `object` + `properties` +
`additionalProperties`, arrays use `items`; mark required keys individually
with `required: true`.
Why nothing is derived automatically: langgraph drops `Annotation` type
information at compile time, leaving only key names and aggregation
semantics in the runtime structure — a schema with fabricated types or
requireds would mislead the model into assembling arguments the validation
layer then rejects, which is worse than an honest `input`. So the fallback
without declared `parameters` is a single required `input` (json), whose
description carries the discovered state keys (e.g. `Expected state keys:
topic, log (accumulated).`); when `asTool: true` and no parameters are
declared, the mount path logs a warn through `ctx.logger` to nudge the
author.
## Imperative entries (dynamic / low-level scenarios)
```ts
// A runtime-constructed graph: createGraphDefinition constructs without
// publishing (defineGraph auto-mounts, so an explicit mount while the
// plugin is active would double-register), paired with an explicit mount;
// ctx is both the fiber owner and the ctx.tools registration target.
const dynamicGraph = createGraphDefinition({
name: 'dynamic_flow',
build: () => buildDynamicGraph(),
asTool: true,
})
const mounted = ctx.graphTools.mount(dynamicGraph, ctx)
// Discovery side: ctx.graphTools.get('echo_graph') / ctx.graphTools.list()
// Already holding a compiled graph, or only want a one-off conversion:
const entry = ctx.graphTools.register(compiledGraph, ctx)
const tool = ctx.graphTools.create(compiledGraph)
```
`import { createGraphTool } from 'dsh-graph-runtime'` also does the pure
conversion directly, with the same effect. Passing a graph definition to
`register` fails with an error pointing at `mount` — graph definitions
carry their own registration declaration. `defineGraph` targets graph
authors (declare-and-publish, auto-mount); `createGraphDefinition` targets
runtime-dynamic scenarios (construction stays construction, mounting stays
mounting).
## Behavior contract
- Defining is registering: `defineGraph` publishes the graph definition to
a package-level static queue; at plugin `apply` the queue is taken over
and drained, and later definitions mount immediately. A single graph
failing to mount (duplicate name, invalid declaration) is skipped with a
`ctx.logger` warning and never blocks startup; after the plugin disposes,
defineGraph queues again and can remount with the next plugin load.
- Registration is only registration: `defineGraph`/`mount`/`register` never
call langgraph's `compile()`; an uncompiled builder is plain-`compile()`d
exactly once at the tool's first real invocation and memoized (structural
discovery only reads the builder's nodes/channels and likewise never
compiles).
- The checkpointer belongs to the graph: the plugin never injects or
manages compile options; `threadId` is only a `thread_id` pass-through,
and whether memory takes effect depends on the instance the author
returns from `build()`.
- Each tool call is one `graph.invoke`; tool arguments pass DSH
parameter-schema validation and the author's `validate` hook before
entering the graph, and the caller's `AbortSignal` is forwarded as-is.
- The graph's final state must be lossless JSON, or it is rejected with an
explicit error; the tool result returns to the model as a pretty-printed
JSON text block.
- Interaction with `ctx.tools` happens only when `asTool: true` is
declared: the auto-mount path registers at the plugin's global layer
(visible to every agent including the default agent); imperative
`mount(definition, ctx)` registers at the layer of the passed ctx (on an
agent-scoped ctx, visible only to that agent, shadowing a same-named
global tool). The `graphTools` registry is separate state: listed names
are unique, `unregister` is idempotent, and entries leave automatically
with the disposing fiber of the mounting path.
- Limits of auto-discovery: `Annotation` type information and `addNode`
`description`s are dropped when langgraph compiles, so a parameter schema
cannot be derived automatically. Discovery degrades safely for a custom
`GraphInvocable` missing runtime fields, but the name must be provided
explicitly.
Install
dsh plugin --profile web add github:snail30xx/dsh-graph-runtime
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-graph-runtime from the hub
- This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.