dsh-graph-runtime
English | 中文
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
# 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):
- id: graph-runtime
name: dsh-graph-runtime
Usage
Graph authors only need defineGraph — defining is registering; no mount
or service call is required:
// 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.
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:
// 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:
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)
// 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
descriptions 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.