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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/agent/agents/subagents/debugAssistant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export function createDebugAssistantAgent(session: AgentSession): Agent {
instructions,
tools: [
csom.getPipelineState,
csom.getSubgraphState,
runTools.getRunStatus,
runTools.debugPipelineRun,
...debugTools.allTools,
Expand Down
4 changes: 4 additions & 0 deletions src/agent/prompts/architect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
2 changes: 1 addition & 1 deletion src/agent/prompts/debugAssistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/agent/prompts/pipelineRepair.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions src/agent/toolBridgeApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AiSpec>;
getSubgraphState(taskEntityId: string): Promise<SubgraphStateResult>;

setPipelineName(name: string): Promise<{ success: boolean }>;
setPipelineDescription(description: string): Promise<{ success: boolean }>;
Expand Down
3 changes: 2 additions & 1 deletion src/agent/tools/csomTools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -89,6 +89,7 @@ describe("createCsomTools", () => {
"delete_output",
"delete_task",
"get_pipeline_state",
"get_subgraph_state",
"rename_input",
"rename_output",
"rename_task",
Expand Down
15 changes: 15 additions & 0 deletions src/agent/tools/csomTools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -369,8 +382,10 @@ export function createCsomTools(bridge: ToolBridgeApi) {

return {
getPipelineState,
getSubgraphState,
allTools: [
getPipelineState,
getSubgraphState,
setPipelineName,
setPipelineDescription,
addTask,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -31,6 +32,7 @@ export function createEditorToolBridge(
): ToolBridgeApi {
return {
...createCsomBridgeHandlers(deps),
...createSubgraphBridgeHandlers(deps),
...createComponentSearchBridgeHandlers(deps),
...createRunBridgeHandlers(deps),
...createDebugBridgeHandlers(deps),
Expand Down
2 changes: 2 additions & 0 deletions src/routes/v2/pages/RunView/toolBridge/runViewToolBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -127,6 +128,7 @@ function createReadOnlyCsomHandlers(deps: BridgeDeps): ReadOnlyCsomHandlers {
export function createRunViewToolBridge(deps: BridgeDeps): ToolBridgeApi {
return {
...createReadOnlyCsomHandlers(deps),
...createSubgraphBridgeHandlers(deps),
...createRunBridgeHandlers(deps),
...createDebugBridgeHandlers(deps),
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
Original file line number Diff line number Diff line change
@@ -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<ToolBridgeApi, "getSubgraphState">;

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<SubgraphStateResult> {
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) };
},
};
}
Loading