Skip to content

Commit 180838a

Browse files
committed
- feat: remote tools and fixes
1 parent beb8c07 commit 180838a

24 files changed

Lines changed: 870 additions & 75 deletions

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ COPY apps/server/package.json apps/server/
5252
COPY apps/web/package.json apps/web/
5353
COPY packages/shared/package.json packages/shared/
5454
COPY packages/build/package.json packages/build/
55+
COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/
5556
RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \
5657
--filter @tangent/server...
5758

@@ -61,6 +62,7 @@ RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false \
6162
COPY apps/server ./apps/server
6263
COPY packages/shared ./packages/shared
6364
COPY packages/build ./packages/build
65+
COPY packages/ui-extensions-sdk ./packages/ui-extensions-sdk
6466

6567
# Produces apps/server/dist/index.js plus its runtime assets (prompts, agents,
6668
# extensions, migrations). Invoked via node directly to avoid pnpm's pre-run

Dockerfile.fullstack

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,12 @@ COPY apps/web/package.json apps/web/
5858
COPY packages/shared/package.json packages/shared/
5959
COPY packages/build/package.json packages/build/
6060
COPY packages/ui-primitives/package.json packages/ui-primitives/
61+
COPY packages/ui-extensions-sdk/package.json packages/ui-extensions-sdk/
6162
COPY packages/windows/package.json packages/windows/
6263
COPY packages/analytics/package.json packages/analytics/
6364
COPY packages/utils/package.json packages/utils/
65+
COPY packages/embed-react/package.json packages/embed-react/
66+
COPY packages/remote-subagent/package.json packages/remote-subagent/
6467
RUN pnpm install --frozen-lockfile --config.strictDepBuilds=false
6568

6669
# Server + UI source plus the shared workspace packages they build against

apps/server/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { createInternalEgressRouter } from "./routes/internalEgress.ts";
3636
import { createInternalExternalAgentsRouter } from "./routes/internalExternalAgents.ts";
3737
import { createInternalMcpRelayRouter } from "./routes/internalMcpRelay.ts";
3838
import { createInternalMemoryRouter } from "./routes/internalMemory.ts";
39+
import { createInternalRemoteToolsRouter } from "./routes/internalRemoteTools.ts";
3940
import { createInternalResourcesRouter } from "./routes/internalResources.ts";
4041
import { createInternalSessionRouter } from "./routes/internalSession.ts";
4142
import { createInternalTriggersRouter } from "./routes/internalTriggers.ts";
@@ -358,6 +359,12 @@ app.use("/internal/session", createInternalSessionRouter(store, emitUiCommand));
358359
// Internal API for bundle extensions to open/answer/close generic MCP relay
359360
// channels bound to their session (remote-runtime specifics stay in the bundle).
360361
app.use("/internal/mcp-relay", createInternalMcpRelayRouter(mcpRelay, store));
362+
// Internal API for the remote-tools extension: list and invoke the RPC tools a
363+
// connected remote environment offers, without spawning a browser sub-agent.
364+
app.use(
365+
"/internal/remote-tools",
366+
createInternalRemoteToolsRouter(remoteGateway),
367+
);
361368

362369
// Mounted last: async failures from any handler above land here with a
363370
// consistent `{ error }` shape (Express 5 forwards rejected promises to it).

apps/server/src/pi/agentConfig.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,16 @@ export const PRIME_ORCHESTRATION_TOOLS = [
4444
* all agents since Pi's `--tools` filter would otherwise strip the extension's
4545
* tool from sub-agents. `pin_artifact` is registered by the session extension
4646
* for every agent, so it must be in the allowlist or Pi would filter it out.
47+
* `list_remote_tools` / `call_remote_tool` are registered by the remote-tools
48+
* extension for every agent, so both must be granted or Pi would strip them.
4749
*/
4850
export const SHARED_AGENT_TOOLS = [
4951
"read_room",
5052
"read_memory",
5153
"message_prime",
5254
"pin_artifact",
55+
"list_remote_tools",
56+
"call_remote_tool",
5357
] as const;
5458

5559
/**
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// @ts-nocheck
2+
/**
3+
* Remote-tools dispatcher extension loaded into every session Pi process via
4+
* `--extension`.
5+
*
6+
* Like the orchestrator/memory extensions, this is authored against Pi's
7+
* extension runtime (it imports modules Pi resolves when loading extensions,
8+
* e.g. `typebox`), not this repo's `node_modules`. It is excluded from our
9+
* type-check (`@ts-nocheck`) and never imported by the server — only passed as a
10+
* path to the Pi subprocess.
11+
*
12+
* A "remote tool" is a named async function hosted by a connected remote
13+
* environment (e.g. a browser embed) and invoked over the `/remote-env`
14+
* WebSocket — no second LLM, no browser sub-agent. Pi freezes its `--tools`
15+
* allowlist at spawn, and the host usually connects after Prime is already
16+
* running, so this ships a stable two-tool dispatcher instead of first-class
17+
* per-host tool names:
18+
* - `list_remote_tools` — the current catalog (changes as hosts connect/leave).
19+
* - `call_remote_tool` — invoke one by name with JSON arguments.
20+
*
21+
* Both are thin clients over this server's internal remote-tools API; the
22+
* gateway owns the socket and routes the call to the session's environment.
23+
*/
24+
25+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
26+
import { Type } from "typebox";
27+
28+
const SESSION_ID = process.env.TANGENT_SESSION_ID ?? "";
29+
const AGENT_ID = process.env.TANGENT_AGENT_ID ?? "";
30+
const INTERNAL_URL = process.env.TANGENT_INTERNAL_URL ?? "";
31+
const INTERNAL_TOKEN = process.env.TANGENT_INTERNAL_TOKEN ?? "";
32+
33+
async function callApi(
34+
method: "GET" | "POST",
35+
endpoint: string,
36+
body?: Record<string, unknown>,
37+
query?: Record<string, string>,
38+
): Promise<unknown> {
39+
const url = new URL(`${INTERNAL_URL}/internal/remote-tools/${endpoint}`);
40+
for (const [key, value] of Object.entries(query ?? {})) {
41+
url.searchParams.set(key, value);
42+
}
43+
44+
const response = await fetch(url, {
45+
method,
46+
headers: {
47+
"content-type": "application/json",
48+
authorization: `Bearer ${INTERNAL_TOKEN}`,
49+
},
50+
body: body ? JSON.stringify(body) : undefined,
51+
});
52+
53+
if (!response.ok) {
54+
const text = await response.text().catch(() => "");
55+
throw new Error(
56+
`internal API ${endpoint} failed (${response.status}): ${text}`,
57+
);
58+
}
59+
return response.json();
60+
}
61+
62+
function textResult(text: string) {
63+
return { content: [{ type: "text", text }], details: {} };
64+
}
65+
66+
/** Renders one JSON-serializable tool result as readable text for the agent. */
67+
function renderResult(value: unknown): string {
68+
if (value === undefined || value === null) return "(no result)";
69+
if (typeof value === "string") return value;
70+
return JSON.stringify(value, null, 2);
71+
}
72+
73+
export default function (pi: ExtensionAPI) {
74+
pi.registerTool({
75+
name: "list_remote_tools",
76+
label: "List Remote Tools",
77+
description:
78+
"List the tools the connected host environment currently offers (e.g. a " +
79+
"browser embedding this session). The catalog is dynamic: it appears when " +
80+
"a host connects and is empty when none is. Call this before " +
81+
"call_remote_tool to see what is available and each tool's arguments.",
82+
promptSnippet: "List the RPC tools the connected host offers",
83+
parameters: Type.Object({}),
84+
async execute() {
85+
const data = (await callApi("GET", "list", undefined, {
86+
sessionId: SESSION_ID,
87+
})) as {
88+
tools: Array<{
89+
name: string;
90+
description: string;
91+
inputSchema: unknown;
92+
}>;
93+
};
94+
95+
if (!data.tools.length) {
96+
return textResult(
97+
"No host environment is connected, so there are no remote tools right now.",
98+
);
99+
}
100+
const lines = data.tools.map(
101+
(tool) =>
102+
`- ${tool.name}: ${tool.description}\n arguments: ${JSON.stringify(tool.inputSchema)}`,
103+
);
104+
return textResult(lines.join("\n"));
105+
},
106+
});
107+
108+
pi.registerTool({
109+
name: "call_remote_tool",
110+
label: "Call Remote Tool",
111+
description:
112+
"Invoke one tool offered by the connected host environment by name, " +
113+
"passing its arguments as a JSON object. Call list_remote_tools first to " +
114+
"learn the available names and each tool's argument schema. Returns the " +
115+
"host's result. Fails clearly if no host is connected or the name is " +
116+
"unknown.",
117+
promptSnippet: "Invoke a host-provided remote tool by name",
118+
parameters: Type.Object({
119+
name: Type.String({
120+
description: "The tool name from list_remote_tools.",
121+
}),
122+
arguments: Type.Optional(
123+
Type.Unknown({
124+
description:
125+
"The tool's arguments as a JSON object matching its inputSchema.",
126+
}),
127+
),
128+
}),
129+
async execute(_toolCallId, params) {
130+
const data = (await callApi("POST", "call", {
131+
sessionId: SESSION_ID,
132+
agentId: AGENT_ID,
133+
name: params.name,
134+
arguments: params.arguments ?? {},
135+
})) as { ok: boolean; result?: unknown; error?: string };
136+
137+
if (!data.ok) {
138+
return textResult(
139+
`Remote tool "${params.name}" failed: ${data.error ?? "unknown error"}`,
140+
);
141+
}
142+
return textResult(renderResult(data.result));
143+
},
144+
});
145+
}

apps/server/src/pi/piAgentManager.ts

Lines changed: 23 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import {
5757
parsePiEvent,
5858
PROXY_PROVIDER_EXTENSION,
5959
readDelta,
60+
REMOTE_TOOLS_EXTENSION,
6061
RESOURCES_EXTENSION,
6162
SESSION_EXTENSION,
6263
toDescriptor,
@@ -234,6 +235,25 @@ function resolveModelArgs(config: AgentConfig): ModelArgs {
234235
return { provider, model, thinking: config.thinkingDepth ?? PI_THINKING };
235236
}
236237

238+
/**
239+
* Built-in extensions loaded into every Pi process, in order. The orchestrator
240+
* gives Prime its sub-agent tools; the proxy-provider registers Pi's providers
241+
* against the LLM proxy (required without an auto-discovered `~/.pi/agent`
242+
* config); memory registers read/remember; resources registers read_resources;
243+
* triggers gives Prime its create/list/enable/disable/delete trigger tools;
244+
* session gives Prime rename_session; remote-tools gives every agent the
245+
* list_remote_tools/call_remote_tool dispatcher.
246+
*/
247+
const BUILTIN_EXTENSIONS = [
248+
ORCHESTRATOR_EXTENSION,
249+
PROXY_PROVIDER_EXTENSION,
250+
MEMORY_EXTENSION,
251+
RESOURCES_EXTENSION,
252+
TRIGGERS_EXTENSION,
253+
SESSION_EXTENSION,
254+
REMOTE_TOOLS_EXTENSION,
255+
];
256+
237257
/** Builds the `pi --mode rpc` CLI args for an agent process. */
238258
function buildPiArgs(
239259
config: AgentConfig,
@@ -259,27 +279,11 @@ function buildPiArgs(
259279
config.tools.join(","),
260280
"--append-system-prompt",
261281
appendPreambles(config, preambles),
262-
// Orchestrator gives Prime its sub-agent tools; the proxy-provider
263-
// extension registers Pi's providers against the LLM proxy (required in
264-
// environments without an auto-discovered `~/.pi/agent` config); the memory
265-
// extension registers the read/remember tools; the resources extension
266-
// registers read_resources; the triggers extension gives Prime its
267-
// create/list/enable/disable/delete trigger tools; the session extension
268-
// gives Prime its rename_session tool.
269-
"--extension",
270-
ORCHESTRATOR_EXTENSION,
271-
"--extension",
272-
PROXY_PROVIDER_EXTENSION,
273-
"--extension",
274-
MEMORY_EXTENSION,
275-
"--extension",
276-
RESOURCES_EXTENSION,
277-
"--extension",
278-
TRIGGERS_EXTENSION,
279-
"--extension",
280-
SESSION_EXTENSION,
281282
];
282283

284+
for (const extension of BUILTIN_EXTENSIONS)
285+
args.push("--extension", extension);
286+
283287
// Bundle-provided skills, workflows, and custom tool extensions, applied to
284288
// every agent in the session so they share the bundle's capabilities.
285289
const flagged: Array<[string, string[]]> = [

apps/server/src/pi/primeSystemPrompt.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,22 @@ that the condition is reached and you have acted on it, tear down **both** sides
109109
dedicated sub-agent). Do not leave a trigger firing or a watcher idling after its
110110
goal is met.
111111

112+
## Host tools
113+
114+
The app embedding this session may connect a host environment that offers its
115+
own tools over an RPC channel (for example, a browser editor exposing functions
116+
to read and mutate what the user is looking at). These are not sub-agents and
117+
cost no extra context — they are plain function calls you make directly.
118+
119+
- `list_remote_tools` — see what the connected host currently offers, including
120+
each tool's arguments. The catalog is dynamic: it appears when a host connects
121+
and is empty when none is, so check it rather than assuming.
122+
- `call_remote_tool` — invoke one by name with a JSON `arguments` object.
123+
124+
Prefer host tools over spawning a sub-agent when the host exposes the capability
125+
you need. If a call reports no host is connected, tell the human the host
126+
(e.g. the editor) is not currently available.
127+
112128
## Session naming
113129

114130
Sessions keep the name they had when they were created. Do not call

apps/server/src/pi/utils.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,18 @@ export const SESSION_EXTENSION = path.join(
7676
"session.ts",
7777
);
7878

79+
/**
80+
* Absolute path to the remote-tools extension loaded into every Pi process. It
81+
* registers the `list_remote_tools` / `call_remote_tool` dispatcher so any agent
82+
* can invoke the RPC tools a connected remote environment offers, without
83+
* spawning a browser sub-agent.
84+
*/
85+
export const REMOTE_TOOLS_EXTENSION = path.join(
86+
import.meta.dirname,
87+
"extensions",
88+
"remoteTools.ts",
89+
);
90+
7991
/** Drops a single optional trailing CR from a line. */
8092
function stripTrailingCr(line: string): string {
8193
return line.endsWith("\r") ? line.slice(0, -1) : line;

0 commit comments

Comments
 (0)