feat(workflow): show durable run records in Chat
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/workflow/tool-workflow/README.md
|
||||
README.md: 29896bee0f78a1d1764c3908965325fcecbf7b53
|
||||
README.zh.md: 12e1ecd8932120c74384a289530954422ba145f2
|
||||
README.md: ba8283a6b517eea79e6c75674a906db01e4b5890
|
||||
README.zh.md: 2af8f5f8b8db2d5edf530d79dd81319846cfeeea
|
||||
|
||||
@@ -12,6 +12,10 @@ Three parameters: `meta` (required identity data: `name`, `description`, and opt
|
||||
|
||||
Collection is synchronous (like [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)): `execute` starts a run and awaits `run.result` inside a `try/finally` that always disposes the run, so the script and its children reach quiescence on every path. `exec.signal` is bridged to `run.cancel()` (including the already-aborted-before-start case). A non-`completed` stop reason maps to an `isError` result reporting the reason—never partial output as success; a parse/meta failure thrown synchronously by `start()` becomes an `isError` the model can correct from. Completion returns canonical `{ runId, agentsStarted, result }`; the Native renderer preserves the meta name, agent count, and JSON value, truncating only that projection at `maxResultChars`.
|
||||
|
||||
For a root transport execution (`exec.parent` absent), the tool also projects the run into the calling Agent's Session: run-start after `start()` returns, matching member starts and endings filtered by `run.id`, then run-end only after `run.result` is available and `dispose()` has reached quiescence. Nested transport calls execute normally but write no workflow record. The first failed Session append disables later recording for that run, emits one warning, and leaves either no record or a legal continuous prefix without changing the tool result or cleanup.
|
||||
|
||||
The browser-safe `@deepseek-ai/dsh-tool-workflow/types` subpath owns these four log-only event payloads and their `SessionEventMap` declaration. The package invariant rejects duplicate starts, unpaired members, terminal events with open members, and updates after run-end on both cold load and live append while accepting missing terminal suffixes.
|
||||
|
||||
## Render intent
|
||||
|
||||
Decided up front (per the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)): a `generic` card titled `workflow: <meta.name>`, read directly from `args.meta.name` (presentation is a pure function of args and does not ask the engine to parse); the script text rides as `rawInput`. The result keeps the generic card.
|
||||
@@ -78,3 +82,4 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
- **The parent turn blocks until the whole workflow settles** — there is no background start/poll surface, and cancellation discards partial output as an error.
|
||||
- **`args` must be an object and Native result text is bounded** — callers wrap top-level arrays/scalars in a field; the canonical workflow result remains complete, while JSON beyond `maxResultChars` is truncated in the model-facing projection rather than stored behind a retrieval handle.
|
||||
- **Workflow policy is fixed per tool registration** — provider selection, caps, and tool name are deployment config, not model-call arguments.
|
||||
- **Durable records are top-level and observational** — nested Code Mode dispatches are not recorded, and a recording failure intentionally degrades to an incomplete prefix rather than changing execution.
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
|
||||
收集是同步的(类似 [`dsh-tool-subagent`](../../subagent/tool-subagent/README.md)):`execute` 启动运行并等待 `run.result`;这些操作位于 `try/finally` 中,该结构总会 dispose(资源释放)运行,使脚本及其子 agent(智能体)在每条路径上完全停稳。`exec.signal` 会桥接到 `run.cancel()`,包括启动前已经中止的情况。非 `completed` 结束原因会映射为报告原因的 `isError` 结果,绝不会把局部输出当作成功;`start()` 同步抛出的解析/meta 失败会变成模型可据以修正的 `isError`。完成时返回规范值 `{ runId, agentsStarted, result }`;Native 渲染器保留 meta 名称、agent 数量和 JSON 值,只会在 `maxResultChars` 处截断该投影。
|
||||
|
||||
对于根 transport 执行(`exec.parent` 缺省),工具还会把运行投影到调用 Agent 的 Session:`start()` 返回后写 run-start,只记录 `run.id` 匹配的成员开始与结束,并且只在 `run.result` 已取得且 `dispose()` 完全停稳后写 run-end。嵌套 transport 调用照常执行,但不写工作流记录。任一次 Session append 首次失败后,本运行会停止后续记录并只告警一次,留下空记录或合法连续前缀,同时不改变工具结果和清理。
|
||||
|
||||
浏览器安全的 `@deepseek-ai/dsh-tool-workflow/types` 子路径拥有这四类 log-only 事件 payload 及其 `SessionEventMap` 声明。包 invariant 会在冷加载和实时追加时拒绝重复 start、未配对成员、仍有开放成员的终点和 run-end 后更新,同时允许缺失终态后缀的连续前缀。
|
||||
|
||||
## 渲染意图
|
||||
|
||||
渲染意图预先确定(见[渲染意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)):使用一个 `generic` 卡片,标题为 `workflow: <meta.name>`,直接从 `args.meta.name` 读取(呈现是参数的纯函数,不要求引擎解析);脚本文本作为 `rawInput` 携带。结果继续使用 generic 卡片。
|
||||
@@ -78,3 +82,4 @@ Use the <toolName> tool ONLY when the user explicitly asks for a workflow or for
|
||||
- **父级轮次会阻塞到整个工作流结算**:没有后台启动/轮询接口,取消会把局部输出作为错误丢弃。
|
||||
- **`args` 必须是对象,Native 结果文本有界**:调用方把顶层数组/标量包装到字段中;规范工作流结果保持完整,超过 `maxResultChars` 的 JSON 会在面向模型的投影中截断,而不是存储在检索句柄背后。
|
||||
- **每次工具注册的工作流策略固定**:提供方选择、上限和工具名称属于部署配置,不是模型调用参数。
|
||||
- **持久记录只覆盖顶层且只供观察**:嵌套 Code Mode dispatch 不记录;记录故障会刻意退化为不完整前缀,而不改变执行。
|
||||
|
||||
@@ -15,12 +15,17 @@
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./types": {
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
@@ -28,6 +33,7 @@
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-workflow": "^0.0.1",
|
||||
|
||||
@@ -15,8 +15,15 @@ import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-session'
|
||||
import type { WorkflowResult, WorkflowRun } from '@deepseek-ai/dsh-workflow'
|
||||
import type { JsonValue, Session, SessionEventMap } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
|
||||
WorkflowRunId, WorkflowRunInfo, WorkflowStopReason,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
ToolWorkflowAgentEndData, ToolWorkflowAgentStartData,
|
||||
ToolWorkflowRunEndData, ToolWorkflowRunStartData,
|
||||
} from './types.ts'
|
||||
// Declaration merge only: makes ctx.systemPrompt visible for the section registration.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
@@ -38,6 +45,114 @@ export const Config: z<Config> = z.object({
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
type BufferedWorkflowEvent =
|
||||
| { readonly kind: 'agent-start'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentInfo }
|
||||
| { readonly kind: 'agent-end'; readonly info: WorkflowRunInfo; readonly agent: WorkflowAgentEndInfo }
|
||||
|
||||
interface WorkflowRecorder {
|
||||
bind(run: WorkflowRun): void
|
||||
finish(stopReason: WorkflowStopReason): void
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
interface ToolWorkflowRecordEventMap {
|
||||
'tool-workflow/run-start': ToolWorkflowRunStartData
|
||||
'tool-workflow/agent-start': ToolWorkflowAgentStartData
|
||||
'tool-workflow/agent-end': ToolWorkflowAgentEndData
|
||||
'tool-workflow/run-end': ToolWorkflowRunEndData
|
||||
}
|
||||
|
||||
/** Render a contained recording failure without trusting the thrown value. */
|
||||
function renderRecordingError(error: unknown): string {
|
||||
try {
|
||||
return String(error)
|
||||
} catch {
|
||||
return '[unrenderable thrown value]'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one top-level workflow run into its parent Session without letting
|
||||
* recording failure affect tool execution. Listeners are installed before
|
||||
* `start()` so even a synchronous provider cannot outrun the recorder.
|
||||
*/
|
||||
function createWorkflowRecorder(ctx: Context, session: Session): WorkflowRecorder {
|
||||
let runId: WorkflowRunId | undefined
|
||||
let enabled = true
|
||||
const buffered: BufferedWorkflowEvent[] = []
|
||||
// These four package-owned events are all log-only. Narrowing the generic
|
||||
// append face here lets TypeScript discharge Session.append's conditional
|
||||
// surface-options tuple once for the complete closed event set.
|
||||
const appendRecord = session.append.bind(session) as <Type extends keyof ToolWorkflowRecordEventMap>(
|
||||
type: Type,
|
||||
data: SessionEventMap[Type],
|
||||
) => void
|
||||
|
||||
const append = <Type extends keyof ToolWorkflowRecordEventMap>(
|
||||
type: Type,
|
||||
data: SessionEventMap[Type],
|
||||
): void => {
|
||||
if (!enabled) return
|
||||
try {
|
||||
appendRecord(type, data)
|
||||
} catch (error: unknown) {
|
||||
enabled = false
|
||||
ctx.logger.warn(`tool-workflow: disabled durable record after ${type} append failed: ${renderRecordingError(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
const record = (event: BufferedWorkflowEvent): void => {
|
||||
if (runId === undefined) {
|
||||
buffered.push(event)
|
||||
return
|
||||
}
|
||||
if (event.info.id !== runId) return
|
||||
if (event.kind === 'agent-start') {
|
||||
const data: ToolWorkflowAgentStartData = {
|
||||
runId,
|
||||
seq: event.agent.seq,
|
||||
label: event.agent.label,
|
||||
...event.agent.phase === undefined ? {} : { phase: event.agent.phase },
|
||||
childId: event.agent.childId,
|
||||
}
|
||||
append('tool-workflow/agent-start', data)
|
||||
return
|
||||
}
|
||||
const data: ToolWorkflowAgentEndData = {
|
||||
runId,
|
||||
seq: event.agent.seq,
|
||||
outcome: event.agent.outcome,
|
||||
}
|
||||
append('tool-workflow/agent-end', data)
|
||||
}
|
||||
|
||||
const disposeStart = ctx.on('workflow/agent-start', (info, agent) => {
|
||||
record({ kind: 'agent-start', info, agent })
|
||||
})
|
||||
const disposeEnd = ctx.on('workflow/agent-end', (info, agent) => {
|
||||
record({ kind: 'agent-end', info, agent })
|
||||
})
|
||||
|
||||
return {
|
||||
bind(run) {
|
||||
runId = run.id
|
||||
append('tool-workflow/run-start', { runId, name: run.meta.name })
|
||||
for (const event of buffered) record(event)
|
||||
buffered.length = 0
|
||||
},
|
||||
finish(stopReason) {
|
||||
/* v8 ignore next -- execute binds every returned run before result settlement can call finish. */
|
||||
if (runId === undefined) return
|
||||
append('tool-workflow/run-end', { runId, stopReason })
|
||||
},
|
||||
dispose() {
|
||||
disposeStart()
|
||||
disposeEnd()
|
||||
buffered.length = 0
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The script-authoring contract, embedded in the tool description. This IS the
|
||||
* model-facing spec: the meta block, the hooks and their exact semantics, and
|
||||
@@ -188,13 +303,23 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// Meta/body validation failures (META_INVALID/SCRIPT_PARSE) throw
|
||||
// synchronously here and become isError results via the registry — the
|
||||
// model sees the violation list and can correct the call.
|
||||
const run: WorkflowRun = ctx.workflows.start({
|
||||
script: args.script,
|
||||
meta: args.meta,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
signal: exec.signal,
|
||||
})
|
||||
const recorder = exec.parent === undefined
|
||||
? createWorkflowRecorder(ctx, parent.session)
|
||||
: undefined
|
||||
let run: WorkflowRun
|
||||
try {
|
||||
run = ctx.workflows.start({
|
||||
script: args.script,
|
||||
meta: args.meta,
|
||||
...args.args !== undefined ? { args: args.args } : {},
|
||||
parent,
|
||||
signal: exec.signal,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
recorder?.dispose()
|
||||
throw error
|
||||
}
|
||||
recorder?.bind(run)
|
||||
|
||||
// Bridge the tool's abort signal to the run: if the parent step is aborted while the
|
||||
// script is in flight, cancel the whole run. The signal also enters the engine directly, but
|
||||
@@ -202,8 +327,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const onAbort = (): void => { run.cancel('parent step aborted') }
|
||||
exec.signal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
let result: WorkflowResult | undefined
|
||||
try {
|
||||
const result = await run.result
|
||||
result = await run.result
|
||||
const error = stopReasonError(result)
|
||||
if (error !== undefined) {
|
||||
// Map a non-clean finish to an isError result (the registry turns a
|
||||
@@ -217,8 +343,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
} finally {
|
||||
exec.signal.removeEventListener('abort', onAbort)
|
||||
// Always reach run quiescence — never leak a live script or children.
|
||||
await run.dispose()
|
||||
try {
|
||||
// Keep member listeners alive through disposal: an engine may
|
||||
// synthesize cancelled member endings while reaching quiescence.
|
||||
await run.dispose()
|
||||
/* v8 ignore next -- WorkflowRun.result never rejects by contract, so result is assigned before finally. */
|
||||
if (result !== undefined) recorder?.finish(result.stopReason)
|
||||
} finally {
|
||||
recorder?.dispose()
|
||||
}
|
||||
}
|
||||
},
|
||||
presentCall: args => presentWorkflowCall(args),
|
||||
|
||||
@@ -1,30 +1,158 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-workflow`.
|
||||
* @module @deepseek-ai/dsh-tool-workflow/invariant
|
||||
*/
|
||||
/** Package-owned durable workflow-record invariants. @module @deepseek-ai/dsh-tool-workflow/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type {} from './types.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-workflow-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
/** Services required to validate existing and newly appended Session logs. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this model-facing adapter has no independent lifecycle stream; execution
|
||||
* relations are owned by the capability seam it calls.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
interface RunTrace {
|
||||
ended: boolean
|
||||
readonly members: Map<number, boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
type WorkflowTrace = Map<string, RunTrace>
|
||||
|
||||
/** Clone the independent fold before validating one candidate append. */
|
||||
function cloneTrace(source: WorkflowTrace): WorkflowTrace {
|
||||
return new Map([...source].map(([runId, run]) => [runId, {
|
||||
ended: run.ended,
|
||||
members: new Map(run.members),
|
||||
}]))
|
||||
}
|
||||
|
||||
/** Require a durable opaque identity to be a non-empty string. */
|
||||
function stringId(value: unknown, label: string, fail: InvariantFailure): string {
|
||||
if (typeof value !== 'string' || value.length === 0) fail(`${label} must be a non-empty string`)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Require one workflow member's 1-based sequence identity. */
|
||||
function memberSeq(value: unknown, fail: InvariantFailure): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < 1) {
|
||||
fail('tool-workflow member seq must be a positive safe integer')
|
||||
}
|
||||
return value as number
|
||||
}
|
||||
|
||||
/** Read one plain payload field without trusting restored plugin data. */
|
||||
function recordOf(event: SessionEvent, fail: InvariantFailure): Record<string, unknown> {
|
||||
const data: unknown = event.data
|
||||
if (data === null || typeof data !== 'object' || Array.isArray(data)) {
|
||||
fail(`${event.type} data must be a JSON object`)
|
||||
}
|
||||
return data as Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Require the named run to exist and remain open. */
|
||||
function openRun(trace: WorkflowTrace, runId: string, eventType: string, fail: InvariantFailure): RunTrace {
|
||||
const run = trace.get(runId)
|
||||
if (run === undefined) fail(`${eventType} has no matching tool-workflow/run-start for run ${runId}`)
|
||||
if (run.ended) fail(`${eventType} appears after tool-workflow/run-end for run ${runId}`)
|
||||
return run
|
||||
}
|
||||
|
||||
/** Advance the workflow-record fold with one relevant Session event. */
|
||||
function applyEvent(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (!event.type.startsWith('tool-workflow/')) return
|
||||
const data = recordOf(event, fail)
|
||||
const runId = stringId(data.runId, `${event.type} runId`, fail)
|
||||
|
||||
switch (event.type) {
|
||||
case 'tool-workflow/run-start': {
|
||||
if (typeof data.name !== 'string' || data.name.length === 0) {
|
||||
fail('tool-workflow/run-start name must be a non-empty string')
|
||||
}
|
||||
if (trace.has(runId)) fail(`tool-workflow/run-start repeats run ${runId}`)
|
||||
trace.set(runId, { ended: false, members: new Map() })
|
||||
return
|
||||
}
|
||||
case 'tool-workflow/agent-start': {
|
||||
const run = openRun(trace, runId, event.type, fail)
|
||||
const seq = memberSeq(data.seq, fail)
|
||||
if (typeof data.label !== 'string') fail('tool-workflow/agent-start label must be a string')
|
||||
if (data.phase !== undefined && typeof data.phase !== 'string') {
|
||||
fail('tool-workflow/agent-start phase must be a string when present')
|
||||
}
|
||||
stringId(data.childId, 'tool-workflow/agent-start childId', fail)
|
||||
if (run.members.has(seq)) fail(`tool-workflow/agent-start repeats member seq ${seq} in run ${runId}`)
|
||||
run.members.set(seq, false)
|
||||
return
|
||||
}
|
||||
case 'tool-workflow/agent-end': {
|
||||
const run = openRun(trace, runId, event.type, fail)
|
||||
const seq = memberSeq(data.seq, fail)
|
||||
if (data.outcome !== 'completed' && data.outcome !== 'failed' && data.outcome !== 'cancelled') {
|
||||
fail(`tool-workflow/agent-end outcome ${String(data.outcome)} is invalid`)
|
||||
}
|
||||
const ended = run.members.get(seq)
|
||||
if (ended === undefined) fail(`tool-workflow/agent-end has no matching member seq ${seq} in run ${runId}`)
|
||||
if (ended) fail(`tool-workflow/agent-end repeats member seq ${seq} in run ${runId}`)
|
||||
run.members.set(seq, true)
|
||||
return
|
||||
}
|
||||
case 'tool-workflow/run-end': {
|
||||
const run = openRun(trace, runId, event.type, fail)
|
||||
if (data.stopReason !== 'completed' && data.stopReason !== 'cancelled' && data.stopReason !== 'error') {
|
||||
fail(`tool-workflow/run-end stopReason ${String(data.stopReason)} is invalid`)
|
||||
}
|
||||
const openMembers = [...run.members].filter(([, ended]) => !ended).map(([seq]) => seq)
|
||||
if (openMembers.length > 0) {
|
||||
fail(`tool-workflow/run-end leaves member seq ${openMembers.join(', ')} open in run ${runId}`)
|
||||
}
|
||||
run.ended = true
|
||||
return
|
||||
}
|
||||
default:
|
||||
fail(`unknown tool-workflow event type ${event.type}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one cold-load or live-append candidate through the package reporter. */
|
||||
function applyChecked(trace: WorkflowTrace, event: SessionEvent, fail: InvariantFailure): void {
|
||||
applyEvent(trace, event, fail)
|
||||
}
|
||||
|
||||
/** Install an independent incremental fold over every attached Session. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
const traces = new WeakMap<Session, WorkflowTrace>()
|
||||
const staged = new WeakMap<SessionEvent, { session: Session; trace: WorkflowTrace }>()
|
||||
|
||||
const seed = (session: Session): WorkflowTrace => {
|
||||
const trace: WorkflowTrace = new Map()
|
||||
for (const event of session.events) applyChecked(trace, event, fail)
|
||||
traces.set(session, trace)
|
||||
return trace
|
||||
}
|
||||
/* v8 ignore next -- session/event always follows list() or session/created seeding. */
|
||||
const traceFor = (session: Session): WorkflowTrace => traces.get(session) ?? seed(session)
|
||||
|
||||
for (const session of ctx.sessions.list()) seed(session)
|
||||
ctx.on('session/created', (session) => { seed(session) }, { global: true })
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
const trace = cloneTrace(traceFor(session))
|
||||
applyChecked(trace, event, fail)
|
||||
staged.set(event, { session, trace })
|
||||
}, { global: true })
|
||||
ctx.on('session/event', (session, event) => {
|
||||
const candidate = staged.get(event)
|
||||
/* v8 ignore next 2 -- internal/dispatch stages the exact session/event callback arguments. */
|
||||
if (candidate === undefined || candidate.session !== session) {
|
||||
return fail('session/event reached publication without matching workflow-record validation')
|
||||
}
|
||||
staged.delete(event)
|
||||
traces.set(session, candidate.trace)
|
||||
}, { global: true })
|
||||
}, { inject: ['sessions'] })
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
64
packages/workflow/tool-workflow/src/types.ts
Normal file
64
packages/workflow/tool-workflow/src/types.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Browser-safe durable workflow-record events written by the model-facing
|
||||
* workflow tool into its calling parent Session.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-workflow/types
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type {
|
||||
WorkflowAgentOutcome, WorkflowRunId, WorkflowStopReason,
|
||||
} from '@deepseek-ai/dsh-workflow/types'
|
||||
|
||||
/** Opens one durable top-level workflow run record. */
|
||||
export interface ToolWorkflowRunStartData {
|
||||
readonly runId: WorkflowRunId
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
/** Records one workflow member after its child Session is published. */
|
||||
export interface ToolWorkflowAgentStartData {
|
||||
readonly runId: WorkflowRunId
|
||||
readonly seq: number
|
||||
readonly label: string
|
||||
readonly phase?: string
|
||||
readonly childId: SessionId
|
||||
}
|
||||
|
||||
/** Settles one previously started workflow member. */
|
||||
export interface ToolWorkflowAgentEndData {
|
||||
readonly runId: WorkflowRunId
|
||||
readonly seq: number
|
||||
readonly outcome: WorkflowAgentOutcome
|
||||
}
|
||||
|
||||
/** Settles one workflow run after its live resources reach quiescence. */
|
||||
export interface ToolWorkflowRunEndData {
|
||||
readonly runId: WorkflowRunId
|
||||
readonly stopReason: WorkflowStopReason
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface SessionEventMap {
|
||||
/**
|
||||
* Opens one top-level workflow record.
|
||||
* @param data - stable run identity and display name.
|
||||
*/
|
||||
'tool-workflow/run-start': ToolWorkflowRunStartData
|
||||
/**
|
||||
* Records one published workflow member.
|
||||
* @param data - run identity, member sequence, display identity, and child Session.
|
||||
*/
|
||||
'tool-workflow/agent-start': ToolWorkflowAgentStartData
|
||||
/**
|
||||
* Records one member settlement.
|
||||
* @param data - run identity, paired member sequence, and outcome.
|
||||
*/
|
||||
'tool-workflow/agent-end': ToolWorkflowAgentEndData
|
||||
/**
|
||||
* Closes one workflow record after cleanup.
|
||||
* @param data - stable run identity and terminal reason.
|
||||
*/
|
||||
'tool-workflow/run-end': ToolWorkflowRunEndData
|
||||
}
|
||||
}
|
||||
199
packages/workflow/tool-workflow/tests/invariant.spec.ts
Normal file
199
packages/workflow/tool-workflow/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
|
||||
import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types'
|
||||
import * as ToolWorkflowInvariant from '../src/invariant.ts'
|
||||
import type {} from '../src/types.ts'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(ToolWorkflowInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('durable workflow-record invariants', () => {
|
||||
it('accepts interleaved complete runs and an unfinished continuous prefix', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('workflow-record-valid'))
|
||||
session.append('turn/start', { turn: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
const first = WorkflowRunId('first')
|
||||
const second = WorkflowRunId('second')
|
||||
const third = WorkflowRunId('third')
|
||||
session.append('tool-workflow/run-start', { runId: first, name: 'first' })
|
||||
session.append('tool-workflow/run-start', { runId: second, name: 'second' })
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'),
|
||||
})
|
||||
session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' })
|
||||
session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' })
|
||||
session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' })
|
||||
session.append('tool-workflow/run-start', { runId: third, name: 'third' })
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'),
|
||||
})
|
||||
session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' })
|
||||
session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' })
|
||||
session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' })
|
||||
expect(() => session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'),
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a malformed candidate before commit and keeps the fold reusable', async () => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create(SessionId('workflow-record-invalid'))
|
||||
const runId = WorkflowRunId('run')
|
||||
session.append('tool-workflow/run-start', { runId, name: 'run' })
|
||||
const before = session.seq
|
||||
expect(() => session.append('tool-workflow/agent-end', {
|
||||
runId, seq: 1, outcome: 'completed',
|
||||
})).toThrow(expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-tool-workflow',
|
||||
}))
|
||||
expect(session.seq).toBe(before)
|
||||
expect(() => session.append('tool-workflow/run-end', {
|
||||
runId, stopReason: 'completed',
|
||||
})).not.toThrow()
|
||||
})
|
||||
|
||||
type Mutation = (session: Session, runId: WorkflowRunIdType) => void
|
||||
const appendRaw = (session: Session, type: string, data: unknown): void => {
|
||||
const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown
|
||||
append(type, data)
|
||||
}
|
||||
const invalidCases: readonly [string, Mutation, RegExp][] = [
|
||||
['null event data', (session) => {
|
||||
appendRaw(session, 'tool-workflow/run-start', null)
|
||||
}, /data must be a JSON object/],
|
||||
['primitive event data', (session) => {
|
||||
appendRaw(session, 'tool-workflow/run-start', 1)
|
||||
}, /data must be a JSON object/],
|
||||
['array event data', (session) => {
|
||||
appendRaw(session, 'tool-workflow/run-start', [])
|
||||
}, /data must be a JSON object/],
|
||||
['numeric run id', (session) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'),
|
||||
})
|
||||
}, /runId must be a non-empty string/],
|
||||
['empty run id', (session) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'),
|
||||
})
|
||||
}, /runId must be a non-empty string/],
|
||||
['empty run name', (session) => {
|
||||
session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' })
|
||||
}, /name must be a non-empty string/],
|
||||
['non-string run name', (session) => {
|
||||
session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never })
|
||||
}, /name must be a non-empty string/],
|
||||
['duplicate run', (session, runId) => {
|
||||
session.append('tool-workflow/run-start', { runId, name: 'again' })
|
||||
}, /repeats run/],
|
||||
['missing run', (session) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'),
|
||||
})
|
||||
}, /no matching tool-workflow\/run-start/],
|
||||
['non-positive member seq', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 0, label: 'bad', childId: SessionId('child'),
|
||||
})
|
||||
}, /positive safe integer/],
|
||||
['non-integer member seq', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1.5, label: 'bad', childId: SessionId('child'),
|
||||
})
|
||||
}, /positive safe integer/],
|
||||
['non-string member label', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 1 as never, childId: SessionId('child'),
|
||||
})
|
||||
}, /label must be a string/],
|
||||
['non-string member phase', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'),
|
||||
})
|
||||
}, /phase must be a string/],
|
||||
['empty child id', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'bad', childId: SessionId(''),
|
||||
})
|
||||
}, /childId must be a non-empty string/],
|
||||
['duplicate member start', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'one', childId: SessionId('child'),
|
||||
})
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'two', childId: SessionId('child-2'),
|
||||
})
|
||||
}, /repeats member seq/],
|
||||
['invalid member outcome', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'one', childId: SessionId('child'),
|
||||
})
|
||||
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never })
|
||||
}, /outcome unknown is invalid/],
|
||||
['duplicate member end', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'one', childId: SessionId('child'),
|
||||
})
|
||||
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
|
||||
session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
|
||||
}, /repeats member seq/],
|
||||
['run end with an open member', (session, runId) => {
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'open', childId: SessionId('child'),
|
||||
})
|
||||
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
|
||||
}, /leaves member seq 1 open/],
|
||||
['invalid run stop reason', (session, runId) => {
|
||||
session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never })
|
||||
}, /stopReason unknown is invalid/],
|
||||
['event after run end', (session, runId) => {
|
||||
session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
|
||||
session.append('tool-workflow/agent-start', {
|
||||
runId, seq: 1, label: 'late', childId: SessionId('child'),
|
||||
})
|
||||
}, /appears after/],
|
||||
['unknown workflow event', (session, runId) => {
|
||||
appendRaw(session, 'tool-workflow/unknown', { runId })
|
||||
}, /unknown tool-workflow event type/],
|
||||
]
|
||||
|
||||
it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => {
|
||||
const ctx = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
const runId = WorkflowRunId('run')
|
||||
session.append('tool-workflow/run-start', { runId, name: 'run' })
|
||||
expect(() => { mutate(session, runId) }).toThrow(pattern)
|
||||
})
|
||||
|
||||
it('validates existing cold history while allowing an unfinished prefix', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid'))
|
||||
valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' })
|
||||
valid.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'),
|
||||
})
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined()
|
||||
|
||||
const brokenCtx = new Context()
|
||||
await brokenCtx.plugin(SessionStore)
|
||||
const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid'))
|
||||
broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' })
|
||||
broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' })
|
||||
broken.append('tool-workflow/agent-start', {
|
||||
runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'),
|
||||
})
|
||||
await brokenCtx.plugin(InvariantService, { enabled: true })
|
||||
await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/)
|
||||
})
|
||||
})
|
||||
@@ -3,15 +3,18 @@ import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow'
|
||||
import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow'
|
||||
import type {
|
||||
WorkflowAgentEndInfo, WorkflowAgentInfo, WorkflowResult, WorkflowRun,
|
||||
WorkflowRunId as WorkflowRunIdType, WorkflowStartRequest,
|
||||
} from '@deepseek-ai/dsh-workflow'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as toolWorkflow from '../src/index.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
@@ -20,30 +23,62 @@ class StubEngine extends WorkflowService {
|
||||
requests: WorkflowStartRequest[] = []
|
||||
cancels: string[] = []
|
||||
disposed = 0
|
||||
disposeBarrier: Promise<void> | undefined
|
||||
settle!: (result: WorkflowResult) => void
|
||||
readonly settlements = new Map<WorkflowRunIdType, (result: WorkflowResult) => void>()
|
||||
startError: Error | undefined
|
||||
emitMemberDuringStart = false
|
||||
|
||||
start(request: WorkflowStartRequest): WorkflowRun {
|
||||
if (this.startError) throw this.startError
|
||||
this.requests.push(request)
|
||||
const id = WorkflowRunId(`run-${this.requests.length}`)
|
||||
const result = new Promise<WorkflowResult>((resolve) => { this.settle = resolve })
|
||||
this.settlements.set(id, this.settle)
|
||||
if (this.emitMemberDuringStart) {
|
||||
const info = { id, meta: request.meta }
|
||||
const member = { seq: 1, label: 'synchronous', childId: SessionId('sync-child') }
|
||||
this.emitWorkflowEvent('workflow/agent-start', info, member)
|
||||
this.emitWorkflowEvent('workflow/agent-end', info, { ...member, outcome: 'completed' })
|
||||
}
|
||||
request.signal?.addEventListener('abort', () => {
|
||||
this.settle({ value: null, stopReason: 'cancelled', error: 'signal', agentsStarted: 0 })
|
||||
}, { once: true })
|
||||
return {
|
||||
id: WorkflowRunId('run-1'),
|
||||
meta: { name: 'stub-flow', description: 'd' },
|
||||
id,
|
||||
meta: request.meta,
|
||||
result,
|
||||
cancel: (reason?: string) => {
|
||||
this.cancels.push(reason ?? 'cancelled')
|
||||
this.settle({ value: null, stopReason: 'cancelled', ...reason !== undefined ? { error: reason } : {}, agentsStarted: 0 })
|
||||
},
|
||||
dispose: () => {
|
||||
dispose: async () => {
|
||||
this.disposed += 1
|
||||
return Promise.resolve()
|
||||
await this.disposeBarrier
|
||||
this.settlements.delete(id)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
settleRun(id: WorkflowRunIdType, result: WorkflowResult): void {
|
||||
const settle = this.settlements.get(id)
|
||||
if (settle === undefined) throw new Error(`unknown stub workflow ${id}`)
|
||||
settle(result)
|
||||
}
|
||||
|
||||
agentStart(id: WorkflowRunIdType, agent: WorkflowAgentInfo): void {
|
||||
this.emitWorkflowEvent('workflow/agent-start', {
|
||||
id,
|
||||
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
|
||||
}, agent)
|
||||
}
|
||||
|
||||
agentEnd(id: WorkflowRunIdType, agent: WorkflowAgentEndInfo): void {
|
||||
this.emitWorkflowEvent('workflow/agent-end', {
|
||||
id,
|
||||
meta: this.requests[Number(String(id).slice(4)) - 1]!.meta,
|
||||
}, agent)
|
||||
}
|
||||
}
|
||||
|
||||
async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
||||
@@ -53,14 +88,19 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) {
|
||||
await ctx.plugin(StubEngine)
|
||||
await ctx.plugin(toolWorkflow, config ?? {})
|
||||
const engine = ctx.workflows as StubEngine
|
||||
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
|
||||
return { ctx, engine, parent }
|
||||
const session = Session.create(SessionId('caller'))
|
||||
const parent = { id: session.id, options: {}, session } as unknown as Agent
|
||||
return { ctx, engine, parent, session }
|
||||
}
|
||||
|
||||
const SCRIPT = 'return 1'
|
||||
const META = { name: 'audit', description: 'd' }
|
||||
|
||||
function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?: AbortSignal }): Promise<ToolExecutionResult> {
|
||||
function execute(ctx: Context, args: unknown, extra?: {
|
||||
agent?: Agent
|
||||
signal?: AbortSignal
|
||||
parent?: ToolExecutionToken
|
||||
}): Promise<ToolExecutionResult> {
|
||||
return ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: CallId('call-1'),
|
||||
@@ -68,6 +108,7 @@ function execute(ctx: Context, args: unknown, extra?: { agent?: Agent; signal?:
|
||||
arguments: args,
|
||||
...extra?.agent ? { agent: extra.agent } : {},
|
||||
...extra?.signal ? { signal: extra.signal } : {},
|
||||
...extra?.parent ? { parent: extra.parent } : {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -90,6 +131,167 @@ describe('dsh-tool-workflow', () => {
|
||||
expect(engine.disposed).toBe(1)
|
||||
})
|
||||
|
||||
it('records one top-level run and its members in the calling Session after cleanup', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
const runId = WorkflowRunId('run-1')
|
||||
engine.agentStart(runId, {
|
||||
seq: 1,
|
||||
label: '',
|
||||
phase: '',
|
||||
childId: SessionId('child-1'),
|
||||
})
|
||||
engine.agentEnd(runId, {
|
||||
seq: 1,
|
||||
label: '',
|
||||
phase: '',
|
||||
childId: SessionId('child-1'),
|
||||
outcome: 'completed',
|
||||
})
|
||||
engine.settleRun(runId, { value: 1, stopReason: 'completed', agentsStarted: 1 })
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(engine.disposed).toBe(1)
|
||||
expect(session.events.map(event => [event.type, event.data])).toEqual([
|
||||
['tool-workflow/run-start', { runId: 'run-1', name: 'audit' }],
|
||||
['tool-workflow/agent-start', {
|
||||
runId: 'run-1', seq: 1, label: '', phase: '', childId: 'child-1',
|
||||
}],
|
||||
['tool-workflow/agent-end', { runId: 'run-1', seq: 1, outcome: 'completed' }],
|
||||
['tool-workflow/run-end', { runId: 'run-1', stopReason: 'completed' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('writes run-end only after run disposal reaches quiescence', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const barrier = Promise.withResolvers<undefined>()
|
||||
engine.disposeBarrier = barrier.promise
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
engine.settleRun(WorkflowRunId('run-1'), {
|
||||
value: null, stopReason: 'completed', agentsStarted: 0,
|
||||
})
|
||||
await vi.waitFor(() => { expect(engine.disposed).toBe(1) })
|
||||
expect(session.events.map(event => event.type)).toEqual(['tool-workflow/run-start'])
|
||||
barrier.resolve(undefined)
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'tool-workflow/run-start', 'tool-workflow/run-end',
|
||||
])
|
||||
})
|
||||
|
||||
it('records zero-member and concurrent runs independently', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const first = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'first' } }, { agent: parent })
|
||||
const second = execute(ctx, { script: SCRIPT, meta: { ...META, name: 'second' } }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(2) })
|
||||
const secondId = WorkflowRunId('run-2')
|
||||
engine.agentStart(secondId, {
|
||||
seq: 1, label: 'member', childId: SessionId('child-2'),
|
||||
})
|
||||
engine.agentEnd(secondId, {
|
||||
seq: 1, label: 'member', childId: SessionId('child-2'), outcome: 'failed',
|
||||
})
|
||||
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
|
||||
engine.settleRun(secondId, { value: null, stopReason: 'error', error: 'child failed', agentsStarted: 1 })
|
||||
expect((await first).isError).toBe(false)
|
||||
expect((await second).isError).toBe(true)
|
||||
expect(session.events.filter(event => event.type === 'tool-workflow/agent-start'))
|
||||
.toHaveLength(1)
|
||||
expect(session.events.filter(event => event.type === 'tool-workflow/run-end').map(event => event.data))
|
||||
.toEqual([
|
||||
{ runId: 'run-1', stopReason: 'completed' },
|
||||
{ runId: 'run-2', stopReason: 'error' },
|
||||
])
|
||||
})
|
||||
|
||||
it('buffers synchronous member events until start returns the run identity', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
engine.emitMemberDuringStart = true
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
engine.settleRun(WorkflowRunId('run-1'), {
|
||||
value: null, stopReason: 'completed', agentsStarted: 1,
|
||||
})
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(session.events.map(event => event.type)).toEqual([
|
||||
'tool-workflow/run-start',
|
||||
'tool-workflow/agent-start',
|
||||
'tool-workflow/agent-end',
|
||||
'tool-workflow/run-end',
|
||||
])
|
||||
})
|
||||
|
||||
it('does not record nested transport executions', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, {
|
||||
agent: parent,
|
||||
parent: Symbol('outer') as ToolExecutionToken,
|
||||
})
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
engine.settleRun(WorkflowRunId('run-1'), { value: null, stopReason: 'completed', agentsStarted: 0 })
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(session.events).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'tool-workflow/run-start',
|
||||
'tool-workflow/agent-start',
|
||||
'tool-workflow/agent-end',
|
||||
'tool-workflow/run-end',
|
||||
] as const)('isolates a first append failure at %s and preserves a valid prefix', async (failedType) => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
const append = session.append.bind(session)
|
||||
session.append = ((type: Parameters<Session['append']>[0], data: never) => {
|
||||
if (type === failedType) throw new Error(`injected ${failedType} failure`)
|
||||
return append(type, data)
|
||||
}) as Session['append']
|
||||
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
const runId = WorkflowRunId('run-1')
|
||||
engine.agentStart(runId, {
|
||||
seq: 1, label: 'member', childId: SessionId('child-1'),
|
||||
})
|
||||
engine.agentEnd(runId, {
|
||||
seq: 1, label: 'member', childId: SessionId('child-1'), outcome: 'completed',
|
||||
})
|
||||
engine.settleRun(runId, { value: null, stopReason: 'completed', agentsStarted: 1 })
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(engine.disposed).toBe(1)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain(failedType)
|
||||
const types = session.events.map(event => event.type)
|
||||
const expectedPrefixes = {
|
||||
'tool-workflow/run-start': [],
|
||||
'tool-workflow/agent-start': ['tool-workflow/run-start'],
|
||||
'tool-workflow/agent-end': ['tool-workflow/run-start', 'tool-workflow/agent-start'],
|
||||
'tool-workflow/run-end': [
|
||||
'tool-workflow/run-start', 'tool-workflow/agent-start', 'tool-workflow/agent-end',
|
||||
],
|
||||
} as const
|
||||
expect(types).toEqual(expectedPrefixes[failedType])
|
||||
})
|
||||
|
||||
it('contains an append failure whose thrown value cannot be rendered', async () => {
|
||||
const { ctx, engine, parent, session } = await setup()
|
||||
const warnings: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn
|
||||
session.append = () => {
|
||||
throw { toString: () => { throw new Error('coercion trap') } }
|
||||
}
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
await vi.waitFor(() => { expect(engine.requests).toHaveLength(1) })
|
||||
engine.settleRun(WorkflowRunId('run-1'), {
|
||||
value: null, stopReason: 'completed', agentsStarted: 0,
|
||||
})
|
||||
expect((await pending).isError).toBe(false)
|
||||
expect(warnings).toHaveLength(1)
|
||||
expect(warnings[0]).toContain('[unrenderable thrown value]')
|
||||
})
|
||||
|
||||
it('maps a non-completed stop reason to an isError result (and still disposes)', async () => {
|
||||
const { ctx, engine, parent } = await setup()
|
||||
const pending = execute(ctx, { script: SCRIPT, meta: META }, { agent: parent })
|
||||
@@ -251,7 +453,8 @@ describe('dsh-tool-workflow', () => {
|
||||
})
|
||||
await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 })
|
||||
await ctx.plugin(toolWorkflow, {})
|
||||
const parent = { id: SessionId('caller'), options: {} } as unknown as Agent
|
||||
const session = Session.create(SessionId('caller'))
|
||||
const parent = { id: session.id, options: {}, session } as unknown as Agent
|
||||
const controller = new AbortController()
|
||||
const pending = execute(ctx, {
|
||||
script: 'await new Promise(() => {})\nreturn 1',
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user