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
132 changes: 132 additions & 0 deletions packages/local-agent-host/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
# @tangent/local-agent-host

The engine that hosts Tangent **sub-agents as local `pi` processes**, used by the
`tangent-local-agent` CLI (`apps/local-agent-cli`).

It connects to a Tangent Shell over the `/remote-env` Socket.IO namespace using
[`@tangent/remote-subagent`](../remote-subagent/README.md), maps each
`spawn` / `message` / `kill` command onto a local `pi --mode rpc` child process
working in the user's open folder, and streams that child's output back as the
same agent events a server-hosted sub-agent produces. It also offers Prime a set
of read-only workspace tools and lets a hosted sub-agent contribute files back
into the Tangent session.

This package is the **host runtime**; the thin CLI that parses flags, resolves a
workspace, and keeps a token fresh lives in `apps/local-agent-cli`.

## Architecture

```mermaid
flowchart LR
shell["Tangent Shell (/remote-env)"] <-->|socket.io| bridge["LocalAgentBridge"]
bridge -->|spawn/message/kill| pi["PiAgent (pi --mode rpc child)"]
pi -->|stdout events| bridge
bridge -->|"agentEvent / subagentUpdate"| shell
pi -->|"put/read session resource"| hostApi["loopback host API"]
hostApi -->|"addResource / readResource"| bridge
```

- **`LocalAgentBridge`** ([src/bridge.ts](src/bridge.ts)) — the core. Holds the
remote-env connection, a roster of hosted `pi` children, and the loopback host
API. `connect(token, environmentId)` (re)connects with a fresh scoped token
without interrupting running sub-agents; `dispose()` tears everything down.
- **`startScopedConnection`** ([src/scopedConnection.ts](src/scopedConnection.ts))
— keeps the bridge connected with a self-renewing token: mints, connects, and
schedules a refresh before expiry, pinning the environment id across refreshes.
- **`mintRemoteEnvToken`** ([src/remoteEnvToken.ts](src/remoteEnvToken.ts)) —
mints a selector/session/user-scoped remote-env token from the Shell.
- **`PiAgent`** ([src/piAdapter.ts](src/piAdapter.ts)) — one long-lived
`pi --mode rpc` child driven over stdin/stdout JSONL. `buildPiArgs` and
`piEnv` assemble its launch; `src/piEvents.ts` translates pi's stdout into
remote-env agent events.
- **Remote file tools** ([src/remoteFileTools.ts](src/remoteFileTools.ts)) —
`createFileTools(getRoot)` returns the read-only workspace tools
(`read_file` / `list_dir` / `find_files` / `search_text`) Prime calls over
`/remote-env` without spawning a sub-agent. All paths resolve under the open
folder, with the guards in [src/workspaceFs.ts](src/workspaceFs.ts).

## Usage

```ts
import { LocalAgentBridge } from "@tangent/local-agent-host/bridge.ts";
import { createFileTools } from "@tangent/local-agent-host/remoteFileTools.ts";
import { startScopedConnection } from "@tangent/local-agent-host/scopedConnection.ts";

const bridge = new LocalAgentBridge({
url: "http://localhost:8787",
selector: "projectId:abc",
metadata: { editor: "my-editor", workspacePath: "/Users/me/proj" },
tools: createFileTools(() => "/Users/me/proj"),
piLaunchOptions: async () => ({
bin: "pi",
cwd: "/Users/me/proj",
proxyExtensionPath: "/path/to/proxyProvider.ts",
sessionResourceExtensionPath: "/path/to/sessionResources.ts",
}),
log: console.log,
onChange: () => {},
onSpawned: (agent) => console.log(`started ${agent.name}`),
});

// Keep a selector-scoped host connected with a self-renewing token:
const stop = startScopedConnection({
shellUrl: "http://localhost:8787",
selector: "projectId:abc",
email: "me@example.com",
connect: (token, environmentId) => bridge.connect(token, environmentId),
onError: console.error,
});
```

## Session resources

A hosted `pi` sub-agent works in the user's **local** workspace, which is not the
Tangent session. So the host gives each sub-agent a small set of tools to push
files/blobs into the session — where Prime and every other agent (including
agents hosted on a different environment) can see and fetch them.

The chain:

1. The sub-agent calls `put_session_resource` / `list_session_resources` /
`read_session_resource`, registered by the bundled pi extension
[resources/sessionResources.ts](resources/sessionResources.ts) (loaded via
`--extension` when a host API is configured).
2. The extension calls back into the host's **loopback HTTP API**
([src/hostApi.ts](src/hostApi.ts)) — bound to `127.0.0.1` on an ephemeral
port and guarded by a random per-process bearer token, so only this host's own
sub-agents can reach it.
3. The bridge forwards each call to the live connection's
`addResource` / `listResources` / `readResource`, which the Shell's gateway
services against the session catalog.

`put_session_resource` takes either inline `content` (for small text/blobs, with
`encoding: 'base64'` for binary) or a `sourcePath` — a local file path the tool
reads off disk itself, resolved against the sub-agent's working folder — so large
files transfer without the agent inlining their bytes. Provide exactly one; a
`sourcePath` read is sent base64 and rejected locally when it exceeds 1 MiB.

`buildPiArgs` loads the extension and `piEnv` injects `TANGENT_HOST_API_URL`,
`TANGENT_HOST_API_TOKEN`, `TANGENT_SESSION_ID`, and `TANGENT_AGENT_ID` only when a
`hostApiUrl` is present on the launch options; the bridge starts the loopback
lazily on the first spawn and closes it on `dispose()`. A `file` resource lands
under the session's `uploads/` folder (binary rides base64), catalogued and
surfaced to Prime; fetches are capped at 1 MiB. See the protocol and server side
in [`@tangent/remote-subagent`](../remote-subagent/README.md#session-resources).

## Bundled extensions (`resources/`)

`pi` loads these from disk, so a host copies them next to its bundle (the CLI's
`esbuild.mjs` does this). They are authored against pi's extension runtime, not
this repo's `node_modules`, so they are excluded from type-check and lint:

- [resources/proxyProvider.ts](resources/proxyProvider.ts) — points pi's model
providers at the Shell's pi proxy (loaded only when a `proxyUrl` is set).
- [resources/sessionResources.ts](resources/sessionResources.ts) — the session
resource tools described above (loaded only when a host API is configured).

## Exports

Consumers import modules directly, e.g. `@tangent/local-agent-host/bridge.ts`.
Key entry points: `bridge.ts` (`LocalAgentBridge`), `scopedConnection.ts`,
`remoteEnvToken.ts`, `piAdapter.ts`, `remoteFileTools.ts`, `hostApi.ts`,
`shellUrl.ts`, and the `resources/*` extensions.
3 changes: 3 additions & 0 deletions packages/local-agent-host/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import node from "@tangent/build/eslint/node";

export default [{ ignores: ["node_modules", "resources"] }, ...node];
29 changes: 29 additions & 0 deletions packages/local-agent-host/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@tangent/local-agent-host",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./resources/*": "./resources/*",
"./*.ts": "./src/*.ts",
"./*": "./src/*.ts"
},
"prettier": "@tangent/build/prettier",
"scripts": {
"lint": "eslint .",
"test": "tsx --test \"src/**/*.test.ts\"",
"typecheck": "tsc --noEmit",
"format": "prettier --write ."
},
"dependencies": {
"@tangent/remote-subagent": "workspace:*",
"@tangent/shared": "workspace:*"
},
"devDependencies": {
"@tangent/build": "workspace:*",
"@types/node": "^25.9.1",
"eslint": "^10.4.0",
"tsx": "^4.22.3",
"typescript": "^6.0.3"
}
}
38 changes: 38 additions & 0 deletions packages/local-agent-host/resources/proxyProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// @ts-nocheck
/**
* Loaded into local pi sub-agents via `--extension` when a pi proxy URL is set.
* Hosts copy it next to their bundle. Authored against pi's extension runtime
* (loaded with jiti), so it is excluded from this package's type-check and
* lint. Mirrors the server's `apps/server/src/pi/extensions/proxyProvider.ts`.
*/

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";

const PROXY_URL = (process.env.PI_PROXY_URL ?? "").replace(/\/+$/, "");

export default function (pi: ExtensionAPI) {
pi.registerProvider("anthropic", {
baseUrl: `${PROXY_URL}/apis/anthropic`,
apiKey: "$PI_PROXY_API_KEY",
});

pi.registerProvider("openai", {
baseUrl: `${PROXY_URL}/v1`,
apiKey: "$PI_PROXY_API_KEY",
});

pi.registerProvider("google", {
baseUrl: `${PROXY_URL}/apis/google/v1`,
apiKey: "$PI_PROXY_API_KEY",
});

pi.registerProvider("groq", {
baseUrl: `${PROXY_URL}/vendors/groq/openai/v1`,
apiKey: "$PI_PROXY_API_KEY",
});

pi.registerProvider("xai", {
baseUrl: `${PROXY_URL}/vendors/xai/v1`,
apiKey: "$PI_PROXY_API_KEY",
});
}
187 changes: 187 additions & 0 deletions packages/local-agent-host/resources/sessionResources.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// @ts-nocheck
/**
* Loaded into local pi sub-agents via `--extension` so an agent working in the
* user's local workspace can contribute files/blobs to the Tangent *session* —
* where Prime and other agents (including those on other environments) can see
* and fetch them. Hosts copy it next to their bundle. Authored against pi's
* extension runtime (loaded with jiti), so it is excluded from this package's
* type-check and lint. Mirrors the server's host-resource extension, but calls
* the host's loopback API instead of the server's internal API.
*/

import { readFile } from "node:fs/promises";
import { basename, resolve } from "node:path";

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";

const API_URL = (process.env.TANGENT_HOST_API_URL ?? "").replace(/\/+$/, "");
const API_TOKEN = process.env.TANGENT_HOST_API_TOKEN ?? "";
const SESSION_ID = process.env.TANGENT_SESSION_ID ?? "";
const AGENT_ID = process.env.TANGENT_AGENT_ID ?? "";

/** Matches the server's per-file resource cap (MAX_FILE_RESOURCE_BYTES). */
const MAX_FILE_BYTES = 1024 * 1024;

async function callHostApi(route: string, payload: unknown): Promise<unknown> {
if (!API_URL) throw new Error("Session resources are unavailable here.");
const response = await fetch(`${API_URL}/${route}`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${API_TOKEN}`,
},
body: JSON.stringify(payload),
});
const data = (await response.json().catch(() => ({}))) as {
ok?: boolean;
result?: unknown;
error?: string;
};
if (!response.ok || !data.ok) {
throw new Error(
data.error ?? `host API ${route} failed (${response.status})`,
);
}
return data.result;
}

function textResult(text: string) {
return { content: [{ type: "text", text }], details: {} };
}

function renderList(resources: Array<{ name: string; uri: string }>): string {
if (!resources || resources.length === 0) return "(no session resources)";
return resources.map((r) => `- ${r.name} (${r.uri})`).join("\n");
}

export default function (pi: ExtensionAPI) {
pi.registerTool({
name: "put_session_resource",
label: "Put Session Resource",
description:
"Write a file/blob into the Tangent session so Prime and other agents " +
"(including those on other environments) can fetch it. Pass 'content' " +
"inline for small pieces (binary must be base64 with encoding: 'base64'), " +
"or pass 'sourcePath' to upload a local file the tool reads itself without " +
"inlining its bytes. Provide exactly one of content/sourcePath. The decoded " +
"size limit is 1 MiB.",
promptSnippet: "Contribute a file to the Tangent session",
parameters: Type.Object({
path: Type.String({
description: "Destination path inside the session's uploads/ folder.",
}),
content: Type.Optional(
Type.String({
description:
"Inline file contents (text, or base64). Use for small pieces.",
}),
),
sourcePath: Type.Optional(
Type.String({
description:
"Local file path the tool reads itself (preferred for large files). " +
"Resolved against the sub-agent's working folder.",
}),
),
encoding: Type.Optional(
Type.Union([Type.Literal("utf8"), Type.Literal("base64")], {
description:
"How inline content is decoded. Defaults to utf8. Ignored for sourcePath.",
}),
),
name: Type.Optional(
Type.String({ description: "Catalog display name; defaults to path." }),
),
contentType: Type.Optional(
Type.String({ description: "MIME type recorded in the catalog meta." }),
),
}),
async execute(
_toolCallId: string,
args: {
path: string;
content?: string;
sourcePath?: string;
encoding?: "utf8" | "base64";
name?: string;
contentType?: string;
},
) {
const hasContent = args.content !== undefined;
const hasSource = args.sourcePath !== undefined && args.sourcePath !== "";
if (hasContent === hasSource) {
throw new Error("Provide exactly one of 'content' or 'sourcePath'.");
}

let content = args.content ?? "";
let encoding: "utf8" | "base64" = args.encoding ?? "utf8";
let defaultName = args.path;
if (hasSource) {
const absolute = resolve(process.cwd(), args.sourcePath as string);
const bytes = await readFile(absolute);
if (bytes.byteLength > MAX_FILE_BYTES) {
throw new Error(
`File is ${bytes.byteLength} bytes; the session resource limit is 1 MiB.`,
);
}
content = bytes.toString("base64");
encoding = "base64";
defaultName = basename(absolute);
}

const input = {
kind: "file",
path: args.path,
content,
encoding,
name: args.name ?? defaultName,
...(args.contentType
? { meta: { contentType: args.contentType } }
: {}),
};
const resource = (await callHostApi("put", {
sessionId: SESSION_ID,
agentId: AGENT_ID,
input,
})) as { uri: string };
return textResult(`Added session resource ${resource.uri}`);
},
});

pi.registerTool({
name: "list_session_resources",
label: "List Session Resources",
description:
"List the files and resources catalogued in the Tangent session, " +
"including those contributed by Prime and agents on other environments.",
promptSnippet: "List the Tangent session's resources",
parameters: Type.Object({}),
async execute() {
const resources = (await callHostApi("list", {
sessionId: SESSION_ID,
})) as Array<{ name: string; uri: string }>;
return textResult(`## Session resources\n\n${renderList(resources)}`);
},
});

pi.registerTool({
name: "read_session_resource",
label: "Read Session Resource",
description:
"Read a Tangent session resource's contents by its uri (e.g. " +
"'uploads/report.csv'), including content another environment contributed.",
promptSnippet: "Read a Tangent session resource",
parameters: Type.Object({
uri: Type.String({ description: "Session-root-relative resource uri." }),
}),
async execute(_toolCallId: string, args: { uri: string }) {
const result = (await callHostApi("read", {
sessionId: SESSION_ID,
uri: args.uri,
})) as { content: string; contentType: string };
const text = Buffer.from(result.content, "base64").toString("utf8");
return textResult(text);
},
});
}
Loading
Loading