diff --git a/.github/workflows/ark-turn.yml b/.github/workflows/ark-turn.yml index dab0b4be4e..b4603500f7 100644 --- a/.github/workflows/ark-turn.yml +++ b/.github/workflows/ark-turn.yml @@ -41,6 +41,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} + - run: npm ci --ignore-scripts --prefix loopx/hosts/oar - run: python -m pip install -e ".[test]" -e packages/loopx-ark-turn - name: Public SDK, real stdio MCP, negative cases and DSH parity run: >- diff --git a/.github/workflows/memt-turn.yml b/.github/workflows/memt-turn.yml index 857ca98f8b..83e70572f0 100644 --- a/.github/workflows/memt-turn.yml +++ b/.github/workflows/memt-turn.yml @@ -28,6 +28,7 @@ jobs: - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python }} + - run: npm ci --ignore-scripts --prefix loopx/hosts/oar - run: python -m pip install -e '.[test]' -e packages/loopx-memt-turn # Explicit test-only installation; upstream retains its noncommercial license. - run: python -m pip install 'memt-sglang[client] @ git+https://github.com/MemTensor/MemT-1.0.git@e75c13d090a6a9d34b29146d901bd84323185f24#subdirectory=inference' diff --git a/.github/workflows/oar-runtime.yml b/.github/workflows/oar-runtime.yml new file mode 100644 index 0000000000..054f3a8983 --- /dev/null +++ b/.github/workflows/oar-runtime.yml @@ -0,0 +1,94 @@ +name: OAR Runtime Integration + +on: + pull_request: + paths: + - "loopx/hosts/oar/**" + - "loopx/control_plane/turn_driver/**" + - "loopx/dsh_goal_mode/**" + - "loopx/chat_dsh.py" + - "tests/test_oar_*.py" + - "examples/loopx-steward-managed-chat-smoke.py" + - "examples/loopx-turn-codex-cli-e2e-smoke.py" + - "pyproject.toml" + - "MANIFEST.in" + - ".github/workflows/oar-runtime.yml" + push: + branches: [main] + paths: + - "loopx/hosts/oar/**" + - "loopx/control_plane/turn_driver/**" + - "loopx/dsh_goal_mode/**" + - "loopx/chat_dsh.py" + - "tests/test_oar_*.py" + - "examples/loopx-steward-managed-chat-smoke.py" + - "examples/loopx-turn-codex-cli-e2e-smoke.py" + - "pyproject.toml" + - "MANIFEST.in" + - ".github/workflows/oar-runtime.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: oar-runtime-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + LOOPX_USAGE_PING: "0" + steps: + - uses: actions/checkout@v7 + - name: Set OAR runtime cache + run: echo "LOOPX_OAR_HOME=$RUNNER_TEMP/loopx-oar" >> "$GITHUB_ENV" + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + cache: pip + - uses: actions/setup-node@v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: | + loopx/hosts/oar/package-lock.json + apps/presentation/dashboard/package-lock.json + - name: Install locked runtime and Python test dependencies + run: | + python -m pip install --disable-pip-version-check -e '.[test,deepseek-harness]' build + npm ci --ignore-scripts --prefix loopx/hosts/oar + - name: Check runtime contracts and real transport fixtures + run: | + npm --prefix loopx/hosts/oar run typecheck + npm --prefix loopx/hosts/oar test + python -m pytest -q --no-cov tests/test_oar_*.py + python examples/loopx-turn-codex-cli-e2e-smoke.py + python examples/loopx-steward-managed-chat-smoke.py + - name: Build the installable distribution + run: | + python scripts/chat_bundle.py build --install + python -m build --wheel + python - <<'PY' + from pathlib import Path + from zipfile import ZipFile + with ZipFile(next(Path("dist").glob("loopx-*.whl"))) as wheel: + names = wheel.namelist() + assert not any("/node_modules/" in name for name in names) + for name in ("runtime.ts", "stdio.ts", "dsh.ts", "dsh_worker.py", "package.json", "package-lock.json"): + assert "loopx/hosts/oar/" + name in names + PY + - name: Qualify the installed wheel outside the source checkout + run: | + python -m venv "$RUNNER_TEMP/oar-wheel-env" + for wheel in dist/loopx-*.whl; do + "$RUNNER_TEMP/oar-wheel-env/bin/python" -m pip install --disable-pip-version-check "$wheel[deepseek-harness]" pytest + done + mkdir -p "$RUNNER_TEMP/oar-wheel-check/tests" + cp tests/test_oar_pi_host.py tests/test_oar_dsh_chat.py "$RUNNER_TEMP/oar-wheel-check/tests/" + cd "$RUNNER_TEMP/oar-wheel-check" + "$RUNNER_TEMP/oar-wheel-env/bin/python" -m loopx.hosts.oar install + "$RUNNER_TEMP/oar-wheel-env/bin/python" -m loopx.hosts.oar status + "$RUNNER_TEMP/oar-wheel-env/bin/python" -m pytest -q tests -k 'real_pi or default_dsh_chat_interrupt' diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index f95fe72224..9fef36a273 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -621,6 +621,7 @@ jobs: run: | python -m pip install --disable-pip-version-check -e ".[test]" npm ci --ignore-scripts + npm ci --ignore-scripts --prefix loopx/hosts/oar - name: Prepare native shell isolation # Ubuntu 24.04 may need an executable-scoped userns profile for bwrap. # Do not disable AppArmor or the system-wide unprivileged-userns guard. diff --git a/MANIFEST.in b/MANIFEST.in index e27deab388..640e4d27dc 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ graft loopx/web/chat +prune loopx/hosts/oar/node_modules exclude loopx/semantics/project_registry_io_manifest_v1.json diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md index a8184bb7a8..9bb531fb6e 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.md @@ -7,6 +7,32 @@ Scope: the shared goals of [Reliability Diagnostics](./long-running-agent-reliab and [Desktop Execution Frontends](./desktop-execution-frontends-v0.md). [中文](./harness-selection-dsh-pi-v0.zh-CN.md) +## OAR integration checkpoint (2026-10-28) + +LoopX-owned bounded Codex and DSH Turns, DSH Chat segments, and an explicitly +selected Pi generic-cli Turn now share `@botiverse/oar@0.75.0` session, event, +control and cleanup management. [Runtime setup](../../../loopx/hosts/oar/README.md) +documents the exact dependency, explicit installation and Node.js 24 requirement +for this optional runtime. Core's Node.js 22.22.3 requirement is unchanged. + +Codex uses OAR's native app-server adapter with the requested sandbox and opaque +resume identity preserved. Pi uses OAR's matching bundled subprocess runtime. +Because upstream OAR has no DSH runtime, LoopX supplies a thin TypeScript runtime +over its existing pinned Python SDK worker. The shared Turn, validation, quota +and work-settlement owners remain authoritative. Missing output, rejected +controls, failed native outcomes or unconfirmed cleanup cannot become success. + +This is a bounded-execution integration checkpoint. The existing Pi/DSH plugins +inside an already running host retain same-session behavior. Rich native Codex +Chat and Codex/DSH operation transports retain their adapters until OAR supports +their dynamic-tool, history-injection and background-control contracts. The +runtime adapter owner must qualify those contracts before extending this +boundary. This checkpoint does not promote the L1, Mode B, restart-recovery or +long-horizon effectiveness claims below; the dated historical tables retain +their original evidence scope. DSH Chat now reports real interrupt support for +the default supervised runtime, while an explicit custom runner may only +support abandonment. + ## Decision Keep **DSH as the first L1 event source**. Do not infer that DSH is already the diff --git a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md index 4517444e7d..8dee3c0300 100644 --- a/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md +++ b/docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md @@ -7,6 +7,26 @@ 与 [Desktop Execution Frontends](./desktop-execution-frontends-v0.zh-CN.md) 的共同目标。 [English](./harness-selection-dsh-pi-v0.md) +## OAR 集成检查点(2026-10-28) + +由 LoopX 启动的有界 Codex、DSH Turn、DSH Chat 分段,以及显式选择的 Pi +generic-cli Turn,现统一使用 `@botiverse/oar@0.75.0` 管理会话、事件、控制和清理。 +[运行时安装说明](../../../loopx/hosts/oar/README.md)记录了精确依赖、显式安装命令及 +该可选运行时的 Node.js 24 要求;Core 的 Node.js 22.22.3 最低要求不变。 + +Codex 使用 OAR 原生 app-server 适配器,保留请求的沙箱权限与不透明的恢复会话标识。 +Pi 使用 OAR 配套的子进程运行时。上游 OAR 尚无 DSH 运行时,因此 LoopX 通过薄层 +TypeScript 运行时连接原有固定版本的 Python SDK worker。Turn、独立验证、quota +和工作结算仍由现有 owner 决定;输出不完整、控制请求被拒、原生执行失败或未确认 +清理完成,均不能成为成功结果。 + +本检查点的范围是有界执行集成。已经运行的 Pi/DSH 内部插件保留同一会话中的行为。 +原生 Codex Chat 与 Codex/DSH operation transport 继续使用现有适配器,直到 OAR +具备对应的动态工具、历史注入与后台控制契约;后续扩展由运行时适配器 owner 先完成 +这些契约的验证。本检查点不升级下文 L1、Mode B、重启恢复或长程效果的结论,带日期 +的历史表格仍保留原证据范围。DSH Chat 默认运行时现在公开真实的中断能力,显式 +自定义 runner 则仍可能只支持丢弃返回结果。 + ## 决策 保留 **DSH 作为 L1 首个事件源**,但不据此宣布它已成为生产 Mode B 的最终首选。 diff --git a/docs/product/runtimes/codex-cli/loopx-turn-codex-cli-quickstart.md b/docs/product/runtimes/codex-cli/loopx-turn-codex-cli-quickstart.md index c2f4168f01..7070b5b015 100644 --- a/docs/product/runtimes/codex-cli/loopx-turn-codex-cli-quickstart.md +++ b/docs/product/runtimes/codex-cli/loopx-turn-codex-cli-quickstart.md @@ -25,6 +25,18 @@ or state is correct. Check the local host once with `codex doctor`. If the default model is newer than the installed Codex CLI, update Codex or pass `--codex-model`. +Bounded Codex Turns use the shared OAR runtime. Install its locked dependency +once in the LoopX service environment with Node.js 24+: + +```bash +python -m loopx.hosts.oar install +python -m loopx.hosts.oar status +``` + +The existing Codex options and native session bindings remain available. See +[runtime setup and compatibility](../../../../loopx/hosts/oar/README.md) for +the installed-package path, Pi/DSH selection and native Chat boundaries. + ## Run One Turn For a write-capable coding todo: diff --git a/examples/loopx-steward-managed-chat-smoke.py b/examples/loopx-steward-managed-chat-smoke.py index 0903dfb51c..dd73ae7cbb 100644 --- a/examples/loopx-steward-managed-chat-smoke.py +++ b/examples/loopx-steward-managed-chat-smoke.py @@ -245,6 +245,22 @@ def _run_turn(args: argparse.Namespace) -> int: with tempfile.TemporaryDirectory(prefix="loopx-steward-managed-chat-") as directory: root = Path(directory) + # The synthetic steward deliberately exercises the restricted DSH + # profile. The default trusted-owner profile requires native Codex. + configuration = root / "store" / "machine" / "configuration.json" + configuration.parent.mkdir(parents=True) + configuration.write_text( + json.dumps({ + "schema_version": "loopx_machine_configuration_v0", + "namespaces": { + "manager_runtime": { + "schema_version": "manager_runtime_profile_v0", + "runtime_profile": "restricted", + }, + }, + }), + encoding="utf-8", + ) store = ChatSessionStore(root / "store") # A real registry keeps this on the collected path the steward actually # runs, instead of the registry-unavailable envelope. @@ -382,8 +398,9 @@ def _assert_segment_is_read_only( The channel pins ``DSH_PERMISSION_MODE=read-only`` for its own segments. This asks the mock model to call the shell tool against a path outside the - segment workspace, then asserts the tool *ran* and was refused, so the - boundary is enforced rather than merely requested in the prompt. + segment workspace and verifies the tool's refusal. A host without a usable + sandbox must refuse execution entirely; report that separately from a + write denied by an available sandbox. """ target = _sandbox_probe_path() @@ -422,8 +439,13 @@ def _assert_segment_is_read_only( and message.get("role") == "tool" and message.get("tool_call_id") == "call_sandbox_probe" ] + backend_unavailable = any( + "no sandbox backend is usable" in str(result) + and "refusing to run the command unconfined" in str(result) + for result in tool_results + ) _assert( - any( + backend_unavailable or any( "denied" in str(result) or "not permitted" in str(result) for result in tool_results ), @@ -433,6 +455,7 @@ def _assert_segment_is_read_only( "permission_mode": STEWARD_SEGMENT_ENV["DSH_PERMISSION_MODE"], "write_attempted": True, "write_denied": True, + "enforcement": "backend_unavailable" if backend_unavailable else "sandbox_denied", } diff --git a/examples/loopx-turn-codex-cli-e2e-smoke.py b/examples/loopx-turn-codex-cli-e2e-smoke.py index d88318fc39..3af1139d03 100755 --- a/examples/loopx-turn-codex-cli-e2e-smoke.py +++ b/examples/loopx-turn-codex-cli-e2e-smoke.py @@ -24,7 +24,7 @@ from tests.control_plane.canonical_authority_fixture import ( # noqa: E402 initialize_canonical_source_fixture, ) -from tests.control_plane.host_process_fixture import write_python_host_fixture # noqa: E402 +from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture # noqa: E402 GOAL_ID = "loopx-turn-real-cli-e2e" @@ -127,7 +127,7 @@ def _write_fixture(root: Path, *, turn_count: int) -> tuple[Path, Path, Path, Pa def _write_fake_codex(root: Path) -> Path: executable = root / "fake-codex" - executable = write_python_host_fixture( + executable = write_codex_app_server_fixture( executable, f"""#!/usr/bin/env python3 import json diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 89fb2d2b52..738925b11b 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -23,6 +23,10 @@ VisibleResponseStreamFilter, parse_agent_response, ) +from .chat_endpoint_remediation import ( + AGENT_ENDPOINT_NEXT_ACTIONS as AGENT_ENDPOINT_NEXT_ACTIONS, + AGENT_ENDPOINT_UNAVAILABLE as AGENT_ENDPOINT_UNAVAILABLE, +) class CodexChatAgentError(RuntimeError): @@ -52,29 +56,6 @@ def _host_tool_gate(summary: str, next_action: str) -> dict[str, str]: } -# One typed endpoint-unavailability error plus the next step for each reason the -# executor readback can publish. A surface that cannot serve a request names the -# blocking fact and what clears it, so an operator never has to infer why an -# endpoint that is listed as a capability refused the session. -AGENT_ENDPOINT_UNAVAILABLE = "agent_endpoint_unavailable" -AGENT_ENDPOINT_NEXT_ACTIONS = { - "dsh_runtime_unavailable": ( - "The LoopX Chat service interpreter cannot import deepseek_harness. " - "Run `loopx doctor` in that service environment and check python.executable; " - "install `loopx[deepseek-harness]` in the same environment, then restart LoopX Chat." - ), - "operator_credential_unconfigured": ( - "Set the managed executor credential (DEEPSEEK_API_KEY, with " - "DEEPSEEK_BASE_URL when the endpoint is not the provider default) in the " - "LoopX Chat service environment, then restart it." - ), - "invalid_reasoning_effort": ( - "Fix the configured reasoning effort (LOOPX_MANAGER_REASONING_EFFORT or " - "LOOPX_TURN_REASONING_EFFORT) and restart LoopX Chat." - ), -} - - def agent_endpoint_error(agent_id: str, *, reason: str = "") -> ValueError: """Return the typed error for an Agent id this runtime cannot serve. diff --git a/loopx/chat_dsh.py b/loopx/chat_dsh.py index af81e34e90..f4d65f2955 100644 --- a/loopx/chat_dsh.py +++ b/loopx/chat_dsh.py @@ -5,6 +5,10 @@ not pretend to hold one. Every Chat turn starts one segment on the resolved managed execution profile, hands it the channel's bounded history plus the current message, and returns the final assistant message as the answer. +Each default segment has its own native id: the pinned SDK cannot reopen a +persisted session in a new process. The Chat binding keeps its stable id and +bounded history; a fresh segment is planned before launch, never as a retry +after a failed resume. Explicit runner hooks retain their existing id contract. What this transport deliberately does **not** claim, because a caller must not infer it: @@ -16,10 +20,10 @@ ``STEWARD_SEGMENT_ENV``), so a model that reaches for a tool is refused by the dsh sandbox itself, and the answer must come from the evidence LoopX supplies. -The managed host cannot cancel a segment once it has started, so this adapter -holds **one** executor slot per binding: a segment start while the previous -segment is still running is refused with a typed error instead of quietly -starting a second executor, and an interrupted segment's answer is discarded +The default OAR host cancels a segment through its supervised process. An +explicit runner may support only abandonment. This adapter holds **one** +executor slot per binding until the worker actually exits: a concurrent start +is refused with a typed error, and an interrupted segment's answer is discarded rather than folded back into the channel's visible history. """ @@ -39,6 +43,7 @@ normalize_runner_outcome, resolve_dsh_home, run_dsh_turn, + terminal_error_reason, terminal_output_budget_state, ) @@ -59,8 +64,8 @@ MANAGED_HOST_CHAT_TIMEOUT = "managed_host_chat_timeout" MANAGED_HOST_CHAT_FAILED = "managed_host_chat_failed" # A second executor for one binding is refused, not queued or run in parallel: -# the managed host has no cancel, so the previous segment can only be waited -# out. The code names the state the channel is in, not a user mistake. +# cancellation is not proof the previous worker has exited. The code names the +# state the channel is in, not a user mistake. MANAGED_HOST_CHAT_SEGMENT_IN_FLIGHT = "managed_host_chat_segment_in_flight" # How long a new segment waits for the previous one to exit before it refuses. @@ -75,12 +80,13 @@ class _SegmentSlot: """The binding's single managed-executor slot. The slot is occupied until the segment's thread actually exits, including - after a channel timeout: the segment keeps running in the host, so the next - start must still see one executor as long as that is true. + after a channel timeout: cancellation cleanup or an explicit runner may + still be running, so the next start must see one executor until it exits. """ thread: threading.Thread | None = None interrupted: bool = False + cancel_event: threading.Event = field(default_factory=threading.Event) @dataclass @@ -119,7 +125,7 @@ def capabilities(self) -> dict[str, Any]: "adapter_kind": "deepseek_harness_segment", "streaming": False, "resume": True, - "interrupt": False, + "interrupt": self.runner is None, # No tool authority is granted by the channel. The segment answers # from the evidence LoopX supplies and delegates work back. "tool_calls": False, @@ -145,7 +151,7 @@ def _prompt(self, message: str) -> str: ) return f"{self.objective.strip()}{history_block}\n\n{_turn_prompt(message)}" - def _run_segment(self, prompt: str) -> dict[str, Any]: + def _run_segment(self, prompt: str, cancel_event: threading.Event) -> dict[str, Any]: budget = dsh_output_token_budget(self.max_tokens) if not budget["valid"]: raise CodexChatAgentError( @@ -161,7 +167,10 @@ def _run_segment(self, prompt: str) -> dict[str, Any]: segment_env.update(STEWARD_SEGMENT_ENV) outcome = runner( prompt=prompt, - session_id=self.session_id, + session_id=( + f"{self.session_id}-segment-{uuid.uuid4().hex}" + if self.runner is None else self.session_id + ), workspace=self.work_dir, session_root=resolve_dsh_home(self.work_dir), provider=self.provider, @@ -172,6 +181,7 @@ def _run_segment(self, prompt: str) -> dict[str, Any]: runtime_bin=self.runtime_bin, request_timeout_seconds=self.timeout_sec, env=segment_env, + **({"cancel_event": cancel_event} if self.runner is None else {}), ) if isinstance(outcome, str): return {"final_response": outcome, "finish_reason": None, "events": []} @@ -222,7 +232,7 @@ def start_turn(self, message: str, event_sink: EventSink) -> dict[str, Any]: def run() -> None: try: - outcome.update(self._run_segment(prompt)) + outcome.update(self._run_segment(prompt, slot.cancel_event)) except BaseException as exc: # noqa: BLE001 - re-raised on the caller thread failure.append(exc) @@ -233,6 +243,7 @@ def run() -> None: worker.start() worker.join(self.timeout_sec) if worker.is_alive(): + slot.cancel_event.set() # The segment is bounded by the caller's deadline, not by the host's # willingness to stop: the channel reports a typed timeout instead of # waiting on the steward forever. @@ -250,6 +261,11 @@ def run() -> None: gate=None, error_code=MANAGED_HOST_CHAT_FAILED, ) from error + if terminal_error_reason(outcome) is not None: + raise CodexChatAgentError( + "The managed host reported a terminal provider failure; no answer was accepted.", + gate=None, error_code=MANAGED_HOST_CHAT_FAILED, + ) budget_state = terminal_output_budget_state(outcome) if budget_state is not None: raise CodexChatAgentError( @@ -290,20 +306,18 @@ def run() -> None: return response def interrupt_turn(self, turn_id: str | None = None) -> None: - # The segment owns its own runtime and exits on its request timeout, so - # there is no retained session to interrupt and no host-side cancel to - # call. What the channel can do is mark the running segment as - # abandoned, so its later answer is discarded instead of becoming the - # channel's newest visible statement. The slot stays occupied until the - # segment exits, because it is still the binding's one executor. + # OAR propagates the stop to the owned runtime tree. Explicit compatible + # runners may only support abandonment; either way retain the slot until + # its thread exits and never publish a late answer. del turn_id with self._slot_lock: slot = self._slot if slot is not None and slot.thread is not None and slot.thread.is_alive(): slot.interrupted = True + slot.cancel_event.set() def close_session(self) -> None: - return None + self.interrupt_turn() def healthcheck(self) -> bool: return True diff --git a/loopx/chat_endpoint_catalog.py b/loopx/chat_endpoint_catalog.py index 4d0331f324..82d80d45f5 100644 --- a/loopx/chat_endpoint_catalog.py +++ b/loopx/chat_endpoint_catalog.py @@ -55,7 +55,7 @@ def managed_host_capability(runtime_root: Path | None = None) -> dict[str, Any]: "unavailable_reason": binding.get("unavailable_reason"), "streaming": False, "resume": True, - "interrupt": False, + "interrupt": True, "tool_calls": False, "trust_scope": "read_only", # The channel pins this for its own segments; naming it here keeps the @@ -63,6 +63,7 @@ def managed_host_capability(runtime_root: Path | None = None) -> dict[str, Any]: "sandbox_mode": STEWARD_SEGMENT_ENV["DSH_PERMISSION_MODE"], "execution_profile": profile, "output_token_budget": binding.get("output_token_budget"), + "oar_runtime": binding.get("oar_runtime"), "source": "builtin", } diff --git a/loopx/chat_endpoint_remediation.py b/loopx/chat_endpoint_remediation.py new file mode 100644 index 0000000000..010b316092 --- /dev/null +++ b/loopx/chat_endpoint_remediation.py @@ -0,0 +1,29 @@ +"""Operator actions for typed Chat endpoint availability refusals. + +The endpoint readback owns the blocking fact. These messages tell a surface +what clears it without selecting an endpoint or starting a native runtime. +""" + +AGENT_ENDPOINT_UNAVAILABLE = "agent_endpoint_unavailable" +AGENT_ENDPOINT_NEXT_ACTIONS = { + "oar_runtime_unavailable": ( + "The LoopX service needs the optional OAR runtime for this endpoint. " + "Activate Node.js 24+ and npm in the same service environment, run " + "`python -m loopx.hosts.oar install`, then verify " + "`python -m loopx.hosts.oar status` before retrying." + ), + "dsh_runtime_unavailable": ( + "The LoopX Chat service interpreter cannot import deepseek_harness. " + "Run `loopx doctor` in that service environment and check python.executable; " + "install `loopx[deepseek-harness]` in the same environment, then restart LoopX Chat." + ), + "operator_credential_unconfigured": ( + "Set the managed executor credential (DEEPSEEK_API_KEY, with " + "DEEPSEEK_BASE_URL when the endpoint is not the provider default) in the " + "LoopX Chat service environment, then restart it." + ), + "invalid_reasoning_effort": ( + "Fix the configured reasoning effort (LOOPX_MANAGER_REASONING_EFFORT or " + "LOOPX_TURN_REASONING_EFFORT) and restart LoopX Chat." + ), +} diff --git a/loopx/control_plane/turn_driver/__init__.py b/loopx/control_plane/turn_driver/__init__.py index 61db30ba38..a03fb2fc1f 100644 --- a/loopx/control_plane/turn_driver/__init__.py +++ b/loopx/control_plane/turn_driver/__init__.py @@ -10,7 +10,6 @@ "CODEX_CLI_SESSION_SCHEMA_VERSION": "codex_cli", "codex_cli_result_schema": "codex_cli", "codex_cli_session_binding": "codex_cli", - "codex_cli_session_id_from_jsonl": "codex_cli", "load_codex_cli_session": "codex_cli", "run_codex_cli_host": "codex_cli", "LOOPX_ITERATION_CONTEXT_POLICY_SCHEMA_VERSION": "driver", @@ -91,7 +90,6 @@ def __dir__() -> list[str]: "build_loopx_turn_transaction_plan", "codex_cli_result_schema", "codex_cli_session_binding", - "codex_cli_session_id_from_jsonl", "decide_loop_disposition", "project_turn_route", "decide_managed_step", diff --git a/loopx/control_plane/turn_driver/codex_cli.py b/loopx/control_plane/turn_driver/codex_cli.py index b36c8d2437..19b25b43a5 100644 --- a/loopx/control_plane/turn_driver/codex_cli.py +++ b/loopx/control_plane/turn_driver/codex_cli.py @@ -6,7 +6,6 @@ import os import re import shutil -import tempfile from collections.abc import Mapping from pathlib import Path from typing import Any @@ -41,7 +40,8 @@ from ..capability_hooks import capability_result_fields from .execution_profile import require_supported_reasoning_effort from .host_failure import BuiltInHostError -from .host_process_transport import HostOutputLines, run_host_process +from .host_process_transport import HostOutputLines +from ...hosts.oar.bridge import run_oar_turn from .transaction import LOOPX_TURN_RESULT_SCHEMA_VERSION, TRANSACTION_PHASES @@ -104,6 +104,13 @@ 429: "rate_limited", 503: "provider_overloaded", } +_OAR_STARTUP_FAILURE_CATEGORIES = { + "auth": "auth_failed", + "quota": "quota_exhausted", + "billing": "quota_exhausted", + "rate_limited": "rate_limited", + "overloaded": "provider_overloaded", +} _FAILURE_CATEGORY_PRIORITY = { # Conflicting observations fail closed before any retryable category. Among # transient classes, prefer the longer rate-limit backoff over overload. @@ -375,20 +382,6 @@ def codex_cli_event_session_id(event: Mapping[str, Any]) -> str | None: return None -def codex_cli_session_id_from_jsonl(value: str) -> str | None: - """Return the first opaque Codex thread id from an exec JSONL stream.""" - - for line in value.splitlines(): - try: - event = json.loads(line) - except json.JSONDecodeError: - continue - if isinstance(event, Mapping): - if session_id := codex_cli_event_session_id(event): - return session_id - return None - - def _diagnostic_failure_category(line: str) -> str | None: text = line.lower().replace("’", "'") if any( @@ -546,7 +539,7 @@ def _event_failure_categories( # 1. A provider error code is the most specific signal. If it exists but is # unknown, fail closed instead of reinterpreting its English message. - code_containers = (event, *error_containers) + code_containers = (event, _mapping(event.get("params")), *error_containers) code_categories: list[str] = [] for container in code_containers: for field in ("code", "error_code", "errorCode"): @@ -614,13 +607,6 @@ def _event_failure_categories( return None, None -def _event_failure_category(event: Mapping[str, Any]) -> str | None: - """Classify one error event without retaining its provider prose.""" - - structured, diagnostic = _event_failure_categories(event) - return structured or diagnostic - - def _select_failure_category(categories: list[str]) -> str | None: if not categories: return None @@ -726,6 +712,26 @@ def _codex_command( return command +def _codex_oar_request( + request: Mapping[str, Any], *, codex_bin: str, project: Path, + prompt: str, sandbox: str, model: str | None, reasoning_effort: str | None, + session_id: str | None, mcp_server: Mapping[str, Any] | None, +) -> dict[str, Any]: + """Project the admitted Codex controls into the shared OAR runtime request.""" + config: dict[str, Any] = { + "runtime": "codex", "command": codex_bin, "cwd": str(project), + "prompt": prompt, "sandbox": sandbox, + "outputSchema": codex_cli_result_schema(request), + # Preserve the invocation-scoped MCP's required/startup/approval + # configuration; OAR owns the app-server process and Session controls. + "launchArgs": _codex_mcp_config_arguments(mcp_server), + } + for field, value in (("model", model), ("effort", reasoning_effort), ("resume", session_id)): + if value is not None: + config[field] = value + return config + + def run_codex_cli_host( request: Mapping[str, Any], *, @@ -840,107 +846,134 @@ def commit() -> None: raise ValueError("native child CLI observation requires the durable host attempt") invocation_id = f"exec:{request['turn_key']}:{attempt}" - with tempfile.TemporaryDirectory(prefix="loopx-turn-codex-") as directory: - temporary = Path(directory) - schema_path = temporary / "result-schema.json" - output_path = temporary / "last-message.json" - schema_path.write_text( - json.dumps( - codex_cli_result_schema(request), - ensure_ascii=False, - separators=(",", ":"), - ), - encoding="utf-8", - ) - command = _codex_command( - codex_bin=str(resolved), - project=project, - schema_path=schema_path, - output_path=output_path, - sandbox=sandbox, - model=model, - reasoning_effort=reasoning_effort, - session_id=session_id, - mcp_server=mcp_server, + observed_session: list[str] = [] + failures = CodexFailureObservation() + missing_resume_target = False + + def observe_record(record: Mapping[str, Any]) -> None: + nonlocal missing_resume_target + if record.get("type") == "result": + terminal = _mapping(record.get("result")) + error = _mapping(terminal.get("error")) + if error.get("name") == "SessionNotFoundError": + missing_resume_target = bool(session_id and error.get("sessionId") == session_id + and error.get("method") == "thread/resume") + failures.structured.add("session_missing" if missing_resume_target else "unknown") + elif error.get("name") == "RuntimeFailureError": + # Session creation can fail before native observation begins. + # Use OAR's typed failure while preserving any more-specific + # native evidence, including an unknown provider code. + if not failures.structured: + failure = _mapping(terminal.get("outcome")).get("failure") + failures.structured.add(_OAR_STARTUP_FAILURE_CATEGORIES.get(str(failure), "unknown")) + elif isinstance(error.get("message"), str): + failures.observe_stderr(error["message"]) + return + if record.get("type") == "ready": + candidate = _valid_session_id(record.get("sessionId")) + if candidate and candidate not in observed_session: + observed_session.append(candidate) + return + if record.get("type") != "native": + return + frame = _mapping(record.get("frame")) + # Child errors and completion items cannot become the parent's result. + # OAR attributes a native child thread with its own session id. + if (not observed_session or frame.get("sessionId") != observed_session[0] + or frame.get("agentPath") != []): + return + body = _mapping(frame.get("body")) + if frame.get("kind") == "response" and body.get("kind") == "rejected": + # OAR control refusals do not retain the native provider error code. + # Familiar prose cannot prove a safe retry after that detail is lost. + failures.structured.add("unknown") + return + if frame.get("kind") != "frame": + return + params = _mapping(body.get("native")) + method = body.get("type") + if not isinstance(method, str): + return + failures.observe_event({"method": method, "params": params}) + item = params.get("item") + if child_observer is not None and method == "item/completed" and isinstance(item, Mapping): + def record_child() -> None: + child_observer.observe(item, session_id=observed_session[0], invocation_id=invocation_id) + + if goal_admission is None: + record_child() + else: + goal_admission.accept_result(record_child) + + diagnostics = HostOutputLines(failures.observe_stderr) + input_text = _prompt(request) + config = _codex_oar_request( + request, codex_bin=str(resolved), project=project, prompt=input_text, + sandbox=sandbox, model=model, reasoning_effort=reasoning_effort, + session_id=session_id, mcp_server=mcp_server, + ) + with capture_codex_input_source(project, input_text, lineage["goal_id"], observed_session): + observed = run_oar_turn( + config, project=project, timeout_seconds=timeout_seconds, + drain_timeout_seconds=OUTPUT_DRAIN_TIMEOUT_SECONDS, + on_record=observe_record, on_stderr=diagnostics.feed, + launch_guard=lambda: launch_scope("codex_cli_host_launch"), ) - observed_session: list[str] = [] - failures = CodexFailureObservation() - - def observe_event(line: str) -> None: - try: - event = json.loads(line) - except json.JSONDecodeError: - return - if isinstance(event, dict): - candidate = codex_cli_event_session_id(event) - if candidate and candidate not in observed_session: - observed_session.append(candidate) - item = event.get("item") - if (child_observer is not None and observed_session - and event.get("type") == "item.completed" and isinstance(item, Mapping)): - def record_child() -> None: - child_observer.observe(item, session_id=observed_session[0], invocation_id=invocation_id) - - if goal_admission is None: - record_child() - else: - goal_admission.accept_result(record_child) - failures.observe_event(event) - - def observe_stderr(line: str) -> None: - failures.observe_stderr(line) - - events = HostOutputLines(observe_event) - diagnostics = HostOutputLines(observe_stderr) - input_text = _prompt(request) - with capture_codex_input_source(project, input_text, lineage["goal_id"], observed_session): - observed = run_host_process(command, project=project, input_text=input_text, - timeout_seconds=timeout_seconds, drain_timeout_seconds=OUTPUT_DRAIN_TIMEOUT_SECONDS, - on_stdout=events.feed, on_stderr=diagnostics.feed, - launch_guard=lambda: launch_scope("codex_cli_host_launch")) - events.finish() - diagnostics.finish() - returncode = observed["returncode"] - timed_out = observed["outcome"] == "timeout" - output_observation_incomplete = not (observed["output_complete"] and events.complete and diagnostics.complete) - if observed["outcome"] not in {"exited", "timeout"}: - raise BuiltInHostError("codex_cli_process_" + observed["outcome"]) - if session_scope == "agent" and ( - len(observed_session) > 1 or (returncode == 0 and not observed_session) - ): - raise BuiltInHostError("codex_cli_session_identity_unconfirmed") - if session_id and any(candidate != session_id for candidate in observed_session): - raise BuiltInHostError("codex_cli_resume_session_identity_changed") - if timed_out: - if observed_session: - store_session(observed_session[0]) - raise BuiltInHostError( - "codex_cli_timeout", - failure_kind="executor_timeout", - recovery_kind=("resume_session" if observed_session else None), - ) - category = failures.category(complete=not output_observation_incomplete) - if session_scope == "todo" and returncode != 0 and category in SESSION_INVALIDATING_FAILURE_CATEGORIES: - discard_session() - if observed_session and ( - returncode == 0 or category not in SESSION_INVALIDATING_FAILURE_CATEGORIES - ): + diagnostics.finish() + returncode = observed["returncode"] + timed_out = observed["outcome"] == "timeout" + output_observation_incomplete = not (observed["output_complete"] and diagnostics.complete) + if observed["outcome"] not in {"exited", "timeout"}: + raise BuiltInHostError("codex_cli_process_" + observed["outcome"]) + if session_scope == "agent" and ( + len(observed_session) > 1 or (returncode == 0 and not observed_session) + ): + raise BuiltInHostError("codex_cli_session_identity_unconfirmed") + if session_id and any(candidate != session_id for candidate in observed_session): + raise BuiltInHostError("codex_cli_resume_session_identity_changed") + if timed_out: + if observed_session: store_session(observed_session[0]) - if returncode != 0: - raise BuiltInHostError( - f"codex_cli_{category}", - failure_kind=_FAILURE_KINDS.get(category, "unknown"), - recovery_kind=( - "resume_session" - if observed_session - and category in _SESSION_RESUMABLE_FAILURE_CATEGORIES - else None - ), - ) - try: - result = json.loads(output_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError) as exc: - raise BuiltInHostError("codex_cli_final_result_missing") from exc - if not isinstance(result, dict): - raise BuiltInHostError("codex_cli_final_result_not_object") - return result + raise BuiltInHostError( + "codex_cli_timeout", + failure_kind="executor_timeout", + recovery_kind=("resume_session" if observed_session else None), + ) + outcome = _mapping(observed.get("turn_outcome")) + if outcome.get("kind") == "failed" and isinstance(outcome.get("reason"), str): + failures.observe_stderr(outcome["reason"]) + category = failures.category(complete=not output_observation_incomplete) + if session_scope == "todo" and returncode != 0 and ( + category in SESSION_INVALIDATING_FAILURE_CATEGORIES or missing_resume_target + ): + # A typed refusal proves this exact resume target is gone even if the + # failed open supplied no cleanup receipt. Failure still remains closed; + # independently remove the binding that cannot be resumed. + discard_session() + if observed_session and ( + returncode == 0 or category not in SESSION_INVALIDATING_FAILURE_CATEGORIES + ): + store_session(observed_session[0]) + if returncode != 0: + raise BuiltInHostError( + f"codex_cli_{category}", + failure_kind=_FAILURE_KINDS.get(category, "unknown"), + recovery_kind=( + "resume_session" + if observed_session + and category in _SESSION_RESUMABLE_FAILURE_CATEGORIES + else None + ), + ) + # The bridge exposes final_response only for a completed root turn. A + # streamed fragment, child response or interrupted result is never parsed. + response = observed.get("final_response") + if not isinstance(response, str): + raise BuiltInHostError("codex_cli_final_result_missing") + try: + result = json.loads(response) + except json.JSONDecodeError as exc: + raise BuiltInHostError("codex_cli_final_result_missing") from exc + if not isinstance(result, dict): + raise BuiltInHostError("codex_cli_final_result_not_object") + return result diff --git a/loopx/control_plane/turn_driver/host_binding.py b/loopx/control_plane/turn_driver/host_binding.py index c34d833dd6..0db6b68a2c 100644 --- a/loopx/control_plane/turn_driver/host_binding.py +++ b/loopx/control_plane/turn_driver/host_binding.py @@ -77,6 +77,7 @@ # launchability fact this projection checks without side effects. DSH_RUNTIME_MODULE = "deepseek_harness" DSH_RUNTIME_UNAVAILABLE = "dsh_runtime_unavailable" +OAR_RUNTIME_UNAVAILABLE = "oar_runtime_unavailable" # Module availability is scoped to this interpreter, not the whole machine. MANAGED_RUNTIME_PROBE_SCHEMA_VERSION = "managed_runtime_probe_v0" RUNTIME_PROBE_SCOPE_INTERPRETER = "probing_interpreter" @@ -91,6 +92,7 @@ # can change here; they never select a host, endpoint, or model themselves. REMEDY_CONFIGURE_OPERATOR_CREDENTIAL = "configure_operator_credential" REMEDY_CONFIGURE_DSH_RUNTIME = "configure_dsh_runtime" +REMEDY_CONFIGURE_OAR_RUNTIME = "configure_oar_runtime" REMEDY_CORRECT_EXECUTION_PROFILE = "correct_execution_profile" REMEDY_SELECT_INDIVIDUAL_HOST = "select_individual_host" @@ -145,12 +147,16 @@ def _managed_unavailable_remediation(reason: str | None) -> list[str]: The list is empty whenever the managed executor can launch, so a caller reads the same field in both states instead of branching on its presence. - Selecting an individual host is always one exit, because it is the - documented alternative to a managed host nothing here can authenticate. + Selecting an individual host clears managed credential/profile refusals. + A shared OAR dependency must be repaired for either built-in bounded host. """ if reason is None: return [] + if reason == OAR_RUNTIME_UNAVAILABLE: + # Both shipped bounded hosts use OAR. Switching between them does not + # repair a missing shared dependency. + return [REMEDY_CONFIGURE_OAR_RUNTIME] remedy_by_reason = { DSH_RUNTIME_UNAVAILABLE: REMEDY_CONFIGURE_DSH_RUNTIME, OPERATOR_CREDENTIAL_UNCONFIGURED: REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, @@ -161,6 +167,28 @@ def _managed_unavailable_remediation(reason: str | None) -> list[str]: ] +def _oar_runtime_readiness( + probe: Callable[[], Mapping[str, Any]] | None, +) -> dict[str, Any]: + """Read optional host installation facts without importing them into Core. + + The installation owner checks the selected Node executable and the locked + dependency; it never installs anything here. Public Turn readback includes + no local paths or process diagnostics from that inspection. + """ + if probe is None: + from ...hosts.oar.installation import inspect_installation + + probe = inspect_installation + observed = probe() + ready = observed.get("ready") is True + return { + "ready": ready, + "oar_version": observed.get("oar_version"), + **({} if ready else {"unavailable_reason": OAR_RUNTIME_UNAVAILABLE}), + } + + def dsh_output_token_budget(max_tokens: int | None = None) -> dict[str, Any]: """Resolve the per-request limit shared by execution and its readback.""" limit = DEFAULT_DSH_OUTPUT_TOKEN_LIMIT if max_tokens is None else max_tokens @@ -210,6 +238,7 @@ def managed_executor_binding( environ: Mapping[str, str] | None = None, dsh_runner_configured: bool = False, module_probe: Callable[[str], bool] | None = None, + oar_probe: Callable[[], Mapping[str, Any]] | None = None, provider: str | None = None, model: str | None = None, reasoning_effort: str | None = None, @@ -234,8 +263,23 @@ def managed_executor_binding( ``runtime_probe`` states what the ``dsh_runtime_unavailable`` verdict is a claim about, so a reader does not take a process-level answer for a machine-level fact. + + ``oar_runtime`` is present only for the built-in bounded OAR hosts. Native + operation transports and explicit DSH runners own their dependencies and + are not blocked by an optional OAR installation. """ + operation_transport_enabled = ( + codex_operation_tools or dsh_operation_module_configured + ) + uses_oar = ( + not operation_transport_enabled + and ( + host == INDIVIDUAL_TURN_HOST + or (host == MANAGED_HOST and not dsh_runner_configured) + ) + ) + oar_runtime = _oar_runtime_readiness(oar_probe) if uses_oar else None if host == MANAGED_HOST: credential_env = configured_operator_credential(environ) runtime_available = bool( @@ -256,8 +300,12 @@ def managed_executor_binding( unavailable_reason = OPERATOR_CREDENTIAL_UNCONFIGURED elif not output_token_budget["valid"]: unavailable_reason = INVALID_OUTPUT_TOKEN_LIMIT - else: + elif profile_reason is not None: unavailable_reason = profile_reason + elif oar_runtime is not None and not oar_runtime["ready"]: + unavailable_reason = OAR_RUNTIME_UNAVAILABLE + else: + unavailable_reason = None binding = { "schema_version": MANAGED_EXECUTOR_BINDING_SCHEMA_VERSION, "executor": host, @@ -282,8 +330,10 @@ def managed_executor_binding( "available": runtime_available, }, } + if oar_runtime is not None: + binding["oar_runtime"] = oar_runtime return _with_operation_transport( - binding, enabled=codex_operation_tools or dsh_operation_module_configured, host=host, + binding, enabled=operation_transport_enabled, host=host, sandbox=codex_sandbox, model=profile["model"], reasoning_effort=profile["reasoning_effort"], dsh_operation_module_configured=dsh_operation_module_configured, ) @@ -311,8 +361,16 @@ def managed_executor_binding( # branches on its presence; only a managed executor probes a runtime. "runtime_probe": None, } + if oar_runtime is not None: + binding["oar_runtime"] = oar_runtime + if not oar_runtime["ready"]: + binding["available"] = False + binding["unavailable_reason"] = OAR_RUNTIME_UNAVAILABLE + binding["unavailable_remediation"] = _managed_unavailable_remediation( + OAR_RUNTIME_UNAVAILABLE + ) return _with_operation_transport( - binding, enabled=codex_operation_tools or dsh_operation_module_configured, host=host, + binding, enabled=operation_transport_enabled, host=host, sandbox=codex_sandbox, model=model, reasoning_effort=reasoning_effort, dsh_operation_module_configured=dsh_operation_module_configured, ) @@ -345,11 +403,12 @@ def managed_executor_binding_from_host_args( *, environ: Mapping[str, str] | None = None, module_probe: Callable[[str], bool] | None = None, + oar_probe: Callable[[], Mapping[str, Any]] | None = None, ) -> dict[str, Any]: """Project one trusted Turn argv binding through the existing host owner. - This is deliberately a read model: it selects no host, executes no probe - command and returns none of the raw argv. Optional host-specific profile + This is deliberately a read model: it selects no host, launches no model + work and returns none of the raw argv. Optional host-specific profile flags are interpreted here, beside their owning Turn host, so coordinator capabilities can consume one provider-neutral executor projection. """ @@ -359,6 +418,8 @@ def managed_executor_binding_from_host_args( host, environ=environ, module_probe=module_probe, + oar_probe=oar_probe, + dsh_runner_configured=bool(turn_host_arg_option(host_args, "--dsh-runner")), provider=( turn_host_arg_option(host_args, "--dsh-provider") if host == MANAGED_HOST @@ -427,12 +488,16 @@ def managed_executor_unavailable_payload( for code in (remediation if isinstance(remediation, list) else []) if str(code) ], - "remediation_host": INDIVIDUAL_TURN_HOST, + "remediation_host": ( + None if binding.get("unavailable_reason") == OAR_RUNTIME_UNAVAILABLE + else INDIVIDUAL_TURN_HOST + ), # An operation transport refusal is not an instruction to configure # model credentials. The existing typed transport owner supplies its # remedies and supported hosts/sandboxes independently of launchability. "remediation_env_vars": ( [] if "operation_transport" in binding + or binding.get("unavailable_reason") == OAR_RUNTIME_UNAVAILABLE else list(OPERATOR_CREDENTIAL_ENV_VARS) ), } diff --git a/loopx/control_plane/turn_driver/host_process.ts b/loopx/control_plane/turn_driver/host_process.ts index 87c1098488..dc9a258ff8 100644 --- a/loopx/control_plane/turn_driver/host_process.ts +++ b/loopx/control_plane/turn_driver/host_process.ts @@ -3,7 +3,7 @@ import {spawn, type ChildProcessWithoutNullStreams} from "node:child_process"; import {setTimeout as delay} from "node:timers/promises"; import {StringDecoder} from "node:string_decoder"; -import {waitForProcessGroupStop} from "./host_process_group.ts"; +import {descendantProcessGroups, waitForProcessGroupStop} from "./host_process_group.ts"; export interface HostProcessRequest { argv: string[]; @@ -21,6 +21,7 @@ export interface HostProcessResult { output_complete: boolean; cleanup_scope: "process_group" | "process_tree_best_effort"; group_signal_sent: boolean; + descendant_cleanup_complete?: boolean; } export type HostProcessOutput = {kind: "stdout" | "stderr"; text: string}; /** The group this owner will clean up, reported once the Host is spawned. @@ -54,12 +55,15 @@ function signalGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signa export type HostProcessHooks = { spawned?: (item: HostProcessSpawned) => Promise; openInput?: (write: (text: string) => Promise) => void; + /** SDKs such as OAR own additional detached groups below this Host. */ + trackDescendants?: boolean; + descendants?: (item: {kind: "descendants"; process_groups: number[]}) => Promise; }; export async function runHostProcess(request: HostProcessRequest, output: (item: HostProcessOutput) => Promise, signal?: AbortSignal, terminationGraceMs = HOST_PROCESS_TERMINATE_GRACE_MS, - {spawned, openInput}: HostProcessHooks = {}): Promise { + {spawned, openInput, trackDescendants, descendants}: HostProcessHooks = {}): Promise { const base: HostProcessResult = {kind: "result", outcome: "spawn_failed", returncode: null, signal: null, output_complete: true, cleanup_scope: process.platform === "win32" ? "process_tree_best_effort" : "process_group", group_signal_sent: false}; @@ -69,6 +73,25 @@ export async function runHostProcess(request: HostProcessRequest, let outcome: HostProcessResult["outcome"] = "exited"; let complete = true, forcedDrain = false, stdoutBytes = 0; let cleanup: Promise | undefined; + const ownedGroups = new Set(); + const observeDescendants = async () => { + if (!trackDescendants || !child.pid || child.exitCode !== null || child.signalCode !== null) return; + for (const group of await descendantProcessGroups(child.pid, child.pid)) ownedGroups.add(group); + if (descendants) { + try { await descendants({kind: "descendants", process_groups: [...ownedGroups]}); } + catch { + // The Python owner may have died. Lost evidence makes output incomplete, + // but must not prevent signals for the groups we already observed. + complete = false; + } + } + }; + const signalDescendants = (signal: NodeJS.Signals) => { + for (const group of ownedGroups) { + try { process.kill(-group, signal); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } + } + }; const clean = () => cleanup ??= (async () => { if (process.platform === "win32") { if (!child.pid) return; @@ -83,16 +106,28 @@ export async function runHostProcess(request: HostProcessRequest, if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); return; } + // Observe before signalling: detached SDK descendants would otherwise be + // reparented and invisible after their OAR owner exits. A second observation + // covers a runtime still opening during the graceful shutdown window. + await observeDescendants(); + signalDescendants("SIGTERM"); // Always signal the owned group, including after the leader's exit. const sent = signalGroup(child, "SIGTERM"); base.group_signal_sent ||= sent; if (sent) { await delay(terminationGraceMs); + await observeDescendants(); + signalDescendants("SIGKILL"); signalGroup(child, "SIGKILL"); // KILL delivery is asynchronous. Closed pipes and a reaped leader do not // establish that descendants have stopped executing or writing. await waitForProcessGroupStop(child.pid!); } + if (trackDescendants) { + signalDescendants("SIGKILL"); + await Promise.all([...ownedGroups].map(waitForProcessGroupStop)); + base.descendant_cleanup_complete = true; + } })(); const stop = (reason: HostProcessResult["outcome"]) => { if (outcome === "exited") outcome = reason; diff --git a/loopx/control_plane/turn_driver/host_process_bridge.ts b/loopx/control_plane/turn_driver/host_process_bridge.ts index ef1f97b78e..575ef94934 100644 --- a/loopx/control_plane/turn_driver/host_process_bridge.ts +++ b/loopx/control_plane/turn_driver/host_process_bridge.ts @@ -43,9 +43,18 @@ process.stdin.on("data", (chunk: Buffer) => { } const lease = raw.delegated_lease; delete raw.delegated_lease; + const trackDescendants = raw.track_descendants === true; + delete raw.track_descendants; + const terminationGraceMs = raw.termination_grace_ms; + delete raw.termination_grace_ms; + if (terminationGraceMs !== undefined && (typeof terminationGraceMs !== "number" + || !Number.isFinite(terminationGraceMs) || terminationGraceMs < 0 || terminationGraceMs > 10000)) { + throw new TypeError("invalid Host termination grace"); + } const request = decodeHostProcessRequest(raw); // The owned process group is reported before either path can run unaccounted. - if (lease === undefined) await emit(await runHostProcess(request, emit, owner.signal, undefined, {spawned: emit})); + if (lease === undefined) await emit(await runHostProcess(request, emit, owner.signal, terminationGraceMs, + {spawned: emit, trackDescendants, descendants: emit})); else { // Ordinary Hosts do not load canonical lease/provider modules. const {decodeDelegatedHostLease, runLeasedHostProcess} = await import("./leased_host_process.ts"); diff --git a/loopx/control_plane/turn_driver/host_process_group.ts b/loopx/control_plane/turn_driver/host_process_group.ts index 6419199ae1..d4ba0c7142 100644 --- a/loopx/control_plane/turn_driver/host_process_group.ts +++ b/loopx/control_plane/turn_driver/host_process_group.ts @@ -5,6 +5,35 @@ import {setTimeout as delay} from "node:timers/promises"; const CLEANUP_DEADLINE_MS = 1000; +/** Detached SDK children retain their parent identity until the owner stops. */ +export async function descendantProcessGroups(parentPid: number, ownerGroup: number): Promise { + const snapshot = await new Promise((resolve, reject) => { + execFile("ps", ["-A", "-o", "pid=", "-o", "ppid=", "-o", "pgid="], + {encoding: "utf8", timeout: CLEANUP_DEADLINE_MS, killSignal: "SIGKILL", maxBuffer: 1024 * 1024}, + (error, stdout) => error ? reject(new Error("managed Host descendant observation failed")) : resolve(stdout)); + }); + if (!snapshot.trim()) throw new Error("invalid managed Host descendant observation"); + const rows = snapshot.split("\n").filter(line => line.trim()).map(line => { + const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s*$/.exec(line); + if (!match) throw new Error("invalid managed Host descendant observation"); + return {pid: Number(match[1]), parent: Number(match[2]), group: Number(match[3])}; + }); + const descendants = new Set([parentPid]); + for (let changed = true; changed;) { + changed = false; + for (const row of rows) { + if (descendants.has(row.parent) && !descendants.has(row.pid)) { + descendants.add(row.pid); changed = true; + } + } + } + if (rows.some(row => descendants.has(row.pid) && row.group !== ownerGroup && !descendants.has(row.group))) { + throw new Error("managed Host descendant joined an unowned process group"); + } + return [...new Set(rows.filter(row => row.pid !== parentPid && descendants.has(row.pid) + && row.group > 1 && row.group !== ownerGroup).map(row => row.group))]; +} + /** Zombies cannot execute; an unknown/non-zombie state must remain live. */ export function hasLiveGroupMembers(snapshot: string, pgid: number): boolean { if (!snapshot.trim()) throw new Error("invalid managed Host process-group observation"); diff --git a/loopx/control_plane/turn_driver/host_process_transport.py b/loopx/control_plane/turn_driver/host_process_transport.py index bd9a1c646a..cf1e5f7693 100644 --- a/loopx/control_plane/turn_driver/host_process_transport.py +++ b/loopx/control_plane/turn_driver/host_process_transport.py @@ -7,6 +7,7 @@ import subprocess import sys import tempfile +import threading import time from collections.abc import Callable, Sequence from contextlib import AbstractContextManager, ExitStack @@ -139,6 +140,21 @@ def _host_process_drain(record: dict[str, Any] | None, *, supervises: str, return HOST_PROCESS_UNATTRIBUTABLE if bridge_present: # the bridge leads its own session return HOST_PROCESS_DRAINING + if record.get("descendant_tracking") is not None: + groups = record.get("descendant_process_groups") + if (record["descendant_tracking"] is not True or not isinstance(groups, list) + or any(type(item) is not int or item <= 1 for item in groups)): + return HOST_PROCESS_UNATTRIBUTABLE + for descendant_group in groups: + present = _process_group_present(descendant_group) + if present is None: + return HOST_PROCESS_UNATTRIBUTABLE + if present: + return HOST_PROCESS_DRAINING + # A lost supervisor cannot certify detached children by the absence of + # its own group. Only its completed tree observation closes that gap. + if record.get("descendant_cleanup_complete") is not True: + return HOST_PROCESS_UNATTRIBUTABLE if group is None: # The supervisor left before reporting a group it may already have spawned. return HOST_PROCESS_UNATTRIBUTABLE if record.get("phase") == "launching" else HOST_PROCESS_DRAINED @@ -248,6 +264,67 @@ def _begin_host_process_record(record_path: Path | None, *, record_value: str, return record +def _exchange_host_process( + proc: subprocess.Popen[str], request: dict[str, Any], *, guarded: GuardedHostBridge | None, + launch_deadline: float | None, launch_scope: ExitStack, + record: dict[str, Any] | None, record_path: Path | None, track_descendants: bool, + on_stdout: Callable[[str], None] | None, on_stderr: Callable[[str], None] | None, +) -> tuple[dict[str, Any] | None, float | None]: + """Send the request and consume facts, releasing admission before Host output.""" + assert proc.stdin is not None and proc.stdout is not None + frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" + if guarded is not None: + guarded.send(frame) + else: + proc.stdin.write(frame) + proc.stdin.flush() + result: dict[str, Any] | None = None + lines = iter(proc.stdout) + while True: + event = (guarded.next(deadline=launch_deadline) if guarded is not None + else json.loads(line) if (line := next(lines, None)) is not None else None) + if event is None: + break + kind = event.get("kind") + if kind == "spawned" and isinstance(event.get("pid"), int): + if record is not None: + assert record_path is not None + group = event.get("process_group") + record.update(phase="spawned", host_pid=event["pid"], + process_group=group if isinstance(group, int) else None) + _write_host_process_record(record_path, record) + launch_deadline = None + launch_scope.close() + elif kind == "descendants" and track_descendants: + groups = event.get("process_groups") + if not isinstance(groups, list) or any(type(group) is not int or group <= 1 for group in groups): + raise RuntimeError("Invalid managed Host descendant observation") + if record is not None: + assert record_path is not None + record["descendant_process_groups"] = groups + _write_host_process_record(record_path, record) + elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): + consume = on_stdout if kind == "stdout" else on_stderr + if consume is not None: + consume(event["text"]) + elif kind == "result" and event.get("outcome") in { + "exited", + "timeout", + "cancelled", + "output_limit", + "spawn_failed", + }: + result = event + launch_deadline = None + launch_scope.close() + elif kind == "not_launched" and guarded is not None: + guarded.not_launched = True + raise TimeoutError("managed Host bridge launch deadline expired") + else: + raise RuntimeError("Invalid managed Host process observation") + return result, launch_deadline + + def run_host_process( argv: Sequence[str], *, @@ -261,6 +338,10 @@ def run_host_process( delegated_lease: dict[str, Any] | None = None, environment: dict[str, str] | None = None, launch_guard: Callable[[], AbstractContextManager[None]] | None = None, + track_descendants: bool = False, + cancel_event: threading.Event | None = None, + termination_grace_seconds: float | None = None, + cleanup_check: Callable[[], bool] | None = None, ) -> dict[str, Any]: """Keep the control pipe open until exit; EOF cancels the owned process group. @@ -282,6 +363,14 @@ def run_host_process( } if delegated_lease is not None: request["delegated_lease"] = delegated_lease + if track_descendants: + if delegated_lease is not None: + raise ValueError("detached descendants must be tracked by their direct Host owner") + request["track_descendants"] = True + if termination_grace_seconds is not None: + if not 0 <= termination_grace_seconds <= 10: + raise ValueError("managed Host termination grace must be between zero and ten seconds") + request["termination_grace_ms"] = termination_grace_seconds * 1000 bridge = Path(__file__).with_name("host_process_bridge.ts") # The bridge runs on the caller's environment, which may pin the release the # Host must run and name the record its supervisor owns. Copy it instead of @@ -294,6 +383,13 @@ def run_host_process( if supervision not in {"direct", "leased"}: raise ValueError("invalid managed Host supervision scope") record_path = Path(record_value) if record_value else None + if cancel_event is not None and cancel_event.is_set(): + if record_path is not None: + prepare_host_process_record(record_path) + return {"kind": "result", "outcome": "cancelled", "returncode": None, + "signal": None, "output_complete": True, "group_signal_sent": False, + "cleanup_scope": "process_group" if os.name != "nt" else "process_tree_best_effort", + **({"descendant_cleanup_complete": True} if track_descendants else {})} supervises = HOST_PROCESS_SUPERVISES_HOST if record_path is not None and delegated_lease is not None: # A leased run starts the private leased CLI, which records the actual @@ -324,59 +420,42 @@ def run_host_process( env=bridge_environment, ) assert proc.stdin is not None and proc.stdout is not None + cancel_watch_done = threading.Event() + cancel_watch: threading.Thread | None = None + if cancel_event is not None: + def watch_cancellation() -> None: + while not cancel_watch_done.wait(0.05): + if cancel_event.is_set(): + try: + proc.stdin.close() # EOF asks the existing TS owner to cancel and drain. + except (OSError, ValueError): + pass + return + + cancel_watch = threading.Thread(target=watch_cancellation, daemon=True) + cancel_watch.start() result: dict[str, Any] | None = None record = None launch_scope = ExitStack() guarded = (GuardedHostBridge(proc, deadline=launch_deadline, - cleanup_timeout=8 if delegated_lease is not None else 5) + cleanup_timeout=max(8 if delegated_lease is not None else 5, (termination_grace_seconds or 0) + 5)) if launch_deadline is not None else None) try: if launch_guard is not None: launch_scope.enter_context(launch_guard()) record = _begin_host_process_record(record_path, record_value=record_value, supervises=supervises, supervision=supervision, bridge_pid=proc.pid) - frame = json.dumps(request, ensure_ascii=False, separators=(",", ":")) + "\n" - if guarded is not None: - guarded.send(frame) - else: - proc.stdin.write(frame) - proc.stdin.flush() - lines = iter(proc.stdout) - while True: - event = (guarded.next(deadline=launch_deadline) if guarded is not None - else json.loads(line) if (line := next(lines, None)) is not None else None) - if event is None: - break - kind = event.get("kind") - if kind == "spawned" and isinstance(event.get("pid"), int): - if record is not None: - assert record_path is not None - group = event.get("process_group") - record.update(phase="spawned", host_pid=event["pid"], - process_group=group if isinstance(group, int) else None) - _write_host_process_record(record_path, record) - launch_deadline = None - launch_scope.close() - elif kind in {"stdout", "stderr"} and isinstance(event.get("text"), str): - consume = on_stdout if kind == "stdout" else on_stderr - if consume is not None: - consume(event["text"]) - elif kind == "result" and event.get("outcome") in { - "exited", - "timeout", - "cancelled", - "output_limit", - "spawn_failed", - }: - result = event - launch_deadline = None - launch_scope.close() - elif kind == "not_launched" and guarded is not None: - guarded.not_launched = True - raise TimeoutError("managed Host bridge launch deadline expired") - else: - raise RuntimeError("Invalid managed Host process observation") + if record is not None and track_descendants: + record.update(descendant_tracking=True, descendant_process_groups=[], descendant_cleanup_complete=False) + assert record_path is not None + _write_host_process_record(record_path, record) + result, launch_deadline = _exchange_host_process( + proc, request, guarded=guarded, launch_deadline=launch_deadline, launch_scope=launch_scope, + record=record, record_path=record_path, track_descendants=track_descendants, + on_stdout=on_stdout, on_stderr=on_stderr, + ) finally: + cancel_watch_done.set() try: if guarded is not None and (launch_deadline is not None or sys.exc_info()[0] is not None): # A failed handoff still owns the Goal fence. The TS bridge must @@ -392,7 +471,7 @@ def run_host_process( # callback failure. Leased cleanup retains its original budget. proc.stdin.close() try: - proc.wait(timeout=8 if delegated_lease is not None else 5) + proc.wait(timeout=max(8 if delegated_lease is not None else 5, (termination_grace_seconds or 0) + 5)) except subprocess.TimeoutExpired: proc.kill() proc.wait() @@ -402,10 +481,18 @@ def run_host_process( guarded.close_output() else: proc.stdout.close() + if cancel_watch is not None: + cancel_watch.join(timeout=0.2) + if result is not None and cleanup_check is not None and not cleanup_check(): + result["output_complete"] = False + if track_descendants: + result["descendant_cleanup_complete"] = False if record is not None and result is not None and proc.returncode == 0: assert record_path is not None # The supervisor returned only after cleaning its group; the group is still re-read. record["phase"] = "finished" + if track_descendants: + record["descendant_cleanup_complete"] = result.get("descendant_cleanup_complete") is True _write_host_process_record(record_path, record) if proc.returncode != 0 or result is None: raise RuntimeError("Managed Host process supervision returned no result") diff --git a/loopx/doctor.py b/loopx/doctor.py index fbbec1fd20..4633d01fed 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -958,6 +958,11 @@ def collect_doctor( decision_context_capture = collect_capture_host_diagnostics(selected_runtime_root) typescript_control_plane = collect_effect_runtime_readiness(deep=deep) + # These bounded model hosts are optional. Their Node 24+ dependency must + # not raise Core's Node requirement or make unrelated CLI work unavailable. + from .hosts.oar.installation import inspect_installation + + oar_runtime = inspect_installation() typescript_runtime_required = True deep_validation = None if deep: @@ -1121,6 +1126,12 @@ def collect_doctor( "ok": bool(typescript_control_plane.get("ready")), "detail": str(typescript_control_plane.get("status")), }, + { + "id": "oar_runtime_ready", + "required": False, + "ok": oar_runtime.get("ready") is True, + "detail": str(oar_runtime.get("reason") or "Optional bounded host runtime is ready"), + }, ] from .desktop_installation import desktop_installation_status @@ -1178,6 +1189,7 @@ def collect_doctor( "runtime_projection_routes": runtime_projection_routes, "decision_context_capture": decision_context_capture, "typescript_control_plane": typescript_control_plane, + "oar_runtime": oar_runtime, "install_freshness": install_freshness, "upgrade_hint": install_freshness, "skill": { @@ -1260,6 +1272,11 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: if isinstance(typescript_control_plane.get("runtime_lifecycle"), dict) else {} ) + oar_runtime = ( + payload.get("oar_runtime") + if isinstance(payload.get("oar_runtime"), dict) + else {} + ) lines = [ "# LoopX Doctor", "", @@ -1421,6 +1438,20 @@ def render_doctor_markdown(payload: dict[str, Any]) -> str: recommended_action = typescript_control_plane.get("recommended_action") if recommended_action: lines.append(f"- recommended_action: {recommended_action}") + if oar_runtime: + lines.extend([ + "", + "## Optional OAR Host Runtime", + "", + "Used by bounded Codex Turns, DeepSeek Harness segments and the Pi host. " + "Core commands and native Codex Chat do not require this dependency.", + "", + f"- ready: `{oar_runtime.get('ready')}`", + f"- oar_version: `{oar_runtime.get('oar_version')}`", + ]) + for name in ("node", "runtime_directory", "reason", "next_action"): + if oar_runtime.get(name): + lines.append(f"- {name}: {oar_runtime[name]}") if payload.get("agent_type") == "zcode": lines.extend(["", "## ZCode Skill Delivery"]) for name, skill in sorted((payload.get("skills") or {}).items()): diff --git a/loopx/dsh_goal_mode/README.md b/loopx/dsh_goal_mode/README.md index c6c1087cf3..b4e82206ff 100644 --- a/loopx/dsh_goal_mode/README.md +++ b/loopx/dsh_goal_mode/README.md @@ -5,6 +5,12 @@ LoopX-governed goal loop. LoopX keeps authority (goal/todo state, quota, validation); the adapter only translates one governed Turn into one bounded dsh work segment and shapes the reply into a typed result. +Bounded execution now uses the shared [OAR runtime](../hosts/oar/README.md). +Install it once with Node.js 24+ using `python -m loopx.hosts.oar install`, in +addition to `loopx[deepseek-harness]`. The Python SDK runs in a supervised worker; +existing DSH configuration and signed request/result contracts remain in place. +The separate native DSH plugin keeps its existing same-session integration. + ## Surface The `deepseek-harness` host surface (aliases: `deepseek_harness`, @@ -53,8 +59,8 @@ a non-ASCII request (`invalid request JSON on stdin`) and dies in the final ## In-process host (`--host dsh`) -`loopx turn run-once --host dsh` runs the same adapter inside the CLI process -instead of a generic subprocess. Provider failures then reach the Turn +`loopx turn run-once --host dsh` calls the same typed adapter directly inside +the CLI process; its SDK execution is supervised through OAR. Provider failures reach the Turn journal as typed `loopx_turn_host_failure_v0` kinds instead of collapsing to `unknown` through a bare nonzero exit, so bounded same-Turn retry stays available. Both failure shapes are covered: a raised transport exception and diff --git a/loopx/dsh_goal_mode/turn_host_adapter.py b/loopx/dsh_goal_mode/turn_host_adapter.py index 24dc19619d..5ba1e09c71 100644 --- a/loopx/dsh_goal_mode/turn_host_adapter.py +++ b/loopx/dsh_goal_mode/turn_host_adapter.py @@ -25,6 +25,7 @@ import json import os import sys +import threading from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path @@ -226,42 +227,67 @@ def run_dsh_turn( runtime_bin: str | None, request_timeout_seconds: float | None, env: Mapping[str, str] | None = None, + cancel_event: threading.Event | None = None, ) -> dict[str, Any]: - """Run one bounded DeepSeek Harness session through the Python SDK. + """Run the existing bounded SDK segment under the shared OAR owner. - Returns the terminal outcome (final response, finish reason, events) - instead of only the final response: the SDK reports provider failures - through ``RunResult.finish_reason`` and the last ``turn/end`` event - rather than raising a Python exception. + Native result and failure facts survive the transport unchanged. OAR owns + lifecycle and observation; the existing adapter owns Turn interpretation. + No dependency installation or alternative runtime is selected here. """ + from ..hosts.oar.bridge import run_oar_turn + from ..hosts.oar import dsh_worker + from ..control_plane.turn_driver.host_failure import HOST_FAILURE_KINDS - try: - from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig - except ImportError as exc: - raise RuntimeError( - "deepseek-harness-sdk is required; install it with " - "`python -m pip install 'loopx[deepseek-harness]'`" - ) from exc - - config = build_sdk_config( - provider=provider, - model=model, - reasoning_effort=( - require_supported_reasoning_effort(reasoning_effort) - if reasoning_effort is not None - else None - ), - workspace=workspace, - dsh_home=session_root, - max_tokens=max_tokens, - cordis=cordis, - runtime_bin=runtime_bin, - request_timeout_seconds=request_timeout_seconds, - env=env, - ) - with DeepSeekHarness(DeepSeekHarnessConfig(**config)) as harness: - result = harness.run(prompt, session_id=session_id) - return normalize_runner_outcome(result) + if reasoning_effort is not None: + reasoning_effort = require_supported_reasoning_effort(reasoning_effort) + native_results: list[dict[str, Any]] = [] + native_failures: list[dict[str, Any]] = [] + + def observe(record: dict[str, Any]) -> None: + if record.get("type") != "native": + return + frame = _mapping(record.get("frame")) + body = _mapping(frame.get("body")) + native = body.get("native") + if frame.get("kind") == "frame" and isinstance(native, dict): + if body.get("type") == "dsh/result": + native_results.append(native) + elif body.get("type") == "dsh/error": + native_failures.append(native) + + observed = run_oar_turn({ + "runtime": "dsh", "cwd": str(workspace), "prompt": prompt, + "resume": session_id, "model": model, + **({"effort": reasoning_effort} if reasoning_effort is not None else {}), + "env": dict(env or {}), + "dsh": { + "python_executable": sys.executable, "worker_path": str(Path(dsh_worker.__file__).resolve()), + "provider": provider, "dsh_home": str(session_root), "max_tokens": max_tokens, + "cordis": str(cordis.expanduser().resolve()) if cordis else None, + "runtime_bin": runtime_bin, "request_timeout_seconds": request_timeout_seconds, + }, + }, project=workspace, timeout_seconds=request_timeout_seconds, + on_record=observe, cancel_event=cancel_event) + if observed["outcome"] == "timeout": + raise TimeoutError("DSH execution exceeded the bounded segment timeout") + if observed["outcome"] != "exited": + raise BuiltInHostError("dsh_runtime_" + observed["outcome"], failure_kind="transport_lost") + if native_failures: + failure_kind = native_failures[-1].get("failure_kind") + raise BuiltInHostError("dsh_sdk_failed", failure_kind=(failure_kind if failure_kind in HOST_FAILURE_KINDS else "unknown")) + if (len(native_results) != 1 or not observed["output_complete"] + or _mapping(observed.get("oar_result")).get("error")): + raise BuiltInHostError("dsh_runtime_result_unconfirmed", failure_kind="transport_lost") + outcome = normalize_runner_outcome(native_results[0]) + if outcome["finish_reason"] in {"completed", "stop"}: + if _mapping(observed.get("turn_outcome")).get("kind") != "completed" or observed["returncode"] != 0: + raise BuiltInHostError("dsh_runtime_completion_unconfirmed", failure_kind="transport_lost") + elif outcome["finish_reason"] == "aborted": + raise BuiltInHostError("dsh_runtime_aborted", failure_kind="unknown") + elif outcome["finish_reason"] not in {"error", "max-tokens"}: + raise DshHostResultError("DSH did not report a recognized terminal outcome") + return outcome def terminal_error_reason(outcome: Mapping[str, Any]) -> dict[str, Any] | None: @@ -502,6 +528,8 @@ def _execute_turn_host_request( "dsh_host_result_rejected", failure_kind="contract_rejected", ) from exc + except BuiltInHostError: + raise except Exception as exc: # noqa: BLE001 - adapter fails closed at boundary raise BuiltInHostError( "dsh_execution_failed", diff --git a/loopx/hosts/oar/README.md b/loopx/hosts/oar/README.md new file mode 100644 index 0000000000..7da7a9a26c --- /dev/null +++ b/loopx/hosts/oar/README.md @@ -0,0 +1,122 @@ +# OAR-managed agent runtimes + +LoopX uses **`@botiverse/oar@0.75.0`** for the sessions, events, controls and +cleanup of its bounded Codex, Pi and DeepSeek Harness executions. LoopX still +owns Turn admission, signed work authority, independent validation, quota and +durable work settlement. An OAR completion is a host observation, never proof +that a Goal or Todo is complete. + +## Install and inspect + +Use the Python environment and operating-system user that run LoopX. OAR needs +**Node.js 24+ and npm**; the rest of LoopX retains its Node.js 22.22.3 minimum. + +```bash +python -m loopx.hosts.oar install +python -m loopx.hosts.oar status +``` + +Installation uses the packaged npm lock with lifecycle scripts disabled. Source +and dependencies are published together in a cache keyed by the source digest. +`LOOPX_OAR_HOME` can select the cache root. Normal Turns and availability probes +do not install, upgrade or contact npm. Missing dependencies produce an +unavailable result with the installation action. Updating LoopX requires running +the explicit install command again when its runtime source or lock changes; +previous source revisions remain independently usable. + +Source contributors can run `npm ci --ignore-scripts --prefix loopx/hosts/oar`. +Python wheels include the TypeScript source, package manifest and lock; they do +not include `node_modules` or require a repository checkout at runtime. + +## Select a runtime + +| LoopX entry point | OAR runtime | Additional setup | +| --- | --- | --- | +| `loopx turn run-once --host codex-cli` | Built-in Codex app-server runtime | Existing Codex CLI and its configured authentication; existing `--codex-*` options remain available | +| `loopx turn run-once --host dsh` and `python -m loopx.dsh_goal_mode` | LoopX DSH runtime backed by the pinned Python SDK | Install `loopx[deepseek-harness]` and configure the existing provider/model credentials | +| Managed DSH Chat segments | Same DSH runtime | Select DSH through the existing restricted steward profile; segments retain `DSH_PERMISSION_MODE=read-only` | +| Explicit `generic-cli` Pi Turn | Built-in Pi runtime in subprocess mode | Configure Pi's provider and model; the pinned OAR dependency supplies the matching Pi runtime | + +The Codex adapter preserves the requested sandbox, model, effort, service tier, +schema, MCP configuration and native session binding. A missing resumed session +does not silently become a new session. The native process receives the requested +sandbox explicitly, including when an ambient OAR default would be more +permissive. OAR controls must be accepted before LoopX treats them as submitted. + +For Pi, select this command through the existing signed generic-cli contract: + +```bash +loopx turn run-once \ + --goal-id \ + --agent-id \ + --host generic-cli \ + --project "$PWD" \ + --host-adapter-command-json '["python","-m","loopx.hosts.oar","turn","--runtime","pi","--model","/"]' \ + --validation-command-json '["./verify-postcondition"]' \ + --execute +``` + +The model option is optional when Pi already has a configured default. The +adapter also accepts `--effort`, `--cwd` and `--timeout-seconds`. Pi uses its +native tools with the operating-system permissions of the selected process; +OAR does not add an OS sandbox to Pi. The existing independent validator must +still accept a candidate before LoopX commits work. An arbitrary separately +installed Pi binary is not substituted for OAR's matching bundled version. + +## Lifecycle and compatibility + +`runtime.ts` lazily loads the selected runtime, subscribes before prompting, +keeps the root session's answer separate from child-agent events, checks control +responses, observes the terminal outcome and disposes the session. `bridge.py` +uses LoopX's shared process supervisor. Cancellation and timeout stop the native +work; an incomplete output or missing cleanup receipt cannot become success. +DSH's Python worker preserves full SDK results and typed provider failures. +Large native records use bounded stdio fragmentation without changing their +OAR event identity. + +Each DSH OAR session owns one bounded Turn. The pinned SDK has no verified +durable-resume API, so this adapter explicitly rejects queued input and a +second prompt on that session. A caller-supplied native ID is preserved; an +existing-ID failure never triggers a fresh-session retry. A new DSH Chat +segment instead receives its own native ID before launch, while the Chat +binding and its bounded visible history remain stable across segments. + +The default DSH Chat adapter can interrupt its running segment. A custom +`--dsh-runner` remains an explicit adapter override and makes only the lifecycle +claims supplied by that runner. DSH Chat still returns one final answer per +segment and does not claim persistent native sessions or token streaming. + +Two existing integration boundaries remain native: + +- The Pi and DSH plugins installed **inside an already running host session** + keep their same-session commands, tools and governed continuation. They do + not start a second OAR-managed host. +- Rich native Codex Chat and the existing Codex/DSH operation transports retain + their adapters for dynamic host tools, history injection and background + controls. OAR 0.75.0 does not supply all of those contracts. This migration + does not advertise those features on the bounded OAR interface. + +## Verification and upstream references + +```bash +npm --prefix loopx/hosts/oar run typecheck +npm --prefix loopx/hosts/oar test +python -m pytest -q --no-cov tests/test_oar_transport.py tests/test_oar_pi_host.py +python examples/loopx-turn-codex-cli-e2e-smoke.py +python examples/loopx-steward-managed-chat-smoke.py +``` + +The tests use synthetic Codex app-server responses and local model fixtures with +the actual OAR, Pi and DSH transports. They do not qualify paid-provider results +or comparative long-horizon effectiveness. + +Design references: + +- [OAR](https://github.com/botiverse/oar/tree/1e8ae0e6c2da3e67e2ed5d358966eaccc9f25665): + runtime/session/event contracts and built-in Codex/Pi adapters. +- [DeepSeek Harness subagent-oar](https://github.com/deepseek-ai/deepseek-harness/blob/f3803c96974ad4a94195fb97e5c637e9c2a3afe3/packages/experimental/subagent-oar/README.zh.md): + DSH launching other OAR runtimes. Upstream OAR has no built-in DSH adapter, + so LoopX supplies one over its existing pinned SDK. +- [Raft source](https://github.com/botiverse/raft-source/tree/db5e399f017d584e49597e3a28757645d6807893): + lazy runtime loading and shared discovery. Its OAR usage does not imply that + all of its native session transports have migrated. diff --git a/loopx/hosts/oar/__init__.py b/loopx/hosts/oar/__init__.py new file mode 100644 index 0000000000..254530b77b --- /dev/null +++ b/loopx/hosts/oar/__init__.py @@ -0,0 +1,5 @@ +"""OAR transport for LoopX-owned agent execution. + +Runtime policy remains in the existing Turn and Chat owners. Importing this +package neither loads a runtime nor installs its optional Node dependencies. +""" diff --git a/loopx/hosts/oar/__main__.py b/loopx/hosts/oar/__main__.py new file mode 100644 index 0000000000..c3426b92e1 --- /dev/null +++ b/loopx/hosts/oar/__main__.py @@ -0,0 +1,70 @@ +"""Install or inspect OAR, or execute a signed Turn with the bundled Pi runtime.""" +from __future__ import annotations + +import argparse +from collections.abc import Sequence +import json +from pathlib import Path +import sys + +from ...control_plane.turn_driver.host_failure import BuiltInHostError +from ...entrypoint import pin_utf8_stdio +from . import installation +from .turn import OarTurnHostConfig, run_oar_host + + +def main(argv: Sequence[str] | None = None) -> int: + if argv is None: + pin_utf8_stdio() + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("install", help="Install the packaged OAR lock into the private runtime cache.") + commands.add_parser("status", help="Inspect the optional runtime without installing dependencies.") + turn = commands.add_parser( + "turn", help="Run one signed generic-cli request using Pi's native tools.", + description=( + "Read one signed LoopX Turn request from stdin. Pi uses its configured " + "models and native tools with the operating-system permissions of this " + "process; this explicit host selection does not provide an OS sandbox." + ), + ) + turn.add_argument("--runtime", choices=("pi",), required=True) + turn.add_argument("--model", help="Native provider/model selector; defaults to Pi's configured model.") + turn.add_argument("--effort", help="Native thinking level supported by the selected Pi model.") + turn.add_argument("--cwd", type=Path, default=Path.cwd()) + turn.add_argument("--timeout-seconds", type=float, default=300.0) + args = parser.parse_args(argv) + if args.command in {"install", "status"}: + try: + result = installation.install() if args.command == "install" else installation.inspect_installation() + except (OSError, RuntimeError, ValueError): + print("OAR installation failed; check Node.js 24+, npm and the runtime cache permissions.", file=sys.stderr) + return 1 + print(json.dumps(result, ensure_ascii=False, separators=(",", ":")), flush=True) + return 0 if result["ready"] else 1 + try: + request = json.load(sys.stdin) + except (ValueError, UnicodeError): + print("OAR Turn host requires one JSON request on stdin.", file=sys.stderr) + return 2 + try: + result = run_oar_host(request, config=OarTurnHostConfig( + runtime=args.runtime, cwd=args.cwd, model=args.model, + effort=args.effort, timeout_seconds=args.timeout_seconds, + )) + except BuiltInHostError as exc: + print( + f"OAR Pi host failed: {exc.reason} ({exc.failure_kind}). " + "Inspect `python -m loopx.hosts.oar status` and the selected Pi provider configuration.", + file=sys.stderr, + ) + return 2 if exc.failure_kind == "contract_rejected" else 1 + except (OSError, RuntimeError, TypeError, ValueError): + print("OAR Turn request or result was rejected; inspect the signed request and native configuration.", file=sys.stderr) + return 1 + print(json.dumps(result, ensure_ascii=False, separators=(",", ":")), flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/hosts/oar/bridge.py b/loopx/hosts/oar/bridge.py new file mode 100644 index 0000000000..d569a7858c --- /dev/null +++ b/loopx/hosts/oar/bridge.py @@ -0,0 +1,170 @@ +"""Python framing for the TypeScript OAR owner and existing Host supervisor.""" +from __future__ import annotations + +from collections.abc import Callable, Mapping +from contextlib import AbstractContextManager +import json +import threading +from pathlib import Path +from typing import Any + +from ...control_plane.turn_driver.host_process_transport import HostOutputLines, run_host_process +from .installation import node_executable, runtime_directory + + +_FRAGMENT_CHARACTERS = 64 * 1024 +_MAX_RECORD_BYTES = 32 * 1024 * 1024 +# A surrogate boundary can shorten each full writer chunk by one code unit. +# Bound list overhead too, so tiny malicious fragments cannot exhaust memory. +_MAX_RECORD_FRAGMENTS = 1 + _MAX_RECORD_BYTES // (_FRAGMENT_CHARACTERS - 1) +_FRAGMENT_FIELDS = {"type", "sequence", "index", "last", "data"} + + +def _unique_json_fields(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + record: dict[str, Any] = {} + for key, value in pairs: + if key in record: + raise ValueError("Duplicate OAR transport field") + record[key] = value + return record + + +def _invalid_json_constant(value: str) -> None: + raise ValueError("Invalid OAR transport JSON constant") + + +def _load_record(text: str) -> dict[str, Any]: + record = json.loads(text, object_pairs_hook=_unique_json_fields, parse_constant=_invalid_json_constant) + if not isinstance(record, dict): + raise ValueError("Invalid OAR transport record") + return record + + +def run_oar_turn( + request: Mapping[str, Any], *, project: Path, + timeout_seconds: float | None = None, drain_timeout_seconds: float = 2, + on_record: Callable[[dict[str, Any]], None] | None = None, + on_stderr: Callable[[str], None] | None = None, + launch_guard: Callable[[], AbstractContextManager[None]] | None = None, + cancel_event: threading.Event | None = None, +) -> dict[str, Any]: + """Run one OAR Turn without acquiring Goal, scheduling or retry authority. + + The shared Host supervisor still owns process groups, hard deadlines and + drain evidence. A successful process exit alone never proves Turn success. + """ + command = [node_executable(), "--no-warnings", "--experimental-strip-types", str(runtime_directory() / "stdio.ts")] + terminal: list[dict[str, Any]] = [] + sessions: list[str] = [] + protocol_complete = True + # Fragment is local transport vocabulary, never an OAR event. Only one + # contiguous record may be in flight; an invalid stream cannot recover. + fragment_parts: list[str] = [] + fragment_sequence = 0 + fragment_index = 0 + fragment_bytes = 0 + + def invalidate() -> None: + nonlocal protocol_complete + protocol_complete = False + fragment_parts.clear() + + def observe(line: str) -> None: + nonlocal protocol_complete, fragment_sequence, fragment_index, fragment_bytes + if not protocol_complete: + return + try: + record = _load_record(line) + except (ValueError, RecursionError): + invalidate() + return + if record.get("type") == "fragment": + if (record.keys() != _FRAGMENT_FIELDS + or type(record["sequence"]) is not int or record["sequence"] != fragment_sequence + or type(record["index"]) is not int or record["index"] != fragment_index + or fragment_index >= _MAX_RECORD_FRAGMENTS + or type(record["last"]) is not bool + or not isinstance(record["data"], str) or not record["data"]): + invalidate() + return + data = record["data"] + try: + fragment_bytes += len(data.encode("utf-8")) + characters = len(data.encode("utf-16-le")) // 2 + except UnicodeError: + invalidate() + return + if characters > _FRAGMENT_CHARACTERS or fragment_bytes > _MAX_RECORD_BYTES: + invalidate() + return + fragment_parts.append(data) + fragment_index += 1 + if not record["last"]: + return + serialized = "".join(fragment_parts) + fragment_parts.clear() + fragment_sequence += 1 + fragment_index = 0 + fragment_bytes = 0 + try: + record = _load_record(serialized) + except (ValueError, RecursionError): + invalidate() + return + elif fragment_parts: + invalidate() + return + if record.get("type") not in ("ready", "event", "native", "result"): + invalidate() + return + if record["type"] == "ready": + session_id = record.get("sessionId") + if not isinstance(session_id, str) or not session_id: + protocol_complete = False + else: + sessions.append(session_id) + elif record["type"] == "result": + result = record.get("result") + if not isinstance(result, dict): + protocol_complete = False + else: + terminal.append(result) + if on_record is not None: + on_record(record) + + lines = HostOutputLines(observe) + def cleanup_confirmed() -> bool: + return (protocol_complete and lines.complete and not fragment_parts + and len(terminal) == 1 and terminal[0].get("cleanupConfirmed") is True) + + observed = run_host_process( + command, project=project, + input_text=json.dumps(dict(request), ensure_ascii=False, separators=(",", ":")) + "\n", + timeout_seconds=timeout_seconds, drain_timeout_seconds=drain_timeout_seconds, + on_stdout=lines.feed, on_stderr=on_stderr, launch_guard=launch_guard, track_descendants=True, + cancel_event=cancel_event, + termination_grace_seconds=6, cleanup_check=cleanup_confirmed, + ) + lines.finish() + result = terminal[0] if len(terminal) == 1 else None + complete = bool(observed["output_complete"] and lines.complete and protocol_complete + and not fragment_parts and len(terminal) == 1) + if result is not None and sessions and result.get("sessionId") != sessions[-1]: + complete = False + if len(sessions) > 1: + complete = False + turn_outcome = result.get("outcome") if result else None + kind = turn_outcome.get("kind") if isinstance(turn_outcome, dict) else None + if kind == "completed" and (len(sessions) != 1 or not isinstance(result.get("sessionId"), str)): + complete = False + final_response = result.get("text") if result is not None and kind == "completed" and complete else None + success = isinstance(final_response, str) and observed["outcome"] == "exited" and observed["returncode"] == 0 + return { + **observed, + "returncode": observed["returncode"] if success or observed["returncode"] != 0 else 1, + "output_complete": complete, + "final_response": final_response if success else None, + "session_id": sessions[-1] if sessions else None, + "turn_outcome": turn_outcome, + "oar_result": result, + } diff --git a/loopx/hosts/oar/dsh.test.ts b/loopx/hosts/oar/dsh.test.ts new file mode 100644 index 0000000000..6dce0035a2 --- /dev/null +++ b/loopx/hosts/oar/dsh.test.ts @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import test, { type TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; +import { awaitTurnEnd } from "@botiverse/oar/observe"; +import type { Event, RawEvent, Session } from "@botiverse/oar"; +import { resolveTestPython } from "../../../scripts/test-python.mjs"; +import { createDshRuntime } from "./dsh.ts"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +const python = resolveTestPython(); +const fakeSdk = ` +import json, os, time +from pathlib import Path +from types import SimpleNamespace + +class DeepSeekHarnessConfig: + def __init__(self, **kwargs): self.kwargs = kwargs + +class DeepSeekHarness: + def __init__(self, config): self.config = config.kwargs + def __enter__(self): return self + def __exit__(self, *args): Path(os.environ['OAR_TEST_CLOSED']).write_text('closed') + def run(self, prompt, *, session_id, on_notification): + Path(os.environ['OAR_TEST_CAPTURE']).write_text(json.dumps({ + 'config': self.config, 'prompt': prompt, 'session_id': session_id, + 'permission': os.environ.get('DSH_PERMISSION_MODE'), + 'removed': os.environ.get('OAR_TEST_REMOVE'), 'pid': os.getpid(), + })) + print('SDK diagnostic stays off the JSONL stream') + events = [] + def emit(kind, data, child=False): + event = {'type': kind, 'data': data} + if not child: events.append(event) + on_notification(SimpleNamespace(method='session.event', payload={ + 'sessionId': 'child-session' if child else session_id, 'event': event, + })) + emit('turn/start', {'turn': 1}) + if prompt == 'wait': + while True: time.sleep(0.01) + if prompt == 'exit': os._exit(0) + if prompt == 'exception': + error = RuntimeError('provider refused ' + os.environ['DEEPSEEK_API_KEY']) + error.code = 'rate_limit' + raise error + emit('assistant/message', {'message': {'id': 'child-message', 'content': [ + {'type': 'text', 'text': 'child output must remain separate'}, + ]}}, True) + emit('request/context', {'model': self.config.get('model')}) + emit('tool/call', {'callId': 'call-1', 'name': 'read', 'arguments': '{"path":"README.md"}'}) + emit('tool/result', {'message': {'toolCallId': 'call-1', 'isError': False, + 'content': [{'type': 'text', 'text': 'tool result'}]}}) + emit('assistant/message', {'message': {'id': 'answer', 'content': [ + {'type': 'reasoning', 'text': 'checked the result'}, + {'type': 'text', 'text': 'root answer'}, + ]}, 'usage': {'inputTokens': 5, 'outputTokens': 4, 'cacheReadTokens': 7, + 'cacheWriteTokens': 2, 'reasoningTokens': 1}}) + reason = {'kind': 'completed'} + if prompt == 'limit': reason = {'kind': 'max-tokens'} + if prompt == 'failure': reason = {'kind': 'error', 'error': { + 'code': 'rate_limit', 'message': 'provider refused ' + os.environ['DEEPSEEK_API_KEY'], + }} + if prompt == 'unknown': reason = {'kind': 'extension-closed'} + if prompt != 'missing': emit('turn/end', {'turn': 1, 'reason': reason}) + return SimpleNamespace(final_response='root answer', events=events, + finish_reason=None if prompt == 'missing' else 'error' if prompt == 'contradictory' else reason['kind']) +`; + +async function fixture(t: TestContext, additionalEnv: Record = {}) { + const directory = await mkdtemp(join(tmpdir(), "loopx-oar-dsh-")); + t.after(() => rm(directory, { recursive: true, force: true })); + await writeFile(join(directory, "deepseek_harness.py"), fakeSdk); + const capture = join(directory, "capture.json"), closed = join(directory, "closed"); + const env = { + PYTHONPATH: [directory, root].join(delimiter), DSH_PERMISSION_MODE: "read-only", + DEEPSEEK_API_KEY: "synthetic-oar-secret", OAR_TEST_CAPTURE: capture, OAR_TEST_CLOSED: closed, + ...additionalEnv, + }; + const runtime = createDshRuntime({ python_executable: python, + provider: "fixture-provider", dsh_home: join(directory, "sessions"), max_tokens: 256, + cordis: join(directory, "settings.yml"), runtime_bin: join(directory, "native-dsh"), + request_timeout_seconds: 45, + }); + const installation = await runtime.installation!({ env }); + assert.equal(installation.kind, "available"); + if (installation.kind !== "available") throw new Error("fixture SDK unavailable"); + const session = await runtime.session(installation, { + cwd: directory, model: "fixture-model", effort: "high", resume: "chosen-session", env, + }); + const observed: Event[] = []; + session.events((event) => observed.push(event)); + t.after(() => session.dispose()); + return { session, runtime, installation, env, directory, capture, closed, observed }; +} + +function resultFrame(session: Session): Extract { + const found = session.records().find((record) => record.kind === "frame" && record.body.type === "dsh/result"); + assert.ok(found && found.kind === "frame"); + return found; +} + +test("DSH SDK transport preserves configuration, native identity, read-only mode and terminal evidence", async (t) => { + const f = await fixture(t, { OAR_TEST_REMOVE: null }); + const prompt = await f.session.prompt("run"); + assert.equal(prompt.kind, "accepted"); + assert.deepEqual(await awaitTurnEnd(f.session, prompt.seq), { kind: "completed" }); + assert.ok(existsSync(f.closed), "SDK cleanup precedes the terminal OAR event"); + const captured = JSON.parse(await readFile(f.capture, "utf8")); + assert.equal(captured.session_id, "chosen-session"); + assert.equal(captured.prompt, "run"); + assert.equal(captured.permission, "read-only"); + assert.equal(captured.config.env.DSH_PERMISSION_MODE, "read-only"); + assert.equal(captured.removed, null); + assert.equal(captured.config.provider, "fixture-provider"); + assert.equal(captured.config.model, "fixture-model"); + assert.equal(captured.config.reasoning_effort, "high"); + assert.equal(captured.config.cwd, f.directory); + assert.equal(captured.config.dsh_home, join(f.directory, "sessions")); + assert.equal(captured.config.max_tokens, 256); + assert.deepEqual(captured.config.patches, [join(f.directory, "settings.yml")]); + assert.equal(captured.config.dsh_bin, join(f.directory, "native-dsh")); + assert.equal(captured.config.request_timeout_seconds, 45); + assert.deepEqual(f.observed.filter((event) => event.kind === "text_delta").map((event) => event.text), ["root answer"]); + assert.equal(f.session.model().value, "fixture-model"); + assert.deepEqual(f.session.usage().value.total, { input: 14, output: 4, cacheRead: 7, cacheWrite: 2, reasoning: 1 }); + assert.ok(f.observed.some((event) => event.kind === "tool_call_started" && event.callId === "call-1")); + assert.ok(f.observed.some((event) => event.kind === "tool_call_ended" && event.result === "ok")); + assert.ok(f.session.records().some((record) => record.sessionId === "child-session")); + const native = resultFrame(f.session).body.native as { final_response: string; finish_reason: string; events: unknown[] }; + assert.equal(native.final_response, "root answer"); + assert.equal(native.finish_reason, "completed"); + assert.ok(native.events.length > 0); + assert.equal(f.observed.filter((event) => event.kind === "turn_ended").length, 1); + assert.deepEqual(f.session.records().map((record) => record.seq), f.session.records().map((_, index) => index)); +}); + +test("DSH owns one native Turn and refuses queued or repeated inputs before another SDK launch", async (t) => { + const f = await fixture(t); + assert.equal(f.session.withdraw, undefined, "there is no held queue to withdraw from"); + const queuedBeforePrompt = await f.session.queue("must not consume the first turn"); + assert.equal(queuedBeforePrompt.kind, "rejected"); + if (queuedBeforePrompt.kind === "rejected") assert.equal(queuedBeforePrompt.code, "unsupported"); + assert.equal(existsSync(f.capture), false); + + const prompt = await f.session.prompt("run"); + assert.equal(prompt.kind, "accepted"); + assert.deepEqual(await awaitTurnEnd(f.session, prompt.seq), { kind: "completed" }); + const captured = await readFile(f.capture, "utf8"); + for (const outcome of [await f.session.prompt("must not reopen the native id"), await f.session.queue("must not queue")]) { + assert.equal(outcome.kind, "rejected"); + if (outcome.kind === "rejected") assert.equal(outcome.code, "unsupported"); + } + assert.equal(f.session.id, "chosen-session"); + assert.equal(f.observed.filter((event) => event.kind === "turn_active").length, 1); + await f.session.dispose(); + assert.equal(await readFile(f.capture, "utf8"), captured, "rejected inputs cannot overwrite the native invocation"); + const disposed = await f.session.prompt("after disposal"); + assert.equal(disposed.kind, "rejected"); + if (disposed.kind === "rejected") assert.equal(disposed.code, "disposed"); +}); + +test("DSH never treats missing, contradictory, unknown or limited terminal evidence as completion", async (t) => { + for (const input of ["missing", "contradictory", "unknown", "limit", "failure"]) { + await t.test(input, async (child) => { + const f = await fixture(child); + const prompt = await f.session.prompt(input); + const outcome = await awaitTurnEnd(f.session, prompt.seq); + assert.equal(outcome.kind, "failed"); + if (input === "limit" && outcome.kind === "failed") assert.equal(outcome.failure, "limit_reached"); + if (input === "missing" || input === "contradictory") { + assert.equal(f.observed.filter((event) => event.kind === "turn_ended").length, 0, + "malformed transport must not invent a native turn-end fact"); + } + assert.ok(resultFrame(f.session)); + assert.ok(!JSON.stringify(f.session.records()).includes("synthetic-oar-secret")); + assert.ok(!JSON.stringify(outcome).includes("synthetic-oar-secret")); + const repeated = await f.session.prompt("must not retry with the same or a fresh native id"); + assert.equal(repeated.kind, "rejected"); + if (repeated.kind === "rejected") { + assert.equal(repeated.code, input === "missing" || input === "contradictory" ? "runtime_exited" : "unsupported"); + } + }); + } +}); + +test("DSH busy controls and abort use OAR records and unwind the actual SDK worker", async (t) => { + const f = await fixture(t); + let ready!: () => void; + const began = new Promise((resolve) => { ready = resolve; }); + f.session.events((event) => { if (event.kind === "turn_active") ready(); }); + const first = await f.session.prompt("wait"); + await began; + const second = await f.session.prompt("must not run"); + assert.equal(second.kind, "rejected"); + if (second.kind === "rejected") assert.equal(second.code, "busy"); + assert.equal(f.session.steer, undefined); + const queued = await f.session.queue("must not be taken over while the first turn runs"); + assert.equal(queued.kind, "rejected"); + if (queued.kind === "rejected") assert.equal(queued.code, "unsupported"); + assert.equal(f.session.withdraw, undefined); + assert.equal((await f.session.abort()).kind, "accepted"); + // Real callers abort and dispose together; this must not signal twice and + // interrupt Python's finally block before it reaps its native runtime. + await f.session.dispose(); + assert.equal((await awaitTurnEnd(f.session, first.seq)).kind, "aborted"); + assert.ok(existsSync(f.closed)); + assert.equal(f.observed.filter((event) => event.kind === "turn_ended").length, 0, + "cancellation records the observed exit, never a fabricated native turn/end"); + assert.equal((await f.session.prompt("after disposal")).kind, "rejected"); +}); + +test("DSH abort without dispose retains the native exit and cannot restart the Session", async (t) => { + const f = await fixture(t); + let ready!: () => void; + const began = new Promise((resolve) => { ready = resolve; }); + f.session.events((event) => { if (event.kind === "turn_active") ready(); }); + const first = await f.session.prompt("wait"); + await began; + assert.equal((await f.session.abort()).kind, "accepted"); + assert.deepEqual(await awaitTurnEnd(f.session, first.seq), { kind: "aborted" }); + assert.ok(existsSync(f.closed), "abort alone still waits for SDK cleanup before its observed exit"); + assert.equal(f.session.records().some((record) => record.kind === "request" + && record.direction === "toRuntime" && record.body.kind === "dispose"), false); + const repeated = await f.session.prompt("must not reopen after abort"); + assert.equal(repeated.kind, "rejected"); + if (repeated.kind === "rejected") assert.equal(repeated.code, "runtime_exited"); + assert.equal(f.session.id, "chosen-session"); + const captured = JSON.parse(await readFile(f.capture, "utf8")); + assert.equal(captured.prompt, "wait"); + assert.equal(captured.session_id, "chosen-session"); +}); + +test("DSH unexpected zero exit and SDK exceptions cannot report a successful turn", async (t) => { + for (const input of ["exit", "exception"]) { + await t.test(input, async (child) => { + const f = await fixture(child); + const prompt = await f.session.prompt(input); + const outcome = await awaitTurnEnd(f.session, prompt.seq); + assert.equal(outcome.kind, "failed"); + assert.equal(f.observed.filter((event) => event.kind === "turn_ended").length, 0); + if (input === "exception") { + const error = f.session.records().find((record) => record.kind === "frame" && record.body.type === "dsh/error"); + assert.ok(error && error.kind === "frame"); + assert.equal((error.body.native as { failure_kind: string }).failure_kind, "rate_limited"); + assert.ok(!JSON.stringify(error).includes("synthetic-oar-secret")); + } + }); + } +}); + +test("DSH refuses unsupported policy and image options before spawning a worker", async (t) => { + const f = await fixture(t); + await assert.rejects(f.runtime.session(f.installation, { + cwd: f.directory, disallowedTools: ["shell"], + }), { name: "UnsupportedOptionError" }); + const prompt = await f.session.prompt("image", { images: [{ path: join(f.directory, "image.png") }] }); + assert.equal(prompt.kind, "rejected"); + if (prompt.kind === "rejected") assert.equal(prompt.code, "unsupported"); + assert.equal(existsSync(f.capture), false); +}); diff --git a/loopx/hosts/oar/dsh.ts b/loopx/hosts/oar/dsh.ts new file mode 100644 index 0000000000..d1d5eeca33 --- /dev/null +++ b/loopx/hosts/oar/dsh.ts @@ -0,0 +1,316 @@ +/** DSH's existing Python SDK, hosted through OAR's public runtime-author SPI. */ +import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { createInterface } from "node:readline"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { defineRuntime, UnsupportedOptionError } from "@botiverse/oar/browser"; +import { createSessionKernel, inputImagesRefusal, sealSession } from "@botiverse/oar/kernel"; +import type { + InputOptions, RawEvent, RefusedSessionOptions, Runtime, RuntimeEventBody, SessionCapabilities, + SessionOptions, TokenTotals, TurnOutcome, +} from "@botiverse/oar"; + +/** Provider-specific launch configuration; model/cwd/effort/env use SessionOptions. */ +export interface DshRuntimeConfig { + readonly python_executable?: string; + readonly worker_path?: string; + readonly provider?: string; + readonly dsh_home?: string; + readonly max_tokens?: number | null; + readonly cordis?: string | null; + readonly runtime_bin?: string | null; + readonly request_timeout_seconds?: number | null; +} + +type JsonObject = Record; +const object = (value: unknown): JsonObject => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {}; +const text = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; +const finite = (value: unknown): number | undefined => + typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; + +const refusedSessionOptions: RefusedSessionOptions = { + systemPrompt: "DSH system prompts are configured by its Cordis composition", + appendSystemPrompt: "DSH system prompts are configured by its Cordis composition", + mcpServers: "Attach DSH MCP servers through its Cordis composition", + disallowedTools: "DSH tool restrictions are configured by its permission mode and composition", + launchArgs: "The pinned DSH SDK owns its runtime launch arguments", + additionalDirectories: "The pinned DSH SDK exposes one workspace", + outputSchema: "The pinned DSH SDK has no native output-schema option", + name: "The pinned DSH SDK has no native session-name option", + serviceTier: "The pinned DSH SDK has no native service-tier option", + userConfiguration: "The pinned DSH SDK cannot exclude user configuration", +}; + +function checkOptions(options: SessionOptions): void { + for (const [key, reason] of Object.entries(refusedSessionOptions)) { + const value = options[key as keyof SessionOptions]; + if (key === "userConfiguration" ? value === "exclude" + : Array.isArray(value) ? value.length > 0 : value !== undefined) { + throw new UnsupportedOptionError(key as keyof SessionOptions, reason!); + } + } +} + +function environment(overlay: SessionOptions["env"]): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const [name, value] of Object.entries(overlay ?? {})) { + if (value === null) delete env[name]; + else env[name] = value; + } + return env; +} + +function credentialRedactor(env: NodeJS.ProcessEnv): (record: T) => T { + // The public kernel accepts a redactor; OAR's built-in credential selector + // is private. Mask known secret values before retention and observation. + const secrets = Object.entries(env).filter(([name, value]) => + /token|key|secret|password|credential|authorization|cookie/i.test(name) + && !/(?:file|path|dir)$/i.test(name) && typeof value === "string" && value.length >= 8) + .map(([, value]) => value!).sort((left, right) => right.length - left.length); + const redact = (value: unknown): unknown => { + if (typeof value === "string") { + for (const secret of secrets) value = (value as string).split(secret).join("[redacted]"); + return value; + } + if (Array.isArray(value)) return value.map(redact); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, part]) => [key, redact(part)])); + } + return value; + }; + return (record: T): T => redact(record) as T; +} + +function sdkConfig(config: DshRuntimeConfig, options: SessionOptions): JsonObject { + const result: JsonObject = { cwd: options.cwd }; + const values: JsonObject = { + provider: config.provider, model: options.model, reasoning_effort: options.effort, + dsh_home: config.dsh_home, max_tokens: config.max_tokens, + dsh_bin: config.runtime_bin, request_timeout_seconds: config.request_timeout_seconds, + }; + for (const [key, value] of Object.entries(values)) { + if (value !== undefined && value !== null) result[key] = value; + } + if (config.cordis) result.patches = [config.cordis]; + if (options.env) { + // Removals were applied to the worker environment; the Python SDK accepts + // only string overrides. Never replace the caller's DSH_PERMISSION_MODE. + result.env = Object.fromEntries(Object.entries(options.env).filter((entry) => entry[1] !== null)); + } + return result; +} + +function nativeOutcome(result: JsonObject): TurnOutcome | null { + if (typeof result.final_response !== "string" || !Array.isArray(result.events) + || result.events.some((event) => event === null || typeof event !== "object" || Array.isArray(event))) { + return null; + } + const terminal = result.events.map(object).findLast((event) => event.type === "turn/end"); + const reason = object(object(terminal?.data).reason); + if (typeof reason.kind !== "string" || result.finish_reason !== reason.kind) { + return null; + } + switch (reason.kind) { + case "completed": + case "stop": + return { kind: "completed" }; + case "aborted": + return { kind: "aborted" }; + case "max-tokens": + return { kind: "failed", failure: "limit_reached", reason: "DSH reached its output-token limit" }; + default: + return { + kind: "failed", failure: "unknown", + reason: text(object(reason.error).message) ?? `DSH turn ended: ${reason.kind}`, + }; + } +} + +/** + * Only proven native fields enter the cross-runtime projection. Raw frames + * preserve unknown/plugin events, descendant sessions, and failure details. + * DSH input/cache token counts are disjoint; OAR input includes cache counts. + */ +function projectEvent(event: JsonObject, totals: { value: TokenTotals }): RuntimeEventBody[] { + const data = object(event.data), message = object(data.message); + const events: RuntimeEventBody[] = []; + if (event.type === "turn/start") events.push({ kind: "turn_active" }); + if (event.type === "request/context" && typeof data.model === "string") { + events.push({ kind: "model", model: data.model }); + } + if (event.type === "assistant/message") { + for (const value of Array.isArray(message.content) ? message.content : []) { + const block = object(value); + const messageId = text(message.id); + if (block.type === "text" && typeof block.text === "string") { + events.push({ kind: "text_delta", text: block.text, ...(messageId ? { messageId } : {}) }); + } else if (block.type === "reasoning" && typeof block.text === "string") { + events.push({ kind: "reasoning", content: { kind: "text", text: block.text }, ...(messageId ? { messageId } : {}) }); + } + } + const usage = object(data.usage), input = finite(usage.inputTokens), output = finite(usage.outputTokens); + if (input !== undefined && output !== undefined) { + const previous = totals.value; + const cacheRead = finite(usage.cacheReadTokens), cacheWrite = finite(usage.cacheWriteTokens); + const reasoning = finite(usage.reasoningTokens); + totals.value = { ...previous, input: previous.input + input + (cacheRead ?? 0) + (cacheWrite ?? 0), output: previous.output + output, + ...(cacheRead === undefined ? {} : { cacheRead: (previous.cacheRead ?? 0) + cacheRead }), + ...(cacheWrite === undefined ? {} : { cacheWrite: (previous.cacheWrite ?? 0) + cacheWrite }), + ...(reasoning === undefined ? {} : { reasoning: (previous.reasoning ?? 0) + reasoning }), + }; + events.push({ kind: "usage", usage: { tokens: totals.value } }); + } + } + if (event.type === "tool/call" && typeof data.callId === "string" && typeof data.name === "string") { + events.push({ kind: "tool_call_started", callId: data.callId, tool: data.name, + ...(typeof data.arguments === "string" ? { input: data.arguments } : {}) }); + } + if (event.type === "tool/result" && typeof message.toolCallId === "string") { + const content = (Array.isArray(message.content) ? message.content : []).map((part) => { + const block = object(part); + return block.type === "text" && typeof block.text === "string" + ? { type: "text" as const, text: block.text } + : { type: "other" as const, value: part }; + }); + events.push({ kind: "tool_call_ended", callId: message.toolCallId, content, + ...(typeof message.isError === "boolean" ? { result: message.isError ? "failed" as const : "ok" as const } : {}) }); + } + if (event.type === "compaction/start") events.push({ kind: "compaction_started" }); + if (event.type === "compaction/end") events.push({ kind: "compaction_ended", + outcome: typeof data.error === "string" ? "failed" : "completed", + ...(typeof data.error === "string" ? { reason: data.error } : {}) }); + return events; +} + +export function createDshRuntime(config: DshRuntimeConfig = {}): Runtime { + const python = config.python_executable ?? "python3"; + const worker = config.worker_path ?? fileURLToPath(new URL("./dsh_worker.py", import.meta.url)); + const runtime = defineRuntime({ + id: "dsh", brand: { name: "DeepSeek Harness", icon: null }, refusedSessionOptions, + installation: async (options) => { + try { + const { stdout } = await promisify(execFile)(python, [worker, "--probe"], { + env: environment(options?.env), encoding: "utf8", timeout: 10_000, maxBuffer: 64 * 1024, + }); + return JSON.parse(stdout).available === true + ? { kind: "available", via: "bundled" } + : { kind: "not_found" }; + } catch { + return { kind: "unsupported", reason: "DSH requires the configured Python interpreter and loopx[deepseek-harness]" }; + } + }, + session: async (_installation, options) => { + checkOptions(options); + // Preserve the existing SDK's caller-selected native identity. Its + // session/prompt API does not expose durable resume: an existing id may + // fail. Never switch ids or retry fresh after that native failure. + const kernel = createSessionKernel(options.resume, credentialRedactor(environment(options.env))); + // OAR requires queue durability metadata but has no availability flag. + // This bounded SDK Session refuses queue() and owns at most one prompt. + const capabilities: SessionCapabilities = { queue: { durable: false }, attribution: "opaque", images: false }; + const totals = { value: { input: 0, output: 0 } as TokenTotals }; + type Running = { child: ChildProcessWithoutNullStreams; done: Promise; finished: boolean; stopping?: Promise }; + const children = new Set(); + let active: Running | null = null; + let prompted = false; + let disposing: Promise | undefined; + + const stop = (run: Running): Promise => { + if (!children.has(run)) return Promise.resolve(); + run.stopping ??= (async () => { + run.child.kill("SIGTERM"); + const force = setTimeout(() => run.child.kill("SIGKILL"), 4_500); + force.unref(); + try { await run.done; } finally { clearTimeout(force); } + })(); + return run.stopping; + }; + + function start(input: string): void { + const child = spawn(python, [worker], { + cwd: options.cwd, env: environment(options.env), stdio: ["pipe", "pipe", "pipe"], + // The enclosing LoopX process runner owns the group, including DSH tools. + detached: false, + }); + let closed!: () => void; + const run: Running = { child, done: new Promise((resolve) => { closed = resolve; }), finished: false }; + active = run; + children.add(run); + child.stderr.resume(); + child.stdin.on("error", () => { /* Process close reports failed transport. */ }); + child.once("error", () => { /* A failed spawn is followed by close. */ }); + const lines = createInterface({ input: child.stdout }); + lines.on("line", (line) => { + let frame: JsonObject; + try { frame = object(JSON.parse(line)); } + catch { + kernel.frame({ type: "dsh/stdout", native: line, events: [] }); + void stop(run); + return; + } + if (frame.type === "notification") { + const native = object(frame.notification), payload = object(native.payload), event = object(payload.event); + const sessionId = text(payload.sessionId) ?? kernel.sessionId; + kernel.frame({ type: text(event.type) ?? text(native.method) ?? "dsh/notification", native, + events: sessionId === kernel.sessionId ? projectEvent(event, totals) : [] }, { sessionId }); + } else if (frame.type === "result") { + const native = object(frame.result); + const outcome = nativeOutcome(native); + run.finished = outcome !== null; + if (run.finished && active === run) active = null; + // SDK RunResult owns the interval through whole-agent idle. An + // intermediate turn/end must not end OAR before this result exists. + kernel.frame({ type: "dsh/result", native, + events: outcome === null ? [] : [{ kind: "turn_ended", outcome }] }); + } else { + kernel.frame({ type: `dsh/${text(frame.type) ?? "unknown"}`, native: frame.error ?? frame, events: [] }); + } + }); + child.once("close", (code) => { + lines.close(); + children.delete(run); + if (active === run) active = null; + if (!run.finished) { + kernel.respond("", { kind: "exited", code }); + } + closed(); + }); + child.stdin.end(JSON.stringify({ prompt: input, session_id: kernel.sessionId, config: sdkConfig(config, options) }) + "\n"); + } + + const accept = (input: string, inputOptions: InputOptions | undefined) => { + const refused = inputImagesRefusal(capabilities, inputOptions?.images); + if (refused) return refused; + if (active !== null) return { kind: "rejected" as const, code: "busy" as const, reason: "DSH is running a turn" }; + if (prompted) return { kind: "rejected" as const, code: "unsupported" as const, + reason: "The pinned DSH SDK supports one native Turn per OAR Session; repeated prompts are unsupported" }; + prompted = true; + start(input); + return { kind: "accepted" as const }; + }; + return sealSession({ + id: kernel.sessionId, capabilities, + prompt: (input, inputOptions) => kernel.control({ kind: "prompt", input, ...inputOptions }, () => accept(input, inputOptions)), + queue: (input, inputOptions) => kernel.control({ kind: "queue", input, ...inputOptions }, () => ({ + kind: "rejected", code: "unsupported", + reason: "The pinned DSH SDK supports one native Turn per OAR Session; queued input is unsupported", + })), + abort: () => kernel.control({ kind: "abort" }, () => { + if (active === null) return { kind: "rejected", code: "no_active_turn", reason: "DSH has no active turn" }; + void stop(active); + return { kind: "accepted" }; + }), + rawEvents: (observer, cursor) => kernel.rawEvents(observer, cursor), + records: () => kernel.records(), graph: () => kernel.graph(), + dispose: () => { + if (disposing) return disposing; + const request = kernel.request("toRuntime", { kind: "dispose" }); + disposing = Promise.all([...children].map(stop)).then(() => { kernel.respond(request.id, { kind: "accepted" }); }); + return disposing; + }, + }); + }, + }); + return runtime; +} diff --git a/loopx/hosts/oar/dsh_worker.py b/loopx/hosts/oar/dsh_worker.py new file mode 100644 index 0000000000..b217190d32 --- /dev/null +++ b/loopx/hosts/oar/dsh_worker.py @@ -0,0 +1,86 @@ +"""JSONL transport for the pinned DeepSeek Harness Python SDK. + +OAR owns the session controls and event stream. This worker only translates one +SDK call, including its native notifications and terminal result. It stays in +the caller's process group so LoopX's existing process runner can stop the +entire runtime tree. The SDK remains responsible for composing DSH permissions. +""" + +from __future__ import annotations + +import contextlib +import json +import signal +import sys +from collections.abc import Mapping +from typing import Any + + +def main() -> int: + output = sys.stdout + if hasattr(output, "reconfigure"): + output.reconfigure(encoding="utf-8") + + def emit(value: Mapping[str, Any]) -> None: + output.write(json.dumps(value, ensure_ascii=True) + "\n") + output.flush() + + def interrupted(_signum: int, _frame: object) -> None: + # Unwind the SDK context manager: its bounded close reaps the runtime. + # A group stop and the owning Node Session may signal us together. + signal.signal(signal.SIGTERM, signal.SIG_IGN) + raise KeyboardInterrupt + + signal.signal(signal.SIGTERM, interrupted) + try: + # SDK logging must not corrupt the JSONL protocol. + with contextlib.redirect_stdout(sys.stderr): + try: + from deepseek_harness import DeepSeekHarness, DeepSeekHarnessConfig + except ImportError as exc: + raise RuntimeError( + "deepseek-harness-sdk is required; install it with " + "`python -m pip install 'loopx[deepseek-harness]'`" + ) from exc + if sys.argv[1:] == ["--probe"]: + emit({"available": True}) + return 0 + request = json.loads(sys.stdin.readline()) + if not isinstance(request, dict): + raise ValueError("DSH worker request must be an object") + prompt, session_id = request.get("prompt"), request.get("session_id") + config = request.get("config") + if not isinstance(prompt, str) or not isinstance(session_id, str) or not session_id: + raise ValueError("DSH worker requires a prompt and a session_id") + if not isinstance(config, dict): + raise ValueError("DSH worker requires an SDK config object") + + def notification(value: Any) -> None: + emit({"type": "notification", "notification": { + "method": value.method, "payload": value.payload, + }}) + + with DeepSeekHarness(DeepSeekHarnessConfig(**config)) as harness: + result = harness.run(prompt, session_id=session_id, on_notification=notification) + # Emit only after the SDK closes, so a consumer of the terminal + # frame cannot strand the native runtime by disposing immediately. + emit({"type": "result", "result": { + "final_response": result.final_response, + "finish_reason": result.finish_reason, + "events": result.events, + }}) + return 0 + except KeyboardInterrupt: + return 130 + except Exception as exc: + from loopx.dsh_goal_mode.host_failure_map import classify_dsh_failure + + emit({"type": "error", "error": { + "name": type(exc).__name__, "message": str(exc)[:4000], + "failure_kind": classify_dsh_failure(exc), + }}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/hosts/oar/installation.py b/loopx/hosts/oar/installation.py new file mode 100644 index 0000000000..5f806cb867 --- /dev/null +++ b/loopx/hosts/oar/installation.py @@ -0,0 +1,131 @@ +"""Explicit, version-locked installation of the optional OAR host dependency. + +Wheels carry the source and npm lock, not a machine-specific node_modules tree. +Only ``python -m loopx.hosts.oar install`` performs network/package-manager IO. +Ordinary Core commands and model turns never install or upgrade dependencies. +""" +from __future__ import annotations + +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +from typing import Any + +from ...control_plane.runtime.node_probe import probe_node +from ...file_lock import exclusive_file_lock + +OAR_VERSION = "0.75.0" +MINIMUM_OAR_NODE_VERSION = (24, 0, 0) +_SOURCE = Path(__file__).parent +_REMEDIATION = "Run `python -m loopx.hosts.oar install` with Node.js 24+ and npm in the LoopX service environment." + + +def _assets() -> list[Path]: + return sorted([ + _SOURCE / "package.json", _SOURCE / "package-lock.json", + *(path for path in _SOURCE.glob("*.ts") if not path.name.endswith(".test.ts")), + ]) + + +def _source_digest() -> str: + digest = hashlib.sha256() + for path in _assets(): + digest.update(path.name.encode("utf-8") + b"\0" + path.read_bytes() + b"\0") + return digest.hexdigest() + + +def _cache_directory() -> Path: + root = Path(os.environ.get("LOOPX_OAR_HOME", "~/.cache/loopx/oar")).expanduser().resolve() + return root / _source_digest() + + +def _dependency_present(directory: Path) -> bool: + try: + manifest = json.loads((directory / "node_modules/@botiverse/oar/package.json").read_text(encoding="utf-8")) + return manifest.get("version") == OAR_VERSION and (directory / "stdio.ts").is_file() + except (OSError, ValueError, AttributeError): + return False + + +def _cached_source_matches(directory: Path) -> bool: + try: + return all((directory / path.name).read_bytes() == path.read_bytes() for path in _assets()) + except OSError: + return False + + +def runtime_directory() -> Path: + # Source checkouts can use the same locked npm package directly. An installed + # wheel uses the immutable, source-bound directory created by install(). + if _dependency_present(_SOURCE): + return _SOURCE + cached = _cache_directory() + if _dependency_present(cached) and _cached_source_matches(cached): + return cached + raise RuntimeError("OAR runtime dependency is unavailable. " + _REMEDIATION) + + +def node_executable() -> str: + probe = probe_node() + if not probe.ready or probe.executable is None: + raise RuntimeError(probe.failure_message + ". " + _REMEDIATION) + version = tuple(int(part) for part in str(probe.version).split("-", 1)[0].split(".")) + if version < MINIMUM_OAR_NODE_VERSION or (version == MINIMUM_OAR_NODE_VERSION and "-" in str(probe.version)): + raise RuntimeError("OAR runtime requires Node.js 24.0.0 or newer. " + _REMEDIATION) + return probe.executable + + +def inspect_installation() -> dict[str, Any]: + try: + node = node_executable() + directory = runtime_directory() + except (OSError, ValueError, RuntimeError) as exc: + return {"ready": False, "oar_version": OAR_VERSION, "reason": str(exc), "next_action": _REMEDIATION} + return {"ready": True, "oar_version": OAR_VERSION, "node": node, "runtime_directory": str(directory)} + + +def install() -> dict[str, Any]: + """Install exactly the packaged lock into a private cache, then publish it. + + The previous source revision remains usable; an interrupted installation + cannot expose half a dependency tree to a concurrent model turn. + """ + node = node_executable() + npm = shutil.which("npm") + if npm is None: + raise RuntimeError("npm is required to install the OAR runtime. " + _REMEDIATION) + target = _cache_directory() + target.parent.mkdir(parents=True, exist_ok=True) + with exclusive_file_lock(target): + if _dependency_present(target) and _cached_source_matches(target): + return {"ready": True, "oar_version": OAR_VERSION, "node": node, "runtime_directory": str(target)} + with tempfile.TemporaryDirectory(prefix=".install-", dir=target.parent) as temporary: + staging = Path(temporary) / "runtime" + staging.mkdir(mode=0o700) + for source in _assets(): + shutil.copyfile(source, staging / source.name) + # No lifecycle scripts, global mutation, floating version, or + # on-demand install during execution. npm verifies lock integrity. + completed = subprocess.run( + [npm, "ci", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], + cwd=staging, check=False, timeout=600, + ) + if completed.returncode: + raise RuntimeError("The locked OAR dependency installation failed; the previous revision is unchanged.") + checked = subprocess.run( + [node, "--input-type=module", "-e", + "await import('@botiverse/oar/kernel'); await import('@botiverse/oar/runtimes/codex'); await import('@botiverse/oar/runtimes/pi');"], + cwd=staging, capture_output=True, check=False, timeout=60, + ) + if checked.returncode or not _dependency_present(staging): + raise RuntimeError("Installed OAR runtime failed its import check; the previous revision is unchanged.") + if target.is_symlink(): + raise RuntimeError("OAR installation target must not be a symbolic link.") + if target.exists(): + shutil.rmtree(target) + staging.replace(target) + return {"ready": True, "oar_version": OAR_VERSION, "node": node, "runtime_directory": str(target)} diff --git a/loopx/hosts/oar/package-lock.json b/loopx/hosts/oar/package-lock.json new file mode 100644 index 0000000000..da89c08037 --- /dev/null +++ b/loopx/hosts/oar/package-lock.json @@ -0,0 +1,1603 @@ +{ + "name": "@loopx/oar-runtime", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@loopx/oar-runtime", + "version": "0.1.0", + "dependencies": { + "@botiverse/oar": "0.75.0" + }, + "devDependencies": { + "@types/node": "24.3.0", + "typescript": "6.0.3" + }, + "engines": { + "node": ">=24.0.0" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.9.0.tgz", + "integrity": "sha512-saCOTPYBK1iqWqJFAYHXbg3+zCoFXyZZFoqQAwdTWkbrZZPpAETr80QOnYr2l+dzPwL0jpNhn3eDY8MoEIDbUA==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.129.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.129.0.tgz", + "integrity": "sha512-MH7LB20kNpGLUpPTe2OG5XvL/KhX8HUO9XyBaFjgFk6TLS4Xjt0VPIuMKywe7POKKPslX+QxSlVok9e+8kG5Nw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1127.0.tgz", + "integrity": "sha512-IDl/lrPb90aH+pZFHGNDmgH9nAUQj5PlZH1sJ3w7RikctyjHSnY3oNjZhrLoaBoQn/rNK0zsP6OHEqEhj2tdLA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-node": "^3.972.82", + "@aws-sdk/eventstream-handler-node": "^3.972.34", + "@aws-sdk/middleware-eventstream": "^3.972.29", + "@aws-sdk/middleware-websocket": "^3.972.52", + "@aws-sdk/token-providers": "3.1127.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.978.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.6.tgz", + "integrity": "sha512-ykF/MiYoM15dgZwF/WLgjJXVbAK2wU6vjPigeEnOHwyUJ0rdlraO5uekecKNVPkvOnz+oju/5zljViQs1nI1FQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@aws-sdk/xml-builder": "^3.972.41", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.37.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.77.tgz", + "integrity": "sha512-CN6dQCDo32a8n2gvm2HBeqGV5BNI9noG96nTj5bGO2HWtt0Rucu/A8pcnJIqkzILOHSuilnCk0Do8/7h6SOXhQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.79.tgz", + "integrity": "sha512-Netsu0nnmvP2HiN1ZFQBeUu+JehQIuubqz+j2kHDgeSE4HDzniCpIZ7RzoWABq+LTcbCi9W03hywifUagoVIWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.22.tgz", + "integrity": "sha512-x7/5h58cqOoZR0RWUZC7v/cJOADQR42iSIQkvU+00HVHR4jh8ZpqEgRPGf2zei59iH5qP40lL3bkq49PuWl71g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-login": "^3.972.84", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.84.tgz", + "integrity": "sha512-G9Ty1uIV0laEvEp2KiMbEhsuBAjgaD3KYrwVjAM5VUxpcIuPBqQI5riA7/qzrnSgYiAKQbubbgkwcqCICKCIxg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.89", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.89.tgz", + "integrity": "sha512-O8xj7/sanrnXfFHu/LUujVBiiwn5mknYenU7xt1w6+yvhR1QoDZWXLFh1gLITaFVPtb1jWgEirC+kFNw+//tyw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.77", + "@aws-sdk/credential-provider-http": "^3.972.79", + "@aws-sdk/credential-provider-ini": "^3.973.22", + "@aws-sdk/credential-provider-process": "^3.972.77", + "@aws-sdk/credential-provider-sso": "^3.973.21", + "@aws-sdk/credential-provider-web-identity": "^3.972.83", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.77.tgz", + "integrity": "sha512-sWA8Jm+ASzkTxoqLizHWRbt8Gj3yZCmZ0hYdvg0uIxuLzWseYcfrDFzaSnkRWlEWSJV38rAWFimdrN0J4NR7Pg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.21.tgz", + "integrity": "sha512-KV2LM8fbYUhDzGEBnPwb+ISNfNlhmnpLS+y6A6VsJpg1EI9aUJ0+dRTxh2GrGdneFH0T4TiuboHxz8i6+M38JA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/token-providers": "3.1157.0", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1157.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1157.0.tgz", + "integrity": "sha512-AeAzcwWRP2uANO2TjFpKjRdvOCzgfIu6YQl9Ceibd6E0gnq5dfOzujFnq2aeqgbLg4wRtXbUZE4gekCCPrz/OQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.83", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.83.tgz", + "integrity": "sha512-iekYilmo2Re51fC7gMkEfZ7jFDXwehSCmXjDESaYQsdgYhsLQFG7rTYr3GBvRIyj+Fkda2NxbZX7hAZDaFD0JQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/nested-clients": "^3.997.51", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.39", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.39.tgz", + "integrity": "sha512-V+AvQ+KFWApgm9134igy78cgoCEAFfV8K3KmlVi511vjkRWAgvy0tGUePrZsER+9EvrmUWEsrA2NBQspiGVBMQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.34.tgz", + "integrity": "sha512-0IUoHM9N75uMaf70Tj2l+qmOSNw8HaDCsoghPRnewMaaPJjdSxzFaCpPBFDmSWuSlc0gBgv4CsvvvPGoBAD4Bg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.59", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.59.tgz", + "integrity": "sha512-lpLmEPrmRSG1ev+LqxkOn1JfVGclnS9b5hvGaSoBgS0JxW2u3QXsJuu+KTHvhdFrRohsqzl70cuiHhyUHFSjYQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.51", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.51.tgz", + "integrity": "sha512-DJK4iGIHWU8Jh8ODMvqFYcX9yGBMXV4tSZAvqlHF5EDd8hbmF3eFO9KIWOnA7wcQjPzEkyMMW1/FgZ9RU8oimQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.6", + "@aws-sdk/signature-v4-multi-region": "^3.996.49", + "@aws-sdk/types": "^3.975.0", + "@smithy/core": "^3.37.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.13.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.13.0.tgz", + "integrity": "sha512-/GGcPoNJ2nzGytdAanpbx7mpp9Coa8LP+/Rpmbktr8YeLuIXs9B0SEz+X3AeonHdyzOIFJdKbN7jOZ+Tj+pMGw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.37.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.49", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.49.tgz", + "integrity": "sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.975.0", + "@smithy/signature-v4": "^5.7.4", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1127.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1127.0.tgz", + "integrity": "sha512-Dv2TMWBshJ+tF6ahs2Sy5bh4Iabsd4GAQqVvE9XZmYmnoaVbpS2QKIKE/HRacc7bTtbjEEvP+laGzHvHlf1CiQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.975.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.975.0.tgz", + "integrity": "sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@botiverse/oar": { + "version": "0.75.0", + "resolved": "https://registry.npmjs.org/@botiverse/oar/-/oar-0.75.0.tgz", + "integrity": "sha512-QpolhDZRQXc4gfibgG2juMHDvWr5p27jqKVJNqbLoMRW3tEV52QB2yf9RYJWvK4As1YCYzmaO7tqNVBz6kCsnA==", + "license": "Apache-2.0", + "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", + "@earendil-works/pi-ai": "^1.4.0", + "@earendil-works/pi-coding-agent": "^1.4.0", + "cross-spawn": "^7.0.6", + "undici": "8.10.2", + "which": "^2.0.2" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@cursor/sdk": "1.1.0", + "@earendil-works/chord": "~1.4.0", + "@earendil-works/pi-durable": "~1.4.0" + }, + "peerDependenciesMeta": { + "@cursor/sdk": { + "optional": true + }, + "@earendil-works/chord": { + "optional": true + }, + "@earendil-works/pi-durable": { + "optional": true + } + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-1.4.0.tgz", + "integrity": "sha512-PoKY84SKUwnt7S5QBZaGUqfNO1kjP3KHpHUA9QpFTj4cOjaVWMYIcBPJ6FDRCdE8Zen9OSECQUd0uUfVgtSmgA==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-ai": "1.4.0", + "typebox": "1.3.27" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-1.4.0.tgz", + "integrity": "sha512-hQM/C4+1w8rHYACzDDf+0vQMnEnpzIop1tptIERVgR3ZeT/hHSzBNG8PaWPgxWQjqSeKCyBZSyp8uTT6eKK1pQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.129.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "1.4.0", + "@google/genai": "2.21.0", + "@smithy/node-http-handler": "4.12.1", + "http-proxy-agent": "9.1.0", + "https-proxy-agent": "9.1.0", + "openai": "7.19.0", + "partial-json": "0.1.7", + "typebox": "1.3.27" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-codemode": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-1.4.0.tgz", + "integrity": "sha512-23CNAXpS9DuLzPMyYuvrXVVio46x4YhffCgCqaWmF7F5H8PooqjrQvX4ucF6M0BhUd+x2tV6r1J0dv8cCME4Cg==", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-1.4.0.tgz", + "integrity": "sha512-Z41LpZ3yYHSC/UUps6Mr39UMFPwH8elhJBG9DY1P7kuzsE5qSK/6eMj2EHzO5evJRQWLbKSqUMw+4nCNtIyiQg==", + "license": "MIT", + "dependencies": { + "@earendil-works/pi-agent-core": "1.4.0", + "@earendil-works/pi-ai": "1.4.0", + "@earendil-works/pi-codemode": "1.4.0", + "@earendil-works/pi-mcp": "1.4.0", + "@earendil-works/pi-tui": "1.4.0", + "@silvia-odwyer/photon-node": "0.3.4", + "brace-expansion": "5.0.12", + "chalk": "6.0.0", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.3", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.8", + "jiti": "2.7.0", + "minimatch": "10.2.6", + "proper-lockfile": "4.1.2", + "quickjs-wasi": "3.6.2", + "semver": "7.8.5", + "typebox": "1.3.27", + "undici": "8.10.2", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-mcp": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-1.4.0.tgz", + "integrity": "sha512-92aZmmGsUpHHknYuKXZHRINQfahntw5hU9If0I0ZM49GojOaFHpkl5/GF3/aatqpt7v7GjZbBfpfSygGKW+pnw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-1.4.0.tgz", + "integrity": "sha512-MLIfowwUYIY65jSjMX82paZOrX/NTvLk6D08h+3Jq42a99RzUOewzuCR0zOlPul3D6UBfddat76VT6LvsHEs1w==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-1.4.0.tgz", + "integrity": "sha512-4obPOjgOD7g4mGSlngIvxyujC1UPdmyI9zdVthbr8GufacFEX+Ce0+scPgHg++k2xj5+QjESM2d5TxvIuzCVmg==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11", + "marked-cjk-friendly": "0.1.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-2.21.0.tgz", + "integrity": "sha512-+PDtco2/Z0ONdzCGekCoCT+O1VJS9xJQNN4XzQpXG/t3El/SWWMkCWlFRO1KmivOHPa4Q0VjUYu1HBKCZ/v33Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.37.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.37.0.tgz", + "integrity": "sha512-97imTcCyiPd8k0e9+ts/Sp0W65Dn9Ti357J7M09Ov4tI8Q1JDJVFSoAJtjEVKqfHEr3Pdo2jfolqKCaaiszH0g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.4", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.4.tgz", + "integrity": "sha512-tHy0K0VtqNd5Y7Y41h0a0Lhh0L1GzC08dTWg0F7vRJWFtTENg7IZikf3wQkanYIRdb7ngoIPMTmqgUi401fEeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.3.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", + "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", + "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.15.0.tgz", + "integrity": "sha512-xic6KSZglg/I4ymp45fdM4GWfYpBK79Fxd/TOvJktlaPgQqqDxvmGwOPZl2V0dyeS3Lj5c180bLhXF43iyAjZQ==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-6.0.0.tgz", + "integrity": "sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gaxios/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/gaxios/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz", + "integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-9.1.0.tgz", + "integrity": "sha512-2NxoveTT58mjYT4n3RPTEfCZGLMbidoO8XEieXfpSYxu+PQJ1qpx4ypwH6N+uF9twBPIvRRgvkvW5HUTYWENig==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/https-proxy-agent": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", + "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", + "license": "MIT", + "dependencies": { + "agent-base": "9.0.0", + "debug": "^4.3.4", + "proxy-agent-negotiate": "1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/ignore": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz", + "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.11", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.11.tgz", + "integrity": "sha512-HnslJfsZkRPBDJRHvVtAaWlZHEpSu7u8LgQuJCELjRKuWR+hpq4A7sLq3p8HaI9ypVoXDXxV34CsQJEe1+J5Aw==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/marked-cjk-friendly": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/marked-cjk-friendly/-/marked-cjk-friendly-0.1.2.tgz", + "integrity": "sha512-GUj8HjiEbur9yhgUo52FBgKizggF1QtT6gVdrYdWW6ZB4lVVjgS3EyyXPyfyRD0V4ghGRPlO7MO80rgSWbm0eA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "marked": ">=15.0.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "7.19.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.19.0.tgz", + "integrity": "sha512-MX2s3u2L5racTO0CC/SWpCOasJQBCJrqLKXK+l82cAhdeF8mPMBEe/gxMm0ZFa2xpKpOFLRjxv5afYEZbBXmbQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.0.0" + }, + "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", + "undici": ">=5 <9", + "ws": "^8.21.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, + "undici": { + "optional": true + }, + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/proxy-agent-negotiate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", + "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "kerberos": "^2.0.0" + }, + "peerDependenciesMeta": { + "kerberos": { + "optional": true + } + } + }, + "node_modules/quickjs-wasi": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/quickjs-wasi/-/quickjs-wasi-3.6.2.tgz", + "integrity": "sha512-FCqGtGOrMgzUiIrMNMA2YnsOxCNwo31dzqXvclXUC6xeT35NJLKXQJsvbeCTjvoFAwZgEAPg8U6+KAPDGXn8Mg==", + "license": "MIT" + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.27", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.27.tgz", + "integrity": "sha512-zu+jc1pcy4UiNThxikUr36f0Rybk9PEeCg/NE6adeWr/SKsdNO4EzZHYRDlv2YCVAfj3Odq3dESSo/jNyoBXzA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/loopx/hosts/oar/package.json b/loopx/hosts/oar/package.json new file mode 100644 index 0000000000..0208ed8579 --- /dev/null +++ b/loopx/hosts/oar/package.json @@ -0,0 +1,13 @@ +{ + "name": "@loopx/oar-runtime", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { "node": ">=24.0.0" }, + "scripts": { + "test": "node --no-warnings --experimental-strip-types --test *.test.ts", + "typecheck": "tsc --project tsconfig.json --noEmit" + }, + "dependencies": { "@botiverse/oar": "0.75.0" }, + "devDependencies": { "@types/node": "24.3.0", "typescript": "6.0.3" } +} diff --git a/loopx/hosts/oar/runtime.test.ts b/loopx/hosts/oar/runtime.test.ts new file mode 100644 index 0000000000..e245881cff --- /dev/null +++ b/loopx/hosts/oar/runtime.test.ts @@ -0,0 +1,347 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Runtime, Session, SessionOptions } from "@botiverse/oar"; +import { createSessionKernel, sealSession } from "@botiverse/oar/kernel"; +import { scriptedRuntime, type ScriptedTurn } from "@botiverse/oar/testing"; +import { + openOarSession, + runOarTurn, + type OarOutputRecord, + type OarSessionConfig, +} from "./runtime.ts"; + +const config: OarSessionConfig = { runtime: "codex", cwd: process.cwd() }; + +function deferred() { return Promise.withResolvers(); } + +function fixture(turn: (turn: ScriptedTurn) => void | Promise) { + const scripted = scriptedRuntime({ id: "codex", turn }); + const sessions: Session[] = []; + const opens: SessionOptions[] = []; + let disposed = 0; + const runtime: { -readonly [K in keyof Runtime]: Runtime[K] } = { + ...scripted, + session: async (installation, options) => { + opens.push(options); + const session = await scripted.session(installation, options); + const owned: Session = { + ...session, + dispose: async () => { disposed += 1; await session.dispose(); }, + }; + sessions.push(owned); + return owned; + }, + }; + return { runtime, sessions, opens, disposed: () => disposed, dependencies: { loadRuntime: () => runtime } }; +} + +test("bounded execution uses real OAR controls and emits a result after native cleanup", async () => { + const host = fixture(async (turn) => { + turn.think("plan"); + await turn.tool("read", "fixture", () => "contents"); + turn.say("answer"); + }); + const records: OarOutputRecord[] = []; + const result = await runOarTurn(config, "question", { onRecord: (record) => { records.push(record); } }, host.dependencies); + assert.equal(result.outcome.kind, "completed"); + assert.equal(result.cleanupConfirmed, true); + assert.equal(result.text, "answer"); + assert.ok(result.sessionId); + assert.ok(result.usage && result.usage.output > 0); + assert.equal(host.disposed(), 1); + assert.equal(records.filter((record) => record.type === "result").length, 1); + assert.equal(records.at(-1)?.type, "result"); + assert.ok(records.some((record) => record.type === "native" && record.frame.kind === "response" && record.frame.body.kind === "exited")); + assert.ok(records.some((record) => record.type === "event" && record.event.kind === "tool_call_ended")); + assert.ok(records.some((record) => record.type === "event" && record.event.kind === "model"), "opening events are replayed"); + assert.deepEqual(host.opens[0]?.launchArgs?.slice(-2), ["-c", 'sandbox_mode="read-only"']); +}); + +test("the admitted Codex sandbox overrides caller launch arguments and preserves native resume options", async () => { + const host = fixture((turn) => { turn.say("ok"); }); + let probeEnv: unknown; + host.runtime.installation = async (options) => { + probeEnv = options?.env; + return { kind: "available", via: "bundled" }; + }; + const expected: OarSessionConfig = { + ...config, + command: "/synthetic/codex", + sandbox: "workspace-write", + launchArgs: ["-c", 'sandbox_mode="danger-full-access"', "-c", 'service_tier="fast"'], + env: { OAR_TEST_VALUE: "one", OAR_TEST_REMOVED: null }, + resume: "saved-native-session", + model: "configured-model", + serviceTier: "fast", + outputSchema: { type: "object" }, + mcpServers: [{ name: "fixture", command: "fixture-tool" }], + }; + const result = await runOarTurn(expected, "continue", {}, host.dependencies); + assert.equal(result.sessionId, "saved-native-session"); + assert.equal(result.outcome.kind, "completed"); + assert.deepEqual(probeEnv, { ...expected.env, OAR_CODEX_BIN: expected.command }); + assert.deepEqual(host.opens[0], { + cwd: expected.cwd, + env: expected.env, + model: expected.model, + serviceTier: expected.serviceTier, + resume: expected.resume, + outputSchema: expected.outputSchema, + mcpServers: expected.mcpServers, + launchArgs: [...expected.launchArgs!, "-c", 'sandbox_mode="workspace-write"'], + }); + assert.equal(process.env.OAR_TEST_VALUE, undefined, "the parent environment is untouched"); +}); + +test("a missing resume target preserves its typed error and never starts a replacement conversation", async () => { + const host = fixture(() => { assert.fail("must not prompt"); }); + let opens = 0; + host.runtime.session = async () => { + opens += 1; + const missing = Object.assign(new Error("native session missing", { cause: { method: "thread/resume", native: { credential: "private" } } }), { + name: "SessionNotFoundError", sessionId: "saved-session", + }); + throw missing; + }; + const records: OarOutputRecord[] = []; + const result = await runOarTurn({ ...config, resume: "saved-session" }, "continue", { + onRecord: (record) => { records.push(record); }, + }, host.dependencies); + assert.equal(opens, 1); + assert.equal(result.outcome.kind, "failed"); + assert.equal(result.sessionId, null); + assert.equal(result.error?.name, "SessionNotFoundError"); + assert.equal(result.error?.sessionId, "saved-session"); + assert.equal(result.error?.method, "thread/resume"); + assert.ok(!JSON.stringify(result).includes("private")); + assert.deepEqual(records.map((record) => record.type), ["result"]); +}); + +test("busy rejection is a failed request without adopting the running turn's text", async () => { + const started = deferred(); + const finish = deferred(); + const host = fixture(async (turn) => { + started.resolve(); + turn.say("first answer"); + await finish.promise; + }); + const managed = await openOarSession(config, {}, host.dependencies); + try { + const first = managed.runTurn("first"); + await started.promise; + const second = await managed.runTurn("second"); + assert.equal(second.outcome.kind, "failed"); + assert.equal(second.rejection?.code, "busy"); + assert.equal(second.text, ""); + finish.resolve(); + assert.equal((await first).text, "first answer"); + assert.equal(host.disposed(), 0, "a reusable session stays open between turns"); + } finally { finish.resolve(); await managed.dispose(); } +}); + +test("reusable sessions keep native identity and expose accepted steering", async () => { + const started = deferred(); + const finish = deferred(); + let turns = 0; + const host = fixture(async (turn) => { + turns += 1; + if (turns === 1) { + started.resolve(); + await finish.promise; + turn.say(turn.steered.join(" ")); + } else turn.say("next"); + }); + const managed = await openOarSession(config, {}, host.dependencies); + try { + const first = managed.runTurn("first"); + await started.promise; + assert.equal((await managed.steer("correction")).kind, "accepted"); + finish.resolve(); + const firstResult = await first; + const secondResult = await managed.runTurn("second"); + assert.equal(firstResult.text, "correction"); + assert.equal(secondResult.text, "next"); + assert.equal(firstResult.sessionId, secondResult.sessionId); + } finally { finish.resolve(); await managed.dispose(); } + await managed.dispose(); + assert.equal(host.disposed(), 1); +}); + +test("an already-cancelled invocation does not load or launch a runtime", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await runOarTurn(config, "unused", { signal: controller.signal }, { + loadRuntime: () => { assert.fail("must not load"); }, + }); + assert.equal(result.outcome.kind, "aborted"); + assert.equal(result.sessionId, null); + assert.equal(result.cleanupConfirmed, true, "no session creation began"); +}); + +test("installation failure confirms cleanup only after the probe has settled", async () => { + const host = fixture(() => { assert.fail("must not prompt"); }); + host.runtime.installation = async () => ({ kind: "not_found" }); + const absent = await runOarTurn(config, "unused", {}, host.dependencies); + assert.equal(absent.outcome.kind, "failed"); + assert.equal(absent.cleanupConfirmed, true); + + const probing = deferred(); + const release = deferred(); + host.runtime.installation = async () => { probing.resolve(); await release.promise; return { kind: "not_found" }; }; + const controller = new AbortController(); + const run = runOarTurn(config, "unused", { signal: controller.signal }, host.dependencies); + await probing.promise; + controller.abort(); + const cancelled = await run; + assert.equal(cancelled.cleanupConfirmed, false, "an in-flight probe may still own a process"); + release.resolve(); +}); + +test("cancellation during open disposes the late session without submitting work", async () => { + const opening = deferred(); + const started = deferred(); + const disposed = deferred(); + const host = fixture(() => { assert.fail("must not prompt after cancellation"); }); + host.runtime.session = async () => { started.resolve(); return opening.promise; }; + const controller = new AbortController(); + const run = runOarTurn(config, "unused", { signal: controller.signal }, host.dependencies); + await started.promise; + controller.abort(); + const cancelled = await run; + assert.equal(cancelled.outcome.kind, "aborted"); + assert.equal(cancelled.cleanupConfirmed, false, "startup is still in flight"); + const scripted = scriptedRuntime({ turn: () => { assert.fail("must not prompt"); } }); + const late = await scripted.session({ kind: "available", via: "bundled" }, { cwd: config.cwd }); + opening.resolve({ ...late, dispose: async () => { await late.dispose(); disposed.resolve(); } }); + await disposed.promise; +}); + +test("turn timeout interrupts and disposes exactly once instead of treating partial text as success", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const started = deferred(); + const finish = deferred(); + const host = fixture(async (turn) => { turn.say("partial"); started.resolve(); await finish.promise; }); + const run = runOarTurn(config, "slow", { timeoutMs: 100 }, host.dependencies); + await started.promise; + t.mock.timers.tick(101); + const result = await run; + finish.resolve(); + assert.equal(result.outcome.kind, "failed"); + assert.equal(result.timedOut, true); + assert.equal(result.cleanupConfirmed, true); + assert.equal(result.text, "partial"); + assert.equal(host.disposed(), 1); +}); + +test("cancellation does not wait for a stuck prompt acknowledgement or abort acknowledgement", async () => { + const admitted = deferred(); + const pending = deferred(); + const host = fixture(() => {}); + const start = host.runtime.session; + host.runtime.session = async (installation, options) => { + const session = await start(installation, options); + return { + ...session, + prompt: async () => { admitted.resolve(); return pending.promise; }, + abort: async () => pending.promise, + }; + }; + const controller = new AbortController(); + const run = runOarTurn(config, "pending", { signal: controller.signal }, host.dependencies); + await admitted.promise; + controller.abort(); + const result = await run; + assert.equal(result.outcome.kind, "aborted"); + assert.equal(host.disposed(), 1); +}); + +test("only the root session's final root message becomes the result", async () => { + const host = fixture(() => {}); + host.runtime.session = async () => { + const kernel = createSessionKernel("root-session"); + return sealSession({ + id: kernel.sessionId, + capabilities: { queue: { durable: false }, attribution: "nested", images: false }, + prompt: async (input, inputOptions) => kernel.control({ kind: "prompt", input, ...inputOptions }, () => { + queueMicrotask(() => { + kernel.frame({ type: "text", native: {}, events: [{ kind: "text_delta", text: "commentary", messageId: "commentary" }] }); + kernel.frame({ type: "child", native: {}, events: [{ kind: "text_delta", text: "wrong child answer" }, { kind: "turn_ended", outcome: { kind: "completed" } }] }, { sessionId: "child-session" }); + kernel.frame({ type: "nested", native: {}, events: [{ kind: "text_delta", text: "wrong nested answer" }] }, { agentPath: ["child"] }); + kernel.frame({ type: "text", native: {}, events: [{ kind: "text_delta", text: "final ", messageId: "final" }, { kind: "text_delta", text: "answer", messageId: "final" }] }); + kernel.frame({ type: "end", native: {}, events: [{ kind: "turn_ended", outcome: { kind: "completed" } }] }); + kernel.frame({ type: "next-turn", native: {}, events: [{ kind: "text_delta", text: "another turn", messageId: "later" }] }); + }); + return { kind: "accepted" }; + }), + queue: async (input, inputOptions) => kernel.control({ kind: "queue", input, ...inputOptions }, () => ({ kind: "accepted" })), + abort: async () => kernel.control({ kind: "abort" }, () => ({ kind: "rejected", code: "no_active_turn", reason: "idle" })), + rawEvents: (callback, cursor) => kernel.rawEvents(callback, cursor), + records: () => kernel.records(), + graph: () => kernel.graph(), + dispose: async () => { const request = kernel.request("toRuntime", { kind: "dispose" }); kernel.respond(request.id, { kind: "exited", code: 0 }); }, + }); + }; + const result = await runOarTurn(config, "question", {}, host.dependencies); + assert.equal(result.outcome.kind, "completed"); + assert.equal(result.sessionId, "root-session"); + assert.equal(result.text, "final answer"); +}); + +test("cleanup failure cannot produce a successful terminal receipt", async () => { + const host = fixture((turn) => { turn.say("generated output"); }); + const open = host.runtime.session; + host.runtime.session = async (installation, options) => { + const session = await open(installation, options); + return { ...session, dispose: async () => { await session.dispose(); throw new Error("cleanup failed"); } }; + }; + const records: OarOutputRecord[] = []; + const result = await runOarTurn(config, "question", { onRecord: (record) => { records.push(record); } }, host.dependencies); + assert.equal(result.outcome.kind, "failed"); + assert.equal(result.error?.message, "cleanup failed"); + assert.equal(result.cleanupConfirmed, false); + assert.equal(result.text, "generated output"); + assert.equal(records.filter((record) => record.type === "result").length, 1); +}); + +test("cleanup has a bounded wait and reports an unconfirmed exit as failure", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const disposing = deferred(); + const pending = deferred(); + const host = fixture((turn) => { turn.say("output"); }); + const open = host.runtime.session; + host.runtime.session = async (installation, options) => { + const session = await open(installation, options); + return { ...session, dispose: async () => { disposing.resolve(); await pending.promise; await session.dispose(); } }; + }; + const run = runOarTurn(config, "question", {}, host.dependencies); + await disposing.promise; + t.mock.timers.tick(5_001); + const result = await run; + pending.resolve(); + assert.equal(result.outcome.kind, "failed"); + assert.match(result.error?.message ?? "", /exit is unconfirmed/); + assert.equal(result.cleanupConfirmed, false); +}); + +test("typed startup failures redact configured credentials without discarding the failure class", async () => { + const host = fixture(() => {}); + host.runtime.session = async () => { + throw Object.assign(new Error("Rejected secret-test-key"), { + name: "RuntimeFailureError", failure: "auth", status: 401, credential: "rejected", + }); + }; + const result = await runOarTurn({ ...config, env: { TEST_API_KEY: "secret-test-key" } }, "question", {}, host.dependencies); + assert.deepEqual(result.outcome, { + kind: "failed", failure: "auth", reason: "Rejected [redacted]", status: 401, credential: "rejected", + }); + assert.ok(!JSON.stringify(result).includes("secret-test-key")); +}); + +test("observer exceptions cannot skip native cleanup", async () => { + const host = fixture((turn) => { turn.say("done"); }); + const result = await runOarTurn(config, "question", { + onRecord: () => { throw new Error("observer failed"); }, + }, host.dependencies); + assert.equal(result.outcome.kind, "completed"); + assert.equal(host.disposed(), 1); +}); diff --git a/loopx/hosts/oar/runtime.ts b/loopx/hosts/oar/runtime.ts new file mode 100644 index 0000000000..fe9c258ede --- /dev/null +++ b/loopx/hosts/oar/runtime.ts @@ -0,0 +1,438 @@ +/** Shared OAR session ownership for LoopX chat and bounded execution callers. */ +import { randomUUID } from "node:crypto"; +import type { + ContextUsage, + ControlOutcome, + Event, + FailureClass, + InputOptions, + McpServer, + RawEvent, + RejectionCode, + Runtime, + Session, + SessionOptions, + TokenTotals, + TurnOutcome, +} from "@botiverse/oar"; +import { turnEndAfter } from "@botiverse/oar/observe"; +import type { DshRuntimeConfig } from "./dsh.ts"; + +/** Existing host selections, kept local to the OAR transport boundary. */ +export type OarRuntimeId = "codex" | "pi" | "dsh"; +export type CodexSandbox = "read-only" | "workspace-write" | "danger-full-access"; + +export interface OarSessionConfig { + readonly runtime: OarRuntimeId; + readonly cwd: string; + readonly env?: Readonly>; + readonly model?: string; + readonly effort?: string; + readonly serviceTier?: string; + readonly resume?: string; + readonly outputSchema?: Readonly>; + readonly mcpServers?: readonly McpServer[]; + readonly launchArgs?: readonly string[]; + /** Explicit Codex executable; Pi uses the version bundled with OAR. */ + readonly command?: string; + /** Explicit authority from the caller; OAR's permissive default is never used. */ + readonly sandbox?: CodexSandbox; + readonly dsh?: DshRuntimeConfig; +} + +export interface OarErrorInfo { + readonly name: string; + readonly message: string; + readonly code?: string; + readonly sessionId?: string; + readonly method?: string; +} + +export interface OarTurnResult { + readonly sessionId: string | null; + readonly text: string; + readonly outcome: TurnOutcome; + readonly usage: TokenTotals | null; + readonly contextUsage: ContextUsage | null; + /** True only after owned disposal settles, or before session creation began. */ + readonly cleanupConfirmed: boolean; + readonly rejection?: { readonly code: RejectionCode; readonly reason: string }; + readonly timedOut?: true; + /** Typed startup failures let existing callers invalidate a missing native binding. */ + readonly error?: OarErrorInfo; +} + +export type OarOutputRecord = + | { readonly type: "ready"; readonly runtime: OarRuntimeId; readonly sessionId: string } + | { readonly type: "event"; readonly event: Event } + | { readonly type: "native"; readonly frame: RawEvent } + | { readonly type: "result"; readonly result: OarTurnResult }; + +export interface OarRunOptions { + readonly signal?: AbortSignal; + /** Covers opening as well as the turn. The owning process also enforces a hard limit. */ + readonly timeoutMs?: number; + readonly onRecord?: (record: OarOutputRecord) => void; +} + +export interface OarRuntimeDependencies { + /** A real OAR runtime can be injected without a vendor binary or account. */ + readonly loadRuntime?: (config: OarSessionConfig) => Runtime | Promise; +} + +const CLEANUP_TIMEOUT_MS = 5_000; + +class OarTimeoutError extends Error { + override readonly name = "OarTimeoutError"; + constructor() { super("OAR execution exceeded its timeout"); } +} + +function stopped(signal: AbortSignal): Error { + return signal.reason instanceof OarTimeoutError + ? signal.reason + : Object.assign(new Error("OAR execution was cancelled"), { name: "AbortError" }); +} + +function deadline(options: Pick): { + signal: AbortSignal; + disarm: () => void; +} { + if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) + || options.timeoutMs <= 0 || options.timeoutMs > 2_147_483_647)) { + throw new RangeError("timeoutMs must be positive and no greater than 2147483647"); + } + const controller = new AbortController(); + const onAbort = (): void => { controller.abort(options.signal?.reason); }; + if (options.signal?.aborted) onAbort(); + else options.signal?.addEventListener("abort", onAbort, { once: true }); + const timer = options.timeoutMs === undefined ? undefined + : setTimeout(() => { controller.abort(new OarTimeoutError()); }, options.timeoutMs); + return { + signal: controller.signal, + disarm: () => { + clearTimeout(timer); + options.signal?.removeEventListener("abort", onAbort); + }, + }; +} + +/** Cancelling a wait never turns a rejected in-flight promise into an unhandled rejection. */ +async function untilStopped(work: Promise, signal: AbortSignal): Promise { + let onAbort: (() => void) | undefined; + const cancellation = new Promise((_resolve, reject) => { + onAbort = () => { reject(stopped(signal)); }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }); + try { return await Promise.race([work, cancellation]); } + finally { if (onAbort) signal.removeEventListener("abort", onAbort); } +} + +/** Use OAR's turn correlation, while releasing the watcher even if cleanup fails. */ +async function waitForTurn(session: Session, afterSeq: number, inputId: string, signal: AbortSignal): Promise { + let unsubscribe: (() => void) | undefined; + const ended = new Promise((resolve) => { + unsubscribe = session.rawEvents((record) => { + if (record.seq <= afterSeq || record.sessionId !== session.id || record.agentPath.length !== 0) return; + const terminal = record.kind === "response" && record.body.kind === "exited" + || record.kind === "frame" && record.body.events.some((event) => event.kind === "turn_ended" || event.kind === "input_dropped"); + if (!terminal) return; + const outcome = turnEndAfter(session.records(), afterSeq, session.id, inputId); + if (outcome) resolve(outcome); + }, { sessionId: session.id, afterSeq }); + }); + try { return await untilStopped(ended, signal); } + finally { unsubscribe?.(); } +} + +function observer(callback?: OarRunOptions["onRecord"]): (record: OarOutputRecord) => void { + return (record) => { + try { callback?.(record); } catch { /* Observation cannot change runtime ownership. */ } + }; +} + +function redact(text: string, config: OarSessionConfig): string { + let result = text; + for (const [name, value] of Object.entries(config.env ?? {})) { + if (typeof value === "string" && value.length > 0 && /token|key|secret|password|credential|authorization|cookie/i.test(name)) { + result = result.split(value).join("[redacted]"); + } + } + return result; +} + +function errorInfo(error: unknown, config: OarSessionConfig): OarErrorInfo { + const value = error instanceof Error ? error : new Error(String(error)); + const detail = value as Error & { code?: unknown; sessionId?: unknown }; + const cause = value.cause as { method?: unknown } | undefined; + return { + name: value.name, + message: redact(value.message, config), + ...(typeof detail.code === "string" ? { code: detail.code } : {}), + ...(typeof detail.sessionId === "string" ? { sessionId: detail.sessionId } : {}), + ...(cause && typeof cause.method === "string" ? { method: cause.method } : {}), + }; +} + +function failed(error: unknown, config: OarSessionConfig, sessionId: string | null = null): OarTurnResult { + const info = errorInfo(error, config); + const detail = error as { failure?: FailureClass; status?: number; credential?: "missing" | "rejected" } | undefined; + const outcome: TurnOutcome = info.name === "AbortError" + ? { kind: "aborted" } + : { + kind: "failed", + failure: error instanceof OarTimeoutError ? "limit_reached" + : info.name === "RuntimeFailureError" && detail?.failure ? detail.failure : "unknown", + reason: info.message, + ...(typeof detail?.status === "number" ? { status: detail.status } : {}), + ...(detail?.failure === "auth" && detail.credential ? { credential: detail.credential } : {}), + }; + return { + sessionId, text: "", outcome, usage: null, contextUsage: null, cleanupConfirmed: false, + ...(error instanceof OarTimeoutError ? { timedOut: true } : {}), + error: info, + }; +} + +function sessionOptions(config: OarSessionConfig): SessionOptions { + const sandbox = config.sandbox ?? "read-only"; + if (!["read-only", "workspace-write", "danger-full-access"].includes(sandbox)) { + throw new Error("Unsupported Codex sandbox mode"); + } + if (typeof config.cwd !== "string" || config.cwd.trim().length === 0) { + throw new Error("An OAR session requires a working directory"); + } + if (config.command !== undefined && (config.runtime !== "codex" || config.command.trim().length === 0)) { + throw new Error("command must name a Codex executable; Pi is bundled with OAR"); + } + return { + cwd: config.cwd, + ...(config.env === undefined ? {} : { env: config.env }), + ...(config.model === undefined ? {} : { model: config.model }), + ...(config.effort === undefined ? {} : { effort: config.effort }), + ...(config.serviceTier === undefined ? {} : { serviceTier: config.serviceTier }), + ...(config.resume === undefined ? {} : { resume: config.resume }), + ...(config.outputSchema === undefined ? {} : { outputSchema: config.outputSchema }), + ...(config.mcpServers === undefined ? {} : { mcpServers: config.mcpServers }), + ...(config.runtime === "codex" + // OAR's sandbox default is danger-full-access. The caller's admitted sandbox + // goes last so a lower-level launch argument cannot expand its authority. + ? { launchArgs: [...(config.launchArgs ?? []), "-c", `sandbox_mode=${JSON.stringify(sandbox)}`] } + : config.launchArgs === undefined ? {} : { launchArgs: config.launchArgs }), + }; +} + +/** Per-runtime imports keep Pi and DSH dependencies out of a Codex-only process. */ +export async function loadOarRuntime(config: OarSessionConfig): Promise { + switch (config.runtime) { + case "codex": return (await import("@botiverse/oar/runtimes/codex")).codexRuntime; + case "pi": return (await import("@botiverse/oar/runtimes/pi")).createPiRuntime({ execution: "subprocess" }); + case "dsh": return (await import("./dsh.ts")).createDshRuntime(config.dsh ?? {}); + default: throw new Error(`Unsupported OAR runtime: ${String(config.runtime)}`); + } +} + +async function disposeWithinLimit(session: Session): Promise { + let timer: ReturnType | undefined; + try { + await Promise.race([ + session.dispose(), + new Promise((_resolve, reject) => { + timer = setTimeout(() => { reject(new Error("OAR session cleanup did not settle; runtime exit is unconfirmed")); }, CLEANUP_TIMEOUT_MS); + }), + ]); + } finally { clearTimeout(timer); } +} + +/** OAR cannot cancel open. A late session remains owned and is disposed without a prompt. */ +async function acquireSession(opening: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const onAbort = (): void => { + if (settled) return; + settled = true; + reject(stopped(signal)); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + void opening.then((session) => { + signal.removeEventListener("abort", onAbort); + if (settled) { + // The enclosing managed process remains the hard cleanup boundary if a + // native open never settles or disposal cannot confirm process exit. + void disposeWithinLimit(session).catch(() => {}); + return; + } + settled = true; + resolve(session); + }, (error: unknown) => { + signal.removeEventListener("abort", onAbort); + if (!settled) { settled = true; reject(error); } + }); + }); +} + +export class ManagedOarSession { + readonly session: Session; + private readonly config: OarSessionConfig; + private readonly unsubscribers: (() => void)[]; + private disposal: Promise | undefined; + + constructor(session: Session, config: OarSessionConfig, emit: (record: OarOutputRecord) => void) { + this.session = session; + this.config = config; + const cursor = { sessionId: session.id, afterSeq: -1 }; + // Raw replay precedes normalized replay. Within each stream, OAR supplies + // its own order and attribution; the host never reconstructs native facts. + this.unsubscribers = [ + session.rawEvents((frame) => { emit({ type: "native", frame }); }, cursor), + session.events((event) => { emit({ type: "event", event }); }, { cursor }), + ]; + emit({ type: "ready", runtime: config.runtime, sessionId: session.id }); + } + + steer(input: string, options?: InputOptions): Promise { + if (!this.session.steer) return Promise.reject(new Error("This runtime does not support steering")); + return this.session.steer(input, options); + } + + queue(input: string, options?: InputOptions): Promise { return this.session.queue(input, options); } + abort(): Promise { return this.session.abort(); } + + dispose(): Promise { + this.disposal ??= disposeWithinLimit(this.session).finally(() => { + for (const unsubscribe of this.unsubscribers) unsubscribe(); + }); + return this.disposal; + } + + /** Returns one turn result; the session stays open for the caller's next turn. */ + async runTurn(input: string, options: Pick = {}): Promise { + const limit = deadline(options); + const inputId = randomUUID(); + const messages = new Map(); + let text = ""; + let waitingForInput = false; + let textEnded = false; + const output = (): string => { + let answer = text; + for (const message of messages.values()) if (message.trim().length > 0) answer = message; + return answer; + }; + const collected = (result: OarTurnResult): OarTurnResult => ({ + ...result, + text: output(), + usage: this.session.usage().value.total, + contextUsage: this.session.contextUsage().value, + }); + const unsubscribe = this.session.events((event) => { + if (event.sessionId !== this.session.id || event.agentPath.length !== 0) return; + if (event.kind === "input_queued" && event.inputId === inputId) waitingForInput = true; + if (event.kind === "turn_active" && event.inputId === inputId) waitingForInput = false; + if (event.kind === "turn_ended" && !waitingForInput) textEnded = true; + if (event.kind !== "text_delta" || waitingForInput || textEnded) return; + text += event.text; + if (event.messageId !== undefined) messages.set(event.messageId, (messages.get(event.messageId) ?? "") + event.text); + }); + const cancel = (): void => { + // Abort acknowledgement can stall independently of disposal. Request both; + // only OAR owns/signals native processes, and dispose is idempotent here. + void this.session.abort().catch(() => {}); + void this.dispose().catch(() => {}); + }; + try { + if (limit.signal.aborted) return failed(stopped(limit.signal), this.config, this.session.id); + limit.signal.addEventListener("abort", cancel, { once: true }); + const admitted = await untilStopped(this.session.prompt(input, { inputId }), limit.signal); + if (admitted.kind === "rejected") { + return { + sessionId: this.session.id, text: "", usage: null, contextUsage: null, cleanupConfirmed: false, + outcome: { kind: "failed", failure: "invalid_request", reason: redact(admitted.reason, this.config) }, + rejection: { code: admitted.code, reason: redact(admitted.reason, this.config) }, + }; + } + const outcome = await waitForTurn(this.session, admitted.seq, inputId, limit.signal); + if (limit.signal.aborted) return collected(failed(stopped(limit.signal), this.config, this.session.id)); + return collected({ sessionId: this.session.id, text: "", outcome, usage: null, contextUsage: null, cleanupConfirmed: false }); + } catch (error) { + return collected(failed(limit.signal.aborted ? stopped(limit.signal) : error, this.config, this.session.id)); + } finally { + limit.signal.removeEventListener("abort", cancel); + limit.disarm(); + unsubscribe(); + } + } +} + +export async function openOarSession( + config: OarSessionConfig, + options: OarRunOptions = {}, + dependencies: OarRuntimeDependencies = {}, +): Promise { + return openOwnedSession(config, options, dependencies, { cleanupConfirmed: true }); +} + +async function openOwnedSession( + config: OarSessionConfig, + options: OarRunOptions, + dependencies: OarRuntimeDependencies, + ownership: { cleanupConfirmed: boolean }, +): Promise { + const limit = deadline(options); + try { + if (limit.signal.aborted) throw stopped(limit.signal); + const nativeOptions = sessionOptions(config); + const runtime = await untilStopped(Promise.resolve((dependencies.loadRuntime ?? loadOarRuntime)(config)), limit.signal); + const env = config.command === undefined ? config.env : { ...config.env, OAR_CODEX_BIN: config.command }; + ownership.cleanupConfirmed = false; + const probing = Promise.resolve().then(() => runtime.installation?.({ env })).then( + (installation) => { ownership.cleanupConfirmed = true; return installation; }, + (error: unknown) => { ownership.cleanupConfirmed = true; throw error; }, + ); + const installation = await untilStopped(probing, limit.signal); + if (limit.signal.aborted) throw stopped(limit.signal); + if (installation?.kind !== "available") { + throw new Error(installation?.kind === "unsupported" ? installation.reason : `OAR runtime ${config.runtime} is not installed`); + } + ownership.cleanupConfirmed = false; + const session = await acquireSession(runtime.session(installation, nativeOptions), limit.signal); + const managed = new ManagedOarSession(session, config, observer(options.onRecord)); + if (limit.signal.aborted) { + await managed.dispose(); + ownership.cleanupConfirmed = true; + throw stopped(limit.signal); + } + return managed; + } finally { limit.disarm(); } +} + +/** One owned native session, one prompt, a terminal result only after cleanup. */ +export async function runOarTurn( + config: OarSessionConfig, + input: string, + options: OarRunOptions = {}, + dependencies: OarRuntimeDependencies = {}, +): Promise { + const emit = observer(options.onRecord); + let managed: ManagedOarSession | undefined; + let result: OarTurnResult; + let limit: ReturnType | undefined; + const ownership = { cleanupConfirmed: true }; + try { + limit = deadline(options); + managed = await openOwnedSession(config, { signal: limit.signal, onRecord: emit }, dependencies, ownership); + result = await managed.runTurn(input, { signal: limit.signal }); + } catch (error) { + result = { ...failed(limit?.signal.aborted ? stopped(limit.signal) : error, config, managed?.session.id ?? null), + cleanupConfirmed: ownership.cleanupConfirmed }; + } finally { limit?.disarm(); } + if (managed) { + try { await managed.dispose(); result = { ...result, cleanupConfirmed: true }; } + catch (error) { + result = { ...result, ...failed(error, config, managed.session.id), text: result.text, + usage: result.usage, contextUsage: result.contextUsage }; + } + } + emit({ type: "result", result }); + return result; +} diff --git a/loopx/hosts/oar/stdio.ts b/loopx/hosts/oar/stdio.ts new file mode 100644 index 0000000000..4927d0dcc0 --- /dev/null +++ b/loopx/hosts/oar/stdio.ts @@ -0,0 +1,92 @@ +/** Private one-Turn JSONL boundary. The parent Host owns the hard deadline. */ +import type {Writable} from "node:stream"; +import type {OarOutputRecord, OarSessionConfig} from "./runtime.ts"; + +// Wire fragments stay below HostOutputLines' 1 MiB limit even when JSON must +// escape every code unit. The limit applies to the complete serialized record. +const FRAGMENT_THRESHOLD = 256 * 1024; +const FRAGMENT_CHARACTERS = 64 * 1024; +const MAX_RECORD_BYTES = 32 * 1024 * 1024; +const cancelled = new AbortController(); +const abort = () => cancelled.abort(); +process.on("SIGTERM", abort); +process.on("SIGINT", abort); +process.stdout.on("error", abort); +process.stderr.on("error", abort); +let writes = Promise.resolve(); +let sequence = 0; + +function write(stream: Writable, text: string): Promise { + return new Promise((resolve, reject) => { + stream.write(text, error => error ? reject(error) : resolve()); + }); +} + +async function writeRecord(serialized: string): Promise { + const bytes = Buffer.byteLength(serialized, "utf8"); + if (bytes > MAX_RECORD_BYTES) throw new Error("OAR record exceeds transport limit"); + if (bytes <= FRAGMENT_THRESHOLD) { + await write(process.stdout, serialized + "\n"); + return; + } + const recordSequence = sequence++; + let index = 0; + for (let start = 0; start < serialized.length;) { + let end = Math.min(start + FRAGMENT_CHARACTERS, serialized.length); + // Avoid turning one astral character into two isolated surrogates when the + // Python JSON decoder observes the individually escaped fragment strings. + const previous = serialized.charCodeAt(end - 1); + const next = serialized.charCodeAt(end); + if (previous >= 0xd800 && previous <= 0xdbff && next >= 0xdc00 && next <= 0xdfff) end--; + await write(process.stdout, JSON.stringify({type: "fragment", sequence: recordSequence, + index: index++, last: end === serialized.length, data: serialized.slice(start, end)}) + "\n"); + start = end; + } +} + +function emit(record: OarOutputRecord): void { + // Observer callbacks cannot throw into the OAR owner. Retain a rejected write + // instead, abort the Turn, and refuse successful process exit below. + try { + const serialized = JSON.stringify(record); + writes = writes.then(() => writeRecord(serialized)); + } catch (error) { + writes = writes.then(() => { throw error; }); + } + void writes.catch(abort); +} + +try { + let input = Buffer.alloc(0); + for await (const chunk of process.stdin) { + input = Buffer.concat([input, chunk as Buffer]); + if (input.length > 8 * 1024 * 1024) throw new Error("OAR request exceeds transport limit"); + } + const request: unknown = JSON.parse(input.toString("utf8")); + if (!request || typeof request !== "object" || Array.isArray(request)) throw new TypeError("invalid OAR request"); + const value = request as OarSessionConfig & {prompt: string}; + if (!["codex", "pi", "dsh"].includes(value.runtime) || typeof value.cwd !== "string" || !value.cwd || + typeof value.prompt !== "string") throw new TypeError("invalid OAR request fields"); + // This dedicated process owns the OAR environment. It cannot mutate the + // caller's environment or the policy of another concurrent Session. + process.env.OAR_KILL_GRACE_MS = "100"; + if (value.runtime === "codex") process.env.OAR_CHILD_STDERR = "inherit"; + const {runOarTurn} = await import("./runtime.ts"); + const result = await runOarTurn(value, value.prompt, {signal: cancelled.signal, onRecord: emit}); + await writes; + process.exitCode = result.outcome.kind === "completed" ? 0 : 1; +} catch { + process.stderr.write("OAR runtime request failed; check runtime installation and the selected native provider.\n"); + process.exitCode = 1; +} finally { + await writes.catch(() => { process.exitCode = 1; }); + // Explicit exit must not discard native stderr already queued by OAR. The + // callback runs only after preceding writes have reached the parent pipe. + await write(process.stderr, "").catch(() => { process.exitCode = 1; }); + process.removeListener("SIGTERM", abort); + process.removeListener("SIGINT", abort); +} +// This is a one-Turn process, never the reusable session API. OAR's documented +// exit hook also kills owned groups whose leader exited while descendants kept +// native pipes open; leaving the event loop alive would strand those tools. +process.exit(process.exitCode ?? 1); diff --git a/loopx/hosts/oar/tsconfig.json b/loopx/hosts/oar/tsconfig.json new file mode 100644 index 0000000000..0207aade31 --- /dev/null +++ b/loopx/hosts/oar/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2024", + "types": ["node"] + }, + "include": ["*.ts"] +} diff --git a/loopx/hosts/oar/turn.py b/loopx/hosts/oar/turn.py new file mode 100644 index 0000000000..aafc303527 --- /dev/null +++ b/loopx/hosts/oar/turn.py @@ -0,0 +1,122 @@ +"""Pi execution for the existing signed generic-cli Turn host contract. + +The operator selects this host explicitly. Pi supplies its native tools and +provider configuration; the signed Turn remains the work authority. This +transport neither supplies an OS sandbox nor changes admission or writeback. +""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +import math +from pathlib import Path +from typing import Any + +from ...control_plane.turn_driver.host_candidate import ( + LOOPX_TURN_HOST_REQUEST_SCHEMA, + build_result, + extract_turn_authority, + parse_model_json, + render_prompt, +) +from ...control_plane.turn_driver.host_failure import BuiltInHostError +from .bridge import run_oar_turn + + +@dataclass(frozen=True) +class OarTurnHostConfig: + cwd: Path + runtime: str = "pi" + model: str | None = None + effort: str | None = None + timeout_seconds: float = 300.0 + env: Mapping[str, str | None] | None = None + + +def _failure_kind(observed: Mapping[str, Any]) -> str: + if observed.get("outcome") == "timeout" or bool( + isinstance(observed.get("oar_result"), Mapping) + and observed["oar_result"].get("timedOut") + ): + return "executor_timeout" + if not observed.get("output_complete"): + return "transport_lost" + outcome = observed.get("turn_outcome") + failure = outcome.get("failure") if isinstance(outcome, Mapping) else None + return { + "auth": "auth_failed", + "quota": "quota_exhausted", + "rate_limited": "rate_limited", + "overloaded": "provider_overloaded", + "provider": "provider_capacity", + "runtime_exited": "transport_lost", + "limit_reached": "output_budget_exhausted", + "invalid_request": "contract_rejected", + "model_unavailable": "contract_rejected", + "input_too_large": "contract_rejected", + "output_schema": "contract_rejected", + }.get(str(failure), "unknown") + + +def run_oar_host( + request: Mapping[str, Any], *, config: OarTurnHostConfig, +) -> dict[str, Any]: + """Translate one verified request; only a completed root answer is a candidate.""" + if not isinstance(request, Mapping) or request.get("schema_version") != LOOPX_TURN_HOST_REQUEST_SCHEMA: + raise BuiltInHostError("oar_request_schema_mismatch", failure_kind="contract_rejected") + try: + authority = extract_turn_authority(request) + except (TypeError, ValueError) as exc: + raise BuiltInHostError("oar_turn_authority_rejected", failure_kind="contract_rejected") from exc + if config.runtime != "pi": + raise BuiltInHostError("oar_turn_runtime_rejected", failure_kind="contract_rejected") + if not math.isfinite(config.timeout_seconds) or config.timeout_seconds <= 0: + raise BuiltInHostError("oar_turn_timeout_rejected", failure_kind="contract_rejected") + workspace = config.cwd.expanduser().resolve() + if not workspace.is_dir(): + raise BuiltInHostError("oar_turn_workspace_unavailable", failure_kind="contract_rejected") + if config.model is not None and ("/" not in config.model or not all(config.model.split("/", 1))): + raise BuiltInHostError("oar_pi_model_requires_provider", failure_kind="contract_rejected") + + envelope = request["turn_envelope"] + action = envelope.get("action") or {} + selected = action.get("selected_todo") or {} + if not isinstance(selected, Mapping): + selected = {} + native_request: dict[str, Any] = { + "runtime": "pi", + "cwd": str(workspace), + "prompt": render_prompt(authority), + "env": { + **dict(config.env or {}), + "LOOPX_TURN_GOAL_ID": str(envelope.get("goal_id") or ""), + "LOOPX_TURN_AGENT_ID": str(envelope.get("agent_id") or ""), + "LOOPX_TURN_TODO_ID": str(selected.get("todo_id") or ""), + "LOOPX_TURN_WORKSPACE": str(workspace), + }, + } + if config.model is not None: + native_request["model"] = config.model + if config.effort is not None: + native_request["effort"] = config.effort + try: + observed = run_oar_turn( + native_request, project=workspace, timeout_seconds=config.timeout_seconds, + ) + except (OSError, RuntimeError, ValueError) as exc: + # Native failures can quote provider keys, requests and paths. Only the + # stable local classification crosses the generic-cli stderr boundary. + raise BuiltInHostError("oar_pi_execution_unavailable", failure_kind="unknown") from exc + outcome = observed.get("turn_outcome") + final = observed.get("final_response") + if ( + observed.get("outcome") != "exited" or observed.get("returncode") != 0 + or observed.get("output_complete") is not True + or not isinstance(outcome, Mapping) or outcome.get("kind") != "completed" + or not isinstance(final, str) + ): + raise BuiltInHostError("oar_pi_execution_failed", failure_kind=_failure_kind(observed)) + return build_result( + request, parse_model_json(final), host_name="Pi through OAR", + fallback_reason="Pi returned no typed JSON result; inspect the native session before another attempt.", + ) diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index df7dce2a14..4f31486fe5 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -4,6 +4,12 @@ The Pi host adapter for LoopX. Pi is a terminal coding agent whose extensions register commands, tools, and event handlers; this adapter turns a Pi session into a LoopX-governed visible goal loop. +For a bounded Pi Turn launched by LoopX, use the separate +[OAR-managed Pi adapter](../hosts/oar/README.md#select-a-runtime). It shares +session/event/lifecycle management with Codex and DSH. The extension described +below remains inside the current Pi session and preserves its native commands, +tools and governed continuation. + ## Surface - **`/loopx`** — with no arguments, runs `loopx bootstrap-command-pack --project .` diff --git a/pyproject.toml b/pyproject.toml index 10eaf4049f..ec3add15c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,9 +75,11 @@ loopx-openviking-semantic-preference = "loopx.extensions.openviking_semantic_pre [tool.setuptools.packages.find] include = ["loopx*"] +exclude = ["loopx.hosts.oar.node_modules*"] [tool.setuptools.exclude-package-data] "loopx.semantics" = ["project_registry_io_manifest_v1.json"] +"loopx.hosts.oar" = ["*.test.ts"] [tool.setuptools.package-data] "loopx" = ["web/chat/*.html", "web/chat/*.json", "web/chat/assets/*", "web/chat/manifest.webmanifest", "web/chat/pwa/*"] @@ -106,6 +108,7 @@ include = ["loopx*"] "loopx.opencode_goal_mode" = ["*.js", "*.mjs", "README.md"] "loopx.opencode2_goal_mode" = ["*.mjs", "README.md"] "loopx.pi_goal_mode" = ["*.ts", "*.mjs", "README.md"] +"loopx.hosts.oar" = ["*.ts", "package.json", "package-lock.json", "README.md"] "loopx.kunluncode_goal_mode" = ["README.md"] "loopx.zcode_goal_mode" = ["README.md", "*.ts", "contract.json", "images/*.png"] diff --git a/tests/capabilities/test_codex_native_child_receipts.py b/tests/capabilities/test_codex_native_child_receipts.py index 178f866434..d4a8afb98b 100644 --- a/tests/capabilities/test_codex_native_child_receipts.py +++ b/tests/capabilities/test_codex_native_child_receipts.py @@ -123,14 +123,17 @@ def test_cli_host_collects_native_items_before_returning_parent_result(tmp_path: "contributions": [{"capability_id": "multi_subagent", "facts": {"max_children": 3}}]}) request["turn_envelope"]["action"]["selected_todo"]["todo_id"] = "todo_native_1" - def host(command, **kwargs): - kwargs["on_stdout"](json.dumps({"type": "thread.started", "thread_id": "parent-1"}) + "\n") + def host(config, **kwargs): + kwargs["on_record"]({"type": "ready", "sessionId": "parent-1"}) for item in _turn()["items"]: - kwargs["on_stdout"](json.dumps({"type": "item.completed", "item": item}) + "\n") - Path(command[command.index("--output-last-message") + 1]).write_text(json.dumps({"parent_work": "preserved"})) - return {"returncode": 0, "outcome": "exited", "output_complete": True} - - monkeypatch.setattr(codex_cli, "run_host_process", host) + kwargs["on_record"]({"type": "native", "frame": { + "kind": "frame", "sessionId": "parent-1", "agentPath": [], + "body": {"type": "item/completed", "native": {"item": item}}, + }}) + return {"returncode": 0, "outcome": "exited", "output_complete": True, + "final_response": json.dumps({"parent_work": "preserved"})} + + monkeypatch.setattr(codex_cli, "run_oar_turn", host) assert codex_cli.run_codex_cli_host(request, runtime_root=tmp_path, project=tmp_path, codex_bin=sys.executable) == {"parent_work": "preserved"} activity = load_native_child_activity(tmp_path, goal_id=GOAL, agent_id=AGENT, @@ -141,30 +144,24 @@ def host(command, **kwargs): def _real_cli_calls(tmp_path, monkeypatch, batches, *, host_attempts=None): - import sys from loopx.control_plane.turn_driver import codex_cli from tests.test_loopx_turn_codex_cli import _request - script = tmp_path / "host.py" - script.write_text(""" -import json, sys + from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture + + event_file = tmp_path / "events.json" + native_log = tmp_path / "native-rpc.jsonl" + monkeypatch.setenv("FIXTURE_NATIVE_CHILD_EVENTS", str(event_file)) + monkeypatch.setenv("FAKE_CODEX_RPC_LOG", str(native_log)) + script = write_codex_app_server_fixture(tmp_path / "native-codex", """ +import json, os, sys from pathlib import Path sys.stdin.read() print(json.dumps({"type": "thread.started", "thread_id": "parent-1"}), flush=True) -for item in json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")): +for item in json.loads(Path(os.environ["FIXTURE_NATIVE_CHILD_EVENTS"]).read_text(encoding="utf-8")): print(json.dumps({"type": "item.completed", "item": item}), flush=True) -Path(sys.argv[2]).write_text(json.dumps({"parent_work": "preserved"}), encoding="utf-8") -""", encoding="utf-8") - event_file = tmp_path / "events.json" - sessions = [] - - def command(**kwargs): - sessions.append(kwargs["session_id"]) - return [sys.executable, str(script), str(event_file), str(kwargs["output_path"])] - - # Replace only executable selection; run the real process, stream parser, - # session store, receipt admission and durable readback. - monkeypatch.setattr(codex_cli, "_codex_command", command) +Path(sys.argv[sys.argv.index("--output-last-message") + 1]).write_text(json.dumps({"parent_work": "preserved"}), encoding="utf-8") +""") for attempt, items in enumerate(batches, 1): request = _request(session_action="start_new" if attempt == 1 else "resume") request["turn_instance_id"] = TURN @@ -174,7 +171,9 @@ def command(**kwargs): request["turn_envelope"]["action"]["selected_todo"]["todo_id"] = "todo_native_1" event_file.write_text(json.dumps(items), encoding="utf-8") assert codex_cli.run_codex_cli_host(request, runtime_root=tmp_path, project=tmp_path, - codex_bin=sys.executable) == {"parent_work": "preserved"} + codex_bin=str(script)) == {"parent_work": "preserved"} + sessions = [row["params"].get("threadId") for row in map(json.loads, native_log.read_text().splitlines()) + if row.get("method") in {"thread/start", "thread/resume"}] assert sessions == [None] + ["parent-1"] * (len(batches) - 1) return load_native_child_activity(tmp_path, goal_id=GOAL, agent_id=AGENT, turn_instance_id=TURN, configured_limit=3) diff --git a/tests/control_plane/codex_app_server_fixture.py b/tests/control_plane/codex_app_server_fixture.py new file mode 100644 index 0000000000..0bffaf3e40 --- /dev/null +++ b/tests/control_plane/codex_app_server_fixture.py @@ -0,0 +1,170 @@ +"""Synthetic app-server peer for the real OAR transport. + +Existing Turn fixtures express their independent artifact/settlement scenarios as +small exec-style programs. Run those programs behind this protocol peer so their +oracles survive the transport migration. Tests of native options read the actual +RPC journal; the compatibility argv is never proof of OAR's wire behavior. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import threading +from typing import Any + +from tests.control_plane.host_process_fixture import write_python_host_fixture + + +def write_codex_app_server_fixture(path: Path, source: str) -> Path: + root = Path(__file__).resolve().parents[2] + header = ( + f"#!{sys.executable}\n" + "import sys as _fixture_sys\n" + "if '--version' in _fixture_sys.argv[1:]:\n" + " print('codex-cli 0.165.0'); raise SystemExit(0)\n" + "if '--help' in _fixture_sys.argv[1:]:\n" + " print('app-server --listen stdio://'); raise SystemExit(0)\n" + "if 'app-server' in _fixture_sys.argv[1:]:\n" + f" _fixture_sys.path.insert(0, {str(root)!r})\n" + " from tests.control_plane.codex_app_server_fixture import serve\n" + " serve(__file__); raise SystemExit(0)\n" + ) + return write_python_host_fixture(path, header + source) + + +def serve(program: str) -> None: + stream = sys.stdout + write_lock = threading.Lock() + launch = list(sys.argv[1:]) + native_log = os.environ.get("FAKE_CODEX_RPC_LOG") + if native_log is None and os.environ.get("FAKE_CODEX_LOG"): + native_log = os.environ["FAKE_CODEX_LOG"] + ".rpc" + + def log(value: dict[str, Any]) -> None: + if native_log: + with Path(native_log).open("a", encoding="utf-8") as handle: + handle.write(json.dumps(value) + "\n") + + log({"launch": launch}) + config = {} + for index, arg in enumerate(launch[:-1]): + if arg == "-c" and "=" in launch[index + 1]: + key, value = launch[index + 1].split("=", 1) + try: + config[key] = json.loads(value) + except ValueError: + config[key] = value + # The fixture's original native identity remains stable across resumes. + source = Path(program).read_text(encoding="utf-8") + identity = re.search(r'["\']thread_id["\']\s*:\s*["\']([^"\']+)', source) + session_id = identity.group(1) if identity else "session-fixture-0001" + cwd = str(Path.cwd()) + model: str | None = None + effort: str | None = None + opened = "thread/start" + turn_id = "fixture-turn-1" + + def send(payload: dict[str, Any]) -> None: + with write_lock: + stream.write(json.dumps(payload) + "\n") + stream.flush() + + def event(method: str, params: dict[str, Any]) -> None: + send({"method": method, "params": {"threadId": session_id, **params}}) + + def execute(params: dict[str, Any]) -> None: + prompt = "\n".join(item["text"] for item in params.get("input", []) + if item.get("type") == "text") + with tempfile.TemporaryDirectory(prefix="codex-native-fixture-") as directory: + output = Path(directory) / "result.json" + schema = Path(directory) / "schema.json" + schema.write_text(json.dumps(params.get("outputSchema", {})), encoding="utf-8") + args = ["exec", "--skip-git-repo-check", "-C", cwd] + if opened == "thread/resume": + args.append("resume") + args.extend(["-c", "sandbox_mode=" + json.dumps(config.get("sandbox_mode", "read-only")), + "--json", "--output-schema", str(schema), "--output-last-message", str(output)]) + for key, value in config.items(): + if key != "sandbox_mode": + args.extend(["-c", key + "=" + json.dumps(value, separators=(",", ":"))]) + if model is not None: + args.extend(["--model", model]) + if effort is not None: + args.extend(["-c", "model_reasoning_effort=" + json.dumps(effort)]) + if opened == "thread/resume": + args.append(session_id) + args.append("-") + child = subprocess.Popen([sys.executable, program, *args], cwd=cwd, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, encoding="utf-8") + errors: list[dict[str, Any]] = [] + + def read() -> None: + for line in child.stdout: + try: + item = json.loads(line) + except ValueError: + continue + if item.get("type") == "item.completed": + event("item/completed", {"turnId": turn_id, "item": item.get("item")}) + elif item.get("type") in {"error", "turn.failed", "response.failed"}: + native = item.get("error") or item.get("response", {}).get("error") or item + # Retain a conflicting outer code too; it must not be + # turned into a retry from familiar inner error prose. + detail = {"error": native, "willRetry": False} + if "code" in item: + detail["code"] = item["code"] + errors.append(native) + event("error", detail) + + reader = threading.Thread(target=read, daemon=True) + reader.start() + child.stdin.write(prompt) + child.stdin.close() + code = child.wait() + reader.join(timeout=.05) + if code == 0 and output.is_file(): + text = output.read_text(encoding="utf-8") + event("item/agentMessage/delta", {"turnId": turn_id, "itemId": "answer", "delta": text}) + event("item/completed", {"turnId": turn_id, "item": { + "id": "answer", "type": "agentMessage", "text": text, "phase": "final_answer"}}) + event("turn/completed", {"turn": {"id": turn_id, "status": "completed"}}) + else: + event("turn/completed", {"turn": {"id": turn_id, "status": "failed", + **({"error": errors[-1]} if errors else {})}}) + + for line in sys.stdin: + request = json.loads(line) + method, params = request.get("method"), request.get("params", {}) + log(request) + if "id" not in request: + continue + result: dict[str, Any] = {} + if method in {"thread/start", "thread/resume"}: + if error := os.environ.get("FAKE_CODEX_OPEN_ERROR"): + send({"id": request["id"], "error": json.loads(error)}) + continue + opened = method + cwd = params.get("cwd", cwd) + model = params.get("model") + effort = params.get("config", {}).get("model_reasoning_effort") + result = {"thread": {"id": session_id}, "model": model, "reasoningEffort": effort} + elif method == "thread/settings/update": + effort = params["effort"] + elif method == "turn/start": + if error := os.environ.get("FAKE_CODEX_TURN_ERROR"): + send({"id": request["id"], "error": json.loads(error)}) + continue + result = {"turn": {"id": turn_id, "status": "inProgress"}} + send({"id": request["id"], "result": result}) + if method == "thread/settings/update": + event("thread/settings/updated", {"threadSettings": {"effort": effort}}) + elif method == "turn/start": + event("turn/started", {"turn": {"id": turn_id}}) + threading.Thread(target=execute, args=(params,), daemon=True).start() + elif method == "turn/interrupt": + event("turn/completed", {"turn": {"id": turn_id, "status": "interrupted"}}) diff --git a/tests/control_plane/test_phase_probe_continuation.py b/tests/control_plane/test_phase_probe_continuation.py index ee7d029bbd..4cddee5e4d 100644 --- a/tests/control_plane/test_phase_probe_continuation.py +++ b/tests/control_plane/test_phase_probe_continuation.py @@ -163,7 +163,8 @@ def test_current_phase_probe_executes_once_after_unique_extension(controllers, t workspace.mkdir() (workspace / "retained-inputs.json").write_text(json.dumps([2, 3])) native = tmp_path / "controlled-codex" - native.write_text(f"#!{sys.executable}\n" + (_PHASE_HOST if mode == "governed" else _HEARTBEAT_PHASE_HOST)) + from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture + write_codex_app_server_fixture(native, f"#!{sys.executable}\n" + (_PHASE_HOST if mode == "governed" else _HEARTBEAT_PHASE_HOST)) native.chmod(0o700) environment = worker_env(workspace) skills = project / "shared-skills" diff --git a/tests/control_plane/test_reward_memory_host_isolation.py b/tests/control_plane/test_reward_memory_host_isolation.py index 5d31e72fff..4ba83f2bb7 100644 --- a/tests/control_plane/test_reward_memory_host_isolation.py +++ b/tests/control_plane/test_reward_memory_host_isolation.py @@ -369,8 +369,10 @@ def test_real_turn_cli_memory_projection_and_settlement(tmp_path: Path, monkeypa }, ) capture = tmp_path / "host-capture.json" + from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture + host = tmp_path / "fixture-codex" - host.write_text(f"#!{sys.executable}\n" + '''import json, os, pathlib, sys + host = write_codex_app_server_fixture(host, f"#!{sys.executable}\n" + '''import json, os, pathlib, sys args = sys.argv[1:] prompt = sys.stdin.read() schema = json.loads(pathlib.Path(args[args.index('--output-schema') + 1]).read_text()) @@ -387,8 +389,7 @@ def test_real_turn_cli_memory_projection_and_settlement(tmp_path: Path, monkeypa result['reward_memory_reflection_json'] = '{"status":"no_evidence"}' pathlib.Path(args[args.index('--output-last-message') + 1]).write_text(json.dumps(result)) print(json.dumps({'type': 'thread.started', 'thread_id': 'fixture-memory-session'})) -''', encoding="utf-8") - host.chmod(0o755) +''') host_args = ["--host", "codex-cli", "--codex-bin", str(host)] if host_kind == "dsh": runner = tmp_path / "fixture_dsh_runner.py" diff --git a/tests/control_plane/test_turn_todoless_settlement.py b/tests/control_plane/test_turn_todoless_settlement.py index daf556a172..fd631d9e44 100644 --- a/tests/control_plane/test_turn_todoless_settlement.py +++ b/tests/control_plane/test_turn_todoless_settlement.py @@ -16,7 +16,7 @@ from loopx.control_plane.quota.turn_envelope import build_turn_envelope, turn_envelope_action_signature_document from loopx.control_plane.turn_driver.driver import build_loopx_turn_plan from loopx.control_plane.turn_driver.executor import validate_loopx_turn_host_result -from tests.control_plane.host_process_fixture import write_python_host_fixture +from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture from test_quota_settlement_cli import ( AGENT_ID, GOAL_ID, _classification_count, _configure_autonomous_replan_fixture, _initialize_git_checkout, _run_cli, _write_fixture, @@ -105,7 +105,7 @@ def _host(tmp_path, *, observation=True, changed=True, fail_once=False): print(json.dumps({"type": "error", "error": {"code": "rate_limit_exceeded"}}), flush=True) raise SystemExit(9) (root / "plan.json").write_text''') - return write_python_host_fixture(tmp_path / "fixture-codex", f"#!{sys.executable}\n" + source.lstrip()) + return write_codex_app_server_fixture(tmp_path / "fixture-codex", f"#!{sys.executable}\n" + source.lstrip()) def _run_turn(project, runtime, registry, host, *, turn=None, resume=None, valid=True, retry=False): diff --git a/tests/control_plane_ts/host_process_group.test.ts b/tests/control_plane_ts/host_process_group.test.ts index 4dae80cc72..7482fbc498 100644 --- a/tests/control_plane_ts/host_process_group.test.ts +++ b/tests/control_plane_ts/host_process_group.test.ts @@ -1,9 +1,14 @@ import assert from "node:assert/strict"; import {execFileSync, spawn} from "node:child_process"; import {once} from "node:events"; +import {mkdtemp, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {join} from "node:path"; import {performance} from "node:perf_hooks"; +import {setTimeout as delay} from "node:timers/promises"; import test from "node:test"; import {hasLiveGroupMembers, waitForProcessGroupStop} from "../../loopx/control_plane/turn_driver/host_process_group.ts"; +import {runHostProcess} from "../../loopx/control_plane/turn_driver/host_process.ts"; test("cleanup observation excludes only zombies, not sleeping or stopped members", () => { assert.equal(hasLiveGroupMembers(" 42 Z\n42 Z+\n100 R\n", 42), false); @@ -58,3 +63,125 @@ test("EPERM is not mistaken for a stopped group", {skip: process.platform === "w t.mock.method(process, "kill", () => { throw Object.assign(new Error("permission denied"), {code: "EPERM"}); }); await assert.rejects(waitForProcessGroupStop(pgid), /did not stop before cleanup deadline/); }); + +test("managed OAR cancellation drains nested detached groups before returning", {skip: process.platform === "win32"}, async t => { + const directory = await mkdtemp(join(tmpdir(), "loopx-oar-descendants-")); + const childMarker = join(directory, "child-counter"), grandchildMarker = join(directory, "grandchild-counter"); + const ancestry = join(directory, "ancestry.json"); + const nativeSession = join(directory, "native-session.json"); + const nativeIdentity = {id: "retained-native-session", turns: ["prior completed work"]}; + await writeFile(nativeSession, JSON.stringify(nativeIdentity)); + const ownedGroups = new Set(); + t.after(async () => { + for (const group of ownedGroups) { + try { process.kill(-group, "SIGKILL"); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + await rm(directory, {recursive: true, force: true}); + }); + const heartbeat = (path: string) => ` + const fs=require('node:fs');let count=0; + const path=${JSON.stringify(path)}; + process.on('SIGTERM',()=>{}); + function tick(){fs.writeFileSync(path+'.next',String(count++));fs.renameSync(path+'.next',path)} + tick();setInterval(tick,10); + `; + const grandchild = heartbeat(grandchildMarker); + const child = ` + const {spawn}=require('node:child_process'); + const grandchild=spawn(process.execPath,['-e',${JSON.stringify(grandchild)}],{detached:true,stdio:'ignore'}); + grandchild.unref(); + ${heartbeat(childMarker)} + fs.writeFileSync(${JSON.stringify(ancestry)},JSON.stringify({child:process.pid,grandchild:grandchild.pid})); + `; + const leader = ` + const {spawn}=require('node:child_process'),fs=require('node:fs'); + const child=spawn(process.execPath,['-e',${JSON.stringify(child)}],{detached:true,stdio:'ignore'}); + child.unref(); + const ready=setInterval(()=>{ + if(fs.existsSync(${JSON.stringify(childMarker)})&&fs.existsSync(${JSON.stringify(grandchildMarker)})){ + clearInterval(ready);process.stdout.write('native-ready\\n'); + } + },5); + process.on('SIGTERM',()=>process.exit(0)); + setInterval(()=>{},1000); + `; + const cancellation = new AbortController(); + let ready = false; + const result = await runHostProcess({ + argv: [process.execPath, "-e", leader], cwd: directory, input: "", + timeout_ms: 5000, drain_timeout_ms: 100, stdout_limit_bytes: 4096, + }, async output => { + if (output.kind === "stdout" && output.text.includes("native-ready")) { + ready = true; + const groups = JSON.parse(await readFile(ancestry, "utf8")); + ownedGroups.add(groups.child); ownedGroups.add(groups.grandchild); + cancellation.abort(); + } + }, cancellation.signal, undefined, {trackDescendants: true, descendants: async observation => { + for (const group of observation.process_groups) ownedGroups.add(group); + }}); + assert.ok(ready, "the nested native workers must actually be running before cancellation"); + assert.equal(result.outcome, "cancelled"); + assert.equal(result.descendant_cleanup_complete, true); + assert.equal(ownedGroups.size, 2, "both independently detached groups are owned"); + for (const group of ownedGroups) await waitForProcessGroupStop(group); + const before = await Promise.all([readFile(childMarker, "utf8"), readFile(grandchildMarker, "utf8")]); + await delay(120); + assert.deepEqual(await Promise.all([readFile(childMarker, "utf8"), readFile(grandchildMarker, "utf8")]), before, + "a stopped OAR owner must not leave either detached generation writing after return"); + assert.deepEqual(JSON.parse(await readFile(nativeSession, "utf8")), nativeIdentity, + "process cleanup preserves the native conversation used by resume"); +}); + +test("lost owner observation cannot strand a stopped detached native process", {skip: process.platform === "win32"}, async t => { + const directory = await mkdtemp(join(tmpdir(), "loopx-oar-owner-loss-")); + const pidPath = join(directory, "native.pid"), effectPath = join(directory, "native-effect"); + let nativePid: number | undefined; + t.after(async () => { + if (nativePid) { + try { process.kill(-nativePid, "SIGKILL"); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; + } + } + await rm(directory, {recursive: true, force: true}); + }); + const native = ` + const fs=require('node:fs'); + fs.writeFileSync(${JSON.stringify(effectPath)},'completed native effect'); + process.stdout.write('ready');setInterval(()=>{},1000); + `; + const leader = ` + const {spawn}=require('node:child_process'),fs=require('node:fs'); + const native=spawn(process.execPath,['-e',${JSON.stringify(native)}],{detached:true,stdio:['ignore','pipe','ignore']}); + native.stdout.once('data',()=>{ + process.kill(native.pid,'SIGSTOP'); + fs.writeFileSync(${JSON.stringify(pidPath)},String(native.pid)); + process.stdout.write('native-stopped\\n'); + }); + process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000); + `; + const cancellation = new AbortController(); + let deliveryFailures = 0; + const result = await runHostProcess({ + argv: [process.execPath, "-e", leader], cwd: directory, input: "", + timeout_ms: 5000, drain_timeout_ms: 100, stdout_limit_bytes: 4096, + }, async output => { + if (output.kind === "stdout" && output.text.includes("native-stopped")) { + nativePid = Number(await readFile(pidPath, "utf8")); + cancellation.abort(); + } + }, cancellation.signal, undefined, {trackDescendants: true, descendants: async observation => { + assert.ok(nativePid && observation.process_groups.includes(nativePid)); + deliveryFailures++; + throw new Error("owner pipe disconnected"); + }}); + assert.ok(nativePid, "the native child must have started and been suspended before owner loss"); + assert.ok(deliveryFailures > 0, "the disconnected observation path must actually execute"); + assert.equal(result.outcome, "cancelled"); + assert.equal(result.output_complete, false, "lost observations cannot be reported as complete"); + assert.equal(result.descendant_cleanup_complete, true); + await waitForProcessGroupStop(nativePid); + assert.equal(await readFile(effectPath, "utf8"), "completed native effect"); +}); diff --git a/tests/test_chat_dsh_adapter.py b/tests/test_chat_dsh_adapter.py index 51866f6c86..0f5898a498 100644 --- a/tests/test_chat_dsh_adapter.py +++ b/tests/test_chat_dsh_adapter.py @@ -61,6 +61,13 @@ def _adapter(runner: _Runner, tmp_path: Path) -> DshChatAdapter: ) +def test_interrupt_capability_names_the_actual_runner_contract(tmp_path: Path) -> None: + adapter = _adapter(_Runner({}), tmp_path) + assert adapter.capabilities()["interrupt"] is False + adapter.runner = None + assert adapter.capabilities()["interrupt"] is True + + def test_a_segment_carries_the_resolved_profile_and_a_read_only_boundary( tmp_path: Path, ) -> None: @@ -110,6 +117,25 @@ def test_an_empty_final_message_fails_closed(tmp_path: Path) -> None: assert raised.value.error_code == MANAGED_HOST_CHAT_FAILED +@pytest.mark.parametrize("reason_source", ["field", "event"]) +def test_a_terminal_error_cannot_publish_nonempty_text( + tmp_path: Path, reason_source: str, +) -> None: + terminal = {"finish_reason": "error"} if reason_source == "field" else { + "events": [{"type": "turn/end", "data": {"reason": { + "kind": "error", "error": {"message": "fixture provider failure"}, + }}}], + } + adapter = _adapter(_Runner({"final_response": _envelope("unaccepted answer"), **terminal}), tmp_path) + events: list[str] = [] + with pytest.raises(CodexChatAgentError) as raised: + adapter.start_turn("status please", lambda kind, _payload: events.append(kind)) + + assert raised.value.error_code == MANAGED_HOST_CHAT_FAILED + assert adapter.history == [] + assert not any(kind.startswith("answer.") for kind in events) + + @pytest.mark.parametrize("final_response", ["", _envelope("truncated answer")]) @pytest.mark.parametrize("reason_source", ["field", "event"]) def test_token_exhaustion_never_publishes_a_segment_answer( diff --git a/tests/test_loopx_turn_codex_cli.py b/tests/test_loopx_turn_codex_cli.py index 4846eb346c..7b69edfa6a 100644 --- a/tests/test_loopx_turn_codex_cli.py +++ b/tests/test_loopx_turn_codex_cli.py @@ -22,9 +22,9 @@ from loopx.control_plane.turn_driver.codex_cli import ( CODEX_CLI_SESSION_SCHEMA_VERSION, CODEX_STDIO_MCP_SERVER_SCHEMA_VERSION, + CodexFailureObservation, _diagnostic_failure_category, _event_failure_categories, - _event_failure_category, _prompt, _select_failure_category, codex_cli_result_schema, @@ -36,9 +36,9 @@ from loopx.control_plane.turn_driver.subagent_execution_topology import ( OPAQUE_REF_PATTERN, ) +from tests.control_plane.codex_app_server_fixture import write_codex_app_server_fixture from tests.control_plane.host_process_fixture import ( COUNTER_PROCESS_SOURCE, - write_python_host_fixture, ) @@ -159,13 +159,9 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: print(failure_event, flush=True) if failure_stderr and not stderr_first: print(failure_stderr, file=sys.stderr, flush=True) - hold_pipe_seconds = os.environ.get("FAKE_CODEX_HOLD_PIPE_SECONDS") - if hold_pipe_seconds: - subprocess.Popen([ - sys.executable, - "-c", - f"import time; time.sleep({float(hold_pipe_seconds)!r})", - ]) + overflow_chars = os.environ.get("FAKE_CODEX_STDERR_OVERFLOW") + if overflow_chars: + print("x" * int(overflow_chars), file=sys.stderr, flush=True) if os.environ.get("FAKE_CODEX_FAILURE_CATEGORY") == "model": print("This model requires a newer version of Codex.", file=sys.stderr) if os.environ.get("FAKE_CODEX_FAILURE_CATEGORY") == "session": @@ -210,7 +206,7 @@ def _fake_codex(tmp_path: Path) -> tuple[Path, Path]: "validation_evidence": ["check:fixture-validation"] }), encoding="utf-8") """ - executable = write_python_host_fixture( + executable = write_codex_app_server_fixture( executable, source.replace( "__COUNTER_PROCESS_SOURCE__", @@ -335,9 +331,9 @@ def test_codex_cli_failure_envelopes_match_real_protocol_shapes() -> None: assert fixture["schema_version"] == "loopx_codex_failure_envelopes_v1" for case in fixture["cases"]: assert case["source"] - assert _event_failure_category(case["event"]) == case["expected"], case[ - "name" - ] + observer = CodexFailureObservation() + observer.observe_event(case["event"]) + assert observer.category() == (case["expected"] or "exit_nonzero"), case["name"] def test_codex_cli_unknown_structured_code_blocks_message_fallback() -> None: @@ -349,7 +345,9 @@ def test_codex_cli_unknown_structured_code_blocks_message_fallback() -> None: }, } - assert _event_failure_category(event) == "unknown" + observer = CodexFailureObservation() + observer.observe_event(event) + assert observer.category() == "unknown" @pytest.mark.parametrize("event_type", ["error", "turn.failed"]) @@ -357,12 +355,18 @@ def test_codex_cli_unknown_structured_code_blocks_message_fallback() -> None: def test_codex_cli_message_only_subscription_limit_is_not_a_transient_retry(event_type, apostrophe) -> None: message = f"You{apostrophe}ve hit your usage limit. Try again after the reset." event = {"type": event_type, "error": {"message": message}} - assert _event_failure_category(event) == "quota_exhausted" + observer = CodexFailureObservation() + observer.observe_event(event) + assert observer.category() == "quota_exhausted" # A present discriminator still wins over familiar prose. event["error"]["code"] = "future_provider_failure" - assert _event_failure_category(event) == "unknown" + observer = CodexFailureObservation() + observer.observe_event(event) + assert observer.category() == "unknown" event["error"]["code"] = "rate_limit_exceeded" - assert _event_failure_category(event) == "rate_limited" + observer = CodexFailureObservation() + observer.observe_event(event) + assert observer.category() == "rate_limited" @pytest.mark.parametrize( @@ -422,7 +426,9 @@ def test_codex_cli_specific_quota_code_wins_over_http_429() -> None: }, } - assert _event_failure_category(event) == "quota_exhausted" + observer = CodexFailureObservation() + observer.observe_event(event) + assert observer.category() == "quota_exhausted" def test_codex_cli_prompt_isolates_subagent_instructions_to_enabled_request() -> None: @@ -493,26 +499,26 @@ def test_codex_cli_host_starts_then_resumes_opaque_session( assert first["turn_key"] == first_request["turn_key"] assert second["turn_key"] == second_request["turn_key"] - argv_rows = [ - json.loads(line) for line in log_path.read_text(encoding="utf-8").splitlines() - ] - assert "resume" not in argv_rows[0] - assert "resume" in argv_rows[1] - assert "session-fixture-0001" in argv_rows[1] - for argv in argv_rows: - assert argv[argv.index("--model") + 1] == "gpt-5.6-sol" - config_values = [ - argv[index + 1] - for index, value in enumerate(argv) - if value == "-c" - ] - assert 'model_reasoning_effort="xhigh"' in config_values - resume_argv = argv_rows[1] - assert resume_argv[resume_argv.index("-c") + 1] == ( - f'sandbox_mode="{sandbox}"' - ) - assert resume_argv[resume_argv.index("-C") + 1] == str(project) - assert resume_argv.index("-C") < resume_argv.index("resume") + # Inspect the actual app-server wire, not the synthetic scenario's + # compatibility argv. OAR must keep the same native session and controls. + native = [json.loads(line) for line in Path(str(log_path) + ".rpc").read_text().splitlines()] + opens = [row for row in native if row.get("method") in {"thread/start", "thread/resume"}] + assert [row["method"] for row in opens] == ["thread/start", "thread/resume"] + assert opens[1]["params"]["threadId"] == "session-fixture-0001" + for row in opens: + assert row["params"]["model"] == "gpt-5.6-sol" + assert row["params"]["cwd"] == str(project) + assert row["params"]["approvalPolicy"] == "never" + assert opens[0]["params"]["config"]["model_reasoning_effort"] == "xhigh" + assert any(row.get("method") == "thread/settings/update" and row["params"]["effort"] == "xhigh" + for row in native) + for row in native: + if "launch" in row: + argv = row["launch"] + values = [argv[i + 1] for i, value in enumerate(argv[:-1]) if value == "-c"] + assert [value for value in values if value.startswith("sandbox_mode=")][-1] == f'sandbox_mode="{sandbox}"' + elif row.get("method") == "turn/start": + assert row["params"]["outputSchema"] == codex_cli_result_schema(first_request) envelope = first_request["turn_envelope"] assert isinstance(envelope, dict) @@ -1066,7 +1072,9 @@ def test_codex_cli_host_materializes_bound_mcp_tools_for_fresh_and_resume( timeout_seconds=5, ) - for argv in map(json.loads, log_path.read_text(encoding="utf-8").splitlines()): + launches = [row["launch"] for row in map(json.loads, Path(str(log_path) + ".rpc").read_text().splitlines()) if "launch" in row] + assert len(launches) == 2 + for argv in launches: config_values = [ argv[index + 1] for index, value in enumerate(argv) @@ -1237,7 +1245,7 @@ def test_codex_cli_host_preserves_observed_session_after_timeout( ) -> None: executable, log_path = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) - monkeypatch.setenv("FAKE_CODEX_SLEEP", "2") + monkeypatch.setenv("FAKE_CODEX_SLEEP", "30") runtime_root = tmp_path / "runtime" project = tmp_path / "project" project.mkdir() @@ -1249,7 +1257,7 @@ def test_codex_cli_host_preserves_observed_session_after_timeout( runtime_root=runtime_root, project=project, codex_bin=str(executable), - timeout_seconds=0.1, + timeout_seconds=3, ) envelope = request["turn_envelope"] @@ -1448,11 +1456,9 @@ def test_codex_cli_host_fails_closed_when_output_observation_is_incomplete( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr( - "loopx.control_plane.turn_driver.codex_cli.OUTPUT_DRAIN_TIMEOUT_SECONDS", - 0.01, - ) - monkeypatch.setenv("FAKE_CODEX_HOLD_PIPE_SECONDS", "0.2") + # Native pipes now belong to OAR. Overflow the actual bounded diagnostic + # reader so an incomplete observation cannot authorize a retry. + monkeypatch.setenv("FAKE_CODEX_STDERR_OVERFLOW", str(1_048_577)) error, _runtime_root = _capture_fake_codex_failure( tmp_path, monkeypatch, @@ -1467,9 +1473,28 @@ def test_codex_cli_host_fails_closed_when_output_observation_is_incomplete( assert error.recovery_kind is None +def test_codex_cli_host_does_not_retry_rpc_rejection_from_familiar_prose( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + executable, log_path = _fake_codex(tmp_path) + monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) + monkeypatch.setenv("FAKE_CODEX_TURN_ERROR", json.dumps({ + "code": -32603, "message": "Too many requests; retry later.", + "data": {"code": "future_non_retryable_code"}, + })) + with pytest.raises(BuiltInHostError) as captured: + run_codex_cli_host(_request(), runtime_root=tmp_path / "runtime", project=tmp_path, + codex_bin=str(executable), timeout_seconds=5) + assert captured.value.reason == "codex_cli_unknown" + assert captured.value.failure_kind == "unknown" + assert captured.value.recovery_kind is None + + +@pytest.mark.parametrize("failure_phase", ["open", "turn"]) def test_codex_cli_host_discards_missing_resume_session( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, + failure_phase: str, ) -> None: executable, log_path = _fake_codex(tmp_path) monkeypatch.setenv("FAKE_CODEX_LOG", str(log_path)) @@ -1485,9 +1510,17 @@ def test_codex_cli_host_discards_missing_resume_session( timeout_seconds=5, ) - monkeypatch.setenv("FAKE_CODEX_FAIL", "1") - monkeypatch.setenv("FAKE_CODEX_FAILURE_CATEGORY", "session") - with pytest.raises(RuntimeError, match="codex_cli_session_missing"): + if failure_phase == "open": + monkeypatch.setenv("FAKE_CODEX_OPEN_ERROR", json.dumps({ + "code": -32600, "message": "no rollout found for thread id session-fixture-0001", + })) + else: + monkeypatch.setenv("FAKE_CODEX_FAIL", "1") + monkeypatch.setenv("FAKE_CODEX_FAILURE_CATEGORY", "session") + # A rejected open cannot attest Session disposal. Its transport remains + # unknown while the exact typed missing target still invalidates the binding. + reason = "codex_cli_unknown" if failure_phase == "open" else "codex_cli_session_missing" + with pytest.raises(BuiltInHostError, match=reason): run_codex_cli_host( _request( turn_key="sha256:" + "f" * 64, @@ -1504,6 +1537,34 @@ def test_codex_cli_host_discards_missing_resume_session( assert codex_cli_session_binding(runtime_root, envelope) is None +@pytest.mark.parametrize(("failure", "category"), [ + ("auth", "auth_failed"), ("quota", "quota_exhausted"), + ("rate_limited", "rate_limited"), ("overloaded", "provider_overloaded"), + ("invalid_request", "unknown"), +]) +def test_codex_cli_host_classifies_typed_oar_startup_failures( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, failure: str, category: str, +) -> None: + from loopx.control_plane.turn_driver import codex_cli + + def startup_failure(config, **kwargs): + outcome = {"kind": "failed", "failure": failure, "reason": "Too many requests"} + kwargs["on_record"]({"type": "result", "result": { + "sessionId": None, "outcome": outcome, + "error": {"name": "RuntimeFailureError", "message": outcome["reason"]}, + }}) + return {"returncode": 1, "outcome": "exited", "output_complete": True, + "turn_outcome": outcome, "final_response": None} + + monkeypatch.setattr(codex_cli, "run_oar_turn", startup_failure) + with pytest.raises(BuiltInHostError) as captured: + run_codex_cli_host(_request(), runtime_root=tmp_path / "runtime", project=tmp_path, + codex_bin=sys.executable) + assert captured.value.reason == "codex_cli_" + category + assert captured.value.recovery_kind is None + assert _session_files(tmp_path / "runtime") == [] + + def test_native_input_observation_is_opt_in_and_does_not_change_host_result(tmp_path, monkeypatch): from loopx.control_plane.turn_driver import codex_input_trace as trajectory_input_capture @@ -1600,7 +1661,7 @@ def test_codex_cli_reaps_descendants_after_result_or_timeout( monkeypatch.setenv("FAKE_CODEX_SLEEP", "30") try: kwargs = dict(runtime_root=tmp_path / "runtime", project=tmp_path, - codex_bin=str(executable), timeout_seconds=1 if timeout else 5) + codex_bin=str(executable), timeout_seconds=3 if timeout else 5) if timeout: with pytest.raises(BuiltInHostError, match="codex_cli_timeout"): run_codex_cli_host(_request(), **kwargs) diff --git a/tests/test_loopx_turn_executor.py b/tests/test_loopx_turn_executor.py index ecb7edba57..75c562e025 100644 --- a/tests/test_loopx_turn_executor.py +++ b/tests/test_loopx_turn_executor.py @@ -177,6 +177,7 @@ def _managed_plan(*, runtime_available: bool) -> dict[str, object]: "dsh", environ={"DEEPSEEK_API_KEY": "fixture-operator-credential"}, module_probe=lambda _module: runtime_available, + oar_probe=lambda: {"ready": True, "oar_version": "fixture-version"}, ) return managed diff --git a/tests/test_manager_channel_binding.py b/tests/test_manager_channel_binding.py index cb83438db1..09aa76f4ee 100644 --- a/tests/test_manager_channel_binding.py +++ b/tests/test_manager_channel_binding.py @@ -52,6 +52,14 @@ from loopx.extensions.lark.cli_resolution import LarkCliResolution +@pytest.fixture(autouse=True) +def ready_optional_oar(monkeypatch): + monkeypatch.setattr( + "loopx.hosts.oar.installation.inspect_installation", + lambda: {"ready": True, "oar_version": "fixture-version"}, + ) + + def _apply_steward_executor_default(runtime_root: Path) -> None: """Store one machine-level steward selection, as a machine surface would.""" diff --git a/tests/test_oar_dsh_chat.py b/tests/test_oar_dsh_chat.py new file mode 100644 index 0000000000..d92cdb2c04 --- /dev/null +++ b/tests/test_oar_dsh_chat.py @@ -0,0 +1,177 @@ +"""Real DSH SDK cancellation through the default Chat -> OAR transport.""" + +from __future__ import annotations + +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import json +import socket +import threading +from pathlib import Path +from typing import Any + +import pytest + +from loopx.chat import CHAT_REVIEW_CLOSE_TAG, CHAT_REVIEW_OPEN_TAG +from loopx.chat_agent import CodexChatAgentError +from loopx.chat_dsh import DshChatAdapter, MANAGED_HOST_CHAT_FAILED +from loopx.control_plane.turn_driver.host_process_transport import ( + HOST_PROCESS_DRAINED, + HOST_PROCESS_RECORD_ENV, + execution_host_drain, + host_process_drain_supported, +) +from loopx.hosts.oar import bridge, installation + + +def test_default_dsh_chat_interrupt_drains_native_work_and_releases_slot( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("deepseek_harness") + if not host_process_drain_supported(): + pytest.skip("This process-tree assertion requires POSIX process groups") + try: + installation.node_executable() + installation.runtime_directory() + except RuntimeError as exc: + pytest.skip(f"Optional pinned OAR runtime is required for this integration: {exc}") + + received = threading.Event() + disconnected = threading.Event() + shutdown = threading.Event() + requests: list[dict[str, Any]] = [] + envelope = CHAT_REVIEW_OPEN_TAG + json.dumps({ + "message": "fresh fixture answer", "proposals": [], "protected_action": None, + "context_handoff": None, "gate": None, + }) + CHAT_REVIEW_CLOSE_TAG + + class Provider(BaseHTTPRequestHandler): + def log_message(self, *_args: Any) -> None: + pass + + def do_POST(self) -> None: + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + requests.append(payload) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + self.wfile.flush() + if len(requests) == 1: + # The real native model request remains in flight until its + # owning runtime closes the socket. No artificial SDK runner + # or injected cancellation result participates in this path. + received.set() + self.connection.settimeout(0.1) + while not shutdown.is_set(): + try: + if self.connection.recv(1) == b"": + disconnected.set() + return + except socket.timeout: + continue + except ConnectionResetError: + disconnected.set() + return + return + chunk = {"choices": [{"delta": {"role": "assistant", "content": envelope}}]} + self.wfile.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + self.wfile.write( + b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],' + b'"usage":{"prompt_tokens":2,"completion_tokens":1}}\n\n' + ) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) + serving = threading.Thread(target=server.serve_forever, daemon=True) + serving.start() + record_path = tmp_path / "host-process.json" + monkeypatch.setenv(HOST_PROCESS_RECORD_ENV, str(record_path)) + for key in ("DSH_CWD", "DSH_HOME", "DSH_SESSION_ROOT"): + monkeypatch.delenv(key, raising=False) + adapter = DshChatAdapter( + objective="Answer from the fixture context only.", work_dir=tmp_path, + provider="deepseek-official", model="deepseek-flash", reasoning_effort="high", + timeout_sec=25, + credential={ + "DEEPSEEK_API_KEY": "fixture-operator-credential", + "DEEPSEEK_BASE_URL": f"http://127.0.0.1:{server.server_port}", + }, + ) + chat_binding_id = adapter.upstream_thread_id + observations: list[dict[str, Any]] = [] + run_oar_turn = bridge.run_oar_turn + + def observe_transport(*args: Any, **kwargs: Any) -> dict[str, Any]: + # Observe the real transport receipt and stream without changing its + # subprocess, native SDK, endpoint, cancellation, or return value. + observation: dict[str, Any] = {"frames": []} + observations.append(observation) + callback = kwargs.get("on_record") + + def on_record(record: dict[str, Any]) -> None: + observation["frames"].append(record) + if callback is not None: + callback(record) + + observed = run_oar_turn(*args, **{**kwargs, "on_record": on_record}) + observation["result"] = observed + return observed + + monkeypatch.setattr(bridge, "run_oar_turn", observe_transport) + failures: list[Exception] = [] + events: list[str] = [] + + def start() -> None: + try: + adapter.start_turn("stalled fixture request", lambda kind, _payload: events.append(kind)) + except Exception as exc: + failures.append(exc) + + caller = threading.Thread(target=start, daemon=True) + caller.start() + try: + assert received.wait(15), "the actual SDK did not reach the local model fixture" + slot = adapter._slot + assert slot is not None and slot.thread is not None and slot.thread.is_alive() + adapter.interrupt_turn() + caller.join(timeout=10) + assert not caller.is_alive(), "interrupt did not release the Chat caller" + assert not slot.thread.is_alive(), "the native segment worker is still running" + assert disconnected.wait(1), "the native SDK left its model request alive" + assert execution_host_drain(record_path) == HOST_PROCESS_DRAINED + observed = observations[0]["result"] + assert observed["outcome"] == "cancelled" + assert observed["oar_result"]["cleanupConfirmed"] is True + assert observed["final_response"] is None + assert len(failures) == 1 and isinstance(failures[0], CodexChatAgentError) + assert failures[0].error_code == MANAGED_HOST_CHAT_FAILED + assert adapter.history == [] + assert not any(kind.startswith("answer.") for kind in events) + cancelled_frame_count = len(observations[0]["frames"]) + + # A new real segment proves the slot was released. The cancelled + # stream remains closed and cannot add late text or native events. + try: + response = adapter.start_turn("fresh fixture request", lambda kind, _payload: events.append(kind)) + except CodexChatAgentError: + native_errors = [ + row["frame"]["body"].get("native") + for row in observations[-1]["frames"] + if row.get("type") == "native" + and row.get("frame", {}).get("body", {}).get("type") == "dsh/error" + ] + pytest.fail(f"the next native segment failed: {native_errors}") + assert response["message"] == "fresh fixture answer" + assert [row["content"] for row in adapter.history] == ["fresh fixture request", "fresh fixture answer"] + assert len(requests) == 2 + assert len(observations[0]["frames"]) == cancelled_frame_count + assert observations[0]["result"]["session_id"] != observations[1]["result"]["session_id"] + assert adapter.upstream_thread_id == chat_binding_id + assert execution_host_drain(record_path) == HOST_PROCESS_DRAINED + finally: + adapter.close_session() + caller.join(timeout=10) + shutdown.set() + server.shutdown() + server.server_close() + serving.join(timeout=2) diff --git a/tests/test_oar_pi_host.py b/tests/test_oar_pi_host.py new file mode 100644 index 0000000000..90e1770be4 --- /dev/null +++ b/tests/test_oar_pi_host.py @@ -0,0 +1,236 @@ +"""Signed Pi host boundary and the actual bundled OAR/Pi provider path.""" +from __future__ import annotations + +from hashlib import sha256 +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from io import StringIO +import json +from pathlib import Path +import subprocess +import sys +from threading import Thread + +import pytest + +from loopx.control_plane.quota.turn_envelope import turn_envelope_action_signature_document +from loopx.control_plane.turn_driver.host_failure import BuiltInHostError +from loopx.hosts.oar import __main__ as cli +from loopx.hosts.oar import installation, turn + + +def signed_request() -> dict: + envelope = { + "schema_version": "loopx_turn_envelope_v0", + "goal_id": "fixture-goal", + "agent_id": "fixture-agent", + "action": {"primary_action": "Inspect the fixture without changing any files.", "must_attempt": True}, + "required_reads": [], + "boundary": {"write_scope": [], "workspace_guard": {"status": "ready", "action": "continue"}}, + "action_signature": {"schema_version": "loopx_action_signature_v0", "matches": True}, + } + digest = "sha256:" + sha256(json.dumps( + turn_envelope_action_signature_document(envelope), ensure_ascii=False, + sort_keys=True, separators=(",", ":"), + ).encode()).hexdigest() + envelope["action_signature"].update(source_hash=digest, envelope_hash=digest) + return { + "schema_version": "loopx_turn_host_request_v0", + "turn_key": "sha256:" + "1" * 64, + "route": "primary_delivery", + "session": {"goal_id": "fixture-goal", "agent_id": "fixture-agent"}, + "turn_envelope": envelope, + "result_contract": {"schema_version": "loopx_turn_result_v0", "completed_phases": ["host_execute", "typed_result"]}, + } + + +def candidate() -> dict: + return { + "result_kind": "wait", "classification": "fixture_observed", + "next_action": "Wait for the owner's next instruction.", + "vision_unchanged_reason": "The fixture requires no change.", + } + + +def successful_observation(text: str) -> dict: + return { + "outcome": "exited", "returncode": 0, "output_complete": True, + "turn_outcome": {"kind": "completed"}, "final_response": text, + } + + +def test_signed_host_preserves_authority_and_uses_native_pi_options(tmp_path, monkeypatch): + calls = [] + + def run(request, **options): + calls.append((request, options)) + return successful_observation(json.dumps(candidate())) + + monkeypatch.setattr(turn, "run_oar_turn", run) + request = signed_request() + result = turn.run_oar_host(request, config=turn.OarTurnHostConfig( + cwd=tmp_path, model="fixture/text", effort="off", timeout_seconds=12, + )) + assert result["schema_version"] == "loopx_turn_result_v0" + assert result["turn_key"] == request["turn_key"] + assert result["result_kind"] == "wait" + native, options = calls[0] + assert native["runtime"] == "pi" + assert native["model"] == "fixture/text" + assert native["effort"] == "off" + assert "Inspect the fixture without changing any files." in native["prompt"] + assert '"write_scope":[]' in native["prompt"] + assert native["env"]["LOOPX_TURN_GOAL_ID"] == "fixture-goal" + assert options["project"] == tmp_path + assert options["timeout_seconds"] == 12 + assert "outputSchema" not in native, "Pi refuses schema enforcement; the shared host parser owns its candidate" + assert "command" not in native + assert "sandbox" not in native, "this explicit native-tool host makes no OS sandbox claim" + + +def test_tampered_or_unsupported_requests_never_launch_pi(tmp_path, monkeypatch): + monkeypatch.setattr(turn, "run_oar_turn", lambda *_args, **_kwargs: pytest.fail("must not launch")) + request = signed_request() + request["turn_envelope"]["action"]["primary_action"] = "tampered action" + with pytest.raises(BuiltInHostError, match="authority_rejected"): + turn.run_oar_host(request, config=turn.OarTurnHostConfig(cwd=tmp_path)) + with pytest.raises(BuiltInHostError, match="schema_mismatch"): + turn.run_oar_host([], config=turn.OarTurnHostConfig(cwd=tmp_path)) + with pytest.raises(BuiltInHostError, match="runtime_rejected"): + turn.run_oar_host(signed_request(), config=turn.OarTurnHostConfig(cwd=tmp_path, runtime="codex")) + + +@pytest.mark.parametrize("outcome,complete,kind", [ + ({"kind": "failed", "failure": "auth", "reason": "credential-private"}, True, "auth_failed"), + ({"kind": "aborted"}, True, "unknown"), + ({"kind": "completed"}, False, "transport_lost"), +]) +def test_native_failure_or_incomplete_output_cannot_become_material_success(tmp_path, monkeypatch, outcome, complete, kind): + observed = { + **successful_observation(json.dumps({"result_kind": "validated_progress", "classification": "untrusted"})), + "turn_outcome": outcome, "output_complete": complete, + } + monkeypatch.setattr(turn, "run_oar_turn", lambda *_args, **_kwargs: observed) + with pytest.raises(BuiltInHostError) as failure: + turn.run_oar_host(signed_request(), config=turn.OarTurnHostConfig(cwd=tmp_path)) + assert failure.value.failure_kind == kind + assert "credential-private" not in str(failure.value) + + +def test_untyped_final_answer_uses_shared_nonmaterial_fallback(tmp_path, monkeypatch): + monkeypatch.setattr(turn, "run_oar_turn", lambda *_args, **_kwargs: successful_observation("plain text")) + result = turn.run_oar_host(signed_request(), config=turn.OarTurnHostConfig(cwd=tmp_path)) + assert result["result_kind"] == "wait" + assert result["classification"] == "no_typed_host_result" + + +def test_cli_missing_dependency_does_not_echo_native_credentials(tmp_path, monkeypatch, capsys): + def unavailable(*_args, **_kwargs): + raise RuntimeError("Missing module, provider supplied credential-private") + + monkeypatch.setattr(turn, "run_oar_turn", unavailable) + monkeypatch.setattr(sys, "stdin", StringIO(json.dumps(signed_request()))) + assert cli.main(["turn", "--runtime", "pi", "--cwd", str(tmp_path)]) == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert "credential-private" not in captured.err + assert "loopx.hosts.oar status" in captured.err + + +def test_cli_status_and_install_dispatch_are_explicit(monkeypatch, capsys): + calls = [] + monkeypatch.setattr(installation, "inspect_installation", lambda: {"ready": False, "reason": "not installed"}) + monkeypatch.setattr(installation, "install", lambda: calls.append("install") or {"ready": True}) + assert cli.main(["status"]) == 1 + assert calls == [] + assert json.loads(capsys.readouterr().out)["ready"] is False + assert cli.main(["install"]) == 0 + assert calls == ["install"] + assert json.loads(capsys.readouterr().out)["ready"] is True + + +@pytest.mark.parametrize("response_status", [200, 401]) +def test_real_pi_sdk_and_packaged_host_cli_against_local_provider(tmp_path, response_status): + """Exercise Python -> Node -> OAR -> native Pi RPC -> HTTP and back.""" + try: + installation.node_executable() + installation.runtime_directory() + except RuntimeError as exc: + pytest.skip(f"Optional pinned OAR runtime is required for this integration: {exc}") + seen = [] + + class Provider(BaseHTTPRequestHandler): + def log_message(self, *_args): + pass + + def do_POST(self): + payload = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + seen.append(payload) + self.send_response(response_status) + if response_status != 200: + body = json.dumps({"error": {"message": "synthetic-key-refused", "type": "invalid_api_key"}}).encode() + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for choices, usage in [ + ([{"index": 0, "delta": {"role": "assistant", "content": json.dumps(candidate())}, "finish_reason": None}], None), + ([{"index": 0, "delta": {}, "finish_reason": "stop"}], {"prompt_tokens": 8, "completion_tokens": 12, "total_tokens": 20}), + ]: + chunk = {"id": "fixture-response", "object": "chat.completion.chunk", "created": 1, + "model": "fixture-text", "choices": choices} + if usage is not None: + chunk["usage"] = usage + self.wfile.write(("data: " + json.dumps(chunk) + "\n\n").encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + server = ThreadingHTTPServer(("127.0.0.1", 0), Provider) + serving = Thread(target=server.serve_forever, daemon=True) + serving.start() + agent_dir = tmp_path / "pi-agent" + agent_dir.mkdir() + workspace = tmp_path / "workspace" + workspace.mkdir() + (agent_dir / "models.json").write_text(json.dumps({"providers": {"loopx-fixture": { + "baseUrl": f"http://127.0.0.1:{server.server_port}/v1", "api": "openai-completions", + "apiKey": "synthetic-key-refused", "models": [{ + "id": "fixture-text", "name": "Fixture Text", "reasoning": False, "input": ["text"], + "contextWindow": 128_000, "maxTokens": 2048, + "cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0}, + }], + }}}), encoding="utf-8") + (agent_dir / "settings.json").write_text(json.dumps({"retry": {"enabled": False}}), encoding="utf-8") + import os + + environment = {**os.environ, "PI_CODING_AGENT_DIR": str(agent_dir), "OAR_PI_AGENT_DIR": str(agent_dir)} + try: + completed = subprocess.run( + [sys.executable, "-m", "loopx.hosts.oar", "turn", "--runtime", "pi", + "--model", "loopx-fixture/fixture-text", "--effort", "off", "--cwd", str(workspace), "--timeout-seconds", "30"], + input=json.dumps(signed_request()), text=True, encoding="utf-8", capture_output=True, + env=environment, cwd=Path(__file__).resolve().parents[1], timeout=40, check=False, + ) + finally: + server.shutdown() + server.server_close() + serving.join(timeout=2) + assert seen, completed.stderr + assert seen[0]["model"] == "fixture-text" + assert "Inspect the fixture without changing any files." in json.dumps(seen[0]["messages"]) + assert "synthetic-key-refused" not in completed.stdout + completed.stderr + if response_status == 200: + assert completed.returncode == 0, completed.stderr + result = json.loads(completed.stdout) + assert result["schema_version"] == "loopx_turn_result_v0" + assert result["turn_key"] == signed_request()["turn_key"] + assert result["classification"] == "fixture_observed" + assert result["result_kind"] == "wait" + else: + assert completed.returncode == 1 + assert completed.stdout == "" + # OAR owns the native failure classification; custom providers can + # report unknown. The host must stop without guessing from error prose. + assert "oar_pi_execution_failed" in completed.stderr diff --git a/tests/test_oar_readiness.py b/tests/test_oar_readiness.py new file mode 100644 index 0000000000..fca01b4185 --- /dev/null +++ b/tests/test_oar_readiness.py @@ -0,0 +1,192 @@ +"""Optional OAR dependencies qualify only the hosts that actually use them.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from loopx.chat_agent import agent_endpoint_error +from loopx.chat_endpoint_catalog import builtin_chat_endpoints +from loopx.control_plane.runtime import node_probe +from loopx.control_plane.runtime.node_probe import probe_node +from loopx.control_plane.turn_driver.host_binding import ( + OAR_RUNTIME_UNAVAILABLE, + REMEDY_CONFIGURE_OAR_RUNTIME, + managed_executor_binding, + managed_executor_binding_from_host_args, + managed_executor_unavailable_payload, +) +from loopx.hosts.oar import installation + + +def _missing_oar() -> dict: + return { + "ready": False, + "oar_version": "fixture-version", + "node": "/private/fixture/node", + "reason": "private diagnostic fixture-credential", + } + + +@pytest.mark.parametrize("host", ["codex-cli", "dsh"]) +def test_bounded_hosts_fail_closed_on_the_shared_dependency(host: str) -> None: + binding = managed_executor_binding( + host, + environ={"DEEPSEEK_API_KEY": "fixture-credential"}, + module_probe=lambda _name: True, + oar_probe=_missing_oar, + ) + assert binding["available"] is False + assert binding["unavailable_reason"] == OAR_RUNTIME_UNAVAILABLE + assert binding["unavailable_remediation"] == [REMEDY_CONFIGURE_OAR_RUNTIME] + assert binding["oar_runtime"] == { + "ready": False, + "oar_version": "fixture-version", + "unavailable_reason": OAR_RUNTIME_UNAVAILABLE, + } + refusal = managed_executor_unavailable_payload( + {"managed_executor": binding}, execute=True, host_projection={"kind": host} + ) + assert refusal is not None + assert refusal["remediation"] == [REMEDY_CONFIGURE_OAR_RUNTIME] + assert refusal["remediation_env_vars"] == [] + assert refusal["remediation_host"] is None + serialized = json.dumps([binding, refusal]) + assert "fixture-credential" not in serialized + assert "/private/" not in serialized + + +@pytest.mark.parametrize( + ("host", "options"), + [ + ("generic-cli", {}), + ("claude-code", {}), + ("dsh", {"dsh_runner_configured": True}), + ("codex-cli", {"codex_operation_tools": True}), + ("dsh", {"dsh_operation_module_configured": True}), + ], +) +def test_other_transports_do_not_probe_the_optional_dependency( + monkeypatch: pytest.MonkeyPatch, host: str, options: dict, +) -> None: + def forbidden() -> dict: + raise AssertionError("this transport does not use OAR") + + monkeypatch.setattr( + "loopx.control_plane.effect_runtime.effect_runtime_result", + lambda _operation, _params: {"transport": {}, "reason": None}, + ) + binding = managed_executor_binding( + host, environ={"DEEPSEEK_API_KEY": "fixture-credential"}, + module_probe=lambda _name: True, oar_probe=forbidden, + model="fixture-model", reasoning_effort="high", **options, + ) + assert "oar_runtime" not in binding + assert binding["available"] is not False + + +def test_explicit_runner_argv_preserves_its_own_launch_contract() -> None: + def forbidden() -> dict: + raise AssertionError("an explicit DSH runner has no OAR dependency") + + binding = managed_executor_binding_from_host_args( + ["--host", "dsh", "--dsh-runner", "fixture:run"], + environ={}, module_probe=lambda _name: False, oar_probe=forbidden, + ) + assert binding["available"] is True + assert binding["runtime_probe"]["scope"] == "configured_runner" + assert "oar_runtime" not in binding + + +def test_node22_remains_a_core_runtime_but_cannot_launch_oar( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(node_probe.shutil, "which", lambda _name: "fixture-node") + monkeypatch.setattr( + node_probe.subprocess, "run", + lambda command, **_kwargs: subprocess.CompletedProcess(command, 0, "v22.22.3\n", ""), + ) + assert probe_node().ready is True + observed = installation.inspect_installation() + assert observed["ready"] is False + assert "24" in observed["reason"] + bounded = managed_executor_binding("codex-cli", environ={}) + assert bounded["unavailable_reason"] == OAR_RUNTIME_UNAVAILABLE + assert managed_executor_binding("generic-cli", environ={})["available"] is None + + +def test_unrelated_imports_and_native_codex_chat_never_load_oar() -> None: + checked = subprocess.run( + [sys.executable, "-c", """ +import sys +from loopx.control_plane.turn_driver.host_binding import managed_executor_binding +from loopx.chat_manager import manager_channel_binding +assert managed_executor_binding('generic-cli')['available'] is None +assert managed_executor_binding('claude-code')['available'] is None +assert manager_channel_binding({'LOOPX_MANAGER_ENDPOINT': 'codex'})['available'] is None +assert not any(name.startswith('loopx.hosts.oar') for name in sys.modules) +"""], + capture_output=True, text=True, check=False, + ) + assert checked.returncode == 0, checked.stderr + + +def test_endpoint_catalog_keeps_native_codex_available_and_repairs_managed_oar( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + monkeypatch.setattr(installation, "inspect_installation", _missing_oar) + monkeypatch.setattr( + "loopx.control_plane.turn_driver.host_binding.dsh_runtime_importable", + lambda _probe=None: True, + ) + monkeypatch.setattr( + "loopx.chat_endpoint_catalog.operator_provider_environ", + lambda _root: {"DEEPSEEK_API_KEY": "fixture-credential"}, + ) + rows = {row["agent_id"]: row for row in builtin_chat_endpoints( + codex_bin=sys.executable, claude_bin="missing-fixture", kiro_cli_bin="missing-fixture", + runtime_root=tmp_path, + )} + assert rows["codex"]["available"] is True + assert "oar_runtime" not in rows["codex"] + assert rows["dsh"]["available"] is False + assert rows["dsh"]["unavailable_reason"] == OAR_RUNTIME_UNAVAILABLE + assert rows["dsh"]["oar_runtime"]["ready"] is False + assert rows["dsh"]["interrupt"] is True + error = agent_endpoint_error("dsh", reason=OAR_RUNTIME_UNAVAILABLE) + assert error.error_code == "agent_endpoint_unavailable" + assert "python -m loopx.hosts.oar install" in error.gate["next_action"] + assert "python -m loopx.hosts.oar status" in error.gate["next_action"] + + +def test_doctor_reports_oar_as_optional_without_blocking_core( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + from loopx import doctor + + monkeypatch.setattr(Path, "home", lambda: tmp_path) + monkeypatch.setattr(installation, "inspect_installation", lambda: { + "ready": False, "oar_version": "fixture-version", + "reason": "OAR requires Node.js 24+", + "next_action": "Run `python -m loopx.hosts.oar install`.", + }) + monkeypatch.setattr( + "loopx.control_plane.effect_runtime.collect_effect_runtime_readiness", + lambda *, deep: {"ready": True, "status": "ready", "detected_node_version": "22.22.3"}, + ) + result = doctor.collect_doctor( + registry_path=tmp_path / "registry.json", + runtime_root_override=str(tmp_path / "runtime"), + ) + check = next(row for row in result["checks"] if row["id"] == "oar_runtime_ready") + assert check["required"] is False + assert check["ok"] is False + assert result["typescript_control_plane"]["ready"] is True + markdown = doctor.render_doctor_markdown(result) + assert "## Optional OAR Host Runtime" in markdown + assert "python -m loopx.hosts.oar install" in markdown + assert "Core commands and native Codex Chat do not require this dependency." in markdown diff --git a/tests/test_oar_transport.py b/tests/test_oar_transport.py new file mode 100644 index 0000000000..7f792845eb --- /dev/null +++ b/tests/test_oar_transport.py @@ -0,0 +1,324 @@ +"""Private OAR transport invariants, exercised through the existing Host owner.""" +from __future__ import annotations + +import json +import sys +import threading +from pathlib import Path +from typing import Any + +import pytest + +from loopx.control_plane.effect_runtime import _node_executable +from loopx.control_plane.turn_driver.host_failure import BuiltInHostError +from loopx.control_plane.turn_driver.host_process_transport import run_host_process +from loopx.dsh_goal_mode import turn_host_adapter +from loopx.hosts.oar import bridge, installation + + +@pytest.fixture +def framed_runtime(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Run actual Node/stdin/stdout/process cleanup without installing OAR.""" + monkeypatch.setattr(bridge, "node_executable", _node_executable) + monkeypatch.setattr(bridge, "runtime_directory", lambda: tmp_path) + + def run(records: list[Any], *, exit_code: int = 0, timeout_seconds: float = 5) -> dict[str, Any]: + (tmp_path / "records.json").write_text(json.dumps(records), encoding="utf-8") + (tmp_path / "stdio.ts").write_text( + "import {readFileSync} from 'node:fs';" + "process.stdin.resume();process.stdin.on('end',()=>{" + "const records=JSON.parse(readFileSync(new URL('./records.json',import.meta.url),'utf8'));" + "for(const record of records) process.stdout.write(JSON.stringify(record)+'\\n');" + f"process.exitCode={exit_code};" + "});", + encoding="utf-8", + ) + return bridge.run_oar_turn( + {"runtime": "dsh", "cwd": str(tmp_path), "prompt": "fixture", "resume": "native-session"}, + project=tmp_path, timeout_seconds=timeout_seconds, + ) + + return run + + +@pytest.fixture +def production_stdio(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Use the shipped writer and Python reader around a deterministic owner.""" + source = Path(bridge.__file__).with_name("stdio.ts").read_text(encoding="utf-8") + (tmp_path / "stdio.ts").write_text(source, encoding="utf-8") + (tmp_path / "runtime.ts").write_text( + "import {readFileSync} from 'node:fs';" + "export async function runOarTurn(config,prompt,{onRecord}){" + "const records=JSON.parse(readFileSync(new URL('./records.json',import.meta.url),'utf8'));" + "for(const record of records) onRecord(record);" + "process.stderr.write(readFileSync(new URL('./stderr.txt',import.meta.url)));" + "return records.findLast(record=>record.type==='result').result;}", + encoding="utf-8", + ) + monkeypatch.setattr(bridge, "node_executable", _node_executable) + monkeypatch.setattr(bridge, "runtime_directory", lambda: tmp_path) + + def run(records: list[dict[str, Any]], *, stderr: str = ""): + (tmp_path / "records.json").write_text(json.dumps(records, ensure_ascii=False), encoding="utf-8") + (tmp_path / "stderr.txt").write_text(stderr, encoding="utf-8") + received: list[dict[str, Any]] = [] + diagnostics: list[str] = [] + result = bridge.run_oar_turn( + {"runtime": "dsh", "cwd": str(tmp_path), "prompt": "fixture", "resume": "native-session"}, + project=tmp_path, timeout_seconds=20, + on_record=received.append, on_stderr=diagnostics.append, + ) + return result, received, "".join(diagnostics) + + return run + + +def completed_records() -> list[dict[str, Any]]: + return [ + {"type": "ready", "runtime": "dsh", "sessionId": "native-session"}, + {"type": "result", "result": {"sessionId": "native-session", "text": "answer", + "outcome": {"kind": "completed"}, "usage": None, "contextUsage": None, "cleanupConfirmed": True}}, + ] + + +def test_oar_complete_result_requires_native_identity_and_clean_process(framed_runtime) -> None: + observed = framed_runtime(completed_records()) + assert observed["outcome"] == "exited" + assert observed["returncode"] == 0 + assert observed["output_complete"] is True + assert observed["final_response"] == "answer" + assert observed["session_id"] == "native-session" + + +def test_large_native_and_terminal_records_round_trip_identically(production_stdio) -> None: + native = {"type": "native", "frame": {"kind": "frame", "body": {"type": "fixture", "native": "__PAYLOAD__"}}} + offset = json.dumps(native, separators=(",", ":")).index("__PAYLOAD__") + # The first emoji straddles the writer's 64 Ki UTF-16 boundary. Escaped + # control characters also expand each wire fragment well beyond its data. + payload = "a" * (65_535 - offset) + "😀" + '\0\n"\\汉字' * 100_000 + native["frame"]["body"]["native"] = payload + records = completed_records() + records[-1]["result"]["text"] = payload + records[1:1] = [native, {"type": "event", "event": {"type": "text_delta", "delta": "between records"}}] + observed, received, _ = production_stdio(records) + assert observed["returncode"] == 0 + assert observed["output_complete"] is True + assert observed["session_id"] == "native-session" + assert observed["final_response"] == payload + assert received == records, "local fragments never replace or truncate the original OAR records" + + +def test_explicit_owner_exit_flushes_queued_native_stderr(production_stdio) -> None: + stderr = "d" * 1_048_577 + "\n" + observed, _, received_stderr = production_stdio(completed_records(), stderr=stderr) + assert observed["returncode"] == 0 + assert observed["output_complete"] is True + assert received_stderr == stderr + + +def fragment_record(record: dict[str, Any], *, sequence: int = 0) -> list[dict[str, Any]]: + serialized = json.dumps(record, separators=(",", ":")) + width = (len(serialized) + 2) // 3 + chunks = [serialized[start:start + width] for start in range(0, len(serialized), width)] + return [{"type": "fragment", "sequence": sequence, "index": index, + "last": index == len(chunks) - 1, "data": chunk} for index, chunk in enumerate(chunks)] + + +@pytest.mark.parametrize("mutation", [ + "missing_last", "missing_middle", "out_of_order", "duplicate", "interleaved", "wrong_sequence", + "reused_sequence", "bad_last", "bad_index", "bad_sequence", "empty_chunk", "extra_field", + "oversized_chunk", "invalid_json", "duplicate_json_fields", "nested_fragment", "isolated_surrogate", +]) +def test_malformed_or_incomplete_fragment_stream_fails_closed(framed_runtime, mutation: str) -> None: + records = completed_records() + fragments = fragment_record(records[-1]) + if mutation == "missing_last": + fragments.pop() + elif mutation == "missing_middle": + fragments.pop(1) + elif mutation == "out_of_order": + fragments[0], fragments[1] = fragments[1], fragments[0] + elif mutation == "duplicate": + fragments.insert(1, fragments[0]) + elif mutation == "interleaved": + fragments.insert(1, {"type": "event", "event": {"type": "text_delta", "delta": "interleaved"}}) + elif mutation == "wrong_sequence": + fragments[1]["sequence"] = 1 + elif mutation == "reused_sequence": + fragments[:0] = fragment_record({"type": "native", "frame": {"body": {"type": "fixture"}}}) + elif mutation == "bad_last": + fragments[-1]["last"] = 1 + elif mutation == "bad_index": + fragments[0]["index"] = False + elif mutation == "bad_sequence": + fragments[0]["sequence"] = False + elif mutation == "empty_chunk": + fragments[0]["data"] = "" + elif mutation == "extra_field": + fragments[0]["unexpected"] = True + elif mutation == "oversized_chunk": + fragments[0]["data"] = "x" * 65_537 + elif mutation == "invalid_json": + fragments[-1]["data"] = "broken JSON" + elif mutation == "duplicate_json_fields": + fragments = [{"type": "fragment", "sequence": 0, "index": 0, "last": True, + "data": '{"type":"native","type":"result"}'}] + elif mutation == "nested_fragment": + fragments = fragment_record(fragments[0]) + elif mutation == "isolated_surrogate": + fragments[0]["data"] = "\ud800" + observed = framed_runtime([records[0], *fragments]) + assert observed["returncode"] != 0 + assert observed["output_complete"] is False + assert observed["final_response"] is None + assert observed["session_id"] == "native-session" + + +def test_reassembled_record_cannot_exceed_32_mib(framed_runtime) -> None: + records = completed_records() + # Whitespace plus the valid terminal object would parse successfully if the + # total bound were only enforced per fragment or per line. + fragments = [{"type": "fragment", "sequence": 0, "index": index, "last": False, + "data": " " * 65_536} for index in range(512)] + fragments.append({"type": "fragment", "sequence": 0, "index": 512, "last": True, + "data": json.dumps(records[-1])}) + observed = framed_runtime([records[0], *fragments], timeout_seconds=20) + assert observed["returncode"] != 0 + assert observed["output_complete"] is False + assert observed["final_response"] is None + assert observed["session_id"] == "native-session" + + +def test_owner_refuses_oversized_record_without_truncating_it(production_stdio) -> None: + records = completed_records() + records[-1]["result"]["text"] = "x" * (32 * 1024 * 1024) + observed, received, stderr = production_stdio(records) + assert observed["returncode"] != 0 + assert observed["output_complete"] is False + assert observed["final_response"] is None + assert received == records[:1] + assert "OAR runtime request failed" in stderr + + +@pytest.mark.parametrize("mutation", ["missing_ready", "missing_identity", "mismatch", "duplicate", "bad_frame", "no_result", "failure_exit", "cleanup_unconfirmed"]) +def test_oar_incomplete_or_contradictory_transport_never_commits_an_answer(framed_runtime, mutation: str) -> None: + records = completed_records() + if mutation == "missing_ready": + records.pop(0) + elif mutation == "missing_identity": + records[-1]["result"].pop("sessionId") + elif mutation == "mismatch": + records[-1]["result"]["sessionId"] = "different-session" + elif mutation == "duplicate": + records.append(records[-1]) + elif mutation == "bad_frame": + records.insert(1, {"type": "not-an-oar-frame"}) + elif mutation == "no_result": + records.pop() + elif mutation == "cleanup_unconfirmed": + records[-1]["result"].pop("cleanupConfirmed") + observed = framed_runtime(records, exit_code=9 if mutation == "failure_exit" else 0) + assert observed["returncode"] != 0 + assert observed["final_response"] is None + if mutation != "missing_ready": + assert observed["session_id"] == "native-session", "failed turns retain the native resume binding" + + +def test_oar_cancellation_retains_observed_session_and_waits_for_cleanup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(bridge, "node_executable", _node_executable) + monkeypatch.setattr(bridge, "runtime_directory", lambda: tmp_path) + marker = tmp_path / "stopped" + (tmp_path / "stdio.ts").write_text( + "import {writeFileSync} from 'node:fs';" + "process.on('SIGTERM',()=>{" + f"writeFileSync({json.dumps(str(marker))},'stopped');" + "process.stdout.write(JSON.stringify({type:'result',result:{sessionId:'native-session'," + "text:'',outcome:{kind:'aborted'},usage:null,contextUsage:null,cleanupConfirmed:true}})+'\\n',()=>process.exit(0));" + "});process.stdin.resume();process.stdin.on('end',()=>{" + "process.stdout.write(JSON.stringify({type:'ready',runtime:'dsh',sessionId:'native-session'})+'\\n');" + "setInterval(()=>{},1000);});", + encoding="utf-8", + ) + cancelled = threading.Event() + observed = bridge.run_oar_turn( + {"runtime": "dsh", "cwd": str(tmp_path), "prompt": "fixture", "resume": "native-session"}, + project=tmp_path, timeout_seconds=5, cancel_event=cancelled, + on_record=lambda record: cancelled.set() if record["type"] == "ready" else None, + ) + assert observed["outcome"] == "cancelled" + assert observed["session_id"] == "native-session" + assert observed["final_response"] is None + assert marker.read_text() == "stopped" + + +def test_cancelled_before_launch_does_not_start_a_host(tmp_path: Path) -> None: + cancelled = threading.Event() + cancelled.set() + marker = tmp_path / "unexpected-launch" + observed = run_host_process( + [sys.executable, "-c", f"from pathlib import Path;Path({str(marker)!r}).write_text('ran')"], + project=tmp_path, input_text="", timeout_seconds=5, cancel_event=cancelled, + track_descendants=True, + ) + assert observed["outcome"] == "cancelled" + assert observed["returncode"] is None + assert not marker.exists() + + +def test_runtime_lookup_never_installs_dependencies_and_rejects_tampered_cache(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + source = tmp_path / "source" + source.mkdir() + for filename in ("package.json", "package-lock.json", "stdio.ts"): + (source / filename).write_text("{}") + monkeypatch.setattr(installation, "_SOURCE", source) + monkeypatch.setenv("LOOPX_OAR_HOME", str(tmp_path / "cache")) + monkeypatch.setattr(installation, "install", lambda: pytest.fail("runtime lookup must not install")) + with pytest.raises(RuntimeError, match="dependency is unavailable"): + installation.runtime_directory() + cached = installation._cache_directory() + manifest = cached / "node_modules/@botiverse/oar/package.json" + manifest.parent.mkdir(parents=True) + manifest.write_text(json.dumps({"version": installation.OAR_VERSION})) + for asset in installation._assets(): + (cached / asset.name).write_bytes(asset.read_bytes()) + assert installation.runtime_directory() == cached + (cached / "stdio.ts").write_text("tampered source") + with pytest.raises(RuntimeError, match="dependency is unavailable"): + installation.runtime_directory() + + +def dsh_call(tmp_path: Path) -> dict[str, Any]: + return {"prompt": "continue", "session_id": "native-session", "workspace": tmp_path, + "session_root": tmp_path / "sessions", "provider": "fixture-provider", "model": "fixture-model", + "reasoning_effort": "high", "max_tokens": 256, "cordis": None, + "runtime_bin": None, "request_timeout_seconds": 30, + "env": {"DSH_PERMISSION_MODE": "read-only"}} + + +@pytest.mark.parametrize("finish_reason", ["completed", "error", "max-tokens", "aborted", "unexpected"]) +def test_dsh_native_terminal_facts_survive_without_accepting_aborted_work( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, finish_reason: str, +) -> None: + captured: list[dict[str, Any]] = [] + native = {"final_response": '{"result_kind":"completed"}', "finish_reason": finish_reason, + "events": [{"type": "turn/end", "data": {"reason": {"kind": finish_reason}}}]} + + def run(request, **kwargs): + captured.append(request) + kwargs["on_record"]({"type": "native", "frame": {"kind": "frame", "body": {"type": "dsh/result", "native": native}}}) + return {"outcome": "exited", "returncode": 0 if finish_reason == "completed" else 1, + "output_complete": True, "oar_result": {}, + "turn_outcome": {"kind": "completed" if finish_reason == "completed" else "aborted" if finish_reason == "aborted" else "failed"}} + + monkeypatch.setattr(bridge, "run_oar_turn", run) + if finish_reason in {"aborted", "unexpected"}: + with pytest.raises((BuiltInHostError, turn_host_adapter.DshHostResultError)): + turn_host_adapter.run_dsh_turn(**dsh_call(tmp_path)) + else: + assert turn_host_adapter.run_dsh_turn(**dsh_call(tmp_path)) == native + request = captured[0] + assert request["resume"] == "native-session" + assert request["dsh"]["python_executable"] == sys.executable + assert Path(request["dsh"]["worker_path"]).name == "dsh_worker.py" + assert request["env"]["DSH_PERMISSION_MODE"] == "read-only" + assert request["effort"] == "high" diff --git a/tests/test_turn_managed_executor_binding.py b/tests/test_turn_managed_executor_binding.py index 6e9e643934..80101a0bf9 100644 --- a/tests/test_turn_managed_executor_binding.py +++ b/tests/test_turn_managed_executor_binding.py @@ -46,6 +46,14 @@ _RUNTIME = lambda _module: True # noqa: E731 - tiny probe fixture +@pytest.fixture(autouse=True) +def ready_optional_oar(monkeypatch): + monkeypatch.setattr( + "loopx.hosts.oar.installation.inspect_installation", + lambda: {"ready": True, "oar_version": "fixture-version"}, + ) + + def test_trusted_host_args_project_the_same_last_explicit_profile_without_raw_argv(): binding = managed_executor_binding_from_host_args( [ @@ -242,6 +250,10 @@ def test_managed_executor_reports_the_operator_credential_and_endpoint(): "scope": RUNTIME_PROBE_SCOPE_INTERPRETER, "available": True, }, + "oar_runtime": { + "ready": True, + "oar_version": "fixture-version", + }, }