Skip to content

Commit 3de87ee

Browse files
committed
refactor: enhance interrupt handling and debug tracing in agent flow
1 parent 9194ffd commit 3de87ee

12 files changed

Lines changed: 190 additions & 121 deletions

application/ports.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,27 @@
1-
import type { AgentModeCompletionAdapter } from "../llm/agentModels.js";
1+
import type { CompletionAdapter } from "adminforth";
22
import type { DetectedLanguage, PreviousUserMessage } from "../domain/languageDetect.js";
33
import type {
44
AgentMessage,
55
AgentStreamChunk,
66
AgentTurnContext,
77
AgentTurnObservability,
8+
PendingInterrupt,
89
} from "../domain/turnTypes.js";
910

11+
export type AgentModelPurpose = "primary" | "summary";
12+
13+
/**
14+
* The LLM provider adapter contract (the "provider port"). Extends AdminForth's
15+
* CompletionAdapter with the agent-spec factory. Declared in the application layer
16+
* so the infrastructure (llm/) depends on this contract — not the other way around.
17+
*/
18+
export type AgentModeCompletionAdapter = CompletionAdapter & {
19+
getLangChainAgentSpec(params: {
20+
maxTokens: number;
21+
purpose: AgentModelPurpose;
22+
}): Promise<{ model: unknown; middleware?: unknown[] }> | { model: unknown; middleware?: unknown[] };
23+
};
24+
1025
/**
1126
* Input for a single streamed agent turn. Either a fresh set of messages, or a
1227
* human-in-the-loop resume payload for a previously interrupted turn.
@@ -21,9 +36,8 @@ export type LlmStreamInput = {
2136
/**
2237
* The boundary between the application layer and the concrete LLM runtime
2338
* (LangChain / LangGraph). Everything provider-specific — model construction,
24-
* the agent graph, middleware, and the raw stream shape — lives behind this
25-
* port. The application layer depends only on this interface, so the turn
26-
* orchestration is testable with a scripted fake and reusable across providers.
39+
* the agent graph, middleware, the raw stream shape, and the LangGraph interrupt
40+
* shape — lives behind this port and is normalized before crossing it.
2741
*/
2842
export interface LlmPort {
2943
/**
@@ -42,12 +56,13 @@ export interface LlmPort {
4256

4357
/**
4458
* Rebuild the pending human-in-the-loop interrupts for a session from persisted
45-
* state (the checkpointer). Used as a fallback when the in-process interrupt
46-
* cache is empty — e.g. after a restart or on a second instance — so approvals
47-
* survive process boundaries. Returns raw interrupt objects (LangGraph shape).
59+
* checkpoint state (used when the in-process cache is empty — after a restart or
60+
* on another instance). Returns already-normalized descriptors; the LangGraph
61+
* interrupt shape never leaves the llm layer. Throws on a checkpoint/runtime
62+
* failure (the caller must not treat that as "no pending interrupt").
4863
*/
4964
getPendingInterrupts(input: {
5065
completionAdapter: AgentModeCompletionAdapter;
5166
sessionId: string;
52-
}): Promise<unknown[]>;
67+
}): Promise<PendingInterrupt[]>;
5368
}

application/runTurnUseCase.ts

Lines changed: 33 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { logger, type IAdminForth } from "adminforth";
22
import { randomUUID } from "crypto";
3-
import { createSequenceDebugCollector } from "../llm/middleware/sequenceDebug.js";
43
import { VegaLiteStreamBuffer } from "../domain/vegaLiteStreamBuffer.js";
54
import { buildAgentTurnSystemPrompt } from "../domain/systemPrompt.js";
65
import { getErrorMessage, isAbortError } from "../shared/errors.js";
@@ -13,15 +12,15 @@ import type {
1312
AgentTurnContext,
1413
AgentTurnObservability,
1514
BaseAgentTurnInput,
15+
DebugSink,
1616
HandleTurnInput,
17+
PendingInterrupt,
1718
RunAndPersistAgentResponseInput,
1819
RunAndPersistAgentResponseResult,
1920
} from "../domain/turnTypes.js";
2021

2122
type AgentMode = PluginOptions["modes"][number];
2223

23-
type PendingInterrupt = { id: string; count: number };
24-
2524
type PreparedTurn = {
2625
prompt: string;
2726
sessionId: string;
@@ -41,28 +40,6 @@ function getApprovalDecision(input: BaseAgentTurnInput) {
4140
: undefined;
4241
}
4342

44-
function getInterruptItems(interrupt: unknown): unknown[] {
45-
return Array.isArray(interrupt) ? interrupt : [interrupt];
46-
}
47-
48-
function getHitlInterrupts(interrupt: unknown): PendingInterrupt[] {
49-
return getInterruptItems(interrupt).flatMap((item) => {
50-
const value = item && typeof item === "object" && "value" in item
51-
? (item as { value: unknown }).value
52-
: item;
53-
const actionRequests = value && typeof value === "object"
54-
? (value as { actionRequests?: unknown }).actionRequests
55-
: undefined;
56-
const interruptId = item && typeof item === "object"
57-
? (item as { id?: unknown }).id
58-
: undefined;
59-
60-
return typeof interruptId === "string" && Array.isArray(actionRequests)
61-
? [{ id: interruptId, count: actionRequests.length }]
62-
: [];
63-
});
64-
}
65-
6643
function buildHitlDecision(decision: "approve" | "reject", prompt?: string) {
6744
if (decision === "approve") {
6845
return { type: "approve" as const };
@@ -129,6 +106,10 @@ export type RunTurnUseCaseDeps = {
129106
modes: PluginOptions["modes"];
130107
getAdminforth: () => IAdminForth;
131108
getAgentSystemPrompt: () => Promise<string>;
109+
/** True when a persistent checkpointer is configured (not the in-memory MemorySaver). */
110+
hasPersistentCheckpointer: boolean;
111+
/** Factory for the per-turn debug sink; the concrete impl lives in the llm layer. */
112+
createDebugSink: () => DebugSink;
132113
};
133114

134115
/**
@@ -161,23 +142,32 @@ export class RunTurnUseCase {
161142
}
162143

163144
/**
164-
* Resolve the pending HITL interrupts for a resume. Prefers the in-process cache
165-
* (populated when the interrupt fired this run) and falls back to the persisted
166-
* checkpoint via the LLM port when the cache is empty (restart / other instance).
145+
* Resolve the pending HITL interrupts for a resume. With a persistent checkpointer the
146+
* checkpoint is authoritative (correct across instances and restarts); with the in-memory
147+
* MemorySaver the in-process cache is authoritative (single process, cannot be stale).
167148
*/
168149
private async resolvePendingInterrupts(sessionId: string, mode: AgentMode): Promise<PendingInterrupt[]> {
169-
const cached = this.pendingInterrupts.get(sessionId);
170-
if (cached && cached.length > 0) {
171-
return cached;
150+
if (this.deps.hasPersistentCheckpointer) {
151+
// The in-process cache can be stale after a cross-instance resume, so it is intentionally
152+
// NOT consulted here. A failure to read the checkpoint is surfaced as a real error — never
153+
// masked as "no pending approval".
154+
try {
155+
return await this.deps.llm.getPendingInterrupts({
156+
completionAdapter: mode.completionAdapter,
157+
sessionId,
158+
});
159+
} catch (error) {
160+
logger.error(
161+
`Failed to read pending approval state from checkpoint for session "${sessionId}": ${getErrorMessage(error)}`,
162+
);
163+
throw error;
164+
}
172165
}
173-
const raw = await this.deps.llm
174-
.getPendingInterrupts({ completionAdapter: mode.completionAdapter, sessionId })
175-
.catch(() => [] as unknown[]);
176-
return getHitlInterrupts(raw);
166+
return this.pendingInterrupts.get(sessionId) ?? [];
177167
}
178168

179169
private async prepareTurn(input: RunAndPersistAgentResponseInput): Promise<PreparedTurn> {
180-
const sequenceDebugSink = createSequenceDebugCollector();
170+
const sequenceDebugSink = this.deps.createDebugSink();
181171
const mode = this.resolveMode(input.modeName);
182172
const approvalDecision = getApprovalDecision(input);
183173
const shouldResume = Boolean(approvalDecision);
@@ -249,11 +239,14 @@ export class RunTurnUseCase {
249239
];
250240
}
251241

252-
private async handleInterrupt(prepared: PreparedTurn, interrupt: unknown) {
253-
const interrupts = getHitlInterrupts(interrupt);
242+
private async handleInterrupt(
243+
prepared: PreparedTurn,
244+
interrupt: unknown,
245+
descriptors: PendingInterrupt[],
246+
) {
254247
const existing = this.pendingInterrupts.get(prepared.sessionId) ?? [];
255248
const merged = new Map(existing.map((item) => [item.id, item.count]));
256-
for (const item of interrupts) {
249+
for (const item of descriptors) {
257250
merged.set(item.id, item.count);
258251
}
259252
this.pendingInterrupts.set(
@@ -299,7 +292,7 @@ export class RunTurnUseCase {
299292

300293
if (chunk.kind === "interrupt") {
301294
interrupted = true;
302-
await this.handleInterrupt(prepared, chunk.interrupt);
295+
await this.handleInterrupt(prepared, chunk.interrupt, chunk.descriptors);
303296
continue;
304297
}
305298

domain/turnTypes.ts

Lines changed: 19 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
11
import type { AdminUser, AudioAdapter } from "adminforth";
2-
import type { Messages } from "@langchain/langgraph";
3-
import type { Command } from "@langchain/langgraph";
4-
import type { AgentChatModel, AgentMiddleware } from "../llm/agentModels.js";
5-
import type { SequenceDebugCollector } from "../llm/middleware/sequenceDebug.js";
62
import type { PreviousUserMessage } from "./languageDetect.js";
73
import type { CurrentPageContext } from "../tools/getUserLocation.js";
84
import type { AgentEventEmitter } from "./agentEvents.js";
95

6+
/**
7+
* Minimal sink the application layer uses to persist per-turn debug traces. The
8+
* concrete implementation (the sequence-debug collector) lives in the llm layer;
9+
* the domain/application layers depend only on this narrow interface, so the
10+
* dependency direction stays llm -> application/domain.
11+
*/
12+
export type DebugSink = {
13+
flush(): void;
14+
getHistory(): unknown[];
15+
};
16+
17+
/** A pending human-in-the-loop approval, normalized (provider-agnostic). */
18+
export type PendingInterrupt = {
19+
id: string;
20+
count: number;
21+
};
22+
1023
export type BaseAgentTurnInput = {
1124
prompt: string;
1225
sessionId: string;
@@ -51,35 +64,7 @@ export type AgentTurnContext = {
5164

5265
export type AgentTurnObservability = {
5366
emit?: AgentEventEmitter;
54-
sequenceDebugSink: SequenceDebugCollector;
55-
};
56-
57-
export type PreparedAgentTurn = {
58-
prompt: string;
59-
sessionId: string;
60-
turnId: string;
61-
previousUserMessages: PreviousUserMessage[];
62-
modeName?: string | null;
63-
context: AgentTurnContext;
64-
observability: AgentTurnObservability;
65-
resume?: {
66-
decision: "approve" | "reject";
67-
interrupts?: { id: string; count: number }[];
68-
};
69-
initialResponse?: string;
70-
};
71-
72-
export type AgentTurnModels = {
73-
model: AgentChatModel;
74-
summaryModel: AgentChatModel;
75-
modelMiddleware?: AgentMiddleware[];
76-
};
77-
78-
export type AgentRuntimeRunInput = {
79-
models: AgentTurnModels;
80-
input: { messages: Messages } | Command;
81-
context: AgentTurnContext;
82-
observability: AgentTurnObservability;
67+
sequenceDebugSink: DebugSink;
8368
};
8469

8570
export type RunAndPersistAgentResponseInput = BaseAgentTurnInput & {
@@ -116,4 +101,4 @@ export type AgentMessage = {
116101
export type AgentStreamChunk =
117102
| { kind: "text"; delta: string }
118103
| { kind: "reasoning"; delta: string }
119-
| { kind: "interrupt"; interrupt: unknown };
104+
| { kind: "interrupt"; interrupt: unknown; descriptors: PendingInterrupt[] };

index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type { AgentEndpointsContext } from "./transport/http/context.js";
1717
import { AgentSessionStore } from "./persistence/sessionStore.js";
1818
import { ChatSurfaceService } from "./transport/surfaces/chatSurfaceService.js";
1919
import { RunTurnUseCase } from "./application/runTurnUseCase.js";
20+
import { createSequenceDebugCollector } from "./llm/middleware/sequenceDebug.js";
2021
import { LangGraphLlm } from "./llm/langGraphLlm.js";
2122
import { AgentModelFactory } from "./llm/modelFactory.js";
2223
import { AgentRuntime } from "./llm/agentRuntime.js";
@@ -94,6 +95,8 @@ export default class AdminForthAgentPlugin extends AdminForthPlugin {
9495
modes: this.options.modes,
9596
getAdminforth: () => this.adminforth,
9697
getAgentSystemPrompt: this.getAgentSystemPrompt.bind(this),
98+
hasPersistentCheckpointer: Boolean(this.options.checkpointResource),
99+
createDebugSink: createSequenceDebugCollector,
97100
});
98101
this.speechTurnService = new SpeechTurnService(
99102
this.runTurnUseCase.runAndPersistAgentResponse.bind(this.runTurnUseCase),

llm/agentModels.ts

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,26 +5,30 @@ import {
55
} from "adminforth";
66
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
77
import type { LLMResult } from "@langchain/core/outputs";
8+
import type { Messages, Command } from "@langchain/langgraph";
89
import {
910
createSequenceDebugMiddleware,
1011
} from "./middleware/sequenceDebug.js";
12+
import type { AgentModeCompletionAdapter, AgentModelPurpose } from "../application/ports.js";
13+
import type { AgentTurnContext, AgentTurnObservability } from "../domain/turnTypes.js";
14+
15+
export type { AgentModeCompletionAdapter, AgentModelPurpose } from "../application/ports.js";
1116

1217
export type AgentChatModel = BaseChatModel<any, any>;
13-
export type AgentModelPurpose = "primary" | "summary";
14-
export type AgentModeCompletionAdapter = CompletionAdapter & {
15-
getLangChainAgentSpec(params: {
16-
maxTokens: number;
17-
purpose: AgentModelPurpose;
18-
}): Promise<{
19-
model: unknown;
20-
middleware?: unknown[];
21-
}> | {
22-
model: unknown;
23-
middleware?: unknown[];
24-
};
18+
export type AgentMiddleware = ReturnType<typeof createSequenceDebugMiddleware>;
19+
20+
export type AgentTurnModels = {
21+
model: AgentChatModel;
22+
summaryModel: AgentChatModel;
23+
modelMiddleware?: AgentMiddleware[];
2524
};
2625

27-
export type AgentMiddleware = ReturnType<typeof createSequenceDebugMiddleware>;
26+
export type AgentRuntimeRunInput = {
27+
models: AgentTurnModels;
28+
input: { messages: Messages } | Command;
29+
context: AgentTurnContext;
30+
observability: AgentTurnObservability;
31+
};
2832

2933
type AgentChatModelSpec = {
3034
model: AgentChatModel;

llm/agentRuntime.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import type { IAdminForth } from "adminforth";
22
import { createAgent, summarizationMiddleware, humanInTheLoopMiddleware } from "langchain";
33
import type { BaseCheckpointSaver } from "@langchain/langgraph";
44
import { createApiBasedToolsMiddleware } from "./middleware/apiToolsMiddleware.js";
5-
import { createSequenceDebugMiddleware } from "./middleware/sequenceDebug.js";
5+
import { createSequenceDebugMiddleware, type SequenceDebugCollector } from "./middleware/sequenceDebug.js";
66
import { createAgentLlmMetricsLogger } from "./agentModels.js";
77
import type { AgentToolProvider } from "../tools/agentToolProvider.js";
8-
import type { AgentRuntimeRunInput, AgentTurnModels } from "../domain/turnTypes.js";
8+
import type { AgentRuntimeRunInput, AgentTurnModels } from "./agentModels.js";
99
import { contextSchema, toLangchainAgentContext } from "./agentContext.js";
1010
import type { ApiBasedTool } from "../tools/apiBasedTools.js";
1111

@@ -42,9 +42,10 @@ export class AgentRuntime {
4242
apiBasedTools,
4343
adminforth,
4444
);
45-
const sequenceDebugMiddleware = createSequenceDebugMiddleware(
46-
input.observability.sequenceDebugSink,
47-
);
45+
// The domain exposes only the narrow DebugSink; the concrete collector (with the
46+
// model-call hooks the debug middleware needs) is an llm-layer detail.
47+
const sequenceDebugSink = input.observability.sequenceDebugSink as SequenceDebugCollector;
48+
const sequenceDebugMiddleware = createSequenceDebugMiddleware(sequenceDebugSink);
4849
const hitlMiddleware = humanInTheLoopMiddleware({
4950
interruptOn: createHumanInTheLoopInterrupts(apiBasedTools),
5051
descriptionPrefix: "Tool execution pending approval",
@@ -82,7 +83,7 @@ export class AgentRuntime {
8283
...input.context,
8384
adminBaseUrl: adminforth.config.baseUrlSlashed,
8485
emit: input.observability.emit,
85-
sequenceDebugSink: input.observability.sequenceDebugSink,
86+
sequenceDebugSink,
8687
}),
8788
});
8889
}

llm/langGraphLlm.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import { HumanMessage, SystemMessage } from "langchain";
33
import type { AgentRuntime } from "./agentRuntime.js";
44
import type { AgentModelFactory } from "./modelFactory.js";
55
import { detectUserLanguage, type PreviousUserMessage } from "../domain/languageDetect.js";
6-
import type { AgentModeCompletionAdapter } from "./agentModels.js";
7-
import { adaptRawStream } from "./streamAdapter.js";
8-
import type { LlmPort, LlmStreamInput } from "../application/ports.js";
9-
import type { AgentMessage } from "../domain/turnTypes.js";
6+
import { adaptRawStream, normalizeInterrupts } from "./streamAdapter.js";
7+
import type { AgentModeCompletionAdapter, LlmPort, LlmStreamInput } from "../application/ports.js";
8+
import type { AgentMessage, PendingInterrupt } from "../domain/turnTypes.js";
109

1110
function toLangchainMessages(messages: AgentMessage[]) {
1211
return messages.map((message) =>
@@ -59,8 +58,9 @@ export class LangGraphLlm implements LlmPort {
5958
async getPendingInterrupts(input: {
6059
completionAdapter: AgentModeCompletionAdapter;
6160
sessionId: string;
62-
}): Promise<unknown[]> {
61+
}): Promise<PendingInterrupt[]> {
6362
const models = await this.modelFactory.create(input.completionAdapter);
64-
return this.runtime.getPendingInterrupts({ models, sessionId: input.sessionId });
63+
const raw = await this.runtime.getPendingInterrupts({ models, sessionId: input.sessionId });
64+
return normalizeInterrupts(raw);
6565
}
6666
}

llm/modelFactory.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { CompletionAdapter } from "adminforth";
2-
import { createAgentChatModel } from "./agentModels.js";
3-
import type { AgentTurnModels } from "../domain/turnTypes.js";
2+
import { createAgentChatModel, type AgentTurnModels } from "./agentModels.js";
43

54
export class AgentModelFactory {
65
constructor(private readonly maxTokens: number) {}

0 commit comments

Comments
 (0)