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
19 changes: 16 additions & 3 deletions src/agent/agents/remoteEditorAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createComponentSearchTools } from "../tools/componentSearchTools";
import { createCsomTools } from "../tools/csomTools";
import { createDebugTools } from "../tools/debugTools";
import { createRunTools } from "../tools/runTools";
import { createSessionResourceTools } from "../tools/sessionResourceTools";
import type { AgentContext } from "../types";

export interface RemoteAgentSpec {
Expand Down Expand Up @@ -111,7 +112,9 @@ export function selectRemoteEditorTools(
return registry.filter((toolDef) => allowed.has(toolDef.name));
}

function formatToolAvailabilitySection(tools: RemoteEditorTool[]): string {
function formatToolAvailabilitySection(
tools: readonly { name: string }[],
): string {
const names = tools.map((toolDef) => `\`${toolDef.name}\``).join(", ");
const available = names || "none";
return `\n\n## Available tools\n\nYou have exactly these tools: ${available}. Do not attempt workflow steps that require a tool outside this list. If a missing tool prevents the requested work, state that limitation clearly.`;
Expand Down Expand Up @@ -150,7 +153,7 @@ function formatCurrentRunSection(context: AgentContext): string {
function buildInstructions(
session: AgentSession,
spec: RemoteAgentSpec,
tools: RemoteEditorTool[],
tools: readonly { name: string }[],
): string {
const base =
session.context.mode === "runView" ? remoteRunPrompt : remoteEditorPrompt;
Expand All @@ -173,7 +176,17 @@ export function buildRemoteEditorAgent(
model,
reasoningEffort: getInheritedReasoningEffort(session.aiConfig, model),
});
const tools = selectRemoteEditorTools(session, spec.tools);
// Session-resource tools are a host capability, not a server-granted CSOM
// tool, so they are always attached (outside the spawn allowlist) — mirroring
// the pi host, whose resource extension is likewise always registered. They
// appear only when the host wired a resource bridge for this turn.
const resourceTools = session.resources
? createSessionResourceTools(session.resources).allTools
: [];
const tools = [
...selectRemoteEditorTools(session, spec.tools),
...resourceTools,
];
const effort = reasoningEffort(spec.thinkingDepth);
const agent = new Agent({
name: "tangle-remote-editor",
Expand Down
5 changes: 4 additions & 1 deletion src/agent/createRemoteEnvWorkerApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
} from "./middleware/agentTrace";
import { recordTurnReasoning } from "./middleware/recordTurnReasoning";
import { createSession, type RecentPipelineRun } from "./session";
import type { SessionResourceBridge } from "./sessionResourceBridge";
import { SkillsLoader } from "./skills/loader";
import type { ToolBridgeApi } from "./toolBridgeApi";
import type { AgentContext, StatusCallback } from "./types";
Expand Down Expand Up @@ -71,6 +72,7 @@ export interface RemoteEnvWorkerApi {
runTurn(
params: RemoteRunTurnParams,
onStatus: StatusCallback,
resources?: SessionResourceBridge,
): Promise<RemoteRunTurnResult>;
abortAgent(agentId: string): void;
killAgent(agentId: string): void;
Expand Down Expand Up @@ -153,7 +155,7 @@ export function createRemoteEnvWorkerApi(): RemoteEnvWorkerApi {
});
},

async runTurn({ agentId, message, recentRuns }, onStatus) {
async runTurn({ agentId, message, recentRuns }, onStatus, resources) {
const hosted = agents.get(agentId);
if (!hosted) {
throw new Error(
Expand Down Expand Up @@ -185,6 +187,7 @@ export function createRemoteEnvWorkerApi(): RemoteEnvWorkerApi {
recentRuns,
context,
componentCatalog: hosted.componentCatalog,
...(resources ? { resources } : {}),
});

// Once per spawn, not per turn: the allowlist is fixed when the agent is
Expand Down
4 changes: 4 additions & 0 deletions src/agent/prompts/remoteEditor.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ Two things to know before calling it:

Layout is not correctness: never use it to try to resolve a validation issue.

## Session resources

The session has a shared resource catalog — files Prime, the user, or agents on other environments contributed (e.g. an uploaded `uploads/*.pipeline.yaml`). When a directive points at such a file, that is where it lives: `list_session_resources` shows the catalog, `read_session_resource` fetches one by its uri (e.g. `uploads/report.yaml`), and `put_session_resource` writes a file back into the session's `uploads/` folder for others to fetch. These are separate from the live canvas: reading a resource does not change the pipeline, and editing the pipeline does not touch the catalog.

## Active subgraph context

`get_pipeline_state` may include an `activeSubgraphPath` breadcrumb of subgraph task names from the root pipeline to whatever subgraph the user is viewing. Treat it as a hint about what the user cares about, but remember every CSOM mutation applies to the root spec. If a change targets an entity inside a nested subgraph, point that out before editing.
Expand Down
4 changes: 4 additions & 0 deletions src/agent/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { AiProviderConfig } from "@/types/aiProvider";
import type { ComponentCatalog } from "./componentCatalog";
import { createComponentCatalog } from "./componentCatalog";
import type { ProxyClient } from "./config";
import type { SessionResourceBridge } from "./sessionResourceBridge";
import type { SkillsLoader } from "./skills/loader";
import type { ToolBridgeApi } from "./toolBridgeApi";
import type { AgentContext, AgentResponse, StatusCallback } from "./types";
Expand All @@ -29,6 +30,7 @@ export interface AgentSession {
context: AgentContext;
componentReferences: AgentResponse["componentReferences"];
componentCatalog: ComponentCatalog;
resources?: SessionResourceBridge;
}

export function createSession(params: {
Expand All @@ -41,6 +43,7 @@ export function createSession(params: {
emitStatus?: StatusCallback;
recentRuns?: RecentPipelineRun[];
componentCatalog?: ComponentCatalog;
resources?: SessionResourceBridge;
}): AgentSession {
return {
threadId: params.threadId,
Expand All @@ -53,5 +56,6 @@ export function createSession(params: {
context: params.context,
componentReferences: {},
componentCatalog: params.componentCatalog ?? createComponentCatalog(),
...(params.resources ? { resources: params.resources } : {}),
};
}
14 changes: 14 additions & 0 deletions src/agent/sessionResourceBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Main-thread access to the Tangent session's resource catalog, handed to the
* worker as a Comlink proxy per turn. The socket client that backs it lives in
* `remoteEnvHost.ts`; the worker's sub-agent reaches it only through this
* narrow surface, scoped to the turn's session.
*/
import type { RemoteResourceInput } from "@tangent/remote-subagent";
import type { Resource } from "@tangent/shared/contracts.ts";

export interface SessionResourceBridge {
list(): Promise<Resource[]>;
read(uri: string): Promise<{ content: string; contentType: string }>;
put(input: RemoteResourceInput): Promise<Resource>;
}
101 changes: 101 additions & 0 deletions src/agent/tools/sessionResourceTools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* Session-resource tools for the remote editor sub-agent.
*
* These mirror the pi host's session-resource extension so a browser sub-agent
* can see and fetch files Prime or agents on other environments contributed to
* the Tangent session (e.g. an uploaded `uploads/*.yaml`). They are host
* capabilities rather than CSOM mutations: each call reaches the main-thread
* socket client through the proxied `SessionResourceBridge`, scoped to the
* turn's session.
*/
import { tool } from "@openai/agents";
import type { Resource } from "@tangent/shared/contracts.ts";
import { z } from "zod";

import { decodeBase64Utf8 } from "@/utils/string";

import type { SessionResourceBridge } from "../sessionResourceBridge";

function renderResourceList(resources: Resource[]): string {
if (resources.length === 0) return "(no session resources)";
return resources
.map(
(resource) => `- ${resource.name} (${resource.uri}) [${resource.kind}]`,
)
.join("\n");
}

export function createSessionResourceTools(resources: SessionResourceBridge) {
const listSessionResources = tool({
name: "list_session_resources",
description:
"List the files and resources catalogued in the Tangent session, including those contributed by Prime and agents on other environments. Returns each resource's name and uri.",
parameters: z.object({}),
execute: async () => {
const catalog = await resources.list();
return `## Session resources\n\n${renderResourceList(catalog)}`;
},
});

const readSessionResource = tool({
name: "read_session_resource",
description:
"Read a Tangent session resource's contents by its uri (e.g. 'uploads/report.yaml'), including content another environment contributed. Returns the decoded text.",
parameters: z.object({
uri: z
.string()
.describe(
"Session-root-relative resource uri, e.g. 'uploads/report.yaml'.",
),
}),
execute: async ({ uri }) => {
const { content } = await resources.read(uri);
return decodeBase64Utf8(content);
},
});

const putSessionResource = tool({
name: "put_session_resource",
description:
"Write a file into the Tangent session's uploads/ folder so Prime and other agents (including those on other environments) can fetch it. Binary content must be base64 with encoding 'base64'.",
parameters: z.object({
path: z
.string()
.describe("Destination path inside the session's uploads/ folder."),
content: z.string().describe("File contents (text, or base64)."),
encoding: z
.enum(["utf8", "base64"])
.nullable()
.optional()
.describe("How content is decoded. Defaults to utf8."),
name: z
.string()
.nullable()
.optional()
.describe("Catalog display name; defaults to the path basename."),
contentType: z
.string()
.nullable()
.optional()
.describe("MIME type recorded in the catalog meta."),
}),
execute: async ({ path, content, encoding, name, contentType }) => {
const stored = await resources.put({
kind: "file",
path,
content,
encoding: encoding ?? "utf8",
...(name ? { name } : {}),
...(contentType ? { meta: { contentType } } : {}),
});
return `Added session resource ${stored.uri}`;
},
});

return {
listSessionResources,
readSessionResource,
putSessionResource,
allTools: [listSessionResources, readSessionResource, putSessionResource],
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,10 @@ import { copyRunToPipeline } from "@/services/pipelineRunService";
import { usePipelineStorage } from "@/services/pipelineStorage/PipelineStorageProvider";
import type { PipelineStorageService } from "@/services/pipelineStorage/PipelineStorageService";
import type { RemotePipelineRef } from "@/services/pipelineStorage/types";
import { createAndAttachPipeline } from "@/services/projects/createAndAttachPipeline";
import {
createAndAttachPipeline,
importAndAttachPipeline,
} from "@/services/projects/createAndAttachPipeline";
import {
pipelineFileTarget,
remotePipelineResourceInput,
Expand All @@ -53,6 +56,7 @@ import { extractCanonicalName } from "@/utils/canonicalPipelineName";
import { isValidComponentSpec } from "@/utils/componentSpec";
import { getInitialName } from "@/utils/getComponentName";
import { extractCloneableTaskArguments } from "@/utils/nodes/taskArguments";
import { decodeBase64Utf8 } from "@/utils/string";

interface TangentProjectAgentProviderProps {
sessionId: string | undefined;
Expand Down Expand Up @@ -131,6 +135,9 @@ export function TangentProjectAgentProvider({
sessionId ? { projectId: store.projectId, sessionId } : undefined,
);
const workerRef = useRef<Comlink.Remote<RemoteEnvWorkerApi> | null>(null);
const readSessionResourceRef = useRef<
((uri: string) => Promise<string>) | null
>(null);

// A project-level backend bridge for the run inspect tools: read-only run and
// execution fetches that don't need any one tab's canvas spec, so Prime can
Expand Down Expand Up @@ -230,6 +237,25 @@ export function TangentProjectAgentProvider({
target: pipelineFileTarget(file),
};
},
importPipeline: async (yaml, name) => {
const file = await importAndAttachPipeline({
storage,
yaml,
name,
attach: (input) => createProjectResource(store.projectId, input),
});
await refreshProjectResources();
return { pipelineName: file.storageKey, fileId: file.id };
},
readSessionResource: async (uri) => {
const read = readSessionResourceRef.current;
if (!read) {
throw new Error(
"The session environment is not connected, so its resources cannot be read yet.",
);
}
return read(uri);
},
clonePipeline: async (runId) => {
const { pipelineName, remote } = await clonePipelineFromRun(
storage,
Expand Down Expand Up @@ -298,6 +324,11 @@ export function TangentProjectAgentProvider({
metadata: describeBrowserEnv({ mode: "editor" }),
});

readSessionResourceRef.current = async (uri) => {
const { content } = await host.readResource(uri);
return decodeBase64Utf8(content);
};

const stop = connectRemoteEnvWithRefresh({
host,
baseUrl,
Expand All @@ -314,6 +345,7 @@ export function TangentProjectAgentProvider({
host.disconnect();
worker.terminate();
workerRef.current = null;
readSessionResourceRef.current = null;
};
}, [sessionId, tools, routingBridge, agentTargets, baseUrl]);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ function makeHost(): RemoteEnvHost {
return {
connect: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
readResource: vi.fn(),
};
}

Expand Down
Loading
Loading