@namzu/sdk 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +167 -0
- package/dist/agents/ReactiveAgent.d.ts.map +1 -1
- package/dist/agents/ReactiveAgent.js +5 -0
- package/dist/agents/ReactiveAgent.js.map +1 -1
- package/dist/agents/SupervisorAgent.d.ts +22 -1
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +47 -14
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-ledger.test.d.ts +2 -0
- package/dist/agents/__tests__/supervisor-ledger.test.d.ts.map +1 -0
- package/dist/agents/__tests__/supervisor-ledger.test.js +131 -0
- package/dist/agents/__tests__/supervisor-ledger.test.js.map +1 -0
- package/dist/config/runtime.d.ts +24 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +9 -0
- package/dist/config/runtime.js.map +1 -1
- package/dist/connector/builtins/webhook.test.js +17 -0
- package/dist/connector/builtins/webhook.test.js.map +1 -1
- package/dist/connector/index.d.ts +1 -0
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -0
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +4 -0
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +1 -0
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +1 -0
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/streamable-http.d.ts +22 -0
- package/dist/connector/mcp/streamable-http.d.ts.map +1 -0
- package/dist/connector/mcp/streamable-http.js +128 -0
- package/dist/connector/mcp/streamable-http.js.map +1 -0
- package/dist/connector/mcp/streamable-http.test.d.ts +2 -0
- package/dist/connector/mcp/streamable-http.test.d.ts.map +1 -0
- package/dist/connector/mcp/streamable-http.test.js +132 -0
- package/dist/connector/mcp/streamable-http.test.js.map +1 -0
- package/dist/persona/assembler.d.ts.map +1 -1
- package/dist/persona/assembler.js +18 -9
- package/dist/persona/assembler.js.map +1 -1
- package/dist/provider/collect.d.ts.map +1 -1
- package/dist/provider/collect.js +4 -1
- package/dist/provider/collect.js.map +1 -1
- package/dist/public-runtime.d.ts +1 -1
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +1 -1
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/execute.d.ts +27 -0
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +124 -21
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.d.ts +9 -6
- package/dist/registry/tool/execute.test.d.ts.map +1 -1
- package/dist/registry/tool/execute.test.js +72 -10
- package/dist/registry/tool/execute.test.js.map +1 -1
- package/dist/registry/toolset/catalog.test.js +62 -0
- package/dist/registry/toolset/catalog.test.js.map +1 -1
- package/dist/runtime/query/__tests__/deferred-tools.test.js +43 -5
- package/dist/runtime/query/__tests__/deferred-tools.test.js.map +1 -1
- package/dist/runtime/query/__tests__/long-document-flow.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/long-document-flow.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/long-document-flow.test.js +154 -0
- package/dist/runtime/query/__tests__/long-document-flow.test.js.map +1 -0
- package/dist/runtime/query/__tests__/prompt.test.js +15 -11
- package/dist/runtime/query/__tests__/prompt.test.js.map +1 -1
- package/dist/runtime/query/index.d.ts +9 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +23 -2
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts +2 -0
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +99 -6
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/checkpoint.js +2 -2
- package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.d.ts +8 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +84 -3
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +12 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js +53 -0
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/hitl-abort-race.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js +73 -0
- package/dist/runtime/query/iteration/phases/hitl-abort-race.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/hitl-answer-question.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js +120 -0
- package/dist/runtime/query/iteration/phases/hitl-answer-question.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/tool-review.d.ts +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/tool-review.js +7 -2
- package/dist/runtime/query/iteration/phases/tool-review.js.map +1 -1
- package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts +19 -0
- package/dist/runtime/query/iteration/phases/working-memory-compaction.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js +177 -0
- package/dist/runtime/query/iteration/phases/working-memory-compaction.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/working-memory.d.ts +31 -0
- package/dist/runtime/query/iteration/phases/working-memory.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/working-memory.js +81 -0
- package/dist/runtime/query/iteration/phases/working-memory.js.map +1 -0
- package/dist/session/workspace/__tests__/shared-run.test.js +1 -1
- package/dist/session/workspace/__tests__/shared-run.test.js.map +1 -1
- package/dist/session/workspace/shared-run.js +1 -0
- package/dist/session/workspace/shared-run.js.map +1 -1
- package/dist/skills/loader.test.js +8 -8
- package/dist/skills/loader.test.js.map +1 -1
- package/dist/streaming/coalesce.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/payload-budget.test.js +48 -0
- package/dist/tools/builtins/__tests__/payload-budget.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/read-file.test.js +9 -0
- package/dist/tools/builtins/__tests__/read-file.test.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +50 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/search-tools.d.ts.map +1 -1
- package/dist/tools/builtins/search-tools.js +32 -7
- package/dist/tools/builtins/search-tools.js.map +1 -1
- package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/approve-plan.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/approve-plan.test.js +114 -0
- package/dist/tools/coordinator/__tests__/approve-plan.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js +309 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts +8 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +195 -15
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/index.d.ts +1 -0
- package/dist/types/agent/index.d.ts.map +1 -1
- package/dist/types/agent/index.js +1 -0
- package/dist/types/agent/index.js.map +1 -1
- package/dist/types/agent/reactive.d.ts +15 -0
- package/dist/types/agent/reactive.d.ts.map +1 -1
- package/dist/types/agent/supervisor.d.ts +18 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/agent/working-memory.d.ts +29 -0
- package/dist/types/agent/working-memory.d.ts.map +1 -0
- package/dist/types/agent/working-memory.js +2 -0
- package/dist/types/agent/working-memory.js.map +1 -0
- package/dist/types/common/index.d.ts +10 -0
- package/dist/types/common/index.d.ts.map +1 -1
- package/dist/types/common/index.js +18 -0
- package/dist/types/common/index.js.map +1 -1
- package/dist/types/common/usage-merge.test.d.ts +2 -0
- package/dist/types/common/usage-merge.test.d.ts.map +1 -0
- package/dist/types/common/usage-merge.test.js +48 -0
- package/dist/types/common/usage-merge.test.js.map +1 -0
- package/dist/types/connector/mcp.d.ts +9 -2
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/hitl/index.d.ts +42 -0
- package/dist/types/hitl/index.d.ts.map +1 -1
- package/dist/types/hitl/index.js +11 -0
- package/dist/types/hitl/index.js.map +1 -1
- package/dist/types/provider/chat.d.ts +9 -0
- package/dist/types/provider/chat.d.ts.map +1 -1
- package/dist/version.d.ts.map +1 -1
- package/dist/version.js +25 -4
- package/dist/version.js.map +1 -1
- package/package.json +1 -1
- package/src/agents/ReactiveAgent.ts +5 -0
- package/src/agents/SupervisorAgent.ts +56 -16
- package/src/agents/__tests__/supervisor-ledger.test.ts +185 -0
- package/src/config/runtime.ts +9 -0
- package/src/connector/builtins/webhook.test.ts +21 -0
- package/src/connector/index.ts +1 -0
- package/src/connector/mcp/client.ts +4 -0
- package/src/connector/mcp/index.ts +1 -0
- package/src/connector/mcp/streamable-http.test.ts +169 -0
- package/src/connector/mcp/streamable-http.ts +152 -0
- package/src/persona/assembler.ts +18 -6
- package/src/provider/collect.ts +4 -1
- package/src/public-runtime.ts +1 -0
- package/src/registry/tool/execute.test.ts +85 -10
- package/src/registry/tool/execute.ts +124 -21
- package/src/registry/toolset/catalog.test.ts +68 -0
- package/src/runtime/query/__tests__/deferred-tools.test.ts +52 -5
- package/src/runtime/query/__tests__/long-document-flow.test.ts +180 -0
- package/src/runtime/query/__tests__/prompt.test.ts +15 -11
- package/src/runtime/query/index.ts +34 -2
- package/src/runtime/query/iteration/index.ts +98 -6
- package/src/runtime/query/iteration/phases/checkpoint.ts +7 -2
- package/src/runtime/query/iteration/phases/compaction.ts +89 -3
- package/src/runtime/query/iteration/phases/context.ts +58 -0
- package/src/runtime/query/iteration/phases/hitl-abort-race.test.ts +86 -0
- package/src/runtime/query/iteration/phases/hitl-answer-question.test.ts +139 -0
- package/src/runtime/query/iteration/phases/tool-review.ts +7 -3
- package/src/runtime/query/iteration/phases/working-memory-compaction.test.ts +215 -0
- package/src/runtime/query/iteration/phases/working-memory.ts +86 -0
- package/src/session/workspace/__tests__/shared-run.test.ts +1 -1
- package/src/session/workspace/shared-run.ts +1 -0
- package/src/skills/loader.test.ts +8 -8
- package/src/streaming/coalesce.test.ts +3 -3
- package/src/tools/builtins/__tests__/payload-budget.test.ts +63 -0
- package/src/tools/builtins/__tests__/read-file.test.ts +12 -0
- package/src/tools/builtins/read-file.ts +50 -2
- package/src/tools/builtins/search-tools.ts +38 -7
- package/src/tools/coordinator/__tests__/approve-plan.test.ts +139 -0
- package/src/tools/coordinator/__tests__/ask-user-question.test.ts +370 -0
- package/src/tools/coordinator/index.ts +227 -17
- package/src/types/agent/index.ts +1 -0
- package/src/types/agent/reactive.ts +17 -0
- package/src/types/agent/supervisor.ts +20 -0
- package/src/types/agent/working-memory.ts +29 -0
- package/src/types/common/index.ts +19 -0
- package/src/types/common/usage-merge.test.ts +53 -0
- package/src/types/connector/mcp.ts +14 -2
- package/src/types/hitl/index.ts +52 -1
- package/src/types/provider/chat.ts +10 -0
- package/src/version.ts +26 -4
|
@@ -9,6 +9,8 @@ import { collect } from '../../../provider/collect.js'
|
|
|
9
9
|
import type { ActivityStore } from '../../../store/activity/memory.js'
|
|
10
10
|
import { GENAI, NAMZU, agentIterationSpanName } from '../../../telemetry/attributes.js'
|
|
11
11
|
import { getTracer } from '../../../telemetry/runtime-accessors.js'
|
|
12
|
+
import type { WorkingMemoryProvider } from '../../../types/agent/working-memory.js'
|
|
13
|
+
import { mergeTokenUsage } from '../../../types/common/index.js'
|
|
12
14
|
import type { ResumeHandler } from '../../../types/hitl/index.js'
|
|
13
15
|
import type { ToolUseId } from '../../../types/ids/index.js'
|
|
14
16
|
import { createAssistantMessage, createUserMessage } from '../../../types/message/index.js'
|
|
@@ -35,6 +37,7 @@ import { runCompactionCheck } from './phases/compaction.js'
|
|
|
35
37
|
import type { IterationContext } from './phases/index.js'
|
|
36
38
|
import { runPlanGate } from './phases/plan.js'
|
|
37
39
|
import { runToolReview } from './phases/tool-review.js'
|
|
40
|
+
import { refreshWorkingMemory } from './phases/working-memory.js'
|
|
38
41
|
|
|
39
42
|
export type { IterationContext } from './phases/index.js'
|
|
40
43
|
export type { PhaseSignal } from './phases/index.js'
|
|
@@ -53,6 +56,7 @@ export interface IterationConfig {
|
|
|
53
56
|
>
|
|
54
57
|
compactionConfig?: CompactionConfig
|
|
55
58
|
workingStateManager?: WorkingStateManager
|
|
59
|
+
workingMemoryProvider?: WorkingMemoryProvider
|
|
56
60
|
advisoryCtx?: AdvisoryContext
|
|
57
61
|
agentBus?: import('../../../bus/index.js').AgentBus
|
|
58
62
|
verificationGate?: import('../../../verification/gate.js').VerificationGate
|
|
@@ -192,8 +196,35 @@ async function* streamProviderTurn(
|
|
|
192
196
|
|
|
193
197
|
const stream = provider.chatStream({ ...params, stream: true }) as AsyncIterable<StreamChunk>
|
|
194
198
|
|
|
199
|
+
// Drive the stream manually so each `.next()` can be RACED against the run
|
|
200
|
+
// abort: a Stop tears the in-flight model request down (the provider got
|
|
201
|
+
// `params.signal`), and we ALSO stop pulling within a tick even if a
|
|
202
|
+
// transport buffers or ignores the signal. The abort rejection propagates
|
|
203
|
+
// out of this generator so the run loop settles the turn as cancelled.
|
|
204
|
+
// `{ once: true }` keeps a multi-iteration run from leaking a listener/turn.
|
|
205
|
+
const it = stream[Symbol.asyncIterator]()
|
|
206
|
+
const signal = params.signal
|
|
207
|
+
let onAbort: (() => void) | undefined
|
|
208
|
+
const aborted: Promise<never> | undefined = signal
|
|
209
|
+
? new Promise<never>((_resolve, reject) => {
|
|
210
|
+
if (signal.aborted) {
|
|
211
|
+
reject(signal.reason)
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
onAbort = () => reject(signal.reason)
|
|
215
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
216
|
+
})
|
|
217
|
+
: undefined
|
|
218
|
+
|
|
195
219
|
try {
|
|
196
|
-
for
|
|
220
|
+
for (;;) {
|
|
221
|
+
const next = it.next()
|
|
222
|
+
// Neutralize the dangling loser so an eventual rejection of the
|
|
223
|
+
// un-awaited `next` is never an unhandled rejection.
|
|
224
|
+
if (aborted) next.catch(() => {})
|
|
225
|
+
const res = await (aborted ? Promise.race([next, aborted]) : next)
|
|
226
|
+
if (res.done) break
|
|
227
|
+
const chunk = res.value
|
|
197
228
|
if (chunk.error) {
|
|
198
229
|
streamError = chunk.error
|
|
199
230
|
break
|
|
@@ -292,10 +323,24 @@ async function* streamProviderTurn(
|
|
|
292
323
|
}
|
|
293
324
|
|
|
294
325
|
if (chunk.finishReason) finishReason = chunk.finishReason
|
|
295
|
-
|
|
326
|
+
// Merge (per-field max), not last-write-wins: a late usage frame that
|
|
327
|
+
// omits input/cache tokens must not zero the counts seen earlier in the
|
|
328
|
+
// stream, which would under-report this turn's accumulated usage.
|
|
329
|
+
if (chunk.usage) usage = mergeTokenUsage(usage, chunk.usage)
|
|
296
330
|
}
|
|
297
331
|
} catch (err) {
|
|
332
|
+
// An abort tears the turn down: propagate it so the run loop settles the
|
|
333
|
+
// run as cancelled rather than recording a normal (errored) turn. Any
|
|
334
|
+
// other stream error is captured into the synthesized response as before.
|
|
335
|
+
if (signal?.aborted) throw err
|
|
298
336
|
streamError = err instanceof Error ? err.message : String(err)
|
|
337
|
+
} finally {
|
|
338
|
+
if (onAbort) signal?.removeEventListener('abort', onAbort)
|
|
339
|
+
// Release the underlying connection on every exit (natural end, error,
|
|
340
|
+
// or abort). `for await` did this implicitly on natural completion; the
|
|
341
|
+
// manual drive must do it explicitly. `.return()` on an already-finished
|
|
342
|
+
// provider generator is a no-op.
|
|
343
|
+
await it.return?.().catch(() => {})
|
|
299
344
|
}
|
|
300
345
|
|
|
301
346
|
// Flush any tool buckets the provider failed to close (no toolCallEnd
|
|
@@ -470,6 +515,7 @@ export class IterationOrchestrator {
|
|
|
470
515
|
launchedTasks: config.launchedTasks ?? new Map(),
|
|
471
516
|
compactionConfig: config.compactionConfig,
|
|
472
517
|
workingStateManager: config.workingStateManager,
|
|
518
|
+
workingMemoryProvider: config.workingMemoryProvider,
|
|
473
519
|
advisoryCtx: config.advisoryCtx,
|
|
474
520
|
agentBus: config.agentBus,
|
|
475
521
|
verificationGate: config.verificationGate,
|
|
@@ -572,11 +618,19 @@ export class IterationOrchestrator {
|
|
|
572
618
|
await this.injectOneTaskNotification()
|
|
573
619
|
}
|
|
574
620
|
|
|
621
|
+
// Re-pin the working-memory block from ground truth at the primacy
|
|
622
|
+
// edge BEFORE compaction runs (so the refreshed slot is what
|
|
623
|
+
// compaction preserves). No-op when no provider is configured.
|
|
624
|
+
await refreshWorkingMemory(this.ctx)
|
|
575
625
|
await runCompactionCheck(this.ctx)
|
|
576
626
|
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
627
|
+
// Cache discipline: keep the tools param byte-stable even on the
|
|
628
|
+
// forced-final iteration and forbid tool use via tool_choice
|
|
629
|
+
// 'none' instead. Dropping the tools array would invalidate the
|
|
630
|
+
// entire prompt-cache prefix (tools render at position 0) and
|
|
631
|
+
// risks a 400 because the history still carries
|
|
632
|
+
// tool_use/tool_result blocks.
|
|
633
|
+
const openAITools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
|
|
580
634
|
|
|
581
635
|
const messages = forceFinalize
|
|
582
636
|
? [
|
|
@@ -610,10 +664,15 @@ export class IterationOrchestrator {
|
|
|
610
664
|
{
|
|
611
665
|
model,
|
|
612
666
|
messages,
|
|
613
|
-
tools: openAITools
|
|
667
|
+
tools: openAITools.length > 0 ? openAITools : undefined,
|
|
668
|
+
toolChoice: forceFinalize && openAITools.length > 0 ? 'none' : undefined,
|
|
614
669
|
temperature: runConfig.temperature,
|
|
615
670
|
maxTokens: runConfig.maxResponseTokens,
|
|
616
671
|
cacheControl: { type: 'auto' },
|
|
672
|
+
// Thread the run abort into the model call so a Stop tears the
|
|
673
|
+
// in-flight turn down (provider passes it to fetch; the consumer
|
|
674
|
+
// also races it). Inert when never aborted.
|
|
675
|
+
signal: this.ctx.abortController.signal,
|
|
617
676
|
},
|
|
618
677
|
this.ctx.emitEvent,
|
|
619
678
|
this.ctx.drainPending,
|
|
@@ -771,6 +830,16 @@ export class IterationOrchestrator {
|
|
|
771
830
|
hasToolCalls: false,
|
|
772
831
|
})
|
|
773
832
|
yield* this.ctx.drainPending()
|
|
833
|
+
// A Stop that lands AFTER the final turn streamed but before
|
|
834
|
+
// this break must settle the run as cancelled, not end_turn —
|
|
835
|
+
// otherwise the just-produced answer is recorded as a clean
|
|
836
|
+
// completion. Mirrors the between-iteration cancel at :511.
|
|
837
|
+
if (this.ctx.abortController.signal.aborted) {
|
|
838
|
+
runMgr.setStopReason('cancelled')
|
|
839
|
+
runMgr.markCancelled()
|
|
840
|
+
iterSpan.end()
|
|
841
|
+
break
|
|
842
|
+
}
|
|
774
843
|
runMgr.setStopReason('end_turn')
|
|
775
844
|
iterSpan.end()
|
|
776
845
|
break
|
|
@@ -815,6 +884,20 @@ export class IterationOrchestrator {
|
|
|
815
884
|
yield* this.ctx.drainPending()
|
|
816
885
|
iterSpan.end()
|
|
817
886
|
} catch (err) {
|
|
887
|
+
// A Stop that aborted the in-flight turn surfaces here as a
|
|
888
|
+
// thrown abort (the provider stream was raced against the run
|
|
889
|
+
// signal). Settle it as a CANCELLATION — mirroring the
|
|
890
|
+
// between-iteration cancel at the top of the loop — rather than
|
|
891
|
+
// recording it as an SDK failure (error span + failed activity)
|
|
892
|
+
// and re-throwing. The run then returns cleanly with a
|
|
893
|
+
// 'cancelled' stop reason instead of propagating an error.
|
|
894
|
+
if (this.ctx.abortController.signal.aborted) {
|
|
895
|
+
runMgr.setStopReason('cancelled')
|
|
896
|
+
runMgr.markCancelled()
|
|
897
|
+
iterSpan.end()
|
|
898
|
+
break
|
|
899
|
+
}
|
|
900
|
+
|
|
818
901
|
if (iterationActivity) {
|
|
819
902
|
this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err))
|
|
820
903
|
}
|
|
@@ -948,13 +1031,22 @@ export class IterationOrchestrator {
|
|
|
948
1031
|
),
|
|
949
1032
|
]
|
|
950
1033
|
|
|
1034
|
+
// Same cache discipline as the forced-final iteration: keep the
|
|
1035
|
+
// tools param identical to prior iterations (cache prefix intact,
|
|
1036
|
+
// no 400 on tool blocks in history) and forbid use via tool_choice.
|
|
1037
|
+
const finalTools = this.ctx.tools.toLLMTools(this.ctx.allowedTools)
|
|
951
1038
|
const response = await collect(
|
|
952
1039
|
this.ctx.provider.chatStream({
|
|
953
1040
|
model,
|
|
954
1041
|
messages: finalMessages,
|
|
1042
|
+
tools: finalTools.length > 0 ? finalTools : undefined,
|
|
1043
|
+
toolChoice: finalTools.length > 0 ? 'none' : undefined,
|
|
955
1044
|
temperature: this.ctx.runConfig.temperature,
|
|
956
1045
|
maxTokens: this.ctx.runConfig.maxResponseTokens,
|
|
957
1046
|
cacheControl: { type: 'auto' },
|
|
1047
|
+
// Cancellable too: a Stop during the closing summary must not
|
|
1048
|
+
// stream to completion.
|
|
1049
|
+
signal: this.ctx.abortController.signal,
|
|
958
1050
|
}),
|
|
959
1051
|
)
|
|
960
1052
|
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { RunEvent } from '../../../../types/run/index.js'
|
|
2
2
|
import { CheckpointManager } from '../../checkpoint.js'
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
type IterationContext,
|
|
5
|
+
type PhaseSignal,
|
|
6
|
+
awaitDecisionOrAbort,
|
|
7
|
+
handleHITLDecision,
|
|
8
|
+
} from './context.js'
|
|
4
9
|
|
|
5
10
|
export async function* runIterationCheckpoint(
|
|
6
11
|
ctx: IterationContext,
|
|
@@ -17,7 +22,7 @@ export async function* runIterationCheckpoint(
|
|
|
17
22
|
yield* ctx.drainPending()
|
|
18
23
|
|
|
19
24
|
const summary = CheckpointManager.buildSummary(ctx.runMgr, iterationNum)
|
|
20
|
-
const iterDecision = await ctx
|
|
25
|
+
const iterDecision = await awaitDecisionOrAbort(ctx, {
|
|
21
26
|
type: 'iteration_checkpoint',
|
|
22
27
|
runId: ctx.runMgr.id,
|
|
23
28
|
checkpointId: iterCheckpoint.id,
|
|
@@ -1,12 +1,37 @@
|
|
|
1
|
+
import { findSafeTrimIndex } from '../../../../compaction/dangling.js'
|
|
1
2
|
import { serializeState } from '../../../../compaction/serializer.js'
|
|
2
3
|
import { buildVerifiedSummary } from '../../../../compaction/verifier.js'
|
|
3
4
|
import { CHARS_PER_TOKEN } from '../../../../constants/limits.js'
|
|
4
5
|
import { createSystemMessage } from '../../../../types/message/index.js'
|
|
5
6
|
import type { IterationContext } from './context.js'
|
|
7
|
+
import { isWorkingMemoryMessage } from './working-memory.js'
|
|
6
8
|
|
|
7
9
|
const COMPACTION_HEADER =
|
|
8
10
|
'[COMPACTED CONTEXT] The following is a structured summary of the conversation so far.'
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Identity check for a prior compaction summary in the leading floor. Used to
|
|
14
|
+
* REPLACE one in place on the per-iteration (contextWindowTokens) path so at
|
|
15
|
+
* most one `[COMPACTED CONTEXT]` block ever lives in the never-trimmed floor,
|
|
16
|
+
* and by the checkpoint-restore path to PRESERVE the summary across a resume
|
|
17
|
+
* (it is the only surviving record of the older history the run compacted away).
|
|
18
|
+
*/
|
|
19
|
+
export function isCompactionMessage(content: string | null | undefined): boolean {
|
|
20
|
+
return typeof content === 'string' && content.startsWith(COMPACTION_HEADER)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Minimum number of compactable (older) messages required before a compaction
|
|
25
|
+
* pass is worth running. When there is NOTHING between the never-trimmed
|
|
26
|
+
* leading floor and the recent window, a pass would only replace nothing with a
|
|
27
|
+
* `[COMPACTED CONTEXT]` summary that joins the floor — pure overhead that fires
|
|
28
|
+
* again next iteration (the permanent-floor thrash, ses_055 D7). Set to the
|
|
29
|
+
* literal EMPTY guard so any existing compaction consumer with ≥1 older message
|
|
30
|
+
* stays byte-identical; the per-iteration Vandal path additionally defangs the
|
|
31
|
+
* cost with `llmVerification:false`.
|
|
32
|
+
*/
|
|
33
|
+
const MIN_OLDER_MESSAGES_TO_COMPACT = 1
|
|
34
|
+
|
|
10
35
|
function estimateTokens(ctx: IterationContext): number {
|
|
11
36
|
let chars = 0
|
|
12
37
|
for (const msg of ctx.runMgr.messages) {
|
|
@@ -31,7 +56,13 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
|
|
|
31
56
|
if (!manager) return
|
|
32
57
|
|
|
33
58
|
const estimatedTokens = estimateTokens(ctx)
|
|
34
|
-
|
|
59
|
+
// F1: measure the CURRENT window against the model context window when the
|
|
60
|
+
// host supplies one, falling back to the run-level cumulative `tokenBudget`
|
|
61
|
+
// otherwise. Repointing the SINGLE `budget` assignment moves BOTH the
|
|
62
|
+
// `<= 0` guard AND the divisor at once — so "compaction on + tokenBudget=0"
|
|
63
|
+
// is no longer a silent no-op. Existing consumers (no contextWindowTokens)
|
|
64
|
+
// stay byte-identical.
|
|
65
|
+
const budget = config.contextWindowTokens ?? ctx.runConfig.tokenBudget
|
|
35
66
|
if (budget <= 0) return
|
|
36
67
|
|
|
37
68
|
const usage = estimatedTokens / budget
|
|
@@ -63,10 +94,34 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
|
|
|
63
94
|
}
|
|
64
95
|
if (systemMessages.length === 0) return
|
|
65
96
|
|
|
66
|
-
|
|
97
|
+
// Tool-pair atomicity guard. A naive cut at `length - keepRecentMessages`
|
|
98
|
+
// can land BETWEEN an assistant-with-toolCalls (which would be dropped into
|
|
99
|
+
// `olderMessages`) and its `tool` results (kept in `recentMessages`),
|
|
100
|
+
// leaving orphaned `tool_result` blocks at the head of the recent window.
|
|
101
|
+
// The Anthropic provider then emits a `tool_result` with no matching
|
|
102
|
+
// `tool_use` and the API rejects the next turn with a 400 — so compaction,
|
|
103
|
+
// whose whole job is to keep a long run alive, instead kills it. Snap the
|
|
104
|
+
// boundary FORWARD to a safe point (existing `findSafeTrimIndex`, previously
|
|
105
|
+
// only wired to the unused ConversationManager strategy classes) so no pair
|
|
106
|
+
// is split. Any message this moves out of the recent window is already
|
|
107
|
+
// represented in the extracted WorkingState the summary is built from.
|
|
108
|
+
const keepStart = findSafeTrimIndex(messages, messages.length - config.keepRecentMessages)
|
|
67
109
|
const recentMessages = messages.slice(keepStart)
|
|
68
110
|
const olderMessages = messages.slice(systemMessages.length, keepStart)
|
|
69
111
|
|
|
112
|
+
// D7: nothing meaningful to compact — skip instead of thrashing the
|
|
113
|
+
// permanent leading floor every iteration (and avoid an LLM verification
|
|
114
|
+
// call when there is no older history to summarize). Scoped to the new
|
|
115
|
+
// contextWindowTokens path so any existing consumer (tokenBudget-only) keeps
|
|
116
|
+
// its exact prior behavior — byte-identical (ses_055 verify).
|
|
117
|
+
if (config.contextWindowTokens != null && olderMessages.length < MIN_OLDER_MESSAGES_TO_COMPACT) {
|
|
118
|
+
ctx.log.debug('Skipping compaction — too few older messages', {
|
|
119
|
+
runId: ctx.runMgr.id,
|
|
120
|
+
olderMessages: olderMessages.length,
|
|
121
|
+
})
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
70
125
|
let compactedContent: string
|
|
71
126
|
|
|
72
127
|
if (config.llmVerification && manager.slotCount() < config.richStateThreshold) {
|
|
@@ -77,7 +132,38 @@ export async function runCompactionCheck(ctx: IterationContext): Promise<void> {
|
|
|
77
132
|
|
|
78
133
|
const compactionMessage = createSystemMessage(`${COMPACTION_HEADER}\n\n${compactedContent}`)
|
|
79
134
|
|
|
80
|
-
|
|
135
|
+
// On the new per-iteration (contextWindowTokens) path, drop any PRIOR
|
|
136
|
+
// `[COMPACTED CONTEXT]` summary from the leading floor — `serializeState` is
|
|
137
|
+
// cumulative, so the new summary supersedes it. Without this the never-trimmed
|
|
138
|
+
// floor accumulates one redundant summary per pass and the frequent
|
|
139
|
+
// per-iteration trigger makes that bloat unbounded (ses_055 verify, MEDIUM).
|
|
140
|
+
// Legacy consumers (no contextWindowTokens) keep their exact prior
|
|
141
|
+
// accumulation behavior — byte-identical.
|
|
142
|
+
const preservedSystem =
|
|
143
|
+
config.contextWindowTokens != null
|
|
144
|
+
? systemMessages.filter((m) => !isCompactionMessage(m.content))
|
|
145
|
+
: systemMessages
|
|
146
|
+
const newMessages = [...preservedSystem, compactionMessage, ...recentMessages]
|
|
147
|
+
|
|
148
|
+
// OPAQUE survival guard (ses_055 D1): the pinned working-memory slot is a
|
|
149
|
+
// leading system message, so it is kept in `preservedSystem` (the compaction
|
|
150
|
+
// filter only drops prior `[COMPACTED CONTEXT]` summaries, never the WM slot)
|
|
151
|
+
// and survives for free — this branch is DEFENSIVE-ONLY, exercised only if a
|
|
152
|
+
// future change drops the slot from the rebuilt set. It re-pins the block
|
|
153
|
+
// already present in `messages` (the one `refreshWorkingMemory` placed).
|
|
154
|
+
// Identity is the sentinel HEADER only — no path parsing, no second provider
|
|
155
|
+
// call, no host format knowledge in the SDK.
|
|
156
|
+
const survives = newMessages.some((m) => m.role === 'system' && isWorkingMemoryMessage(m.content))
|
|
157
|
+
if (!survives) {
|
|
158
|
+
const priorSlot = messages.find((m) => m.role === 'system' && isWorkingMemoryMessage(m.content))
|
|
159
|
+
if (priorSlot) {
|
|
160
|
+
// Re-pin as the last leading system message, before the summary.
|
|
161
|
+
newMessages.splice(preservedSystem.length, 0, priorSlot)
|
|
162
|
+
ctx.log.warn('Re-pinned working-memory slot dropped by compaction', {
|
|
163
|
+
runId: ctx.runMgr.id,
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
81
167
|
|
|
82
168
|
const oldCount = messages.length
|
|
83
169
|
messages.length = 0
|
|
@@ -6,6 +6,7 @@ import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
|
|
|
6
6
|
import type { RunPersistence } from '../../../../manager/run/persistence.js'
|
|
7
7
|
import type { ActivityStore } from '../../../../store/activity/memory.js'
|
|
8
8
|
import type { TaskGateway, TaskHandle } from '../../../../types/agent/gateway.js'
|
|
9
|
+
import type { WorkingMemoryProvider } from '../../../../types/agent/working-memory.js'
|
|
9
10
|
import type { HITLResumeDecision, ResumeHandler } from '../../../../types/hitl/index.js'
|
|
10
11
|
import type { TaskId } from '../../../../types/ids/index.js'
|
|
11
12
|
import type { LLMProvider } from '../../../../types/provider/index.js'
|
|
@@ -63,6 +64,8 @@ export interface IterationContext {
|
|
|
63
64
|
|
|
64
65
|
readonly workingStateManager?: WorkingStateManager
|
|
65
66
|
|
|
67
|
+
readonly workingMemoryProvider?: WorkingMemoryProvider
|
|
68
|
+
|
|
66
69
|
readonly advisoryCtx?: AdvisoryContext
|
|
67
70
|
|
|
68
71
|
readonly agentBus?: AgentBus
|
|
@@ -74,6 +77,57 @@ export interface IterationContext {
|
|
|
74
77
|
|
|
75
78
|
export type PhaseSignal = 'continue' | 'stop'
|
|
76
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Await a HITL `resumeHandler` decision, but RACE it against the run's abort
|
|
82
|
+
* signal. A Stop that arrives while the run is parked on a tool-review or
|
|
83
|
+
* iteration checkpoint used to do nothing until the host eventually answered
|
|
84
|
+
* (the park await was not cancellable). Racing the signal lets a Stop resolve
|
|
85
|
+
* the park immediately as an `abort` decision, which `handleHITLDecision`
|
|
86
|
+
* turns into `setStopReason('cancelled') + markCancelled + stop`. Fails closed:
|
|
87
|
+
* a resume-handler rejection also resolves to `abort` rather than hanging.
|
|
88
|
+
*/
|
|
89
|
+
export async function awaitDecisionOrAbort(
|
|
90
|
+
ctx: IterationContext,
|
|
91
|
+
request: Parameters<ResumeHandler>[0],
|
|
92
|
+
): Promise<HITLResumeDecision> {
|
|
93
|
+
const signal = ctx.abortController?.signal
|
|
94
|
+
// No abort signal wired (e.g. a minimal test harness) → behave exactly as a
|
|
95
|
+
// direct resumeHandler await, no race. In production RunContextFactory always
|
|
96
|
+
// provides the controller, so the race below is live.
|
|
97
|
+
if (!signal) return ctx.resumeHandler(request)
|
|
98
|
+
const abortDecision: HITLResumeDecision = {
|
|
99
|
+
action: 'abort',
|
|
100
|
+
reason: 'run aborted while parked for HITL',
|
|
101
|
+
}
|
|
102
|
+
if (signal.aborted) return abortDecision
|
|
103
|
+
return new Promise<HITLResumeDecision>((resolve) => {
|
|
104
|
+
let settled = false
|
|
105
|
+
const onAbort = (): void => {
|
|
106
|
+
if (settled) return
|
|
107
|
+
settled = true
|
|
108
|
+
resolve(abortDecision)
|
|
109
|
+
}
|
|
110
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
111
|
+
Promise.resolve(ctx.resumeHandler(request)).then(
|
|
112
|
+
(decision) => {
|
|
113
|
+
if (settled) return
|
|
114
|
+
settled = true
|
|
115
|
+
signal.removeEventListener('abort', onAbort)
|
|
116
|
+
resolve(decision)
|
|
117
|
+
},
|
|
118
|
+
(err) => {
|
|
119
|
+
if (settled) return
|
|
120
|
+
settled = true
|
|
121
|
+
signal.removeEventListener('abort', onAbort)
|
|
122
|
+
resolve({
|
|
123
|
+
action: 'abort',
|
|
124
|
+
reason: err instanceof Error ? err.message : 'resume handler failed',
|
|
125
|
+
})
|
|
126
|
+
},
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
77
131
|
export async function* handleHITLDecision(
|
|
78
132
|
ctx: IterationContext,
|
|
79
133
|
decision: HITLResumeDecision,
|
|
@@ -125,6 +179,10 @@ export async function* handleHITLDecision(
|
|
|
125
179
|
case 'approve_tools':
|
|
126
180
|
case 'modify_tools':
|
|
127
181
|
case 'reject_tools':
|
|
182
|
+
// 'answer_question' can only arrive misdirected at an iteration
|
|
183
|
+
// checkpoint (answers are consumed inside the ask_user_question
|
|
184
|
+
// tool's own park); treat it as a plain continue.
|
|
185
|
+
case 'answer_question':
|
|
128
186
|
return 'continue'
|
|
129
187
|
default: {
|
|
130
188
|
const _exhaustive: never = decision
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { HITLResumeDecision } from '../../../../types/hitl/index.js'
|
|
4
|
+
import type { RunId } from '../../../../types/ids/index.js'
|
|
5
|
+
import { type IterationContext, awaitDecisionOrAbort } from './context.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* C12: a Stop that arrives while the run is parked on a HITL decision must
|
|
9
|
+
* interrupt the park (resolve as `abort`) instead of hanging until the host
|
|
10
|
+
* eventually answers. `awaitDecisionOrAbort` races `resumeHandler` against
|
|
11
|
+
* `ctx.abortController.signal`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
function ctxWith(opts: {
|
|
15
|
+
controller?: AbortController
|
|
16
|
+
resumeHandler: IterationContext['resumeHandler']
|
|
17
|
+
}): IterationContext {
|
|
18
|
+
return {
|
|
19
|
+
abortController: opts.controller,
|
|
20
|
+
resumeHandler: opts.resumeHandler,
|
|
21
|
+
runMgr: { id: 'run_abort_test' as RunId },
|
|
22
|
+
} as unknown as IterationContext
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const REVIEW_REQUEST = {
|
|
26
|
+
type: 'tool_review' as const,
|
|
27
|
+
runId: 'run_abort_test' as RunId,
|
|
28
|
+
checkpointId: 'cp_1' as `cp_${string}`,
|
|
29
|
+
toolCalls: [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('awaitDecisionOrAbort (C12 — cancellable HITL park)', () => {
|
|
33
|
+
it('returns abort immediately when the signal is already aborted', async () => {
|
|
34
|
+
const controller = new AbortController()
|
|
35
|
+
controller.abort()
|
|
36
|
+
const ctx = ctxWith({
|
|
37
|
+
controller,
|
|
38
|
+
// A handler that never resolves — proving we did not wait on it.
|
|
39
|
+
resumeHandler: () => new Promise<HITLResumeDecision>(() => {}),
|
|
40
|
+
})
|
|
41
|
+
const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
|
|
42
|
+
expect(decision.action).toBe('abort')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('resolves as abort when the signal fires DURING the park', async () => {
|
|
46
|
+
const controller = new AbortController()
|
|
47
|
+
const ctx = ctxWith({
|
|
48
|
+
controller,
|
|
49
|
+
resumeHandler: () => new Promise<HITLResumeDecision>(() => {}), // never answers
|
|
50
|
+
})
|
|
51
|
+
const pending = awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
|
|
52
|
+
controller.abort()
|
|
53
|
+
const decision = await pending
|
|
54
|
+
expect(decision.action).toBe('abort')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns the real decision when the handler answers before any abort', async () => {
|
|
58
|
+
const controller = new AbortController()
|
|
59
|
+
const ctx = ctxWith({
|
|
60
|
+
controller,
|
|
61
|
+
resumeHandler: async () => ({ action: 'approve_tools' }) as HITLResumeDecision,
|
|
62
|
+
})
|
|
63
|
+
const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
|
|
64
|
+
expect(decision.action).toBe('approve_tools')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('degrades to a direct resumeHandler await when no controller is wired', async () => {
|
|
68
|
+
const ctx = ctxWith({
|
|
69
|
+
resumeHandler: async () => ({ action: 'reject_tools' }) as HITLResumeDecision,
|
|
70
|
+
})
|
|
71
|
+
const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
|
|
72
|
+
expect(decision.action).toBe('reject_tools')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('fails closed to abort when the resume handler rejects', async () => {
|
|
76
|
+
const controller = new AbortController()
|
|
77
|
+
const ctx = ctxWith({
|
|
78
|
+
controller,
|
|
79
|
+
resumeHandler: async () => {
|
|
80
|
+
throw new Error('handler boom')
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
const decision = await awaitDecisionOrAbort(ctx, REVIEW_REQUEST)
|
|
84
|
+
expect(decision.action).toBe('abort')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { type HITLResumeDecision, autoApproveHandler } from '../../../../types/hitl/index.js'
|
|
4
|
+
import type { CheckpointId, RunId } from '../../../../types/ids/index.js'
|
|
5
|
+
import type { ChatCompletionResponse } from '../../../../types/provider/index.js'
|
|
6
|
+
import type { RunEvent } from '../../../../types/run/index.js'
|
|
7
|
+
import { type IterationContext, handleHITLDecision } from './context.js'
|
|
8
|
+
import { runToolReview } from './tool-review.js'
|
|
9
|
+
|
|
10
|
+
async function drainGenerator<TReturn>(
|
|
11
|
+
gen: AsyncGenerator<RunEvent, TReturn>,
|
|
12
|
+
): Promise<{ events: RunEvent[]; value: TReturn }> {
|
|
13
|
+
const events: RunEvent[] = []
|
|
14
|
+
let next = await gen.next()
|
|
15
|
+
while (!next.done) {
|
|
16
|
+
events.push(next.value)
|
|
17
|
+
next = await gen.next()
|
|
18
|
+
}
|
|
19
|
+
return { events, value: next.value }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('autoApproveHandler user_question case', () => {
|
|
23
|
+
it('returns the non-fabricating no-answer sentinel with the questionId echoed', async () => {
|
|
24
|
+
const decision = await autoApproveHandler({
|
|
25
|
+
type: 'user_question',
|
|
26
|
+
runId: 'run_headless_test' as RunId,
|
|
27
|
+
checkpointId: 'cp_question_toolu_1' as CheckpointId,
|
|
28
|
+
question: {
|
|
29
|
+
questionId: 'toolu_1',
|
|
30
|
+
question: 'Pick one?',
|
|
31
|
+
options: [
|
|
32
|
+
{ id: 'opt_1', label: 'A' },
|
|
33
|
+
{ id: 'opt_2', label: 'B' },
|
|
34
|
+
],
|
|
35
|
+
multiSelect: false,
|
|
36
|
+
allowFreeText: true,
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
expect(decision).toEqual({
|
|
41
|
+
action: 'answer_question',
|
|
42
|
+
selectedOptionIds: [],
|
|
43
|
+
freeText: 'No user is available to answer. Proceed using your best judgment.',
|
|
44
|
+
questionId: 'toolu_1',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('handleHITLDecision answer_question case', () => {
|
|
50
|
+
it("treats a misdirected 'answer_question' at an iteration checkpoint as continue", async () => {
|
|
51
|
+
// The branch returns without touching the context — answers are
|
|
52
|
+
// consumed inside the ask_user_question tool's own park, so an
|
|
53
|
+
// answer arriving here can only be misdirected.
|
|
54
|
+
const ctx = {} as IterationContext
|
|
55
|
+
const { events, value } = await drainGenerator(
|
|
56
|
+
handleHITLDecision(
|
|
57
|
+
ctx,
|
|
58
|
+
{ action: 'answer_question', selectedOptionIds: ['opt_1'] },
|
|
59
|
+
'cp_checkpoint_1',
|
|
60
|
+
'iteration checkpoint',
|
|
61
|
+
),
|
|
62
|
+
)
|
|
63
|
+
expect(value).toBe('continue')
|
|
64
|
+
expect(events).toEqual([])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('runToolReview answer_question case', () => {
|
|
69
|
+
function reviewContext(decision: HITLResumeDecision) {
|
|
70
|
+
const warns: string[] = []
|
|
71
|
+
const pushed: unknown[] = []
|
|
72
|
+
let batches = 0
|
|
73
|
+
const ctx = {
|
|
74
|
+
tools: { get: () => undefined },
|
|
75
|
+
checkpointMgr: {
|
|
76
|
+
create: async () => ({ id: 'cp_review_1' as CheckpointId }),
|
|
77
|
+
},
|
|
78
|
+
emitEvent: async () => {},
|
|
79
|
+
drainPending: (): Generator<RunEvent> => [][Symbol.iterator]() as Generator<RunEvent>,
|
|
80
|
+
resumeHandler: async () => decision,
|
|
81
|
+
log: {
|
|
82
|
+
debug: () => {},
|
|
83
|
+
info: () => {},
|
|
84
|
+
warn: (message: string) => {
|
|
85
|
+
warns.push(message)
|
|
86
|
+
},
|
|
87
|
+
error: () => {},
|
|
88
|
+
},
|
|
89
|
+
runMgr: {
|
|
90
|
+
id: 'run_tool_review_test' as RunId,
|
|
91
|
+
pushMessage: (message: unknown) => {
|
|
92
|
+
pushed.push(message)
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
toolExecutor: {
|
|
96
|
+
executeBatch: async () => {
|
|
97
|
+
batches += 1
|
|
98
|
+
return { messages: [], results: [] }
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
} as unknown as IterationContext
|
|
102
|
+
return { ctx, warns, pushed, batchCount: () => batches }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const response: ChatCompletionResponse = {
|
|
106
|
+
id: 'resp_1',
|
|
107
|
+
model: 'test-model',
|
|
108
|
+
message: {
|
|
109
|
+
role: 'assistant',
|
|
110
|
+
content: null,
|
|
111
|
+
toolCalls: [
|
|
112
|
+
{
|
|
113
|
+
id: 'toolu_review_1',
|
|
114
|
+
type: 'function',
|
|
115
|
+
function: { name: 'ask_user_question', arguments: '{}' },
|
|
116
|
+
},
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
finishReason: 'tool_calls',
|
|
120
|
+
usage: {
|
|
121
|
+
promptTokens: 0,
|
|
122
|
+
completionTokens: 0,
|
|
123
|
+
totalTokens: 0,
|
|
124
|
+
cachedTokens: 0,
|
|
125
|
+
cacheWriteTokens: 0,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
it("warns and executes the batch on a misdirected 'answer_question' instead of stalling", async () => {
|
|
130
|
+
const { ctx, warns, batchCount } = reviewContext({
|
|
131
|
+
action: 'answer_question',
|
|
132
|
+
selectedOptionIds: [],
|
|
133
|
+
})
|
|
134
|
+
const { value } = await drainGenerator(runToolReview(ctx, response, 1))
|
|
135
|
+
expect(value).toBe('executed')
|
|
136
|
+
expect(batchCount()).toBe(1)
|
|
137
|
+
expect(warns).toHaveLength(1)
|
|
138
|
+
})
|
|
139
|
+
})
|