Skip to content

Commit e88d185

Browse files
committed
feat(sdk): add chat.history read primitives for HITL flows
Add getPendingToolCalls, getResolvedToolCalls, extractNewToolResults, getChain, and findMessage to chat.history. Lifts the accumulator-walking logic that customers building human-in-the-loop tools were re-implementing into the SDK.
1 parent 02e4a2b commit e88d185

3 files changed

Lines changed: 510 additions & 3 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@trigger.dev/sdk": minor
3+
---
4+
5+
Add read primitives to `chat.history` for HITL flows: `getPendingToolCalls()`, `getResolvedToolCalls()`, `extractNewToolResults(message)`, `getChain()`, and `findMessage(messageId)`. These lift the accumulator-walking logic that customers building human-in-the-loop tools were re-implementing into the SDK.
6+
7+
Use `getPendingToolCalls()` to gate fresh user turns while a tool call is awaiting an answer. Use `extractNewToolResults(message)` to dedup tool results when persisting to your own store — the helper returns only the parts whose `toolCallId` is not already resolved on the chain.
8+
9+
```ts
10+
const pending = chat.history.getPendingToolCalls();
11+
if (pending.length > 0) {
12+
// an addToolOutput is expected before a new user message
13+
}
14+
15+
onTurnComplete: async ({ responseMessage }) => {
16+
const newResults = chat.history.extractNewToolResults(responseMessage);
17+
for (const r of newResults) {
18+
await db.toolResults.upsert({ id: r.toolCallId, output: r.output, errorText: r.errorText });
19+
}
20+
};
21+
```

packages/trigger-sdk/src/v3/ai.ts

Lines changed: 182 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ import {
4444
convertToModelMessages,
4545
dynamicTool,
4646
generateId as generateMessageId,
47+
getToolName,
48+
isToolUIPart,
4749
jsonSchema,
4850
JSONSchema7,
4951
Schema,
@@ -1411,20 +1413,197 @@ function getChatHistoryState(): UIMessage[] {
14111413
}
14121414

14131415
/**
1414-
* Imperative API for modifying the accumulated message history.
1416+
* A tool call surfaced by `chat.history.getPendingToolCalls()` /
1417+
* `getResolvedToolCalls()`. Identifies the call by its `toolCallId` plus
1418+
* the `messageId` of the assistant message that hosts it, so callers can
1419+
* locate the part precisely without re-walking the chain.
1420+
*/
1421+
export type ChatToolCallRef = {
1422+
toolCallId: string;
1423+
toolName: string;
1424+
messageId: string;
1425+
};
1426+
1427+
/**
1428+
* A new tool result surfaced by `chat.history.extractNewToolResults()`.
1429+
* `errorText` is set iff the part is in `output-error` state; otherwise
1430+
* `output` carries the resolved value.
1431+
*/
1432+
export type ChatNewToolResult = {
1433+
toolCallId: string;
1434+
toolName: string;
1435+
output: unknown;
1436+
errorText?: string;
1437+
};
1438+
1439+
/**
1440+
* Tool parts that are "done" — either succeeded with a value or failed
1441+
* with an error. Excludes pending (`input-streaming`/`input-available`)
1442+
* and approval (`approval-requested`/`approval-responded`) states.
1443+
* @internal
1444+
*/
1445+
function isResolvedToolState(state: unknown): state is "output-available" | "output-error" {
1446+
return state === "output-available" || state === "output-error";
1447+
}
1448+
1449+
/** @internal */
1450+
function isPendingToolState(state: unknown): state is "input-available" {
1451+
return state === "input-available";
1452+
}
1453+
1454+
/**
1455+
* Walk an assistant message and yield each tool part with its callId,
1456+
* name, and state. Skips non-assistant messages and non-tool parts.
1457+
* @internal
1458+
*/
1459+
function* iterateToolParts(
1460+
message: UIMessage
1461+
): Generator<{ part: any; toolCallId: string; toolName: string; state: unknown }> {
1462+
if (message.role !== "assistant") return;
1463+
for (const part of (message.parts ?? []) as any[]) {
1464+
if (!isToolUIPart(part)) continue;
1465+
const toolCallId = part.toolCallId;
1466+
if (typeof toolCallId !== "string" || toolCallId.length === 0) continue;
1467+
yield {
1468+
part,
1469+
toolCallId,
1470+
toolName: getToolName(part),
1471+
state: part.state,
1472+
};
1473+
}
1474+
}
1475+
1476+
/**
1477+
* Tool parts on the *leaf* assistant message that are still waiting on
1478+
* an answer (`input-available` state). Used to gate fresh user turns
1479+
* during HITL flows.
1480+
* @internal
1481+
*/
1482+
function getPendingToolCallsFromHistory(messages: UIMessage[]): ChatToolCallRef[] {
1483+
for (let i = messages.length - 1; i >= 0; i--) {
1484+
const msg = messages[i]!;
1485+
if (msg.role !== "assistant") continue;
1486+
const pending: ChatToolCallRef[] = [];
1487+
for (const { toolCallId, toolName, state } of iterateToolParts(msg)) {
1488+
if (isPendingToolState(state)) {
1489+
pending.push({ toolCallId, toolName, messageId: msg.id });
1490+
}
1491+
}
1492+
return pending;
1493+
}
1494+
return [];
1495+
}
1496+
1497+
/**
1498+
* All tool parts across the chain that have already produced an output
1499+
* (`output-available` or `output-error`). Used to dedup re-saves when
1500+
* the AI SDK resends an assistant with progressively more answered
1501+
* parts.
1502+
* @internal
1503+
*/
1504+
function getResolvedToolCallsFromHistory(messages: UIMessage[]): ChatToolCallRef[] {
1505+
const out: ChatToolCallRef[] = [];
1506+
for (const msg of messages) {
1507+
for (const { toolCallId, toolName, state } of iterateToolParts(msg)) {
1508+
if (isResolvedToolState(state)) {
1509+
out.push({ toolCallId, toolName, messageId: msg.id });
1510+
}
1511+
}
1512+
}
1513+
return out;
1514+
}
1515+
1516+
/**
1517+
* Pure helper: tool parts in `message` that have a fresh result not
1518+
* already represented by the resolved toolCallIds in `messages`. The
1519+
* `errorText` field is present only for `output-error` parts.
1520+
* @internal
1521+
*/
1522+
function extractNewToolResultsFromHistory(
1523+
message: UIMessage,
1524+
messages: UIMessage[]
1525+
): ChatNewToolResult[] {
1526+
const resolved = new Set(
1527+
getResolvedToolCallsFromHistory(messages).map((r) => r.toolCallId)
1528+
);
1529+
const out: ChatNewToolResult[] = [];
1530+
for (const { part, toolCallId, toolName, state } of iterateToolParts(message)) {
1531+
if (!isResolvedToolState(state)) continue;
1532+
if (resolved.has(toolCallId)) continue;
1533+
if (state === "output-error") {
1534+
out.push({ toolCallId, toolName, output: undefined, errorText: part.errorText });
1535+
} else {
1536+
out.push({ toolCallId, toolName, output: part.output });
1537+
}
1538+
}
1539+
return out;
1540+
}
1541+
1542+
/**
1543+
* Imperative API for reading and modifying the accumulated message history.
14151544
*
14161545
* Mutations use the same deferred override mechanism as `chat.setMessages()`:
1417-
* they are applied at lifecycle checkpoints (after hooks return).
1546+
* they are applied at lifecycle checkpoints (after hooks return). Reads are
1547+
* synchronous against the current accumulator state.
14181548
*
14191549
* Can be called from `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`,
1420-
* `run()`, or AI SDK tools.
1550+
* `run()`, `onAction`, or AI SDK tools.
14211551
*/
14221552
const chatHistory = {
14231553
/** Read the current accumulated UI messages (copy). */
14241554
all(): UIMessage[] {
14251555
return [...getChatHistoryState()];
14261556
},
14271557

1558+
/**
1559+
* Read the current chain as an ordered `UIMessage[]`. Alias for `all()` —
1560+
* use this when working alongside parent-aware APIs (TRI-9120) where
1561+
* "chain" disambiguates from "graph".
1562+
*/
1563+
getChain(): UIMessage[] {
1564+
return [...getChatHistoryState()];
1565+
},
1566+
1567+
/**
1568+
* Find a message by id. Returns `undefined` if no message with that id
1569+
* is present in the current chain.
1570+
*/
1571+
findMessage(messageId: string): UIMessage | undefined {
1572+
return getChatHistoryState().find((m) => m.id === messageId);
1573+
},
1574+
1575+
/**
1576+
* Tool calls on the leaf assistant message still waiting on an answer
1577+
* (`input-available` state). Use this to gate fresh user turns during
1578+
* HITL flows: if `getPendingToolCalls().length > 0`, an `addToolOutput`
1579+
* is expected before any new user message.
1580+
*
1581+
* Returns `[]` if there is no assistant message yet, or if the leaf
1582+
* assistant has no pending tool calls.
1583+
*/
1584+
getPendingToolCalls(): ChatToolCallRef[] {
1585+
return getPendingToolCallsFromHistory(getChatHistoryState());
1586+
},
1587+
1588+
/**
1589+
* Tool calls across the chain with a final result (`output-available`
1590+
* or `output-error`). Use this to dedup re-saves when the AI SDK
1591+
* resends an assistant message with progressively more answered parts.
1592+
*/
1593+
getResolvedToolCalls(): ChatToolCallRef[] {
1594+
return getResolvedToolCallsFromHistory(getChatHistoryState());
1595+
},
1596+
1597+
/**
1598+
* Pure helper: returns the tool parts in `message` whose results are
1599+
* not already represented in the current chain. Use this when
1600+
* persisting tool results to your own store: each call surfaces only
1601+
* the *new* answers, so writes stay idempotent across re-streams.
1602+
*/
1603+
extractNewToolResults(message: UIMessage): ChatNewToolResult[] {
1604+
return extractNewToolResultsFromHistory(message, getChatHistoryState());
1605+
},
1606+
14281607
/** Replace all accumulated messages. Same as `chat.setMessages()`. */
14291608
set(messages: UIMessage[]): void {
14301609
locals.set(chatOverrideMessagesKey, messages);

0 commit comments

Comments
 (0)