Files
deepseek-harness/packages/bundle/headless/src/json-stream.ts
T

333 lines
13 KiB
TypeScript

/**
* `--json` run projection: a bounded, ordered event stream derived from one
* Agent's durable Session events. Every projected event is a commit point:
* text and reasoning come from committed `assistant/message` content, never
* from a live attempt that may still be retried or discarded, so the stream
* never carries content the durable log does not contain.
* @module @deepseek-ai/dsh-headless/json-stream
*/
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { lastAssistantStreamChunk } from '@deepseek-ai/dsh-llm/assistant-stream'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
/** Default per-string and per-key cap applied to every bounded projected payload. */
export const MAX_STRING_BYTES = 8 * 1024
/** Default cap on one projected event's serialized bytes, newline included; the terminal `final` is exempt. */
export const MAX_EVENT_BYTES = 32 * 1024
/** Bytes every newline-delimited writer appends after one serialized event. */
const LINE_TERMINATOR_BYTES = 1
/** The stdout sink a projection writes newline-delimited events to. */
export interface JsonSink {
/** Write one chunk of the event stream. */
write(chunk: string): unknown
}
/** Tunables for {@link projectJsonRun}; every field defaults. */
export interface JsonProjectionOptions {
/** Working directory reported by the opening `session` event. */
cwd?: string
/** Per-string and per-key byte cap; longer values are truncated and flagged. */
maxStringBytes?: number
}
/** The live handle of one `--json` projection. */
export interface JsonProjection {
/** Write the terminal `final` event carrying the run's answer text. */
finish(text: string): void
/** Stop observing the Session. */
dispose(): void
}
/** Mutable truncation state threaded through one payload bound. */
interface BoundState {
truncated: boolean
}
/** Truncate one UTF-8 string to `maxBytes`, dropping a split trailing character. */
function truncateUtf8(text: string, maxBytes: number): string {
const buffer = Buffer.from(text, 'utf8').subarray(0, maxBytes)
const decoded = buffer.toString('utf8')
return decoded.endsWith('\uFFFD') ? decoded.slice(0, -1) : decoded
}
/** Cap one object key, flagging the payload when it was cut. */
function boundKey(key: string, maxBytes: number, state: BoundState): string {
if (Buffer.byteLength(key, 'utf8') <= maxBytes) return key
state.truncated = true
return truncateUtf8(key, maxBytes)
}
/** Maximum container depth one projected payload keeps before the tail is cut. */
const MAX_DEPTH = 64
/** Recursively cap every string in one JSON-serializable value, keys included. */
function boundValue(value: unknown, maxBytes: number, state: BoundState, depth = 0): unknown {
if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value
state.truncated = true
return truncateUtf8(value, maxBytes)
}
if (Array.isArray(value)) {
// A legal but pathologically deep argument would otherwise recurse until
// the stack overflows, and the isolated listener would silently drop the
// event; cut the tail at a depth no real tool schema reaches.
if (depth >= MAX_DEPTH) {
state.truncated = true
return '[truncated: depth]'
}
return value.map(item => boundValue(item, maxBytes, state, depth + 1))
}
if (value !== null && typeof value === 'object') {
if (depth >= MAX_DEPTH) {
state.truncated = true
return '[truncated: depth]'
}
// A null prototype keeps a literal `__proto__` key as data instead of
// invoking the inherited setter, which would silently drop it. Two keys
// that share a truncated prefix collide last-wins; only a payload naming
// two multi-kilobyte keys can reach that.
const bounded = Object.create(null) as Record<string, unknown>
for (const [key, item] of Object.entries(value)) {
bounded[boundKey(key, maxBytes, state)] = boundValue(item, maxBytes, state, depth + 1)
}
return bounded
}
return value
}
/**
* Bound every string and key in one projected payload, adding `truncated: true`
* when any was cut. {@link boundJsonLine} composes this with the whole-line cap.
* @param event - the event payload to bound.
* @param maxStringBytes - per-string and per-key byte cap.
* @returns a copy with every over-long string and key truncated.
*/
function boundJsonEvent(
event: Record<string, unknown>,
maxStringBytes: number = MAX_STRING_BYTES,
): Record<string, unknown> {
const state: BoundState = { truncated: false }
const bounded = boundValue(event, maxStringBytes, state) as Record<string, unknown>
if (state.truncated) bounded.truncated = true
return bounded
}
/**
* Serialize one projected payload under both limits: every string and key is
* capped at `maxStringBytes`, and the serialized line at `maxEventBytes`. The
* line cap reserves the newline the writer appends, so the complete record
* stays within it. When the line is still too long, scalar fields survive and
* structured fields are dropped; when even those are too long, only `type` and
* `truncated` remain.
* @param event - the event payload to serialize.
* @param maxStringBytes - per-string and per-key byte cap.
* @param maxEventBytes - cap on the serialized line plus its trailing newline.
* @returns the bounded JSON line, without a trailing newline.
*/
export function boundJsonLine(
event: Record<string, unknown>,
maxStringBytes: number = MAX_STRING_BYTES,
maxEventBytes: number = MAX_EVENT_BYTES,
): string {
const limit = maxEventBytes - LINE_TERMINATOR_BYTES
const bounded = boundJsonEvent(event, maxStringBytes)
const line = JSON.stringify(bounded)
if (Buffer.byteLength(line, 'utf8') <= limit) return line
const scalars = Object.create(null) as Record<string, unknown>
for (const [key, value] of Object.entries(bounded)) {
if (value === null || typeof value !== 'object') scalars[key] = value
}
scalars.truncated = true
const short = JSON.stringify(scalars)
if (Buffer.byteLength(short, 'utf8') <= limit) return short
return JSON.stringify({ type: bounded.type, truncated: true })
}
/**
* Parse raw tool-call arguments as the executor does: empty input is `{}`,
* invalid or non-round-trippable JSON (a non-finite number) stays text.
*/
function parseArguments(raw: string): unknown {
if (raw === '') return {}
const seen = { nonFinite: false }
try {
const parsed: unknown = JSON.parse(raw, (_key, value: unknown) => {
// `1e400` is valid JSON but parses to Infinity, which JSON.stringify
// reports as null; keep the raw text rather than misdescribe the call.
if (typeof value === 'number' && !Number.isFinite(value)) seen.nonFinite = true
return value
})
return seen.nonFinite ? raw : parsed
} catch {
return raw
}
}
/** Join the text blocks of a tool result's model-facing content. */
function resultText(blocks: readonly { type: string; text?: string }[]): string {
return blocks
.filter((block): block is { type: string; text: string } =>
block.type === 'text' && typeof block.text === 'string')
.map(block => block.text)
.join('')
}
/** Provider token accounting for one step, as carried by `assistant/message`. */
type StepUsage = NonNullable<SessionEvent<'assistant/message'>['data']['usage']>
/** Accumulated usage for one step plus whether every attempt reported a sample. */
interface StepUsageState {
usage: StepUsage | undefined
/** False once any attempt in the step omitted a usage sample. */
complete: boolean
}
/** The usage a provider reported in one attempt's stream, if any. */
function streamUsage(stream: SessionEvent<'assistant/message'>['data']['stream']): StepUsage | undefined {
return lastAssistantStreamChunk(stream, 'usage')?.usage
}
/**
* Accumulate one step's usage across its attempts. A retried attempt keeps its
* usage only in its `assistant/attempt` stream, so the committed message alone
* would under-report billed tokens. An attempt that reports no sample makes the
* step total unknowable, so the step omits usage entirely rather than publish a
* partial sum as if it were complete; an optional bucket is summed only when
* every contribution reports it.
* @param state - usage accumulated so far and whether it is still complete.
* @param next - usage reported by the next attempt.
* @returns the updated state.
*/
function addUsage(state: StepUsageState, next: StepUsage | undefined): StepUsageState {
if (next === undefined) return { usage: state.usage, complete: false }
if (state.usage === undefined) return { usage: next, complete: state.complete }
const total = state.usage
const sum = (a: number | undefined, b: number | undefined): number | undefined =>
a === undefined || b === undefined ? undefined : a + b
const totalTokens = sum(total.totalTokens, next.totalTokens)
const cacheReadTokens = sum(total.cacheReadTokens, next.cacheReadTokens)
const cacheWriteTokens = sum(total.cacheWriteTokens, next.cacheWriteTokens)
const reasoningTokens = sum(total.reasoningTokens, next.reasoningTokens)
return {
usage: {
inputTokens: total.inputTokens + next.inputTokens,
outputTokens: total.outputTokens + next.outputTokens,
...totalTokens === undefined ? {} : { totalTokens },
...cacheReadTokens === undefined ? {} : { cacheReadTokens },
...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
...reasoningTokens === undefined ? {} : { reasoningTokens },
},
complete: state.complete,
}
}
/**
* Project one Agent's run as newline-delimited JSON on `sink`.
*
* The opening `session` event is written before the subscription starts, so a
* caller must invoke this before submitting the task. Text and reasoning are
* emitted only when the step's `assistant/message` commits them, and the
* terminal `final` event carries the same lossless answer the default mode
* prints (it is deliberately not bounded).
* @param ctx - plugin context carrying the live Session feed.
* @param agent - the exact Agent whose events belong to this invocation.
* @param sink - stdout sink receiving one JSON object per line.
* @param options - projection tunables.
* @returns the projection handle that finishes or disposes the stream.
*/
export function projectJsonRun(
ctx: Context,
agent: Agent,
sink: JsonSink,
options: JsonProjectionOptions = {},
): JsonProjection {
const maxStringBytes = options.maxStringBytes ?? MAX_STRING_BYTES
let disposed = false
let stepUsage: StepUsageState = { usage: undefined, complete: true }
const write = (event: Record<string, unknown>): void => {
sink.write(`${boundJsonLine(event, maxStringBytes)}\n`)
}
const onSessionEvent = (session: unknown, event: SessionEvent): void => {
if (session !== agent.session) return
switch (event.type) {
case 'turn/start':
write({ type: 'status', phase: 'turn_start', turn: event.data.turn })
return
case 'step/start':
write({ type: 'status', phase: 'step_start', turn: event.data.turn, step: event.data.step })
return
case 'assistant/attempt':
// A retried attempt has no committed message; its billed tokens live
// only in the stream, so accumulate them into the step's total.
stepUsage = addUsage(stepUsage, streamUsage(event.data.stream))
return
case 'assistant/message':
stepUsage = addUsage(stepUsage, event.data.usage ?? streamUsage(event.data.stream))
for (const block of event.data.message.content) {
if (block.type === 'reasoning') write({ type: 'thinking', text: block.text })
else if (block.type === 'text') write({ type: 'text', text: block.text })
}
return
case 'step/end': {
const { usage, complete } = stepUsage
stepUsage = { usage: undefined, complete: true }
write({
type: 'status', phase: 'step_end', turn: event.data.turn, step: event.data.step,
// A partial sum would read as an exact total, so omit it entirely
// when any attempt in the step failed to report usage.
...complete && usage !== undefined ? { usage } : {},
})
return
}
case 'turn/end':
write({ type: 'status', phase: 'turn_end', turn: event.data.turn, reason: event.data.reason })
return
case 'tool/call':
write({
type: 'tool_call',
callId: event.data.callId,
tool: event.data.name,
input: parseArguments(event.data.arguments),
})
return
case 'tool/result': {
// Compaction replaces older results in the surface; those are history,
// not this run's output, and would otherwise duplicate a callId.
if (event.surfaceOp !== 'append') return
const message = event.data.message
write({
type: 'tool_result',
callId: message.toolCallId,
status: message.isError === true ? 'error' : 'completed',
result: resultText(message.content),
})
return
}
default:
return
}
}
write({ type: 'session', sessionId: agent.id, cwd: options.cwd ?? process.cwd() })
const stopSession = ctx.on('session/event', onSessionEvent)
return {
finish(text: string): void {
// The answer is the lossless terminal contract, so it is not truncated.
if (disposed) return
sink.write(`${JSON.stringify({ type: 'final', text })}\n`)
},
dispose(): void {
disposed = true
stopSession()
},
}
}