CaiZongyuan/dsh-ag-ui

dsh-ag-ui

AG-UI protocol gateway plugin for DeepSeek Harness

待分类ag-uicordisdeepseek-harness

dsh-ag-ui

English | 简体中文

A community DeepSeek Harness Host plugin that exposes DSH Agents through the AG-UI protocol. It provides an authenticated HTTP/SSE Gateway, AG-UI thread-to-DSH Agent bindings, streamed text and Tool events, browser-owned Tools, and continuation of the same DSH turn after a browser Tool result returns. The same projection core is also available in an embedding form: the separate dsh-ag-ui-adapter package spawns a private loopback micro-host behind an AG-UI AbstractAgent.

This is a community project. It is not an official DeepSeek or AG-UI package.

Features

  • Standard Cordis Service plugin exposed as ctx.agUi
  • Transport-neutral Agent-scoped browser Tool broker exposed as ctx.browserTools
  • Installable DSH Profile Bundle through dsh plugin add
  • Floored AG-UI protocol range (~0.0.59)
  • Authenticated BFF-to-Gateway requests with trusted tenant and user headers
  • Streamed per-thread file upload and authenticated download routes
  • Process-local (tenantId, userId, threadId) bindings to DSH Agents
  • AG-UI text streaming and backend Tool result projection
  • Agent-scoped browser Tools supplied by RunAgentInput.tools
  • Frontend Tool Promise parking and ToolMessage continuation across HTTP runs
  • Bidirectional shared state through RunAgentInput.state, ag_ui_update_state, and STATE_SNAPSHOT
  • Presenter cards for backend Tool calls as versioned dsh:tool:view CUSTOM events, live and on cold replay
  • React renderers for every card kind in the separate dsh-ag-ui-cards package, with component tests against recorded gateway events
  • An embedding adapter in the separate dsh-ag-ui-adapter package that spawns a loopback DSH micro-host and serves it as an AG-UI AbstractAgent
  • A keyless Dojo-compatible example for five standard AG-UI features
  • Run and message idempotency
  • Bounded requests, context, Tool schemas, event buffers, threads, and run ledgers
  • Complete Cordis disposal of routes, Agents, Tools, timers, and pending calls

Requirements

  • Node.js ^22.19.0 or >=24.0.0
  • A DSH Profile containing the standard Host services
  • A registered DSH model provider and model
  • An authenticated Backend-for-Frontend that keeps the Gateway secret away from browsers

Installation

Install the bundle into a DSH Profile:

dsh plugin --profile web add dsh-ag-ui

For the GitHub checkout before an npm release:

dsh plugin --profile web add github:CaiZongyuan/dsh-ag-ui

The bundle always mounts the lightweight browser-tools row. The AG-UI Gateway row stays dormant until all required environment variables are present, so native DSH integrations can lease browser-owned Tools without configuring a second model route or Gateway secret.

export DSH_AG_UI_PROVIDER='openai'
export DSH_AG_UI_MODEL='gpt-5.6-sol'
export DSH_AG_UI_SHARED_SECRET="$(openssl rand -hex 32)"
export DSH_AG_UI_PATH='/ag-ui' # optional

dsh --profile web

The bundle inserts an always-on browser-tools row and a conditional Host-plane ag-ui row. The first never creates an Agent: another integration selects an existing Agent and supplies a browser transport.

Browser Tool broker

dsh-ag-ui/browser-tools hides provider-safe name checks, object-rooted schema validation, exact Agent-scope registration, catalog replacement, collisions, cancellation, timeout, and lease teardown behind one interface. A caller owns Agent selection and transport:

const lease = ctx.browserTools.bind(agent, owner, tools, {
  invoke: (call, signal) => browserTransport.invoke(call, signal),
})

lease.update(nextTools)
lease.dispose()

Browser context and Tool results are capability data, not authorization. Durable actions still require server-owned identity, resource checks, and any domain confirmation flow.

Profile configuration

Environment variables are the shortest setup path. A Profile can instead override the bundle row in its own cordis.patch.yml:

- id: ag-ui
  disabled: false
  config:
    provider: openai
    model: gpt-5.6-sol
    sharedSecret: !!js process.env.DSH_AG_UI_SHARED_SECRET
    path: /ag-ui
    maxThreads: 100
    frontendToolTimeoutMs: 300000

A later Profile patch replaces the bundle row's complete config; include every value that deployment needs.

Presented deliverables

Harness present declarations become standard AG-UI ACTIVITY_SNAPSHOT events with activityType: "dsh-deliverables". Their activity messages survive history reads and restart with the same id, derived from the native session and event sequence. Nested present calls are included even if their enclosing tool later fails.

Activity content preserves native turn, callId, and files: [{ path, description? }], adding a relative url to each file. A trusted BFF must proxy this URL with the same authenticated tenant and user headers as agent runs:

GET /ag-ui/threads/:threadId/deliverables/:eventSeq/files/:fileIndex

The route reads only a declaration from that authenticated thread. It uses the Session's native filesystem and persisted cwd, including absolute paths the provider permits. Preset-isolated filesystems are resolved through the native preset roster; the host filesystem is used only when that preset supplies none. The host must supply @deepseek-ai/dsh-fs in that Agent scope and mount @deepseek-ai/dsh-tool-present where the tool should be available. No attachment store or upload receipt is needed for a deliverable. The response downloads the current file as an attachment with Cache-Control: no-store; it does not archive the original bytes. Deleted files and non-regular files return 404, provider access denials return 403, and files exceeding maxFileBytes return 413. Reads are bounded and cancelled when the client disconnects. The URLs are authenticated references, not public sharing links.

Render dsh-deliverables activities in the client's transcript. They do not turn generic file tool results or client-supplied tool messages into declared deliverables.

Configuration

provider, model, and sharedSecret are required. sharedSecret must contain at least 16 UTF-8 bytes.

FieldDefaultPurpose
path/ag-uiBase Host HTTP route for runs and files
providerrequiredRegistered DSH model provider route
modelrequiredModel ID owned by the provider
workspaceRoot<DSH_HOME>/workspacesRoot for per-thread workspace directories, named by durable session id
agentPresetnoneDeployment-default agent preset id composed into every thread
tenantPresets{}Per-tenant preset ids taking precedence over agentPreset
selectableAgentPresets{}Canonical preset ids each authenticated tenant may select for a blank thread
sharedSecretrequiredBearer secret shared only with the trusted BFF
tenantHeaderx-dsh-tenant-idTrusted tenant identity header
userHeaderx-dsh-user-idTrusted user identity header
allowNonLoopbackfalsePermit a non-loopback Host bind explicitly
maxRequestBytes262144Maximum request body bytes
maxFileBytes104857600Maximum bytes per uploaded file or deliverable download
maxIdentityBytes256Maximum bytes per protocol or identity ID
maxMessages256Maximum message count per request
maxMessageBytes524288Maximum combined message JSON bytes
maxFilesPerMessage8Maximum non-text parts in one user message
maxContexts32Maximum context entry count
maxContextBytes131072Maximum combined context JSON bytes
maxTools32Maximum browser Tool count
maxToolBytes131072Maximum browser Tool JSON bytes
maxToolSchemaDepth16Maximum browser Tool schema depth
maxForwardedPropsBytes65536Maximum forwardedProps JSON bytes
maxStateBytes65536Maximum state JSON bytes
maxThreads100Maximum process-local live threads
threadIdleMs1800000Idle thread lifetime
frontendToolTimeoutMs300000Maximum browser Tool result wait
humanInteractionTimeoutMs300000Maximum wait for each native human request; integer from 1 to 2147483647 ms
maxPendingInterrupts16Maximum live human requests per thread
maxRunEvents4096Maximum events retained per run
maxRunEventBytes2097152Maximum retained event bytes per run
maxRunsPerThread32Maximum retained run ledger entries and, separately, waiting requests per thread

agentPreset composes each thread's agent from the host's agent-preset registry (mount the roster plugin before this Gateway); an unresolvable id fails Gateway activation loudly, a per-tenant entry overrides the deployment default for that tenant's threads, and a resumed thread keeps the composition its own durable session recorded. Without agentPreset, threads keep the host composition unchanged.

Each thread uses <workspaceRoot>/<sessionId> as its DSH working directory. The directory is named by the durable session id, so client thread ids stay off disk. When the Host provides workspaceRegistry, the Gateway registers new workspaces for DSH Web.

File routes require the official fileUploads and attachments services, already mounted by @deepseek-ai/dsh-web-app. POST <path>/threads/<threadId>/files streams the raw body with content-length, optional content-type, and percent-encoded x-file-name. Harness owns streamed storage, content hashes, temporary-file cleanup, and staged receipts. The response retains its AG-UI URL source and filename/size/sha256 metadata.

Clients must preserve the returned URL query when changing a proxy prefix. The gateway signs the native file reference and receipt for the authenticated session. GET verifies the signature and principal/thread mapping before calling the official streamed reader. Same-name uploads keep their display name and receive distinct receipt URLs. Downloads remain authorized after cold resume; rotating the shared secret invalidates old URLs. Pre-native unsigned upload URLs require a fresh upload.

User messages accept ordered text and signed thread-file URL parts. Images use official image admission; other files become native file content parts. Harness owns receipt binding, successful admission retirement, and rollback when queue delivery fails. Rejected admission can retry its still-staged receipt. A consumed, explicitly retired, or cold unsent receipt returns FILE_NOT_STAGED and requires re-upload; the gateway never restores expired authority. MESSAGES_SNAPSHOT preserves the exact accepted AG-UI parts. Shared-state and frontend Tool admission are revalidated after asynchronous file processing, before publishing those parts. Inline data parts are not accepted.

For multiple presets within one tenant, the host can grant selection with selectableAgentPresets: { "tenant-1": ["alpha", "beta"] }. A run may then request forwardedProps: { agentPreset: "beta" }. The Gateway validates grants against the roster at activation and calls native agentPresets.select under the thread's run reservation before its first turn. The roster alone grants no authority, and the BFF must still authenticate the tenant and authorize access to its application features.

Selection is optional. Omitting it preserves the current composition; repeating the effective canonical id is a no-op, including after restart. A different ungranted id fails with HTTP 403 PRESET_NOT_ALLOWED; a granted change after the first turn fails with HTTP 409 PRESET_LOCKED. A history-only request never selects a preset, so a session created by a history read can still choose one on its first work run. The native session log owns the selected composition and restores it after restart. Tool names are validated against the selected composition before SSE starts. A successful selection remains recorded if that validation rejects the run or its client disconnects; no user turn is started, and the blank session can select again.

maxRunEvents must retain at least the mandatory opening and terminal events. maxRunEventBytes bounds the complete retained Run record, including RUN_STARTED and its terminal event, and must be large enough for the configured maximum identity length. A non-loopback DSH WebServer requires allowNonLoopback: true. Prefer a loopback Gateway behind a same-host authenticated BFF.

Opening and final durable history snapshots both count toward this bound. Buffer overflow ends the HTTP run and cancels only its currently claimed native turn. An overflowing history-only read does not cancel another active turn. Completed duplicate requests still replay the exact retained events.

Architecture

One projection core, two supported shapes. The core is the dsh-ag-ui Host service: it binds AG-UI threads to DSH Agents and translates runs, events, tools, shared state, and presenter cards in both directions. Everything around it is packaging.

Deployment form — BFF Gateway            Embedding form — dsh-ag-ui-adapter

Browser                                   Node.js application
  -> authenticated application BFF         -> DshAgent (an AG-UI AbstractAgent)
       bearer secret and trusted                spawns a private micro-host child:
       identity headers                         - loopback webserver, ephemeral port
  -> POST /ag-ui on the Host                    - the same published dsh-ag-ui
  -> dsh-ag-ui Host Service                       gateway row, per-process secret
  -> DSH Agent / Session / Tool runtime         - the application's Agent core and
  -> model provider and backend Tools              model plugin rows
                                             -> run() over loopback HTTP to the
                                                same gateway service

The deployment form fronts a shared Host with an authenticated BFF for browser clients. The embedding form (dsh-ag-ui-adapter) composes a throwaway Host per application process — nothing is spawned before the first run, and the child never outlives the process. Both shapes speak the same protocol to the same projection core, so run semantics, browser Tools, shared state, presenter cards, idempotency, and disposal behave identically.

In both forms the gateway binding key is the exact (tenantId, userId, threadId) tuple supplied through trusted identity headers.

Trust posture

  • The gateway listens on the Host webserver. Keep that webserver loopback and behind a same-host authenticated BFF; a non-loopback bind requires the explicit allowNonLoopback setting and is almost always a mistake.
  • The bearer secret authenticates one service-to-service hop — the BFF (or, in the embedding form, the adapter process) to the gateway. It is not end-user authentication: the gateway never sees a user credential and by itself grants nothing user-scoped.
  • End-user identity travels in the trusted tenantHeader/userHeader headers. Whoever holds the secret can assert any identity, so the secret holder must itself be trustworthy — in the deployment form, authenticating the user before injecting those headers is the BFF's whole job; in the embedding form the adapter process is the trusted principal.
  • Browser-supplied identity, permission, patient ID, resource ID, context, state, forwardedProps, Tool schemas, and IDs inside messages are untrusted wire input and never grant backend authority.
  • Backend Tools can derive the authenticated thread identity from the Agent:
const identity = ctx.agUi.identityFor(exec.agent)
if (identity === undefined) {
  throw new Error('This Tool requires an authenticated AG-UI thread.')
}

const { principal, threadId } = identity

The application should map this tuple to its server-owned resource authorization state.

BFF proxy

The browser must not call the private Gateway directly. A BFF should authenticate the user, authorize the application resource, retain the browser request body exactly, and inject trusted identity headers.

app.post('/api/agent', async (c) => {
  const user = await authenticateApplicationRequest(c.req.raw)
  const body = new Uint8Array(await c.req.raw.arrayBuffer())

  const upstream = await fetch('http://127.0.0.1:3080/ag-ui', {
    method: 'POST',
    headers: {
      authorization: `Bearer ${process.env.DSH_AG_UI_SHARED_SECRET}`,
      'content-type': 'application/json',
      'x-dsh-tenant-id': user.tenantId,
      'x-dsh-user-id': user.userId,
    },
    body,
  })

  return new Response(upstream.body, {
    status: upstream.status,
    headers: upstream.headers,
  })
})

The BFF owns login, sessions, CSRF protection, tenant policy, resource authorization, audit, and rate limits. Do not treat the Gateway bearer secret as end-user authentication.

Proxying Host service-plugin remotes

The AG-UI gateway is one Host-plane service with an HTTP remote; other DSH service plugins can mount routes on the same loopback webserver. The same rule covers every one of them: the browser never reaches the Host directly. Expose each remote through the application backend under an application-owned route, with the authenticate → authorize → forward shape above and the credentials that service expects. The Host port itself stays loopback and unadvertised to clients.

AG-UI client

The Gateway wire protocol accepts official clients in the supported range (>=0.0.59 <0.1.0) and does not require an exact pin. The Gateway-owned DshHttpAgent companion is tested and peered with @ag-ui/client ~0.0.59:

pnpm add dsh-ag-ui @ag-ui/client@~0.0.59

The optional Gateway-owned client companion omits presentation messages from HTTP input while retaining every user and Tool message. It preserves the final synthetic pair added by A2UI middleware. The agent keeps its complete local history for rendering and middleware; standard HttpAgent with full history is also supported.

Send page-specific browser Tools and current context on every run:

import { randomUUID } from '@ag-ui/client'
import { DshHttpAgent } from 'dsh-ag-ui/client'

const agent = new DshHttpAgent({
  url: '/api/agent',
  threadId: 'application-thread-123',
})

agent.addMessage({
  id: randomUUID(),
  role: 'user',
  content: 'Review the current draft.',
})

await agent.runAgent({
  runId: randomUUID(),
  tools: browserTools,
  context: [{
    description: 'Current page state',
    value: JSON.stringify(readPageSnapshot()),
  }],
  forwardedProps: {},
})

Assistant messages do not acknowledge earlier input, so the companion never discards user messages based on their position. The Gateway deduplicates accepted messages by ID. Large user and Tool histories still count toward the configured HTTP request-body limit; this companion does not guarantee bounded request size.

If the model calls a browser-owned Tool, the current HTTP run finishes successfully while the DSH Tool Promise remains pending. The browser executes the Tool, appends one standard AG-UI ToolMessage with the same toolCallId, and starts another run. The Gateway resolves the original Promise and continues the same DSH turn.

The official @ag-ui/a2ui-middleware renders from the streamed Tool arguments and never sends a browser result. The Gateway therefore does not park the render Tool the middleware flags in forwardedProps.injectA2UITool: the call settles at once with {"status":"rendered"}, its result streams in the same run, and the DSH turn continues. A render Tool a client registers itself still parks like any browser-owned Tool. A later forwardedProps.a2uiAction starts the next turn as durable plugin context. That context keeps the readable middleware result plus the complete validated action JSON, including its optional timestamp, with recursively sorted object keys. The Gateway accepts only the middleware's exact bounded action envelope and matching final log_a2ui_event assistant/Tool pair; it does not import arbitrary assistant history into DSH.

The synthetic result message ID identifies the action in the native inbox and durable log. Redelivery of the same formed pair is idempotent across HTTP run IDs and restarts; reusing that identity with changed action content is rejected. Each new click needs a new result ID, even when its payload matches an earlier click. Middleware retries must preserve the formed pair identities.

Do not send ordinary browser Tool results through AG-UI resume[]; that field is reserved for explicit interrupt/HITL flows.

Native questions and approvals

Mount the native @deepseek-ai/dsh-user-questions and/or @deepseek-ai/dsh-user-approval services in the Host profile. A business preset can mount the official @deepseek-ai/dsh-tool-ask-user Tool. The Gateway answers requests only for its exact live root Agent; it does not install the services, replace their policies, or handle subagent questions.

A native human request ends the HTTP run with RUN_FINISHED and outcome: {type: "interrupt", interrupts: [...]}. The Harness turn continues waiting on its original Promise. Submit a fresh runId in the same thread with resume[] to answer it:

{
  "threadId": "thread-1",
  "runId": "answer-1",
  "messages": [], "tools": [], "context": [], "state": {}, "forwardedProps": {},
  "resume": [{"interruptId": "<published-id>", "status": "resolved", "payload": {"approved": true}}]
}

Approval interrupts use reason: "approval", optional native toolCallId, and {approved: boolean} as their response. false rejects this action; status: "cancelled" withdraws the request. Only the native service can grant allowed-once, and its never policy still rejects without prompting. Gateway responses never grant lasting permission or manufacture backend Tool results.

Question interrupts use reason: "user_question". metadata.dsh.questions contains the native questions, options and optional intent; responseSchema describes the answer structure. Respond with {answers: [{id, selected: ["option label"], custom?: "text"}]}. Answer every question exactly once. Single-select questions accept one option or custom text; multi-select permits both. A cancelled question rejects through the native ASK_ABORTED error.

Every published interrupt must appear once in a resume batch. Validation precedes SSE, message admission, context/state changes and answer consumption. Invalid batches leave the pending work untouched. New user messages cannot share a run with human responses; already-ready frontend Tool results of the same turn can. Unknown interrupt ids are logged and ignored within the authenticated thread. Retained identical run ids replay without answering twice.

A history-only run repeats the same published interrupt ids and shared-state snapshot. Later native questions wait for the next accepted continuation; reload does not enlarge a form another tab already received. A newly discovered question may follow a frontend Tool's completed HTTP run; the next continuation or history read publishes it.

Each request has a finite server deadline, unaffected by reconnect. Timeout, native abort, overflow or disposal cancels the waiting work. A known expired resolved response returns INTERRUPT_UNAVAILABLE; send status: "cancelled" to clear that stale client gate without executing anything. expiresAt is omitted because current client