Files
jianf 5c6aaf53cf feat(memory): land the loop ports without wiring them
The four files under lib/loop/ were written against 0.2.1-rc.2 and have been
verified to import on their own; they are the substrate a replacement loop
needs to take over step(). This commit only puts them in the tree — lib/loop.js
is untouched, so nothing imports them yet and behaviour is unchanged.

Two packaging facts they bring with them:

  files  `lib/*.js` does not recurse, so lib/loop/ was shipping nothing.
         `lib/loop.js` and `lib/loop/*.js` are now listed separately because
         neither glob covers the other.
  peer   runtime-context.js imports @deepseek-ai/dsh-session, which the peer
         table never declared. G2b's allowlist covered it, so the static guard
         passed over a real declaration gap.

test/loop-ports.test.js pins the port interface rather than its behaviour: the
modules import alone, the export names survive, and the inbox projection keeps
key "inbox" / stateVersion 1. That literal pair is a wire format shared with
stock-loop sessions — a different key silently reads as "no pending input", a
different version makes stateSchema.parse refuse stored rows. The schema's
parse() is also exercised against seven malformed persisted rows, since
replacing a zod schema with a hand-written check is exactly the kind of change
that can be a no-op and still look fine.
2026-11-04 17:27:36 +08:00

255 lines
9.3 KiB
JavaScript

/**
* Schedules one assistant step's tool calls. Exclusive calls form barriers;
* parallel calls use a bounded rolling pool and are reclassified before start.
* Dispatch may overlap, while policy, results, and result context remain
* model-ordered. Abort or an internal scheduler failure stops replenishment
* and drains started calls.
*
* Reproduced from `dsh-agent-loop/lib/types/tool-calls.js:474-708`
* (0.2.1-rc.2, MIT); the loop replacement deliberately changes no scheduling
* behaviour, so this file is a port rather than a rewrite.
*
* ★ One substitution: the host reads its parallelism cap from
* `ctx.agentLoop.config.maxParallelToolCalls` (dsh-agent-loop/lib/index.js:553),
* a service this plugin does not provide (its only consumer is the Web settings
* panel, which is out of scope). The caller passes the same value explicitly
* through {@link ToolCallSchedulerOptions.maxParallelToolCalls} instead; the
* cap is still a live getter, so a dynamic override keeps working.
*
* @module dsh-memory/lib/loop/tool-calls
*/
import { createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_RUNTIME_SCHEDULER } from '@deepseek-ai/dsh-tools'
/**
* Throw on a value the compiler proved unreachable.
*
* Inlined rather than imported from `@deepseek-ai/dsh-util-values`, and that is a
* G2b decision, not an oversight: `dsh-util-values` is a *dependency* of
* `dsh-agent-loop`, not one of its peers (dsh-agent-loop/package.json). Adding it
* here would therefore introduce a runtime dependency this plugin carries itself
* — exactly what G2b exists to catch — and it would break H5 (no new deps) for a
* three-line helper. The four lines below are the upstream body; the message
* prefix stays identical so a log line still names the same call site.
*
* @param {never} value the value that cannot occur
* @param {string} [context] name of the switch that produced it
* @throws {Error} always
*/
function assertNever(value, context) {
const rendered = JSON.stringify(value) ?? String(value)
throw new Error(`unreachable variant${context ? ` in ${context}` : ''}: ${rendered}`)
}
/**
* Schedule one assistant step's tool calls by their live concurrency mode.
*
* Ordinary completion and abort commit started-call results in order. Abort
* drains them, records synthetic results for unstarted calls, and returns with
* the signal still aborted after accepting started-call context through the
* caller-supplied acceptor.
*
* @param {object} ctx loop context that owns the tool registry and carries the initiating Agent
* @param {number} turn current turn number
* @param {number} step current step number
* @param {Array<object>} toolCalls assistant calls in model order
* @param {AbortSignal} signal abort signal shared by the step
* @param {(context: object) => void} acceptContext accepts committed result context for the next step
* @param {{maxParallelToolCalls: () => number}} options the live parallelism cap
* @returns {Promise<{concluded: boolean}>}
*/
export async function executeToolCalls(ctx, turn, step, toolCalls, signal, acceptContext, options) {
const agent = ctx.agents.requireInitiator()
const { session } = agent
const planned = toolCalls.map((block) => ({
block,
exec: { callId: block.id, name: block.name, arguments: parseArguments(block.arguments), agent, signal },
}))
let next = 0
let concluded = false
while (next < planned.length) {
const first = planned[next]
const mode = ctx.tools.executionMode(first.exec).kind
const outcome = await runGroup(
ctx,
turn,
step,
mode === 'parallel' ? planned.slice(next) : [first],
mode,
signal,
acceptContext,
options,
)
next += outcome.consumed
concluded ||= outcome.concluded
if (outcome.aborted) {
for (const call of planned.slice(next)) appendSkippedToolCall(session, turn, step, call.block)
return { concluded }
}
}
return { concluded }
}
/** Parse model arguments, preserving invalid JSON as text and mapping empty input to `{}`. */
function parseArguments(raw) {
try {
return raw ? JSON.parse(raw) : {}
} catch {
return raw
}
}
/**
* Run one exclusive barrier or parallel pool.
*
* @param {object} ctx
* @param {number} turn
* @param {number} step
* @param {Array<{block: object, exec: object}>} group
* @param {'parallel'|'exclusive'} mode
* @param {AbortSignal} signal
* @param {(context: object) => void} acceptContext
* @param {{maxParallelToolCalls: () => number}} options
*/
async function runGroup(ctx, turn, step, group, mode, signal, acceptContext, options) {
const { session } = ctx.agents.requireInitiator()
const maxParallelToolCalls = options.maxParallelToolCalls()
const slots = group.map(() => undefined)
const callSeqs = group.map(() => undefined)
let nextToStart = 0
let committed = 0
let started = 0
let aborted = signal.aborted
let concluded = false
let schedulerFailure
const throwSchedulerFailure = () => {
if (schedulerFailure !== undefined) throw schedulerFailure.error
}
const commitReady = async () => {
while (committed < group.length) {
const slot = slots[committed]
if (slot === undefined) break
const call = group[committed]
const result = slot.needsPost
? await ctx.tools[TOOL_RUNTIME_SCHEDULER].finalize(slot.exec, slot.result)
: ctx.tools[TOOL_RUNTIME_SCHEDULER].finish(slot.exec, slot.result)
appendToolResult(session, turn, step, call.block, result, callSeqs[committed])
for (const context of result.additionalContexts ?? []) acceptContext(context)
concluded ||= result.concludesTurn === true
committed++
}
}
const inFlight = new Map()
const startCall = async (index) => {
const call = group[index]
callSeqs[index] = appendToolCall(session, turn, step, call.block)
started++
const prepared = await ctx.tools[TOOL_RUNTIME_SCHEDULER].prepare(call.exec)
throwSchedulerFailure()
switch (prepared.kind) {
case 'dispatch': {
const promise = ctx.tools[TOOL_RUNTIME_SCHEDULER].dispatch(prepared.exec).then(
(outcome) => {
slots[index] = { exec: prepared.exec, result: outcome.result, needsPost: outcome.kind === 'post-result' }
return index
},
(error) => {
schedulerFailure ??= { error }
return index
},
)
inFlight.set(index, promise)
break
}
case 'post-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: true }
break
case 'final-result':
slots[index] = { exec: prepared.exec, result: prepared.result, needsPost: false }
break
default:
assertNever(prepared, 'tool-call scheduler prepare result')
}
}
const fillPool = async () => {
while (!aborted && nextToStart < group.length && inFlight.size < maxParallelToolCalls) {
const nextCall = group[nextToStart]
if (nextToStart > 0 && mode === 'parallel' && ctx.tools.executionMode(nextCall.exec).kind !== 'parallel') break
await startCall(nextToStart)
nextToStart++
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
if (signal.aborted) aborted = true
}
}
try {
await fillPool()
while (inFlight.size > 0) {
const settledIndex = await Promise.race(inFlight.values())
inFlight.delete(settledIndex)
throwSchedulerFailure()
await commitReady()
throwSchedulerFailure()
if (signal.aborted) aborted = true
await fillPool()
}
} catch (error) {
schedulerFailure ??= { error }
await Promise.allSettled(inFlight.values())
throw schedulerFailure.error
}
if (aborted) {
for (const call of group.slice(started)) appendSkippedToolCall(session, turn, step, call.block)
return { consumed: group.length, aborted: true, concluded }
}
if (committed !== started) throw new Error('tool-call scheduler: uncommitted settled calls')
return { consumed: started, aborted: false, concluded }
}
/** Append the durable call/result pair for a model call skipped after cancellation. */
function appendSkippedToolCall(session, turn, step, block) {
const callSeq = appendToolCall(session, turn, step, block)
appendToolResult(
session,
turn,
step,
block,
{
content: [{ type: 'text', text: 'Error: tool call aborted before dispatch' }],
isError: true,
error: {
message: 'tool call aborted before dispatch',
info: { name: 'AbortError', code: TOOL_ABORTED_BEFORE_DISPATCH },
},
},
callSeq,
)
}
/** Append a started call and return the event seq that its result must cite. */
function appendToolCall(session, turn, step, block) {
return session.append('tool/call', { turn, step, callId: block.id, name: block.name, arguments: block.arguments }).seq
}
/** Append a model-ordered result linked to its call event. */
function appendToolResult(session, turn, step, block, result, callSeq) {
const message = createToolResultMessage({
callId: block.id,
content: result.content,
isError: result.isError,
})
session.append(
'tool/result',
{
turn,
step,
message,
...(result.error?.info ? { error: result.error.info } : {}),
...(result.meta !== undefined ? { meta: result.meta } : {}),
},
{ surfaceOp: 'append', sourceEventSeqs: [callSeq] },
)
}