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
10 changes: 7 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"hydrate-component-library": "tsx scripts/hydrate-component-library.ts public/component_library.original.yaml public/component_library.yaml",
"agent:server": "tsx scripts/agent/server.ts",
"agent:index": "tsx scripts/agent/registry/indexer.ts",
"agent:index-docs": "tsx scripts/agent/registry/docsIndexer.ts"
"agent:index-docs": "tsx scripts/agent/registry/docsIndexer.ts",
"agent:publish-index": "mkdir -p public/agent-index && cp scripts/agent/registry/.vector-store.json public/agent-index/vector-store.json && cp scripts/agent/registry/.docs-vector-store.json public/agent-index/docs-vector-store.json",
"agent:publish-skills": "mkdir -p public/agent-skills && rsync -a --delete scripts/agent/skills/ public/agent-skills/"
},
"dependencies": {
"@bugsnag/js": "^8.9.0",
Expand Down Expand Up @@ -91,6 +93,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"comlink": "^4.4.2",
"date-fns": "^4.2.1",
"dexie": "^4.4.2",
"dexie-react-hooks": "^4.4.0",
Expand All @@ -105,6 +108,9 @@
"mobx-keystone": "^1.21.0",
"mobx-react-lite": "^4.1.1",
"nanoid": "^5.1.11",
"openai": "^6.33.0",
"@openai/agents": "^0.4.0",
"@openai/agents-core": "^0.4.0",
"papaparse": "^5.5.3",
"prism-react-renderer": "^2.4.1",
"pyodide": "^0.29.4",
Expand All @@ -131,7 +137,6 @@
"@babel/plugin-proposal-decorators": "^7.29.0",
"@eslint/js": "^9.39.2",
"@hey-api/openapi-ts": "^0.97.2",
"@langchain/anthropic": "^1.3.26",
"@langchain/classic": "^1.0.30",
"@langchain/core": "^1.1.39",
"@langchain/langgraph": "^1.2.8",
Expand Down Expand Up @@ -166,7 +171,6 @@
"jsdom": "^29.1.1",
"knip": "^6.14.1",
"langchain": "^1.3.1",
"openai": "^6.33.0",
"prettier": "^3.8.3",
"tsx": "^4.22.3",
"typescript": "^5.9.3",
Expand Down
328 changes: 272 additions & 56 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

12 changes: 7 additions & 5 deletions scripts/agent/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,9 +697,11 @@ Agents emit standard Markdown links whose `href` uses a custom protocol:

```markdown
<!-- Entity chip — references a task, input, or output in the current pipeline -->

[Preprocess Data](entity://task_abc123)

<!-- Component chip — references a component from the registry -->

[Train XGBoost](component://train-xgboost-on-csv)
```

Expand All @@ -709,12 +711,12 @@ The link label becomes the chip's display text. The identifier after the protoco

Renders an **EntityChip** (`EntityChip.tsx`) for tasks, inputs, and outputs in the current pipeline.

| Aspect | Detail |
| --------------- | ---------------------------------------------------------------------------------------- |
| **Protocol** | `entity://<entityId>` |
| **Resolved by** | Looks up the entity in the current `rootSpec` (tasks, inputs, outputs) |
| Aspect | Detail |
| --------------- | ------------------------------------------------------------------------------------------------ |
| **Protocol** | `entity://<entityId>` |
| **Resolved by** | Looks up the entity in the current `rootSpec` (tasks, inputs, outputs) |
| **Icon** | Context-aware: `SquareFunction` (task), `ArrowRightToLine` (input), `ArrowLeftFromLine` (output) |
| **Click** | Navigates the editor to the referenced entity (selects + focuses the node on the canvas) |
| **Click** | Navigates the editor to the referenced entity (selects + focuses the node on the canvas) |

### `component://` — Component Registry Chip

Expand Down
40 changes: 40 additions & 0 deletions src/agent/agents/subagents/debugAssistant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Agent } from "@openai/agents";

import { config } from "../../config";
import { attachObservabilityHooks } from "../../middleware/observability";
import debugAssistantPrompt from "../../prompts/debugAssistant.md?raw";
import type { AgentSession, RecentPipelineRun } from "../../session";
import { createCsomTools } from "../../tools/csomTools";
import { executionDebugTools } from "../../tools/debugTools";

function formatRecentRunsContext(runs: RecentPipelineRun[]): string {
if (runs.length === 0) return "";

const lines = runs.map(
(r) =>
`- Run ${r.id} | status: ${r.status ?? "unknown"} | root_exec: ${r.root_execution_id} | by: ${r.created_by} | ${r.created_at}`,
);
return (
"\n\n## Recent Pipeline Runs (from the frontend)\n\n" +
lines.join("\n") +
"\n\nUse these run IDs and root_execution_ids directly — no need to ask the user for them."
);
}

export function createDebugAssistantAgent(session: AgentSession): Agent {
const csom = createCsomTools(session.bridge);
const instructions =
debugAssistantPrompt + formatRecentRunsContext(session.recentRuns);

const agent = new Agent({
name: "debug-assistant",
handoffDescription:
"Help users understand why pipeline runs failed. Fetches run data, analyzes " +
"per-task statuses and error logs, and explains the root cause. Read-only — does not modify the pipeline.",
instructions,
tools: [csom.getPipelineState, ...executionDebugTools],
model: config.subagentModel,
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}
22 changes: 22 additions & 0 deletions src/agent/agents/subagents/generalHelp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Agent } from "@openai/agents";

import { config } from "../../config";
import { attachObservabilityHooks } from "../../middleware/observability";
import generalHelpPrompt from "../../prompts/generalHelp.md?raw";
import type { AgentSession } from "../../session";
import { createSearchComponentsTool } from "../../tools/searchComponents";
import { searchDocsTool } from "../../tools/searchDocs";

export function createGeneralHelpAgent(session: AgentSession): Agent {
const agent = new Agent({
name: "general-help",
handoffDescription:
"Answer general questions about Tangle concepts, features, best practices, " +
"and product behavior. Not specific to the current pipeline.",
instructions: generalHelpPrompt,
tools: [createSearchComponentsTool(session), searchDocsTool],
model: config.subagentModel,
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}
23 changes: 23 additions & 0 deletions src/agent/agents/subagents/genericAssistant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Agent } from "@openai/agents";

import { config } from "../../config";
import { attachObservabilityHooks } from "../../middleware/observability";
import genericAssistantPrompt from "../../prompts/genericAssistant.md?raw";
import type { AgentSession } from "../../session";
import { createCsomTools } from "../../tools/csomTools";
import { createSearchComponentsTool } from "../../tools/searchComponents";

export function createGenericAssistantAgent(session: AgentSession): Agent {
const csom = createCsomTools(session.bridge);
const agent = new Agent({
name: "generic-assistant",
handoffDescription:
"Explain what a pipeline does, describe data flow, clarify component behavior, " +
"and answer questions about the current pipeline state. Read-only — never modifies the pipeline.",
instructions: genericAssistantPrompt,
tools: [csom.getPipelineState, createSearchComponentsTool(session)],
model: config.subagentModel,
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}
29 changes: 29 additions & 0 deletions src/agent/agents/subagents/pipelineArchitect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { Agent } from "@openai/agents";

import { config } from "../../config";
import { attachObservabilityHooks } from "../../middleware/observability";
import architectPrompt from "../../prompts/architect.md?raw";
import type { AgentSession } from "../../session";
import { createCsomTools } from "../../tools/csomTools";
import { pipelineRunTools } from "../../tools/runTools";
import { createSearchComponentsTool } from "../../tools/searchComponents";

export function createPipelineArchitectAgent(session: AgentSession): Agent {
const csom = createCsomTools(session.bridge);
const agent = new Agent({
name: "pipeline-architect",
handoffDescription:
"Build new pipelines or add stages to existing ones. Assembles registry " +
"components into a graph using CSOM tools. Use for constructive tasks like " +
'"build a CSV dedup pipeline" or "add an output stage". Cannot create custom Python components.',
instructions: architectPrompt,
tools: [
...csom.allTools,
createSearchComponentsTool(session),
...pipelineRunTools,
],
model: config.orchestratorModel,
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}
24 changes: 24 additions & 0 deletions src/agent/agents/subagents/pipelineRepair.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Agent } from "@openai/agents";

import { config } from "../../config";
import { attachObservabilityHooks } from "../../middleware/observability";
import pipelineRepairPrompt from "../../prompts/pipelineRepair.md?raw";
import type { AgentSession } from "../../session";
import { createCsomTools } from "../../tools/csomTools";
import { createSearchComponentsTool } from "../../tools/searchComponents";

export function createPipelineRepairAgent(session: AgentSession): Agent {
const csom = createCsomTools(session.bridge);
const agent = new Agent({
name: "pipeline-repair",
handoffDescription:
"Diagnose and fix validation issues, broken connections, missing inputs, and other " +
"structural problems in existing pipelines. Can mutate the pipeline via CSOM tools. " +
"Asks the user for input when fixes are ambiguous.",
instructions: pipelineRepairPrompt,
tools: [...csom.allTools, createSearchComponentsTool(session)],
model: config.orchestratorModel,
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}
79 changes: 79 additions & 0 deletions src/agent/agents/tangleDispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { Agent, MemorySession, run } from "@openai/agents";
import { RECOMMENDED_PROMPT_PREFIX } from "@openai/agents-core/extensions";

import { config, ensureProxyConfigured } from "../config";
import { attachObservabilityHooks } from "../middleware/observability";
import dispatcherPrompt from "../prompts/dispatcher.md?raw";
import type { AgentSession } from "../session";
import { createCsomTools } from "../tools/csomTools";
import { createDebugAssistantAgent } from "./subagents/debugAssistant";
import { createGeneralHelpAgent } from "./subagents/generalHelp";
import { createGenericAssistantAgent } from "./subagents/genericAssistant";
import { createPipelineArchitectAgent } from "./subagents/pipelineArchitect";
import { createPipelineRepairAgent } from "./subagents/pipelineRepair";

// Per-thread session memory lives for the lifetime of the worker. Persisting
// across reloads would require a custom Session backed by Dexie — out of
// scope for this migration.
const sessions = new Map<string, MemorySession>();

function getOrCreateSession(threadId: string): MemorySession {
const existing = sessions.get(threadId);
if (existing) return existing;
const created = new MemorySession({ sessionId: threadId });
sessions.set(threadId, created);
return created;
}

function createDispatcherAgent(session: AgentSession) {
const csom = createCsomTools(session.bridge);
const agent = Agent.create({
name: "tangle-dispatcher",
model: config.orchestratorModel,
instructions: `${RECOMMENDED_PROMPT_PREFIX}\n\n${dispatcherPrompt}`,
tools: [csom.getPipelineState],
handoffs: [
createGenericAssistantAgent(session),
createPipelineArchitectAgent(session),
createPipelineRepairAgent(session),
createDebugAssistantAgent(session),
createGeneralHelpAgent(session),
],
});
attachObservabilityHooks(agent, session.emitStatus);
return agent;
}

export interface DispatcherInvokeParams {
message: string;
threadId: string;
selectedEntityId?: string;
session: AgentSession;
}

export interface DispatcherInvokeResult {
answer: string;
threadId: string;
}

export async function invokeDispatcher(
params: DispatcherInvokeParams,
): Promise<DispatcherInvokeResult> {
ensureProxyConfigured();

const sessionMemory = getOrCreateSession(params.threadId);
const agent = createDispatcherAgent(params.session);

let userContent = params.message;
if (params.selectedEntityId) {
userContent += `\n\n[Context: The user has entity $id="${params.selectedEntityId}" selected in the editor.]`;
}

const result = await run(agent, userContent, { session: sessionMemory });
const answer =
typeof result.finalOutput === "string"
? result.finalOutput
: JSON.stringify(result.finalOutput ?? "");

return { answer, threadId: params.threadId };
}
73 changes: 73 additions & 0 deletions src/agent/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Browser-side agent configuration. Mirrors `scripts/agent/config.ts` but
* sources values from `import.meta.env` instead of `process.env`.
*
* Per the experiment scope, the proxy token is exposed to the browser;
* securing it is out of scope.
*
* Configures `@openai/agents` to talk to the Shopify proxy via the OpenAI
* client (the proxy exposes Anthropic / Claude models through the OpenAI
* Chat Completions surface).
*/
import {
setDefaultOpenAIClient,
setOpenAIAPI,
setTracingDisabled,
} from "@openai/agents";
import OpenAI from "openai";

interface AgentEnv {
VITE_AI_PROXY_BASE_URL?: string;
VITE_AI_PROXY_TOKEN?: string;
VITE_AGENT_ORCHESTRATOR_MODEL?: string;
VITE_AGENT_SUBAGENT_MODEL?: string;
VITE_BACKEND_API_URL?: string;
VITE_AGENT_SKILLS_BASE_URL?: string;
}

const env = (import.meta.env ?? {}) as unknown as AgentEnv;

function requireToken(): string {
const token = env.VITE_AI_PROXY_TOKEN;
if (!token) {
throw new Error(
"VITE_AI_PROXY_TOKEN is not set. The in-browser agent cannot reach the LLM proxy without it.",
);
}
return token;
}

export const config = {
proxyBaseUrl: env.VITE_AI_PROXY_BASE_URL ?? "https://proxy.shopify.ai/v1",
orchestratorModel: env.VITE_AGENT_ORCHESTRATOR_MODEL ?? "claude-sonnet-4-6",
subagentModel: env.VITE_AGENT_SUBAGENT_MODEL ?? "claude-haiku-4-5",
tangleApiUrl: env.VITE_BACKEND_API_URL ?? "http://localhost:8000",
skillsBaseUrl: env.VITE_AGENT_SKILLS_BASE_URL ?? "/agent-skills",
} as const;

let configured = false;

/**
* Wires the Shopify proxy as the default OpenAI client for `@openai/agents`.
* Idempotent — safe to call from every `ask()` turn.
*
* - `setOpenAIAPI("chat_completions")`: Claude models reach us through the
* proxy's Chat Completions surface, not the OpenAI Responses API.
* - `setTracingDisabled(true)`: the OpenAI tracing exporter would otherwise
* try to POST to `api.openai.com`, which is unreachable through the proxy.
*/
export function ensureProxyConfigured(): void {
if (configured) return;
const apiKey = requireToken();
setDefaultOpenAIClient(
new OpenAI({
apiKey,
baseURL: config.proxyBaseUrl,
defaultHeaders: { "X-Shopify-Access-Token": apiKey },
dangerouslyAllowBrowser: true,
}),
);
setOpenAIAPI("chat_completions");
setTracingDisabled(true);
configured = true;
}
Loading
Loading