diff --git a/src/agent/agents/subagents/debugAssistant.ts b/src/agent/agents/subagents/debugAssistant.ts index 0c0b14421a..b71800fc36 100644 --- a/src/agent/agents/subagents/debugAssistant.ts +++ b/src/agent/agents/subagents/debugAssistant.ts @@ -46,6 +46,7 @@ export function createDebugAssistantAgent(session: AgentSession): Agent { instructions, tools: [ csom.getPipelineState, + csom.getSubgraphState, runTools.getRunStatus, runTools.debugPipelineRun, ...debugTools.allTools, diff --git a/src/agent/prompts/architect.md b/src/agent/prompts/architect.md index 440a751669..e56d288ba7 100644 --- a/src/agent/prompts/architect.md +++ b/src/agent/prompts/architect.md @@ -47,6 +47,10 @@ Every entity has a stable `$id`. Use these IDs when referencing entities in tool `get_pipeline_state` may include an `activeSubgraphPath` field — a breadcrumb of subgraph task names from the root pipeline to whatever subgraph the user is currently viewing. Treat this as a hint about where the user's attention is, but remember: every CSOM mutation always applies to the root spec. When you build new structure, prefer extending the root pipeline (or an explicit subgraph the user named) rather than the nested view the user happens to be focused on. +## Looking inside a subgraph + +`get_pipeline_state` reports a subgraph task by its interface only — `isSubgraph: true` plus its input and output ports — so its inner tasks and bindings are not in that payload. Call `get_subgraph_state(taskEntityId)` when you need to know what a subgraph actually does before wiring into or around it. The result is the same shape as `get_pipeline_state`, and its inner tasks carry `isSubgraph` too, so call again with an inner `$id` to go deeper. Never assume a subgraph's contents from its name alone. + ## When to defer to another specialist - Targeted edits to fix validation errors in an existing pipeline → defer to **pipeline-repair**. diff --git a/src/agent/prompts/debugAssistant.md b/src/agent/prompts/debugAssistant.md index 7dc01f189a..162b379c6a 100644 --- a/src/agent/prompts/debugAssistant.md +++ b/src/agent/prompts/debugAssistant.md @@ -14,7 +14,7 @@ You are the **Debug Assistant** specialist for Tangle Pipeline Studio. Your job - `get_container_state(executionId)` — pod/container state, exit code, debug info. - `get_container_log(executionId)` — trailing 8KB of stdout/stderr + captured error messages. 6. If the failure is not in the failed-children snapshot (e.g. an orchestration error or pre-launch failure), look at `run.annotations`, `rootStatus`, and the root execution log to explain. -7. If `get_pipeline_state` would help you point at a specific task in the user's spec by id, call it once. +7. If `get_pipeline_state` would help you point at a specific task in the user's spec by id, call it once. That payload describes a subgraph task by its interface only, so when the failure lies inside one, call `get_subgraph_state(taskEntityId)` to resolve the inner task and its `$id` — repeat with an inner `$id` for deeper nesting. ## Recommending a fix diff --git a/src/agent/prompts/pipelineRepair.md b/src/agent/prompts/pipelineRepair.md index 8116e30458..5b53b16e44 100644 --- a/src/agent/prompts/pipelineRepair.md +++ b/src/agent/prompts/pipelineRepair.md @@ -68,6 +68,10 @@ Every entity has a stable `$id`. Use these IDs when referencing entities in tool `get_pipeline_state` may include an `activeSubgraphPath` field — a breadcrumb of subgraph task names from the root pipeline to whatever subgraph the user is currently viewing. Treat this as a hint about what part of the pipeline the user cares about, but remember: every CSOM mutation always applies to the root spec. If a fix targets an entity inside a nested subgraph, point that out and ask the user before editing. +## Looking inside a subgraph + +`get_pipeline_state` reports a subgraph task by its interface only — `isSubgraph: true` plus its input and output ports — so its inner tasks and bindings are not in that payload. When a validation issue or the user's question points inside a subgraph, call `get_subgraph_state(taskEntityId)` to get its contents in the same shape, and call it again with an inner `$id` for deeper nesting. Diagnose from the real contents rather than guessing from the subgraph's name. + ## Response Formatting When referring to pipeline entities (tasks, inputs, outputs) in your response, use this markdown link format so the UI can render them as interactive chips: diff --git a/src/agent/toolBridgeApi.ts b/src/agent/toolBridgeApi.ts index 4c4d9579e9..ef6754266f 100644 --- a/src/agent/toolBridgeApi.ts +++ b/src/agent/toolBridgeApi.ts @@ -103,8 +103,15 @@ export type ExecutionDetails = GetExecutionInfoResponse; export type ExecutionState = GetGraphExecutionStateResponse; export type ContainerState = GetContainerExecutionStateResponse; +export interface SubgraphStateResult { + success: boolean; + spec?: AiSpec; + error?: string; +} + export interface ToolBridgeApi { getPipelineState(): Promise; + getSubgraphState(taskEntityId: string): Promise; setPipelineName(name: string): Promise<{ success: boolean }>; setPipelineDescription(description: string): Promise<{ success: boolean }>; diff --git a/src/agent/tools/csomTools.test.ts b/src/agent/tools/csomTools.test.ts index 2e85fd990d..b8a606604f 100644 --- a/src/agent/tools/csomTools.test.ts +++ b/src/agent/tools/csomTools.test.ts @@ -74,7 +74,7 @@ function hasAllOf(schema: JsonSchemaNode | undefined): boolean { } describe("createCsomTools", () => { - it("exposes the full 18-tool surface", () => { + it("exposes the full 19-tool surface", () => { const { allTools } = createCsomTools(makeBridge()); const names = allTools.map((t) => t.name).sort(); expect(names).toEqual( @@ -89,6 +89,7 @@ describe("createCsomTools", () => { "delete_output", "delete_task", "get_pipeline_state", + "get_subgraph_state", "rename_input", "rename_output", "rename_task", diff --git a/src/agent/tools/csomTools.ts b/src/agent/tools/csomTools.ts index 7599267b59..0aaa8f020c 100644 --- a/src/agent/tools/csomTools.ts +++ b/src/agent/tools/csomTools.ts @@ -90,6 +90,19 @@ export function createCsomTools(bridge: ToolBridgeApi) { execute: async () => asJson(await bridge.getPipelineState()), }); + const getSubgraphState = tool({ + name: "get_subgraph_state", + description: + "Get the contents of one subgraph task — its inner tasks, bindings, and I/O — as JSON. `get_pipeline_state` reports only a subgraph task's interface, so call this for any task flagged `isSubgraph` before answering questions about what runs inside it. Inner tasks carry the same flag: call again with an inner task's $id to go deeper.", + parameters: z.object({ + taskEntityId: z + .string() + .describe("$id of the subgraph task to look inside"), + }), + execute: async ({ taskEntityId }) => + asJson(await bridge.getSubgraphState(taskEntityId)), + }); + const setPipelineName = tool({ name: "set_pipeline_name", description: "Set the pipeline name.", @@ -369,8 +382,10 @@ export function createCsomTools(bridge: ToolBridgeApi) { return { getPipelineState, + getSubgraphState, allTools: [ getPipelineState, + getSubgraphState, setPipelineName, setPipelineDescription, addTask, diff --git a/src/routes/v2/pages/Editor/components/AiChat/toolBridge/index.ts b/src/routes/v2/pages/Editor/components/AiChat/toolBridge/index.ts index 0688d83bf6..292d6ce80b 100644 --- a/src/routes/v2/pages/Editor/components/AiChat/toolBridge/index.ts +++ b/src/routes/v2/pages/Editor/components/AiChat/toolBridge/index.ts @@ -16,6 +16,7 @@ import type { ToolBridgeApi } from "@/agent/toolBridgeApi"; import { createComponentSearchBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/componentSearchBridge"; import { createDebugBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/debugBridge"; import { createRunBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/runBridge"; +import { createSubgraphBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge"; import type { CsomBridgeDeps } from "./csomBridge"; import { createCsomBridgeHandlers } from "./csomBridge"; @@ -31,6 +32,7 @@ export function createEditorToolBridge( ): ToolBridgeApi { return { ...createCsomBridgeHandlers(deps), + ...createSubgraphBridgeHandlers(deps), ...createComponentSearchBridgeHandlers(deps), ...createRunBridgeHandlers(deps), ...createDebugBridgeHandlers(deps), diff --git a/src/routes/v2/pages/RunView/toolBridge/runViewToolBridge.ts b/src/routes/v2/pages/RunView/toolBridge/runViewToolBridge.ts index 1f60a7bb56..a41ebac5e9 100644 --- a/src/routes/v2/pages/RunView/toolBridge/runViewToolBridge.ts +++ b/src/routes/v2/pages/RunView/toolBridge/runViewToolBridge.ts @@ -13,6 +13,7 @@ import { validateSpec } from "@/models/componentSpec/validation/validateSpec"; import { serializeSpecForAi } from "@/routes/v2/shared/components/AiChat/serializeSpecForAi"; import { createDebugBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/debugBridge"; import { createRunBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/runBridge"; +import { createSubgraphBridgeHandlers } from "@/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge"; import type { BridgeDeps } from "@/routes/v2/shared/components/AiChat/toolBridge/utils"; import { requireSpec } from "@/routes/v2/shared/components/AiChat/toolBridge/utils"; @@ -127,6 +128,7 @@ function createReadOnlyCsomHandlers(deps: BridgeDeps): ReadOnlyCsomHandlers { export function createRunViewToolBridge(deps: BridgeDeps): ToolBridgeApi { return { ...createReadOnlyCsomHandlers(deps), + ...createSubgraphBridgeHandlers(deps), ...createRunBridgeHandlers(deps), ...createDebugBridgeHandlers(deps), }; diff --git a/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.test.ts b/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.test.ts new file mode 100644 index 0000000000..48ebdb4b75 --- /dev/null +++ b/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; + +import type { ComponentSpec } from "@/models/componentSpec"; +import { IncrementingIdGenerator } from "@/models/componentSpec/factories/idGenerator"; +import { YamlDeserializer } from "@/models/componentSpec/serialization/yamlDeserializer"; +import { serializeSpecForAi } from "@/routes/v2/shared/components/AiChat/serializeSpecForAi"; + +import { createSubgraphBridgeHandlers } from "./subgraphBridge"; +import type { BridgeDeps } from "./utils"; + +const containerComponent = (name: string, image: string) => ({ + name, + spec: { + name, + inputs: [{ name: "path", type: "String" }], + outputs: [{ name: "table", type: "String" }], + implementation: { container: { image } }, + }, +}); + +const pipelineYaml = { + name: "RootPipeline", + inputs: [{ name: "raw_path", type: "String" }], + implementation: { + graph: { + tasks: { + Preprocess: { + componentRef: { + name: "Preprocess", + spec: { + name: "Preprocess", + inputs: [{ name: "path", type: "String" }], + outputs: [{ name: "table", type: "String" }], + implementation: { + graph: { + tasks: { + DropNulls: { + componentRef: containerComponent("DropNulls", "clean:1"), + arguments: { + path: { graphInput: { inputName: "path" } }, + }, + }, + Normalize: { + componentRef: { + name: "Normalize", + spec: { + name: "Normalize", + inputs: [{ name: "path", type: "String" }], + outputs: [{ name: "table", type: "String" }], + implementation: { + graph: { + tasks: { + ScaleColumns: { + componentRef: containerComponent( + "ScaleColumns", + "scale:1", + ), + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + arguments: { path: { graphInput: { inputName: "raw_path" } } }, + }, + Train: { + componentRef: containerComponent("Train", "train:1"), + }, + }, + }, + }, +}; + +function deserialize(): ComponentSpec { + return new YamlDeserializer(new IncrementingIdGenerator()).deserialize( + pipelineYaml, + ); +} + +function makeDeps(spec: ComponentSpec): BridgeDeps { + return { + getSpec: () => spec, + getActiveSubgraphPath: () => [], + }; +} + +function taskIdByName(spec: ComponentSpec, name: string): string { + const task = spec.tasks.find((t) => t.name === name); + if (!task) throw new Error(`No task named ${name}`); + return task.$id; +} + +describe("createSubgraphBridgeHandlers", () => { + it("does not expose inner tasks through the pipeline-state payload", () => { + const aiSpec = serializeSpecForAi(deserialize()); + + const preprocess = aiSpec.tasks.find((t) => t.name === "Preprocess"); + expect(preprocess?.isSubgraph).toBe(true); + expect(JSON.stringify(aiSpec)).not.toContain("DropNulls"); + }); + + it("returns the inner tasks and bindings of a deserialized subgraph", async () => { + const spec = deserialize(); + const { getSubgraphState } = createSubgraphBridgeHandlers(makeDeps(spec)); + + const result = await getSubgraphState(taskIdByName(spec, "Preprocess")); + + expect(result.success).toBe(true); + expect(result.spec?.name).toBe("Preprocess"); + expect(result.spec?.tasks.map((t) => t.name)).toEqual([ + "DropNulls", + "Normalize", + ]); + expect(result.spec?.inputs.map((i) => i.name)).toEqual(["path"]); + expect(result.spec?.bindings.length).toBeGreaterThan(0); + }); + + it("flags a nested subgraph so the model can request the next level", async () => { + const spec = deserialize(); + const { getSubgraphState } = createSubgraphBridgeHandlers(makeDeps(spec)); + + const preprocess = await getSubgraphState(taskIdByName(spec, "Preprocess")); + const normalize = preprocess.spec?.tasks.find( + (t) => t.name === "Normalize", + ); + expect(normalize?.isSubgraph).toBe(true); + + const nested = await getSubgraphState(normalize!.$id); + + expect(nested.success).toBe(true); + expect(nested.spec?.tasks.map((t) => t.name)).toEqual(["ScaleColumns"]); + }); + + it("reports a task that is not a subgraph", async () => { + const spec = deserialize(); + const { getSubgraphState } = createSubgraphBridgeHandlers(makeDeps(spec)); + + const result = await getSubgraphState(taskIdByName(spec, "Train")); + + expect(result.success).toBe(false); + expect(result.spec).toBeUndefined(); + expect(result.error).toContain("is not a subgraph"); + }); + + it("reports an unknown task id", async () => { + const spec = deserialize(); + const { getSubgraphState } = createSubgraphBridgeHandlers(makeDeps(spec)); + + const result = await getSubgraphState("task_does_not_exist"); + + expect(result.success).toBe(false); + expect(result.error).toContain("task_does_not_exist"); + }); + + it("throws a model-friendly error when no pipeline is open", async () => { + const { getSubgraphState } = createSubgraphBridgeHandlers({ + getSpec: () => null, + getActiveSubgraphPath: () => [], + }); + + await expect(getSubgraphState("task_1")).rejects.toThrow( + "No pipeline is currently open", + ); + }); +}); diff --git a/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.ts b/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.ts new file mode 100644 index 0000000000..3021af3017 --- /dev/null +++ b/src/routes/v2/shared/components/AiChat/toolBridge/subgraphBridge.ts @@ -0,0 +1,63 @@ +/** + * Read-only handler exposing one subgraph's contents on demand. + * + * `serializeSpecForAi` narrows every task's component spec to its interface, + * so a subgraph task in `get_pipeline_state` carries `isSubgraph: true` and + * its ports but nothing about what runs inside it. Inlining nested graphs + * there would grow every request by the whole tree, so the model asks for + * the one subgraph it cares about and gets the same `AiSpec` shape back — + * recursing further through the `isSubgraph` flags inside it. + */ +import type { SubgraphStateResult, ToolBridgeApi } from "@/agent/toolBridgeApi"; +import type { ComponentSpec, Task } from "@/models/componentSpec"; +import { serializeSpecForAi } from "@/routes/v2/shared/components/AiChat/serializeSpecForAi"; + +import type { BridgeDeps } from "./utils"; +import { requireSpec } from "./utils"; + +type SubgraphHandlers = Pick; + +function findTaskById( + spec: ComponentSpec, + taskEntityId: string, +): Task | undefined { + for (const task of spec.tasks) { + if (task.$id === taskEntityId) { + return task; + } + + const nested = + task.subgraphSpec && findTaskById(task.subgraphSpec, taskEntityId); + if (nested) { + return nested; + } + } + + return undefined; +} + +export function createSubgraphBridgeHandlers( + deps: BridgeDeps, +): SubgraphHandlers { + return { + async getSubgraphState(taskEntityId): Promise { + const task = findTaskById(requireSpec(deps), taskEntityId); + + if (!task) { + return { + success: false, + error: `No task with $id "${taskEntityId}" exists in this pipeline.`, + }; + } + + if (!task.subgraphSpec) { + return { + success: false, + error: `Task "${task.name}" is not a subgraph — it has no inner tasks to inspect.`, + }; + } + + return { success: true, spec: serializeSpecForAi(task.subgraphSpec) }; + }, + }; +}