Skip to content
Open
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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@
"@hyperjump/browser": "^1.3.1",
"@hyperjump/json-schema": "^1.17.6",
"@monaco-editor/react": "^4.7.0",
"@openai/agents": "^0.4.15",
"@openai/agents-core": "^0.4.15",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.3",
Expand Down Expand Up @@ -104,6 +106,7 @@
"mobx-keystone": "^1.21.0",
"mobx-react-lite": "^4.1.1",
"nanoid": "^5.1.11",
"openai": "^6.39.0",
"papaparse": "^5.5.3",
"prism-react-renderer": "^2.4.1",
"pyodide": "^0.29.4",
Expand Down
666 changes: 666 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

66 changes: 66 additions & 0 deletions src/agent/agents/tangleDispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* Top-level dispatcher agent for the in-browser AI assistant.
*/
import { Agent, MemorySession, run } from "@openai/agents";

import { ensureProxyConfigured, requireOrchestratorModel } from "../config";
import { attachObservabilityHooks } from "../middleware/observability";
import dispatcherPrompt from "../prompts/dispatcher.md?raw";
import type { AgentSession } from "../session";
import type { StatusCallback } from "../types";

interface DispatcherInvokeParams {
message: string;
threadId: string;
token: string;
session: AgentSession;
}

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

export interface TangleDispatcher {
invoke(params: DispatcherInvokeParams): Promise<DispatcherInvokeResult>;
}

export function createDispatcher({
emitStatus,
}: {
emitStatus: StatusCallback;
}): TangleDispatcher {
const sessions = new Map<string, MemorySession>();

const agent = Agent.create({
name: "tangle-dispatcher",
model: requireOrchestratorModel(),
instructions: dispatcherPrompt,
tools: [],
handoffs: [],
});
attachObservabilityHooks(agent, emitStatus);

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

return {
async invoke(params) {
ensureProxyConfigured(params.token);
const sessionMemory = getOrCreateSessionMemory(params.threadId);
const result = await run(agent, params.message, {
session: sessionMemory,
});
const answer =
typeof result.finalOutput === "string"
? result.finalOutput
: JSON.stringify(result.finalOutput ?? "");
return { answer, threadId: params.threadId };
},
};
}
20 changes: 20 additions & 0 deletions src/agent/aiTokenStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/**
* Shared IndexedDB-backed storage for the AI assistant's LLM proxy token.
*/
import localforage from "localforage";

const store = localforage.createInstance({
name: "oasis-app",
storeName: "settings",
description: "Store for application settings",
});

const KEY = "aiAssistantProxyToken";

export async function getAiToken(): Promise<string | null> {
return (await store.getItem<string>(KEY)) ?? null;
}

export async function setAiToken(token: string): Promise<void> {
await store.setItem(KEY, token);
}
86 changes: 86 additions & 0 deletions src/agent/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Configuration and one-time setup for the in-browser agent.
*
* Static values (proxy URL, model names, mode) come from the committed
* `src/config/aiAssistantConfig.json` file. The secret proxy token is
* NOT read here — the worker reads it from IndexedDB (via
* `src/agent/aiTokenStore.ts`, shared with the main thread) on every
* `ask()` turn and passes the resolved value into
* `ensureProxyConfigured(token)`, which re-builds the OpenAI client
* when the token rotates.
*
* `proxyMode` exists so a future PR can flip the runtime from
* `"browser-direct"` (current beta) to `"backend-proxy"` without a
* rewrite. Only `"browser-direct"` is implemented in this PR.
*/
import {
setDefaultOpenAIClient,
setOpenAIAPI,
setTracingDisabled,
} from "@openai/agents";
import OpenAI from "openai";

import aiAssistantConfig from "@/config/aiAssistantConfig.json";

export const config = aiAssistantConfig as {
proxyBaseUrl: string;
proxyMode: "browser-direct" | "backend-proxy";
orchestratorModel: string;
subagentModel: string;
};

function requireProxyBaseUrl(): string {
if (!config.proxyBaseUrl) {
throw new Error(
"AI assistant: proxyBaseUrl is empty. Set it in src/config/aiAssistantConfig.json before enabling the ai-assistant beta flag.",
);
}
return config.proxyBaseUrl;
}

export function requireOrchestratorModel(): string {
if (!config.orchestratorModel) {
throw new Error(
"AI assistant: orchestratorModel is empty. Set it in src/config/aiAssistantConfig.json.",
);
}
return config.orchestratorModel;
}

let lastConfiguredToken: string | null = null;

/**
* Wires the configured LLM proxy as the default OpenAI client for
* `@openai/agents`. Called once per turn from the dispatcher with the
* current token. Re-builds the client when the token rotates; otherwise
* a no-op.
*
* - `setOpenAIAPI("chat_completions")`: the proxy exposes Chat
* Completions, not the OpenAI Responses API.
* - `setTracingDisabled(true)`: the SDK's default tracing exporter
* would POST to `api.openai.com`, which is unreachable through the
* proxy.
*/
export function ensureProxyConfigured(token: string): void {
if (config.proxyMode === "backend-proxy") {
throw new Error(
"AI assistant: backend-proxy mode is not implemented yet. Set proxyMode to 'browser-direct' in src/config/aiAssistantConfig.json.",
);
}
if (!token) {
throw new Error(
"AI assistant: missing proxy token. Set it via the AI panel.",
);
}
if (lastConfiguredToken === token) return;
setDefaultOpenAIClient(
new OpenAI({
apiKey: token,
baseURL: requireProxyBaseUrl(),
dangerouslyAllowBrowser: true,
}),
);
setOpenAIAPI("chat_completions");
setTracingDisabled(true);
lastConfiguredToken = token;
}
80 changes: 80 additions & 0 deletions src/agent/middleware/observability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
/**
* Observability hooks for the in-browser agent.
*
* `@openai/agents` exposes lifecycle events on every `Agent` instance
* via its inherited `EventEmitter`. We attach listeners that translate
* the raw events into short status strings and forward them to the
* main thread through the Comlink-proxied status callback.
*
* Wire this on EVERY agent. Once an agent is active after a handoff, only its
* own hooks fire, not the dispatcher's. Without per-agent wiring the
* status line freezes mid-conversation.
*/
import type { Agent } from "@openai/agents";

import type { StatusCallback } from "../types";

const TOOL_STATUS_LABELS: Record<string, string> = {
search_components: "Searching component registry...",
search_docs: "Searching documentation...",
get_pipeline_state: "Reading pipeline state...",
add_task: "Adding task...",
delete_task: "Removing task...",
rename_task: "Renaming task...",
add_input: "Adding input...",
add_output: "Adding output...",
delete_input: "Removing input...",
delete_output: "Removing output...",
connect_nodes: "Connecting nodes...",
delete_edge: "Removing connection...",
set_task_argument: "Configuring task...",
create_subgraph: "Creating subgraph...",
unpack_subgraph: "Unpacking subgraph...",
validate_pipeline: "Validating pipeline...",
submit_pipeline_run: "Submitting run...",
get_run_status: "Checking run status...",
debug_pipeline_run: "Fetching run logs...",
get_pipeline_run: "Fetching run details...",
get_execution_state: "Inspecting execution state...",
get_execution_details: "Fetching execution details...",
get_container_state: "Inspecting container state...",
get_container_log: "Fetching container logs...",
};

const SUB_AGENT_LABELS: Record<string, string> = {
"pipeline-architect": "Building pipeline...",
"pipeline-repair": "Repairing pipeline...",
"debug-assistant": "Analyzing issues...",
"general-help": "Looking up information...",
};

// `Agent<any, any>` matches both the dispatcher (which infers handoff
// output types) and each sub-agent (default `TextOutput`). The hook
// payloads are independent of the agent's generic parameters, so the
// looser type here is intentional.
export function attachObservabilityHooks(
agent: Agent<any, any>,
emitStatus: StatusCallback,
): void {
agent.on("agent_start", () => {
emitStatus({ text: "Thinking..." });
});

agent.on("agent_end", () => {
emitStatus({ text: "Preparing response..." });
});

agent.on("agent_tool_start", (_ctx, toolDef) => {
emitStatus({
text: TOOL_STATUS_LABELS[toolDef.name] ?? "Working...",
});
});

agent.on("agent_handoff", (_ctx, nextAgent) => {
emitStatus({
text:
SUB_AGENT_LABELS[nextAgent.name] ??
`Delegating to ${nextAgent.name}...`,
});
});
}
44 changes: 44 additions & 0 deletions src/agent/prompts/dispatcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Tangle Assistant — System Prompt

You are the **Tangle Assistant**, an AI helper for Tangle Pipeline Studio. Your job in this release is to answer questions about Tangle, ML pipelines, and how to use the product.

## What you can do today

- Explain Tangle concepts (pipelines, tasks, components, runs, executions, inputs/outputs, subgraphs, etc.).
- Discuss ML pipeline patterns and best practices at a general level.
- Suggest approaches the user could take in Tangle Pipeline Studio.

## What you cannot do today

- You **cannot** inspect the user's current pipeline. You have no access to its tasks, connections, arguments, or YAML.
- You **cannot** make changes to the pipeline. You have no tools that mutate the editor state.
- You **cannot** run pipelines, fetch run logs, or look up execution status.

If the user asks for any of the above, be upfront: explain that those abilities are not available yet, and offer what you can — for example, a conceptual explanation, a checklist, or pseudocode they can apply themselves.

## Off-topic handling

If the user asks something unrelated to Tangle, ML pipelines, or data workflows, respond briefly and politely:

> "I'm the Tangle Assistant — I can help with pipeline concepts and how to use Tangle. That question is outside what I can help with today."

Do not attempt to answer off-topic questions.

## Response formatting

Future releases will surface entities and components as interactive chips in the chat panel via these markdown link formats:

```
[Entity Name](entity://$id)
[Component Name](component://component-id)
```

If you ever reference a specific entity or component by id, use those link formats verbatim — do not rewrite them as bold, italic, or backticks. Today you do not have a tool to look up real ids, so only emit these links when the user has already mentioned an id explicitly.

## Style

- Be brief and natural. Aim for a few short paragraphs or a short list, not a wall of text.
- Use plain language. Define jargon when you introduce it.
- When you give steps, number them.
- When code is helpful, use fenced code blocks with an explicit language tag.
- Never apologize for limitations more than once per turn — state them plainly and move on.
13 changes: 13 additions & 0 deletions src/agent/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* Per-request session for the in-browser agent.
*/

export interface AgentSession {
threadId: string;
}

export function createSession(params: { threadId: string }): AgentSession {
return {
threadId: params.threadId,
};
}
2 changes: 2 additions & 0 deletions src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ export interface AgentResponse {
threadId: string;
componentReferences: Record<string, { name: string; yamlText: string }>;
}

export type StatusCallback = (status: { text: string }) => void;
Loading
Loading