From 69eec8cc7e98c532f4a9de06efd0a3a94615b3fa Mon Sep 17 00:00:00 2001 From: wjk <1471511095@qq.com> Date: Wed, 2 Sep 2026 09:43:31 +0800 Subject: [PATCH] feat(runtime): add Deep Agents sidecar behind the AG-UI contract Keep Web, TUI, and the TS control plane, and run Deep Agents as an independent HTTP service so tool identity, HITL, and cancel share one protocol. --- .env.example | 12 + apps/api/package.json | 1 - .../src/interaction-runtime-adapter.test.ts | 18 + apps/api/src/interaction-runtime-adapter.ts | 14 +- apps/api/src/routes/capabilities.ts | 8 +- apps/api/src/run-agent-assembly.ts | 239 ++--- apps/api/src/run-checkpoint-resume.ts | 2 +- apps/api/src/run-finalizer.ts | 13 + apps/api/src/run-identity-orchestrator.ts | 20 + apps/api/src/run-memory-assembly.ts | 109 +++ apps/api/src/runtime-agent.ts | 504 ++++++++++ apps/api/src/runtime/client.ts | 124 +++ apps/api/src/runtime/factory.ts | 53 ++ apps/api/src/runtime/in-process.ts | 47 + apps/api/src/runtime/scenarios.test.ts | 30 + apps/api/src/runtime/scenarios.ts | 185 ++++ apps/api/src/runtime/sse.test.ts | 15 + apps/api/src/runtime/sse.ts | 40 + apps/api/src/runtime/stub-server.ts | 79 ++ apps/api/src/runtime/types.ts | 81 ++ apps/api/src/server.ts | 871 +----------------- apps/api/src/session-title.ts | 54 +- .../chat/CollaborationInterruptHandler.tsx | 19 +- .../chat/RestoredInterruptHandler.tsx | 6 +- .../components/task-console/TaskConsole.tsx | 13 +- .../task-console/TraceDagCanvas.tsx | 2 +- apps/web/src/i18n/messages/en.json | 6 +- apps/web/src/i18n/messages/zh-CN.json | 6 +- apps/web/src/lib/config-api/capabilities.ts | 6 +- apps/web/src/lib/config-api/types.ts | 2 + docs/en/README.md | 2 +- docs/en/architecture/overview.md | 2 +- .../reference/deep-agents-runtime-boundary.md | 94 ++ docs/en/reference/deep-agents-runtime.md | 11 + docs/en/reference/rest-api.md | 2 +- docs/zh/README.md | 2 +- docs/zh/architecture/overview.md | 2 +- docs/zh/reference/agent-runtime.md | 2 + .../reference/deep-agents-runtime-boundary.md | 166 ++++ docs/zh/reference/deep-agents-runtime.md | 175 ++++ docs/zh/reference/rest-api.md | 2 +- package-lock.json | 1 - package.json | 4 + packages/contracts/src/index.ts | 12 +- scripts/dev.mjs | 118 +-- scripts/smoke-deepagents-runtime.mjs | 74 ++ scripts/smoke-deepagents-sdk.mjs | 137 +++ scripts/stack-runner.mjs | 64 +- scripts/stack-runtime-config.mjs | 9 +- scripts/stack-runtime-config.test.mjs | 7 + scripts/start-deepagents-runtime.mjs | 34 + scripts/start-runtime-stub.mjs | 12 + services/deepagents-runtime/.gitignore | 7 + services/deepagents-runtime/README.md | 33 + services/deepagents-runtime/pyproject.toml | 46 + .../src/deepagents_runtime/__init__.py | 3 + .../src/deepagents_runtime/__main__.py | 22 + .../src/deepagents_runtime/agent.py | 197 ++++ .../src/deepagents_runtime/app.py | 114 +++ .../src/deepagents_runtime/config.py | 54 ++ .../src/deepagents_runtime/events.py | 202 ++++ .../src/deepagents_runtime/messages.py | 65 ++ .../src/deepagents_runtime/models.py | 65 ++ .../src/deepagents_runtime/stream.py | 316 +++++++ .../tests/test_app_and_sdk.py | 95 ++ .../deepagents-runtime/tests/test_config.py | 21 + .../deepagents-runtime/tests/test_events.py | 110 +++ .../deepagents-runtime/tests/test_messages.py | 26 + .../tests/test_stream_mapping.py | 202 ++++ 69 files changed, 3864 insertions(+), 1225 deletions(-) create mode 100644 apps/api/src/runtime-agent.ts create mode 100644 apps/api/src/runtime/client.ts create mode 100644 apps/api/src/runtime/factory.ts create mode 100644 apps/api/src/runtime/in-process.ts create mode 100644 apps/api/src/runtime/scenarios.test.ts create mode 100644 apps/api/src/runtime/scenarios.ts create mode 100644 apps/api/src/runtime/sse.test.ts create mode 100644 apps/api/src/runtime/sse.ts create mode 100644 apps/api/src/runtime/stub-server.ts create mode 100644 apps/api/src/runtime/types.ts create mode 100644 docs/en/reference/deep-agents-runtime-boundary.md create mode 100644 docs/en/reference/deep-agents-runtime.md create mode 100644 docs/zh/reference/deep-agents-runtime-boundary.md create mode 100644 docs/zh/reference/deep-agents-runtime.md create mode 100644 scripts/smoke-deepagents-runtime.mjs create mode 100644 scripts/smoke-deepagents-sdk.mjs create mode 100644 scripts/start-deepagents-runtime.mjs create mode 100644 scripts/start-runtime-stub.mjs create mode 100644 services/deepagents-runtime/.gitignore create mode 100644 services/deepagents-runtime/README.md create mode 100644 services/deepagents-runtime/pyproject.toml create mode 100644 services/deepagents-runtime/src/deepagents_runtime/__init__.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/__main__.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/agent.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/app.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/config.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/events.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/messages.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/models.py create mode 100644 services/deepagents-runtime/src/deepagents_runtime/stream.py create mode 100644 services/deepagents-runtime/tests/test_app_and_sdk.py create mode 100644 services/deepagents-runtime/tests/test_config.py create mode 100644 services/deepagents-runtime/tests/test_events.py create mode 100644 services/deepagents-runtime/tests/test_messages.py create mode 100644 services/deepagents-runtime/tests/test_stream_mapping.py diff --git a/.env.example b/.env.example index 867b55b6..14ee6501 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,18 @@ API_PORT=8787 WEB_HOST=127.0.0.1 WEB_PORT=3000 +# Independent Deep Agents runtime. `npm run dev` starts services/deepagents-runtime +# on :8790 and sets RUNTIME_SERVICE_URL automatically. Leave empty only when you +# want the in-process TypeScript stub, or pass --no-runtime. +# RUNTIME_SERVICE_URL=http://127.0.0.1:8790 +# RUNTIME_SERVICE_TOKEN= +# RUNTIME_HOST=127.0.0.1 +# RUNTIME_PORT=8790 +# RUNTIME_STUB_HOST=127.0.0.1 +# RUNTIME_STUB_PORT=8790 +# Use a scripted model when LLM_API_KEY is empty. Set live to require a real key. +# DEEPAGENTS_RUNTIME_MODEL=fake + # LLM_* values are optional server-side defaults. Models can also be created in the Web UI after deploy. LLM_PROVIDER=openai-compatible LLM_MODEL=qwen-plus diff --git a/apps/api/package.json b/apps/api/package.json index 8159bda5..fe5719c2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@ag-ui/client": "^0.0.57", - "@ag-ui/mastra": "^1.0.3", "@copilotkit/runtime": "0.0.0-mme-ag-ui-0-0-46-20260227141603", "@datafoundry/agent-runtime": "0.2.0", "@datafoundry/contracts": "0.2.0", diff --git a/apps/api/src/interaction-runtime-adapter.test.ts b/apps/api/src/interaction-runtime-adapter.test.ts index e372aa63..04992c8c 100644 --- a/apps/api/src/interaction-runtime-adapter.test.ts +++ b/apps/api/src/interaction-runtime-adapter.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildHitlSuspendBridgeEvents, + parseInterruptValue, type HitlToolCallBoundaryState, type InteractionInterrupt } from "./interaction-runtime-adapter.js"; @@ -108,3 +109,20 @@ describe("buildHitlSuspendBridgeEvents", () => { expect(events[0]).toBe(interactionEvent); }); }); + +describe("parseInterruptValue", () => { + it("accepts agent_interrupt and legacy mastra_suspend payloads", () => { + expect(parseInterruptValue({ + type: "agent_interrupt", + toolCallId: "c1", + toolName: "ask_user", + runId: "run-1" + }).type).toBe("agent_interrupt"); + expect(parseInterruptValue({ + type: "mastra_suspend", + toolCallId: "c1", + toolName: "submit_plan", + runId: "run-1" + }).toolName).toBe("submit_plan"); + }); +}); diff --git a/apps/api/src/interaction-runtime-adapter.ts b/apps/api/src/interaction-runtime-adapter.ts index d0fa759c..e6371572 100644 --- a/apps/api/src/interaction-runtime-adapter.ts +++ b/apps/api/src/interaction-runtime-adapter.ts @@ -5,6 +5,7 @@ import type { MetadataStore } from "@datafoundry/metadata"; import { createHash, randomUUID } from "node:crypto"; export type InteractionInterrupt = { + type?: "agent_interrupt" | "mastra_suspend"; args: unknown; resumeSchema: unknown; runId: string; @@ -28,7 +29,7 @@ export class InteractionRuntimeAdapter { ) {} /** - * Convert Mastra's interrupt event into the stable application interaction event. + * Convert a runtime interrupt event into the stable application interaction event. * Returns both the parsed interrupt (for TOOL_CALL_START persistence) and the * `interaction.requested` CUSTOM event. */ @@ -123,7 +124,7 @@ export const buildHitlToolCallStartEvent = (interrupt: InteractionInterrupt): Ba /** * Pair with {@link buildHitlToolCallStartEvent} before the transport-only RUN_FINISHED. - * Mastra's on_interrupt path never emits TOOL_CALL_END; without it, AbstractAgent + * Some runtimes emit only on_interrupt; without TOOL_CALL_END, AbstractAgent * verifyEvents rejects RUN_FINISHED while the tool call is still active. */ export const buildHitlToolCallEndEvent = (interrupt: InteractionInterrupt): BaseEvent => @@ -172,7 +173,7 @@ export function buildHitlSuspendBridgeEvents(input: { return events; } -/** Extract a Mastra resume command from an AG-UI run request. */ +/** Extract a HITL resume command from an AG-UI run request. */ export const extractInteractionResume = (input: RunAgentInput): InteractionResume | undefined => { if (!isRecord(input.forwardedProps) || !isRecord(input.forwardedProps.command)) { return undefined; @@ -205,7 +206,7 @@ const readInterruptEventValue = ( return customEvent.value; } return { - type: "mastra_suspend", + type: interrupt.type ?? "agent_interrupt", args: interrupt.args, resumeSchema: interrupt.resumeSchema, runId: interrupt.runId, @@ -215,11 +216,11 @@ const readInterruptEventValue = ( }; }; -const parseInterruptValue = (value: unknown): InteractionInterrupt => { +export const parseInterruptValue = (value: unknown): InteractionInterrupt => { const parsed = typeof value === "string" ? parseJson(value) : value; if ( !isRecord(parsed) - || parsed.type !== "mastra_suspend" + || (parsed.type !== "agent_interrupt" && parsed.type !== "mastra_suspend") || typeof parsed.toolCallId !== "string" || typeof parsed.toolName !== "string" || typeof parsed.runId !== "string" @@ -230,6 +231,7 @@ const parseInterruptValue = (value: unknown): InteractionInterrupt => { throw new Error(`UNSUPPORTED_INTERACTION_TOOL:${parsed.toolName}`); } return { + type: parsed.type, args: parsed.args, resumeSchema: parsed.resumeSchema, runId: parsed.runId, diff --git a/apps/api/src/routes/capabilities.ts b/apps/api/src/routes/capabilities.ts index 2c3a314c..708e8d90 100644 --- a/apps/api/src/routes/capabilities.ts +++ b/apps/api/src/routes/capabilities.ts @@ -10,6 +10,8 @@ const CAPABILITIES = { "conversation.memory": true, "conversation.title": true, "interaction.resume": true, + "runtime.dataTools": false, + "runtime.traceDag": false, "datasource.fieldMasking": true, "datasource.extendedTypes": true, "datasource.introspectionPolicy": true, @@ -22,12 +24,12 @@ const CAPABILITIES = { "kb.scope": true, "llm.advancedSampling": true, "llm.samplingParams": true, - knowledge: true, - mcp: true, + knowledge: false, + mcp: false, "mcp.stdio": true, "mcp.toolPolicy": true, "skill.resourceBinding": true, - skills: true + skills: false }; export const handleCapabilitiesRequest = (method: string | undefined): ConfigApiResponse => { diff --git a/apps/api/src/run-agent-assembly.ts b/apps/api/src/run-agent-assembly.ts index cba9d8f0..60e98b5f 100644 --- a/apps/api/src/run-agent-assembly.ts +++ b/apps/api/src/run-agent-assembly.ts @@ -1,48 +1,35 @@ -import { MastraAgent } from "@ag-ui/mastra"; import type { RunAgentInput } from "@ag-ui/client"; -import type { ArtifactService, SessionOutputService } from "@datafoundry/artifacts"; import { - createDataFoundry, createDataFoundryRunContext, - type AgentRunContext, - type AgentContextItem, - type AgUiEventEmitter, - type ContextPackage, - type ContextPackageRecorder, - type GoalRuntimeAdapter, - type RunProtocolBoundary, - type ContextPackageRef, - type ProtocolStateStore, - type SessionIntent, - type TaskStateRuntime, - type WorkspaceAttachment + type AgentRunContext } from "@datafoundry/agent-runtime"; -import type { DataGateway } from "@datafoundry/data-gateway"; -import type { FileAssetService } from "@datafoundry/files"; -import type { KnowledgeService } from "@datafoundry/knowledge"; -import type { LongTermMemoryRecord } from "@datafoundry/metadata"; -import type { SkillRecord, SkillSelectionResult } from "@datafoundry/skills"; import type { InteractionResume } from "./interaction-runtime-adapter.js"; -import { createPolicyMcpTools } from "./policy-mcp-tools.js"; -import type { McpRuntime, ResolvedRunConfig } from "./run-config-resolver.js"; +import { createRuntimeTransport } from "./runtime/factory.js"; +import { + RUNTIME_PROVIDER, + V1_SYSTEM_PROMPT, + type RuntimeRunRequest, + type RuntimeTransport +} from "./runtime/types.js"; +import type { ResolvedRunConfig } from "./run-config-resolver.js"; import type { EffectiveRunConfig } from "./run-input.js"; export type RunAgentAssembly = { destroyWorkspace(): Promise; - goalRuntime?: GoalRuntimeAdapter | undefined; governedMessages: RunAgentInput["messages"]; - mastraAgent: MastraAgent; - protocol: RunProtocolBoundary; - flushProtocolEvents(): void; - workspace: { - command_execution_enabled: boolean; - isolation: "bwrap" | "none" | "seatbelt"; - }; - /** Persistent cross-session workspace root (read-only asset area). */ - workspaceDir: string; - /** Per-session directory (agent filesystem basePath; new files default here). */ + runtime: RuntimeTransport; + buildRunRequest(input: { + checkpointRef?: string; + interactionResume?: InteractionResume; + messages: RunAgentInput["messages"]; + runId: string; + sessionId: string; + userId: string; + workspaceId: string; + }): RuntimeRunRequest; sessionDir: string; + workspaceDir: string; }; type CreateRunAgentContextInput = { @@ -57,42 +44,12 @@ type CreateRunAgentContextInput = { }; type CreateRunAgentAssemblyInput = { - abortSignal?: AbortSignal | undefined; - artifactService: ArtifactService; - dataGateway: DataGateway; - effectiveRunConfig: EffectiveRunConfig; - emitter: AgUiEventEmitter; - contextPackageRecorder?: ContextPackageRecorder; - contextPackageExists(reference: ContextPackageRef): boolean; - evidenceContextItems?: AgentContextItem[] | undefined; - fileAssetService: FileAssetService; - goal?: EffectiveRunConfig["goal"] | undefined; - initialContextPackage?: ContextPackage | undefined; - interactionResume?: InteractionResume | undefined; - knowledgeService: KnowledgeService; - longTermMemories: LongTermMemoryRecord[]; - mcpRuntime: McpRuntime; messages: RunAgentInput["messages"]; - modelContextProfile?: ResolvedRunConfig["modelContextProfile"] | undefined; modelProvider: ResolvedRunConfig["modelProvider"]; - protocolStateStore: ProtocolStateStore; - modelSettings?: ResolvedRunConfig["modelSettings"] | undefined; - runContext: AgentRunContext; - sessionOutputService: SessionOutputService; - selectedSkills: SkillRecord[]; - /** Session intent resolved by the caller; enables deterministic protocol - * inheritance for weak follow-ups. */ - sessionIntent?: SessionIntent | undefined; - /** Budgeted background block for the protocol classifier. */ - classifierContext?: string | undefined; - skillSelection: SkillSelectionResult; - taskStateRuntime: TaskStateRuntime; - userId: string; - workspaceId: string; - workspaceRoot: string; + runtime?: RuntimeTransport; }; -/** Create the canonical agent run context used by Mastra tools, projections, and metadata. */ +/** Create the canonical agent run context used by projections and metadata. */ export const createRunAgentContext = (input: CreateRunAgentContextInput): AgentRunContext => createDataFoundryRunContext({ user_id: input.userId, @@ -110,119 +67,55 @@ export const createRunAgentContext = (input: CreateRunAgentContextInput): AgentR ...(input.effectiveRunConfig.activeLlmProfileId ? { requested_llm_profile_id: input.effectiveRunConfig.activeLlmProfileId } : {}), - ...(input.effectiveRunConfig.activeSkillId ? { active_skill_id: input.effectiveRunConfig.activeSkillId } : {}), - ...(input.effectiveRunConfig.enabledKnowledgeIds.length > 0 - ? { enabled_knowledge_ids: input.effectiveRunConfig.enabledKnowledgeIds } - : {}), - ...(input.effectiveRunConfig.enabledMcpServerIds.length > 0 - ? { enabled_mcp_server_ids: input.effectiveRunConfig.enabledMcpServerIds } - : {}), - ...(input.effectiveRunConfig.mentioned - ? { - mentioned: { - db: input.effectiveRunConfig.mentioned.db, - kb: input.effectiveRunConfig.mentioned.kb, - mcp: input.effectiveRunConfig.mentioned.mcp, - skill: input.effectiveRunConfig.mentioned.skill - } - } - : {}), - ...((input.effectiveRunConfig.pinnedPaths?.length ?? 0) > 0 - ? { pinned_paths: input.effectiveRunConfig.pinnedPaths } - : {}), - ...(input.effectiveRunConfig.evidenceRefs.length > 0 - ? { evidence_refs: input.effectiveRunConfig.evidenceRefs } - : {}), model_name: input.modelProvider.model_name }); -/** Assemble the Mastra-backed AG-UI agent and its run-scoped execution metadata. */ -export const createRunAgentAssembly = async ( +/** Assemble a Deep Agents runtime client for one AG-UI run. */ +export const createRunAgentAssembly = ( input: CreateRunAgentAssemblyInput -): Promise => { - const mcpTools = createPolicyMcpTools(input.mcpRuntime.servers); - const { - agent, - commandExecutionEnabled, - destroyWorkspace, - goalRuntime, - governedMessages, - flushProtocolEvents, - isolation, - protocol, - workspaceDir, - sessionDir - } = await createDataFoundry({ - ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}), - artifactService: input.artifactService, - ...(input.contextPackageRecorder ? { contextPackageRecorder: input.contextPackageRecorder } : {}), - contextPackageExists: input.contextPackageExists, - dataGateway: input.dataGateway, - fileAssetService: input.fileAssetService, - ...(input.initialContextPackage ? { initialContextPackage: input.initialContextPackage } : {}), - knowledgeService: input.knowledgeService, - ...(input.mcpRuntime.toolNames.length > 0 ? { mcpToolNames: input.mcpRuntime.toolNames } : {}), - ...(Object.keys(mcpTools).length > 0 ? { mcpTools } : {}), - emitter: input.emitter, - ...(input.effectiveRunConfig.protocol ? { explicitProtocol: input.effectiveRunConfig.protocol } : {}), - messages: input.messages, - ...(input.modelContextProfile ? { modelContextProfile: input.modelContextProfile } : {}), - modelProvider: input.modelProvider, - protocolStateStore: input.protocolStateStore, - ...(input.effectiveRunConfig.resourceRevisions - ? { resourceRevisions: input.effectiveRunConfig.resourceRevisions } - : {}), - ...(input.modelSettings ? { modelSettings: input.modelSettings } : {}), - ...(input.evidenceContextItems?.length ? { evidenceContextItems: input.evidenceContextItems } : {}), - ...(input.longTermMemories.length > 0 ? { longTermMemory: { records: input.longTermMemories } } : {}), - runContext: input.runContext, - sessionOutputService: input.sessionOutputService, - selectedSkills: input.selectedSkills, - ...(input.sessionIntent ? { sessionIntent: input.sessionIntent } : {}), - ...(input.classifierContext ? { classifierContext: input.classifierContext } : {}), - skillSelection: input.skillSelection, - taskStateRuntime: input.taskStateRuntime, - ...(!input.interactionResume && input.goal ? { goal: input.goal } : {}), - ...(input.effectiveRunConfig.fileIds.length > 0 - ? { workspaceAttachments: resolveWorkspaceAttachments(input) } - : {}), - workspaceRoot: input.workspaceRoot - }); - const mastraAgent = new MastraAgent({ - agent, - resourceId: input.userId +): RunAgentAssembly => { + const runtime = input.runtime ?? createRuntimeTransport({ + ...(process.env.RUNTIME_SERVICE_TOKEN ? { token: process.env.RUNTIME_SERVICE_TOKEN } : {}) }); - return { - destroyWorkspace, - flushProtocolEvents, - ...(goalRuntime ? { goalRuntime } : {}), - governedMessages, - mastraAgent, - protocol, - workspace: { - command_execution_enabled: commandExecutionEnabled, - isolation - }, - workspaceDir, - sessionDir + destroyWorkspace: async () => undefined, + governedMessages: input.messages, + runtime, + sessionDir: "", + workspaceDir: "", + buildRunRequest: ({ checkpointRef, interactionResume, messages, runId, sessionId, userId, workspaceId }) => ({ + threadId: sessionId, + runId, + messages, + systemPrompt: V1_SYSTEM_PROMPT, + model: { + provider: RUNTIME_PROVIDER, + name: input.modelProvider.model_name, + ...(input.modelProvider.kind === "openai-compatible" + ? { profileId: "openai-compatible" } + : {}) + }, + limits: { maxSteps: 80 }, + ...(checkpointRef ? { checkpointRef } : {}), + ...(interactionResume + ? { + resume: { + interrupt: { + type: interactionResume.interrupt.type === "mastra_suspend" + ? "mastra_suspend" + : "agent_interrupt", + toolCallId: interactionResume.interrupt.toolCallId, + toolName: interactionResume.interrupt.toolName, + runId: interactionResume.interrupt.runId, + args: interactionResume.interrupt.args, + suspendPayload: interactionResume.interrupt.suspendPayload, + resumeSchema: interactionResume.interrupt.resumeSchema + }, + response: interactionResume.response + } + } + : {}), + trace: { userId, workspaceId } + }) }; }; - -const resolveWorkspaceAttachments = (input: CreateRunAgentAssemblyInput): WorkspaceAttachment[] => - input.effectiveRunConfig.fileIds.map((fileId) => { - const resolved = input.fileAssetService.getRef({ - user_id: input.userId, - workspace_id: input.workspaceId, - id: fileId - }); - return { - file_id: resolved.ref.id, - filename: resolved.ref.filename, - ...(resolved.ref.declared_mime_type ?? resolved.asset.detected_mime_type - ? { mime_type: resolved.ref.declared_mime_type ?? resolved.asset.detected_mime_type } - : {}), - size_bytes: resolved.asset.size_bytes, - source_path: resolved.asset.storage_path - }; - }); diff --git a/apps/api/src/run-checkpoint-resume.ts b/apps/api/src/run-checkpoint-resume.ts index 80da7848..c546e6b5 100644 --- a/apps/api/src/run-checkpoint-resume.ts +++ b/apps/api/src/run-checkpoint-resume.ts @@ -42,7 +42,7 @@ export function resolveCheckpointResumeSeed(input: { }; } -function checkpointIdFromRunInput(runInput: RunAgentInput): string | undefined { +export function checkpointIdFromRunInput(runInput: RunAgentInput): string | undefined { const forwardedProps = recordValue(runInput.forwardedProps); const state = recordValue(runInput.state); return stringValue(forwardedProps?.checkpointId) ?? diff --git a/apps/api/src/run-finalizer.ts b/apps/api/src/run-finalizer.ts index 37d34877..0a5c9930 100644 --- a/apps/api/src/run-finalizer.ts +++ b/apps/api/src/run-finalizer.ts @@ -74,6 +74,19 @@ export class RunFinalizer { this.input.emit(input.terminalEvent); } + async finish(input: { terminalEvent: BaseEvent }): Promise { + await this.flushCompletedMemoryWithTimeout(); + await this.syncSessionOutputs().catch(() => undefined); + this.input.metadataStore.runs.updateStatus({ + user_id: this.input.userId, + run_id: this.input.runId, + status: "completed" + }); + this.input.emit(createRunStatusDelta("completed", { runId: this.input.runId })); + await this.input.destroyWorkspace().catch(() => undefined); + this.input.emit(input.terminalEvent); + } + async complete(input: { goalRuntime?: GoalRuntimeAdapter | undefined; terminalDecision?: ProtocolCompletionDecision | undefined; diff --git a/apps/api/src/run-identity-orchestrator.ts b/apps/api/src/run-identity-orchestrator.ts index 69c415f9..3b354b92 100644 --- a/apps/api/src/run-identity-orchestrator.ts +++ b/apps/api/src/run-identity-orchestrator.ts @@ -2,6 +2,7 @@ import type { BaseEvent, RunAgentInput } from "@ag-ui/client"; import { type MetadataStore, type RunEventWriter } from "@datafoundry/metadata"; import type { InteractionResume } from "./interaction-runtime-adapter.js"; +import { RUNTIME_BOUND_EVENT } from "./runtime/types.js"; import { createRunRequestFingerprint, resolveExistingRun, @@ -87,6 +88,15 @@ export const resolveRunIdentity = (input: ResolveRunIdentityInput): RunIdentityR if (!resume) { throw new Error(`INTERACTION_RESUME_REQUIRED:${runId}`); } + if (!hasRuntimeBoundEvent(input.runEventWriter, input.userId, runId)) { + input.metadataStore.runs.updateStatus({ + user_id: input.userId, + run_id: runId, + status: "failed", + error_message: "LEGACY_RUNTIME_SUSPEND_UNRECOVERABLE" + }); + throw new Error("LEGACY_RUNTIME_SUSPEND_UNRECOVERABLE"); + } if (existingRun?.session_id !== sessionId) { throw new Error(`RUN_SESSION_MISMATCH:${runId}`); } @@ -147,3 +157,13 @@ export const resolveRunIdentity = (input: ResolveRunIdentityInput): RunIdentityR ...(selectedDatasourceId ? { selectedDatasourceId } : {}) }; }; + +const hasRuntimeBoundEvent = ( + runEventWriter: RunEventWriter, + userId: string, + runId: string +): boolean => + runEventWriter.replay({ user_id: userId, run_id: runId }).some((envelope) => { + const event = envelope.event as { name?: string; type?: string }; + return event.type === "CUSTOM" && event.name === RUNTIME_BOUND_EVENT; + }); diff --git a/apps/api/src/run-memory-assembly.ts b/apps/api/src/run-memory-assembly.ts index eb92d3c0..05743c37 100644 --- a/apps/api/src/run-memory-assembly.ts +++ b/apps/api/src/run-memory-assembly.ts @@ -33,6 +33,115 @@ export type RunMemoryAssembly = { flushDraftsMemory(): ConversationMessageRecord[]; }; +type CreateMetadataRunMemoryAssemblyInput = { + isResume: boolean; + metadataStore: MetadataStore; + modelName: string; + runId: string; + runInput: RunAgentInput; + selectedDatasourceId?: string; + sessionId: string; + userId: string; + userInput: string; + evidenceRefs?: EvidenceRef[]; +}; + +/** Metadata-only conversation assembly used by the Deep Agents runtime path. */ +export const createMetadataRunMemoryAssembly = ( + input: CreateMetadataRunMemoryAssemblyInput +): RunMemoryAssembly => { + const conversationMemory = new ConversationMemoryService({ + compactMemorySource: "metadata-summary", + historyProvider: ({ excludeRunId, limit, sessionId, userId }) => { + const lineage = resolveSessionLineage({ + metadataStore: input.metadataStore, + sessionId, + userId + }); + const summary = latestVisibleConversationSummary({ + lineage, + metadataStore: input.metadataStore, + sessionId, + userId + }); + return { + history: listVisibleConversationMessages({ + excludeRunId, + lineage, + limit, + metadataStore: input.metadataStore, + userId + }), + ...(summary ? { summary } : {}) + }; + }, + repository: input.metadataStore.conversationMessages, + sessionId: input.sessionId, + summaryRepository: input.metadataStore.conversationSummaries, + userId: input.userId + }); + + if (!input.isResume) { + const currentUserRecord = conversationMemory.persistCurrentUserMessage({ + currentUserText: input.userInput, + evidenceRefs: input.evidenceRefs ?? [], + runId: input.runId, + runInput: input.runInput + }); + input.metadataStore.sessions.touchLastMessage({ + user_id: input.userId, + session_id: input.sessionId, + last_message_at: currentUserRecord.created_at + }); + } + + const conversationMessages = input.isResume + ? input.runInput.messages + : conversationMemory.buildRunMessages({ + currentUserText: input.userInput, + modelName: input.modelName, + runId: input.runId, + runInput: input.runInput + }).messages; + const conversationMemoryObserver = conversationMemory.createEventObserver({ runId: input.runId }); + const longTermMemories = resolveLongTermMemories({ + ...(input.selectedDatasourceId ? { datasourceId: input.selectedDatasourceId } : {}), + metadataStore: input.metadataStore, + sessionId: input.sessionId, + userId: input.userId, + userInput: input.userInput + }); + + return { + conversationMemoryObserver, + conversationMessages, + longTermMemories, + flushDraftsMemory: () => { + const assistantRecords = conversationMemoryObserver.flushDrafts(); + const lastAssistantRecord = assistantRecords.at(-1); + if (lastAssistantRecord) { + input.metadataStore.sessions.touchLastMessage({ + user_id: input.userId, + session_id: input.sessionId, + last_message_at: lastAssistantRecord.created_at + }); + } + return assistantRecords; + }, + flushCompletedMemory: async ({ signal }) => { + const assistantRecords = await conversationMemoryObserver.flushCompleted({ signal }); + const lastAssistantRecord = assistantRecords.at(-1); + if (lastAssistantRecord) { + input.metadataStore.sessions.touchLastMessage({ + user_id: input.userId, + session_id: input.sessionId, + last_message_at: lastAssistantRecord.created_at + }); + } + } + }; +}; + type CreateRunMemoryAssemblyInput = { conversationMemoryMode: AgentMemoryMode; isResume: boolean; diff --git a/apps/api/src/runtime-agent.ts b/apps/api/src/runtime-agent.ts new file mode 100644 index 00000000..5d5989e4 --- /dev/null +++ b/apps/api/src/runtime-agent.ts @@ -0,0 +1,504 @@ +import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput } from "@ag-ui/client"; +import { createCustomEvent, type AgentRunContext } from "@datafoundry/agent-runtime"; +import { type MeResponse } from "@datafoundry/contracts"; +import { type FileAssetService } from "@datafoundry/files"; +import { RunEventWriter, type MetadataStore } from "@datafoundry/metadata"; +import { Observable } from "rxjs"; + +import { + buildHitlSuspendBridgeEvents, + extractInteractionResume, + InteractionRuntimeAdapter +} from "./interaction-runtime-adapter.js"; +import { persistCurrentUserMessage } from "./conversation-memory.js"; +import { createRunAgentAssembly, createRunAgentContext } from "./run-agent-assembly.js"; +import { RunCancelRegistry } from "./run-cancel-registry.js"; +import { resolveRunConfig } from "./run-config-resolver.js"; +import { RunEventPipeline } from "./run-event-pipeline.js"; +import { RunFinalizer, createRunStatusDelta } from "./run-finalizer.js"; +import { resolveRunIdentity } from "./run-identity-orchestrator.js"; +import { extractLastUserText } from "./run-input.js"; +import { createMetadataRunMemoryAssembly } from "./run-memory-assembly.js"; +import { checkpointIdFromRunInput } from "./run-checkpoint-resume.js"; +import { startSessionTitleTask } from "./session-title.js"; +import { TaskPlanProjector } from "./task-plan-projector.js"; +import { ToolCallResultBridge } from "./tool-call-result-bridge.js"; +import type { RuntimeTransport } from "./runtime/types.js"; +import { assistantMessageIdFromEvent } from "./protocol-run-completion.js"; + +export const emitEarlyRunFailure = ( + subscriber: { complete(): void; next(event: BaseEvent): void }, + runId: string, + message: string +): void => { + const timestamp = Date.now(); + subscriber.next({ type: EventType.RUN_STARTED, runId, timestamp }); + subscriber.next(createRunStatusDelta("failed", { errorMessage: message, runId })); + subscriber.next({ type: EventType.RUN_ERROR, message, timestamp }); + subscriber.complete(); +}; + +export const persistEarlyFailedUserMessage = (input: { + errorMessage: string; + isResume: boolean; + metadataStore: MetadataStore; + runId: string; + runInput: RunAgentInput; + sessionId: string; + userId: string; + userInput: string; +}): void => { + if (input.isResume || !input.userInput.trim()) { + return; + } + try { + input.metadataStore.sessions.create({ + user_id: input.userId, + id: input.sessionId + }); + input.metadataStore.runs.claim({ + user_id: input.userId, + id: input.runId, + session_id: input.sessionId, + user_input: input.userInput, + status: "running", + model_name: "unresolved" + }); + input.metadataStore.runs.updateStatus({ + user_id: input.userId, + run_id: input.runId, + status: "failed", + error_message: input.errorMessage + }); + const record = persistCurrentUserMessage({ + currentUserText: input.userInput, + repository: input.metadataStore.conversationMessages, + runId: input.runId, + runInput: input.runInput, + sessionId: input.sessionId, + userId: input.userId + }); + input.metadataStore.sessions.touchLastMessage({ + user_id: input.userId, + session_id: input.sessionId, + last_message_at: record.created_at + }); + } catch (error) { + console.warn("[data-foundry] failed to persist early failed user message", error); + } +}; + +export type DataFoundryAgUiAgentInput = { + fileAssetService: FileAssetService; + metadataStore: MetadataStore; + memoryExtractionTimeoutMs: number; + runCancelRegistry: RunCancelRegistry; + runtime: RuntimeTransport; + user: MeResponse; + workspaceId: string; +}; + +export class DataFoundryAgUiAgent extends AbstractAgent { + private input: DataFoundryAgUiAgentInput; + + constructor(input: DataFoundryAgUiAgentInput) { + super({ + agentId: "dataFoundry", + description: "DataFoundry control-plane agent backed by an external Deep Agents runtime." + }); + this.input = input; + } + + clone(): DataFoundryAgUiAgent { + const cloned = super.clone() as DataFoundryAgUiAgent; + cloned.input = this.input; + return cloned; + } + + run(runInput: RunAgentInput): Observable { + return new Observable((subscriber) => { + const interactionResume = extractInteractionResume(runInput); + const runId = interactionResume?.interrupt.runId ?? runInput.runId; + const run = async (): Promise => { + const sessionId = runInput.threadId; + const normalizedRunInput = runId === runInput.runId ? runInput : { ...runInput, runId }; + const userInput = extractLastUserText(normalizedRunInput) ?? "CopilotKit AG-UI run"; + if (checkpointIdFromRunInput(normalizedRunInput)) { + persistEarlyFailedUserMessage({ + errorMessage: "CHECKPOINT_RESUME_DISABLED", + isResume: Boolean(interactionResume), + metadataStore: this.input.metadataStore, + runId, + runInput: normalizedRunInput, + sessionId, + userId: this.input.user.id, + userInput + }); + emitEarlyRunFailure(subscriber, runId, "CHECKPOINT_RESUME_DISABLED"); + return; + } + + let effectiveRunConfig; + let modelProvider; + let modelSettings; + let runTimeoutMs; + try { + ({ + effectiveRunConfig, + modelProvider, + modelSettings, + runTimeoutMs + } = resolveRunConfig({ + metadataStore: this.input.metadataStore, + runInput: normalizedRunInput, + userId: this.input.user.id, + userInput, + workspaceId: this.input.workspaceId + })); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + persistEarlyFailedUserMessage({ + errorMessage: message, + isResume: Boolean(interactionResume), + metadataStore: this.input.metadataStore, + runId, + runInput: normalizedRunInput, + sessionId, + userId: this.input.user.id, + userInput + }); + emitEarlyRunFailure(subscriber, runId, message); + return; + } + + const runEventWriter = new RunEventWriter(this.input.metadataStore.runEvents); + const identity = resolveRunIdentity({ + effectiveRunConfig, + ...(interactionResume ? { interactionResume } : {}), + metadataStore: this.input.metadataStore, + modelName: modelProvider.model_name, + runCancelRegistry: this.input.runCancelRegistry, + runEventWriter, + runInput: normalizedRunInput, + userId: this.input.user.id, + userInput + }); + if (identity.kind === "replay") { + identity.events.forEach((event) => subscriber.next(event)); + subscriber.complete(); + return; + } + + const { isResume, selectedDatasourceId } = identity; + const memoryAssembly = createMetadataRunMemoryAssembly({ + isResume, + metadataStore: this.input.metadataStore, + modelName: modelProvider.model_name, + runId, + runInput: normalizedRunInput, + ...(selectedDatasourceId ? { selectedDatasourceId } : {}), + sessionId, + userId: this.input.user.id, + userInput, + evidenceRefs: effectiveRunConfig.evidenceRefs + }); + const runContext: AgentRunContext = createRunAgentContext({ + effectiveRunConfig, + modelProvider, + runId, + ...(selectedDatasourceId ? { selectedDatasourceId } : {}), + sessionId, + userId: this.input.user.id, + userInput, + workspaceId: this.input.workspaceId + }); + const agentAssembly = createRunAgentAssembly({ + messages: memoryAssembly.conversationMessages, + modelProvider, + runtime: this.input.runtime + }); + const taskPlanProjector = new TaskPlanProjector(runContext); + const toolCallResultBridge = new ToolCallResultBridge(); + const runAbortController = new AbortController(); + const interactionRuntime = new InteractionRuntimeAdapter( + this.input.metadataStore, + this.input.user.id, + sessionId, + runId + ); + const eventPipeline = new RunEventPipeline({ + conversationMemoryObserver: memoryAssembly.conversationMemoryObserver, + runEventWriter, + runId, + sessionId, + taskPlanProjector, + toolCallResultBridge, + userId: this.input.user.id, + sink: (event) => subscriber.next(event) + }); + const emit = (event: BaseEvent): void => { + eventPipeline.emit(event); + }; + const finalizer = new RunFinalizer({ + destroyWorkspace: agentAssembly.destroyWorkspace, + emit, + fileAssetService: this.input.fileAssetService, + flushCompletedMemory: (flushInput) => memoryAssembly.flushCompletedMemory(flushInput), + flushDraftsMemory: () => { + memoryAssembly.flushDraftsMemory(); + }, + memoryExtractionTimeoutMs: this.input.memoryExtractionTimeoutMs, + metadataStore: this.input.metadataStore, + runId, + sessionId, + userId: this.input.user.id, + sessionDir: agentAssembly.sessionDir, + workspaceId: this.input.workspaceId + }); + + let suspended = false; + let resumeResolved = false; + let finalization: Promise | undefined; + let unregisterCancel = (): void => undefined; + let runTimeout: ReturnType | undefined; + let terminalStarted = false; + let sessionTitleStarted = false; + let lastAssistantMessageId: string | undefined; + const startedToolCallIds = new Set(); + const endedToolCallIds = new Set(); + const clearRunTimeout = (): void => { + if (runTimeout) { + clearTimeout(runTimeout); + runTimeout = undefined; + } + }; + const failRun = (message: string, terminalEvent?: BaseEvent): void => { + if (terminalStarted) { + return; + } + terminalStarted = true; + runAbortController.abort(new Error(message)); + clearRunTimeout(); + unregisterCancel(); + finalizer.fail({ + errorMessage: message, + terminalEvent: terminalEvent ?? { + type: EventType.RUN_ERROR, + message, + timestamp: Date.now() + } + }); + }; + const cancelRun = (reason = "RUN_CANCELLED"): void => { + if (terminalStarted) { + return; + } + terminalStarted = true; + runAbortController.abort(new Error(reason)); + clearRunTimeout(); + unregisterCancel(); + void this.input.runtime.cancelRun(runId, reason).catch(() => undefined); + finalization = finalizer.cancelRun({ + reason, + terminalEvent: { + type: EventType.RUN_FINISHED, + status: "cancelled", + timestamp: Date.now() + } as BaseEvent + }); + void finalization.then(() => subscriber.complete(), (error: unknown) => subscriber.error(error)); + }; + unregisterCancel = this.input.runCancelRegistry.register({ + cancel: cancelRun, + runId, + sessionId, + userId: this.input.user.id + }); + subscriber.add(() => unregisterCancel()); + + const runtimeRequest = agentAssembly.buildRunRequest({ + ...(interactionResume ? { interactionResume } : {}), + messages: agentAssembly.governedMessages, + runId, + sessionId, + userId: this.input.user.id, + workspaceId: this.input.workspaceId + }); + + try { + for await (const event of this.input.runtime.startRun(runtimeRequest, { + signal: runAbortController.signal + }) as AsyncIterable) { + if (terminalStarted) { + break; + } + const assistantMessageId = assistantMessageIdFromEvent(event); + if (assistantMessageId) { + lastAssistantMessageId = assistantMessageId; + } + const interactionRequested = interactionRuntime.capture(event); + if (interactionRequested) { + terminalStarted = true; + clearRunTimeout(); + unregisterCancel(); + suspended = true; + const bridgeEvents = buildHitlSuspendBridgeEvents({ + interrupt: interactionRequested.interrupt, + interactionEvent: interactionRequested.event, + ...(event.type === EventType.CUSTOM && event.name === "on_interrupt" + ? { passthroughInterruptEvent: event } + : {}), + state: { startedToolCallIds, endedToolCallIds } + }); + for (const bridgeEvent of bridgeEvents) { + if (bridgeEvent === interactionRequested.event) { + emit(bridgeEvent); + finalizer.suspend(); + continue; + } + emit(bridgeEvent); + } + subscriber.next({ + type: EventType.RUN_FINISHED, + timestamp: Date.now() + }); + break; + } + if (event.type === EventType.RUN_FINISHED && suspended) { + continue; + } + if (event.type === EventType.RUN_FINISHED && interactionResume?.response === false) { + terminalStarted = true; + clearRunTimeout(); + unregisterCancel(); + finalization = finalizer.cancel({ + interactionResolvedEvent: interactionRuntime.cancel(interactionResume), + terminalEvent: event + }); + break; + } + if (event.type === EventType.RUN_FINISHED) { + terminalStarted = true; + clearRunTimeout(); + unregisterCancel(); + finalization = finalizer.finish({ terminalEvent: event }); + break; + } + if (event.type === EventType.RUN_ERROR) { + failRun("AG-UI run error", event); + break; + } + if ( + event.type === EventType.TOOL_CALL_START + && typeof event.toolCallId === "string" + && event.toolCallId.length > 0 + ) { + startedToolCallIds.add(event.toolCallId); + } + if ( + event.type === EventType.TOOL_CALL_END + && typeof event.toolCallId === "string" + && event.toolCallId.length > 0 + ) { + endedToolCallIds.add(event.toolCallId); + } + emit(event); + + if ( + interactionResume + && !resumeResolved + && event.type === EventType.TOOL_CALL_RESULT + && event.toolCallId === interactionResume.interrupt.toolCallId + ) { + try { + emit(interactionRuntime.resolve(interactionResume)); + resumeResolved = true; + } catch (error) { + const message = error instanceof Error ? error.message : "Interaction resume failed"; + emit({ + type: EventType.RUN_ERROR, + message, + timestamp: Date.now() + }); + } + } + + if (event.type === EventType.RUN_STARTED) { + emit(createCustomEvent("run.config.resolved", { + active_datasource_id: effectiveRunConfig.activeDatasourceId, + active_llm_profile_id: effectiveRunConfig.activeLlmProfileId, + requested_llm_profile_id: effectiveRunConfig.activeLlmProfileId, + runtime_provider: "deepagents", + workspace_id: this.input.workspaceId, + ...(runTimeoutMs !== undefined ? { run_timeout_ms: runTimeoutMs } : {}) + })); + emit({ + type: EventType.STATE_SNAPSHOT, + snapshot: { + selectedDatasourceId, + runId, + runStatus: "running", + sessionId + }, + timestamp: Date.now() + }); + if (!isResume && !sessionTitleStarted) { + sessionTitleStarted = true; + startSessionTitleTask({ + emit, + metadataStore: this.input.metadataStore, + model: modelProvider.kind === "openai-compatible" ? modelProvider.model : undefined, + modelTemperature: modelSettings?.temperature, + sessionId, + userId: this.input.user.id, + userInput + }); + } + } + } + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown runtime error"; + failRun(message); + } + + if (runTimeoutMs !== undefined && !terminalStarted) { + runTimeout = setTimeout(() => { + runAbortController.abort(new Error(`RUN_TIMEOUT:${runTimeoutMs}`)); + failRun(`RUN_TIMEOUT:${runTimeoutMs}`); + subscriber.complete(); + }, runTimeoutMs); + } + + subscriber.add(() => { + if (!terminalStarted) { + runAbortController.abort(new Error("RUN_SUBSCRIBER_CLOSED")); + } + clearRunTimeout(); + unregisterCancel(); + }); + + if (finalization) { + await finalization; + } + if (!subscriber.closed) { + subscriber.complete(); + } + }; + + run().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + persistEarlyFailedUserMessage({ + errorMessage: message, + isResume: Boolean(interactionResume), + metadataStore: this.input.metadataStore, + runId, + runInput, + sessionId: runInput.threadId, + userId: this.input.user.id, + userInput: extractLastUserText(runInput) ?? "CopilotKit AG-UI run" + }); + emitEarlyRunFailure(subscriber, runId, message); + }); + }); + } +}; diff --git a/apps/api/src/runtime/client.ts b/apps/api/src/runtime/client.ts new file mode 100644 index 00000000..5974a36a --- /dev/null +++ b/apps/api/src/runtime/client.ts @@ -0,0 +1,124 @@ +import type { BaseEvent } from "@ag-ui/client"; + +import { consumeSseBuffer } from "./sse.js"; +import { + RUNTIME_CONTRACT_VERSION, + RUNTIME_PROVIDER, + type RuntimeHealth, + type RuntimeRunRequest, + type RuntimeTransport +} from "./types.js"; + +export type HttpRuntimeClientOptions = { + token?: string; + url: string; +}; + +const DEFAULT_HEALTH: RuntimeHealth = { + status: "unavailable", + provider: RUNTIME_PROVIDER, + version: RUNTIME_CONTRACT_VERSION, + capabilities: { + streaming: false, + tools: false, + interrupt: false, + cancel: false + } +}; + +export class HttpRuntimeClient implements RuntimeTransport { + constructor(private readonly options: HttpRuntimeClientOptions) {} + + async health(): Promise { + try { + const response = await fetch(new URL("/health", this.options.url), { + headers: this.headers() + }); + if (!response.ok) { + return { ...DEFAULT_HEALTH, status: "degraded" }; + } + const body = await response.json() as Partial; + return { + status: body.status === "ok" || body.status === "degraded" ? body.status : "degraded", + provider: typeof body.provider === "string" ? body.provider : RUNTIME_PROVIDER, + version: typeof body.version === "string" ? body.version : RUNTIME_CONTRACT_VERSION, + capabilities: { + streaming: body.capabilities?.streaming !== false, + tools: Boolean(body.capabilities?.tools), + interrupt: Boolean(body.capabilities?.interrupt), + cancel: Boolean(body.capabilities?.cancel) + } + }; + } catch { + return DEFAULT_HEALTH; + } + } + + async *startRun( + request: RuntimeRunRequest, + options: { signal?: AbortSignal } = {} + ): AsyncIterable { + const response = await fetch(new URL("/runs/stream", this.options.url), { + method: "POST", + headers: { + ...this.headers(), + Accept: "text/event-stream", + "Content-Type": "application/json" + }, + body: JSON.stringify(request), + ...(options.signal ? { signal: options.signal } : {}) + }); + if (!response.ok || !response.body) { + const detail = await response.text().catch(() => ""); + throw new Error(`RUNTIME_STREAM_FAILED:${response.status}:${detail.slice(0, 200)}`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + buffer += decoder.decode(value, { stream: true }); + const parsed = consumeSseBuffer(buffer); + buffer = parsed.remainder; + for (const event of parsed.events) { + yield event as BaseEvent; + } + } + if (buffer.trim()) { + const parsed = consumeSseBuffer(`${buffer}\n\n`); + for (const event of parsed.events) { + yield event as BaseEvent; + } + } + } finally { + reader.releaseLock(); + } + } + + async cancelRun(runId: string, reason = "RUN_CANCELLED"): Promise { + const response = await fetch(new URL(`/runs/${encodeURIComponent(runId)}/cancel`, this.options.url), { + method: "POST", + headers: { + ...this.headers(), + "Content-Type": "application/json" + }, + body: JSON.stringify({ reason }) + }); + if (!response.ok && response.status !== 404) { + throw new Error(`RUNTIME_CANCEL_FAILED:${response.status}`); + } + } + + private headers(): Record { + return this.options.token + ? { Authorization: `Bearer ${this.options.token}` } + : {}; + } +} + +export const createHttpRuntimeClient = (options: HttpRuntimeClientOptions): RuntimeTransport => + new HttpRuntimeClient(options); diff --git a/apps/api/src/runtime/factory.ts b/apps/api/src/runtime/factory.ts new file mode 100644 index 00000000..a7724ef3 --- /dev/null +++ b/apps/api/src/runtime/factory.ts @@ -0,0 +1,53 @@ +import { createHttpRuntimeClient } from "./client.js"; +import { createInProcessRuntime } from "./in-process.js"; +import { + RUNTIME_CONTRACT_VERSION, + RUNTIME_PROVIDER, + type RuntimeHealth, + type RuntimeTransport +} from "./types.js"; + +export type RuntimeFactoryOptions = { + token?: string; + url?: string; +}; + +export const resolveRuntimeServiceUrl = ( + env: Record = process.env +): string | undefined => { + const value = env.RUNTIME_SERVICE_URL?.trim(); + return value ? value.replace(/\/+$/, "") : undefined; +}; + +export const createRuntimeTransport = ( + options: RuntimeFactoryOptions = {} +): RuntimeTransport => { + const url = options.url ?? resolveRuntimeServiceUrl(); + if (!url) { + return createInProcessRuntime(); + } + return createHttpRuntimeClient({ + url, + ...(options.token ? { token: options.token } : {}) + }); +}; + +export const unavailableRuntimeHealth = (): RuntimeHealth => ({ + status: "unavailable", + provider: RUNTIME_PROVIDER, + version: RUNTIME_CONTRACT_VERSION, + capabilities: { + streaming: false, + tools: false, + interrupt: false, + cancel: false + } +}); + +export const probeRuntimeHealth = async (transport: RuntimeTransport): Promise => { + try { + return await transport.health(); + } catch { + return unavailableRuntimeHealth(); + } +}; diff --git a/apps/api/src/runtime/in-process.ts b/apps/api/src/runtime/in-process.ts new file mode 100644 index 00000000..12e80d01 --- /dev/null +++ b/apps/api/src/runtime/in-process.ts @@ -0,0 +1,47 @@ +import type { BaseEvent } from "@ag-ui/client"; + +import { generateStubEvents } from "./scenarios.js"; +import { + RUNTIME_CONTRACT_VERSION, + RUNTIME_PROVIDER, + type RuntimeHealth, + type RuntimeRunRequest, + type RuntimeTransport +} from "./types.js"; + +const canceledRuns = new Set(); + +export class InProcessRuntime implements RuntimeTransport { + async health(): Promise { + return { + status: "ok", + provider: `${RUNTIME_PROVIDER}-stub`, + version: RUNTIME_CONTRACT_VERSION, + capabilities: { + streaming: true, + tools: true, + interrupt: true, + cancel: true + } + }; + } + + async *startRun( + request: RuntimeRunRequest, + options: { signal?: AbortSignal } = {} + ): AsyncIterable { + canceledRuns.delete(request.runId); + for (const event of generateStubEvents(request)) { + if (options.signal?.aborted || canceledRuns.has(request.runId)) { + return; + } + yield event; + } + } + + async cancelRun(runId: string): Promise { + canceledRuns.add(runId); + } +} + +export const createInProcessRuntime = (): RuntimeTransport => new InProcessRuntime(); diff --git a/apps/api/src/runtime/scenarios.test.ts b/apps/api/src/runtime/scenarios.test.ts new file mode 100644 index 00000000..859c4987 --- /dev/null +++ b/apps/api/src/runtime/scenarios.test.ts @@ -0,0 +1,30 @@ +import { EventType } from "@ag-ui/client"; +import { describe, expect, it } from "vitest"; + +import { generateStubEvents, resolveRuntimeStubScenario } from "./scenarios.js"; +import { AGENT_INTERRUPT_TYPE, INTERRUPT_EVENT_NAME, V1_SYSTEM_PROMPT } from "./types.js"; + +const request = (content: string) => ({ + threadId: "session-1", + runId: "run-1", + messages: [{ id: "m1", role: "user" as const, content }], + systemPrompt: V1_SYSTEM_PROMPT +}); + +describe("runtime stub scenarios", () => { + it("selects interrupt, tool, and text from user phrasing", () => { + expect(resolveRuntimeStubScenario(request("please interrupt now"))).toBe("interrupt"); + expect(resolveRuntimeStubScenario(request("make a plan"))).toBe("tool"); + expect(resolveRuntimeStubScenario(request("hello"))).toBe("text"); + }); + + it("emits a standard interrupt payload", () => { + const events = [...generateStubEvents(request("please interrupt"))]; + const interrupt = events.find((event) => event.type === EventType.CUSTOM && event.name === INTERRUPT_EVENT_NAME); + expect(interrupt?.value).toMatchObject({ + type: AGENT_INTERRUPT_TYPE, + toolName: "ask_user", + runId: "run-1" + }); + }); +}); diff --git a/apps/api/src/runtime/scenarios.ts b/apps/api/src/runtime/scenarios.ts new file mode 100644 index 00000000..ce7552c3 --- /dev/null +++ b/apps/api/src/runtime/scenarios.ts @@ -0,0 +1,185 @@ +import { EventType, type BaseEvent } from "@ag-ui/client"; + +import { + AGENT_INTERRUPT_TYPE, + INTERRUPT_EVENT_NAME, + RUNTIME_BOUND_EVENT, + RUNTIME_CONTRACT_VERSION, + RUNTIME_PROVIDER, + type RuntimeRunRequest +} from "./types.js"; + +export type RuntimeStubScenario = "text" | "tool" | "interrupt"; + +export const resolveRuntimeStubScenario = (request: RuntimeRunRequest): RuntimeStubScenario => { + if (request.resume) { + return "text"; + } + const lastUser = lastUserText(request); + if (/\b(interrupt|ask)\b/i.test(lastUser)) { + return "interrupt"; + } + if (/\b(tool|plan)\b/i.test(lastUser)) { + return "tool"; + } + return "text"; +}; + +export const buildRuntimeBoundEvent = ( + runId: string, + checkpointRef = `ckpt:${runId}` +): BaseEvent => ({ + type: EventType.CUSTOM, + name: RUNTIME_BOUND_EVENT, + value: { + provider: RUNTIME_PROVIDER, + version: RUNTIME_CONTRACT_VERSION, + checkpointRef + }, + timestamp: Date.now() +} as BaseEvent); + +export function* generateStubEvents(request: RuntimeRunRequest): Generator { + const timestamp = Date.now(); + yield { + type: EventType.RUN_STARTED, + threadId: request.threadId, + runId: request.runId, + timestamp + } as BaseEvent; + yield buildRuntimeBoundEvent(request.runId); + + if (request.resume) { + const interrupt = request.resume.interrupt; + if (request.resume.response === false) { + yield { + type: EventType.RUN_FINISHED, + threadId: request.threadId, + runId: request.runId, + status: "cancelled", + timestamp: Date.now() + } as BaseEvent; + return; + } + yield { + type: EventType.TOOL_CALL_RESULT, + toolCallId: interrupt.toolCallId, + toolCallName: interrupt.toolName, + content: JSON.stringify(request.resume.response ?? {}), + timestamp: Date.now() + } as BaseEvent; + yield* textReply(request, "已收到你的回复,继续。"); + yield { + type: EventType.RUN_FINISHED, + threadId: request.threadId, + runId: request.runId, + timestamp: Date.now() + } as BaseEvent; + return; + } + + const scenario = resolveRuntimeStubScenario(request); + if (scenario === "tool") { + const toolCallId = `call_todo_${request.runId}`; + yield { + type: EventType.TOOL_CALL_START, + toolCallId, + toolCallName: "write_todos", + timestamp: Date.now() + } as BaseEvent; + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId, + delta: JSON.stringify({ todos: [{ content: "整理问题", status: "in_progress" }] }), + timestamp: Date.now() + } as BaseEvent; + yield { + type: EventType.TOOL_CALL_END, + toolCallId, + toolCallName: "write_todos", + timestamp: Date.now() + } as BaseEvent; + yield { + type: EventType.TOOL_CALL_RESULT, + toolCallId, + toolCallName: "write_todos", + content: JSON.stringify({ ok: true }), + timestamp: Date.now() + } as BaseEvent; + yield* textReply(request, "已记下待办,接下来用对话继续。"); + yield { + type: EventType.RUN_FINISHED, + threadId: request.threadId, + runId: request.runId, + timestamp: Date.now() + } as BaseEvent; + return; + } + + if (scenario === "interrupt") { + const toolCallId = `call_ask_${request.runId}`; + yield { + type: EventType.CUSTOM, + name: INTERRUPT_EVENT_NAME, + value: { + type: AGENT_INTERRUPT_TYPE, + toolCallId, + toolName: "ask_user", + runId: request.runId, + args: { + question: "需要我继续吗?", + options: ["继续", "停止"] + }, + suspendPayload: { + question: "需要我继续吗?", + options: ["继续", "停止"] + }, + resumeSchema: { type: "object" } + }, + timestamp: Date.now() + } as BaseEvent; + return; + } + + yield* textReply(request, `这是 Deep Agents 接入桩的回复:${lastUserText(request) || "你好"}`); + yield { + type: EventType.RUN_FINISHED, + threadId: request.threadId, + runId: request.runId, + timestamp: Date.now() + } as BaseEvent; +} + +function* textReply(request: RuntimeRunRequest, text: string): Generator { + const messageId = `msg_${request.runId}`; + yield { + type: EventType.TEXT_MESSAGE_START, + messageId, + role: "assistant", + timestamp: Date.now() + } as BaseEvent; + yield { + type: EventType.TEXT_MESSAGE_CONTENT, + messageId, + delta: text, + timestamp: Date.now() + } as BaseEvent; + yield { + type: EventType.TEXT_MESSAGE_END, + messageId, + timestamp: Date.now() + } as BaseEvent; +} + +const lastUserText = (request: RuntimeRunRequest): string => { + for (let index = request.messages.length - 1; index >= 0; index -= 1) { + const message = request.messages[index]; + if (message?.role !== "user") { + continue; + } + if (typeof message.content === "string") { + return message.content; + } + } + return ""; +}; diff --git a/apps/api/src/runtime/sse.test.ts b/apps/api/src/runtime/sse.test.ts new file mode 100644 index 00000000..9713ba10 --- /dev/null +++ b/apps/api/src/runtime/sse.test.ts @@ -0,0 +1,15 @@ +import { EventType } from "@ag-ui/client"; +import { describe, expect, it } from "vitest"; + +import { consumeSseBuffer, encodeAgUiSseEvent } from "./sse.js"; + +describe("AG-UI SSE frames", () => { + it("round-trips one event across split chunks", () => { + const event = { type: EventType.RUN_STARTED, runId: "run-1", timestamp: 1 }; + const encoded = encodeAgUiSseEvent(event); + const first = consumeSseBuffer(encoded.slice(0, 12)); + expect(first.events).toEqual([]); + const second = consumeSseBuffer(first.remainder + encoded.slice(12)); + expect(second.events).toEqual([event]); + }); +}); diff --git a/apps/api/src/runtime/sse.ts b/apps/api/src/runtime/sse.ts new file mode 100644 index 00000000..7dd54223 --- /dev/null +++ b/apps/api/src/runtime/sse.ts @@ -0,0 +1,40 @@ +import type { BaseEvent } from "@ag-ui/client"; + +/** Encode one AG-UI event as an SSE `data:` frame. */ +export const encodeAgUiSseEvent = (event: BaseEvent): string => + `data: ${JSON.stringify(event)}\n\n`; + +export type SseParseResult = { + events: unknown[]; + remainder: string; +}; + +/** Incremental SSE parser for AG-UI event streams. */ +export const consumeSseBuffer = (buffer: string): SseParseResult => { + const events: unknown[] = []; + let remainder = buffer.replace(/\r\n/g, "\n"); + while (true) { + const boundary = remainder.indexOf("\n\n"); + if (boundary < 0) { + break; + } + const frame = remainder.slice(0, boundary); + remainder = remainder.slice(boundary + 2); + const dataLines = frame + .split("\n") + .filter((line) => line.startsWith("data:")) + .map((line) => line.slice(5).trimStart()); + if (dataLines.length === 0) { + continue; + } + const payload = dataLines.join("\n"); + if (!payload || payload === "[DONE]") { + continue; + } + events.push(JSON.parse(payload) as unknown); + } + return { events, remainder }; +}; + +export const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; diff --git a/apps/api/src/runtime/stub-server.ts b/apps/api/src/runtime/stub-server.ts new file mode 100644 index 00000000..8be1141f --- /dev/null +++ b/apps/api/src/runtime/stub-server.ts @@ -0,0 +1,79 @@ +import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import { generateStubEvents } from "./scenarios.js"; +import { encodeAgUiSseEvent } from "./sse.js"; +import { + RUNTIME_CONTRACT_VERSION, + RUNTIME_PROVIDER, + type RuntimeRunRequest +} from "./types.js"; + +export type CreateRuntimeStubServerOptions = { + token?: string; +}; + +const canceledRuns = new Set(); + +export const createRuntimeStubServer = (options: CreateRuntimeStubServerOptions = {}): Server => + createHttpServer(async (request, response) => { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "127.0.0.1"}`); + if (options.token && request.headers.authorization !== `Bearer ${options.token}`) { + sendJson(response, 401, { error: "UNAUTHORIZED" }); + return; + } + + if (request.method === "GET" && url.pathname === "/health") { + sendJson(response, 200, { + status: "ok", + provider: `${RUNTIME_PROVIDER}-stub`, + version: RUNTIME_CONTRACT_VERSION, + capabilities: { streaming: true, tools: true, interrupt: true, cancel: true } + }); + return; + } + + const cancelMatch = url.pathname.match(/^\/runs\/([^/]+)\/cancel$/); + if (request.method === "POST" && cancelMatch) { + canceledRuns.add(decodeURIComponent(cancelMatch[1] ?? "")); + sendJson(response, 200, { canceled: true }); + return; + } + + if (request.method === "POST" && url.pathname === "/runs/stream") { + const body = await readJson(request); + const runRequest = body as RuntimeRunRequest; + if (!runRequest?.runId || !runRequest.threadId || !Array.isArray(runRequest.messages)) { + sendJson(response, 400, { error: "INVALID_RUNTIME_RUN_REQUEST" }); + return; + } + canceledRuns.delete(runRequest.runId); + const payload = [...generateStubEvents(runRequest)] + .map((event) => encodeAgUiSseEvent(event)) + .join(""); + response.writeHead(200, { + "Cache-Control": "no-cache", + Connection: "keep-alive", + "Content-Length": Buffer.byteLength(payload), + "Content-Type": "text/event-stream; charset=utf-8", + "X-Accel-Buffering": "no" + }); + response.end(payload); + return; + } + + sendJson(response, 404, { error: "NOT_FOUND" }); + }); + +const readJson = async (request: IncomingMessage): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of request) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + const raw = Buffer.concat(chunks).toString("utf8"); + return raw ? JSON.parse(raw) as unknown : {}; +}; + +const sendJson = (response: ServerResponse, status: number, body: unknown): void => { + response.writeHead(status, { "Content-Type": "application/json; charset=utf-8" }); + response.end(JSON.stringify(body)); +}; diff --git a/apps/api/src/runtime/types.ts b/apps/api/src/runtime/types.ts new file mode 100644 index 00000000..c2ab6443 --- /dev/null +++ b/apps/api/src/runtime/types.ts @@ -0,0 +1,81 @@ +import type { Message } from "@ag-ui/client"; + +export const RUNTIME_PROVIDER = "deepagents"; +export const RUNTIME_CONTRACT_VERSION = "v1"; +export const RUNTIME_BOUND_EVENT = "runtime.bound"; +export const INTERRUPT_EVENT_NAME = "on_interrupt"; +export const AGENT_INTERRUPT_TYPE = "agent_interrupt"; +export const LEGACY_MASTRA_INTERRUPT_TYPE = "mastra_suspend"; + +export const V1_SYSTEM_PROMPT = [ + "You are DataFoundry's assistant.", + "Data warehouse tools, knowledge retrieval, and skill packages are not connected in this runtime version.", + "You may converse, use built-in planning/todo/filesystem tools, and ask the user questions when you need confirmation." +].join(" "); + +export type RuntimeInterruptToolName = "ask_user" | "submit_plan"; + +export type RuntimeInterrupt = { + type: typeof AGENT_INTERRUPT_TYPE | typeof LEGACY_MASTRA_INTERRUPT_TYPE; + args?: unknown; + resumeSchema?: unknown; + runId: string; + suspendPayload?: unknown; + toolCallId: string; + toolName: RuntimeInterruptToolName; +}; + +export type RuntimeRunResume = { + interrupt: RuntimeInterrupt; + response: unknown; +}; + +export type RuntimeModelRef = { + name?: string; + profileId?: string; + provider?: string; +}; + +export type RuntimeRunRequest = { + checkpointRef?: string; + limits?: { maxSteps?: number }; + messages: Message[]; + model?: RuntimeModelRef; + resume?: RuntimeRunResume; + runId: string; + systemPrompt: string; + threadId: string; + trace?: { + userId?: string; + workspaceId?: string; + }; +}; + +export type RuntimeCapabilities = { + cancel: boolean; + interrupt: boolean; + streaming: boolean; + tools: boolean; +}; + +export type RuntimeHealth = { + capabilities: RuntimeCapabilities; + provider: string; + status: "ok" | "degraded" | "unavailable"; + version: string; +}; + +export type RuntimeBoundValue = { + checkpointRef?: string; + provider: string; + version: string; +}; + +export type RuntimeTransport = { + cancelRun(runId: string, reason?: string): Promise; + health(): Promise; + startRun( + request: RuntimeRunRequest, + options?: { signal?: AbortSignal } + ): AsyncIterable; +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 9fe478d1..00510ffe 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,17 +1,11 @@ -import { AbstractAgent, EventType, type BaseEvent, type RunAgentInput } from "@ag-ui/client"; import { CopilotRuntime, ExperimentalEmptyAdapter, copilotRuntimeNodeHttpEndpoint } from "@copilotkit/runtime"; import { - CONVERSATION_WORKING_MEMORY_CONFIG, - createTaskStateRuntime, - createCustomEvent, - parseAgentMemoryMode, resolveSkillCacheDir, - type AgentMemoryMode, - type TaskStateRuntime + type AgentMemoryMode } from "@datafoundry/agent-runtime"; import { LocalArtifactService, SessionOutputService } from "@datafoundry/artifacts"; import { type MeResponse, createEnvConfig, createErrorResult, createSuccessResult } from "@datafoundry/contracts"; @@ -19,7 +13,6 @@ import { LocalDataGateway } from "@datafoundry/data-gateway"; import { LocalFileAssetService } from "@datafoundry/files"; import { LocalKnowledgeService } from "@datafoundry/knowledge"; import { - RunEventWriter, createMetadataStore, type UserRecord, type MetadataStore @@ -35,7 +28,6 @@ import { readFileSync } from "node:fs"; import { createServer as createHttpServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { Observable } from "rxjs"; import { handleConfigApiRequest } from "./config-api.js"; import { createAsyncMemoByKey, createStartupTimer } from "./async-memo.js"; @@ -50,36 +42,10 @@ import { resolvePasswordSessionIdentity, sendAuthError } from "./auth/routes.js"; -import { createMetadataContextPackageRecorder } from "./context-package-recorder.js"; -import { MetadataProtocolStateStore } from "./protocol-state-store.js"; -import { buildHelperContext } from "@datafoundry/agent-runtime"; -import { replayPendingProtocolEvents } from "./protocol-event-recovery.js"; -import { - commitSessionIntentFromProtocolStartWithinTransaction, - resolveSessionIntentForRun -} from "./session-intent.js"; -import { assistantMessageIdFromEvent, completeProtocolRun } from "./protocol-run-completion.js"; -import { persistCurrentUserMessage } from "./conversation-memory.js"; -import { resolveEvidenceReferenceContext } from "./evidence-reference-context.js"; -import { createRunAgentAssembly, createRunAgentContext } from "./run-agent-assembly.js"; -import { RunCheckpointProjector } from "./run-checkpoint-projector.js"; -import { TraceSectionCoordinator } from "./trace-section-coordinator.js"; -import { resolveCheckpointResumeSeed, type CheckpointResumeSeed } from "./run-checkpoint-resume.js"; -import { resolveRunConfig } from "./run-config-resolver.js"; -import { resolveRunIdentity } from "./run-identity-orchestrator.js"; -import { createRunMemoryAssembly } from "./run-memory-assembly.js"; -import { extractLastUserText } from "./run-input.js"; -import { - buildHitlSuspendBridgeEvents, - extractInteractionResume, - InteractionRuntimeAdapter -} from "./interaction-runtime-adapter.js"; import { RunCancelRegistry } from "./run-cancel-registry.js"; -import { RunEventPipeline } from "./run-event-pipeline.js"; -import { RunFinalizer, createRunStatusDelta } from "./run-finalizer.js"; -import { startSessionTitleTask } from "./session-title.js"; -import { TaskPlanProjector } from "./task-plan-projector.js"; -import { ToolCallResultBridge } from "./tool-call-result-bridge.js"; +import { createRuntimeTransport, probeRuntimeHealth } from "./runtime/factory.js"; +import { DataFoundryAgUiAgent } from "./runtime-agent.js"; +import type { RuntimeHealth, RuntimeTransport } from "./runtime/types.js"; const COPILOTKIT_PATH = "/api/copilotkit"; const DEFAULT_WORKSPACE_ID = "default"; @@ -87,79 +53,17 @@ const SERVER_DIR = dirname(fileURLToPath(import.meta.url)); const BUILTIN_SKILL_ROOT = join(SERVER_DIR, "../../../packages/skills/builtin"); const skillCacheSignatures = new Map(); const legacyDemoRemovedUsers = new Set(); -/** Set true only after createServer finishes required init (Mastra + builtins). */ +/** Set true only after createServer finishes required control-plane init. */ let serverReady = false; let startupTimings: Record = {}; let startupTotalMs = 0; - -const emitEarlyRunFailure = ( - subscriber: { complete(): void; next(event: BaseEvent): void }, - runId: string, - message: string -): void => { - const timestamp = Date.now(); - subscriber.next({ type: EventType.RUN_STARTED, runId, timestamp }); - subscriber.next(createRunStatusDelta("failed", { errorMessage: message, runId })); - subscriber.next({ type: EventType.RUN_ERROR, message, timestamp }); - subscriber.complete(); -}; - -const persistEarlyFailedUserMessage = (input: { - errorMessage: string; - isResume: boolean; - metadataStore: MetadataStore; - runId: string; - runInput: RunAgentInput; - sessionId: string; - userId: string; - userInput: string; -}): void => { - if (input.isResume || !input.userInput.trim()) { - return; - } - try { - input.metadataStore.sessions.create({ - user_id: input.userId, - id: input.sessionId - }); - input.metadataStore.runs.claim({ - user_id: input.userId, - id: input.runId, - session_id: input.sessionId, - user_input: input.userInput, - status: "running", - model_name: "unresolved" - }); - input.metadataStore.runs.updateStatus({ - user_id: input.userId, - run_id: input.runId, - status: "failed", - error_message: input.errorMessage - }); - const record = persistCurrentUserMessage({ - currentUserText: input.userInput, - repository: input.metadataStore.conversationMessages, - runId: input.runId, - runInput: input.runInput, - sessionId: input.sessionId, - userId: input.userId - }); - input.metadataStore.sessions.touchLastMessage({ - user_id: input.userId, - session_id: input.sessionId, - last_message_at: record.created_at - }); - } catch (error) { - // Keep the transport error visible even if best-effort history persistence fails. - console.warn("[data-foundry] failed to persist early failed user message", error); - } -}; +let runtimeHealth: RuntimeHealth | undefined; export type CreateServerOptions = { conversationMemoryMode?: AgentMemoryMode | undefined; memoryExtractionTimeoutMs?: number | undefined; metadataStore?: MetadataStore; - taskStateRuntime?: TaskStateRuntime; + runtime?: RuntimeTransport; }; export const createServer = async (options: CreateServerOptions = {}): Promise => { @@ -168,9 +72,6 @@ export const createServer = async (options: CreateServerOptions = {}): Promise options.metadataStore ?? createMetadataStore({ @@ -197,18 +98,12 @@ export const createServer = async (options: CreateServerOptions = {}): Promise taskStateRuntimePromise); + runtimeHealth = await timer.measure("runtime_health", () => probeRuntimeHealth(runtime)); // After restart, cancel-registry is empty — reclaim queued/running rows left by dead workers. const reclaimedActiveRuns = await timer.measure("stale_active_run_reclaim", () => @@ -247,6 +142,8 @@ export const createServer = async (options: CreateServerOptions = {}): Promise { metadataStore.close(); - if (ownsTaskStateRuntime) { - void taskStateRuntime.close(); - } }); return server; }; type HandleCopilotKitRequestInput = { - artifactService: LocalArtifactService; - sessionOutputService: SessionOutputService; - conversationMemoryMode: AgentMemoryMode; request: IncomingMessage; response: ServerResponse; metadataStore: MetadataStore; - dataGateway: LocalDataGateway; fileAssetService: LocalFileAssetService; - knowledgeService: LocalKnowledgeService; memoryExtractionTimeoutMs: number; runCancelRegistry: RunCancelRegistry; - taskStateRuntime: TaskStateRuntime; + runtime: RuntimeTransport; user: MeResponse; workspaceId: string; }; @@ -389,716 +273,38 @@ const handleCopilotKitRequest = async ({ request, response, metadataStore, - dataGateway, - artifactService, - sessionOutputService, fileAssetService, - conversationMemoryMode, - knowledgeService, memoryExtractionTimeoutMs, runCancelRegistry, - taskStateRuntime, + runtime, user, workspaceId }: HandleCopilotKitRequestInput): Promise => { - const runtime = new CopilotRuntime({ + const copilotRuntime = new CopilotRuntime({ agents: { dataFoundry: new DataFoundryAgUiAgent({ - dataGateway, - artifactService, - sessionOutputService, fileAssetService, - conversationMemoryMode, - knowledgeService, memoryExtractionTimeoutMs, metadataStore, runCancelRegistry, - taskStateRuntime, + runtime, user, - workspaceId, - workspaceRoot: process.env.WORKSPACE_ROOT ?? join(process.env.STORAGE_ROOT_DIR ?? "storage", "workspaces") + workspaceId }) as never } }); const endpointOptions = { endpoint: COPILOTKIT_PATH, - runtime, + runtime: copilotRuntime, serviceAdapter: new ExperimentalEmptyAdapter(), cors: { origin: "*" } } as unknown as Parameters[0]; const endpoint = copilotRuntimeNodeHttpEndpoint(endpointOptions); - - try { - await endpoint(request, response); - } catch (error) { - throw error; - } + await endpoint(request, response); }; -type DataFoundryAgUiAgentInput = { - artifactService: LocalArtifactService; - sessionOutputService: SessionOutputService; - conversationMemoryMode: AgentMemoryMode; - dataGateway: LocalDataGateway; - defaultDatasourceId?: string; - fileAssetService: LocalFileAssetService; - metadataStore: MetadataStore; - knowledgeService: LocalKnowledgeService; - memoryExtractionTimeoutMs: number; - runCancelRegistry: RunCancelRegistry; - taskStateRuntime: TaskStateRuntime; - user: MeResponse; - workspaceId: string; - workspaceRoot: string; -}; - -class DataFoundryAgUiAgent extends AbstractAgent { - private input: DataFoundryAgUiAgentInput; - - constructor(input: DataFoundryAgUiAgentInput) { - super({ - agentId: "dataFoundry", - description: "Read-only data analysis agent backed by Mastra and Data Gateway." - }); - this.input = input; - } - - clone(): DataFoundryAgUiAgent { - const cloned = super.clone() as DataFoundryAgUiAgent; - cloned.input = this.input; - return cloned; - } - - run(runInput: RunAgentInput): Observable { - return new Observable((subscriber) => { - const interactionResume = extractInteractionResume(runInput); - const runId = interactionResume?.interrupt.runId ?? runInput.runId; - const run = async (): Promise => { - const sessionId = runInput.threadId; - // CopilotKit may send a fresh runId on resume; Mastra embeds runInput.runId in - // on_interrupt payloads, so keep AG-UI identity aligned with the suspended run. - const normalizedRunInput = - runId === runInput.runId ? runInput : { ...runInput, runId }; - const userInput = extractLastUserText(normalizedRunInput) ?? "CopilotKit AG-UI run"; - let effectiveRunConfig; - let mcpRuntime; - let modelContextProfile; - let modelProvider; - let modelSettings; - let reasoningModel; - let runTimeoutMs; - let selectedSkills; - let skillSelection; - try { - ({ - effectiveRunConfig, - mcpRuntime, - modelContextProfile, - modelProvider, - modelSettings, - reasoningModel, - runTimeoutMs, - selectedSkills, - skillSelection - } = resolveRunConfig({ - ...(this.input.defaultDatasourceId - ? { defaultDatasourceId: this.input.defaultDatasourceId } - : {}), - metadataStore: this.input.metadataStore, - runInput: normalizedRunInput, - userId: this.input.user.id, - userInput, - workspaceId: this.input.workspaceId - })); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - persistEarlyFailedUserMessage({ - errorMessage: message, - isResume: Boolean(interactionResume), - metadataStore: this.input.metadataStore, - runId, - runInput: normalizedRunInput, - sessionId, - userId: this.input.user.id, - userInput - }); - emitEarlyRunFailure(subscriber, runId, message); - return; - } - const runEventWriter = new RunEventWriter(this.input.metadataStore.runEvents); - const identity = resolveRunIdentity({ - effectiveRunConfig, - ...(interactionResume ? { interactionResume } : {}), - metadataStore: this.input.metadataStore, - modelName: modelProvider.model_name, - runCancelRegistry: this.input.runCancelRegistry, - runEventWriter, - runInput: normalizedRunInput, - userId: this.input.user.id, - userInput - }); - if (identity.kind === "replay") { - identity.events.forEach((event) => subscriber.next(event)); - subscriber.complete(); - return; - } - const { isResume, selectedDatasourceId } = identity; - let checkpointResumeSeed: CheckpointResumeSeed | undefined; - try { - checkpointResumeSeed = resolveCheckpointResumeSeed({ - metadataStore: this.input.metadataStore, - runInput: normalizedRunInput, - sessionId, - userId: this.input.user.id - }); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - persistEarlyFailedUserMessage({ - errorMessage: message, - isResume: Boolean(interactionResume), - metadataStore: this.input.metadataStore, - runId, - runInput: normalizedRunInput, - sessionId, - userId: this.input.user.id, - userInput - }); - emitEarlyRunFailure(subscriber, runId, message); - return; - } - - const memoryAssembly = await createRunMemoryAssembly({ - conversationMemoryMode: this.input.conversationMemoryMode, - isResume, - metadataStore: this.input.metadataStore, - model: modelProvider.model, - modelName: modelProvider.model_name, - modelTemperature: modelSettings?.temperature, - runId, - runInput: normalizedRunInput, - ...(selectedDatasourceId ? { selectedDatasourceId } : {}), - sessionId, - taskStateRuntime: this.input.taskStateRuntime, - userId: this.input.user.id, - userInput, - evidenceRefs: effectiveRunConfig.evidenceRefs - }); - const { - conversationMemoryObserver, - conversationMessages, - longTermMemories - } = memoryAssembly; - const runContext = createRunAgentContext({ - effectiveRunConfig, - modelProvider, - runId, - ...(selectedDatasourceId ? { selectedDatasourceId } : {}), - sessionId, - userId: this.input.user.id, - userInput, - workspaceId: this.input.workspaceId - }); - const evidenceContext = resolveEvidenceReferenceContext({ - evidenceRefs: effectiveRunConfig.evidenceRefs, - metadataStore: this.input.metadataStore, - sessionId, - userId: this.input.user.id, - workspaceId: this.input.workspaceId - }); - const taskPlanProjector = new TaskPlanProjector(runContext); - const toolCallResultBridge = new ToolCallResultBridge(); - const checkpointProjector = new RunCheckpointProjector(this.input.metadataStore, this.input.user.id); - const traceSectionCoordinator = new TraceSectionCoordinator( - this.input.metadataStore, - modelProvider, - this.input.user.id - ); - const contextPackageRecorder = createMetadataContextPackageRecorder({ - metadataStore: this.input.metadataStore, - runId, - sessionId, - userId: this.input.user.id - }); - const persistedProtocolState = this.input.metadataStore.protocolStates.latestByRun({ - user_id: this.input.user.id, - run_id: runId - }); - const persistedIntentBinding = this.input.metadataStore.sessionIntents.findRunBinding({ - user_id: this.input.user.id, - run_id: runId - }); - if (persistedProtocolState && !persistedIntentBinding) { - throw new Error(`RUN_INTENT_BINDING_REQUIRED:${runId}`); - } - const sessionIntent = resolveSessionIntentForRun({ - metadataStore: this.input.metadataStore, - userId: this.input.user.id, - sessionId, - runId - }); - const protocolStateStore = new MetadataProtocolStateStore( - this.input.metadataStore, - this.input.user.id, - { - onCreateWithinTransaction: ({ state, events }) => { - commitSessionIntentFromProtocolStartWithinTransaction({ - metadataStore: this.input.metadataStore, - userId: this.input.user.id, - sessionId, - runId, - userInput, - ...(sessionIntent ? { expectedBaseRevisionId: sessionIntent.revisionId } : {}), - state, - events - }); - } - } - ); - const runAbortController = new AbortController(); - const interactionRuntime = new InteractionRuntimeAdapter( - this.input.metadataStore, - this.input.user.id, - sessionId, - runId - ); - const eventPipeline = new RunEventPipeline({ - checkpointProjector, - conversationMemoryObserver, - runEventWriter, - runId, - sessionId, - taskPlanProjector, - traceSectionCoordinator, - toolCallResultBridge, - userId: this.input.user.id, - sink: (event) => subscriber.next(event) - }); - const emit = (event: BaseEvent): void => { - eventPipeline.emit(event); - }; - replayPendingProtocolEvents({ runId, stateStore: protocolStateStore, emit }); - const classifierContext = buildHelperContext({ - ...(sessionIntent - ? { sessionIntent: { protocolId: sessionIntent.protocolId, intentText: sessionIntent.intentText } } - : {}), - conversationSummary: this.input.metadataStore.conversationSummaries.latest({ - user_id: this.input.user.id, - session_id: sessionId - })?.summary_text, - recentQueries: recentUserQueries(conversationMessages), - relevantMemories: longTermMemories.map((memory) => memory.content_text) - }); - const agentAssembly = await createRunAgentAssembly({ - abortSignal: runAbortController.signal, - contextPackageRecorder, - contextPackageExists: (reference) => Boolean( - this.input.metadataStore.contextPackageSnapshots.findByPackageRevision({ - user_id: this.input.user.id, - package_id: reference.packageId, - revision: reference.revision - }) - ), - dataGateway: this.input.dataGateway, - artifactService: this.input.artifactService, - sessionOutputService: this.input.sessionOutputService, - effectiveRunConfig, - ...(evidenceContext.items.length ? { evidenceContextItems: evidenceContext.items } : {}), - fileAssetService: this.input.fileAssetService, - emitter: { emit }, - ...(effectiveRunConfig.goal ? { goal: effectiveRunConfig.goal } : {}), - ...(checkpointResumeSeed ? { initialContextPackage: checkpointResumeSeed.contextPackage } : {}), - ...(interactionResume ? { interactionResume } : {}), - knowledgeService: this.input.knowledgeService, - longTermMemories, - mcpRuntime, - messages: conversationMessages, - ...(modelContextProfile ? { modelContextProfile } : {}), - modelProvider, - protocolStateStore, - ...(modelSettings ? { modelSettings } : {}), - runContext, - selectedSkills, - skillSelection, - ...(sessionIntent ? { sessionIntent } : {}), - ...(classifierContext ? { classifierContext: classifierContext.text } : {}), - taskStateRuntime: this.input.taskStateRuntime, - userId: this.input.user.id, - workspaceId: this.input.workspaceId, - workspaceRoot: this.input.workspaceRoot - }); - const finalizer = new RunFinalizer({ - destroyWorkspace: agentAssembly.destroyWorkspace, - emit, - fileAssetService: this.input.fileAssetService, - flushCompletedMemory: (flushInput) => memoryAssembly.flushCompletedMemory(flushInput), - flushDraftsMemory: () => { - memoryAssembly.flushDraftsMemory(); - }, - memoryExtractionTimeoutMs: this.input.memoryExtractionTimeoutMs, - metadataStore: this.input.metadataStore, - runId, - sessionId, - userId: this.input.user.id, - sessionDir: agentAssembly.sessionDir, - workspaceId: this.input.workspaceId - }); - let subscription: { unsubscribe(): void } | undefined; - let suspended = false; - let resumeResolved = false; - let finalization: Promise | undefined; - let unregisterCancel = (): void => undefined; - let runTimeout: ReturnType | undefined; - let terminalStarted = false; - let sessionTitleStarted = false; - let lastAssistantMessageId: string | undefined; - /** toolCallIds that already emitted TOOL_CALL_START / END in this run (HITL bridge). */ - const startedToolCallIds = new Set(); - const endedToolCallIds = new Set(); - const clearRunTimeout = (): void => { - if (runTimeout) { - clearTimeout(runTimeout); - runTimeout = undefined; - } - }; - const failRun = (message: string, terminalEvent?: BaseEvent): void => { - if (terminalStarted) { - return; - } - terminalStarted = true; - runAbortController.abort(new Error(message)); - clearRunTimeout(); - unregisterCancel(); - finalizer.fail({ - errorMessage: message, - terminalEvent: terminalEvent ?? { - type: EventType.RUN_ERROR, - message, - timestamp: Date.now() - } - }); - }; - const cancelRun = (reason = "RUN_CANCELLED"): void => { - if (terminalStarted) { - return; - } - terminalStarted = true; - runAbortController.abort(new Error(reason)); - clearRunTimeout(); - unregisterCancel(); - subscription?.unsubscribe(); - finalization = finalizer.cancelRun({ - reason, - terminalEvent: { - type: EventType.RUN_FINISHED, - status: "cancelled", - timestamp: Date.now() - } as BaseEvent - }); - void finalization.then(() => subscriber.complete(), (error: unknown) => subscriber.error(error)); - }; - unregisterCancel = this.input.runCancelRegistry.register({ - cancel: cancelRun, - runId, - sessionId, - userId: this.input.user.id - }); - subscriber.add(() => unregisterCancel()); - - if (this.input.conversationMemoryMode === "working-memory-readonly") { - await ensureConversationWorkingMemoryThread({ - resourceId: this.input.user.id, - taskStateRuntime: this.input.taskStateRuntime, - threadId: sessionId - }); - } - - subscription = agentAssembly.mastraAgent.run({ - ...normalizedRunInput, - runId, - messages: agentAssembly.governedMessages - }).subscribe({ - next: (event) => { - if (terminalStarted) { - return; - } - const assistantMessageId = assistantMessageIdFromEvent(event); - if (assistantMessageId) { - lastAssistantMessageId = assistantMessageId; - } - const interactionRequested = interactionRuntime.capture(event); - if (interactionRequested) { - terminalStarted = true; - clearRunTimeout(); - unregisterCancel(); - suspended = true; - // R-018 / AG-UI verifyEvents: close the tool-call span before transport RUN_FINISHED - // whether upstream already emitted START or Mastra skipped start/end entirely. - const bridgeEvents = buildHitlSuspendBridgeEvents({ - interrupt: interactionRequested.interrupt, - interactionEvent: interactionRequested.event, - ...(event.type === EventType.CUSTOM && event.name === "on_interrupt" - ? { passthroughInterruptEvent: event } - : {}), - state: { startedToolCallIds, endedToolCallIds } - }); - for (const bridgeEvent of bridgeEvents) { - if (bridgeEvent === interactionRequested.event) { - emit(bridgeEvent); - finalizer.suspend(); - continue; - } - emit(bridgeEvent); - } - // Stream must finalize so CopilotKit can surface the interrupt UI via onRunFinalized. - // This synthetic terminal event is transport-only; suspended runs must not replay as finished. - subscriber.next({ - type: EventType.RUN_FINISHED, - timestamp: Date.now() - }); - return; - } - if (event.type === EventType.RUN_FINISHED && suspended) { - return; - } - if (event.type === EventType.RUN_FINISHED && interactionResume?.response === false) { - terminalStarted = true; - clearRunTimeout(); - unregisterCancel(); - finalization = finalizer.cancel({ - interactionResolvedEvent: interactionRuntime.cancel(interactionResume), - terminalEvent: event - }); - return; - } - if (event.type === EventType.RUN_FINISHED) { - terminalStarted = true; - clearRunTimeout(); - unregisterCancel(); - const persistedAssistantMessage = lastAssistantMessageId - ? undefined - : this.input.metadataStore.conversationMessages.findLatestAssistantByRun({ - user_id: this.input.user.id, - session_id: sessionId, - run_id: runId - }); - finalization = completeProtocolRun({ - finalizer, - ...(agentAssembly.goalRuntime ? { goalRuntime: agentAssembly.goalRuntime } : {}), - ...(lastAssistantMessageId ? { lastAssistantMessageId } : {}), - ...(persistedAssistantMessage?.message_id - ? { persistedAssistantMessageId: persistedAssistantMessage.message_id } - : {}), - protocol: agentAssembly.protocol, - runId, - terminalEvent: event - }); - return; - } - if (event.type === EventType.RUN_ERROR) { - failRun("AG-UI run error", event); - return; - } - if ( - event.type === EventType.TOOL_CALL_START - && typeof event.toolCallId === "string" - && event.toolCallId.length > 0 - ) { - startedToolCallIds.add(event.toolCallId); - } - if ( - event.type === EventType.TOOL_CALL_END - && typeof event.toolCallId === "string" - && event.toolCallId.length > 0 - ) { - endedToolCallIds.add(event.toolCallId); - } - emit(event); - - if (event.type === EventType.RUN_STARTED) { - agentAssembly.flushProtocolEvents(); - } - - if ( - interactionResume - && !resumeResolved - && event.type === EventType.TOOL_CALL_RESULT - && event.toolCallId === interactionResume.interrupt.toolCallId - ) { - try { - emit(interactionRuntime.resolve(interactionResume)); - resumeResolved = true; - } catch (error) { - const message = error instanceof Error ? error.message : "Interaction resume failed"; - emit({ - type: EventType.RUN_ERROR, - message, - timestamp: Date.now() - }); - } - } - - if (event.type === EventType.RUN_STARTED) { - emit(createCustomEvent("run.config.resolved", { - active_datasource_id: effectiveRunConfig.activeDatasourceId, - active_skill_id: effectiveRunConfig.activeSkillId, - enabled_datasource_ids: effectiveRunConfig.enabledDatasourceIds, - file_ids: effectiveRunConfig.fileIds, - enabled_knowledge_ids: effectiveRunConfig.enabledKnowledgeIds, - enabled_mcp_server_ids: effectiveRunConfig.enabledMcpServerIds, - selected_skill_ids: selectedSkills.map((skill) => skill.id), - skill_mode: effectiveRunConfig.skillMode, - requested_llm_profile_id: effectiveRunConfig.activeLlmProfileId, - active_llm_profile_id: effectiveRunConfig.activeLlmProfileId, - workspace_id: this.input.workspaceId, - workspace: agentAssembly.workspace, - ...(modelContextProfile - ? { - context_window: modelContextProfile.contextWindow, - input_budget: Math.max( - modelContextProfile.contextWindow - - modelContextProfile.outputReserve - - modelContextProfile.safetyMargin, - 0 - ) - } - : {}), - ...(reasoningModel !== undefined ? { reasoning_model: reasoningModel } : {}), - ...(runTimeoutMs !== undefined ? { run_timeout_ms: runTimeoutMs } : {}), - ...(effectiveRunConfig.mentioned - ? { - mentioned: { - db: effectiveRunConfig.mentioned.db, - kb: effectiveRunConfig.mentioned.kb, - mcp: effectiveRunConfig.mentioned.mcp, - skill: effectiveRunConfig.mentioned.skill, - ...(effectiveRunConfig.mentioned.excluded && effectiveRunConfig.mentioned.excluded.length > 0 - ? { excluded: effectiveRunConfig.mentioned.excluded } - : {}) - } - } - : {}), - ...((effectiveRunConfig.pinnedPaths?.length ?? 0) > 0 - ? { pinned_paths: effectiveRunConfig.pinnedPaths } - : {}), - ...(effectiveRunConfig.evidenceRefs.length > 0 - ? { - evidence_refs: effectiveRunConfig.evidenceRefs, - evidence_resolution: evidenceContext.diagnostics - } - : {}), - ...(effectiveRunConfig.disabledByPolicy && effectiveRunConfig.disabledByPolicy.length > 0 - ? { disabled_by_policy: effectiveRunConfig.disabledByPolicy } - : {}), - ...(effectiveRunConfig.unavailableResources && effectiveRunConfig.unavailableResources.length > 0 - ? { unavailable_resources: effectiveRunConfig.unavailableResources } - : {}) - })); - emit(createCustomEvent("skill.selection", { - audit: skillSelection.audit, - effective_tool_policy: skillSelection.effectiveToolPolicy, - mode: effectiveRunConfig.skillMode, - selected: selectedSkills.map((skill) => ({ - id: skill.id, - name: skill.name, - revision: skill.revision, - tags: skill.tags - })) - })); - emit({ - type: EventType.STATE_SNAPSHOT, - snapshot: { - selectedDatasourceId, - runId, - runStatus: "running", - sessionId - }, - timestamp: Date.now() - }); - if (!isResume && !sessionTitleStarted) { - sessionTitleStarted = true; - startSessionTitleTask({ - // Title generation is async and may finish after the agent run - // terminals; still forward the event while the stream is open - // (finalizer continues emitting after RUN_FINISHED). - emit, - metadataStore: this.input.metadataStore, - model: modelProvider.model, - modelTemperature: modelSettings?.temperature, - sessionId, - userId: this.input.user.id, - // Title the session by its recorded task, not by a weak follow-up: - // a branched session whose first message is "再次尝试" should carry - // its inherited intent as the title. - userInput: resolveSessionIntentForRun({ - metadataStore: this.input.metadataStore, - userId: this.input.user.id, - sessionId - })?.intentText ?? userInput - }); - } - } - - }, - error: (error: unknown) => { - const message = error instanceof Error ? error.message : "Unknown AG-UI agent error"; - const event: BaseEvent = { - type: EventType.RUN_ERROR, - message, - timestamp: Date.now() - }; - failRun(message, event); - subscriber.complete(); - }, - complete: () => { - clearRunTimeout(); - unregisterCancel(); - if (finalization) { - void finalization.then(() => subscriber.complete(), (error: unknown) => subscriber.error(error)); - return; - } - subscriber.complete(); - } - }); - - if (runTimeoutMs !== undefined) { - runTimeout = setTimeout(() => { - runAbortController.abort(new Error(`RUN_TIMEOUT:${runTimeoutMs}`)); - subscription?.unsubscribe(); - failRun(`RUN_TIMEOUT:${runTimeoutMs}`); - subscriber.complete(); - }, runTimeoutMs); - } - - subscriber.add(() => { - if (!terminalStarted) { - runAbortController.abort(new Error("RUN_SUBSCRIBER_CLOSED")); - } - clearRunTimeout(); - unregisterCancel(); - subscription?.unsubscribe(); - }); - }; - - run().catch((error: unknown) => { - const message = error instanceof Error ? error.message : String(error); - persistEarlyFailedUserMessage({ - errorMessage: message, - isResume: Boolean(interactionResume), - metadataStore: this.input.metadataStore, - runId, - runInput, - sessionId: runInput.threadId, - userId: this.input.user.id, - userInput: extractLastUserText(runInput) ?? "CopilotKit AG-UI run" - }); - emitEarlyRunFailure(subscriber, runId, message); - }); - }); - } -} - const isCopilotKitPath = (pathname: string): boolean => pathname === COPILOTKIT_PATH || pathname.startsWith(`${COPILOTKIT_PATH}/`); @@ -1130,26 +336,6 @@ const resolveRequestAuth = ( }; }; -const ensureConversationWorkingMemoryThread = async (input: { - resourceId: string; - taskStateRuntime: TaskStateRuntime; - threadId: string; -}): Promise => { - const existing = await input.taskStateRuntime.memory.getThreadById({ - resourceId: input.resourceId, - threadId: input.threadId - }); - if (existing) { - return; - } - await input.taskStateRuntime.memory.createThread({ - memoryConfig: CONVERSATION_WORKING_MEMORY_CONFIG, - resourceId: input.resourceId, - saveThread: true, - threadId: input.threadId - }); -}; - const headerString = (value: string | string[] | undefined): string | undefined => Array.isArray(value) ? value[0] : value; @@ -1362,18 +548,3 @@ const stringRecordValue = (record: Record | undefined, key: str const value = record?.[key]; return typeof value === "string" && value.trim() ? value.trim() : undefined; }; - -const recentUserQueries = (messages: RunAgentInput["messages"]): string[] => { - const queries = messages.flatMap((message) => { - if (message.role !== "user" || message.id?.startsWith("memory-summary:")) return []; - if (typeof message.content === "string") return [message.content.trim()]; - if (!Array.isArray(message.content)) return []; - const text = message.content.flatMap((part) => - typeof part === "object" && part !== null && "type" in part && part.type === "text" - && "text" in part && typeof part.text === "string" - ? [part.text] - : []).join("\n").trim(); - return text ? [text] : []; - }).filter((query) => query.length > 0); - return queries.slice(0, -1).slice(-2); -}; diff --git a/apps/api/src/session-title.ts b/apps/api/src/session-title.ts index 884c4238..f95f851c 100644 --- a/apps/api/src/session-title.ts +++ b/apps/api/src/session-title.ts @@ -1,11 +1,7 @@ -import { Agent } from "@mastra/core/agent"; import { createCustomEvent } from "@datafoundry/agent-runtime"; import type { MetadataStore, SessionRecord } from "@datafoundry/metadata"; import type { BaseEvent } from "@ag-ui/client"; -/** Reasoning models (e.g. deepseek-v4-pro) spend output budget on thinking first. */ -const TITLE_TIMEOUT_MS = 15_000; -const TITLE_MAX_OUTPUT_TOKENS = 256; const TITLE_MAX_CHARS = 32; export type SessionTitleTaskInput = { @@ -34,12 +30,12 @@ const generateAndPersistSessionTitle = async (input: SessionTitleTaskInput): Pro return; } - const title = await generateLlmTitle(input).catch(() => fallbackTitle(input.userInput)); + const title = fallbackTitle(input.userInput); const updated = input.metadataStore.sessions.updateAutoTitleIfAllowed({ user_id: input.userId, session_id: input.sessionId, title: title.title, - title_source: title.source === "llm" ? "llm" : "fallback" + title_source: "fallback" }); if (!updated) { return; @@ -47,52 +43,6 @@ const generateAndPersistSessionTitle = async (input: SessionTitleTaskInput): Pro input.emit(createCustomEvent("session.title", sessionTitleDto(updated))); }; -const generateLlmTitle = async ( - input: SessionTitleTaskInput -): Promise<{ source: "llm"; title: string }> => { - const agent = new Agent({ - id: "session-title-generator", - name: "Session Title Generator", - instructions: [ - "你为数据分析 Agent 的新会话生成左侧会话列表标题。", - "只根据用户第一条请求生成 3 到 8 个中文字符或短词。", - "不要使用标点、引号、Markdown、前后缀说明。", - "不要包含用户隐私、凭证、环境变量或内部实现细节。", - "如果用户请求主要是英文,可以生成简短英文标题。" - ].join("\n"), - model: input.model as never, - defaultOptions: { - maxSteps: 1, - modelSettings: { - maxOutputTokens: TITLE_MAX_OUTPUT_TOKENS, - temperature: input.modelTemperature ?? 0.2 - }, - providerOptions: { - openai: { - systemMessageMode: "system" - } - } - } - }); - const output = await agent.generate(buildTitlePrompt(input.userInput), { - abortSignal: AbortSignal.timeout(TITLE_TIMEOUT_MS) - }); - const title = sanitizeTitle(output.text); - if (!title) { - // Empty text usually means the output budget was spent on reasoning tokens. - throw new Error("SESSION_TITLE_EMPTY"); - } - return { source: "llm", title }; -}; - -const buildTitlePrompt = (userInput: string): string => [ - "为下面这条用户请求生成一个会话短标题。", - "", - "", - userInput.slice(0, 1000), - "" -].join("\n"); - const fallbackTitle = (userInput: string): { source: "fallback"; title: string } => ({ source: "fallback", title: sanitizeTitle(userInput) || "新会话" diff --git a/apps/web/src/app/data-tasks/components/chat/CollaborationInterruptHandler.tsx b/apps/web/src/app/data-tasks/components/chat/CollaborationInterruptHandler.tsx index b3871d4c..db5c957b 100644 --- a/apps/web/src/app/data-tasks/components/chat/CollaborationInterruptHandler.tsx +++ b/apps/web/src/app/data-tasks/components/chat/CollaborationInterruptHandler.tsx @@ -28,17 +28,20 @@ import { setPendingCollaborationInterrupt, } from "./pending-collaboration-interrupt"; -type MastraInterrupt = { - type?: string; +export type AgentInterrupt = { + type?: "agent_interrupt" | "mastra_suspend" | string; toolCallId?: string; toolName?: "ask_user" | "submit_plan"; suspendPayload?: Record; args?: Record; }; +/** @deprecated Use AgentInterrupt. Kept for replay of Mastra-era sessions. */ +export type MastraInterrupt = AgentInterrupt; + type ChoiceOption = { label: string; value: string; description?: string }; -function parseInterruptValue(value: unknown): MastraInterrupt | null { +export function parseInterruptValue(value: unknown): AgentInterrupt | null { const raw = typeof value === "string" ? (() => { @@ -50,10 +53,14 @@ function parseInterruptValue(value: unknown): MastraInterrupt | null { })() : value; if (!raw || typeof raw !== "object") return null; - return raw as MastraInterrupt; + const record = raw as AgentInterrupt; + if (record.type && record.type !== "agent_interrupt" && record.type !== "mastra_suspend") { + return record.toolCallId && record.toolName ? record : null; + } + return record; } -function readQuestion(interrupt: MastraInterrupt): string { +function readQuestion(interrupt: AgentInterrupt): string { const payload = interrupt.suspendPayload; if (payload && typeof payload.question === "string") return payload.question; if (interrupt.args && typeof interrupt.args.question === "string") { @@ -83,7 +90,7 @@ function normalizeOption(item: unknown): ChoiceOption | null { return { label, value, description }; } -function readOptions(interrupt: MastraInterrupt): ChoiceOption[] { +function readOptions(interrupt: AgentInterrupt): ChoiceOption[] { const sources = [interrupt.suspendPayload?.options, interrupt.args?.options]; for (const raw of sources) { if (!Array.isArray(raw)) continue; diff --git a/apps/web/src/app/data-tasks/components/chat/RestoredInterruptHandler.tsx b/apps/web/src/app/data-tasks/components/chat/RestoredInterruptHandler.tsx index eb186b0e..b0ba2296 100644 --- a/apps/web/src/app/data-tasks/components/chat/RestoredInterruptHandler.tsx +++ b/apps/web/src/app/data-tasks/components/chat/RestoredInterruptHandler.tsx @@ -10,7 +10,7 @@ import { AskUserPrompt, parseInterruptValue, SubmitPlanPrompt, - type MastraInterrupt, + type AgentInterrupt, } from "./CollaborationInterruptHandler"; import { removeRestoredInterrupt, @@ -22,7 +22,7 @@ import { usePendingCollaborationInterrupt, } from "./pending-collaboration-interrupt"; -function toMastraInterrupt(value: unknown): MastraInterrupt | null { +function toAgentInterrupt(value: unknown): AgentInterrupt | null { return parseInterruptValue(value); } @@ -56,7 +56,7 @@ export function RestoredInterruptHandler({ ); }, [capabilitiesReady, collaborationResponses, livePending?.source, restoredInterrupts]); - const interrupt = pending ? toMastraInterrupt(pending.interruptEvent) : null; + const interrupt = pending ? toAgentInterrupt(pending.interruptEvent) : null; const canResume = capabilitiesReady && diff --git a/apps/web/src/app/data-tasks/components/task-console/TaskConsole.tsx b/apps/web/src/app/data-tasks/components/task-console/TaskConsole.tsx index dbc56d07..f976dac5 100755 --- a/apps/web/src/app/data-tasks/components/task-console/TaskConsole.tsx +++ b/apps/web/src/app/data-tasks/components/task-console/TaskConsole.tsx @@ -23,6 +23,7 @@ import type { } from "../../data-task-state"; import type { JobDto } from "../../../../lib/config-api"; import { configApi } from "../../../../lib/config-api"; +import { getRuntimeCapabilities } from "../../../../lib/config-api/capabilities"; import { dataStepKindForTool, dataStepLabel, hasCapability, toolDisplayTitle } from "../../data-task-state"; import { artifactExportClient } from "../../artifact-export-client"; import { @@ -924,8 +925,16 @@ function DeliverablesZone({ {artifacts.length === 0 ? ( ) : (
diff --git a/apps/web/src/app/data-tasks/components/task-console/TraceDagCanvas.tsx b/apps/web/src/app/data-tasks/components/task-console/TraceDagCanvas.tsx index 3a2aab04..b9d5b26a 100644 --- a/apps/web/src/app/data-tasks/components/task-console/TraceDagCanvas.tsx +++ b/apps/web/src/app/data-tasks/components/task-console/TraceDagCanvas.tsx @@ -179,7 +179,7 @@ export function TraceDagCanvas({ ); } diff --git a/apps/web/src/i18n/messages/en.json b/apps/web/src/i18n/messages/en.json index fe216f3e..da992eed 100644 --- a/apps/web/src/i18n/messages/en.json +++ b/apps/web/src/i18n/messages/en.json @@ -205,7 +205,11 @@ "noStepsYet": "No steps yet", "noStepsYetDescription": "After you send a question, process steps appear here in order. Parallel tools are grouped under one step.", "noOutputs": "No outputs yet", - "noOutputsDescription": "SQL, datasets, charts, and reports appear here after a question. Preview an item in a popup, or Cite it on the right to select table/text or the whole artifact." + "noOutputsDescription": "SQL, datasets, charts, and reports appear here after a question. Preview an item in a popup, or Cite it on the right to select table/text or the whole artifact.", + "runtimeDataToolsDisabled": "Data outputs are not enabled in this version", + "runtimeDataToolsDisabledDescription": "The control plane now talks to an independent agent runtime. Data queries, SQL audit, and artifacts are not connected yet. Chat, built-in tool steps, and human approval still work.", + "runtimeTraceDisabled": "Semantic trace is not enabled in this version", + "runtimeTraceDisabledDescription": "Older sessions can still replay existing traces. New runs do not produce a semantic DAG." }, "outputsHintExport": "Preview any output in a popup, or Cite it on the right to select a part or the whole.", "outputsHint": "Preview any output in a popup, or Cite it on the right.", diff --git a/apps/web/src/i18n/messages/zh-CN.json b/apps/web/src/i18n/messages/zh-CN.json index da992e0a..4520e500 100644 --- a/apps/web/src/i18n/messages/zh-CN.json +++ b/apps/web/src/i18n/messages/zh-CN.json @@ -205,7 +205,11 @@ "noStepsYet": "暂无步骤", "noStepsYetDescription": "发送问题后,处理步骤会按顺序显示在这里。并行工具会合并到同一步骤下。", "noOutputs": "暂无输出", - "noOutputsDescription": "提问后,SQL、数据集、图表与报告会显示在这里。可在弹窗中预览,或通过「引用」在右侧打开以选中表格/文本或引用全部。" + "noOutputsDescription": "提问后,SQL、数据集、图表与报告会显示在这里。可在弹窗中预览,或通过「引用」在右侧打开以选中表格/文本或引用全部。", + "runtimeDataToolsDisabled": "本版本未启用数据产出", + "runtimeDataToolsDisabledDescription": "当前已接入独立 Agent Runtime,数据查询、SQL 审计与 artifact 尚未接入。对话、工具步骤与人工确认仍然可用。", + "runtimeTraceDisabled": "语义 Trace 本版本未启用", + "runtimeTraceDisabledDescription": "历史会话仍可回放已有 Trace;新运行不会生成语义 DAG。" }, "outputsHintExport": "可在弹窗中预览任意输出,或通过「引用」在右侧打开以选中部分或引用全部。", "outputsHint": "可在弹窗中预览任意输出,或通过「引用」在右侧打开。", diff --git a/apps/web/src/lib/config-api/capabilities.ts b/apps/web/src/lib/config-api/capabilities.ts index f564e83b..c320807f 100644 --- a/apps/web/src/lib/config-api/capabilities.ts +++ b/apps/web/src/lib/config-api/capabilities.ts @@ -1,7 +1,7 @@ import type { BackendCapabilitiesResponse } from "./types"; import type { BackendCapability } from "../../app/data-tasks/data-task-state"; -export type RuntimeCapability = "conversationMemory" | "knowledge" | "mcp" | "skills"; +export type RuntimeCapability = "conversationMemory" | "knowledge" | "mcp" | "skills" | "dataTools" | "traceDag"; const DEFAULT_BACKEND_CAPABILITIES: Record = { "datasource.server": false, @@ -22,6 +22,8 @@ const DEFAULT_RUNTIME_CAPABILITIES: Record = { knowledge: false, mcp: false, skills: false, + dataTools: false, + traceDag: false, }; let backendCapabilities: Record = { @@ -53,6 +55,8 @@ export function applyBackendCapabilities( knowledge: response.knowledge ?? false, mcp: response.mcp ?? false, skills: response.skills ?? false, + dataTools: response["runtime.dataTools"] ?? false, + traceDag: response["runtime.traceDag"] ?? false, }; return backendCapabilities; } diff --git a/apps/web/src/lib/config-api/types.ts b/apps/web/src/lib/config-api/types.ts index 08e19ee5..2716b10b 100644 --- a/apps/web/src/lib/config-api/types.ts +++ b/apps/web/src/lib/config-api/types.ts @@ -82,6 +82,8 @@ export type BackendCapabilitiesResponse = { mcp?: boolean; skills?: boolean; files?: boolean; + "runtime.dataTools"?: boolean; + "runtime.traceDag"?: boolean; }; export type FileAssetRefDto = { diff --git a/docs/en/README.md b/docs/en/README.md index 99a54f45..e5bfe875 100644 --- a/docs/en/README.md +++ b/docs/en/README.md @@ -15,7 +15,7 @@ These docs are for product trials, customer demos, open-source visitors, and int | Use the terminal interface | [TUI guide](guides/tui.md) | | Connect your own data sources | [Data sources guide](guides/data-sources.md) | | Browse supported data sources | [Supported data sources](reference/supported-datasources.md) | -| Learn about APIs and integration | [REST API reference](reference/rest-api.md), [Configuration API reference](reference/configuration-api.md), and [Agent Runtime reference](reference/agent-runtime.md) | +| Learn about APIs and integration | [REST API reference](reference/rest-api.md), [Configuration API reference](reference/configuration-api.md), [Agent Runtime reference](reference/agent-runtime.md), [Deep Agents Runtime contract](reference/deep-agents-runtime.md), and [v1 capability boundary](reference/deep-agents-runtime-boundary.md) | | Understand system structure | [Architecture overview](architecture/overview.md) | | Review security boundaries | [Security](security.md) | diff --git a/docs/en/architecture/overview.md b/docs/en/architecture/overview.md index 0b2b9635..3ceb64ed 100644 --- a/docs/en/architecture/overview.md +++ b/docs/en/architecture/overview.md @@ -121,7 +121,7 @@ npm run start:web The browser reaches REST and CopilotKit SSE through the same-origin Next BFF. Probes: - `GET /healthz` — process liveness -- `GET /ready` — Mastra and builtin resources ready (response includes `startup_ms` / `phases`) +- `GET /ready` — control plane ready (response includes `startup_ms` / `phases` / `runtime`). REST and history replay still work when the runtime is unavailable. Reverse-proxy sample: [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example) — compress static assets; leave the SSE path uncompressed and unbuffered. Contributor hot-reload: [Quick start appendix](../quick-start.md). diff --git a/docs/en/reference/deep-agents-runtime-boundary.md b/docs/en/reference/deep-agents-runtime-boundary.md new file mode 100644 index 00000000..b8c5a886 --- /dev/null +++ b/docs/en/reference/deep-agents-runtime-boundary.md @@ -0,0 +1,94 @@ +# Deep Agents Runtime v1 capability boundary + +This document is for **runtime implementers**. It states what v1 must provide, what is already wired, what is leftover, and what is explicitly out of scope. Wire format stays in the [contract](deep-agents-runtime.md). Chinese source of truth: [v1 能力边界](../../zh/reference/deep-agents-runtime-boundary.md). Snapshot date: 2026-09-01. + +Confirm three things: + +1. The must-have list is accepted. +2. Each leftover is owned by runtime or the control plane. +3. Out-of-scope items stay out of v1. + +## Split of responsibility + +| Side | Owns | Does not own | +| --- | --- | --- | +| Control plane (`apps/api`) | Sessions, auth, assembled `messages` / `systemPrompt`, event persist and projection, HITL transport, cancel orchestration, Web / TUI | Does not run LangGraph or interpret runtime checkpoint internals | +| Runtime (`services/deepagents-runtime`) | Deep Agents / LangGraph, AG-UI SSE, tools, interrupt / resume, cancel, opaque `checkpointRef` | Does not know DataFoundry metadata, data gateway, SQL audit, artifacts, knowledge, or skills | +| Clients | Render AG-UI events and restored history | Do not call runtime HTTP directly | + +Without `RUNTIME_SERVICE_URL`, the API uses an in-process TypeScript stub. `npm run dev` starts the Python runtime on `:8790` and injects the URL. + +## v1 must-haves + +### Transport + +`GET /health`, `POST /runs/stream` (AG-UI SSE), `POST /runs/:runId/cancel`. Optional bearer token. No model keys or database secrets in the run request. + +Cancel should stop the graph and finish with `RUN_FINISHED` + `status: "cancelled"`. + +### Event identity + +AG-UI `toolCallId` **is** the model `tool_calls[].id` (for example `call_xxx`). It is **not** the LangGraph / LangChain execution `run_id`. + +- `on_tool_start` binds an existing model id. It must not emit another `TOOL_CALL_START`. +- Same-name parallel calls use FIFO. +- Do not replay `TOOL_CALL_ARGS` after the model already streamed them. +- `TOOL_CALL_RESULT` must include `messageId` and `role: "tool"` (CopilotKit will otherwise leave the card running). +- If the graph ends with open tool ids and there is no interrupt, emit `RUN_ERROR` (`UNFINISHED_TOOL_CALLS:…`). Do not synthesize `TOOL_CALL_END`. +- Do not reopen a text `messageId` that already received `TEXT_MESSAGE_END`. + +### Conversation, tools, HITL + +Streaming text and multi-turn on the same `threadId` are in scope. + +In-scope tools: `write_todos`, `ask_user` (the only HITL tool on `interrupt_on`), and Deep Agents built-in filesystem tools such as `glob` if the SDK exposes them. Those filesystem tools are **not** DataFoundry data tools. The control plane does not govern their paths. + +`on_interrupt` uses `type: "agent_interrupt"`. Resume reuses the original `runId`. `response === false` cancels the interrupt. `mastra_suspend` is replay-only; refuse to continue it. + +History replay after refresh is a control-plane concern. Runtime only returns an opaque `checkpointRef` via `runtime.bound`. + +## Verified on 2026-09-01 + +Live model + Web + API. Runtime unit tests: 25 passed. + +Plain chat; one `write_todos`; three same-name `glob` calls with distinct ids; `ask_user` then resume with 「继续」; follow-up text in the same thread; mid-stream cancel (`canceled`); refresh restore of a completed tool run; Schema/SQL-style prompts do not crash and do not run DataFoundry data tools. + +Fake-model coverage: `npm run smoke:deepagents-sdk`. + +## Leftovers + +| ID | Symptom | Owner | Notes | +| --- | --- | --- | --- | +| L1 | After HITL resume, conversation DTO may still list `pendingInteractions` / `ask_user` as pending | Control plane first | UI and checkpoint already completed. Runtime should still emit `TOOL_CALL_RESULT` for that id on resume | +| L2 | Contract mentions `submit_plan`; runtime does not `interrupt_on` it | Runtime | Mapping `write_todos` → `submit_plan` is translation only | +| L3 | HITL reject / 「停止」 not verified in the browser | Both | Code path exists | +| L4 | Control plane may persist cancel as `terminalEvent: RUN_ERROR` with status `canceled` | Align both | Runtime should send `RUN_FINISHED` + `cancelled` | +| L5 | Reusing `msg_{runId}` after a closed text segment | Runtime | Not hit in the verified tool turns | +| L6 | `./deploy.sh` does not start the Python runtime | Deploy / control plane | `npm run dev` already does | +| L7 | SDK filesystem tools are ungoverned | Runtime to confirm | Live model called `glob`. Keep as generic tools, or disable in v1 | + +Do not “fix” leftovers by deduping on tool name, synthesizing `END` before `RUN_FINISHED`, disabling AG-UI verify, or merging same-name running cards in the UI. + +## Explicitly out of scope + +Not bugs: DataFoundry schema/SQL tools, SQL audit, artifacts, workspace/sandbox metadata, knowledge, skills, MCP, goals/memory. No datasource credentials to the runtime. Old Mastra interrupts cannot resume here. + +A user asking to “list tables” may get a refusal, a filesystem no-op, or a text explanation. The control plane does not expect real `inspect_schema` / `run_sql` results. + +## Confirmation checklist + +**Must:** the three HTTP endpoints; model-id `toolCallId`; RESULT with `messageId` + `role: "tool"`; streaming + multi-turn; `ask_user` interrupt/resume; cancel with `RUN_FINISHED` cancelled; opaque `checkpointRef`; reject `mastra_suspend`; no data-plane secrets. + +**Should:** FIFO for same-name tools; `RUN_ERROR` on leftover tool ids; fake-model path; honor control-plane `systemPrompt` and `limits.maxSteps`; written decision on L7. + +**Must not:** treat LangGraph `run_id` as a new AG-UI id; synthesize `TOOL_CALL_END` to pass verify; rewrite business `systemPrompt`; require `artifact` / `sql_audit` / `skill.selection` in v1; connect production databases from the runtime. + +## Next period (not this boundary) + +Control-plane tool gateway for data tools; SQL audit and artifacts; real `submit_plan`; deploy-script runtime; close L1 pending projection. + +## See also + +- [Contract](deep-agents-runtime.md) +- Chinese detail: [v1 能力边界](../../zh/reference/deep-agents-runtime-boundary.md) +- Implementation: `services/deepagents-runtime/` diff --git a/docs/en/reference/deep-agents-runtime.md b/docs/en/reference/deep-agents-runtime.md new file mode 100644 index 00000000..84472aad --- /dev/null +++ b/docs/en/reference/deep-agents-runtime.md @@ -0,0 +1,11 @@ +# Deep Agents Runtime contract + +This is the only interface between the DataFoundry control plane and an independent agent runtime. Web, TUI, and REST stay runtime-agnostic. The runtime stays DataFoundry-agnostic. + +v1 covers conversation, streaming, cancel, HITL, and history persistence. Data tools, SQL audit, artifacts, semantic governance, and skills are out of scope. + +The Python sidecar in `services/deepagents-runtime` implements the contract with Deep Agents `create_deep_agent`. `npm run dev` starts it on `:8790`. Without `LLM_API_KEY` it uses a scripted model on the real LangGraph path. Verify with `npm run smoke:deepagents-sdk`. + +See the Chinese document for the full field tables and examples: [Deep Agents Runtime 接入契约](../../zh/reference/deep-agents-runtime.md). + +Capability snapshot for runtime implementers (must / leftover / out of scope): [v1 capability boundary](deep-agents-runtime-boundary.md). Chinese source: [v1 能力边界](../../zh/reference/deep-agents-runtime-boundary.md). diff --git a/docs/en/reference/rest-api.md b/docs/en/reference/rest-api.md index f84c6032..955f9628 100644 --- a/docs/en/reference/rest-api.md +++ b/docs/en/reference/rest-api.md @@ -72,7 +72,7 @@ Web v1 does not expose workspace switching; custom integrations should use the w | Method | Path | Purpose | | --- | --- | --- | | GET | `/healthz` | Process liveness. | -| GET | `/ready` | Readiness: Mastra / builtins finished; response includes `startup_ms` and `phases`. | +| GET | `/ready` | Control-plane readiness; response includes `startup_ms`, `phases`, and independent runtime health. | | GET | `/api/v1/capabilities` | Read backend capability switches. | | GET | `/api/v1/me` | Read current identity. | diff --git a/docs/zh/README.md b/docs/zh/README.md index 63ba9ae0..a0e99c11 100644 --- a/docs/zh/README.md +++ b/docs/zh/README.md @@ -18,7 +18,7 @@ DataFoundry 是一个面向数据分析场景的 AI 工作台。它把自然语 | 使用终端界面 | [TUI 指南](guides/tui.md) | | 连接自己的数据源 | [数据源指南](guides/data-sources.md) | | 查看支持的数据源 | [支持的数据源](reference/supported-datasources.md) | -| 了解 API 和集成方式 | [REST API 参考](reference/rest-api.md)、[配置 API 参考](reference/configuration-api.md) 与 [Agent Runtime 参考](reference/agent-runtime.md) | +| 了解 API 和集成方式 | [REST API 参考](reference/rest-api.md)、[配置 API 参考](reference/configuration-api.md)、[Agent Runtime 参考](reference/agent-runtime.md)、[Deep Agents Runtime 契约](reference/deep-agents-runtime.md) 与 [v1 能力边界](reference/deep-agents-runtime-boundary.md) | | 了解系统结构 | [架构概览](architecture/overview.md) | | 检查安全边界 | [安全说明](security.md) | diff --git a/docs/zh/architecture/overview.md b/docs/zh/architecture/overview.md index 7c8a429a..dc2c1ed7 100644 --- a/docs/zh/architecture/overview.md +++ b/docs/zh/architecture/overview.md @@ -125,7 +125,7 @@ npm run start:web 浏览器经 Next 同源 BFF 访问 REST 与 CopilotKit SSE。探针区分: - `GET /healthz`:进程存活 -- `GET /ready`:Mastra 与 builtin 资源就绪(响应含 `startup_ms` / `phases`) +- `GET /ready`:控制面就绪(响应含 `startup_ms` / `phases` / `runtime`)。runtime 不可用时 REST 与历史回放仍可服务。 反代样例见 [`deploy/nginx.datafoundry.conf.example`](https://github.com/datagallery-lab/datafoundry/blob/main/deploy/nginx.datafoundry.conf.example):静态资源压缩,SSE 路径不压缩、不缓冲。贡献者热更新见 [快速开始附录](../quick-start.md)。 diff --git a/docs/zh/reference/agent-runtime.md b/docs/zh/reference/agent-runtime.md index 85648c4b..fecc5ab8 100644 --- a/docs/zh/reference/agent-runtime.md +++ b/docs/zh/reference/agent-runtime.md @@ -107,6 +107,8 @@ workspace defaults - 文件、知识库、Skill 和 MCP 工具由后端策略筛选。 - 事件流可用于展示和回放,不携带敏感明文。 +第一版控制面已改为对接独立 Deep Agents runtime。客户端仍走 `POST /api/copilotkit`。Runtime 实现方请阅读 [Deep Agents Runtime 接入契约](deep-agents-runtime.md) 与 [v1 能力边界](deep-agents-runtime-boundary.md)。 + ## 延伸阅读 - 配置资源:[配置 API 参考](configuration-api.md) diff --git a/docs/zh/reference/deep-agents-runtime-boundary.md b/docs/zh/reference/deep-agents-runtime-boundary.md new file mode 100644 index 00000000..d0036bfe --- /dev/null +++ b/docs/zh/reference/deep-agents-runtime-boundary.md @@ -0,0 +1,166 @@ +# Deep Agents Runtime v1 能力边界 + +这份文档给 **runtime 实现方** 确认边界:什么必须具备、现在已经具备、哪些是遗留、哪些明确不做。协议字段与事件形状以 [接入契约](deep-agents-runtime.md) 为准。本文是 2026-09-01 的能力快照,不替代契约。 + +读者只需要回答三件事: + +1. 边界内的「必须具备」是否认账。 +2. 「遗留项」里哪些由 runtime 收口,哪些由控制面收口。 +3. 「明确不做」是否同意,避免下一期能力被提前塞进 v1。 + +## 分工 + +| 角色 | 负责 | 不负责 | +| --- | --- | --- | +| 控制面 (`apps/api`) | 会话、鉴权、整理 `messages` / `systemPrompt`、事件落库与投影、HITL 传输、取消编排、前端 / TUI | 不执行 LangGraph,不持有 runtime checkpoint 内部结构 | +| Runtime (`services/deepagents-runtime`) | 跑 Deep Agents / LangGraph、发 AG-UI SSE、工具执行、中断与恢复、取消、不透明 `checkpointRef` | 不感知 DataFoundry 元数据、数据网关、SQL 审计、artifact、知识库、Skill | +| 客户端 (Web / TUI) | 渲染 AG-UI 事件与会话回放 | 不直连 runtime HTTP | + +未配置 `RUNTIME_SERVICE_URL` 时,控制面回退到进程内 TypeScript 桩。`npm run dev` 会拉起 Python runtime(默认 `:8790`)并注入该 URL。 + +## v1 必须具备 + +这些是 runtime 对控制面的承诺。缺一项,前端或控制面就不能按当前协议工作。 + +### 传输 + +| 能力 | 约定 | +| --- | --- | +| 存活探测 | `GET /health`,声明 `provider`、`version`、`capabilities` | +| 流式 run | `POST /runs/stream`,SSE,每条 `data:` 是一个 AG-UI `BaseEvent` | +| 取消 | `POST /runs/:runId/cancel`,尽快停图;随后发带 `status: "cancelled"` 的 `RUN_FINISHED` | +| 鉴权 | 可校验 `Authorization: Bearer ` | +| 密钥隔离 | 请求里没有模型 Key、数据库密码、MCP Token | + +### 事件与身份 + +AG-UI `toolCallId` **等于模型 `tool_calls[].id`**(例如 `call_xxx`),不等于 LangGraph / LangChain 执行层 `run_id`(UUID)。 + +| 规则 | 原因 | +| --- | --- | +| `on_chat_model_stream` / `on_chat_model_end` 用模型 id 发 `TOOL_CALL_START` / `ARGS` | 前端与控制面只认这一套 id | +| `on_tool_start` 只能绑定已有模型 id,不得再 `START` | 否则同一次调用会出现两条「运行中」,`RUN_FINISHED` 会被 AG-UI 拒掉 | +| 同名并行按 FIFO 对齐 | 两个 `write_todos` 必须分别对应 `call_a`、`call_b` | +| 仅当没有未绑定的模型 id 时,才允许用执行层 id 开新调用 | 兼容「模型没带 id」的退化路径 | +| `on_tool_start` 不得重放模型已经发过的 ARGS | 重复 delta 会拼坏 JSON,CopilotKit 关不掉工具卡 | +| `TOOL_CALL_RESULT` 必须带 `messageId` 和 `role: "tool"` | CopilotKit 靠这条生成 tool 消息;缺了前端会一直「运行中」 | +| 图跑完仍有未结束的 `toolCallId` 时发 `RUN_ERROR`(`UNFINISHED_TOOL_CALLS:…`),禁止补 `TOOL_CALL_END` | 补 END 会掩盖真实中断或丢结果 | +| 工具前后的文本用独立 `messageId`,不要对已 `TEXT_MESSAGE_END` 的 id 再 `START` | 避免校验失败、回复被丢掉 | + +### 对话与工具 + +| 能力 | v1 范围 | +| --- | --- | +| 流式文本 | `TEXT_MESSAGE_START` / `CONTENT` / `END` | +| 同 `threadId` 多轮 | 控制面带上历史 `messages`;runtime 用自己的 checkpointer | +| `write_todos` | Deep Agents Todo 中间件,已作为第一支工具验收 | +| `ask_user` | 唯一正式接入的 HITL 工具,`interrupt_on.ask_user` | +| SDK 自带 filesystem(如 `glob`) | **不是** DataFoundry 数据工具。真实模型会调用。控制面不治理路径与权限,只当普通 AG-UI 工具展示 | + +### HITL + +中断时发 CUSTOM `on_interrupt`,`value.type = "agent_interrupt"`。`toolName` 当前实现只保证 `ask_user`。 + +恢复时控制面再次 `POST /runs/stream`,**复用原 `runId`**,带 `resume.interrupt` 与 `resume.response`。`response === false` 表示用户取消该中断。 + +旧 `mastra_suspend` 只用于历史回放,runtime 必须拒绝续跑。 + +### 持久化与回放 + +- Runtime 只维护自己的 checkpoint,经 `runtime.bound` 回传不透明 `checkpointRef`。 +- 对话历史、工具结果、checkpoint 状态由**控制面**落库。 +- 刷新页面后的回放走控制面会话 API,不要求 runtime 重放 SSE。 + +## 已接入并验收 + +2026-09-01 用真实模型 + Web(`http://127.0.0.1:3000`)和控制面(`:8787`)跑过。Runtime 单测:`services/deepagents-runtime` 下 25 passed。 + +| 场景 | 结果 | +| --- | --- | +| 纯对话 | 流式文本,`RUN_FINISHED` | +| 单次 `write_todos` | 一条 `call_*`,RESULT 到达前端,工具卡结束 | +| 同名三次 `glob` | 三个不同模型 id,全部 `completed`,成功率 100% | +| `ask_user` 中断后点「继续」 | 弹出协作卡,恢复后有收尾文本,checkpoint `completed` | +| 同会话追问 | HITL 之后纯对话不再挂起 | +| 流式中点停止 | `POST .../cancel` 200;控制面 checkpoint `canceled` | +| 刷新回放已完成的工具会话 | 一条 `write_todos` + 原文回复,不再「运行中」 | +| Schema / SQL 类提示 | 不崩。没有 `inspect_schema` / 真实查库;模型可能改调 filesystem | + +假模型路径(`DEEPAGENTS_RUNTIME_MODEL=fake`)覆盖对话、`write_todos`、`ask_user` 中断与恢复,见 `npm run smoke:deepagents-sdk`。 + +## 遗留项 + +边界内已承诺、但尚未收口。请 runtime 标出责任方。 + +| 编号 | 现象 | 建议责任 | 说明 | +| --- | --- | --- | --- | +| L1 | HITL 恢复后,会话 DTO 里 `pendingInteractions` 与 `ask_user` 仍可能是 `pending` | 控制面为主 | 页面已走完且 checkpoint 为 `completed`。恢复路径要能把工具标成完成;runtime 恢复后应再发该 `toolCallId` 的 `TOOL_CALL_RESULT` | +| L2 | 契约写了 `submit_plan`,runtime 未把它放进 `interrupt_on` | Runtime | `write_todos` 中断映射到 `submit_plan` 只存在于事件翻译,真实图不会因 todo 挂起 | +| L3 | HITL 点「停止」/ `response === false` 未做前端验收 | 双方 | 代码路径在,缺真实点击证据 | +| L4 | 用户取消后,控制面 `terminalEvent` 可能记成 `RUN_ERROR`,status 为 `canceled` | 控制面记录,runtime 对齐 | Runtime 应发 `RUN_FINISHED` + `status: "cancelled"`,不要只断流 | +| L5 | 工具前若已结束一段文本,再用同一个 `msg_{runId}` 开下一段 | Runtime | 当前验收的工具回合没有这段前导文本,风险仍在 | +| L6 | `./deploy.sh` 尚未自动拉起 Python runtime | 控制面 / 部署 | 本地 `npm run dev` 已接入;部署形态未对齐 | +| L7 | SDK filesystem 工具对控制面不可治理 | 需 runtime 确认 | 真实模型会 `glob`。v1 允许当普通工具展示,还是应在 runtime 关掉,需要书面确认 | + +不要用这些方式「修」L 系列:按工具名去重、在 `RUN_FINISHED` 前补 `END`、关掉 AG-UI 校验、前端合并同名 running 卡片。那些会掩盖身份错误。 + +## 明确不做(不是缺陷) + +v1 **不**把下列能力算进 runtime 边界。前端相应面板显示「本版本未启用」或「后端暂不支持」。 + +- DataFoundry 数据工具:查 Schema、只读 SQL、数据源探测 +- SQL 审计、artifact、workspace / sandbox 元数据 +- 知识库检索、Skill、MCP、目标 / 记忆 / protocol 门控 +- 控制面不得把数据源凭据或业务策略下沉到 runtime +- 旧 Mastra 中断不能在新 runtime 上 resume + +用户问「展示数据源中的表」时,runtime 可以拒绝、用 filesystem 空转,或用文本说明未接入。控制面不期望出现 `inspect_schema` / `run_sql` 的真实结果。 + +## Runtime 应具备的能力(确认清单) + +请按「必须 / 应当 / 禁止」签字或回注。 + +### 必须 + +- [ ] 实现契约三个 HTTP 端点,SSE 为 AG-UI `BaseEvent` +- [ ] `toolCallId` 使用模型 `tool_calls[].id`;执行层 id 只做绑定 +- [ ] `TOOL_CALL_RESULT` 含 `toolCallId`、`toolCallName`、`content`、`messageId`、`role: "tool"` +- [ ] 支持流式文本、同线程多轮 +- [ ] 支持 `ask_user` 中断与按原 `runId` 恢复 +- [ ] 支持 `POST /runs/:runId/cancel`,并以 `RUN_FINISHED`(`cancelled`)收尾 +- [ ] `runtime.bound` 带回不透明 `checkpointRef` +- [ ] 拒绝 `mastra_suspend` 续跑 +- [ ] 不接收、不索要数据面凭据 + +### 应当 + +- [ ] 同名并行 FIFO +- [ ] 未结束工具在无 interrupt 时 `RUN_ERROR`,不补 `END` +- [ ] 无 Key 时可用假模型走同一条 LangGraph 路径 +- [ ] 遵守控制面下发的 `systemPrompt` 与 `limits.maxSteps` +- [ ] 书面确认是否保留 SDK filesystem 工具(见 L7) + +### 禁止 + +- [ ] 用 LangGraph `run_id` 当作新的 AG-UI `toolCallId` +- [ ] 为通过校验而补发 `TOOL_CALL_END` +- [ ] 覆盖或改写控制面 `systemPrompt` 里的业务策略 +- [ ] 把 `artifact` / `sql_audit` / `skill.selection` 当成 v1 必发事件 +- [ ] 在 runtime 内直连生产库或 DataFoundry 元数据库 + +## 下一期(不在本次边界) + +下列能力要单独立项,不要 quietly 扩进 v1 契约: + +1. 经控制面工具网关接入数据工具(权限、审计、`ToolObservation`) +2. SQL 审计、artifact、workspace 信号 +3. 正式 `submit_plan` HITL +4. 部署脚本拉起 Python runtime +5. 清理 HITL 恢复后的 pending 投影(L1) + +## 相关文档 + +- [Deep Agents Runtime 接入契约](deep-agents-runtime.md) — 字段与事件协议 +- [Agent Runtime 与 AG-UI 参考](agent-runtime.md) — 客户端如何调控制面 +- 实现:`services/deepagents-runtime/` +- 控制面客户端:`apps/api/src/runtime/` diff --git a/docs/zh/reference/deep-agents-runtime.md b/docs/zh/reference/deep-agents-runtime.md new file mode 100644 index 00000000..e9507bf2 --- /dev/null +++ b/docs/zh/reference/deep-agents-runtime.md @@ -0,0 +1,175 @@ +# Deep Agents Runtime 接入契约 + +这篇文档是 DataFoundry 控制面与独立 Agent Runtime 服务之间的唯一界面。前端、TUI 和 REST 不感知 runtime 实现;runtime 不感知 DataFoundry 内部服务。 + +第一版只打通对话、流式、取消、HITL 与历史持久化。数据工具、SQL 审计、artifact 产出、语义治理与 Skill 均不接入。 + +## 端点 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/health` | Runtime 存活与能力声明 | +| POST | `/runs/stream` | 启动或恢复一次 run,返回 AG-UI SSE | +| POST | `/runs/:runId/cancel` | 取消正在执行的 run | + +默认本地服务:`http://127.0.0.1:8790`,由 `services/deepagents-runtime` 用 Deep Agents SDK(`create_deep_agent`)实现。`npm run dev` 会拉起该进程并写入 `RUNTIME_SERVICE_URL`。未配置 URL 时,API 回退到进程内 TypeScript 桩。独立启动:`npm run runtime:deepagents`。旧桩仍可通过 `npm run runtime:stub` 使用。 + +内部调用可带 `Authorization: Bearer `。不要把数据库凭据或模型密钥放进 run 请求。 + +## `GET /health` + +```json +{ + "status": "ok", + "provider": "deepagents", + "version": "v1", + "capabilities": { + "streaming": true, + "tools": true, + "interrupt": true, + "cancel": true + } +} +``` + +`status` 为 `ok` 或 `degraded`。控制面 `/ready` 会把该结果放进 `runtime` 字段;runtime 不可用时 REST 与历史回放仍可服务。 + +## `POST /runs/stream` + +请求体为 `RuntimeRunRequest`: + +```json +{ + "threadId": "session-001", + "runId": "run-001", + "messages": [ + { "id": "m1", "role": "user", "content": "你好" } + ], + "systemPrompt": "You are DataFoundry's assistant. Data tools are not connected in this version.", + "model": { + "profileId": "server-default", + "name": "qwen-plus", + "provider": "openai-compatible" + }, + "limits": { + "maxSteps": 80 + }, + "resume": { + "interrupt": { + "type": "agent_interrupt", + "toolCallId": "call_ask_1", + "toolName": "ask_user", + "runId": "run-001" + }, + "response": { "answer": "继续" } + }, + "checkpointRef": "opaque-runtime-checkpoint", + "trace": { + "userId": "user-1", + "workspaceId": "ws-1" + } +} +``` + +| 字段 | 必填 | 说明 | +| --- | --- | --- | +| `threadId` | 是 | 会话 ID,对应控制面 session | +| `runId` | 是 | 单次 run ID;resume 必须复用挂起时的 runId | +| `messages` | 是 | 服务端整理后的对话历史与本轮输入 | +| `systemPrompt` | 是 | 由控制面构建,runtime 不得自行拼接业务指令 | +| `model` | 否 | 模型选择元数据,不含 API Key | +| `limits` | 否 | 步数等软限制 | +| `resume` | 否 | HITL 恢复;`response === false` 表示取消该中断 | +| `checkpointRef` | 否 | runtime 私有 checkpoint 的不透明引用 | +| `trace` | 否 | 仅用于日志,不含凭据 | + +响应为 `text/event-stream`,每条 `data:` 行是一个 AG-UI `BaseEvent` JSON。 + +## 事件 + +Runtime 必须发出标准生命周期、文本、reasoning 与 tool call 事件: + +- `RUN_STARTED` / `RUN_FINISHED` / `RUN_ERROR` +- `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` / `TEXT_MESSAGE_END` +- `TOOL_CALL_START` / `TOOL_CALL_ARGS` / `TOOL_CALL_END` / `TOOL_CALL_RESULT` +- 可选 reasoning / activity 事件 + +控制面是唯一事件序列器:它会落库、投影并转发给前端。Runtime 只发自己产生的事件。 + +建议额外发一条 CUSTOM 事件,便于控制面识别来源: + +```json +{ + "type": "CUSTOM", + "name": "runtime.bound", + "value": { + "provider": "deepagents", + "version": "v1", + "checkpointRef": "opaque-runtime-checkpoint" + } +} +``` + +第一版不要求 `artifact`、`sql_audit`、`workspace.metadata`、`sandbox.output`、`skill.selection`、`goal.updated`、`context.compiled`。前端会把这些面板显示为「本版本未启用」。 + +## HITL + +中断时发 CUSTOM `on_interrupt`,value 使用中性结构: + +```json +{ + "type": "agent_interrupt", + "toolCallId": "call_ask_1", + "toolName": "ask_user", + "runId": "run-001", + "args": { "question": "需要我继续吗?", "options": ["继续", "停止"] }, + "suspendPayload": { "question": "需要我继续吗?", "options": ["继续", "停止"] }, + "resumeSchema": { "type": "object" } +} +``` + +`toolName` 目前支持 `ask_user` 与 `submit_plan`。控制面会补齐 `interaction.requested`、必要的 `TOOL_CALL_START/END`,并向客户端发一条仅用于传输的 `RUN_FINISHED`。 + +恢复时控制面再次调用 `/runs/stream`,带上原 `runId` 与 `resume`。旧 Mastra 会话的 `mastra_suspend` 仅用于只读回放,不能在新 runtime 上续跑。 + +## 取消 + +`POST /runs/:runId/cancel` 请求体: + +```json +{ "reason": "RUN_CANCELLED" } +``` + +Runtime 应尽快停止执行。控制面随后把 run 标为 canceled。 + +## 第一版验证 + +Python runtime 直接调用 `create_deep_agent`。未配置 `LLM_API_KEY` 时默认用脚本化模型走通同一条 LangGraph 路径(对话、`write_todos`、`ask_user` HITL)。配置了 Key 后走 `LLM_BASE_URL` / `LLM_MODEL` 的 OpenAI 兼容接口。验证命令: + +```bash +cd services/deepagents-runtime && uv sync +npm run smoke:deepagents-sdk +``` + +## 桩服务场景 + +独立桩 `npm run runtime:stub` 根据用户文本或 `forwardedProps.runtimeStubScenario` 选择: + +| 场景 | 触发 | 行为 | +| --- | --- | --- | +| `text` | 默认 | 流式文本回复后结束 | +| `tool` | 文本含 `tool` / `plan` | 发出一次 `write_todos` 工具调用 | +| `interrupt` | 文本含 `interrupt` / `ask` | 发出 `ask_user` 中断 | + +恢复中断后,桩会发出 `TOOL_CALL_RESULT` 与收尾文本。 + +## 安全 + +- 不传数据库密码、模型 API Key、MCP Token。 +- Runtime 实验期只用 demo 或本地数据,不连生产库。 +- 数据工具接入留到下一期再议。 + +## 延伸阅读 + +- [v1 能力边界](deep-agents-runtime-boundary.md) — 已接入、遗留项、明确不做,以及 runtime 确认清单 +- [Agent Runtime 与 AG-UI 参考](agent-runtime.md) — 客户端如何调用控制面 diff --git a/docs/zh/reference/rest-api.md b/docs/zh/reference/rest-api.md index bb74939e..d7c2b024 100644 --- a/docs/zh/reference/rest-api.md +++ b/docs/zh/reference/rest-api.md @@ -72,7 +72,7 @@ Web v1 不暴露 workspace 切换;自建集成除非自行管理 workspace 路 | Method | Path | 用途 | | --- | --- | --- | | GET | `/healthz` | 进程存活(liveness)。 | -| GET | `/ready` | 就绪探针:Mastra / builtin 初始化完成;响应含 `startup_ms` 与 `phases`。 | +| GET | `/ready` | 控制面就绪探针;响应含 `startup_ms`、`phases` 与独立 runtime 健康状态。 | | GET | `/api/v1/capabilities` | 读取后端能力开关。 | | GET | `/api/v1/me` | 读取当前身份。 | diff --git a/package-lock.json b/package-lock.json index e43a03ca..a074d85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,7 +51,6 @@ "version": "0.2.0", "dependencies": { "@ag-ui/client": "^0.0.57", - "@ag-ui/mastra": "^1.0.3", "@copilotkit/runtime": "0.0.0-mme-ag-ui-0-0-46-20260227141603", "@datafoundry/agent-runtime": "0.2.0", "@datafoundry/contracts": "0.2.0", diff --git a/package.json b/package.json index 11c615b2..fa7ad032 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,10 @@ "smoke:server-datasources": "npm run build && node scripts/smoke-server-datasources-e2e.mjs", "smoke:sql": "npm run build && node scripts/smoke-sql-readonly.mjs", "smoke:agent": "npm run build && node scripts/smoke-agent-runtime.mjs", + "runtime:stub": "npm run build && node scripts/start-runtime-stub.mjs", + "runtime:deepagents": "node scripts/start-deepagents-runtime.mjs", + "smoke:deepagents-runtime": "npm run build && node scripts/smoke-deepagents-runtime.mjs", + "smoke:deepagents-sdk": "node scripts/smoke-deepagents-sdk.mjs", "smoke:agent-protocol-deepseek": "npm run build && node scripts/smoke-agent-protocol-deepseek.mjs", "smoke:trace-sections": "npm run build && node scripts/smoke-trace-sections.mjs", "eval:dacomp6": "node scripts/run-dacomp6-complex-case.mjs", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 717b7fd4..06d0619d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -376,7 +376,17 @@ export const ENV_VARIABLE_SPECS: EnvVariableSpec[] = [ }, { name: "SQL_DEFAULT_LIMIT", required: false, default_value: "100", description: "Default read-only SQL row limit." }, { name: "SQL_MAX_LIMIT", required: false, default_value: "1000", description: "Maximum read-only SQL row limit." }, - { name: "SQL_TIMEOUT_MS", required: false, default_value: "10000", description: "Read-only SQL timeout in ms." } + { name: "SQL_TIMEOUT_MS", required: false, default_value: "10000", description: "Read-only SQL timeout in ms." }, + { + name: "RUNTIME_SERVICE_URL", + required: false, + description: "Independent Deep Agents runtime base URL. Empty uses the in-process TypeScript stub." + }, + { + name: "RUNTIME_SERVICE_TOKEN", + required: false, + description: "Optional bearer token for the independent runtime service." + } ]; export const createEnvConfig = (env: Record): EnvConfig => ({ diff --git a/scripts/dev.mjs b/scripts/dev.mjs index 995bccfb..c5e41d6c 100755 --- a/scripts/dev.mjs +++ b/scripts/dev.mjs @@ -1,119 +1,13 @@ #!/usr/bin/env node /** - * Build workspace packages and start API + web dev servers (Linux, Windows, macOS). + * Build workspace packages and start API + web + Deep Agents runtime. * * Usage: - * npm run dev # start both - * npm run dev -- --api # API only + * npm run dev # start API, web, and runtime + * npm run dev -- --api # API + runtime * npm run dev -- --web # web only + * npm run dev -- --no-runtime */ -import { spawn, execSync } from "node:child_process"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; +import { runStack } from "./stack-runner.mjs"; -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const args = process.argv.slice(2); -const apiOnly = args.includes("--api"); -const webOnly = args.includes("--web"); -const startApi = !webOnly || apiOnly; -const startWeb = !apiOnly || webOnly; - -execSync("node scripts/ensure-dev-environment.mjs", { - cwd: root, - stdio: "inherit", - env: process.env, - shell: true, -}); - -for (const port of [8787, 3000]) { - try { - freePort(port); - } catch { - // Port may already be free; dev servers will fail loudly if it stays busy. - } -} - -/** @type {import('node:child_process').ChildProcess[]} */ -const children = []; - -if (startApi) { - children.push(spawnNpm(["--workspace", "@datafoundry/api", "run", "dev"])); -} -if (startWeb) { - children.push(spawnNpm(["--workspace", "@datafoundry/web", "run", "dev"])); -} - -if (children.length === 0) { - console.error("Nothing to start. Use --api and/or --web."); - process.exit(1); -} - -console.log( - "\n[dev] " + - (startApi ? "API → http://127.0.0.1:8787 " : "") + - (startWeb ? "Web → http://localhost:3000/data-tasks" : "") + - "\n", -); - -function shutdown(signal) { - for (const child of children) { - if (!child.killed) child.kill(signal); - } -} - -process.on("SIGINT", () => shutdown("SIGINT")); -process.on("SIGTERM", () => shutdown("SIGTERM")); - -for (const child of children) { - child.on("exit", (code, signal) => { - if (signal) return; - if (code && code !== 0) { - shutdown("SIGTERM"); - process.exit(code); - } - }); -} - -function spawnNpm(args) { - return spawn("npm", args, { - cwd: root, - stdio: "inherit", - env: process.env, - shell: true, - }); -} - -function freePort(port) { - if (process.platform === "win32") { - let output = ""; - try { - output = execSync(`netstat -ano | findstr :${port}`, { - encoding: "utf8", - shell: true, - stdio: ["ignore", "pipe", "ignore"], - }); - } catch { - return; - } - - const pids = new Set(); - for (const line of output.split(/\r?\n/u)) { - if (!/\bLISTENING\b/u.test(line)) continue; - const pid = line.trim().split(/\s+/u).at(-1); - if (pid && /^\d+$/u.test(pid) && pid !== "0") { - pids.add(pid); - } - } - - for (const pid of pids) { - execSync(`taskkill /F /PID ${pid}`, { stdio: "ignore", shell: true }); - } - return; - } - - execSync(`fuser -k ${port}/tcp 2>/dev/null || true`, { - cwd: root, - stdio: "ignore", - shell: true, - }); -} +await runStack({ mode: "development", args: process.argv.slice(2) }); diff --git a/scripts/smoke-deepagents-runtime.mjs b/scripts/smoke-deepagents-runtime.mjs new file mode 100644 index 00000000..eee866e7 --- /dev/null +++ b/scripts/smoke-deepagents-runtime.mjs @@ -0,0 +1,74 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { createRuntimeStubServer } from "../apps/api/dist/runtime/stub-server.js"; +import { HttpRuntimeClient } from "../apps/api/dist/runtime/client.js"; +import { createInProcessRuntime } from "../apps/api/dist/runtime/in-process.js"; + +const eventsOf = async (iterable) => { + const events = []; + for await (const event of iterable) { + events.push(event); + } + return events; +}; + +const inProcess = createInProcessRuntime(); +const health = await inProcess.health(); +assert.equal(health.status, "ok"); + +const textEvents = await eventsOf(inProcess.startRun({ + threadId: "s1", + runId: "r-text", + messages: [{ id: "m1", role: "user", content: "你好" }], + systemPrompt: "test" +})); +assert.ok(textEvents.some((event) => event.type === "TEXT_MESSAGE_CONTENT")); +assert.ok(textEvents.some((event) => event.type === "RUN_FINISHED")); +assert.ok(textEvents.some((event) => event.name === "runtime.bound")); + +const toolEvents = await eventsOf(inProcess.startRun({ + threadId: "s1", + runId: "r-tool", + messages: [{ id: "m1", role: "user", content: "make a plan" }], + systemPrompt: "test" +})); +assert.ok(toolEvents.some((event) => event.type === "TOOL_CALL_START" && event.toolCallName === "write_todos")); + +const interruptEvents = await eventsOf(inProcess.startRun({ + threadId: "s1", + runId: "r-ask", + messages: [{ id: "m1", role: "user", content: "please interrupt" }], + systemPrompt: "test" +})); +const interrupt = interruptEvents.find((event) => event.name === "on_interrupt"); +assert.equal(interrupt?.value?.type, "agent_interrupt"); + +const resumeEvents = await eventsOf(inProcess.startRun({ + threadId: "s1", + runId: "r-ask", + messages: [{ id: "m1", role: "user", content: "please interrupt" }], + systemPrompt: "test", + resume: { + interrupt: interrupt.value, + response: { answer: "继续" } + } +})); +assert.ok(resumeEvents.some((event) => event.type === "TOOL_CALL_RESULT")); + +const server = createRuntimeStubServer(); +await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); +const address = server.address(); +const client = new HttpRuntimeClient({ url: `http://127.0.0.1:${address.port}` }); +const remoteHealth = await client.health(); +assert.equal(remoteHealth.status, "ok"); +const remoteEvents = await eventsOf(client.startRun({ + threadId: "s2", + runId: "r-http", + messages: [{ id: "m1", role: "user", content: "hello" }], + systemPrompt: "test" +})); +assert.ok(remoteEvents.some((event) => event.type === "RUN_FINISHED")); +await client.cancelRun("r-http"); +server.close(); + +console.log("smoke-deepagents-runtime: ok"); diff --git a/scripts/smoke-deepagents-sdk.mjs b/scripts/smoke-deepagents-sdk.mjs new file mode 100644 index 00000000..6fc39485 --- /dev/null +++ b/scripts/smoke-deepagents-sdk.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createServer } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const serviceDir = join(root, "services", "deepagents-runtime"); + +const parseSse = (text) => { + const events = []; + for (const frame of text.split("\n\n")) { + const line = frame.trim(); + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (!payload || payload === "[DONE]") continue; + events.push(JSON.parse(payload)); + } + return events; +}; + +const readResponse = async (response) => { + const text = Buffer.from(await response.arrayBuffer()).toString("utf8"); + return { ok: response.ok, status: response.status, events: parseSse(text), text }; +}; + +const freePort = async () => { + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +}; + +const waitForHealth = async (url, token) => { + for (let attempt = 0; attempt < 40; attempt += 1) { + try { + const response = await fetch(`${url}/health`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.ok) { + return await response.json(); + } + } catch { + // process still starting + } + await delay(250); + } + throw new Error("DEEPAGENTS_RUNTIME_HEALTH_TIMEOUT"); +}; + +const port = await freePort(); +const token = process.env.RUNTIME_SERVICE_TOKEN; +const child = spawn("uv", ["run", "deepagents-runtime"], { + cwd: serviceDir, + env: { + ...process.env, + DEEPAGENTS_RUNTIME_MODEL: "fake", + RUNTIME_HOST: "127.0.0.1", + RUNTIME_PORT: String(port), + }, + stdio: ["ignore", "pipe", "pipe"], + shell: process.platform === "win32", +}); + +let stderr = ""; +child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); +}); + +try { + const url = `http://127.0.0.1:${port}`; + const health = await waitForHealth(url, token); + assert.equal(health.status, "ok"); + assert.equal(health.provider, "deepagents"); + + const headers = { + Accept: "text/event-stream", + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const textResponse = await fetch(`${url}/runs/stream`, { + method: "POST", + headers, + body: JSON.stringify({ + threadId: "smoke-sdk", + runId: "smoke-text", + messages: [{ id: "m1", role: "user", content: "你好" }], + systemPrompt: "test", + }), + }); + const textResult = await readResponse(textResponse); + assert.equal(textResult.ok, true, `text stream failed: ${textResult.status} ${textResult.text.slice(0, 200)}`); + assert.ok(textResult.events.some((event) => event.type === "TEXT_MESSAGE_CONTENT")); + assert.ok(textResult.events.some((event) => event.type === "RUN_FINISHED")); + assert.ok(textResult.events.some((event) => event.name === "runtime.bound")); + + const interruptResponse = await fetch(`${url}/runs/stream`, { + method: "POST", + headers, + body: JSON.stringify({ + threadId: "smoke-sdk-ask", + runId: "smoke-ask", + messages: [{ id: "m1", role: "user", content: "please interrupt" }], + systemPrompt: "test", + }), + }); + const interruptResult = await readResponse(interruptResponse); + const interrupt = interruptResult.events.find((event) => event.name === "on_interrupt"); + assert.equal(interrupt?.value?.type, "agent_interrupt"); + + const resumeResponse = await fetch(`${url}/runs/stream`, { + method: "POST", + headers, + body: JSON.stringify({ + threadId: "smoke-sdk-ask", + runId: "smoke-ask", + messages: [{ id: "m1", role: "user", content: "please interrupt" }], + systemPrompt: "test", + resume: { + interrupt: interrupt.value, + response: { answer: "继续" }, + }, + }), + }); + const resumeResult = await readResponse(resumeResponse); + assert.ok(resumeResult.events.some((event) => event.type === "RUN_FINISHED")); + + console.log("smoke-deepagents-sdk: ok"); +} catch (error) { + console.error(stderr); + throw error; +} finally { + child.kill("SIGTERM"); +} diff --git a/scripts/stack-runner.mjs b/scripts/stack-runner.mjs index 06bf90cf..7cdbf00f 100644 --- a/scripts/stack-runner.mjs +++ b/scripts/stack-runner.mjs @@ -35,6 +35,22 @@ export async function runStack({ mode, args = [] }) { } const children = []; + const startRuntime = startApi && !args.includes("--no-runtime") && !runtimeConfig.RUNTIME_SERVICE_URL; + if (startRuntime) { + if (mode === "development") { + freePort(Number(runtimeConfig.RUNTIME_PORT)); + } + const runtimeProcess = spawnDeepagentsRuntime(runtimeConfig, process.env); + if (runtimeProcess) { + children.push(runtimeProcess); + runtimeConfig.RUNTIME_SERVICE_URL = `http://${runtimeConfig.RUNTIME_HOST}:${runtimeConfig.RUNTIME_PORT}`; + await waitForRuntimeHealth( + runtimeConfig.RUNTIME_SERVICE_URL, + process.env.RUNTIME_SERVICE_TOKEN, + ); + } + } + if (startApi) { const command = mode === "development" @@ -60,7 +76,11 @@ export async function runStack({ mode, args = [] }) { throw new Error("Nothing to start. Use --api and/or --web."); } - console.log(formatStackEndpoints(runtimeConfig, { startApi, startWeb })); + console.log(formatStackEndpoints(runtimeConfig, { + startApi, + startWeb, + startRuntime: Boolean(runtimeConfig.RUNTIME_SERVICE_URL) && startApi && !args.includes("--no-runtime"), + })); let shuttingDown = false; const shutdown = (signal) => { if (shuttingDown) return; @@ -87,9 +107,9 @@ function loadRootEnv() { if (existsSync(envPath)) loadEnvFile(envPath); } -function spawnProcess(label, command, args, env) { +function spawnProcess(label, command, args, env, cwd = root) { const child = spawn(command, args, { - cwd: root, + cwd, stdio: "inherit", env, shell: process.platform === "win32", @@ -98,6 +118,44 @@ function spawnProcess(label, command, args, env) { return { child, label }; } +async function waitForRuntimeHealth(url, token, timeoutMs = 180000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(`${url}/health`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (response.ok) { + return true; + } + } catch { + // process still starting or uv is syncing + } + await new Promise((resolve) => setTimeout(resolve, 500)); + } + console.warn(`[stack] Deep Agents runtime did not become healthy at ${url}; API will still start.`); + return false; +} + +function spawnDeepagentsRuntime(config, env) { + const serviceDir = join(root, "services", "deepagents-runtime"); + if (!existsSync(join(serviceDir, "pyproject.toml"))) { + console.warn("[stack] Deep Agents runtime package missing; API will use the in-process stub."); + return null; + } + return spawnProcess( + "Deep Agents Runtime", + "uv", + ["run", "deepagents-runtime"], + { + ...env, + RUNTIME_HOST: config.RUNTIME_HOST, + RUNTIME_PORT: config.RUNTIME_PORT, + }, + serviceDir, + ); +} + function freePort(port) { try { if (process.platform === "win32") { diff --git a/scripts/stack-runtime-config.mjs b/scripts/stack-runtime-config.mjs index 7b8540e8..40944ff0 100644 --- a/scripts/stack-runtime-config.mjs +++ b/scripts/stack-runtime-config.mjs @@ -7,11 +7,15 @@ function port(value, fallback, name) { } export function resolveStackRuntimeConfig(env = process.env) { + const runtimeUrl = env.RUNTIME_SERVICE_URL?.trim(); return { API_HOST: env.API_HOST?.trim() || "127.0.0.1", API_PORT: port(env.API_PORT, 8787, "API_PORT"), WEB_HOST: env.WEB_HOST?.trim() || "127.0.0.1", - WEB_PORT: port(env.WEB_PORT, 3000, "WEB_PORT") + WEB_PORT: port(env.WEB_PORT, 3000, "WEB_PORT"), + RUNTIME_HOST: env.RUNTIME_HOST?.trim() || env.RUNTIME_SERVICE_HOST?.trim() || "127.0.0.1", + RUNTIME_PORT: port(env.RUNTIME_PORT || env.RUNTIME_SERVICE_PORT, 8790, "RUNTIME_PORT"), + ...(runtimeUrl ? { RUNTIME_SERVICE_URL: runtimeUrl.replace(/\/+$/, "") } : {}) }; } @@ -23,5 +27,8 @@ export function formatStackEndpoints(config, enabled) { const lines = ["DataFoundry endpoints:"]; if (enabled.startWeb) lines.push(` Web: http://127.0.0.1:${config.WEB_PORT}`); if (enabled.startApi) lines.push(` API: http://${config.API_HOST}:${config.API_PORT}`); + if (enabled.startRuntime) { + lines.push(` Runtime: ${config.RUNTIME_SERVICE_URL || `http://${config.RUNTIME_HOST}:${config.RUNTIME_PORT}`}`); + } return lines.join("\n"); } diff --git a/scripts/stack-runtime-config.test.mjs b/scripts/stack-runtime-config.test.mjs index a846bc3a..db9d75ee 100644 --- a/scripts/stack-runtime-config.test.mjs +++ b/scripts/stack-runtime-config.test.mjs @@ -26,6 +26,13 @@ test("prints actual configured endpoints", () => { assert.match(output, /http:\/\/127\.0\.0\.1:3310/); }); +test("prints the Deep Agents runtime endpoint when enabled", () => { + const config = resolveStackRuntimeConfig({ RUNTIME_SERVICE_PORT: "8791" }); + assert.equal(config.RUNTIME_PORT, "8791"); + const output = formatStackEndpoints(config, { startApi: true, startWeb: false, startRuntime: true }); + assert.match(output, /http:\/\/127\.0\.0\.1:8791/); +}); + test("rejects invalid ports", () => { assert.throws(() => resolveStackRuntimeConfig({ WEB_PORT: "70000" }), /WEB_PORT/); }); diff --git a/scripts/start-deepagents-runtime.mjs b/scripts/start-deepagents-runtime.mjs new file mode 100644 index 00000000..3f9c1a7b --- /dev/null +++ b/scripts/start-deepagents-runtime.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const serviceDir = join(root, "services", "deepagents-runtime"); + +if (!existsSync(join(serviceDir, "pyproject.toml"))) { + console.error("[deepagents-runtime] missing services/deepagents-runtime/pyproject.toml"); + process.exit(1); +} + +const child = spawn("uv", ["run", "deepagents-runtime"], { + cwd: serviceDir, + env: process.env, + stdio: "inherit", + shell: process.platform === "win32", +}); + +child.on("error", (error) => { + console.error(`[deepagents-runtime] unable to start: ${error.message}`); + console.error("Install uv, then run: cd services/deepagents-runtime && uv sync"); + process.exit(1); +}); + +child.on("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); +}); diff --git a/scripts/start-runtime-stub.mjs b/scripts/start-runtime-stub.mjs new file mode 100644 index 00000000..7dbed5fe --- /dev/null +++ b/scripts/start-runtime-stub.mjs @@ -0,0 +1,12 @@ +#!/usr/bin/env node +import { createRuntimeStubServer } from "../apps/api/dist/runtime/stub-server.js"; + +const host = process.env.RUNTIME_STUB_HOST ?? "127.0.0.1"; +const port = Number.parseInt(process.env.RUNTIME_STUB_PORT ?? "8790", 10); +const server = createRuntimeStubServer({ + ...(process.env.RUNTIME_SERVICE_TOKEN ? { token: process.env.RUNTIME_SERVICE_TOKEN } : {}) +}); + +server.listen(port, host, () => { + console.log(`[runtime-stub] listening on http://${host}:${port}`); +}); diff --git a/services/deepagents-runtime/.gitignore b/services/deepagents-runtime/.gitignore new file mode 100644 index 00000000..b8fe4539 --- /dev/null +++ b/services/deepagents-runtime/.gitignore @@ -0,0 +1,7 @@ +.venv/ +__pycache__/ +.pytest_cache/ +*.pyc +uv.lock +dist/ +*.egg-info/ diff --git a/services/deepagents-runtime/README.md b/services/deepagents-runtime/README.md new file mode 100644 index 00000000..d6098ac7 --- /dev/null +++ b/services/deepagents-runtime/README.md @@ -0,0 +1,33 @@ +# Deep Agents Runtime + +DataFoundry 控制面的独立 Agent Runtime。对外只暴露已冻结的 HTTP / SSE 契约,对内调用 Deep Agents SDK 的 `create_deep_agent`。 + +## 端点 + +- `GET /health` +- `POST /runs/stream`(AG-UI SSE) +- `POST /runs/:runId/cancel` + +## 本地启动 + +```bash +uv sync +uv run deepagents-runtime +``` + +或在仓库根目录执行 `npm run runtime:deepagents`。`npm run dev` 会默认拉起本服务并设置 `RUNTIME_SERVICE_URL`。 + +未配置 `LLM_API_KEY` 时使用脚本化模型,仍走真实 SDK / LangGraph。要强制走线上模型: + +```bash +export DEEPAGENTS_RUNTIME_MODEL=live +export LLM_API_KEY=... +export LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +export LLM_MODEL=qwen-plus +``` + +## 测试 + +```bash +uv run pytest +``` diff --git a/services/deepagents-runtime/pyproject.toml b/services/deepagents-runtime/pyproject.toml new file mode 100644 index 00000000..8185afd4 --- /dev/null +++ b/services/deepagents-runtime/pyproject.toml @@ -0,0 +1,46 @@ +[project] +name = "deepagents-runtime" +version = "0.1.0" +description = "DataFoundry Deep Agents runtime sidecar (AG-UI SSE contract)" +requires-python = ">=3.11" +license = "Apache-2.0" +authors = [ + { name = "DataFoundry" }, +] + +dependencies = [ + "deepagents>=0.3.0", + "fastapi>=0.115.0", + "langchain-openai>=0.3.0", + "uvicorn[standard]>=0.32.0", +] + +[dependency-groups] +dev = [ + "httpx>=0.28.0", + "pytest>=8.0", + "pytest-asyncio>=0.24.0", +] + +[project.scripts] +deepagents-runtime = "deepagents_runtime.__main__:main" + +[[tool.uv.index]] +url = "https://mirrors.aliyun.com/pypi/simple/" +default = true + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/deepagents_runtime"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = ["src"] +asyncio_mode = "auto" + +[tool.ruff] +line-length = 120 +target-version = "py311" diff --git a/services/deepagents-runtime/src/deepagents_runtime/__init__.py b/services/deepagents-runtime/src/deepagents_runtime/__init__.py new file mode 100644 index 00000000..41e7d7f3 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/__init__.py @@ -0,0 +1,3 @@ +"""Deep Agents runtime sidecar for the DataFoundry control plane.""" + +__version__ = "0.1.0" diff --git a/services/deepagents-runtime/src/deepagents_runtime/__main__.py b/services/deepagents-runtime/src/deepagents_runtime/__main__.py new file mode 100644 index 00000000..76de731f --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/__main__.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os + +import uvicorn + +from deepagents_runtime.config import RuntimeSettings + + +def main() -> None: + settings = RuntimeSettings.from_env() + os.environ.setdefault("DEEPAGENTS_RUNTIME_MODEL", "fake" if settings.fake_model else "") + uvicorn.run( + "deepagents_runtime.app:app", + host=settings.host, + port=settings.port, + factory=False, + ) + + +if __name__ == "__main__": + main() diff --git a/services/deepagents-runtime/src/deepagents_runtime/agent.py b/services/deepagents-runtime/src/deepagents_runtime/agent.py new file mode 100644 index 00000000..a0d66a68 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/agent.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +from typing import Any + +from deepagents_runtime.config import DEFAULT_SYSTEM_PROMPT, RuntimeSettings +from deepagents_runtime.messages import last_user_text, message_text + + +def last_human_content(messages: list[Any]) -> str: + for message in reversed(messages): + kind = getattr(message, "type", None) or (message.get("role") if isinstance(message, dict) else None) + if kind in {"human", "user"}: + return message_text(getattr(message, "content", None) if not isinstance(message, dict) else message.get("content")) + return last_user_text( + [{"role": "user", "content": getattr(message, "content", "")} for message in messages] + ) + + +def ask_user(question: str, options: list[str] | None = None) -> str: + """Ask the user a clarifying question and wait for their reply.""" + if options: + return f"Waiting for the user to answer: {question} ({', '.join(options)})" + return f"Waiting for the user to answer: {question}" + + +class ScriptedChatModel: + """Deterministic chat model used when DEEPAGENTS_RUNTIME_MODEL=fake. + + Still goes through create_deep_agent / LangGraph; it only replaces the LLM. + """ + + def __init__(self, responses: list[Any] | None = None) -> None: + self._responses = list(responses or []) + self._index = 0 + self._bound: Any = None + + @property + def _llm_type(self) -> str: + return "deepagents-runtime-scripted" + + def bind_tools(self, tools: Any, **kwargs: Any) -> ScriptedChatModel: + clone = ScriptedChatModel(self._responses) + clone._index = self._index + clone._bound = tools + return clone + + def _next_message(self, messages: list[Any]) -> Any: + from langchain_core.messages import AIMessage + + if self._responses: + message = self._responses[min(self._index, len(self._responses) - 1)] + self._index += 1 + return message + last = messages[-1] if messages else None + last_type = getattr(last, "type", None) + if last_type == "tool": + return AIMessage(content="已收到你的回复,继续。") + text = last_human_content(messages) + lowered = text.lower() + if "interrupt" in lowered or "ask" in lowered: + return AIMessage( + content="", + tool_calls=[ + { + "name": "ask_user", + "args": {"question": "需要我继续吗?", "options": ["继续", "停止"]}, + "id": "call_ask_scripted", + "type": "tool_call", + } + ], + ) + if "tool" in lowered or "plan" in lowered: + return AIMessage( + content="", + tool_calls=[ + { + "name": "write_todos", + "args": {"todos": [{"content": "整理问题", "status": "in_progress"}]}, + "id": "call_todo_scripted", + "type": "tool_call", + } + ], + ) + return AIMessage(content=f"这是 Deep Agents SDK 的回复:{text or '你好'}") + + def _generate(self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any) -> Any: + from langchain_core.outputs import ChatGeneration, ChatResult + + return ChatResult(generations=[ChatGeneration(message=self._next_message(messages))]) + + async def _agenerate(self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any) -> Any: + return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs) + + def invoke(self, input: Any, config: Any = None, **kwargs: Any) -> Any: + messages = input if isinstance(input, list) else [input] + return self._next_message(messages) + + async def ainvoke(self, input: Any, config: Any = None, **kwargs: Any) -> Any: + return self.invoke(input, config=config, **kwargs) + + +def _as_langchain_model(model: Any) -> Any: + from langchain_core.language_models.chat_models import BaseChatModel + + if isinstance(model, BaseChatModel): + return model + if isinstance(model, ScriptedChatModel): + return _wrap_scripted(model) + return model + + +def _wrap_scripted(scripted: ScriptedChatModel) -> Any: + from langchain_core.language_models.chat_models import BaseChatModel + from langchain_core.messages import AIMessage + from langchain_core.outputs import ChatGeneration, ChatResult + from pydantic import PrivateAttr + + class BoundScriptedChatModel(BaseChatModel): + _inner: ScriptedChatModel = PrivateAttr() + + def __init__(self, inner: ScriptedChatModel) -> None: + super().__init__() + self._inner = inner + + @property + def _llm_type(self) -> str: + return "deepagents-runtime-scripted" + + def bind_tools(self, tools: Any, **kwargs: Any) -> BoundScriptedChatModel: + return BoundScriptedChatModel(self._inner.bind_tools(tools, **kwargs)) + + def _generate(self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any) -> ChatResult: + message = self._inner._next_message(messages) + if not isinstance(message, AIMessage): + message = AIMessage(content=str(message)) + return ChatResult(generations=[ChatGeneration(message=message)]) + + async def _agenerate( + self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any + ) -> ChatResult: + return self._generate(messages, stop=stop, run_manager=run_manager, **kwargs) + + return BoundScriptedChatModel(scripted) + + +def create_chat_model(settings: RuntimeSettings, *, injected: Any = None) -> Any: + if injected is not None: + return _as_langchain_model(injected) + if settings.fake_model: + return _wrap_scripted(ScriptedChatModel()) + if not settings.llm_api_key: + raise RuntimeError("LLM_API_KEY is required unless DEEPAGENTS_RUNTIME_MODEL=fake") + from langchain_openai import ChatOpenAI + + kwargs: dict[str, Any] = { + "model": settings.llm_model, + "api_key": settings.llm_api_key, + } + if settings.llm_base_url: + kwargs["base_url"] = settings.llm_base_url + return ChatOpenAI(**kwargs) + + +def create_runtime_agent( + settings: RuntimeSettings, + *, + model: Any = None, + checkpointer: Any = None, + system_prompt: str | None = None, +) -> Any: + from deepagents import create_deep_agent + from langchain.tools import tool + from langgraph.checkpoint.memory import MemorySaver + + resolved_model = create_chat_model(settings, injected=model) + saver = checkpointer or MemorySaver() + ask_tool = tool(ask_user) + kwargs: dict[str, Any] = { + "model": resolved_model, + "system_prompt": system_prompt or DEFAULT_SYSTEM_PROMPT, + "tools": [ask_tool], + "interrupt_on": {"ask_user": {"allowed_decisions": ["respond", "reject"]}}, + "checkpointer": saver, + } + todo_middleware = _todo_middleware() + if todo_middleware is not None: + kwargs["middleware"] = [todo_middleware] + return create_deep_agent(**kwargs) + + +def _todo_middleware() -> Any | None: + try: + from langchain.agents.middleware import TodoListMiddleware + + return TodoListMiddleware() + except Exception: + return None diff --git a/services/deepagents-runtime/src/deepagents_runtime/app.py b/services/deepagents-runtime/src/deepagents_runtime/app.py new file mode 100644 index 00000000..0ba802b8 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/app.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator +from typing import Any + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from deepagents_runtime.agent import create_runtime_agent +from deepagents_runtime.config import RuntimeSettings +from deepagents_runtime.events import CONTRACT_VERSION, PROVIDER, encode_sse +from deepagents_runtime.models import CancelRequest, RuntimeHealth, RuntimeRunRequest +from deepagents_runtime.stream import iter_runtime_events + + +class RunRegistry: + def __init__(self) -> None: + self._events: dict[str, asyncio.Event] = {} + + def begin(self, run_id: str) -> asyncio.Event: + event = asyncio.Event() + self._events[run_id] = event + return event + + def cancel(self, run_id: str) -> bool: + event = self._events.get(run_id) + if event is None: + return False + event.set() + return True + + def end(self, run_id: str) -> None: + self._events.pop(run_id, None) + + +def create_app( + settings: RuntimeSettings | None = None, + *, + agent: Any = None, + model: Any = None, +) -> FastAPI: + resolved = settings or RuntimeSettings.from_env() + registry = RunRegistry() + + app = FastAPI(title="DataFoundry Deep Agents Runtime", version=CONTRACT_VERSION) + app.state.settings = resolved + app.state.agent = agent + app.state.model = model + app.state.registry = registry + + def ensure_agent() -> Any: + if app.state.agent is None: + app.state.agent = create_runtime_agent(resolved, model=app.state.model) + return app.state.agent + + @app.middleware("http") + async def check_token(request: Request, call_next): # type: ignore[no-untyped-def] + token = resolved.token + if token and request.headers.get("authorization") != f"Bearer {token}": + return JSONResponse({"error": "UNAUTHORIZED"}, status_code=401) + return await call_next(request) + + @app.get("/health") + async def health() -> RuntimeHealth: + try: + ready = ensure_agent() is not None + except Exception: + ready = False + return RuntimeHealth( + status="ok" if ready else "degraded", + provider=PROVIDER, + version=CONTRACT_VERSION, + capabilities={ + "streaming": True, + "tools": True, + "interrupt": True, + "cancel": True, + }, + ) + + @app.post("/runs/{run_id}/cancel") + async def cancel_run(run_id: str, body: CancelRequest | None = None) -> dict[str, Any]: + canceled = registry.cancel(run_id) + return {"canceled": canceled, "reason": (body.reason if body else "RUN_CANCELLED")} + + @app.post("/runs/stream") + async def stream_run(request: RuntimeRunRequest) -> StreamingResponse: + runtime_agent = ensure_agent() + cancel_event = registry.begin(request.runId) + + async def generate() -> AsyncIterator[bytes]: + try: + async for event in iter_runtime_events(runtime_agent, request, cancelled=cancel_event): + yield encode_sse(event).encode("utf-8") + if event.get("type") in {"RUN_FINISHED", "RUN_ERROR"} or event.get("name") == "on_interrupt": + return + finally: + registry.end(request.runId) + + return StreamingResponse( + generate(), + media_type="text/event-stream; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + return app + + +app = create_app() diff --git a/services/deepagents-runtime/src/deepagents_runtime/config.py b/services/deepagents-runtime/src/deepagents_runtime/config.py new file mode 100644 index 00000000..e855cb27 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/config.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass + +DEFAULT_SYSTEM_PROMPT = ( + "You are DataFoundry's assistant. " + "Data warehouse tools, knowledge retrieval, and skill packages are not connected in this runtime version. " + "You may converse, use built-in planning/todo/filesystem tools, and ask the user questions when you need confirmation." +) + + +@dataclass(frozen=True) +class RuntimeSettings: + host: str = "127.0.0.1" + port: int = 8790 + token: str | None = None + llm_model: str = "qwen-plus" + llm_base_url: str | None = None + llm_api_key: str | None = None + fake_model: bool = False + + @property + def model_configured(self) -> bool: + return self.fake_model or bool(self.llm_api_key) + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> RuntimeSettings: + source = env if env is not None else os.environ + token = (source.get("RUNTIME_SERVICE_TOKEN") or "").strip() or None + model_mode = (source.get("DEEPAGENTS_RUNTIME_MODEL") or "").strip().lower() + api_key = (source.get("LLM_API_KEY") or source.get("OPENAI_API_KEY") or "").strip() or None + if model_mode == "fake": + fake_model = True + elif model_mode == "live": + fake_model = False + else: + fake_model = api_key is None + return cls( + host=(source.get("RUNTIME_HOST") or source.get("RUNTIME_SERVICE_HOST") or "127.0.0.1").strip(), + port=_port(source.get("RUNTIME_PORT") or source.get("RUNTIME_SERVICE_PORT") or "8790"), + token=token, + llm_model=(source.get("LLM_MODEL") or "qwen-plus").strip(), + llm_base_url=(source.get("LLM_BASE_URL") or "").strip() or None, + llm_api_key=api_key, + fake_model=fake_model, + ) + + +def _port(value: str) -> int: + parsed = int(value) + if parsed < 1 or parsed > 65535: + raise ValueError("RUNTIME_PORT must be between 1 and 65535") + return parsed diff --git a/services/deepagents-runtime/src/deepagents_runtime/events.py b/services/deepagents-runtime/src/deepagents_runtime/events.py new file mode 100644 index 00000000..9ac73756 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/events.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +import json +from typing import Any + +PROVIDER = "deepagents" +CONTRACT_VERSION = "v1" +RUNTIME_BOUND_EVENT = "runtime.bound" +INTERRUPT_EVENT_NAME = "on_interrupt" +AGENT_INTERRUPT_TYPE = "agent_interrupt" +ASK_USER = "ask_user" +SUBMIT_PLAN = "submit_plan" +PLAN_TOOL_NAMES = frozenset({"write_todos", "submit_plan"}) + + +def encode_sse(event: dict[str, Any]) -> str: + return f"data: {json.dumps(event, ensure_ascii=False)}\n\n" + + +def run_started(thread_id: str, run_id: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "RUN_STARTED", + "threadId": thread_id, + "runId": run_id, + "timestamp": timestamp, + } + + +def run_finished( + thread_id: str, + run_id: str, + *, + timestamp: int, + status: str | None = None, +) -> dict[str, Any]: + event: dict[str, Any] = { + "type": "RUN_FINISHED", + "threadId": thread_id, + "runId": run_id, + "timestamp": timestamp, + } + if status is not None: + event["status"] = status + return event + + +def run_error(message: str, *, timestamp: int) -> dict[str, Any]: + return {"type": "RUN_ERROR", "message": message, "timestamp": timestamp} + + +def runtime_bound(run_id: str, checkpoint_ref: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "CUSTOM", + "name": RUNTIME_BOUND_EVENT, + "value": { + "provider": PROVIDER, + "version": CONTRACT_VERSION, + "checkpointRef": checkpoint_ref, + }, + "timestamp": timestamp, + } + + +def text_message_start(message_id: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TEXT_MESSAGE_START", + "messageId": message_id, + "role": "assistant", + "timestamp": timestamp, + } + + +def text_message_content(message_id: str, delta: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TEXT_MESSAGE_CONTENT", + "messageId": message_id, + "delta": delta, + "timestamp": timestamp, + } + + +def text_message_end(message_id: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TEXT_MESSAGE_END", + "messageId": message_id, + "timestamp": timestamp, + } + + +def text_reply_events(message_id: str, text: str, *, timestamp: int) -> list[dict[str, Any]]: + return [ + text_message_start(message_id, timestamp=timestamp), + text_message_content(message_id, text, timestamp=timestamp), + text_message_end(message_id, timestamp=timestamp), + ] + + +def tool_call_start(tool_call_id: str, tool_name: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TOOL_CALL_START", + "toolCallId": tool_call_id, + "toolCallName": tool_name, + "timestamp": timestamp, + } + + +def tool_call_args(tool_call_id: str, delta: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TOOL_CALL_ARGS", + "toolCallId": tool_call_id, + "delta": delta, + "timestamp": timestamp, + } + + +def tool_call_end(tool_call_id: str, tool_name: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TOOL_CALL_END", + "toolCallId": tool_call_id, + "toolCallName": tool_name, + "timestamp": timestamp, + } + + +def tool_call_result(tool_call_id: str, tool_name: str, content: str, *, timestamp: int) -> dict[str, Any]: + return { + "type": "TOOL_CALL_RESULT", + "toolCallId": tool_call_id, + "toolCallName": tool_name, + "content": content, + "messageId": f"msg_tool_{tool_call_id}", + "role": "tool", + "timestamp": timestamp, + } + + +def map_interrupt_tool_name(name: str) -> str: + if name == ASK_USER: + return ASK_USER + if name in PLAN_TOOL_NAMES: + return SUBMIT_PLAN + return ASK_USER + + +def first_action_request(value: Any) -> dict[str, Any] | None: + if isinstance(value, dict): + requests = value.get("action_requests") + if isinstance(requests, list) and requests: + first = requests[0] + if isinstance(first, dict): + return first + action = value.get("action") + if isinstance(action, dict): + return action + if value.get("name") or value.get("toolName"): + return value + if isinstance(value, list) and value: + first = value[0] + if isinstance(first, dict): + inner = first_action_request(first.get("value", first)) + return inner or (first if first.get("name") else None) + return first_action_request(getattr(first, "value", None)) + raw = getattr(value, "value", None) + if raw is not None and raw is not value: + return first_action_request(raw) + return None + + +def build_interrupt_event( + run_id: str, + value: Any, + *, + timestamp: int, + tool_call_id: str | None = None, +) -> dict[str, Any]: + action = first_action_request(value) or {} + name = str(action.get("name") or action.get("toolName") or ASK_USER) + args = action.get("args") + if args is None: + args = action.get("suspendPayload") or {} + tool_name = map_interrupt_tool_name(name) + tool_call_id = str( + tool_call_id + or action.get("id") + or action.get("toolCallId") + or f"call_{name}_{run_id}" + ) + payload = args if isinstance(args, dict) else {"value": args} + return { + "type": "CUSTOM", + "name": INTERRUPT_EVENT_NAME, + "value": { + "type": AGENT_INTERRUPT_TYPE, + "toolCallId": tool_call_id, + "toolName": tool_name, + "runId": run_id, + "args": payload, + "suspendPayload": payload, + "resumeSchema": {"type": "object"}, + }, + "timestamp": timestamp, + } diff --git a/services/deepagents-runtime/src/deepagents_runtime/messages.py b/services/deepagents-runtime/src/deepagents_runtime/messages.py new file mode 100644 index 00000000..d7df89d0 --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/messages.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import json +from typing import Any + + +def message_text(content: Any) -> str: + if content is None: + return "" + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, str): + parts.append(item) + elif isinstance(item, dict): + text = item.get("text") or item.get("content") + if isinstance(text, str): + parts.append(text) + return "".join(parts) + return str(content) + + +def last_user_text(messages: list[dict[str, Any]]) -> str: + for message in reversed(messages): + if message.get("role") == "user": + return message_text(message.get("content")) + return "" + + +def to_langchain_messages(messages: list[dict[str, Any]]) -> list[Any]: + from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + + converted: list[Any] = [] + for message in messages: + role = message.get("role") + content = message_text(message.get("content")) + message_id = message.get("id") + extras = {"id": message_id} if isinstance(message_id, str) and message_id else {} + if role == "user": + converted.append(HumanMessage(content=content, **extras)) + elif role == "assistant": + converted.append(AIMessage(content=content, **extras)) + elif role == "system": + converted.append(SystemMessage(content=content, **extras)) + return converted + + +def resume_message(response: Any) -> str: + if response is False or response is None: + return "" + if isinstance(response, str): + return response + if isinstance(response, dict): + for key in ("answer", "message", "text", "content"): + value = response.get(key) + if value is not None: + return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False) + return json.dumps(response, ensure_ascii=False) + return str(response) + + +def chunk_text(content: Any) -> str: + return message_text(content) diff --git a/services/deepagents-runtime/src/deepagents_runtime/models.py b/services/deepagents-runtime/src/deepagents_runtime/models.py new file mode 100644 index 00000000..2be3cd4a --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/models.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class RuntimeModelRef(BaseModel): + name: str | None = None + profileId: str | None = None + provider: str | None = None + + +class RuntimeLimits(BaseModel): + maxSteps: int | None = None + + +class RuntimeInterrupt(BaseModel): + type: str + args: Any = None + resumeSchema: Any = None + runId: str + suspendPayload: Any = None + toolCallId: str + toolName: str + + +class RuntimeRunResume(BaseModel): + interrupt: RuntimeInterrupt + response: Any = None + + +class RuntimeTrace(BaseModel): + userId: str | None = None + workspaceId: str | None = None + + +class RuntimeRunRequest(BaseModel): + checkpointRef: str | None = None + limits: RuntimeLimits | None = None + messages: list[dict[str, Any]] + model: RuntimeModelRef | None = None + resume: RuntimeRunResume | None = None + runId: str + systemPrompt: str = "" + threadId: str + trace: RuntimeTrace | None = None + + +class CancelRequest(BaseModel): + reason: str = "RUN_CANCELLED" + + +class RuntimeCapabilities(BaseModel): + cancel: bool = True + interrupt: bool = True + streaming: bool = True + tools: bool = True + + +class RuntimeHealth(BaseModel): + capabilities: RuntimeCapabilities = Field(default_factory=RuntimeCapabilities) + provider: str + status: str + version: str diff --git a/services/deepagents-runtime/src/deepagents_runtime/stream.py b/services/deepagents-runtime/src/deepagents_runtime/stream.py new file mode 100644 index 00000000..9c3225ce --- /dev/null +++ b/services/deepagents-runtime/src/deepagents_runtime/stream.py @@ -0,0 +1,316 @@ +from __future__ import annotations + +import json +import time +from collections.abc import AsyncIterator +from dataclasses import dataclass, field +from typing import Any + +from langgraph.types import Command + +from deepagents_runtime.events import ( + build_interrupt_event, + run_error, + run_finished, + run_started, + runtime_bound, + text_message_content, + text_message_end, + text_message_start, + tool_call_args, + tool_call_end, + tool_call_result, + tool_call_start, +) +from deepagents_runtime.messages import chunk_text, resume_message, to_langchain_messages +from deepagents_runtime.models import RuntimeRunRequest + + +@dataclass +class StreamState: + thread_id: str + run_id: str + message_id: str | None = None + text_open: bool = False + started_tools: set[str] = field(default_factory=set) + ended_tools: set[str] = field(default_factory=set) + tool_names: dict[str, str] = field(default_factory=dict) + unbound_by_name: dict[str, list[str]] = field(default_factory=dict) + execution_to_tool_id: dict[str, str] = field(default_factory=dict) + emitted_args: set[str] = field(default_factory=set) + emitted_text: bool = False + interrupted: bool = False + + +def unfinished_tool_ids(state: StreamState) -> list[str]: + return [tool_id for tool_id in state.tool_names if tool_id not in state.ended_tools] + + +def _register_tool_start(state: StreamState, tool_id: str, tool_name: str, *, awaiting_execution: bool = True) -> bool: + if not tool_id or tool_id in state.started_tools: + return False + state.started_tools.add(tool_id) + state.tool_names[tool_id] = tool_name + if awaiting_execution: + state.unbound_by_name.setdefault(tool_name, []).append(tool_id) + return True + + +def _bind_execution(state: StreamState, tool_name: str, execution_id: str) -> str | None: + queued = state.unbound_by_name.get(tool_name) or [] + if queued: + tool_id = queued.pop(0) + if execution_id: + state.execution_to_tool_id[execution_id] = tool_id + return tool_id + if execution_id and execution_id in state.started_tools: + return execution_id + return None + + +def _resolve_execution(state: StreamState, tool_name: str, execution_id: str) -> str | None: + if execution_id and execution_id in state.execution_to_tool_id: + return state.execution_to_tool_id[execution_id] + bound = _bind_execution(state, tool_name, execution_id) + if bound: + return bound + return next( + (tool_id for tool_id, name in state.tool_names.items() if name == tool_name and tool_id not in state.ended_tools), + None, + ) + + +def now_ms() -> int: + return int(time.time() * 1000) + + +def checkpoint_ref_for(thread_id: str) -> str: + return f"thread:{thread_id}" + + +def build_graph_input(request: RuntimeRunRequest) -> Any: + if request.resume is None: + messages = to_langchain_messages(request.messages) + if not messages: + raise ValueError("messages must not be empty") + return {"messages": messages} + + if request.resume.interrupt.type == "mastra_suspend": + raise ValueError("LEGACY_RUNTIME_SUSPEND_UNRECOVERABLE") + + if request.resume.response is False: + return Command( + resume={ + "decisions": [ + { + "type": "reject", + "message": "User cancelled this interruption.", + } + ] + } + ) + + return Command( + resume={ + "decisions": [ + { + "type": "respond", + "message": resume_message(request.resume.response), + } + ] + } + ) + + +def _close_text(state: StreamState, timestamp: int) -> list[dict[str, Any]]: + if not state.text_open or not state.message_id: + return [] + state.text_open = False + return [text_message_end(state.message_id, timestamp=timestamp)] + + +def _ensure_text(state: StreamState, timestamp: int) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + if state.message_id is None: + state.message_id = f"msg_{state.run_id}" + if not state.text_open: + events.append(text_message_start(state.message_id, timestamp=timestamp)) + state.text_open = True + return events + + +def _stringify_tool_output(output: Any) -> str: + content = getattr(output, "content", output) + if isinstance(content, str): + return content + try: + return json.dumps(content, ensure_ascii=False) + except TypeError: + return str(content) + + +def map_stream_event(event: dict[str, Any], state: StreamState, *, timestamp: int) -> list[dict[str, Any]]: + kind = event.get("event") + data = event.get("data") if isinstance(event.get("data"), dict) else {} + events: list[dict[str, Any]] = [] + + if kind == "on_chat_model_stream": + chunk = data.get("chunk") + text = chunk_text(getattr(chunk, "content", "")) + if text: + events.extend(_ensure_text(state, timestamp)) + events.append(text_message_content(state.message_id or f"msg_{state.run_id}", text, timestamp=timestamp)) + state.emitted_text = True + for tool_chunk in getattr(chunk, "tool_call_chunks", None) or []: + if not isinstance(tool_chunk, dict): + continue + tool_id = str(tool_chunk.get("id") or "") + tool_name = str(tool_chunk.get("name") or state.tool_names.get(tool_id) or "tool") + args = tool_chunk.get("args") + if _register_tool_start(state, tool_id, tool_name): + events.extend(_close_text(state, timestamp)) + events.append(tool_call_start(tool_id, tool_name, timestamp=timestamp)) + if tool_id and args: + delta = args if isinstance(args, str) else json.dumps(args, ensure_ascii=False) + events.append(tool_call_args(tool_id, delta, timestamp=timestamp)) + state.emitted_args.add(tool_id) + return events + + if kind == "on_chat_model_end": + message = data.get("output") or data.get("message") + text = chunk_text(getattr(message, "content", "")) + if text and not state.emitted_text: + events.extend(_ensure_text(state, timestamp)) + events.append(text_message_content(state.message_id or f"msg_{state.run_id}", text, timestamp=timestamp)) + state.emitted_text = True + events.extend(_close_text(state, timestamp)) + for call in getattr(message, "tool_calls", None) or []: + if not isinstance(call, dict): + continue + tool_id = str(call.get("id") or "") + tool_name = str(call.get("name") or "tool") + if not _register_tool_start(state, tool_id, tool_name): + continue + events.append(tool_call_start(tool_id, tool_name, timestamp=timestamp)) + args = call.get("args") + if args: + events.append(tool_call_args(tool_id, json.dumps(args, ensure_ascii=False), timestamp=timestamp)) + state.emitted_args.add(tool_id) + return events + + if kind == "on_tool_start": + tool_name = str(event.get("name") or "tool") + execution_id = str(data.get("id") or event.get("run_id") or "") + tool_id = _bind_execution(state, tool_name, execution_id) + if tool_id is None: + tool_id = execution_id or f"tool_{tool_name}_{state.run_id}" + if _register_tool_start(state, tool_id, tool_name, awaiting_execution=False): + events.extend(_close_text(state, timestamp)) + events.append(tool_call_start(tool_id, tool_name, timestamp=timestamp)) + if execution_id: + state.execution_to_tool_id[execution_id] = tool_id + args = data.get("input") + if tool_id and args is not None and tool_id not in state.emitted_args: + events.append( + tool_call_args( + tool_id, + args if isinstance(args, str) else json.dumps(args, ensure_ascii=False), + timestamp=timestamp, + ) + ) + state.emitted_args.add(tool_id) + return events + + if kind == "on_tool_end": + tool_name = str(event.get("name") or "tool") + execution_id = str(event.get("run_id") or data.get("id") or "") + tool_id = _resolve_execution(state, tool_name, execution_id) + if tool_id is None: + tool_id = execution_id or f"tool_{tool_name}_{state.run_id}" + if tool_id not in state.ended_tools: + events.append(tool_call_end(tool_id, tool_name, timestamp=timestamp)) + events.append(tool_call_result(tool_id, tool_name, _stringify_tool_output(data.get("output")), timestamp=timestamp)) + state.ended_tools.add(tool_id) + state.tool_names[tool_id] = tool_name + return events + + return events + + +def interrupts_from_state(state: Any) -> list[Any]: + found: list[Any] = [] + direct = getattr(state, "interrupts", None) + if direct: + found.extend(list(direct)) + for task in getattr(state, "tasks", None) or []: + task_interrupts = getattr(task, "interrupts", None) + if task_interrupts: + found.extend(list(task_interrupts)) + return found + + +async def iter_runtime_events( + agent: Any, + request: RuntimeRunRequest, + *, + cancelled: Any | None = None, +) -> AsyncIterator[dict[str, Any]]: + timestamp = now_ms() + yield run_started(request.threadId, request.runId, timestamp=timestamp) + yield runtime_bound(request.runId, request.checkpointRef or checkpoint_ref_for(request.threadId), timestamp=timestamp) + + if request.resume is not None and request.resume.response is False: + yield run_finished(request.threadId, request.runId, status="cancelled", timestamp=now_ms()) + return + + config = { + "configurable": {"thread_id": request.threadId}, + "recursion_limit": request.limits.maxSteps if request.limits and request.limits.maxSteps else 80, + } + stream_state = StreamState(thread_id=request.threadId, run_id=request.runId) + + try: + payload = build_graph_input(request) + except ValueError as error: + yield run_error(str(error), timestamp=now_ms()) + return + + try: + stream = agent.astream_events(payload, config=config, version="v2") + try: + async for event in stream: + if cancelled is not None and getattr(cancelled, "is_set", lambda: False)(): + yield run_finished(request.threadId, request.runId, status="cancelled", timestamp=now_ms()) + return + for mapped in map_stream_event(event, stream_state, timestamp=now_ms()): + yield mapped + finally: + aclose = getattr(stream, "aclose", None) + if callable(aclose): + await aclose() + + for mapped in _close_text(stream_state, now_ms()): + yield mapped + + graph_state = await agent.aget_state(config) + interrupts = interrupts_from_state(graph_state) + if interrupts: + stream_state.interrupted = True + yield build_interrupt_event( + request.runId, + interrupts, + timestamp=now_ms(), + tool_call_id=next(reversed(stream_state.tool_names), None), + ) + return + + leftover = unfinished_tool_ids(stream_state) + if leftover: + yield run_error(f"UNFINISHED_TOOL_CALLS:{','.join(leftover)}", timestamp=now_ms()) + return + yield run_finished(request.threadId, request.runId, timestamp=now_ms()) + except Exception as error: # noqa: BLE001 — surface agent failures as AG-UI RUN_ERROR + if cancelled is not None and getattr(cancelled, "is_set", lambda: False)(): + yield run_finished(request.threadId, request.runId, status="cancelled", timestamp=now_ms()) + return + yield run_error(str(error), timestamp=now_ms()) diff --git a/services/deepagents-runtime/tests/test_app_and_sdk.py b/services/deepagents-runtime/tests/test_app_and_sdk.py new file mode 100644 index 00000000..8741d279 --- /dev/null +++ b/services/deepagents-runtime/tests/test_app_and_sdk.py @@ -0,0 +1,95 @@ +import json + +from fastapi.testclient import TestClient + +from deepagents import create_deep_agent +from deepagents_runtime.agent import create_runtime_agent +from deepagents_runtime.app import create_app +from deepagents_runtime.config import RuntimeSettings + + +def parse_sse(body: bytes) -> list[dict]: + events = [] + for frame in body.decode("utf-8").split("\n\n"): + line = frame.strip() + if line.startswith("data:"): + payload = line[5:].strip() + if payload and payload != "[DONE]": + events.append(json.loads(payload)) + return events + + +def run_request(run_id: str, content: str, **extra) -> dict: + return { + "threadId": "session-sdk", + "runId": run_id, + "messages": [{"id": "m1", "role": "user", "content": content}], + "systemPrompt": "test", + **extra, + } + + +def test_create_runtime_agent_uses_deepagents_sdk(): + agent = create_runtime_agent(RuntimeSettings(fake_model=True)) + assert hasattr(agent, "astream_events") + assert hasattr(agent, "aget_state") + assert create_deep_agent.__module__.startswith("deepagents") + + +def test_health_and_text_stream_through_sdk(): + client = TestClient(create_app(RuntimeSettings(fake_model=True))) + health = client.get("/health") + assert health.status_code == 200 + body = health.json() + assert body["status"] == "ok" + assert body["provider"] == "deepagents" + assert body["capabilities"]["interrupt"] is True + + response = client.post("/runs/stream", json=run_request("r-text", "你好")) + assert response.status_code == 200 + events = parse_sse(response.content) + types = [event["type"] for event in events] + assert "RUN_STARTED" in types + assert "TEXT_MESSAGE_CONTENT" in types + assert "RUN_FINISHED" in types + assert any(event.get("name") == "runtime.bound" for event in events) + text = "".join(event.get("delta", "") for event in events if event["type"] == "TEXT_MESSAGE_CONTENT") + assert "Deep Agents SDK" in text or "你好" in text + + +def test_interrupt_and_resume_through_sdk(): + client = TestClient(create_app(RuntimeSettings(fake_model=True))) + interrupted = client.post("/runs/stream", json=run_request("r-ask", "please interrupt")) + events = parse_sse(interrupted.content) + interrupt = next(event for event in events if event.get("name") == "on_interrupt") + assert interrupt["value"]["type"] == "agent_interrupt" + assert interrupt["value"]["toolName"] == "ask_user" + assert "RUN_FINISHED" not in [event["type"] for event in events] + + resumed = client.post("/runs/stream", json=run_request( + "r-ask", + "please interrupt", + resume={"interrupt": interrupt["value"], "response": {"answer": "继续"}}, + )) + resume_events = parse_sse(resumed.content) + assert any(event["type"] == "RUN_FINISHED" for event in resume_events) + assert any( + event["type"] in {"TOOL_CALL_RESULT", "TEXT_MESSAGE_CONTENT"} + for event in resume_events + ) + + +def test_write_todos_tool_goes_through_sdk(): + client = TestClient(create_app(RuntimeSettings(fake_model=True))) + response = client.post("/runs/stream", json=run_request("r-tool", "make a plan")) + events = parse_sse(response.content) + assert any(event.get("toolCallName") == "write_todos" for event in events) + assert any(event["type"] == "TOOL_CALL_RESULT" for event in events) + assert any(event["type"] == "RUN_FINISHED" for event in events) + + +def test_cancel_unknown_run_is_ok(): + client = TestClient(create_app(RuntimeSettings(fake_model=True))) + response = client.post("/runs/missing/cancel", json={"reason": "RUN_CANCELLED"}) + assert response.status_code == 200 + assert response.json()["canceled"] is False diff --git a/services/deepagents-runtime/tests/test_config.py b/services/deepagents-runtime/tests/test_config.py new file mode 100644 index 00000000..67809036 --- /dev/null +++ b/services/deepagents-runtime/tests/test_config.py @@ -0,0 +1,21 @@ +from deepagents_runtime.config import RuntimeSettings + + +def test_missing_api_key_defaults_to_fake_model(): + settings = RuntimeSettings.from_env({"LLM_MODEL": "qwen-plus"}) + assert settings.fake_model is True + assert settings.model_configured is True + + +def test_api_key_defaults_to_live_model(): + settings = RuntimeSettings.from_env({"LLM_API_KEY": "sk-test", "LLM_MODEL": "qwen-plus"}) + assert settings.fake_model is False + assert settings.llm_api_key == "sk-test" + + +def test_explicit_fake_overrides_api_key(): + settings = RuntimeSettings.from_env({ + "LLM_API_KEY": "sk-test", + "DEEPAGENTS_RUNTIME_MODEL": "fake", + }) + assert settings.fake_model is True diff --git a/services/deepagents-runtime/tests/test_events.py b/services/deepagents-runtime/tests/test_events.py new file mode 100644 index 00000000..f9390851 --- /dev/null +++ b/services/deepagents-runtime/tests/test_events.py @@ -0,0 +1,110 @@ +from deepagents_runtime.events import ( + AGENT_INTERRUPT_TYPE, + INTERRUPT_EVENT_NAME, + RUNTIME_BOUND_EVENT, + build_interrupt_event, + encode_sse, + run_error, + run_finished, + run_started, + runtime_bound, + text_reply_events, + tool_call_result, +) + + +def test_run_lifecycle_events_match_contract(): + started = run_started("thread-1", "run-1", timestamp=1) + assert started == { + "type": "RUN_STARTED", + "threadId": "thread-1", + "runId": "run-1", + "timestamp": 1, + } + finished = run_finished("thread-1", "run-1", timestamp=2) + assert finished["type"] == "RUN_FINISHED" + assert "status" not in finished + cancelled = run_finished("thread-1", "run-1", status="cancelled", timestamp=3) + assert cancelled["status"] == "cancelled" + error = run_error("boom", timestamp=4) + assert error == {"type": "RUN_ERROR", "message": "boom", "timestamp": 4} + + +def test_runtime_bound_is_opaque_checkpoint_ref(): + event = runtime_bound("run-1", "ckpt:thread-1", timestamp=9) + assert event["type"] == "CUSTOM" + assert event["name"] == RUNTIME_BOUND_EVENT + assert event["value"] == { + "provider": "deepagents", + "version": "v1", + "checkpointRef": "ckpt:thread-1", + } + + +def test_text_reply_emits_start_content_end(): + events = text_reply_events("msg_1", "你好", timestamp=5) + assert [event["type"] for event in events] == [ + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + ] + assert events[1]["delta"] == "你好" + assert events[0]["role"] == "assistant" + + +def test_hitl_interrupt_maps_action_request_to_agent_interrupt(): + event = build_interrupt_event( + run_id="run-ask", + value={ + "action_requests": [ + { + "id": "call_ask_1", + "name": "ask_user", + "args": {"question": "需要我继续吗?", "options": ["继续", "停止"]}, + } + ] + }, + timestamp=11, + ) + assert event["type"] == "CUSTOM" + assert event["name"] == INTERRUPT_EVENT_NAME + assert event["value"] == { + "type": AGENT_INTERRUPT_TYPE, + "toolCallId": "call_ask_1", + "toolName": "ask_user", + "runId": "run-ask", + "args": {"question": "需要我继续吗?", "options": ["继续", "停止"]}, + "suspendPayload": {"question": "需要我继续吗?", "options": ["继续", "停止"]}, + "resumeSchema": {"type": "object"}, + } + + +def test_write_todos_interrupt_maps_to_submit_plan(): + event = build_interrupt_event( + run_id="run-plan", + value={ + "action_requests": [ + {"name": "write_todos", "args": {"todos": [{"content": "整理问题"}]}} + ] + }, + timestamp=12, + ) + assert event["value"]["toolName"] == "submit_plan" + assert event["value"]["toolCallId"].startswith("call_write_todos_") + + +def test_tool_call_result_is_a_tool_role_message(): + event = tool_call_result("call_1", "write_todos", "Updated todo list", timestamp=7) + assert event["type"] == "TOOL_CALL_RESULT" + assert event["toolCallId"] == "call_1" + assert event["toolCallName"] == "write_todos" + assert event["content"] == "Updated todo list" + assert event["role"] == "tool" + assert event["messageId"] + + +def test_sse_frame_is_data_json(): + frame = encode_sse({"type": "RUN_STARTED", "runId": "r1"}) + assert frame.startswith("data: {") + assert frame.endswith("\n\n") + assert '"runId": "r1"' in frame diff --git a/services/deepagents-runtime/tests/test_messages.py b/services/deepagents-runtime/tests/test_messages.py new file mode 100644 index 00000000..a238c024 --- /dev/null +++ b/services/deepagents-runtime/tests/test_messages.py @@ -0,0 +1,26 @@ +from deepagents_runtime.messages import last_user_text, resume_message, to_langchain_messages + + +def test_last_user_text_reads_latest_user_message(): + assert last_user_text([ + {"role": "system", "content": "sys"}, + {"role": "user", "content": "第一问"}, + {"role": "assistant", "content": "答"}, + {"role": "user", "content": [{"type": "text", "text": "第二问"}]}, + ]) == "第二问" + + +def test_resume_message_accepts_string_and_answer_object(): + assert resume_message("继续") == "继续" + assert resume_message({"answer": "停止"}) == "停止" + assert resume_message(False) == "" + + +def test_to_langchain_messages_keeps_roles(): + converted = to_langchain_messages([ + {"id": "s1", "role": "system", "content": "规则"}, + {"id": "u1", "role": "user", "content": "你好"}, + {"id": "a1", "role": "assistant", "content": "在"}, + ]) + assert [message.type for message in converted] == ["system", "human", "ai"] + assert converted[1].content == "你好" diff --git a/services/deepagents-runtime/tests/test_stream_mapping.py b/services/deepagents-runtime/tests/test_stream_mapping.py new file mode 100644 index 00000000..8ffd7565 --- /dev/null +++ b/services/deepagents-runtime/tests/test_stream_mapping.py @@ -0,0 +1,202 @@ +from deepagents_runtime.stream import StreamState, map_stream_event + + +class _Chunk: + def __init__(self, content="", tool_call_chunks=None): + self.content = content + self.tool_call_chunks = tool_call_chunks or [] + + +class _Message: + def __init__(self, tool_calls=None): + self.tool_calls = tool_calls or [] + + +def test_map_chat_model_stream_emits_text_events(): + state = StreamState(thread_id="t", run_id="r") + events = map_stream_event( + {"event": "on_chat_model_stream", "data": {"chunk": _Chunk("你好")}}, + state, + timestamp=1, + ) + assert [event["type"] for event in events] == ["TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT"] + assert events[1]["delta"] == "你好" + + +def test_map_chat_model_end_emits_full_text_when_not_streamed(): + state = StreamState(thread_id="t", run_id="r") + events = map_stream_event( + {"event": "on_chat_model_end", "data": {"output": _MessageWithContent("完整回复")}}, + state, + timestamp=4, + ) + assert [event["type"] for event in events] == [ + "TEXT_MESSAGE_START", + "TEXT_MESSAGE_CONTENT", + "TEXT_MESSAGE_END", + ] + assert events[1]["delta"] == "完整回复" + + +class _MessageWithContent: + def __init__(self, content, tool_calls=None): + self.content = content + self.tool_calls = tool_calls or [] + + +def _replay(raw_events: list[tuple[dict, int]]): + from deepagents_runtime.stream import unfinished_tool_ids + + state = StreamState(thread_id="t", run_id="r") + mapped = [] + for event, timestamp in raw_events: + mapped.extend(map_stream_event(event, state, timestamp=timestamp)) + return state, mapped, unfinished_tool_ids(state) + + +def test_map_tool_call_chunks_and_end(): + state, mapped, unfinished = _replay([ + ( + { + "event": "on_chat_model_stream", + "data": { + "chunk": _Chunk( + tool_call_chunks=[{"id": "call_1", "name": "write_todos", "args": '{"todos":[]}'}] + ) + }, + }, + 2, + ), + ( + {"event": "on_tool_end", "name": "write_todos", "run_id": "lg-1", "data": {"output": {"ok": True}}}, + 3, + ), + ]) + starts = [event for event in mapped if event["type"] == "TOOL_CALL_START"] + assert len(starts) == 1 + assert starts[0]["toolCallId"] == "call_1" + ends = [event for event in mapped if event["type"] == "TOOL_CALL_END"] + assert ends[0]["toolCallId"] == "call_1" + assert unfinished == [] + + +def test_langgraph_tool_start_does_not_open_a_second_call(): + """One model tool_call.id is one AG-UI toolCallId. on_tool_start is execution, not a new call.""" + _state, mapped, unfinished = _replay([ + ( + { + "event": "on_chat_model_end", + "data": { + "output": _MessageWithContent( + "", + tool_calls=[{"id": "call_todo_1", "name": "write_todos", "args": {"todos": []}}], + ) + }, + }, + 1, + ), + ( + { + "event": "on_tool_start", + "name": "write_todos", + "run_id": "01a05d8c-db63-7bf1-8552-ac6d4447e00a", + "data": {"input": {"todos": [{"content": "show tool UI"}]}}, + }, + 2, + ), + ( + { + "event": "on_tool_end", + "name": "write_todos", + "run_id": "01a05d8c-db63-7bf1-8552-ac6d4447e00a", + "data": {"output": "Updated todo list"}, + }, + 3, + ), + ]) + starts = [event for event in mapped if event["type"] == "TOOL_CALL_START"] + assert [event["toolCallId"] for event in starts] == ["call_todo_1"] + assert [event["toolCallId"] for event in mapped if event["type"] == "TOOL_CALL_END"] == ["call_todo_1"] + results = [event for event in mapped if event["type"] == "TOOL_CALL_RESULT"] + assert [event["toolCallId"] for event in results] == ["call_todo_1"] + assert results[0]["role"] == "tool" + assert results[0]["messageId"] + assert unfinished == [] + + +def test_langgraph_tool_start_does_not_replay_model_args(): + _state, mapped, _unfinished = _replay([ + ( + { + "event": "on_chat_model_end", + "data": { + "output": _MessageWithContent( + "", + tool_calls=[{"id": "call_todo_1", "name": "write_todos", "args": {"todos": []}}], + ) + }, + }, + 1, + ), + ( + { + "event": "on_tool_start", + "name": "write_todos", + "run_id": "01a05d8c-db63-7bf1-8552-ac6d4447e00a", + "data": {"input": {"todos": [{"content": "show tool UI"}]}}, + }, + 2, + ), + ]) + args_events = [event for event in mapped if event["type"] == "TOOL_CALL_ARGS"] + assert [event["toolCallId"] for event in args_events] == ["call_todo_1"] + assert args_events[0]["delta"] == '{"todos": []}' + + +def test_two_same_name_tools_keep_model_ids_in_order(): + _state, mapped, unfinished = _replay([ + ( + { + "event": "on_chat_model_end", + "data": { + "output": _MessageWithContent( + "", + tool_calls=[ + {"id": "call_a", "name": "write_todos", "args": {"todos": [{"content": "a"}]}}, + {"id": "call_b", "name": "write_todos", "args": {"todos": [{"content": "b"}]}}, + ], + ) + }, + }, + 1, + ), + ({"event": "on_tool_start", "name": "write_todos", "run_id": "lg-a", "data": {"input": {}}}, 2), + ({"event": "on_tool_end", "name": "write_todos", "run_id": "lg-a", "data": {"output": "a"}}, 3), + ({"event": "on_tool_start", "name": "write_todos", "run_id": "lg-b", "data": {"input": {}}}, 4), + ({"event": "on_tool_end", "name": "write_todos", "run_id": "lg-b", "data": {"output": "b"}}, 5), + ]) + assert [event["toolCallId"] for event in mapped if event["type"] == "TOOL_CALL_START"] == ["call_a", "call_b"] + results = [event for event in mapped if event["type"] == "TOOL_CALL_RESULT"] + assert [event["toolCallId"] for event in results] == ["call_a", "call_b"] + assert [event["content"] for event in results] == ["a", "b"] + assert unfinished == [] + + +def test_unfinished_tool_ids_stay_open_when_end_is_missing(): + _state, mapped, unfinished = _replay([ + ( + { + "event": "on_chat_model_end", + "data": { + "output": _MessageWithContent( + "", + tool_calls=[{"id": "call_open", "name": "ask_user", "args": {"question": "?"}}], + ) + }, + }, + 1, + ), + ({"event": "on_tool_start", "name": "ask_user", "run_id": "lg-ask", "data": {"input": {}}}, 2), + ]) + assert [event["toolCallId"] for event in mapped if event["type"] == "TOOL_CALL_START"] == ["call_open"] + assert unfinished == ["call_open"]