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
validatechecks, retrying with the rejection reason on failure (default 3, configurable); once it passes, the corresponding graph executes. Every chosen-tool execution is wrapped by thegraphTools/pre-execute/graphTools/post-executewaterfall 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 owncordis.patch.yml; adding the package name to the web profile'sdsh.profile.bundlesauto-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
GenerateOptionshas 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 markedfallback: true. The default fallback is the built-ingraph_routing_fallback(a no-op: empty input, returns{}); override it throughfallbackwith aToolDefinitionor aGraphDefinition, 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'svalidatehook (GraphValidationError, withcontext.requestavailable during routing for semantic rejection). A graph's own runtime failure is not a routing failure and propagates as-is. - Filtering:
filterfollows the same allow/deny semantics astools.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:
executioncarriestool/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 withoutnext()vetoes the rest — the first decision wins. deny(pre) andblock(post) join the ordinary retry loop: the reason is fed back to the model and retries exhaust into the standard error. Ablockrejects any settled outcome — it can also convert a propagating graph error into a retried choice instead of an error.acceptkeeps the settled outcome; withresultit 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 plainctx.onlike any cordis event; inside a listener,thisis 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:
defineGraphpublishes the graph definition to a package-level static queue; at pluginapplythe queue is taken over and drained, and later definitions mount immediately. A single graph failing to mount (duplicate name, invalid declaration) is skipped with actx.loggerwarning 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/registernever call langgraph'scompile(); 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;
threadIdis only athread_idpass-through, and whether memory takes effect depends on the instance the author returns frombuild(). - Each tool call is one
graph.invoke; tool arguments pass DSH parameter-schema validation and the author'svalidatehook before entering the graph, and the caller'sAbortSignalis 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.toolshappens only whenasTool: trueis declared: the auto-mount path registers at the plugin's global layer (visible to every agent including the default agent); imperativemount(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). ThegraphToolsregistry is separate state: listed names are unique,unregisteris idempotent, and entries leave automatically with the disposing fiber of the mounting path. - Limits of auto-discovery:
Annotationtype information andaddNodedescriptions are dropped when langgraph compiles, so a parameter schema cannot be derived automatically. Discovery degrades safely for a customGraphInvocablemissing runtime fields, but the name must be provided explicitly.
No comments yet. Be the first to write one.