diff --git a/.github/workflows/agentic.yml b/.github/workflows/agentic.yml new file mode 100644 index 0000000..b8d374c --- /dev/null +++ b/.github/workflows/agentic.yml @@ -0,0 +1,83 @@ +name: Agentic forecaster + +on: + pull_request: + branches: [main] + paths: + - "utils/llm/**" + - "tests/unit/agentic/**" + - "tests/conftest.py" + - "pyproject.toml" + - "scripts/check_agentic_wheel.py" + - ".github/workflows/agentic.yml" + push: + branches: [main] + paths: + - "utils/llm/**" + - "tests/unit/agentic/**" + - "tests/conftest.py" + - "pyproject.toml" + - "scripts/check_agentic_wheel.py" + - ".github/workflows/agentic.yml" + workflow_dispatch: + +permissions: + contents: read + +jobs: + offline: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.13"] + env: + FRI_REQUIRE_PI: "1" + PI_OFFLINE: "1" + PI_TELEMETRY: "0" + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + + - uses: actions/setup-node@v6 + with: + node-version: "22.19.0" + + - name: Build and install the wheel + run: | + python -m pip install build + python -m build --wheel --outdir "$RUNNER_TEMP/wheels" + python -m venv "$RUNNER_TEMP/wheel-venv" + "$RUNNER_TEMP/wheel-venv/bin/python" -m pip install "$RUNNER_TEMP"/wheels/*.whl + + - name: Install pi from the packaged lockfile + working-directory: ${{ runner.temp }} + run: | + unset PYTHONPATH + "$RUNNER_TEMP/wheel-venv/bin/python" - <<'PY' + import os + from importlib.resources import files + from pathlib import Path + + runtime = Path(os.environ["RUNNER_TEMP"]) / "pi-runtime" + runtime.mkdir() + resources = files("utils.llm.agentic") + for name in ("package.json", "package-lock.json"): + (runtime / name).write_bytes(resources.joinpath(name).read_bytes()) + PY + npm ci --prefix "$RUNNER_TEMP/pi-runtime" --no-audit --no-fund + echo "FORECASTER_PI=$RUNNER_TEMP/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" >> "$GITHUB_ENV" + + - name: Check the installed wheel outside the checkout + working-directory: ${{ runner.temp }} + run: | + unset PYTHONPATH + "$RUNNER_TEMP/wheel-venv/bin/python" "$GITHUB_WORKSPACE/scripts/check_agentic_wheel.py" + + - name: Run offline agentic tests + run: | + python -m pip install ".[dev]" + python -m pytest tests/unit/agentic diff --git a/.gitignore b/.gitignore index 48fd99f..533d8b5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,8 @@ plotly_charts *.html *.json +!utils/llm/agentic/package.json +!utils/llm/agentic/package-lock.json *.csv - __pycache__/ @@ -27,6 +29,7 @@ pip-delete-this-directory.txt venv/ ENV/ .worktrees/ +node_modules/ # Emacs backup files *~ @@ -60,6 +63,7 @@ Thumbs.db .coverage htmlcov/ # Build artifacts +agentic-run/ build/ *.egg-info/ dist/ diff --git a/README.md b/README.md index dde1632..b1c1b0a 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,10 @@ uv add fri-utils # Quickstart +Agentic forecasting is available through the explicit `utils.llm.agentic.run` API. +See [Agentic forecasting](docs/agentic.md) for runtime setup, API keys, and a bounded example. +Existing `ModelRun.get_response()` calls remain one-shot. + ## LLM Text Output Shared `ModelRun` objects are the primary surface for LLM calls. A model run is an exact base model diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 23e6c4a..223b8f7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,37 @@ # Third-Party Notices -This repository includes normalized metadata derived from third-party sources. +This repository includes code and normalized metadata derived from the sources below. + +## AIRO forecaster + +The agentic forecasting harness in `utils/llm/agentic` is adapted from +[`forecastingresearch/airo`](https://github.com/forecastingresearch/airo/tree/3ece40dfa87961c4fb03f826d007ed4e3d42b1f9/packages/fri-forecaster) +at commit `3ece40dfa87961c4fb03f826d007ed4e3d42b1f9`. The source is licensed under MIT: + +```text +MIT License + +Copyright (c) 2026 Forecasting Research Institute + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included +in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` ## Models.dev diff --git a/docs/agentic.md b/docs/agentic.md new file mode 100644 index 0000000..a549092 --- /dev/null +++ b/docs/agentic.md @@ -0,0 +1,165 @@ +# Agentic forecasting + +`utils.llm.agentic.run` runs AIRO's forecasting harness with web research and +structured submissions. Callers supply the prompt and forecast schema. Existing +`ModelRun.get_response()` calls remain one-shot; importing this module starts no work. + +This API does not register model runs or change AIRO, ForecastBench, or DWH callers. +Before publishing benchmark results, give the full agent configuration a distinct +identity. An existing one-shot `model_run_key` does not identify an agentic run. + +## Runtime + +Install `fri-utils` in your Python environment. Agentic calls also require +Node.js 22.19 or newer and pi 0.99.2. Install the packaged, locked runtime outside +the Python package: + +```bash +export FORECASTER_RUNTIME="$HOME/.cache/fri-utils/pi-0.99.2" +python - <<'PY' +import os +from importlib.resources import files +from pathlib import Path + +runtime = Path(os.environ["FORECASTER_RUNTIME"]) +runtime.mkdir(parents=True, exist_ok=True) +resources = files("utils.llm.agentic") +for name in ("package.json", "package-lock.json"): + (runtime / name).write_bytes(resources.joinpath(name).read_bytes()) +PY +npm ci --prefix "$FORECASTER_RUNTIME" +export FORECASTER_PI="$FORECASTER_RUNTIME/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" +node "$FORECASTER_PI" --version +``` + +The harness checks pi's version before each run. The `pi=` argument takes precedence +over `env["FORECASTER_PI"]`, then the process's `FORECASTER_PI`. Set an absolute path; +the harness does not select a pi installation from `PATH`. + +## Keys + +Set `FORECASTER_MODEL` to the exact `provider/model-id` supported by pi and your +API account. This is a pi identifier, not a `model_run_key` or slug. Provide that +provider's key and `TAVILY_API_KEY` when using `web_search` or `read_page`. + +| Provider prefix | Environment variable | +| --- | --- | +| `openai` | `OPENAI_API_KEY` | +| `anthropic` | `ANTHROPIC_API_KEY` | +| `google` | `GEMINI_API_KEY` | +| `xai` | `XAI_API_KEY` | +| `openrouter` | `OPENROUTER_API_KEY` | +| `deepinfra` | `DEEPINFRA_API_KEY` | + +The harness reads the process environment plus explicit `env={...}` overrides. +It does not search for `.env` files. For local use, put the selected model and keys +in a git-ignored `.env`, then call `load_dotenv(".env", override=False)` as below. +Existing environment values take precedence over that file. Deployment jobs should +inject secrets under the same environment names. + +`configure_api_keys(from_gcp=True)` configures Python clients in memory. It does not +pass keys to pi. For example, inject GCP secret `API_KEY_OPENAI` as `OPENAI_API_KEY` +in the job environment. Do not put key values in command-line arguments, logs, or source control. + +Calls use API billing. The harness removes Anthropic bearer/OAuth token variables +and rejects a `PI_CODING_AGENT_DIR` containing an OAuth login. Use a dedicated directory +without a `pi /login`; omit the variable to use the harness's temporary agent directory. + +## Call + +This example makes paid provider and Tavily calls. It forecasts one question, sets +a turn limit and a three-minute timeout, and disables retries. Select +`FORECASTER_MODEL` and provide keys before running it. + +```python +import os +from datetime import datetime, timezone + +from dotenv import load_dotenv + +from utils.llm.agentic import run + +load_dotenv(".env", override=False) + +result = run( + model=os.environ["FORECASTER_MODEL"], + system=( + "You are a calibrated forecaster. Research the question with web_search " + "and read_page, then call submit_forecast with your probability, rationale, " + "and key_sources." + ), + prompt=( + f"Today is {datetime.now(timezone.utc).date()}. " + "Question q1: Will NASA report a higher global annual temperature anomaly " + "for 2027 than for 2026? Resolve using NASA's published annual values " + "available on 2028-03-01. Give the probability that the answer is Yes." + ), + workdir="agentic-run", + cell_schema={ + "type": "object", + "required": ["question_id", "probability"], + "properties": { + "question_id": {"type": "string", "enum": ["q1"]}, + "probability": {"type": "number", "minimum": 0, "maximum": 1}, + }, + "additionalProperties": False, + }, + key_fields=["question_id"], + allowed_keys=["q1"], + tool_descriptions={ + "web_search": "Search the web for relevant evidence.", + "read_page": "Read a source page returned by web search.", + "submit_forecast": "Submit forecasts, rationale, and key_sources once, at the end.", + }, + cells_tool=False, + research_tools=("web_search", "read_page"), + min_research=1, + thinking="low", + max_turns=6, + timeout=180, + retries=0, +) + +missing = {"q1"} - result["cells"].keys() +if result["rc"] != 0 or result["usage"]["api_error"] or missing: + raise RuntimeError(f"Forecast failed or incomplete; inspect {result['workdir']}") +if not result["answer"] or result["usage"]["research_ok"] < 1: + raise RuntimeError(f"No researched final answer; inspect {result['workdir']}") +print(result["cells"]["q1"]) +print(result["answer"]) +print(result["usage"]) +``` + +`cell_schema` can also describe numeric values or quantiles. `key_fields` identifies +each cell; multiple field values join with `|` in `allowed_keys`. Provide descriptions +for each enabled research tool, `submit_forecast`, and `submit_cells` if enabled. +The final submission contains `forecasts`, `rationale`, and optional `key_sources`. + +## Results and limits + +Each attempt writes a fresh `attempt-N` directory under `workdir`. It contains the +prompt, system prompt, tool specification, event trace, and stderr. Submissions create +`pieces.jsonl` and/or `final.json`; an optional debrief creates `debrief.json`. +The result includes merged `cells`, the final `answer`, `evidence`, `usage`, `rc`, +`forced`, `trace`, and `workdir`. Final cells override earlier pieces. Check expected +keys and errors before consuming results: pi can exit with code zero after an API error. + +`max_turns` requests final submission at the limit and permits up to two more final +turns. `forced` records a submission forced by this limit. `timeout` bounds each +attempt; neither setting is a dollar cap. On macOS and Linux, a timeout also stops +the research subprocesses. Other platforms stop pi only. Retries are opt-in and can spend again. +Returned usage covers only the final attempt and excludes Tavily charges. Earlier +attempt traces remain on disk. Review all attempts and provider billing before a +large run. + +Run the offline tests without secrets: + +```bash +pytest tests/unit/agentic +FRI_REQUIRE_PI=1 FORECASTER_PI="$FORECASTER_PI" pytest tests/unit/agentic +``` + +The second command requires the installed runtime and exercises it without paid +provider calls. Before rollout, review one bounded live result for valid cells, +evidence, complete artifacts, latency, and actual cost. Consumer migrations and a +ForecastBench biweekly run need separate validation. diff --git a/pyproject.toml b/pyproject.toml index e3745bd..caad422 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ exclude = ["tests*", "htmlcov*", "venv*"] license-files = ["LICENSE", "THIRD_PARTY_NOTICES.md"] [tool.setuptools.package-data] +"utils.llm.agentic" = ["pi_tools.ts", "package.json", "package-lock.json"] "utils.llm.metadata" = ["*.json"] [tool.black] diff --git a/scripts/check_agentic_wheel.py b/scripts/check_agentic_wheel.py new file mode 100644 index 0000000..76d950f --- /dev/null +++ b/scripts/check_agentic_wheel.py @@ -0,0 +1,134 @@ +"""Check the installed agentic wheel with pi and a localhost model, without API calls. + +Run from outside the checkout, with PYTHONPATH unset, using the wheel's Python +environment. FORECASTER_PI must point to a separate pinned pi installation. +""" + +import importlib.util +import json +import os +import sys +import tempfile +from importlib.resources import files +from pathlib import Path + +from utils.llm.agentic import harness + + +def main() -> None: + """Exercise bundled TypeScript and Python tools from the installed wheel.""" + checkout = Path(__file__).resolve().parents[1] + package_dir = Path(harness.__file__).resolve().parent + assert not Path.cwd().resolve().is_relative_to(checkout), "Run outside the checkout." + assert not os.environ.get("PYTHONPATH"), "Unset PYTHONPATH for the installed-wheel check." + assert package_dir.is_relative_to(Path(sys.prefix).resolve()), "Import the installed wheel." + pi = Path(os.environ["FORECASTER_PI"]).resolve() + assert not pi.is_relative_to(package_dir), "Install pi outside the Python package." + resources = files("utils.llm.agentic") + package = json.loads(resources.joinpath("package.json").read_text()) + lock = json.loads(resources.joinpath("package-lock.json").read_text()) + dependency = "@earendil-works/pi-coding-agent" + expected_version = package["dependencies"][dependency] + assert lock["packages"][f"node_modules/{dependency}"]["version"] == expected_version + assert harness.pi_version(str(pi)) == expected_version, "The installed pi version differs." + + # Load only the stdlib localhost fixture, keeping the checkout off sys.path. + fixture_path = checkout / "tests/unit/agentic/scripted_model.py" + spec = importlib.util.spec_from_file_location("wheel_scripted_model", fixture_path) + fixture = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fixture) + model = fixture.ScriptedModel( + [ + ("read_page", {"url": "https://example.invalid/blocked"}), + ( + "submit_forecast", + {"forecasts": [{"id": "q1", "probability": 0.4}], "rationale": "Local fixture."}, + ), + ("submit_debrief", {"checked": False}), + ("submit_debrief", {"checked": True}), + "done", + ] + ) + try: + with tempfile.TemporaryDirectory(prefix="fri-wheel-smoke-") as directory: + root = Path(directory) + agent = root / "agent" + agent.mkdir() + (agent / "models.json").write_text( + json.dumps( + { + "providers": { + "fake": { + "baseUrl": model.url, + "api": "openai-completions", + "apiKey": "local-test-key", + "models": [{"id": "scripted"}], + } + } + } + ) + ) + validator = root / "validator.py" + validator.write_text( + "def check(value):\n" + " return [] if value.get('checked') is True else ['Check the forecast.']\n" + ) + result = harness.run( + model="fake/scripted", + system="Return the scripted forecast.", + prompt="Forecast q1.", + workdir=str(root / "calls"), + cell_schema={ + "type": "object", + "required": ["id", "probability"], + "properties": { + "id": {"type": "string"}, + "probability": {"type": "number", "minimum": 0, "maximum": 1}, + }, + }, + key_fields=["id"], + allowed_keys=["q1"], + cells_tool=False, + research_tools=("read_page",), + tool_descriptions={"read_page": "Read a page.", "submit_forecast": "Submit."}, + min_research=0, + thinking="off", + pi=str(pi), + timeout=60, + env={ + "PI_CODING_AGENT_DIR": str(agent), + "PI_OFFLINE": "1", + "PI_TELEMETRY": "0", + "FORECASTER_BLOCK_URL_PATTERNS": ".*", + "TAVILY_API_KEY": "", + }, + debrief={ + "tool": { + "name": "submit_debrief", + "description": "Confirm the forecast was checked.", + "parameters": { + "type": "object", + "required": ["checked"], + "properties": {"checked": {"type": "boolean"}}, + }, + }, + "prompt": "Check the forecast and submit the debrief.", + "retries": 1, + "validator": {"file": str(validator), "function": "check"}, + }, + ) + stderr = (Path(result["workdir"]) / "stderr.txt").read_text() + assert result["rc"] == 0, stderr + assert result["cells"] == {"q1": {"id": "q1", "probability": 0.4}}, result + assert result["usage"]["research_failed"] == 1, result + assert result["evidence"][0]["error"] == "page read failed: page unavailable", result + assert result["debrief"]["complete"] is True, result + assert result["debrief"]["attempts"] == 2, result + assert result["debrief"]["error"] is None, result + finally: + model.close() + print("Installed wheel passed: pi extension, research tool CLI, and debrief validator.") + + +if __name__ == "__main__": + main() diff --git a/tests/unit/agentic/__init__.py b/tests/unit/agentic/__init__.py new file mode 100644 index 0000000..37af97b --- /dev/null +++ b/tests/unit/agentic/__init__.py @@ -0,0 +1 @@ +"""Offline contract tests for the opt-in agentic forecaster.""" diff --git a/tests/unit/agentic/conftest.py b/tests/unit/agentic/conftest.py new file mode 100644 index 0000000..b9020d3 --- /dev/null +++ b/tests/unit/agentic/conftest.py @@ -0,0 +1,28 @@ +"""Keep agentic tests independent of local credentials and pi state.""" + +import pytest + + +@pytest.fixture(autouse=True) +def isolated_agentic_environment(monkeypatch, tmp_path): + """Use dummy credentials and a fresh agent directory for every test.""" + for name in ( + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "GEMINI_API_KEY", + "XAI_API_KEY", + "OPENROUTER_API_KEY", + "DEEPINFRA_API_KEY", + "TAVILY_API_KEY", + ): + monkeypatch.setenv(name, "test-key") + for name in ( + "ANTHROPIC_AUTH_TOKEN", + "ANTHROPIC_OAUTH_TOKEN", + "FORECASTER_BLOCK_URL_PATTERNS", + "FORECASTER_ENV_FILE", + ): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(tmp_path / "agent")) + monkeypatch.setenv("PI_OFFLINE", "1") + monkeypatch.setenv("PI_TELEMETRY", "0") diff --git a/tests/unit/agentic/scripted_model.py b/tests/unit/agentic/scripted_model.py new file mode 100644 index 0000000..7840bde --- /dev/null +++ b/tests/unit/agentic/scripted_model.py @@ -0,0 +1,98 @@ +"""Serve deterministic model responses over localhost, with no provider calls.""" + +import json +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +REFUSAL = object() + + +class ScriptedModel: + """Serve scripted tool calls through an OpenAI-compatible local endpoint.""" + + def __init__(self, script): + """Start a local model server and record incoming requests.""" + self.script, self.requests = list(script), [] + model = self + + class Handler(BaseHTTPRequestHandler): + """Return scripted server-sent events for each model request.""" + + def log_message(self, *a): + """Suppress routine HTTP request logging.""" + pass + + def do_POST(self): + """Reply with the next scripted assistant turn.""" + body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + model.requests.append(body) + turn = ( + model.script[len(model.requests) - 1] + if len(model.requests) <= len(model.script) + else "done" + ) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + for chunk in model.chunks(turn, len(model.requests)): + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.url = f"http://127.0.0.1:{self.server.server_address[1]}/v1" + + @staticmethod + def chunks(turn, n): + """Encode a tool call, refusal, or text as streaming completion chunks.""" + base = {"id": f"c{n}", "object": "chat.completion.chunk", "created": 0, "model": "scripted"} + if turn is REFUSAL: + delta, finish = {"role": "assistant", "content": ""}, "content_filter" + elif isinstance(turn, str): + delta, finish = {"role": "assistant", "content": turn}, "stop" + else: + name, args = turn + delta = { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": f"call_{n}", + "type": "function", + "function": {"name": name, "arguments": json.dumps(args)}, + } + ], + } + finish = "tool_calls" + yield dict(base, choices=[{"index": 0, "delta": delta, "finish_reason": None}]) + yield dict(base, choices=[{"index": 0, "delta": {}, "finish_reason": finish}]) + yield dict( + base, + choices=[], + usage={"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + ) + + def tools(self, n): + """Return tools offered in the numbered request, starting at one.""" + return {t["function"]["name"] for t in self.requests[n - 1].get("tools") or []} + + def text(self, n, *roles): + """Return the last matching message in the numbered request.""" + msg = [m for m in self.requests[n - 1]["messages"] if m.get("role") in roles][-1] + content = msg["content"] + return content if isinstance(content, str) else "".join(p.get("text", "") for p in content) + + def last_user(self, n): + """Return the last user message in the numbered request.""" + return self.text(n, "user") + + def tool_result(self, n): + """Return the tool response visible in the numbered request.""" + return self.text(n, "tool") + + def close(self): + """Stop the local server and release its listening socket.""" + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) diff --git a/tests/unit/agentic/test_harness.py b/tests/unit/agentic/test_harness.py new file mode 100644 index 0000000..7f7d6cf --- /dev/null +++ b/tests/unit/agentic/test_harness.py @@ -0,0 +1,250 @@ +"""Check the process boundary using recorded outputs from a fake pi.""" + +import json +import os +import sys +import textwrap + +import pytest + +from utils.llm.agentic import harness + +SCHEMA = { + "type": "object", + "properties": {"k": {"type": "string"}, "probability": {"type": "number"}}, +} +DESCRIPTIONS = { + "web_search": "Search.", + "read_page": "Read.", + "submit_cells": "Submit some cells.", + "submit_forecast": "Submit the forecast.", +} + +FAKE_PI = textwrap.dedent("""\ + #!{python} + import json, os, sys + from pathlib import Path + + if "--version" in sys.argv: + print({version!r}) + raise SystemExit(0) + out = Path(os.environ["FORECASTER_OUT"]) + (out / "stdin.txt").write_text(sys.stdin.read()) + system = Path(sys.argv[sys.argv.index("--system-prompt") + 1]) + (out / "seen-system.txt").write_text(system.read_text()) + (out / "env.json").write_text(json.dumps({{ + name: os.environ.get(name) for name in ( + "PI_CODING_AGENT_DIR", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN", + "ANTHROPIC_API_KEY", "FORECASTER_BLOCK_URL_PATTERNS" + ) + }})) + if {mode!r} == "broken": + raise SystemExit(1) + if {mode!r} == "provider-error": + print(json.dumps({{"type": "message_end", "message": {{ + "role": "assistant", "stopReason": "error", "errorMessage": "rate limited", + "usage": {{"input": 3}} + }}}})) + raise SystemExit(0) + (out / "pieces.jsonl").write_text(json.dumps({{"forecasts": [ + {{"k": "a", "probability": 0.1}}, {{"k": "b", "probability": 0.2}}, + {{"k": "unknown-piece", "probability": 0.9}} + ]}}) + "\\n") + (out / "final.json").write_text(json.dumps({{"answer": {{ + "forecasts": [{{"k": "b", "probability": 0.7}}, + {{"k": "unknown-final", "probability": 0.8}}], + "rationale": "because", "key_sources": ["https://example.org"] + }}}})) + for event in [ + {{"type": "tool_execution_start", "toolCallId": "one", "toolName": "web_search", + "args": {{"query": "base rate"}}}}, + {{"type": "tool_execution_end", "toolCallId": "one", "toolName": "web_search", + "result": {{"details": {{"results": [1, 2]}}}}}}, + {{"type": "tool_execution_start", "toolCallId": "two", "toolName": "read_page", + "args": {{"url": "https://example.org"}}}}, + {{"type": "tool_execution_end", "toolCallId": "two", "toolName": "read_page", + "result": {{"details": {{"text": "", "error": "page read failed"}}}}}}, + {{"type": "tool_execution_end", "toolName": "submit_forecast", "result": {{}}}}, + {{"type": "message_end", "message": {{"role": "assistant", "usage": {{ + "input": 10, "output": 5, "cacheRead": 2, "cost": {{"total": 0.01}} + }}}}}}, + {{"type": "message_end", "message": {{"role": "assistant", "usage": {{ + "input": 1, "output": 1, "cost": {{"total": 0.02}} + }}}}}} + ]: + print(json.dumps(event)) + """) + + +def fake_pi(tmp_path, *, version="0.99.2", mode="success", name="pi"): + """Write a deterministic executable with a real version-check boundary.""" + path = tmp_path / name + path.write_text(FAKE_PI.format(python=sys.executable, version=version, mode=mode)) + path.chmod(0o755) + return str(path) + + +@pytest.fixture +def run_args(tmp_path): + """Provide a small forecasting request with known output keys.""" + return { + "model": "anthropic/test-model", + "system": "System instructions.", + "prompt": "Forecast these two cells.", + "workdir": str(tmp_path / "work"), + "cell_schema": SCHEMA, + "key_fields": ["k"], + "allowed_keys": ["a", "b"], + "tool_descriptions": DESCRIPTIONS, + } + + +def test_merges_allowed_cells_and_collects_evidence_and_usage(tmp_path, run_args): + """Keep valid pieces, prefer final cells, and join events by tool call ID.""" + result = harness.run(pi=fake_pi(tmp_path), **run_args) + + assert {key: value["probability"] for key, value in result["cells"].items()} == { + "a": 0.1, + "b": 0.7, + } + assert result["answer"]["rationale"] == "because" + assert result["evidence"] == [ + { + "tool": "web_search", + "args": {"query": "base rate"}, + "result": {"results": [1, 2]}, + "error": None, + }, + { + "tool": "read_page", + "args": {"url": "https://example.org"}, + "result": {"text": "", "error": "page read failed"}, + "error": "page read failed", + }, + ] + usage = result["usage"] + assert [usage[key] for key in ("turns", "input_tokens", "output_tokens", "cached_tokens")] == [ + 2, + 11, + 6, + 2, + ] + assert usage["cost_usd"] == pytest.approx(0.03) + assert (usage["research_ok"], usage["research_failed"]) == (1, 1) + attempt = tmp_path / "work" / "attempt-1" + assert (attempt / "stdin.txt").read_text() == run_args["prompt"] + assert (attempt / "seen-system.txt").read_text() == run_args["system"] + + +def test_failed_process_cannot_reuse_previous_answers(tmp_path, run_args): + """Use a fresh attempt directory even after an earlier successful call.""" + harness.run(pi=fake_pi(tmp_path), **run_args) + result = harness.run(pi=fake_pi(tmp_path, mode="broken"), **run_args) + assert result["rc"] == 1 + assert result["workdir"].endswith("attempt-2") + assert result["answer"] == {} + assert result["cells"] == {} + + +def test_provider_error_is_reported_and_retried(tmp_path, run_args, monkeypatch): + """Retry provider errors even when pi exits with a successful status.""" + monkeypatch.setattr(harness, "RETRY_BACKOFF", 0) + result = harness.run(pi=fake_pi(tmp_path, mode="provider-error"), retries=2, **run_args) + assert result["rc"] == 0 + assert result["usage"]["api_error"] == "rate limited" + assert result["cells"] == {} + assert sorted(path.name for path in (tmp_path / "work").iterdir()) == [ + "attempt-1", + "attempt-2", + "attempt-3", + ] + + +def test_runtime_selection_is_explicit_then_call_env_then_process(tmp_path, run_args, monkeypatch): + """Resolve the runtime at call time and honor per-call overrides.""" + process_pi = fake_pi(tmp_path, name="process-pi") + call_pi = fake_pi(tmp_path, name="call-pi") + explicit_pi = fake_pi(tmp_path, name="explicit-pi") + monkeypatch.setenv("FORECASTER_PI", process_pi) + assert harness.resolve_pi() == process_pi + assert harness.resolve_pi(env={"FORECASTER_PI": call_pi}) == call_pi + assert harness.resolve_pi(explicit_pi, env={"FORECASTER_PI": call_pi}) == explicit_pi + monkeypatch.setenv("FORECASTER_PI", call_pi) + assert harness.resolve_pi() == call_pi + monkeypatch.delenv("FORECASTER_PI") + with pytest.raises(RuntimeError, match="FORECASTER_PI"): + harness.run(**run_args) + assert not (tmp_path / "work").exists() + + +def test_wrong_runtime_version_stops_before_forecasting(tmp_path, run_args): + """Reject an incompatible pi before a model process receives the request.""" + with pytest.raises(RuntimeError, match="0.99.2"): + harness.run(pi=fake_pi(tmp_path, version="0.98.0"), **run_args) + assert not (tmp_path / "work").exists() + + +def test_environment_overrides_are_private_and_oauth_tokens_are_removed( + tmp_path, run_args, monkeypatch +): + """Pass call-specific keys and URL filters without changing process state.""" + monkeypatch.setenv("ANTHROPIC_AUTH_TOKEN", "test-bearer") + monkeypatch.setenv("ANTHROPIC_OAUTH_TOKEN", "test-oauth") + monkeypatch.setenv("FORECASTER_BLOCK_URL_PATTERNS", "original") + before = dict(os.environ) + result = harness.run( + pi=fake_pi(tmp_path), + env={"ANTHROPIC_API_KEY": "call-key", "FORECASTER_BLOCK_URL_PATTERNS": "blocked"}, + **run_args, + ) + assert dict(os.environ) == before + seen = json.loads((tmp_path / "work" / "attempt-1" / "env.json").read_text()) + assert seen["ANTHROPIC_API_KEY"] == "call-key" + assert seen["FORECASTER_BLOCK_URL_PATTERNS"] == "blocked" + assert seen["ANTHROPIC_AUTH_TOKEN"] is None + assert seen["ANTHROPIC_OAUTH_TOKEN"] is None + assert result["rc"] == 0 + + +def test_missing_key_and_saved_oauth_login_stop_before_forecasting(tmp_path, run_args): + """Require API billing even when an agent directory contains a login.""" + pi = fake_pi(tmp_path) + with pytest.raises(RuntimeError, match="ANTHROPIC_API_KEY"): + harness.run(pi=pi, env={"ANTHROPIC_API_KEY": ""}, **run_args) + agent = tmp_path / "logged-in-agent" + agent.mkdir() + (agent / "auth.json").write_text( + json.dumps({"anthropic": {"type": "oauth", "access": "test-token"}}) + ) + with pytest.raises(RuntimeError, match="login"): + harness.run(pi=pi, env={"PI_CODING_AGENT_DIR": str(agent)}, **run_args) + assert not (tmp_path / "work").exists() + + +@pytest.mark.parametrize("agent_value", ["agent", "~/agent"]) +def test_agent_directory_checked_and_used_is_same_under_other_cwd( + agent_value, tmp_path, run_args, monkeypatch +): + """Resolve the billing directory before checking it and changing the child cwd.""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("HOME", str(tmp_path / "home")) + expected = tmp_path / "home" / "agent" if agent_value.startswith("~") else tmp_path / "agent" + child_cwd = tmp_path / "elsewhere" + child_cwd.mkdir() + pi = fake_pi(tmp_path) + call_options = { + "pi": pi, + "cwd": str(child_cwd), + "env": {"PI_CODING_AGENT_DIR": agent_value}, + **run_args, + } + result = harness.run(**call_options) + assert result["rc"] == 0 + seen = json.loads((tmp_path / "work" / "attempt-1" / "env.json").read_text()) + assert seen["PI_CODING_AGENT_DIR"] == str(expected) + (expected / "auth.json").write_text( + json.dumps({"anthropic": {"type": "oauth", "access": "test-token"}}) + ) + with pytest.raises(RuntimeError, match="login"): + harness.run(**call_options) + assert sorted(path.name for path in (tmp_path / "work").iterdir()) == ["attempt-1"] diff --git a/tests/unit/agentic/test_pi_tools.py b/tests/unit/agentic/test_pi_tools.py new file mode 100644 index 0000000..3dc901a --- /dev/null +++ b/tests/unit/agentic/test_pi_tools.py @@ -0,0 +1,582 @@ +"""Check the real pi extension with scripted localhost responses and no paid calls.""" + +import json +import os +import tempfile +import textwrap +import unittest + +from utils.llm.agentic import harness + +from .scripted_model import REFUSAL, ScriptedModel + +SCHEMA = { + "type": "object", + "required": ["k", "probability"], + "properties": {"k": {"type": "string"}, "probability": {"type": "number"}}, +} +DESC = {"web_search": "s", "read_page": "r", "submit_cells": "c", "submit_forecast": "f"} + +# Research without the network: web_search answers, read_page fails. A +# debrief validator runs as the real tool_cli runs it. +FAKE_TOOL_CLI = textwrap.dedent("""\ + import json, sys + tool = sys.argv[1] + if tool == "validate": + from utils.llm.agentic.tool_cli import validate + print(json.dumps(validate(**json.load(sys.stdin)))) + else: + print(json.dumps({"results": [{"url": "u", "title": "t"}]} if tool == "web_search" + else {"error": "page read failed: 404"})) + """) +VALIDATOR = "def check(value, need):\n return [f'{k}: missing' for k in need if k not in (value or {})]\n" + + +# Configured runtimes must work; required CI runs must never silently skip. +NEEDS_PI = unittest.skipUnless( + bool(os.environ.get("FORECASTER_PI")) or os.environ.get("FRI_REQUIRE_PI") == "1", + "set FORECASTER_PI to run the local scripted-model tests", +) + + +class PiCase(unittest.TestCase): + """Run the pinned pi against a scripted model and fake research tools.""" + + def setUp(self): + """Create temporary files and replace the research CLI.""" + self.tmp = tempfile.TemporaryDirectory() + d = self.tmp.name + cli = os.path.join(d, "tool_cli.py") + open(cli, "w").write(FAKE_TOOL_CLI) + self.real_cli, harness.TOOL_CLI = harness.TOOL_CLI, [cli] + + def tearDown(self): + """Restore the real research CLI and remove temporary files.""" + harness.TOOL_CLI = self.real_cli + self.tmp.cleanup() + + def run_pi(self, script, **kw): + """Execute one scripted model conversation in an isolated agent directory.""" + model = ScriptedModel(script) + self.addCleanup(model.close) + agent = os.path.join(self.tmp.name, "agent") + os.makedirs(agent, exist_ok=True) + json.dump( + { + "providers": { + "fake": { + "baseUrl": model.url, + "api": "openai-completions", + "apiKey": "x", + "models": [{"id": "scripted"}], + } + } + }, + open(os.path.join(agent, "models.json"), "w"), + ) + args = dict( + model="fake/scripted", + system="S", + prompt="P", + workdir=os.path.join(self.tmp.name, "w"), + cell_schema=SCHEMA, + key_fields=["k"], + allowed_keys=["a", "b"], + tool_descriptions=DESC, + min_research=2, + thinking="off", + timeout=120, + env={"PI_CODING_AGENT_DIR": agent, "PI_OFFLINE": "1", "PI_TELEMETRY": "0"}, + ) + args["env"].update(kw.pop("env", {})) + args.update(kw) + return harness.run(**args), model + + +@NEEDS_PI +class PiToolsTest(PiCase): + """Check research and submission behavior through the real pi extension.""" + + def test_research_floor_pieces_and_final(self): + """Verify research floor pieces and final.""" + res, model = self.run_pi( + [ + ( + "submit_forecast", + {"forecasts": [{"k": "a", "probability": 0.1}], "rationale": "early"}, + ), # 1 + ("read_page", {"url": "u"}), # 2 + ("web_search", {"query": "q1"}), # 3 + ("web_search", {"query": "q2"}), # 4 + ( + "submit_cells", + { + "forecasts": [ + {"k": "a", "probability": 0.2}, + {"k": "zzz", "probability": 0.9}, + ] + }, + ), # 5 + ( + "submit_forecast", + {"forecasts": [{"k": "zzz", "probability": 0.5}], "rationale": "r"}, + ), # 6 + ( + "submit_forecast", + { + "forecasts": [{"k": "b", "probability": 0.3}], + "rationale": "r", + "key_sources": ["s"], + }, + ), # 7 + ("submit_forecast", {"forecasts": [], "rationale": "again"}), # 8 + "done", # 9 + ] + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(len(model.requests), 9) + + # 1: submitting before any research is refused + self.assertIn( + "submit_forecast is not offered yet: 2 more successful research call(s)", + model.tool_result(2), + ) + # 2: a failed read does not count toward the floor + self.assertIn("page read failed", model.tool_result(3)) + # 5: only wanted keys are accepted from a piece + piece = json.loads(model.tool_result(6)) + self.assertEqual( + (piece["accepted"], piece["received"], piece["missing_first"]), (1, 1, ["b"]) + ) + # 6: unknown and missing keys get one warning, and nothing is written + warn = json.loads(model.tool_result(7)) + self.assertEqual((warn["unknown_keys"], warn["missing_keys"]), (["zzz"], ["b"])) + # 7: accepted; 8: a second final answer is refused + self.assertTrue(json.loads(model.tool_result(8).split(" Done")[0])["ok"]) + self.assertIn("already called", model.tool_result(9)) + + final = json.load(open(os.path.join(res["workdir"], "final.json"))) + self.assertEqual((final["research"], final["research_failed"]), (2, 1)) + self.assertEqual(final["answer"]["key_sources"], ["s"]) + self.assertEqual( + [ + json.loads(line)["accepted"] + for line in open(os.path.join(res["workdir"], "pieces.jsonl")) + ], + [1], + ) + self.assertEqual( + {k: c["probability"] for k, c in res["cells"].items()}, {"a": 0.2, "b": 0.3} + ) + self.assertEqual((res["usage"]["research_ok"], res["usage"]["research_failed"]), (2, 1)) + + def test_no_research_offers_only_submit_forecast(self): + """Verify no research offers only submit forecast.""" + res, model = self.run_pi( + [ + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.4}, + {"k": "b", "probability": 0.6}, + ], + "rationale": "r", + }, + ), + "done", + ], + research_tools=(), + min_research=0, + cells_tool=False, + tool_descriptions={"submit_forecast": "f"}, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + offered = {t["function"]["name"] for t in model.requests[0]["tools"]} + self.assertEqual( + offered, {"submit_forecast"} + ) # no research tools, and none of pi's built-ins + self.assertEqual( + {k: c["probability"] for k, c in res["cells"].items()}, {"a": 0.4, "b": 0.6} + ) + self.assertEqual( + model.last_user(1), "P" + ) # the prompt, from stdin, as the first user message + + def test_numeric_forecast_schema_is_preserved(self): + """Accept caller-defined numeric forecasts instead of requiring probabilities.""" + schema = { + "type": "object", + "required": ["date", "estimate"], + "properties": {"date": {"type": "string"}, "estimate": {"type": "number"}}, + } + result, model = self.run_pi( + [ + ( + "submit_forecast", + {"forecasts": [{"date": "2030-01-01", "estimate": 42.5}], "rationale": "r"}, + ), + "done", + ], + cell_schema=schema, + key_fields=["date"], + allowed_keys=["2030-01-01"], + research_tools=(), + min_research=0, + cells_tool=False, + tool_descriptions={"submit_forecast": "Submit the numeric forecast."}, + ) + self.assertEqual(result["rc"], 0) + self.assertEqual(result["cells"], {"2030-01-01": {"date": "2030-01-01", "estimate": 42.5}}) + sent = model.requests[0]["tools"][0]["function"]["parameters"] + self.assertEqual(sent["properties"]["forecasts"]["items"]["required"], ["date", "estimate"]) + + def test_a_prompt_past_the_argv_limit_reaches_the_model(self): + """Verify a prompt past the argv limit reaches the model.""" + prompt = "Forecast.\n" + "".join( + f"--- question {i} ---\nWill it happen?\n" for i in range(6000) + ) + self.assertGreater(len(prompt.encode()), 131072) + res, model = self.run_pi( + [ + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.4}, + {"k": "b", "probability": 0.6}, + ], + "rationale": "r", + }, + ), + "done", + ], + prompt=prompt, + research_tools=(), + min_research=0, + cells_tool=False, + tool_descriptions={"submit_forecast": "f"}, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(model.last_user(1), prompt.strip()) # verbatim but for pi's trim + self.assertEqual(open(os.path.join(res["workdir"], "prompt.txt")).read(), prompt) + + def test_a_system_prompt_past_the_argv_limit_reaches_the_model(self): + """Verify a system prompt past the argv limit reaches the model.""" + system = "The survey.\n" + "".join( + f"=== G{i:04d} ===\nWhat will it be?\n" for i in range(6000) + ) + self.assertGreater(len(system.encode()), 131072) + res, model = self.run_pi( + [ + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.4}, + {"k": "b", "probability": 0.6}, + ], + "rationale": "r", + }, + ), + "done", + ], + system=system, + research_tools=(), + min_research=0, + cells_tool=False, + tool_descriptions={"submit_forecast": "f"}, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertTrue( + model.text(1, "system", "developer").startswith(system) + ) # pi appends its section + self.assertEqual(model.last_user(1), "P") + + +SEARCH_WITH_CAUSE = { + "type": "object", + "required": ["query", "cause"], + "properties": { + "query": {"type": "string"}, + "recent_days": {"type": "integer"}, + "cause": {"type": "string", "enum": ["ai", "bio"]}, + }, +} + + +@NEEDS_PI +class LoopOptionsTest(PiCase): + """Check the optional coverage, turn-limit, and debrief behavior.""" + + def test_withheld_until_every_call_and_group_counts(self): + """Verify withheld until every call and group counts.""" + res, model = self.run_pi( + [ + ("read_page", {"url": "u"}), # fails, still counts + ("web_search", {"query": "q", "cause": "ai", "recent_days": 7}), + ("web_search", {"query": "q", "cause": "bio"}), # no recent_days: not coverage + ("web_search", {"query": "q", "cause": "bio", "recent_days": 30}), + ( + "submit_forecast", + {"forecasts": [{"k": "a", "probability": 0.1}], "rationale": "r"}, + ), + "done", + ], + min_research=4, + floor_counts="returned", + coverage=["ai", "bio"], + withhold_final=True, + warn_missing_keys=False, + tool_parameters={"web_search": SEARCH_WITH_CAUSE}, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + for n in (1, 2, 3, 4): # 4 returned (one failed) and both groups covered after call 4 + self.assertNotIn("submit_forecast", model.tools(n)) + self.assertNotIn("submit_cells", model.tools(n)) + self.assertTrue({"submit_forecast", "submit_cells"} <= model.tools(5)) + sent = next(t for t in model.requests[0]["tools"] if t["function"]["name"] == "web_search") + self.assertEqual( + sent["function"]["parameters"]["properties"]["cause"]["enum"], ["ai", "bio"] + ) + final = json.load(open(os.path.join(res["workdir"], "final.json"))) + self.assertEqual( + (final["research"], final["research_failed"], final["forced"]), (3, 1, False) + ) + self.assertEqual( + {k: c["probability"] for k, c in res["cells"].items()}, {"a": 0.1} + ) # no warning for b + + def test_turn_limit_offers_only_submission(self): + """Verify turn limit offers only submission.""" + res, model = self.run_pi( + [ + ("web_search", {"query": "q1"}), + ("web_search", {"query": "q2"}), + ( + "submit_forecast", + {"forecasts": [{"k": "a", "probability": 0.2}], "rationale": "forced"}, + ), + "done", + ], + min_research=5, + withhold_final=True, + warn_missing_keys=False, + max_turns=3, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(model.tools(3), {"submit_forecast"}) + self.assertTrue( + model.last_user(3).startswith("Now call submit_forecast with your final answer.") + ) + self.assertTrue(res["forced"]) + + def test_turn_limit_ends_a_call_that_never_submits(self): + """Verify turn limit ends a call that never submits.""" + res, model = self.run_pi( + ["thinking", "still thinking", "no", "no", "no", "no"], + min_research=1, + withhold_final=True, + max_turns=3, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(len(model.requests), 5) # the limit is max_turns + 2 + self.assertIn("Keep researching with the tools", model.last_user(2)) + self.assertEqual(model.tools(5), {"submit_forecast"}) + self.assertFalse(os.path.exists(os.path.join(res["workdir"], "final.json"))) + + def test_debrief_merges_and_retries(self): + """Verify debrief merges and retries.""" + validator = os.path.join(self.tmp.name, "validator.py") + open(validator, "w").write(VALIDATOR) + debrief = { + "tool": { + "name": "submit_debrief", + "description": "d", + "parameters": { + "type": "object", + "properties": {"a": {"type": "string"}, "b": {"type": "string"}}, + }, + }, + "prompt": "Debrief now.", + "retries": 1, + "validator": {"file": validator, "function": "check", "kwargs": {"need": ["a", "b"]}}, + } + res, model = self.run_pi( + [ + ("web_search", {"query": "q1"}), + ("web_search", {"query": "q2"}), + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.1}, + {"k": "b", "probability": 0.2}, + ], + "rationale": "r", + }, + ), + ("submit_debrief", {"a": "why a"}), + ("submit_debrief", {"b": "why b"}), + "done", + ], + debrief=debrief, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertNotIn("submit_debrief", model.tools(1)) + self.assertEqual(model.tools(4), {"submit_debrief"}) + self.assertEqual(model.last_user(4), "Debrief now.") + self.assertIn("b: missing", model.tool_result(5)) + self.assertIn("fixing: b: missing", model.last_user(5)) + self.assertEqual( + res["debrief"], + { + "result": {"a": "why a", "b": "why b"}, + "complete": True, + "attempts": 2, + "problems": [], + "error": None, + }, + ) + + def test_debrief_asks_again_after_a_refusal(self): + # A refused turn ends pi's run unless something asks again. + """Verify debrief asks again after a refusal.""" + debrief = { + "tool": { + "name": "submit_debrief", + "description": "d", + "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}, + }, + "prompt": "Debrief now.", + "retries": 1, + } + res, model = self.run_pi( + [ + ("web_search", {"query": "q1"}), + ("web_search", {"query": "q2"}), + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.1}, + {"k": "b", "probability": 0.2}, + ], + "rationale": "r", + }, + ), + REFUSAL, + ("submit_debrief", {"a": "why a"}), + "done", + ], + debrief=debrief, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertEqual(model.last_user(5), "Now call submit_debrief with the debrief.") + self.assertEqual(model.tools(5), {"submit_debrief"}) + self.assertEqual( + res["debrief"], + { + "result": {"a": "why a"}, + "complete": True, + "attempts": 2, + "problems": [], + "error": None, + }, + ) + + def test_validator_exceptions_are_retried_and_never_count_as_valid(self): + """Recover from a validator exception or stop incomplete after bounded retries.""" + validator = os.path.join(self.tmp.name, "fallible_validator.py") + with open(validator, "w") as handle: + handle.write( + "from pathlib import Path\n" + "def check(value, marker, recovers):\n" + " marker = Path(marker)\n" + " first = not marker.exists()\n" + " marker.touch()\n" + " if first or not recovers:\n" + " raise RuntimeError('validator unavailable')\n" + " return []\n" + ) + for recovers in (True, False): + with self.subTest(recovers=recovers): + debrief = { + "tool": { + "name": "submit_debrief", + "description": "Explain the forecast.", + "parameters": {"type": "object", "properties": {"a": {"type": "string"}}}, + }, + "prompt": "Debrief now.", + "retries": 1, + "validator": { + "file": validator, + "function": "check", + "kwargs": { + "marker": os.path.join(self.tmp.name, f"validated-{recovers}"), + "recovers": recovers, + }, + }, + } + result, model = self.run_pi( + [ + ( + "submit_forecast", + {"forecasts": [{"k": "a", "probability": 0.2}], "rationale": "r"}, + ), + ("submit_debrief", {"a": "why a"}), + ("submit_debrief", {"a": "why a"}), + "done", + ], + min_research=0, + warn_missing_keys=False, + debrief=debrief, + ) + self.assertEqual(result["rc"], 0) + rejected = json.loads(model.tool_result(3)) + self.assertFalse(rejected["accepted"]) + self.assertIn("validator failed:", rejected["problems"][0]) + self.assertEqual(result["debrief"]["attempts"], 2) + self.assertEqual(result["debrief"]["complete"], recovers) + self.assertEqual(len(model.requests), 4) + if recovers: + self.assertIsNone(result["debrief"]["error"]) + self.assertEqual(result["debrief"]["problems"], []) + else: + self.assertIn("validator failed:", result["debrief"]["error"]) + self.assertEqual(result["debrief"]["problems"], [result["debrief"]["error"]]) + + +@NEEDS_PI +class UrlGuardPassthroughTest(PiCase): + """Check per-call URL exclusions through the real Python tool process.""" + + def test_env_reaches_read_page(self): + """Verify env reaches read page.""" + harness.TOOL_CLI = self.real_cli # the real CLI; with no Tavily key nothing is fetched + res, model = self.run_pi( + [ + ("read_page", {"url": "https://example.com/paper.pdf"}), # 1: blocked by the guard + ( + "read_page", + {"url": "https://example.org/other"}, + ), # 2: passes it, stops at the key + ( + "submit_forecast", + { + "forecasts": [ + {"k": "a", "probability": 0.5}, + {"k": "b", "probability": 0.5}, + ], + "rationale": "r", + }, + ), + "done", + ], + min_research=0, + env={"FORECASTER_BLOCK_URL_PATTERNS": "example\\.com", "TAVILY_API_KEY": ""}, + ) + self.assertEqual(res["rc"], 0, open(os.path.join(res["workdir"], "stderr.txt")).read()) + self.assertIn("page read failed: page unavailable", model.tool_result(2)) + self.assertIn("no TAVILY_API_KEY set", model.tool_result(3)) + self.assertEqual((res["usage"]["research_ok"], res["usage"]["research_failed"]), (0, 2)) diff --git a/tests/unit/agentic/test_timeout.py b/tests/unit/agentic/test_timeout.py new file mode 100644 index 0000000..68aac59 --- /dev/null +++ b/tests/unit/agentic/test_timeout.py @@ -0,0 +1,86 @@ +"""Check that a timed-out forecast stops its local tool subprocesses.""" + +import os +import signal +import sys +import textwrap +import time + +import pytest + +from utils.llm.agentic import harness + + +@pytest.mark.skipif(os.name != "posix", reason="Process-group cleanup requires POSIX.") +def test_timeout_stops_tool_child_before_returning(tmp_path): + """A tool child cannot write results after its forecast has timed out.""" + child = tmp_path / "tool.py" + child.write_text(textwrap.dedent("""\ + import os + import time + from pathlib import Path + + root = Path(os.environ["FRI_TEST_CHILD_DIR"]) + (root / "child.pid").write_text(str(os.getpid())) + while not (root / "release").exists(): + time.sleep(0.01) + (root / "late-result").write_text("The tool survived its forecast.") + """)) + pi = tmp_path / "pi" + pi.write_text(f"#!{sys.executable}\n" + textwrap.dedent("""\ + import os + import subprocess + import sys + import time + from pathlib import Path + + if "--version" in sys.argv: + print("0.99.2") + raise SystemExit(0) + root = Path(os.environ["FRI_TEST_CHILD_DIR"]) + (root / "pi.pid").write_text(str(os.getpid())) + subprocess.Popen([sys.executable, str(root / "tool.py")]) + while True: + time.sleep(0.1) + """)) + pi.chmod(0o755) + child_pid = None + try: + result = harness.run( + model="fake/scripted", + system="Forecast.", + prompt="Forecast q1.", + workdir=str(tmp_path / "calls"), + cell_schema={"type": "object"}, + key_fields=["id"], + allowed_keys=["q1"], + tool_descriptions={"submit_forecast": "Submit."}, + research_tools=(), + cells_tool=False, + pi=str(pi), + timeout=1, + env={"FRI_TEST_CHILD_DIR": str(tmp_path)}, + ) + child_pid = int((tmp_path / "child.pid").read_text()) + assert result["rc"] == "timeout" + assert result["cells"] == {} + pi_pid = int((tmp_path / "pi.pid").read_text()) + with pytest.raises(ChildProcessError): + os.waitpid(pi_pid, os.WNOHANG) + + (tmp_path / "release").touch() + marker = tmp_path / "late-result" + deadline = time.monotonic() + 1 + while time.monotonic() < deadline and not marker.exists(): + try: + os.kill(child_pid, 0) + except ProcessLookupError: + break + time.sleep(0.01) + assert not marker.exists(), "The tool child continued after the forecast timed out." + finally: + if child_pid is not None: + try: + os.kill(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass diff --git a/tests/unit/agentic/test_tools.py b/tests/unit/agentic/test_tools.py new file mode 100644 index 0000000..3cccfae --- /dev/null +++ b/tests/unit/agentic/test_tools.py @@ -0,0 +1,86 @@ +"""Check research filtering, page windows, and retry boundaries without HTTP.""" + +import io +import json +from unittest.mock import Mock + +import pytest + +from utils.llm.agentic import tool_cli, tools + + +def response(data): + """Encode a Tavily-shaped response for urllib's context manager.""" + return io.BytesIO(json.dumps(data).encode()) + + +@pytest.mark.parametrize("url", ["https://blocked.example/report", "https://BLOCKED.example/"]) +def test_blocked_page_never_reaches_tavily(url, monkeypatch): + """Apply URL exclusions before issuing any research request.""" + monkeypatch.setenv("FORECASTER_BLOCK_URL_PATTERNS", r"blocked\.example") + network = Mock(side_effect=AssertionError("blocked URL must not be fetched")) + monkeypatch.setattr(tools.urllib.request, "urlopen", network) + result = tools.read_page(url) + assert result["text"] == "" + assert "page unavailable" in result["error"] + network.assert_not_called() + + +@pytest.mark.parametrize("patterns", [None, "", r"blocked\.example"]) +def test_search_filter_removes_derived_answer_only_when_results_are_removed(patterns, monkeypatch): + """Avoid exposing a summary derived from excluded search results.""" + data = { + "answer": "Summary of both sources.", + "results": [ + {"title": "Blocked source", "url": "https://blocked.example/report", "content": "x"}, + {"title": "Allowed source", "url": "https://allowed.example/report", "content": "y"}, + ], + } + if patterns is not None: + monkeypatch.setenv("FORECASTER_BLOCK_URL_PATTERNS", patterns) + monkeypatch.setattr(tools.urllib.request, "urlopen", lambda *args, **kwargs: response(data)) + result = tools.web_search("base rate") + if patterns: + assert result["answer"] is None + assert [row["url"] for row in result["results"]] == ["https://allowed.example/report"] + else: + assert result["answer"] == data["answer"] + assert len(result["results"]) == 2 + + +def test_read_page_returns_contiguous_windows(monkeypatch): + """Let callers page through extracted text without gaps or duplication.""" + text = "x" * 7000 + "tail" + monkeypatch.setattr( + tools.urllib.request, + "urlopen", + lambda *args, **kwargs: response({"results": [{"raw_content": text}]}), + ) + first = tools.read_page("https://allowed.example/report") + second = tools.read_page("https://allowed.example/report", offset=first["next_offset"]) + assert first["text"] + second["text"] == text + assert first["total_chars"] == len(text) + assert "next_offset" not in second + + +def test_missing_research_key_returns_error_without_http(monkeypatch): + """Report unavailable research rather than making an unauthenticated request.""" + monkeypatch.delenv("TAVILY_API_KEY") + network = Mock(side_effect=AssertionError("missing key must stop HTTP")) + monkeypatch.setattr(tools.urllib.request, "urlopen", network) + assert "TAVILY_API_KEY" in tools.web_search("base rate")["error"] + assert "TAVILY_API_KEY" in tools.read_page("https://example.org")["error"] + network.assert_not_called() + + +def test_cli_retries_rate_limits_but_not_other_failures(): + """Retry a temporary quota response without repeatedly fetching a missing page.""" + replies = iter([{"error": "HTTP Error 429: Too Many Requests"}, {"results": []}]) + waits = [] + assert tool_cli.call(lambda **kwargs: next(replies), {}, sleep=waits.append) == {"results": []} + assert len(waits) == 1 + assert waits[0] > 0 + waits.clear() + missing = {"error": "page read failed: 404"} + assert tool_cli.call(lambda **kwargs: missing, {}, sleep=waits.append) == missing + assert waits == [] diff --git a/utils/llm/agentic/__init__.py b/utils/llm/agentic/__init__.py new file mode 100644 index 0000000..5e09348 --- /dev/null +++ b/utils/llm/agentic/__init__.py @@ -0,0 +1,9 @@ +"""Explicit agentic forecasting through AIRO's pinned pi harness. + +Importing this module does not configure credentials or start pi. Existing +``ModelRun.get_response`` calls keep their one-shot behavior. +""" + +from .harness import run + +__all__ = ["run"] diff --git a/utils/llm/agentic/harness.py b/utils/llm/agentic/harness.py new file mode 100644 index 0000000..9fd715e --- /dev/null +++ b/utils/llm/agentic/harness.py @@ -0,0 +1,485 @@ +"""Run one explicit agentic forecasting call through pi. + +Copied from AIRO's fri-forecaster harness; see THIRD_PARTY_NOTICES.md. +The caller supplies the prompt, the shape of a forecast cell and which cells +it wants. This module runs pi with +pi_tools.ts, then folds pi's JSON event stream into evidence and +usage and merges the submitted pieces with the final answer. + + res = run(model=MODEL, system=SYS, prompt=P, workdir=W, + cell_schema={...}, key_fields=["id", "resolution_date"], + allowed_keys=["q1|2026-10-18", ...]) + res["cells"] # {key: cell}, pieces first, final.json's cells override + res["answer"] # the submit_forecast arguments (rationale, key_sources, ...) + +Nothing here knows about any one caller's questions or rows. +""" + +import json +import os +import signal +import subprocess +import sys +import tempfile +import time +from datetime import datetime, timezone + +from .keys import KEY_FOR + +HERE = os.path.dirname(os.path.abspath(__file__)) +EXT = os.path.join(HERE, "pi_tools.ts") +TOOL_CLI = ["-m", "utils.llm.agentic.tool_cli"] +RESEARCH_TOOLS = ("web_search", "read_page") +# pi_tools.ts uses this exact extension API. The packaged npm manifests install +# pi separately from the Python package; no runtime is downloaded by this module. +PINNED_PI_VERSION = "0.99.2" +# Isolation: no built-in tools, no discovered extensions/skills/prompt +# templates, and no AGENTS.md/CLAUDE.md from the cwd or its parents, so the +# saved system.txt + prompt.txt are the whole prompt. +ISOLATION = [ + "--no-builtin-tools", + "--no-session", + "--no-extensions", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", +] +# Billing: API keys only. pi takes a bearer or OAuth token from the environment +# over ANTHROPIC_API_KEY, and a `pi /login` leaves OAuth credentials in its +# agent directory (~/.pi/agent by default), which it also prefers -- a personal +# Claude subscription paid for a run that way (2026-10-15). So pi runs without +# these variables, in the agent directory the caller names (PI_CODING_AGENT_DIR) +# or an empty one of ours, and never in one that holds a login. +LOGIN_TOKENS = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_OAUTH_TOKEN") +AGENT_DIR = os.path.join(tempfile.gettempdir(), "fri-forecaster-agent") +# A provider error ends pi's turn with exit 0 and no cells (Anthropic's +# "out of extra usage" 400 on bursts, 2026-10-14). Retries wait this many +# seconds times the attempt number. +RETRY_BACKOFF = 20 + + +def key_of(cell, key_fields): + """Join the caller's key fields into a forecast-cell identifier.""" + return "|".join(str(cell.get(k)) for k in key_fields) + + +# The loop's optional settings (pi_tools.ts's header says what each does). +# Unset, a call runs the default loop: the ForecastBench driver's. +LOOP_OPTIONS = ( + "floor_counts", + "coverage", + "withhold_final", + "warn_missing_keys", + "max_turns", + "debrief", +) + + +def write_spec( + workdir, + *, + cell_schema, + key_fields, + allowed_keys, + tool_descriptions, + min_research=0, + research_tools=RESEARCH_TOOLS, + final_extra=None, + cells_tool=True, + tool_parameters=None, + **loop, +): + """Write spec.json for the pi extension. + + tool_descriptions: {tool name: description} + for every tool offered (research tools, submit_forecast, and submit_cells + unless cells_tool is False); tool_parameters: {tool name: JSON schema} to + replace a tool's built-in parameters; `loop`: LOOP_OPTIONS. + """ + unknown = set(loop) - set(LOOP_OPTIONS) + if unknown: + raise TypeError(f"unknown loop options {sorted(unknown)}; have {LOOP_OPTIONS}") + tools = { + name: { + "description": tool_descriptions[name], + **({"parameters": tool_parameters[name]} if name in (tool_parameters or {}) else {}), + } + for name in list(research_tools) + + (["submit_cells"] if cells_tool else []) + + ["submit_forecast"] + } + spec = { + "tool_cli": TOOL_CLI, + "research_tools": list(research_tools), + "min_research": min_research, + "tools": tools, + "cell_schema": cell_schema, + "key_fields": list(key_fields), + "allowed_keys": list(allowed_keys), + "final_extra": final_extra or {}, + **{k: v for k, v in loop.items() if v is not None}, + } + os.makedirs(workdir, exist_ok=True) + with open(os.path.join(workdir, "spec.json"), "w") as f: + json.dump(spec, f, indent=1) + return spec + + +def resolve_pi(pi=None, env=None): + """Resolve an explicit runtime path at call time, without searching PATH.""" + configured = pi or (env if env is not None else os.environ).get("FORECASTER_PI") + if not configured: + raise RuntimeError( + f"Set FORECASTER_PI or pass pi= with the path to a pi {PINNED_PI_VERSION} " + "executable or cli.js. Install it using the packaged npm manifests." + ) + return os.path.abspath(os.path.expanduser(os.fspath(configured))) + + +def pi_argv(pi=None): + """Build the command prefix for an explicitly configured pi runtime.""" + pi = resolve_pi(pi) + return ["node", pi] if pi.endswith(".js") else [pi] + + +def pi_version(pi=None, env=None): + """Read the installed pi version without making a model call.""" + try: + return ( + subprocess.run( + pi_argv(resolve_pi(pi, env)) + ["--version"], + capture_output=True, + text=True, + timeout=60, + check=True, + env=env, + ) + .stdout.strip() + .splitlines()[-1] + ) + except (OSError, subprocess.SubprocessError, IndexError, RuntimeError): + return None + + +# The prompt goes to pi on STDIN and the system prompt as a file, never as +# arguments. Linux hands one argument at most 128 KiB (MAX_ARG_STRLEN): AIRO's +# joint prompt passed that on 2026-10-19, and so can a long system prompt +# shared across calls for the provider to cache. pi reads piped stdin as its +# first prompt, verbatim but for surrounding whitespace (it trims), so what the +# model reads and prompt.txt differ by at most a trailing newline; given a path +# that exists, `--system-prompt` reads the file (system.txt) as is. Callers can +# check this flag before sending a prompt that would not fit an argument. (The +# same cap is why a debrief validator's value reaches tool_cli on stdin: +# pi_tools.ts.) +PROMPT_ON_STDIN = True + + +def pi_command(model, system_file, *, thinking="high", pi=None): + """Build an isolated pi invocation; the user prompt goes on stdin.""" + return pi_argv(pi) + [ + "-p", + "--mode", + "json", + *ISOLATION, + "-e", + EXT, + "--model", + model, + "--thinking", + thinking, + "--system-prompt", + system_file, + ] + + +def new_attempt_dir(workdir): + """Create a fresh attempt directory that cannot reuse an earlier answer.""" + workdir = os.path.abspath(workdir) + os.makedirs(workdir, exist_ok=True) + n = 1 + max( + [ + int(d.split("-", 1)[1]) + for d in os.listdir(workdir) + if d.startswith("attempt-") and d.split("-", 1)[1].isdigit() + ] + or [0] + ) + path = os.path.join(workdir, f"attempt-{n}") + os.makedirs(path) + return path + + +def parse_events(lines): + """Extract research evidence and usage from pi's JSON event stream. + + pi (0.99) puts a tool's args on tool_execution_start and its result on + tool_execution_end: they are joined by toolCallId. + """ + args_by_id = {} + evidence, turns = [], 0 + usage = { + "turns": 0, + "input_tokens": 0, + "output_tokens": 0, + "cached_tokens": 0, + "cost_usd": 0.0, + "api_error": None, + } + for line in lines: + if not line.strip(): + continue + try: + e = json.loads(line) + except ValueError: + continue + if e.get("type") == "tool_execution_start": + args_by_id[e.get("toolCallId")] = e.get("args") + if e.get("type") == "tool_execution_end" and e.get("toolName") in RESEARCH_TOOLS: + res = e.get("result") + det = (res or {}).get("details") if isinstance(res, dict) else None + result = det if det is not None else res + evidence.append( + { + "tool": e["toolName"], + "args": ( + e.get("args") + if e.get("args") is not None + else args_by_id.get(e.get("toolCallId")) + ), + "result": result, + "error": (result or {}).get("error") if isinstance(result, dict) else None, + } + ) + if e.get("type") == "message_end" and (e.get("message") or {}).get("role") == "assistant": + u = e["message"].get("usage") or {} + turns += 1 + usage["input_tokens"] += int(u.get("input") or 0) + usage["output_tokens"] += int(u.get("output") or 0) + usage["cached_tokens"] += int(u.get("cacheRead") or 0) + usage["cost_usd"] = round( + usage["cost_usd"] + float(((u.get("cost") or {}).get("total")) or 0), 6 + ) + if e["message"].get("stopReason") == "error": + usage["api_error"] = (e["message"].get("errorMessage") or "error")[:300] + usage["turns"] = turns + usage["research_ok"] = sum(1 for ev in evidence if not ev["error"]) + usage["research_failed"] = sum(1 for ev in evidence if ev["error"]) + return evidence, usage + + +def collect(workdir, key_fields, allowed_keys): + """Merge partial and final cells, keeping only caller-allowed keys. + + Final cells replace earlier partial cells with the same key. + """ + allowed = set(allowed_keys) + received, delivery = {}, {"cells_calls": 0, "cells_in_pieces": 0, "cells_in_final": 0} + pieces = os.path.join(workdir, "pieces.jsonl") + if os.path.exists(pieces): + for line in open(pieces): + if not line.strip(): + continue + p = json.loads(line) + delivery["cells_calls"] += 1 + for f in p.get("forecasts") or []: + if isinstance(f, dict) and key_of(f, key_fields) in allowed: + received[key_of(f, key_fields)] = f + delivery["cells_in_pieces"] = len(received) + final_path = os.path.join(workdir, "final.json") + answer = json.load(open(final_path))["answer"] if os.path.exists(final_path) else {} + for f in answer.get("forecasts") or []: + if isinstance(f, dict) and key_of(f, key_fields) in allowed: + received[key_of(f, key_fields)] = f + delivery["cells_in_final"] += 1 + return received, answer, delivery + + +def pi_env(model, env=None): + """Build the child environment and enforce API-key billing. + + Per-call values override the process environment without changing it. + Login tokens are removed, and the agent directory cannot contain OAuth logins. + """ + full = dict(os.environ, **(env or {})) + for k in LOGIN_TOKENS: + full.pop(k, None) + key = KEY_FOR.get(model.partition("/")[0]) + if key and not full.get(key): + raise RuntimeError( + f"{model}: {key} is not set; pass it in env= or load it into the " + "process environment. pi is billed to API keys only." + ) + agent = os.path.abspath(os.path.expanduser(full.get("PI_CODING_AGENT_DIR") or AGENT_DIR)) + os.makedirs(agent, exist_ok=True) + auth = read_json(os.path.join(agent, "auth.json")) or {} + logins = sorted(p for p, v in auth.items() if isinstance(v, dict) and v.get("type") == "oauth") + if logins: + raise RuntimeError( + f"{agent}/auth.json holds a login for {', '.join(logins)}; pi is billed to API keys only: " + f"point PI_CODING_AGENT_DIR at a directory without one" + ) + full["PI_CODING_AGENT_DIR"] = agent + return full + + +def run( + *, + model, + system, + prompt, + workdir, + cell_schema, + key_fields, + allowed_keys, + tool_descriptions, + min_research=0, + research_tools=RESEARCH_TOOLS, + final_extra=None, + cells_tool=True, + thinking="high", + pi=None, + cwd=None, + env=None, + timeout=None, + tool_parameters=None, + retries=0, + **loop, +): + """Run pi in a fresh attempt directory and return forecasts with evidence. + + Writes spec.json, prompt.txt, system.txt, events.jsonl, stderr.txt there + (and pi_tools.ts writes pieces.jsonl, final.json, debrief.json). pi's + environment is pi_env's: billed to API keys only. A call that ends in a + provider error with no cells (usage["api_error"]) is made again, up to + `retries` times, each in its own attempt folder, RETRY_BACKOFF seconds + times the attempt number apart. Usage covers only the returned attempt and + excludes Tavily charges. Callers must inspect rc, usage.api_error, and cell + coverage before accepting a result. + """ + full_env = pi_env(model, env) + pi = resolve_pi(pi, full_env) + version = pi_version(pi, full_env) + if version != PINNED_PI_VERSION: + raise RuntimeError( + f"Expected pi {PINNED_PI_VERSION} at {pi}; " + f"found {version or 'no working runtime'}. Install the pinned runtime." + ) + for attempt in range(retries + 1): + res = _call( + model=model, + system=system, + prompt=prompt, + workdir=workdir, + cell_schema=cell_schema, + key_fields=key_fields, + allowed_keys=allowed_keys, + tool_descriptions=tool_descriptions, + min_research=min_research, + research_tools=research_tools, + final_extra=final_extra, + cells_tool=cells_tool, + thinking=thinking, + pi=pi, + cwd=cwd, + env=full_env, + timeout=timeout, + tool_parameters=tool_parameters, + **loop, + ) + if res["cells"] or not res["usage"]["api_error"] or attempt == retries: + return res + time.sleep(RETRY_BACKOFF * (attempt + 1)) + + +def _call( + *, + model, + system, + prompt, + workdir, + cell_schema, + key_fields, + allowed_keys, + tool_descriptions, + min_research, + research_tools, + final_extra, + cells_tool, + thinking, + pi, + cwd, + env, + timeout, + tool_parameters, + **loop, +): + workdir = new_attempt_dir(workdir) + write_spec( + workdir, + cell_schema=cell_schema, + key_fields=key_fields, + allowed_keys=allowed_keys, + tool_descriptions=tool_descriptions, + min_research=min_research, + research_tools=research_tools, + final_extra=final_extra, + cells_tool=cells_tool, + tool_parameters=tool_parameters, + **loop, + ) + open(os.path.join(workdir, "prompt.txt"), "w").write(prompt) + system_file = os.path.abspath(os.path.join(workdir, "system.txt")) # pi may run in another cwd + open(system_file, "w").write(system) + cmd = pi_command(model, system_file, thinking=thinking, pi=pi) + full_env = dict(env, FORECASTER_OUT=workdir, FORECASTER_PY=sys.executable) + t0 = datetime.now(timezone.utc) + events_path = os.path.join(workdir, "events.jsonl") + with open(events_path, "w") as ev, open(os.path.join(workdir, "stderr.txt"), "w") as err: + with subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=ev, + stderr=err, + env=full_env, + cwd=cwd, + start_new_session=os.name == "posix", + ) as process: + try: + process.communicate(input=prompt.encode(), timeout=timeout) + rc = process.returncode + except subprocess.TimeoutExpired: + # pi starts Python tools. Stop the whole group before returning, + # so a timed-out attempt cannot keep making research requests. + if os.name == "posix": + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass + else: + process.kill() + process.communicate() + rc = "timeout" + secs = (datetime.now(timezone.utc) - t0).total_seconds() + evidence, usage = parse_events(open(events_path)) + usage["harness"] = "pi" + usage["seconds"] = round(secs) + cells, answer, delivery = collect(workdir, key_fields, allowed_keys) + final = read_json(os.path.join(workdir, "final.json")) or {} + return { + "rc": rc, + "seconds": secs, + "evidence": evidence, + "usage": usage, + "cells": cells, + "answer": answer, + "delivery": delivery, + "forced": bool(final.get("forced")), + "debrief": read_json(os.path.join(workdir, "debrief.json")), + "trace": events_path, + "workdir": workdir, + } + + +def read_json(path): + """Read a JSON artifact if it exists.""" + return json.load(open(path)) if os.path.exists(path) else None diff --git a/utils/llm/agentic/keys.py b/utils/llm/agentic/keys.py new file mode 100644 index 0000000..12df4d0 --- /dev/null +++ b/utils/llm/agentic/keys.py @@ -0,0 +1,12 @@ +"""Environment key names used by the pi providers supported by this harness.""" + +# Credentials are inherited from the caller, with optional per-call overrides. +# The application may explicitly load a .env file before calling the harness. +KEY_FOR = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "google": "GEMINI_API_KEY", + "xai": "XAI_API_KEY", + "openrouter": "OPENROUTER_API_KEY", + "deepinfra": "DEEPINFRA_API_KEY", +} diff --git a/utils/llm/agentic/package-lock.json b/utils/llm/agentic/package-lock.json new file mode 100644 index 0000000..fc655f8 --- /dev/null +++ b/utils/llm/agentic/package-lock.json @@ -0,0 +1,1964 @@ +{ + "name": "fri-forecaster", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fri-forecaster", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.99.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.99.2.tgz", + "integrity": "sha512-6R1BZ2N77CrVcGf3eC2KovTz1Q4RYiAeydvVWQT546N2fi1nBc81aURlbOZCgruWoW9VY/UrLzDynF4YTolpoA==", + "hasShrinkwrap": true, + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.99.2", + "@earendil-works/pi-agent-core": "^0.99.2", + "@earendil-works/pi-ai": "^0.99.2", + "@earendil-works/pi-codemode": "^0.99.2", + "@earendil-works/pi-mcp": "^0.99.2", + "@earendil-works/pi-tui": "^0.99.2", + "@silvia-odwyer/photon-node": "0.3.4", + "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-coding-agent/node_modules/@anthropic-ai/sdk": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.124.0.tgz", + "integrity": "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA==", + "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/chord": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-agent-core": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.99.2", + "@earendil-works/pi-ai": "^0.99.2", + "@earendil-works/pi-telemetry": "^0.99.2", + "diff": "8.0.4", + "ignore": "7.0.8", + "typebox": "1.3.27", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.124.0", + "@aws-sdk/client-bedrock-runtime": "3.1127.0", + "@earendil-works/pi-telemetry": "^0.99.2", + "@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-coding-agent/node_modules/@earendil-works/pi-ai/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/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/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/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-ai/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/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-codemode": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-codemode/-/pi-codemode-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "quickjs-wasi": "3.6.2" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-mcp": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-mcp/-/pi-mcp-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-telemetry": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.99.2.tgz", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@earendil-works/pi-tui": { + "version": "0.99.2", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.99.2.tgz", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.11" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@protobufjs/utf8": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", + "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "license": "BSD-3-Clause" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/google-auth-library": { + "version": "10.6.2", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", + "integrity": "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw==", + "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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/http-proxy-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/lru-cache": { + "version": "11.4.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", + "integrity": "sha512-W+R+kFL4HgVxONq2bhXPi3bGpzGe/yEhVOp233qw9wCRtgncJ15P3bC+e4zZMu4Cq7d+WAJjXGW0uUkifhcatA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/p-retry/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/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/@earendil-works/pi-coding-agent/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "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/@earendil-works/pi-coding-agent/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" + } + } + } +} diff --git a/utils/llm/agentic/package.json b/utils/llm/agentic/package.json new file mode 100644 index 0000000..1469029 --- /dev/null +++ b/utils/llm/agentic/package.json @@ -0,0 +1,8 @@ +{ + "name": "fri-forecaster", + "private": true, + "description": "pi, pinned: the agent loop harness.py runs (harness.REPO_PI)", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.99.2" + } +} diff --git a/utils/llm/agentic/pi_tools.ts b/utils/llm/agentic/pi_tools.ts new file mode 100644 index 0000000..ec16dc8 --- /dev/null +++ b/utils/llm/agentic/pi_tools.ts @@ -0,0 +1,290 @@ +// FRI Forecaster: the pi extension. Generic over what is being forecast. +// +// Factored out of code/pi_harness/redlines_tools.ts (2026-10-05). The +// driver (harness.py) writes $FORECASTER_OUT/spec.json: +// +// tool_cli Python argument prefix for the research-tool module +// research_tools which of web_search / read_page to offer ([] = no research) +// min_research research calls that must return before submitting +// tools {name: {description, parameters?}} for every tool offered; +// `parameters`, a JSON schema, replaces the built-in one +// cell_schema JSON schema of ONE forecast cell +// key_fields fields of a cell that identify it, e.g. ["question_id","horizon"] +// allowed_keys every wanted key, fields joined by "|" +// final_extra {name: JSON schema} extra properties of submit_forecast +// +// Optional, each off by default (what the ForecastBench driver runs). AIRO's +// loop (redlines.llm.call_tools) sets them all: +// +// floor_counts "successful" (default): only research calls without an +// error count toward min_research; "returned": every call +// coverage question-group keys: submission waits for a web_search +// with `cause` = each key and `recent_days` set +// withhold_final the submit tools are not declared until the floor and +// coverage are met (default: declared, and refused) +// warn_missing_keys false: accept the final answer as sent (default true: +// one warning for unknown or missing keys) +// max_turns the turn limit: from the last turn, only +// submit_forecast is offered and the model is asked for it +// in words, for up to two more turns; then no tools. Also +// turns a reply in prose into a nudge to research or submit +// debrief {tool: {name, description, parameters}, prompt, retries, +// validator?: {file, function, kwargs}}: after the final +// answer, one more user turn offering only the debrief +// tool; deliveries merge, the validator's problems go back +// for up to `retries` more tries; written to debrief.json +// +// Imports match the pi the harness is pinned to (harness.py +// PINNED_PI_VERSION; package.json: @earendil-works/pi-coding-agent 0.99.2). +// +// Tools: the research tools shell out to the Python implementations +// (tools.py, via the tool_cli module) so every model shares one search +// stack; submit_cells appends pieces to pieces.jsonl; submit_forecast writes +// final.json. The driver merges them. +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { execFileSync } from "node:child_process"; +import { appendFileSync, readFileSync, writeFileSync } from "node:fs"; + +const OUT = process.env.FORECASTER_OUT; +if (!OUT) throw new Error("FORECASTER_OUT is not set (the driver sets it)"); +const PY = process.env.FORECASTER_PY || "python3"; +const spec = JSON.parse(readFileSync(`${OUT}/spec.json`, "utf8")); +const keyFields: string[] = spec.key_fields; +const allowed = new Set(spec.allowed_keys); +const minResearch: number = spec.min_research; +const countsAll = spec.floor_counts === "returned"; +const coverage: string[] = spec.coverage || []; +const withholdFinal = !!spec.withhold_final; +const warnMissing = spec.warn_missing_keys !== false; +const maxTurns: number = spec.max_turns || 0; +const debrief = spec.debrief || null; +let research = 0; // research calls that returned without an error +let researchFailed = 0; // ...and those that did: they count toward the floor only under floor_counts "returned" +const covered = new Set(); +const received = new Map(); +const cap = (s: string, n = 8000) => (s.length > n ? s.slice(0, n) : s); +const keyOf = (f: any) => keyFields.map((k) => String(f?.[k])).join("|"); +const counted = () => (countsAll ? research + researchFailed : research); +const uncovered = () => coverage.filter((k) => !covered.has(k)); +let forcing = false; // past the turn limit: submit_forecast is offered regardless +const unlocked = () => forcing || (counted() >= minResearch && uncovered().length === 0); + +function runPy(args: string[], input?: string) { + return JSON.parse(execFileSync(PY, [...spec.tool_cli, ...args], + { input, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, env: process.env })); +} +const py = (tool: string, args: any) => runPy([tool, JSON.stringify(args)]); +// A debrief can outgrow one argv string (128 KB on Linux): it goes on stdin. +const validated = (value: any): string[] => + (runPy(["validate"], JSON.stringify({ ...debrief.validator, value })).problems || []).map(String); + +// A refused call: the model reads `body` as the tool's result. +function refuse(body: any) { + return { content: [{ type: "text" as const, text: JSON.stringify(body) }], details: {}, isError: true }; +} + +function notYet(name: string) { + const parts: string[] = []; + if (counted() < minResearch) { + parts.push(countsAll + ? `${minResearch - counted()} more research call(s) must return first (${counted()} of ${minResearch} so far)` + : `${minResearch - research} more successful research call(s) must return first (${research} of ${minResearch} so far; ${researchFailed} failed)`); + } + const missingCover = uncovered(); + if (missingCover.length) { + parts.push(`a web_search with recent_days set and cause= is still needed for: ${missingCover.join(", ")}`); + } + return refuse({ error: `${name} is not offered yet: ${parts.join("; ")}` }); +} + +function missing() { + return [...allowed].filter((k) => !received.has(k)); +} + +function say(text: string) { + return { type: "custom_message" as const, customType: "forecaster", content: text, display: true }; +} + +let finalDone = false; // one final answer per call +let finalWarned = false; // one chance to fix unknown/missing keys, then accepted as is +const cells = Type.Array(Type.Unsafe(spec.cell_schema)); +const paramsOf = (name: string, builtin: any) => + spec.tools[name]?.parameters ? Type.Unsafe(spec.tools[name].parameters) : builtin; + +export default function (pi: ExtensionAPI) { + const research_tools: string[] = spec.research_tools; + const submitTools = [...(spec.tools.submit_cells ? ["submit_cells"] : []), "submit_forecast"]; + const offer = () => pi.setActiveTools(unlocked() || !withholdFinal ? [...research_tools, ...submitTools] : research_tools); + + function researched(params: any, r: any, isSearch: boolean) { + const ok = r && !r.error; + if (ok) research += 1; else researchFailed += 1; + if (isSearch && params?.cause != null && params?.recent_days && (ok || countsAll)) covered.add(String(params.cause)); + if (withholdFinal) offer(); + } + + if (research_tools.includes("web_search")) pi.registerTool({ + name: "web_search", label: "web_search", + description: spec.tools.web_search.description, + parameters: paramsOf("web_search", Type.Object({ + query: Type.String(), + max_results: Type.Optional(Type.Integer({ minimum: 1, maximum: 10 })), + recent_days: Type.Optional(Type.Integer({ minimum: 1 })), + })), + async execute(_id, params) { + const r = py("web_search", params); + researched(params, r, true); + return { content: [{ type: "text", text: cap(JSON.stringify(r)) }], details: r }; + }, + }); + if (research_tools.includes("read_page")) pi.registerTool({ + name: "read_page", label: "read_page", + description: spec.tools.read_page.description, + parameters: paramsOf("read_page", Type.Object({ url: Type.String(), offset: Type.Optional(Type.Integer({ minimum: 0 })) })), + async execute(_id, params) { + const r = py("read_page", params); + researched(params, r, false); + return { content: [{ type: "text", text: cap(JSON.stringify(r)) }], details: r }; + }, + }); + if (spec.tools.submit_cells) pi.registerTool({ + name: "submit_cells", label: "submit_cells", + description: spec.tools.submit_cells.description, + parameters: paramsOf("submit_cells", Type.Object({ forecasts: cells })), + async execute(_id, params: any) { + if (!unlocked()) return notYet("submit_cells"); + let n = 0; + for (const f of params.forecasts || []) { + if (f && typeof f === "object" && allowed.has(keyOf(f))) { + received.set(keyOf(f), f); + n += 1; + } + } + appendFileSync(`${OUT}/pieces.jsonl`, JSON.stringify({ t: Date.now(), accepted: n, forecasts: params.forecasts }) + "\n"); + const miss = missing(); + const r = { accepted: n, received: received.size, of: allowed.size, missing: miss.length, missing_first: miss.slice(0, 20) }; + return { content: [{ type: "text", text: JSON.stringify(r) }], details: r }; + }, + }); + const finalProps: Record = { + forecasts: Type.Optional(cells), + rationale: Type.String(), + key_sources: Type.Optional(Type.Array(Type.String())), + }; + for (const [k, s] of Object.entries(spec.final_extra || {})) finalProps[k] = Type.Unsafe(s); + pi.registerTool({ + name: "submit_forecast", label: "submit_forecast", + description: spec.tools.submit_forecast.description, + parameters: paramsOf("submit_forecast", Type.Object(finalProps)), + async execute(_id, params: any) { + if (!unlocked()) return notYet("submit_forecast"); + if (finalDone) return refuse({ error: "submit_forecast was already called; stop here." }); + const unknown = (params.forecasts || []).filter((f: any) => !allowed.has(keyOf(f))).map(keyOf); + const have = new Set([...received.keys(), ...(params.forecasts || []).map(keyOf).filter((k: string) => allowed.has(k))]); + const stillMissing = [...allowed].filter((k) => !have.has(k)); + if (warnMissing && (unknown.length || stillMissing.length) && !finalWarned) { + finalWarned = true; + return refuse({ error: "not submitted: fix and call submit_forecast again", unknown_keys: unknown, missing_keys: stillMissing }); + } + finalDone = true; + writeFileSync(`${OUT}/final.json`, JSON.stringify({ t: Date.now(), research, research_failed: researchFailed, forced: forcing, answer: params }, null, 1)); + const r = { ok: true, received: received.size, of: allowed.size, in_final: (params.forecasts || []).length }; + return { content: [{ type: "text", text: JSON.stringify(r) + (debrief ? "" : " Done: stop here.") }], details: r }; + }, + }); + + // The debrief: deliveries merge; the merged whole is validated. + const d = { result: null as any, complete: false, attempts: 0, problems: [] as string[], error: null as string | null }; + let phase: "forecast" | "debrief" | "done" = "forecast"; + let debriefCalled = false; + const saveDebrief = () => writeFileSync(`${OUT}/debrief.json`, JSON.stringify(d, null, 1)); + const parsed = (args: any) => { + if (!args || typeof args !== "object" || Array.isArray(args)) return args; + const out: Record = {}; + for (const [k, v] of Object.entries(args)) { + let x: any = v; + if (typeof v === "string" && v.trim().startsWith("{")) { try { x = JSON.parse(v); } catch { /* not JSON: kept */ } } + out[k] = x; + } + return out; + }; + if (debrief) pi.registerTool({ + name: debrief.tool.name, label: debrief.tool.name, + description: debrief.tool.description, + parameters: Type.Unsafe(debrief.tool.parameters), + async execute(_id, params: any) { + if (phase !== "debrief") return refuse({ error: `${debrief.tool.name} is offered after submit_forecast` }); + debriefCalled = true; + const args = parsed(params); + d.result = d.result && typeof d.result === "object" && args && typeof args === "object" ? { ...d.result, ...args } : args; + d.error = null; + try { + d.problems = debrief.validator ? validated(d.result) : []; + } catch (e: any) { + d.error = `validator failed: ${String(e?.message || e).slice(0, 300)}`; + d.problems = [d.error]; + } + d.complete = d.error === null && d.problems.length === 0; + saveDebrief(); + const r = { accepted: d.complete, problems: d.problems }; + return { content: [{ type: "text", text: cap(JSON.stringify(r)) + (d.complete ? " Done: stop here." : "") }], details: r }; + }, + }); + + let turns = 0; + const limit = maxTurns ? maxTurns + 2 : 0; + const askFinal = "Now call submit_forecast with your final answer. Include any remaining forecast cells and required summary fields; previously accepted cells are retained."; + + pi.on("session_start", async () => { + if (debrief) pi.setActiveTools(pi.getActiveTools().filter((t) => t !== debrief.tool.name)); + if (withholdFinal) offer(); + }); + + pi.on("turn_end", async () => { + turns += 1; + if (phase === "forecast" && finalDone) { + if (!debrief) { phase = "done"; return; } + phase = "debrief"; + pi.setActiveTools([debrief.tool.name]); + saveDebrief(); + return { entries: [say(debrief.prompt)], continue: true }; + } + if (phase === "debrief") { + d.attempts += 1; + const called = debriefCalled; + debriefCalled = false; + if (!called) d.problems = [`${debrief.tool.name} was not called`]; + saveDebrief(); + if (d.complete || d.attempts >= 1 + (debrief.retries ?? 1)) { + phase = "done"; + pi.setActiveTools([]); + return; + } + // Not called (prose, or a refusal, which cannot continue from here): the + // run is settling, and agent_before_settle asks again. + if (!called) return; + return { entries: [say(`Call ${debrief.tool.name} again with what is still missing or wrong (entries you already sent are kept), fixing: ${d.problems.join("; ").slice(0, 4000)}`)], continue: true }; + } + if (phase === "forecast" && maxTurns) { + if (turns >= limit) { pi.setActiveTools([]); return; } + if (turns >= maxTurns - 1) { + forcing = true; + pi.setActiveTools(["submit_forecast"]); + return { entries: [say(askFinal)], continue: true }; + } + } + }); + + // A reply in prose (no tool call, or a provider refusal) ends pi's run; in the + // debrief, and under max_turns, nudge instead. + pi.on("agent_before_settle", async () => { + if (phase === "debrief") return { entries: [say(`Now call ${debrief.tool.name} with the debrief.`)], continue: true }; + if (phase !== "forecast" || finalDone || !maxTurns || turns >= limit || forcing) return; + const missingCover = uncovered(); + return { entries: [say(unlocked() + ? "Now call submit_forecast with your final answer." + : `Keep researching with the tools: submit_forecast is offered after ${minResearch} tool calls have returned (${counted()} so far)` + + (missingCover.length ? ` and after a web_search with recent_days on each of: ${missingCover.join(", ")}` : "") + ".")], continue: true }; + }); +} diff --git a/utils/llm/agentic/tool_cli.py b/utils/llm/agentic/tool_cli.py new file mode 100644 index 0000000..ee72895 --- /dev/null +++ b/utils/llm/agentic/tool_cli.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Run research tools as a Python module for the pi extension. + +``python -m utils.llm.agentic.tool_cli web_search '{"query": ...}'`` prints +the tool result as JSON. Keys come from the environment the harness passes down. + +`tool_cli.py validate` (arguments on stdin: {file, function, kwargs, value}) +runs a caller's debrief validator, `function(value, **kwargs)` from the +Python file `file`, and prints {"problems": [...]}. +""" + +import importlib.util +import json +import random +import sys +import time + +from . import tools + +FN = {"web_search": tools.web_search, "read_page": tools.read_page} +# Tavily answers 429 when many agents search at once (2026-10-06 practice +# run: 32 concurrent calls, ~1,700 failures). Back off and retry here, not in +# tools.py, so the direct tool functions retain their single-request behavior. +BACKOFF = [2, 5, 10, 20, 40] + + +def rate_limited(res): + """Return whether a tool result reports an HTTP 429 error.""" + return isinstance(res, dict) and "429" in str(res.get("error") or "") + + +def call(fn, args, sleep=time.sleep): + """Retry rate-limited tool calls with bounded exponential backoff.""" + res = fn(**args) + for wait in BACKOFF: + if not rate_limited(res): + break + sleep(wait * random.uniform(0.75, 1.25)) + res = fn(**args) + return res + + +def validate(file, function, value, kwargs=None): + """Run a caller-supplied debrief validator and return its problems.""" + spec = importlib.util.spec_from_file_location("forecaster_validator", file) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return { + "problems": [str(p) for p in (getattr(module, function)(value, **(kwargs or {})) or [])] + } + + +def main(): + """Dispatch one tool or validator call and print its JSON result.""" + if sys.argv[1] == "validate": + print(json.dumps(validate(**json.load(sys.stdin)))) + return + tool, args = sys.argv[1], json.loads(sys.argv[2] if len(sys.argv) > 2 else "{}") + if tool not in FN: + sys.exit(f"unknown tool {tool!r}") + print(json.dumps(call(FN[tool], args))) + + +if __name__ == "__main__": + main() diff --git a/utils/llm/agentic/tools.py b/utils/llm/agentic/tools.py new file mode 100644 index 0000000..d1ff11f --- /dev/null +++ b/utils/llm/agentic/tools.py @@ -0,0 +1,250 @@ +"""Search and read web evidence through Tavily using the Python standard library. + +Copied from AIRO's fri-forecaster package; see THIRD_PARTY_NOTICES.md. +The functions return JSON-compatible results, including an error field when a +request fails. The pi extension records these results as forecast evidence. +""" + +import json +import os +import re +import urllib.error +import urllib.request +from datetime import date, timedelta + +TAVILY_URL = "https://api.tavily.com/search" +TAVILY_EXTRACT_URL = "https://api.tavily.com/extract" + +# One read_page reply is this many characters of the page. pi_tools.ts +# caps a tool reply at 8000 characters, so a window has to fit under that with +# its envelope; the model pages through a longer document with `offset`. +PAGE_CHARS = 7000 + +# URL GUARD (2026-10-16, AIRO's anchor-guard experiment). OFF unless the +# environment sets FORECASTER_BLOCK_URL_PATTERNS: regexes separated by newlines +# or commas (so no commas inside a pattern), searched case-insensitively +# against each URL. pi_tools.ts hands its +# environment to every tool process, so one variable reaches both tools. +# read_page refuses a matching URL without calling Tavily, with the ordinary +# failure shape and a neutral reason; web_search drops matching results, and +# results titled as the AIRO paper (BLOCK_TITLES), and blanks Tavily's +# `answer` when it dropped anything, because that summary was written from +# the unfiltered results. ForecastBench shares these tools, so nothing here +# changes when the variable is unset. +BLOCK_ENV = "FORECASTER_BLOCK_URL_PATTERNS" +BLOCK_TITLES = ("Automated Artificial Intelligence Risk Outlook",) + + +def blocked_patterns(): + """Compile the caller's optional URL exclusion patterns.""" + raw = os.environ.get(BLOCK_ENV) or "" + return [re.compile(p.strip(), re.I) for p in re.split(r"[\n,]", raw) if p.strip()] + + +def is_blocked(url, patterns): + """Return whether a URL matches an exclusion pattern.""" + return bool(url) and any(p.search(url) for p in patterns) + + +def search_body(query, max_results=5, recent_days=None, today=None): + """Build a Tavily search request without credentials. + + With `recent_days`, the + search runs on Tavily's NEWS index bounded to [today - recent_days, today] + by exact dates -- probed 2026-09-02: start_date/end_date filter precisely + there, and not at all on the general index, whose only bound is the coarse + time_range bucket. Without it, the general index, unbounded. + """ + body = { + "query": query, + "max_results": max(1, min(int(max_results or 5), 10)), + # "advanced" (2026-09-02 evening, the source-quality pass): Tavily + # re-ranks and extracts the relevant passage per result instead of + # returning the page head; two credits a search instead of one. + "search_depth": "advanced", + "include_answer": True, + } + if recent_days: + today = today or date.today() + days = max(1, min(int(recent_days), 3650)) + body.update( + { + "topic": "news", + "include_answer": False, + "start_date": (today - timedelta(days=days)).isoformat(), + "end_date": today.isoformat(), + } + ) + return body + + +def web_search(query, max_results=5, recent_days=None, cause=None): + """Search the web via Tavily. + + Returns {query, answer?, results:[{title,url, + content, published?}]}. `recent_days` bounds the search to news published in + the last N days (see search_body); the date each result was published comes + back with it. + + `cause` (2026-09-11) is the question group the search is for. It is echoed + on the result and never sent to Tavily: the forecasting loop's coverage + rule in pi_tools.ts reads it, with `recent_days`, + off the recorded evidence to decide whether every group has been checked. + + Needs TAVILY_API_KEY; without it (or on any network error) returns a result + carrying an `error` string and no results, so the loop keeps going ungrounded. + """ + tag = {"cause": cause} if cause is not None else {} + key = os.environ.get("TAVILY_API_KEY") + if not key: + return { + "query": query, + **tag, + "results": [], + "error": "web search unavailable (no TAVILY_API_KEY set)", + } + spec = search_body(query, max_results, recent_days) + body = json.dumps(dict(spec, api_key=key)).encode() + req = urllib.request.Request( + TAVILY_URL, data=body, headers={"Content-Type": "application/json"} + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.load(resp) + except (urllib.error.URLError, ValueError, TimeoutError) as e: + return {"query": query, **tag, "results": [], "error": f"web search failed: {e}"} + out = {"query": query, **tag, "answer": data.get("answer"), "results": []} + guard = blocked_patterns() + if recent_days: + out["window"] = {"start": spec["start_date"], "end": spec["end_date"]} + for r in data.get("results", []): + if guard and ( + is_blocked(r.get("url"), guard) + or any(t in (r.get("title") or "") for t in BLOCK_TITLES) + ): + out["answer"] = None + continue + item = { + "title": r.get("title"), + "url": r.get("url"), + # Tavily already returns trimmed snippets; cap defensively + "content": (r.get("content") or "")[:1500], + } + if r.get("published_date"): + item["published"] = r["published_date"] + out["results"].append(item) + return out + + +def read_page(url, offset=0): + """Read one window of web-page text through Tavily extract. + + Return PAGE_CHARS + characters from `offset`. Returns {url, offset, total_chars, text, + next_offset?}; `next_offset` is present only when there is more. + + Needs TAVILY_API_KEY (the same key as web_search); without it, or when the + page cannot be fetched, returns a result carrying an `error` string and no + text, so the loop keeps going. + """ + if is_blocked(url, blocked_patterns()): + return {"url": url, "text": "", "error": "page read failed: page unavailable"} + key = os.environ.get("TAVILY_API_KEY") + if not key: + return {"url": url, "text": "", "error": "page reading unavailable (no TAVILY_API_KEY set)"} + body = json.dumps({"urls": [url], "extract_depth": "basic"}).encode() + req = urllib.request.Request( + TAVILY_EXTRACT_URL, + data=body, + headers={"Content-Type": "application/json", "Authorization": f"Bearer {key}"}, + ) + try: + with urllib.request.urlopen(req, timeout=45) as resp: + data = json.load(resp) + except (urllib.error.URLError, ValueError, TimeoutError) as e: + return {"url": url, "text": "", "error": f"page read failed: {e}"} + results = data.get("results") or [] + if not results: + failed = data.get("failed_results") or [] + why = ( + failed[0].get("error") if failed and isinstance(failed[0], dict) else None + ) or "no content returned" + return {"url": url, "text": "", "error": f"page read failed: {why}"} + text = results[0].get("raw_content") or "" + try: + start = max(0, int(offset or 0)) + except (TypeError, ValueError): + start = 0 + out = { + "url": url, + "offset": start, + "total_chars": len(text), + "text": text[start : start + PAGE_CHARS], + } + if start + PAGE_CHARS < len(text): + out["next_offset"] = start + PAGE_CHARS + return out + + +WEB_SEARCH_TOOL = { + "name": "web_search", + "description": ( + "Search the web for current facts, recent events, base rates, expert " + "estimates and reporting relevant to a question. Use it to ground your " + "forecast in evidence past your training cutoff, and use it repeatedly: " + "one query per thing you need to know, refined by what earlier results " + "said. Set recent_days to search only news published in the last N days " + "(counted back from today's date, which your prompt states); each such " + "result carries its publication date. Returns titled result snippets with " + "URLs; call read_page on a URL when the snippet is not enough." + ), + "parameters": { + "type": "object", + "properties": { + "query": {"type": "string", "description": "the search query"}, + "max_results": { + "type": "integer", + "description": "how many results to return (default 5, max 10)", + }, + "recent_days": { + "type": "integer", + "description": "only news published within this many days " + "before today; omit to search the whole web", + }, + # A forecasting run narrows this to its question groups (an enum) + # and can require it with tool_parameters. + "cause": { + "type": "string", + "description": "the question group this search is for, by the key " + "the prompt uses for it", + }, + }, + "required": ["query"], + }, + "fn": web_search, +} + +READ_PAGE_TOOL = { + "name": "read_page", + "description": ( + "Read the text of one web page (a search result, a report, a paper, a " + "dataset page) when a snippet is not enough to rely on. Returns up to " + f"{PAGE_CHARS} characters from `offset`; if the reply carries `next_offset`, " + "call again with it to keep reading." + ), + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "the page to read"}, + "offset": { + "type": "integer", + "description": "character offset to start from (default 0)", + }, + }, + "required": ["url"], + }, + "fn": read_page, +} + +# the default grounding toolset for a forecasting run +FORECAST_TOOLS = [WEB_SEARCH_TOOL, READ_PAGE_TOOL]