{contentString.length > 0 && (
@@ -133,19 +148,11 @@ export function AssistantMessage({
thread={thread}
/>
)}
- {/**
- * TODO: Support rendering interrupts.
- * Tracking issue: https://github.com/langchain-ai/open-agent-platform/issues/22
- */}
- {/* {isAgentInboxInterruptSchema(threadInterrupt?.value) &&
- (isLastMessage || hasNoAIOrToolMessages) && (
-
- )}
- {threadInterrupt?.value &&
- !isAgentInboxInterruptSchema(threadInterrupt.value) &&
- isLastMessage ? (
-
- ) : null} */}
+
| Record
[];
+}) {
+ const [isExpanded, setIsExpanded] = useState(false);
+
+ const contentStr = JSON.stringify(interrupt, null, 2);
+ const contentLines = contentStr.split("\n");
+ const shouldTruncate = contentLines.length > 4 || contentStr.length > 500;
+
+ // Function to truncate long string values
+ const truncateValue = (value: any): any => {
+ if (typeof value === "string" && value.length > 100) {
+ return value.substring(0, 100) + "...";
+ }
+
+ if (Array.isArray(value) && !isExpanded) {
+ return value.slice(0, 2).map(truncateValue);
+ }
+
+ if (isComplexValue(value) && !isExpanded) {
+ const strValue = JSON.stringify(value, null, 2);
+ if (strValue.length > 100) {
+ // Return plain text for truncated content instead of a JSON object
+ return `Truncated ${strValue.length} characters...`;
+ }
+ }
+
+ return value;
+ };
+
+ // Process entries based on expanded state
+ const processEntries = () => {
+ if (Array.isArray(interrupt)) {
+ return isExpanded ? interrupt : interrupt.slice(0, 5);
+ } else {
+ const entries = Object.entries(interrupt);
+ if (!isExpanded && shouldTruncate) {
+ // When collapsed, process each value to potentially truncate it
+ return entries.map(([key, value]) => [key, truncateValue(value)]);
+ }
+ return entries;
+ }
+ };
+
+ const displayEntries = processEntries();
+
+ return (
+
+
+
+
+
+
+
+
+ {displayEntries.map((item, argIdx) => {
+ const [key, value] = Array.isArray(interrupt)
+ ? [argIdx.toString(), item]
+ : (item as [string, any]);
+ return (
+
+
+ {key}
+ |
+
+ {isComplexValue(value) ? (
+
+ {JSON.stringify(value, null, 2)}
+
+ ) : (
+ String(value)
+ )}
+ |
+
+ );
+ })}
+
+
+
+
+
+ {(shouldTruncate ||
+ (Array.isArray(interrupt) && interrupt.length > 5)) && (
+ setIsExpanded(!isExpanded)}
+ className="flex w-full cursor-pointer items-center justify-center border-t-[1px] border-gray-200 py-2 text-gray-500 transition-all duration-200 ease-in-out hover:bg-gray-50 hover:text-gray-600"
+ initial={{ scale: 1 }}
+ whileHover={{ scale: 1.02 }}
+ whileTap={{ scale: 0.98 }}
+ >
+ {isExpanded ? : }
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/features/chat/components/thread/messages/interrupt-types.ts b/apps/web/src/features/chat/components/thread/messages/interrupt-types.ts
new file mode 100644
index 00000000..6ad8b53b
--- /dev/null
+++ b/apps/web/src/features/chat/components/thread/messages/interrupt-types.ts
@@ -0,0 +1,56 @@
+/**
+ * Configuration interface that defines what actions are allowed for a human interrupt.
+ * This controls the available interaction options when the graph is paused for human input.
+ *
+ * @property {boolean} allow_ignore - Whether the human can choose to ignore/skip the current step
+ * @property {boolean} allow_respond - Whether the human can provide a text response/feedback
+ * @property {boolean} allow_edit - Whether the human can edit the provided content/state
+ * @property {boolean} allow_accept - Whether the human can accept/approve the current state
+ */
+export interface HumanInterruptConfig {
+ allow_ignore: boolean;
+ allow_respond: boolean;
+ allow_edit: boolean;
+ allow_accept: boolean;
+}
+/**
+ * Represents a request for human action within the graph execution.
+ * Contains the action type and any associated arguments needed for the action.
+ *
+ * @property {string} action - The type or name of action being requested (e.g., "Approve XYZ action")
+ * @property {Record} args - Key-value pairs of arguments needed for the action
+ */
+export interface ActionRequest {
+ action: string;
+ args: Record;
+}
+/**
+ * Represents an interrupt triggered by the graph that requires human intervention.
+ * This is passed to the `interrupt` function when execution is paused for human input.
+ *
+ * @property {ActionRequest} action_request - The specific action being requested from the human
+ * @property {HumanInterruptConfig} config - Configuration defining what actions are allowed
+ * @property {string} [description] - Optional detailed description of what input is needed
+ */
+export interface HumanInterrupt {
+ action_request: ActionRequest;
+ config: HumanInterruptConfig;
+ description?: string;
+}
+/**
+ * The response provided by a human to an interrupt, which is returned when graph execution resumes.
+ *
+ * @property {("accept"|"ignore"|"response"|"edit")} type - The type of response:
+ * - "accept": Approves the current state without changes
+ * - "ignore": Skips/ignores the current step
+ * - "response": Provides text feedback or instructions
+ * - "edit": Modifies the current state/content
+ * @property {null|string|ActionRequest} args - The response payload:
+ * - null: For ignore/accept actions
+ * - string: For text responses
+ * - ActionRequest: For edit actions with updated content
+ */
+export type HumanResponse = {
+ type: "accept" | "ignore" | "response" | "edit";
+ args: null | string | ActionRequest;
+};
diff --git a/apps/web/src/features/chat/components/thread/messages/interrupt.tsx b/apps/web/src/features/chat/components/thread/messages/interrupt.tsx
new file mode 100644
index 00000000..f290afd5
--- /dev/null
+++ b/apps/web/src/features/chat/components/thread/messages/interrupt.tsx
@@ -0,0 +1,47 @@
+import { HumanInterrupt } from "./interrupt-types";
+import { GenericInterruptView } from "./generic-interrupt";
+import { ThreadViewChat } from "./thread-view-chat";
+
+export function isAgentInboxInterruptSchema(
+ value: unknown,
+): value is HumanInterrupt | HumanInterrupt[] {
+ const valueAsObject = Array.isArray(value) ? value[0] : value;
+ return (
+ valueAsObject &&
+ typeof valueAsObject === "object" &&
+ "action_request" in valueAsObject &&
+ typeof valueAsObject.action_request === "object" &&
+ "config" in valueAsObject &&
+ typeof valueAsObject.config === "object" &&
+ "allow_respond" in valueAsObject.config &&
+ "allow_accept" in valueAsObject.config &&
+ "allow_edit" in valueAsObject.config &&
+ "allow_ignore" in valueAsObject.config
+ );
+}
+
+interface InterruptProps {
+ interruptValue?: unknown;
+ isLastMessage: boolean;
+ hasNoAIOrToolMessages: boolean;
+}
+
+export function Interrupt({
+ interruptValue,
+ isLastMessage,
+ hasNoAIOrToolMessages,
+}: InterruptProps) {
+ return (
+ <>
+ {isAgentInboxInterruptSchema(interruptValue) &&
+ (isLastMessage || hasNoAIOrToolMessages) && (
+
+ )}
+ {interruptValue &&
+ !isAgentInboxInterruptSchema(interruptValue) &&
+ isLastMessage ? (
+
+ ) : null}
+ >
+ );
+}
diff --git a/apps/web/src/features/chat/components/thread/messages/thread-view-chat.tsx b/apps/web/src/features/chat/components/thread/messages/thread-view-chat.tsx
new file mode 100644
index 00000000..8f4b75bd
--- /dev/null
+++ b/apps/web/src/features/chat/components/thread/messages/thread-view-chat.tsx
@@ -0,0 +1,78 @@
+import { useEffect, useState } from "react";
+import { HumanInterrupt } from "./interrupt-types";
+import { StateView } from "@/components/agent-inbox/components/state-view";
+import { ThreadActionsView } from "@/components/agent-inbox/components/thread-actions-view";
+import { ThreadData } from "@/components/agent-inbox/types";
+import { useQueryState } from "nuqs";
+import { useStreamContext } from "@/features/chat/providers/Stream";
+
+interface ThreadViewChatProps {
+ interrupt: HumanInterrupt | HumanInterrupt[];
+}
+
+export function ThreadViewChat({ interrupt }: ThreadViewChatProps) {
+ const thread = useStreamContext();
+ const [showDescription, setShowDescription] = useState(false);
+ const [showState, setShowState] = useState(false);
+ const showSidePanel = showDescription || showState;
+ const [threadId] = useQueryState("threadId");
+ const [threadData, setThreadData] = useState();
+
+ useEffect(() => {
+ if (!threadId) {
+ return;
+ }
+ thread.client.threads.get(threadId).then((thread) => {
+ setThreadData({
+ thread,
+ interrupts: Array.isArray(interrupt) ? interrupt : [interrupt],
+ status: "interrupted",
+ });
+ });
+ }, [interrupt, threadId]);
+
+ const handleShowSidePanel = (
+ showState: boolean,
+ showDescription: boolean,
+ ) => {
+ if (showState && showDescription) {
+ console.error("Cannot show both state and description");
+ return;
+ }
+ if (showState) {
+ setShowDescription(false);
+ setShowState(true);
+ } else if (showDescription) {
+ setShowState(false);
+ setShowDescription(true);
+ } else {
+ setShowState(false);
+ setShowDescription(false);
+ }
+ };
+
+ if (!threadData) {
+ return null;
+ }
+
+ return (
+
+ {showSidePanel ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/components/agent-inbox/contexts/ThreadContext.tsx b/apps/web/src/providers/Thread/index.tsx
similarity index 88%
rename from apps/web/src/components/agent-inbox/contexts/ThreadContext.tsx
rename to apps/web/src/providers/Thread/index.tsx
index 780bdf21..37ca21a9 100644
--- a/apps/web/src/components/agent-inbox/contexts/ThreadContext.tsx
+++ b/apps/web/src/providers/Thread/index.tsx
@@ -10,13 +10,13 @@ import { createClient } from "@/lib/client";
import { Run, Thread, ThreadStatus } from "@langchain/langgraph-sdk";
import React, { Dispatch, SetStateAction, useTransition } from "react";
import { parseAsInteger, parseAsString, useQueryState } from "nuqs";
-import { IMPROPER_SCHEMA } from "../constants";
import {
getInterruptFromThread,
processInterruptedThread,
processThreadWithoutInterrupts,
} from "./utils";
-import { logger } from "../utils/logger";
+import { IMPROPER_SCHEMA } from "@/constants";
+import { useAgentsContext } from "../Agents";
type ThreadContentType<
ThreadValues extends Record = Record,
@@ -55,9 +55,29 @@ const ThreadsContext = React.createContext(
function ThreadsProviderInternal<
ThreadValues extends Record = Record,
>({ children }: { children: React.ReactNode }): React.ReactElement {
+ const { agents } = useAgentsContext();
const [agentInboxId] = useQueryState("agentInbox");
+ // Need to track agent ID too since this context is used in the chat page which doesn't follow the agent inbox pattern
+ const [agentId_] = useQueryState("agentId");
const [isPending] = useTransition();
+ const getAgentInboxIds = (): [string, string] | undefined => {
+ if (agentInboxId) {
+ const [assistantId, deploymentId] = agentInboxId.split(":");
+ return [assistantId, deploymentId];
+ }
+ if (!agentId_) {
+ return undefined;
+ }
+ const deploymentId = agents.find(
+ (a) => a.assistant_id === agentId_,
+ )?.deploymentId;
+ if (!deploymentId) {
+ return undefined;
+ }
+ return [agentId_, deploymentId];
+ };
+
// Get thread filter query params using the custom hook
const [inboxParam] = useQueryState(
"inbox",
@@ -74,13 +94,6 @@ function ThreadsProviderInternal<
const fetchThreads = React.useCallback(
async (agentId: string, deploymentId: string) => {
- if (!agentInboxId) {
- toast.error("No agent inbox ID found", {
- richColors: true,
- });
- return;
- }
-
setLoading(true);
const client = createClient(deploymentId);
@@ -215,8 +228,7 @@ function ThreadsProviderInternal<
setThreadData(sortedData);
setHasMoreThreads(threads.length === limit);
- } catch (e) {
- logger.error("Failed to fetch threads", e);
+ } catch (_) {
toast.error("Failed to load threads. Please try again.");
} finally {
// Always reset loading state, even after errors
@@ -240,8 +252,7 @@ function ThreadsProviderInternal<
try {
// Fetch threads
fetchThreads(assistantId, deploymentId);
- } catch (e) {
- logger.error("Error occurred while fetching threads", e);
+ } catch (_) {
toast.error("Failed to load threads. Please try again.");
// Always reset loading state in case of error
setLoading(false);
@@ -250,14 +261,11 @@ function ThreadsProviderInternal<
const fetchSingleThread = React.useCallback(
async (threadId: string): Promise | undefined> => {
- if (!agentInboxId) {
- toast.error("No agent inbox ID found when fetching thread.", {
- richColors: true,
- });
+ const agentInboxIds = getAgentInboxIds() ?? [];
+ if (!agentInboxIds.length) {
return;
}
-
- const [_, deploymentId] = agentInboxId.split(":");
+ const [_, deploymentId] = agentInboxIds;
const client = createClient(deploymentId);
try {
@@ -315,8 +323,7 @@ function ThreadsProviderInternal<
interrupts: undefined,
invalidSchema: undefined,
};
- } catch (error) {
- logger.error("Error fetching single thread", error);
+ } catch (_) {
toast.error("Failed to load thread details. Please try again.");
return undefined;
}
@@ -325,14 +332,11 @@ function ThreadsProviderInternal<
);
const ignoreThread = async (threadId: string) => {
- if (!agentInboxId) {
- toast.error("No agent inbox ID found when fetching thread.", {
- richColors: true,
- });
+ const agentInboxIds = getAgentInboxIds() ?? [];
+ if (!agentInboxIds.length) {
return;
}
-
- const [_, deploymentId] = agentInboxId.split(":");
+ const [_, deploymentId] = agentInboxIds;
const client = createClient(deploymentId);
try {
@@ -349,8 +353,7 @@ function ThreadsProviderInternal<
description: "Ignored thread",
duration: 3000,
});
- } catch (e) {
- logger.error("Error ignoring thread", e);
+ } catch (_) {
toast.error("Failed to ignore thread. Please try again.", {
duration: 3000,
});
@@ -373,34 +376,26 @@ function ThreadsProviderInternal<
}>
| undefined
: Promise | undefined => {
- if (!agentInboxId) {
- toast.error("No agent inbox ID found when fetching thread.", {
- richColors: true,
- });
+ const agentInboxIds = getAgentInboxIds() ?? [];
+ if (!agentInboxIds.length) {
return;
}
-
- const [assistantId, deploymentId] = agentInboxId.split(":");
+ const [assistantId, deploymentId] = agentInboxIds;
const client = createClient(deploymentId);
- try {
- if (options?.stream) {
- return client.runs.stream(threadId, assistantId, {
- command: {
- resume: response,
- },
- streamMode: "events",
- }) as any; // Type assertion needed due to conditional return type
- }
- return client.runs.create(threadId, assistantId, {
+ if (options?.stream) {
+ return client.runs.stream(threadId, assistantId, {
command: {
resume: response,
},
+ streamMode: "events",
}) as any; // Type assertion needed due to conditional return type
- } catch (e: any) {
- logger.error("Error sending human response", e);
- throw e;
}
+ return client.runs.create(threadId, assistantId, {
+ command: {
+ resume: response,
+ },
+ }) as any; // Type assertion needed due to conditional return type
};
const contextValue: ThreadContentType = {
diff --git a/apps/web/src/components/agent-inbox/contexts/utils.ts b/apps/web/src/providers/Thread/utils.ts
similarity index 98%
rename from apps/web/src/components/agent-inbox/contexts/utils.ts
rename to apps/web/src/providers/Thread/utils.ts
index a10f57bd..80f3cdeb 100644
--- a/apps/web/src/components/agent-inbox/contexts/utils.ts
+++ b/apps/web/src/providers/Thread/utils.ts
@@ -1,6 +1,6 @@
import { Thread, ThreadState } from "@langchain/langgraph-sdk";
-import { HumanInterrupt, ThreadData } from "../types";
-import { IMPROPER_SCHEMA } from "../constants";
+import { IMPROPER_SCHEMA } from "@/constants";
+import { HumanInterrupt, ThreadData } from "@/components/agent-inbox/types";
// TODO: Delete this once interrupt issue fixed.
export const tmpCleanInterrupts = (interrupts: Record) => {