feat(coding-agent): added tool.<name>(args) execution in Python prelude

- Added `tool.<name>(args)` execution in Python prelude and docs, enabling direct tool call syntax.
- Added per-execution Python tool-bridge registration, env wiring, and executor lifecycle management.
- Implemented authenticated loopback `/v1/tool` handling with session/name validation and structured responses.
- Updated runtime and lint handling to permit controlled global eval usage in indirect eval paths.
- Added tests for success, failures, invalid bodies, and `emitStatus` propagation in Python tool bridges.
This commit is contained in:
can1357
2026-05-12 08:43:36 +02:00
parent 2f7b2aaccd
commit dcf3f1c8a3
10 changed files with 444 additions and 8 deletions
+3 -1
View File
@@ -1,13 +1,15 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed the `timeoutMs` execution option to no longer be enforced during worker-based JS runs, so callers must rely on external cancellation signals for time limits
### Added
- Added Python `tool.<name>(args)` support to `executePython` sessions so evaluated Python code can invoke session tools through the prelude `tool` proxy
- Added per-execution Python tool bridge session registration and loopback endpoint wiring so Python tool calls resolve to host tools and return tool results
- Added status-event forwarding for Python tool bridge calls so `tool` invocations can emit execution status updates
- Added browser-tab JavaScript execution through the shared runtime so tab runs now expose the standard helper globals (`read`, `write`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output`, `display`, and `tool`)
- Added static ESM `import` support to browser-tab JavaScript by rewriting top-level imports and resolving them against the tab session context
- Added substring fallback matching to `HistoryStorage.search` so infix and short-token queries that FTS5 prefix matching misses are still returned
@@ -14,6 +14,10 @@ export function indirectEval(source: string, filename?: string): unknown {
const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
// Read `eval` via a property access so the call site is *indirect* (global scope),
// not direct (this module's lexical scope). The cast erases the DOM lib return type.
// We deliberately avoid `node:vm` because Bun crashes the parent with SIGTRAP when
// Worker.terminate() fires mid-`vm.runInContext` synchronous loop — indirect eval is
// the executor for user code in the worker.
// biome-ignore lint/security/noGlobalEval: see comment above — this is the executor.
const geval = globalThis.eval as (src: string) => unknown;
return geval(withPragma);
}
@@ -172,7 +172,6 @@ export function demoteTopLevelLexicals(code: string): string {
return result;
}
/**
* Strip TypeScript syntax (type annotations, `interface`, `as`, `satisfies`, generics in
* call expressions, etc.) before the import/lexical rewriters parse the code. We use Bun's
@@ -34,7 +34,6 @@ export interface RuntimeOptions {
* via `setRunScope()` instead.
*/
extraGlobals?: Record<string, unknown>;
}
/**
@@ -148,7 +147,6 @@ function formatConsoleArgs(args: unknown[]): string {
.join(" ");
}
function buildRequire(cwd: string): NodeJS.Require {
return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
}
+62 -1
View File
@@ -1,5 +1,7 @@
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
import { OutputSink } from "../../session/streaming-output";
import type { ToolSession } from "../../tools";
import type { JsStatusEvent } from "../js/shared/types";
import { shutdownSharedGateway } from "./gateway-coordinator";
import {
checkPythonKernelAvailability,
@@ -8,6 +10,7 @@ import {
type KernelExecuteResult,
PythonKernel,
} from "./kernel";
import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
const MAX_KERNEL_SESSIONS = 4;
@@ -49,6 +52,18 @@ export interface PythonExecutorOptions {
/** Artifact path/id for full output storage */
artifactPath?: string;
artifactId?: string;
/**
* ToolSession used to resolve host-side `tool.<name>(args)` calls made from
* the Python prelude's bridge proxy. When omitted, the bridge env vars are
* not injected and any `tool.foo(...)` raises in Python.
*/
toolSession?: ToolSession;
/** Callback for status events emitted by tool bridge invocations. */
emitStatus?: (event: JsStatusEvent) => void;
/** @internal Bridge session id, set by `executePython` before delegating. */
bridgeSessionId?: string;
/** @internal Bridge endpoint info, set by `executePython` before delegating. */
bridge?: { url: string; token: string };
}
export interface PythonKernelExecutor {
@@ -113,6 +128,10 @@ interface KernelSessionExecutionOptions {
signal?: AbortSignal;
deadlineMs?: number;
kernelOwnerId?: string;
/** Bridge session identifier exported into the kernel env as PI_TOOL_BRIDGE_SESSION. */
bridgeSessionId?: string;
/** Cached bridge connection info. When present, env vars for tool.<name>() get injected. */
bridge?: { url: string; token: string };
}
class PythonExecutionCancelledError extends Error {
@@ -137,10 +156,20 @@ function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineM
* directory (preferred by the prelude when resolving output IDs, so subagents
* see the parent's flat dir instead of a non-existent sibling).
*/
function buildKernelEnv(options: { sessionFile?: string; artifactsDir?: string }): Record<string, string> | undefined {
function buildKernelEnv(options: {
sessionFile?: string;
artifactsDir?: string;
bridgeSessionId?: string;
bridge?: { url: string; token: string };
}): Record<string, string> | undefined {
const env: Record<string, string> = {};
if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
if (options.bridge && options.bridgeSessionId) {
env.PI_TOOL_BRIDGE_URL = options.bridge.url;
env.PI_TOOL_BRIDGE_TOKEN = options.bridge.token;
env.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId;
}
return Object.keys(env).length > 0 ? env : undefined;
}
@@ -871,6 +900,20 @@ async function executeWithKernel(
const deadlineMs = getExecutionDeadlineMs(options);
let executionTimeoutMs: number | undefined;
const emitStatus =
options?.emitStatus ??
((event: JsStatusEvent) => {
displayOutputs.push({ type: "status", event });
});
const unregisterBridge =
options?.toolSession && options?.bridgeSessionId
? registerPyToolBridge(options.bridgeSessionId, {
toolSession: options.toolSession,
signal: options.signal,
emitStatus,
})
: null;
try {
executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
const result = await kernel.execute(code, {
@@ -923,6 +966,8 @@ async function executeWithKernel(
const error = err instanceof Error ? err : new Error(String(err));
logger.error("Python execution failed", { error: error.message });
throw error;
} finally {
unregisterBridge?.();
}
}
@@ -954,7 +999,20 @@ export async function executePython(code: string, options?: PythonExecutorOption
const kernelMode = executionOptions.kernelMode ?? "session";
if (executionOptions.toolSession && !executionOptions.bridge) {
try {
executionOptions.bridge = await ensurePyToolBridge();
} catch (err) {
logger.warn("Failed to start Python tool bridge", {
error: err instanceof Error ? err.message : String(err),
});
}
}
if (kernelMode === "per-call") {
if (executionOptions.bridge && !executionOptions.bridgeSessionId) {
executionOptions.bridgeSessionId = `py-bridge:${crypto.randomUUID()}`;
}
const env = buildKernelEnv(executionOptions);
requireRemainingTimeoutMs(deadlineMs);
const startOptions = buildKernelStartOptions(cwd, env, executionOptions);
@@ -967,6 +1025,9 @@ export async function executePython(code: string, options?: PythonExecutorOption
}
const sessionId = executionOptions.sessionId ?? `session:${cwd}`;
if (executionOptions.bridge && !executionOptions.bridgeSessionId) {
executionOptions.bridgeSessionId = sessionId;
}
if (executionOptions.reset) {
const existing = kernelSessions.get(sessionId);
if (existing) {
@@ -41,6 +41,7 @@ export default {
artifactPath: opts.artifactPath,
artifactId: opts.artifactId,
onChunk: opts.onChunk,
toolSession: opts.session,
};
const result = await executePython(code, executorOptions);
return {
@@ -372,3 +372,87 @@ if "__omp_prelude_loaded__" not in globals():
return current
class _ToolCallable:
"""Invokes one host-side tool via the loopback HTTP bridge."""
__slots__ = ("_proxy", "_name")
def __init__(self, proxy: "_ToolProxy", name: str):
self._proxy = proxy
self._name = name
def __repr__(self) -> str:
return f"<tool.{self._name}>"
def __call__(self, args=None, /, **kwargs):
import urllib.request, urllib.error
if args is None:
merged: dict = {}
elif isinstance(args, dict):
merged = dict(args)
else:
raise TypeError(
f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})"
)
merged.update(kwargs)
if "_i" not in merged:
merged["_i"] = "py prelude"
payload = json.dumps(
{"session": self._proxy._session, "name": self._name, "args": merged}
).encode("utf-8")
req = urllib.request.Request(
f"{self._proxy._base}/v1/tool",
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {self._proxy._token}",
},
)
try:
with urllib.request.urlopen(req) as resp:
body = resp.read()
except urllib.error.HTTPError as exc:
body = exc.read()
try:
data = json.loads(body)
except json.JSONDecodeError:
raise RuntimeError(
f"tool.{self._name}: bridge returned non-JSON response: {body[:200]!r}"
) from None
if not isinstance(data, dict) or not data.get("ok"):
msg = (data or {}).get("error") if isinstance(data, dict) else None
raise RuntimeError(msg or f"tool.{self._name} failed")
return data.get("value")
class _ToolProxy:
"""`tool.<name>(args)` proxy mirroring the JS runtime bridge."""
__slots__ = ("_base", "_token", "_session")
def __init__(self, base: str, token: str, session: str):
self._base = base.rstrip("/")
self._token = token
self._session = session
def __getattr__(self, name: str) -> _ToolCallable:
if name.startswith("_"):
raise AttributeError(name)
return _ToolCallable(self, name)
def __getitem__(self, name: str) -> _ToolCallable:
return _ToolCallable(self, name)
def __repr__(self) -> str:
return f"<tool proxy session={self._session}>"
if all(
_k in os.environ
for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
):
tool = _ToolProxy(
os.environ["PI_TOOL_BRIDGE_URL"],
os.environ["PI_TOOL_BRIDGE_TOKEN"],
os.environ["PI_TOOL_BRIDGE_SESSION"],
)
@@ -0,0 +1,137 @@
/**
* HTTP loopback bridge that lets the Python kernel synchronously invoke
* host-side tools by name, mirroring the JS worker's `tool.<name>(args)` proxy.
*
* The Python prelude builds a `tool` proxy that POSTs to `/v1/tool` over a
* 127.0.0.1 loopback socket; the host resolves the request against the
* `ToolSession` registered for the current execution and forwards to the same
* `callSessionTool` implementation the JS bridge uses.
*/
import { logger } from "@oh-my-pi/pi-utils";
import type { ToolSession } from "../../tools";
import { callSessionTool, type JsStatusEvent } from "../js/tool-bridge";
export interface PyToolBridgeEntry {
toolSession: ToolSession;
signal?: AbortSignal;
emitStatus?: (event: JsStatusEvent) => void;
}
export interface PyToolBridgeInfo {
url: string;
token: string;
}
interface BridgeServer {
info: PyToolBridgeInfo;
stop: () => Promise<void>;
}
const registrations = new Map<string, PyToolBridgeEntry>();
let serverPromise: Promise<BridgeServer> | null = null;
async function startServer(): Promise<BridgeServer> {
const token = crypto.randomUUID();
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(req) {
const url = new URL(req.url);
if (req.method !== "POST" || url.pathname !== "/v1/tool") {
return new Response("Not Found", { status: 404 });
}
if (req.headers.get("authorization") !== `Bearer ${token}`) {
return new Response("Forbidden", { status: 403 });
}
let body: { session?: unknown; name?: unknown; args?: unknown };
try {
body = (await req.json()) as { session?: unknown; name?: unknown; args?: unknown };
} catch {
return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const sessionId = typeof body.session === "string" ? body.session : "";
const name = typeof body.name === "string" ? body.name : "";
if (!sessionId || !name) {
return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
}
const entry = registrations.get(sessionId);
if (!entry) {
return Response.json(
{ ok: false, error: `No active Python tool bridge session: ${sessionId}` },
{ status: 200 },
);
}
try {
const value = await callSessionTool(name, body.args, {
session: entry.toolSession,
signal: entry.signal,
emitStatus: entry.emitStatus,
});
return Response.json({ ok: true, value });
} catch (err) {
return Response.json({
ok: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
});
const info: PyToolBridgeInfo = {
url: `http://${server.hostname}:${server.port}`,
token,
};
logger.debug("Python tool bridge listening", { url: info.url });
return {
info,
stop: async () => {
await server.stop(true);
},
};
}
/** Starts the bridge server lazily and returns its connection info. */
export async function ensurePyToolBridge(): Promise<PyToolBridgeInfo> {
if (!serverPromise) {
serverPromise = startServer();
}
try {
const server = await serverPromise;
return server.info;
} catch (err) {
serverPromise = null;
throw err;
}
}
/**
* Register a tool session for the duration of one execution. The returned
* function MUST be called to remove the entry once execution finishes.
*/
export function registerPyToolBridge(sessionId: string, entry: PyToolBridgeEntry): () => void {
registrations.set(sessionId, entry);
return () => {
if (registrations.get(sessionId) === entry) {
registrations.delete(sessionId);
}
};
}
/** Stop the bridge and clear registrations. Test-only / shutdown helper. */
export async function disposePyToolBridge(): Promise<void> {
registrations.clear();
const pending = serverPromise;
serverPromise = null;
if (!pending) return;
try {
const server = await pending;
await server.stop();
} catch (err) {
logger.debug("Failed to stop Python tool bridge", {
error: err instanceof Error ? err.message : String(err),
});
}
}
@@ -50,10 +50,10 @@ env(key?=None, value?=None) → str | None | dict
No args → full environment as dict. One arg → value of `key`. Two args → set `key=value` and return value.
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
Read task/agent output by ID. Single id returns text/dict; multiple ids return a list.
tool.<name>(args) → unknown
Invoke any session tool by name. `args` is the tool's parameter object.
```
{{#if js}}**JavaScript only:** `tool.<name>(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`).
{{/if}}</prelude>
</prelude>
<output>
Cells render like a Jupyter notebook. `display(value)` renders non-presentable data as an interactive JSON tree. Presentable values (figures, images, dataframes, etc.) use their native representation.
@@ -0,0 +1,150 @@
import { afterAll, describe, expect, it } from "bun:test";
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
import {
disposePyToolBridge,
ensurePyToolBridge,
registerPyToolBridge,
} from "@oh-my-pi/pi-coding-agent/eval/py/tool-bridge";
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
interface FakeCall {
id: string;
args: unknown;
signal?: AbortSignal;
}
function makeFakeTool(name: string, calls: FakeCall[], result: AgentToolResult): AgentTool {
const tool = {
name,
label: name,
description: name,
parameters: { type: "object" },
async execute(id: string, args: unknown, signal?: AbortSignal): Promise<AgentToolResult> {
calls.push({ id, args, signal });
return result;
},
} as unknown as AgentTool;
return tool;
}
function makeSession(tools: Map<string, AgentTool>): ToolSession {
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
}
async function call(
info: { url: string; token: string },
body: Record<string, unknown>,
overrides?: { token?: string },
): Promise<Response> {
return await fetch(`${info.url}/v1/tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${overrides?.token ?? info.token}`,
},
body: JSON.stringify(body),
});
}
describe("Python tool bridge HTTP server", () => {
afterAll(async () => {
await disposePyToolBridge();
});
it("dispatches calls to the registered ToolSession and returns the tool value", async () => {
const calls: FakeCall[] = [];
const readTool = makeFakeTool("read", calls, {
content: [{ type: "text", text: "file body" }],
});
const session = makeSession(new Map([["read", readTool]]));
const info = await ensurePyToolBridge();
const unregister = registerPyToolBridge("test-session-1", { toolSession: session });
try {
const res = await call(info, {
session: "test-session-1",
name: "read",
args: { path: "foo.ts", _i: "py prelude" },
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body).toEqual({ ok: true, value: "file body" });
expect(calls).toHaveLength(1);
// `_i` survives the bridge round trip so transcript renderers have a label.
expect((calls[0]!.args as { _i?: string })._i).toBe("py prelude");
} finally {
unregister();
}
});
it("returns ok=false when no session is registered for the given id", async () => {
const info = await ensurePyToolBridge();
const res = await call(info, { session: "missing", name: "read", args: {} });
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; error?: string };
expect(body.ok).toBe(false);
expect(typeof body.error).toBe("string");
});
it("surfaces tool errors as ok=false with the error message", async () => {
const session = {
getToolByName: (_: string) =>
({
name: "boom",
label: "boom",
description: "boom",
parameters: { type: "object" },
async execute(): Promise<AgentToolResult> {
throw new Error("kapow");
},
}) as unknown as AgentTool,
} as unknown as ToolSession;
const info = await ensurePyToolBridge();
const unregister = registerPyToolBridge("err-session", { toolSession: session });
try {
const res = await call(info, { session: "err-session", name: "boom", args: {} });
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toEqual({ ok: false, error: "kapow" });
} finally {
unregister();
}
});
it("rejects requests with a bad bearer token", async () => {
const info = await ensurePyToolBridge();
const res = await call(info, { session: "anything", name: "read", args: {} }, { token: "wrong" });
expect(res.status).toBe(403);
});
it("returns 400 when body is missing required fields", async () => {
const info = await ensurePyToolBridge();
const res = await call(info, { name: "read" });
expect(res.status).toBe(400);
});
it("invokes emitStatus alongside the tool result", async () => {
const calls: FakeCall[] = [];
const readTool = makeFakeTool("read", calls, {
content: [{ type: "text", text: "abc" }],
});
const session = makeSession(new Map([["read", readTool]]));
const info = await ensurePyToolBridge();
const statusEvents: Array<{ op: string }> = [];
const unregister = registerPyToolBridge("status-session", {
toolSession: session,
emitStatus: event => statusEvents.push(event),
});
try {
const res = await call(info, {
session: "status-session",
name: "read",
args: { path: "foo.ts" },
});
expect(res.status).toBe(200);
expect(statusEvents).toHaveLength(1);
expect(statusEvents[0]!.op).toBe("read");
} finally {
unregister();
}
});
});