From dcf3f1c8a3ae0d0a524cae27ed6c4cb43d382c77 Mon Sep 17 00:00:00 2001 From: can1357 Date: Tue, 12 May 2026 08:43:36 +0200 Subject: [PATCH] feat(coding-agent): added tool.(args) execution in Python prelude - Added `tool.(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. --- packages/coding-agent/CHANGELOG.md | 4 +- .../src/eval/js/shared/indirect-eval.ts | 4 + .../src/eval/js/shared/rewrite-imports.ts | 1 - .../src/eval/js/shared/runtime.ts | 2 - packages/coding-agent/src/eval/py/executor.ts | 63 +++++++- packages/coding-agent/src/eval/py/index.ts | 1 + packages/coding-agent/src/eval/py/prelude.py | 84 ++++++++++ .../coding-agent/src/eval/py/tool-bridge.ts | 137 ++++++++++++++++ .../coding-agent/src/prompts/tools/eval.md | 6 +- .../test/core/python-tool-bridge.test.ts | 150 ++++++++++++++++++ 10 files changed, 444 insertions(+), 8 deletions(-) create mode 100644 packages/coding-agent/src/eval/py/tool-bridge.ts create mode 100644 packages/coding-agent/test/core/python-tool-bridge.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f87329af7..3a207f74a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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.(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 diff --git a/packages/coding-agent/src/eval/js/shared/indirect-eval.ts b/packages/coding-agent/src/eval/js/shared/indirect-eval.ts index 1cb6a9894..61bd93e56 100644 --- a/packages/coding-agent/src/eval/js/shared/indirect-eval.ts +++ b/packages/coding-agent/src/eval/js/shared/indirect-eval.ts @@ -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); } diff --git a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts index 0cfe303b3..943e18181 100644 --- a/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts +++ b/packages/coding-agent/src/eval/js/shared/rewrite-imports.ts @@ -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 diff --git a/packages/coding-agent/src/eval/js/shared/runtime.ts b/packages/coding-agent/src/eval/js/shared/runtime.ts index 418669650..b1ba2278d 100644 --- a/packages/coding-agent/src/eval/js/shared/runtime.ts +++ b/packages/coding-agent/src/eval/js/shared/runtime.ts @@ -34,7 +34,6 @@ export interface RuntimeOptions { * via `setRunScope()` instead. */ extraGlobals?: Record; - } /** @@ -148,7 +147,6 @@ function formatConsoleArgs(args: unknown[]): string { .join(" "); } - function buildRequire(cwd: string): NodeJS.Require { return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href); } diff --git a/packages/coding-agent/src/eval/py/executor.ts b/packages/coding-agent/src/eval/py/executor.ts index 123bb417f..e34789857 100644 --- a/packages/coding-agent/src/eval/py/executor.ts +++ b/packages/coding-agent/src/eval/py/executor.ts @@ -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.(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.() get injected. */ + bridge?: { url: string; token: string }; } class PythonExecutionCancelledError extends Error { @@ -137,10 +156,20 @@ function getExecutionDeadlineMs(options?: Pick | undefined { +function buildKernelEnv(options: { + sessionFile?: string; + artifactsDir?: string; + bridgeSessionId?: string; + bridge?: { url: string; token: string }; +}): Record | undefined { const env: Record = {}; 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) { diff --git a/packages/coding-agent/src/eval/py/index.ts b/packages/coding-agent/src/eval/py/index.ts index 8fd63f8c7..9ba93ce65 100644 --- a/packages/coding-agent/src/eval/py/index.ts +++ b/packages/coding-agent/src/eval/py/index.ts @@ -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 { diff --git a/packages/coding-agent/src/eval/py/prelude.py b/packages/coding-agent/src/eval/py/prelude.py index 0c13619fe..495ab9f6f 100644 --- a/packages/coding-agent/src/eval/py/prelude.py +++ b/packages/coding-agent/src/eval/py/prelude.py @@ -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"" + + 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.(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"" + + 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"], + ) diff --git a/packages/coding-agent/src/eval/py/tool-bridge.ts b/packages/coding-agent/src/eval/py/tool-bridge.ts new file mode 100644 index 000000000..0cbfe8f3f --- /dev/null +++ b/packages/coding-agent/src/eval/py/tool-bridge.ts @@ -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.(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; +} + +const registrations = new Map(); +let serverPromise: Promise | null = null; + +async function startServer(): Promise { + 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 { + 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 { + 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), + }); + } +} diff --git a/packages/coding-agent/src/prompts/tools/eval.md b/packages/coding-agent/src/prompts/tools/eval.md index b9c291124..e60106e9a 100644 --- a/packages/coding-agent/src/prompts/tools/eval.md +++ b/packages/coding-agent/src/prompts/tools/eval.md @@ -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.(args) → unknown + Invoke any session tool by name. `args` is the tool's parameter object. ``` - -{{#if js}}**JavaScript only:** `tool.(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`). -{{/if}} + 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. diff --git a/packages/coding-agent/test/core/python-tool-bridge.test.ts b/packages/coding-agent/test/core/python-tool-bridge.test.ts new file mode 100644 index 000000000..c905fe922 --- /dev/null +++ b/packages/coding-agent/test/core/python-tool-bridge.test.ts @@ -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 { + calls.push({ id, args, signal }); + return result; + }, + } as unknown as AgentTool; + return tool; +} + +function makeSession(tools: Map): ToolSession { + return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession; +} + +async function call( + info: { url: string; token: string }, + body: Record, + overrides?: { token?: string }, +): Promise { + 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 { + 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(); + } + }); +});