Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions src/graphs/MultiAgentGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,46 @@
return content.match(HANDOFF_INSTRUCTIONS_PATTERN)?.[1]?.trim() ?? null;
}

/** Whether a tool name marks a handoff transfer (static or conditional). */
function isTransferToolName(name: unknown): boolean {
return (
typeof name === 'string' &&
(name.startsWith(Constants.LC_TRANSFER_TO_) ||
name === 'conditional_transfer')
);
}

/**
* Drop transfer `tool_use` content blocks from an AI message's array content.
* Companion to the reception's tool-call filtering: array-content providers
* (Anthropic) serialize retained blocks verbatim, so a transfer block whose
* call/result the reception stripped — or a parallel sibling's transfer block,
* whose result never reaches this recipient's state — would replay as an
* unmatched `tool_use`. Matched by the gathered ids AND by transfer name
* (sibling blocks have no collectable id here). String content passes through.
*/
function filterTransferToolUseBlocks(
content: AIMessage['content'],
transferToolCallIds: ReadonlySet<string>
): AIMessage['content'] {
if (!Array.isArray(content)) {
return content;
}
return content.filter((block) => {
if (
typeof block !== 'object' ||
(block as { type?: string } | null)?.type !== 'tool_use'
) {
return true;
}
const toolUse = block as { id?: string; name?: string };
if (toolUse.id != null && transferToolCallIds.has(toolUse.id)) {
return false;
}
return !isTransferToolName(toolUse.name);
});
}

function isValidHandoffGroupId(value: unknown): value is number {
return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}
Expand Down Expand Up @@ -542,7 +582,7 @@
* 3. Include all messages before the AIMessage plus the filtered pair
*/
const messages = state.messages;
let filteredMessages = messages;

Check warning on line 585 in src/graphs/MultiAgentGraph.ts

View workflow job for this annotation

GitHub Actions / validate / lint

The value assigned to 'filteredMessages' is not used in subsequent statements

Check warning on line 585 in src/graphs/MultiAgentGraph.ts

View workflow job for this annotation

GitHub Actions / validate / lint

The value assigned to 'filteredMessages' is not used in subsequent statements
let aiMessageIndex = -1;

/** Find the AIMessage containing this tool call */
Expand Down Expand Up @@ -842,9 +882,23 @@
remainingToolCalls.length > 0 ||
(typeof aiMsg.content === 'string' && aiMsg.content.trim())
) {
/** Keep the message but without transfer tool calls */
/**
* Keep the message but without transfer tool calls — AND
* without their `tool_use` content blocks. Array-content
* providers (Anthropic) serialize the retained blocks
* verbatim, so a transfer block whose call/result this
* filter just stripped would reach the receiving agent as
* an unmatched `tool_use` and the provider rejects the
* request. Filtered by transfer NAME as well as the
* gathered ids: a parallel sibling's transfer block has no
* result in THIS recipient's state, so its id is never
* collected, but its name still marks it.
*/
const filteredAiMsg = new AIMessage({
content: aiMsg.content,
content: filterTransferToolUseBlocks(
aiMsg.content,
transferToolCallIds
),
tool_calls: remainingToolCalls,
id: aiMsg.id,
});
Expand Down
15 changes: 14 additions & 1 deletion src/hitl/askUserQuestion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,24 @@ import type {
* ```
*/
export function askUserQuestion(
question: AskUserQuestionRequest
question: AskUserQuestionRequest,
options?: {
/**
* The calling tool's `tool_call_id`, surfaced on the interrupt payload
* as `tool_call_id` so hosts can attribute the question (and answer) to
* the exact tool-call content part. Tool bodies created with
* `tool(fn, …)` receive it as `config.toolCall.id` — LangChain stamps
* the full ToolCall onto the config when a tool is invoked with one.
* Optional: positional hosts and custom nodes can omit it.
*/
toolCallId?: string;
}
): AskUserQuestionResolution {
const payload: AskUserQuestionInterruptPayload = {
type: 'ask_user_question',
question,
...(options?.toolCallId != null &&
options.toolCallId !== '' && { tool_call_id: options.toolCallId }),
};
return interrupt<AskUserQuestionInterruptPayload, AskUserQuestionResolution>(
payload
Expand Down
98 changes: 97 additions & 1 deletion src/langfuseTraceShaping.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { LangfuseOtelSpanAttributes } from '@langfuse/tracing';
import type { ReadableSpan } from '@opentelemetry/sdk-trace-base';
import { Constants } from '@/common';

const LANGGRAPH_START_NODE = '__start__';
const ANONYMOUS_LAMBDA_NAME = 'RunnableLambda';
Expand Down Expand Up @@ -179,17 +180,112 @@ function getMessageToolCalls(
return calls;
}

/**
* Id-bearing `invalid_tool_calls` on the assistant turn. ToolNode pairs these
* with synthesized error results (and an invalid-only turn is routed on them
* alone), so the tool-dispatch span must count them as part of the executing
* batch — otherwise an invalid-only dispatch finds zero calls and the span
* keeps the full serialized graph state as its input, and a mixed dispatch
* silently omits the malformed call. `args` stays the raw unparsed string.
*/
/** Tool-result ids present in the serialized state — ToolNode's
* `!toolMessageIds.has(id)` execution filter, mirrored for the span. */
function getToolResultIds(
messages: Record<string, unknown>[]
): Set<string> {
const ids = new Set<string>();
for (const message of messages) {
if (getMessageRole(message) !== 'tool') {
continue;
}
const rawId =
message.tool_call_id ??
(isRecord(message.kwargs) ? message.kwargs.tool_call_id : undefined) ??
(isRecord(message.data) ? message.data.tool_call_id : undefined);
if (typeof rawId === 'string' && rawId !== '') {
ids.add(rawId);
}
}
return ids;
}

function getMessageInvalidToolCalls(
message: Record<string, unknown>,
answeredIds: ReadonlySet<string>
): SerializedToolCall[] {
const rawCalls =
message.invalid_tool_calls ??
(isRecord(message.kwargs) ? message.kwargs.invalid_tool_calls : undefined) ??
(isRecord(message.data) ? message.data.invalid_tool_calls : undefined);
if (!Array.isArray(rawCalls)) {
return [];
}
const calls: SerializedToolCall[] = [];
for (const rawCall of rawCalls) {
// Same attribution predicate ToolNode executes with (id-bearing,
// non-server) so the span never claims calls the node deliberately skips.
if (
!isRecord(rawCall) ||
typeof rawCall.id !== 'string' ||
rawCall.id === '' ||
answeredIds.has(rawCall.id) ||
rawCall.id.startsWith(Constants.ANTHROPIC_SERVER_TOOL_PREFIX)
) {
continue;
}
// Same name fallback ToolNode synthesizes with — a nameless attributable
// call still gets a result, so it must still count in the span input.
const call = normalizeToolCall(
typeof rawCall.name === 'string' && rawCall.name !== ''
? rawCall
: { ...rawCall, name: 'unknown' }
);
if (call != null) {
calls.push(call);
}
}
return calls;
}

/** The serialized message's own id (uuid), NOT the LC-serialization type id
* array that `message.id` carries in constructor dumps. */
function getSerializedMessageId(
message: Record<string, unknown>
): string | undefined {
const kwargsId = isRecord(message.kwargs) ? message.kwargs.id : undefined;
const dataId = isRecord(message.data) ? message.data.id : undefined;
const rawId = message.id;
const id = kwargsId ?? dataId ?? rawId;
return typeof id === 'string' && id !== '' ? id : undefined;
}

/** Latest assistant turn's tool calls — the calls this tool node is executing. */
function findPendingToolCalls(value: unknown): SerializedToolCall[] {
const messages = getMessageArray(value);
if (messages == null) {
return [];
}
/**
* Invalid calls count only where ToolNode's own gate lets them execute:
* the messages-state form (a bare-array state means the node returns a
* plain output list and skips invalid handling) with an id-bearing
* assistant message (no id, no reducer upsert). Mirrors
* `canPromoteInvalidCalls` so the span never reports skipped calls.
*/
const invalidCallsApply = !Array.isArray(value);
const answeredIds = invalidCallsApply
? getToolResultIds(messages)
: undefined;
for (let i = messages.length - 1; i >= 0; i--) {
if (getMessageRole(messages[i]) !== 'assistant') {
continue;
}
const calls = getMessageToolCalls(messages[i]);
const calls = [
...getMessageToolCalls(messages[i]),
...(invalidCallsApply && getSerializedMessageId(messages[i]) != null
? getMessageInvalidToolCalls(messages[i], answeredIds!)
: []),
];
if (calls.length > 0) {
return calls;
}
Expand Down
13 changes: 12 additions & 1 deletion src/session/messageSerialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
ToolMessage,
BaseMessage,
} from '@langchain/core/messages';
import type { ToolCall } from '@langchain/core/messages/tool';
import type { ToolCall, InvalidToolCall } from '@langchain/core/messages/tool';
import type { UsageMetadata } from '@langchain/core/messages';
import type { JsonObject, JsonValue, SerializedSessionMessage } from './types';

Expand All @@ -15,6 +15,7 @@ type MessageExtras = {
name?: string;
tool_call_id?: string;
tool_calls?: ToolCall[];
invalid_tool_calls?: InvalidToolCall[];
usage_metadata?: UsageMetadata;
additional_kwargs?: unknown;
response_metadata?: unknown;
Expand Down Expand Up @@ -133,6 +134,13 @@ export function serializeMessage(
if (extras.tool_calls) {
serialized.toolCalls = toJsonValue(extras.tool_calls);
}
/** Malformed-call metadata must round-trip with the calls: the content
* (with any raw `tool_use` blocks) survives serialization, so dropping
* `invalid_tool_calls` would strand those blocks without the entries
* ToolNode repairs the pairing from on restore. */
if (extras.invalid_tool_calls && extras.invalid_tool_calls.length > 0) {
serialized.invalidToolCalls = toJsonValue(extras.invalid_tool_calls);
}
return serialized;
}

Expand All @@ -153,6 +161,9 @@ export function deserializeMessage(
return new AIMessage({
...common,
tool_calls: serialized.toolCalls as ToolCall[] | undefined,
invalid_tool_calls: serialized.invalidToolCalls as
| InvalidToolCall[]
| undefined,
usage_metadata: serialized.usageMetadata as UsageMetadata | undefined,
});
}
Expand Down
1 change: 1 addition & 0 deletions src/session/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export interface SerializedSessionMessage {
name?: string;
toolCallId?: string;
toolCalls?: JsonValue;
invalidToolCalls?: JsonValue;
usageMetadata?: JsonObject;
}

Expand Down
Loading
Loading