diff --git a/docs/examples/finance-chart-sandbox/README.mdx b/docs/examples/finance-chart-sandbox/README.mdx index 62ee87c..47bf972 100644 --- a/docs/examples/finance-chart-sandbox/README.mdx +++ b/docs/examples/finance-chart-sandbox/README.mdx @@ -1,42 +1,43 @@ --- title: Finance Chart (Sandbox) -description: Chart a stock's closing-price history with the Perplexity Agent API sandbox tool — fetched and built inside an isolated container as a background task, then rendered locally. +description: Chart a stock's closing-price history with the Perplexity Agent API sandbox tool — the sandbox fetches the prices and renders the chart inside an isolated container, returning the CSV and PNG as downloadable files. sidebar_position: 8 -keywords: [agent-api, sandbox, code-execution, stock-chart, matplotlib, csv, background] +keywords: [agent-api, sandbox, code-execution, file-creation, stock-chart, matplotlib, csv, png, background] --- # Finance Chart (Sandbox) A command-line tool that charts a stock's closing-price history using Perplexity's [Agent API](https://docs.perplexity.ai/docs/agent-api/quickstart) [`sandbox`](https://docs.perplexity.ai/docs/agent-api/tools/sandbox) tool. -The whole agent loop runs inside **one background Agent API request**: +Everything runs inside **one background Agent API request**: -1. The model is given the `sandbox` tool — a full agentic Python environment that includes the Perplexity SDK (web search + URL fetch). -2. Inside the sandbox it finds and fetches the ticker's historical daily closing prices, parses them, and prints a clean `date,close` CSV to stdout between sentinel fences. +1. The model is given the `sandbox` tool — a Python environment with `urllib`/`pandas`/`matplotlib` and a writable working directory. +2. Inside the sandbox it fetches the ticker's daily closing prices from a **pinned data source** (Yahoo Finance's v8 chart JSON endpoint), **writes `prices.csv`**, and **renders `chart.png`**. The sandbox exposes both files as downloadable **artifacts**. -The script polls the request to completion, pulls the CSV out of the sandbox's stdout, saves it, and renders the line chart **locally** with matplotlib. +The script polls the request to completion and downloads both files — no local rendering, so it has no third-party dependencies. -![AAPL closing price chart, last month](../../static/img/finance-chart-sandbox-aapl.png) +![AAPL closing price chart, last month](https://raw.githubusercontent.com/perplexityai/api-cookbook/main/static/img/finance-chart-sandbox-aapl.png) ## How it differs from the docs (important) This recipe was built and verified against the live Agent API. A few realities shape the design: - **The `sandbox` tool requires a background task.** On the synchronous/streaming path the request is rejected with `streaming failed: ... unknown tool "sandbox"`. You must submit with `background: true` and poll the response by id. This script always does that. -- **`stdout` is nested.** The execution output lives at `sandbox_results.results[].stdout`, not at the top level of the `sandbox_results` item. -- **`finance_search` has no history (current deployment).** The top-level `finance_search` tool returns only the latest quote, so the price *series* is gathered from inside the sandbox using its in-container Perplexity SDK. -- **Sandbox data fetching is best-effort.** Because the sandbox pulls from third-party web sources, requests can be rate-limited. The script retries the whole call a few times (`--attempts`) until it gets a usable CSV. -- **No PNG comes back.** `sandbox_results` carries only text, so the chart is rendered client-side — and you keep a reusable `.csv`. +- **The sandbox returns files.** Anything the sandbox writes to its working directory comes back as a `share_file` output item carrying a `file_id`, `filename`, and a ready `/v1/responses/{id}/files/{file_id}/content` URL — you can also list them with `GET /v1/responses/{id}/files`. The script downloads the `prices.csv` and `chart.png` artifacts directly, instead of scraping anything out of `stdout`. +- **Pin the data source to cut latency.** The slow part of an unconstrained sandbox run is the model *hunting* for a price source (public pages `429` or gate behind captchas — easily 3–7 sandbox calls). Telling it to hit Yahoo's v8 chart JSON endpoint directly turns that into a **single** fetch, and leaves token budget to render the chart in the same session. A typical run is now **one sandbox invocation**. +- **Give the sandbox output-token headroom.** The sandbox spends `max_output_tokens` *writing the code* that fetches the data and renders the chart. A tight cap can starve the file-writing step (the data gets fetched but the files are never written). This recipe uses `8192`. +- **`finance_search` has no history (current deployment).** The top-level `finance_search` tool returns only the *latest quote* — a single row — so it can't produce a price *series*. The history is fetched inside the sandbox instead. +- **Sandbox data fetching is best-effort.** Even a pinned source can rate-limit; the prompt falls back to a second source (Stooq), and the script retries the whole call a few times (`--attempts`). The Agent API is called over **raw HTTP** (stdlib `urllib`, no SDK) so the exact request body is visible and the endpoint is configurable. ## Features - One background request orchestrates the sandbox; the script polls it to completion (resilient to transient 5xx) -- Sandbox fetches the price history itself and emits a fenced `date,close` CSV; the script extracts it from `sandbox_results.results[].stdout` (with message-text fallbacks) -- Automatic retries until a usable CSV is parsed -- Renders a clean closing-price line chart with matplotlib (headless `Agg` backend) -- Saves both a reusable CSV and a PNG +- The sandbox **fetches the prices and renders the chart itself**, writing a `date,close` CSV and a PNG; the script reads the `share_file` artifacts off the response and downloads them (falling back to the `/files` endpoint) +- Pinned data source (Yahoo v8 chart JSON, Stooq fallback) keeps it to ~1 sandbox call +- Automatic retries until both files come back and the CSV parses +- No third-party dependencies — the chart is rendered server-side, in the sandbox - Configurable `--base-url` / `PERPLEXITY_BASE_URL`; reports sandbox invocation count and request cost ## Prerequisites @@ -48,7 +49,8 @@ The Agent API is called over **raw HTTP** (stdlib `urllib`, no SDK) so the exact ```bash cd docs/examples/finance-chart-sandbox -pip install -r requirements.txt # matplotlib only — the API is called over raw HTTP +# No dependencies to install — the API is called over raw HTTP and the chart is +# rendered inside the sandbox. (requirements.txt is intentionally empty.) chmod +x finance_chart_sandbox.py ``` @@ -74,7 +76,7 @@ This writes `AAPL_6mo.csv` and `AAPL_6mo.png` to the current directory. ```bash ./finance_chart_sandbox.py TICKER [--period 6mo] [--start YYYY-MM-DD --end YYYY-MM-DD] \ - [--model MODEL] [--attempts 3] [--max-steps 25] [--poll-timeout 300] \ + [--model MODEL] [--attempts 3] [--max-steps 15] [--poll-timeout 300] \ [--out-dir DIR] [--base-url URL] [--api-key KEY] [--keep-json] ``` @@ -99,22 +101,22 @@ PERPLEXITY_BASE_URL=https://api.perplexity.ai ./finance_chart_sandbox.py NVDA ## Example Output ``` -[attempt 1/3] Asking the sandbox to fetch AAPL closing prices over the past 1 month... +[attempt 1/3] Asking the sandbox to fetch AAPL closing prices over the past 1 month and plot them... -Data points: 21 (2026-04-30 → 2026-05-29) +Data points: 21 (2026-05-08 → 2026-06-08) CSV: AAPL_1mo.csv -Chart: AAPL_1mo.png -Sandbox invocations: 8 -Cost: 0.3509 USD +Chart: AAPL_1mo.png (fetched and rendered in the sandbox) +Sandbox invocations: 1 +Cost: 0.1003 USD ``` The CSV (`AAPL_1mo.csv`): ```csv date,close -2026-04-30,271.35 -2026-05-01,280.14 -2026-05-04,276.83 +2026-05-08,293.32 +2026-05-11,292.68 +2026-05-12,294.80 ... ``` @@ -124,17 +126,19 @@ date,close A small web app in [`webapp/`](webapp/) puts a **natural-language** front door on the agent loop: ask *"What was Apple's stock price over the last 6 months?"* -and the model resolves the ticker and period itself, fetches the prices in the -sandbox, and the page charts the result. +and the model resolves the ticker and period itself, fetches the prices and +renders the chart in the sandbox, and the page shows the result. Unlike the CLI (which calls the API over raw HTTP), the web backend uses the -**Perplexity Python SDK** and reuses the CLI module's CSV-extraction and parsing +**Perplexity Python SDK** and reuses the CLI module's parsing and shared-file helpers. It runs a **two-phase** flow, because the `sandbox` tool only runs as a (non-streamable) background task: 1. **Data** — a background call (`client.responses.create(..., background=True)` then `client.responses.retrieve(id)`) where the sandbox resolves the ticker + - period, fetches the prices, and prints a `META`/`CSV` block. + period, fetches the prices, **writes `prices.csv` and renders `chart.png`** + (both downloaded by the backend), and prints a tiny `META` block for routing + (ticker + label). 2. **Answer** — a separate **streaming** call (`stream=True`) that writes a short natural-language analysis of the series, token by token. @@ -142,16 +146,17 @@ helpers. It runs a **two-phase** flow, because the `sandbox` tool only runs as a | --- | --- | | `POST /api/charts` | Submit a question (`{query, attempts?}`) → returns a `job_id` | | `GET /api/charts/{job_id}/events` | **Server-Sent Events**: `progress` → `chart` → streamed `token`s → `done` | +| `GET /api/charts/{job_id}/chart.png` | The chart PNG the sandbox rendered | | `GET /api/charts/{job_id}/response.json` | The **raw Agent API response** from phase 1 (sandbox code, stdout, usage) | | `GET /api/charts/{job_id}/csv` | Download the `date,close` CSV | The job runs in a worker thread and writes incremental state onto the job; the SSE endpoint merely *tails* that state, so reconnects never re-run the work. The -frontend (vanilla JS + [Chart.js](https://www.chartjs.org/) from a CDN — no -build step) renders the chart on the `chart` event, appends the streamed -analysis live, and links to the raw JSON for inspection. +frontend is vanilla JS with **no build step and no charting library** — on the +`chart` event it simply points an `` at the sandbox-rendered PNG, appends +the streamed analysis live, and links to the raw JSON and CSV. -![Finance Chart sandbox web UI](../../static/img/finance-chart-sandbox-ui.png) +![Finance Chart sandbox web UI](https://raw.githubusercontent.com/perplexityai/api-cookbook/main/static/img/finance-chart-sandbox-ui.png) ### Run it @@ -164,7 +169,7 @@ python app.py # serves http://127.0.0.1:8000 ``` Open the page, type a question (or click an example), and hit **Ask**. The -status line updates per attempt while the background sandbox runs (~30–60s). +status line updates per attempt while the background sandbox runs (~20–40s). ## Code Walkthrough @@ -173,15 +178,18 @@ status line updates per attempt while the background sandbox runs (~30–60s). ```python payload = { "model": "openai/gpt-5.5", - "instructions": SYSTEM_PROMPT, # "print the CSV between fences" - "input": "Produce the daily closing-price CSV for AAPL over the past 6 months. ...", + "instructions": SYSTEM_PROMPT, # "fetch from Yahoo v8, write CSV + render PNG" + "input": "Fetch this exact URL ... Write prices.csv and render chart.png for AAPL ...", "tools": [{"type": "sandbox"}], "background": True, # required for the sandbox tool - "max_steps": 25, + "max_output_tokens": 8192, # headroom for the in-sandbox code + "max_steps": 15, } # POST https://api.perplexity.ai/v1/responses (Authorization: Bearer ) ``` +The prompt pins the data source (`https://query1.finance.yahoo.com/v8/finance/chart/?range=&interval=1d`, Stooq as fallback) so the sandbox fetches in one shot instead of hunting across rate-limited pages. + **2. Poll the response by id until it completes.** ```python @@ -191,38 +199,40 @@ while body["status"] in ("queued", "in_progress"): body = get(f"/v1/responses/{body['id']}") # tolerate transient 5xx ``` -**3. Pull the CSV out of the nested sandbox stdout.** +**3. Find the files the sandbox shared and download them.** ```python for item in body["output"]: - if item["type"] == "sandbox_results": - for res in item["results"]: - stdout = res["stdout"] # contains the fenced CSV + if item["type"] == "share_file": + url = item["url"] # /v1/responses/{id}/files/{file_id}/content + # item["filename"] is "prices.csv" or "chart.png" +data = get_raw(url) # Authorization: Bearer ``` -The script searches each `sandbox_results.results[].stdout` for text between the `===CSV_START===` / `===CSV_END===` fences (falling back to the message text and a ```` ```csv ```` block), validates it parses into ≥2 `date,close` rows, and retries the whole call if not. +The script reads the `share_file` items off the response (falling back to `GET /v1/responses/{id}/files` if none are inlined), downloads both the `.csv` and the `.png`, validates the CSV parses into ≥2 `date,close` rows, and retries the whole call if not. -**4. Render the chart locally.** The CSV is parsed with the stdlib `csv` module and plotted with matplotlib's headless `Agg` backend. Because the sandbox returns only text, rendering lives on the client side and you keep a tidy `.csv`. +**4. Keep the files.** The CSV and the sandbox-rendered PNG are written to disk — there's nothing to render client-side, so the CLI has no third-party dependencies. (The CSV is parsed only to report the series length.) ## Prompting Guidance -- **Fence the machine-readable output.** Asking the sandbox to wrap the CSV in unique sentinel lines makes extraction robust even when the model adds commentary or debug prints. -- **Tell it to retry sources.** Public price endpoints rate-limit (e.g. Yahoo `429`) or gate behind captchas; instructing the model to try another source on failure improves the hit rate. +- **Make the files the deliverable.** State plainly that the task is complete only once `prices.csv` and `chart.png` exist in the working directory — otherwise the model may answer with the prices in prose and never write the files. +- **Pin the data source.** Handing the sandbox the exact fetch URL (Yahoo's v8 chart JSON) collapses a multi-call source hunt into a single fetch — the biggest latency win — and frees up budget to render the chart in the same session. +- **Give output-token headroom.** The sandbox spends `max_output_tokens` writing the code that fetches the data and renders the chart; with too small a cap it runs out before the write step. `8192` is comfortable. +- **Name a fallback source.** Even a pinned endpoint can `429`; telling the model to fall back to a second source (Stooq) improves the hit rate. - **Forbid fabrication.** The system prompt instructs the model to use only prices it actually retrieved — never to interpolate or estimate. ## Pricing - **`sandbox`**: `$0.03` per container session -- **In-sandbox SDK search queries**: `$0.005` per request (the sandbox issues these to gather the data) - **Model tokens**: billed separately per Agent API token pricing -Sandbox invocations are counted under `usage.tool_calls_details.sandbox.invocation`. A typical run here is a few sandbox calls plus a handful of in-sandbox searches. See [Perplexity Pricing](https://docs.perplexity.ai/docs/getting-started/pricing) for current rates. +Sandbox invocations are counted under `usage.tool_calls_details.sandbox.invocation`, and file sharing under `usage.tool_calls_details.share_file.invocation`. With a pinned data source a typical run is **one** sandbox invocation (no in-container web searches), which keeps cost low (~`$0.10` in our runs). See [Perplexity Pricing](https://docs.perplexity.ai/docs/getting-started/pricing) for current rates. ## Limitations - `sandbox` is in **preview** and must be run as a background task -- Price history is fetched from third-party web sources inside the sandbox, so **data accuracy and availability depend on those sources** — values should be sanity-checked, and obscure/non-US tickers may fail -- Fetching is **best-effort**: rate limits can cause an attempt to return no CSV; the script retries, but a run may still fail (raise `--attempts`) +- Price history comes from third-party sources (Yahoo v8 chart JSON, Stooq fallback) fetched inside the sandbox, so **data accuracy and availability depend on those sources** — values should be sanity-checked, and obscure/non-US tickers may fail (Stooq expects a `.us` suffix) +- Fetching is **best-effort**: rate limits can cause an attempt to return no files; the script retries, but a run may still fail (raise `--attempts`) - Each attempt is a separate billed sandbox session - This is not investment advice diff --git a/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py index f3687e4..5d27a30 100644 --- a/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py +++ b/docs/examples/finance-chart-sandbox/finance_chart_sandbox.py @@ -1,31 +1,40 @@ #!/usr/bin/env python3 """ Finance Chart (Sandbox) - Plot a stock's closing-price history using the -Perplexity Agent API ``sandbox`` tool, then render the chart locally. +Perplexity Agent API ``sandbox`` tool. The sandbox fetches the prices AND +renders the chart, returning both as files — no local rendering. -The agent loop runs entirely inside one **background** Agent API request: +Everything runs inside one **background** Agent API request: - 1. The model is given the ``sandbox`` tool — a full agentic Python - environment that includes the Perplexity SDK (web search + URL fetch). - 2. Inside the sandbox it searches for / fetches the ticker's historical - daily closing prices, parses them, and prints a clean ``date,close`` CSV - to stdout between sentinel fences. + 1. The model is given the ``sandbox`` tool — a Python environment with + ``urllib``/``pandas``/``matplotlib`` and a writable working directory. + 2. It fetches the ticker's daily closing prices from a **pinned** data source + (Yahoo Finance's v8 chart JSON endpoint), writes them to ``prices.csv``, + and renders a line chart to ``chart.png``. + 3. Both files are saved to the sandbox workspace, which the Agent API exposes + as downloadable **artifacts** (``share_file`` output items). -We poll the request until it completes, pull the CSV out of the sandbox's -stdout (``sandbox_results.results[].stdout``), save it, and render the line -chart locally with matplotlib. +We poll the request to completion, read the shared files off the response, and +download the CSV and PNG. This script has **no third-party dependencies** — it +only speaks raw HTTP. Why this shape? - The ``sandbox`` tool is rejected on the synchronous/streaming path ("streaming failed: ... unknown tool"); it must run with ``background: true`` and be polled by id. This script always does that. -- ``sandbox_results`` carries only text (code/stdout/stderr) — there is no - binary artifact channel — so the chart is rendered on this side, and you - also keep a reusable ``.csv``. -- Top-level ``finance_search`` returns only the latest quote (no history) on - the current deployment, so the price *series* is gathered from inside the - sandbox. Because that relies on third-party web data, it is best-effort: - the script retries the whole call a few times until it gets a usable CSV. +- The sandbox now creates files. Anything written to the workspace comes back + as a ``share_file`` output item (``file_id`` + a ``/v1/responses/{id}/files/ + {file_id}/content`` url); you can also list them via + ``GET /v1/responses/{id}/files``. So both the CSV and the chart PNG are + downloaded directly. +- **Latency: pin the data source.** The slow part of an unconstrained sandbox + run is the model *discovering* a working price source (public pages 429 or + gate behind captchas). Telling it to hit Yahoo's v8 chart JSON endpoint + directly turns a multi-call hunt into a single fetch, which also leaves token + budget for rendering the chart in the same session. +- **``finance_search`` has no history.** The top-level ``finance_search`` tool + returns only the *latest quote* (a single row) on the current deployment — it + cannot produce a price *series* — so the history is fetched in the sandbox. The Agent API is called over **raw HTTP** (no SDK) so the request body — and the sandbox tool in it — is fully visible, and the endpoint is configurable @@ -41,12 +50,11 @@ import csv import json import os -import re import sys import time import urllib.error import urllib.request -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Tuple @@ -54,12 +62,17 @@ DEFAULT_BASE_URL = "https://api.perplexity.ai" RESPONSES_PATH = "/v1/responses" -CSV_START = "===CSV_START===" -CSV_END = "===CSV_END===" +# Filenames the sandbox is told to produce, matched on the way back by suffix. +CSV_NAME = "prices.csv" +PNG_NAME = "chart.png" +# Yahoo's v8 chart JSON understands these range tokens directly; anything else +# is expressed as an explicit period1/period2 window instead. +YAHOO_RANGES = {"1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"} -# Friendly --period values mapped to a natural-language phrase the model can -# act on. Anything not in this map is passed through verbatim. + +# Friendly --period values mapped to a natural-language phrase (used in logs / +# the chart title). Anything not in this map is passed through verbatim. PERIOD_PHRASES: Dict[str, str] = { "1mo": "the past 1 month", "3mo": "the past 3 months", @@ -70,30 +83,40 @@ } -SYSTEM_PROMPT = f"""You run inside a Python sandbox that includes the -`perplexity` SDK (web search and URL fetch) plus pandas and the standard -library. Your job is to produce a CSV of a stock's daily closing prices. - -Approach: -- Use the perplexity SDK to obtain the daily closing prices: search the web - and/or fetch a historical-price page that exposes a clean date/close table. -- If a source fails or is rate-limited, try a different one. Do not give up - after a single failure. -- Print ONLY the final CSV to stdout, wrapped exactly between these fences: - {CSV_START} - - {CSV_END} - Header must be `date,close`; one row per trading day; sorted ascending by - date; dates as YYYY-MM-DD; close as a plain number. Put no logs, debug - output, or commentary inside the fences. - -Never fabricate or interpolate prices — use only values you actually -retrieved.""" +SYSTEM_PROMPT = f"""You run inside a Python sandbox with a writable working +directory that includes `urllib`/`requests`, `pandas`, `matplotlib`, and the +standard library. Your job is to produce two files: a CSV of a stock's daily +closing prices and a line chart of them. + +The two files ARE the deliverable. The task is complete only once `{CSV_NAME}` +and `{PNG_NAME}` exist in the working directory — they are returned to the +caller as downloadable artifacts. Do not end your turn with a text answer in +place of the files. + +Steps: +1. Fetch the daily closing prices from the EXACT URL you are given (Yahoo + Finance's v8 chart JSON), sending a browser `User-Agent` header such as + `Mozilla/5.0`. Parse `result.timestamp` (epoch seconds) together with + `result.indicators.quote[0].close`; drop any null closes. If that request + fails or is rate-limited, fall back to the Stooq daily CSV + (`https://stooq.com/q/d/l/?s=.us&i=d`) and filter to the window. + Never fabricate or interpolate prices. +2. Write the data to `{CSV_NAME}`: header `date,close`; one row per trading + day; sorted ascending by date; dates YYYY-MM-DD; close as a plain number. +3. Render a closing-price line chart with matplotlib (headless `Agg` backend) + and save it to `{PNG_NAME}`: + - figure ~10x5 inches at 150 dpi + - a single line in color #1f77b4, ~1.6pt wide, with a light fill below it + - dashed gridlines, x-axis label "Date", y-axis label "Close (USD)" + - the exact title you are given + - concise, auto-spaced date ticks on the x-axis +4. Verify both files exist, then print only a one-line confirmation.""" PROMPT_TEMPLATE = ( - "Produce the daily closing-price CSV for {ticker} over {period_phrase}. " - "Print it to stdout between the {start} / {end} fences." + "Fetch this exact URL for the daily closing prices: {url}\n" + "Write {csv} and render {png} for {ticker} over {period_phrase}. " + 'Title the chart exactly "{ticker} closing price — {period_label}".' ) @@ -141,7 +164,7 @@ def _request( headers={ "Authorization": f"Bearer {key}", "Content-Type": "application/json", - "User-Agent": "api-cookbook-finance-chart-sandbox/1.0", + "User-Agent": "api-cookbook-finance-chart-sandbox/3.0", }, method=method, ) @@ -155,6 +178,21 @@ def _request( return err.code, {"error": {"message": err.reason}} +def _download(base_url: str, key: str, url_or_path: str, timeout: int = 120) -> bytes: + """GET a file's raw bytes from an absolute URL or a base-relative path.""" + url = url_or_path if url_or_path.startswith("http") else base_url + url_or_path + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Bearer {key}", + "User-Agent": "api-cookbook-finance-chart-sandbox/3.0", + }, + method="GET", + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.read() + + def _poll(base_url: str, key: str, response_id: str, deadline: float) -> dict: """Poll a background response until terminal status (resilient to 5xx).""" url = f"{base_url}{RESPONSES_PATH}/{response_id}" @@ -170,11 +208,41 @@ def _poll(base_url: str, key: str, response_id: str, deadline: float) -> dict: return body +def yahoo_chart_url( + ticker: str, period: str, start: Optional[str], end: Optional[str] +) -> str: + """Build the Yahoo v8 chart JSON URL for the ticker over the window. + + Uses a ``range`` token for the standard lookback periods; an explicit + ``period1``/``period2`` epoch window for date ranges or non-standard + periods. + """ + base = f"https://query1.finance.yahoo.com/v8/finance/chart/{ticker.upper()}" + if not start and not end and period in YAHOO_RANGES: + return f"{base}?range={period}&interval=1d" + + def _epoch(date_str: str) -> int: + return int( + datetime.strptime(date_str, "%Y-%m-%d") + .replace(tzinfo=timezone.utc) + .timestamp() + ) + + p1 = _epoch(start) if start else 0 + # +1 day so the end date itself is included. + p2 = _epoch(end) + 86400 if end else int(time.time()) + return f"{base}?period1={p1}&period2={p2}&interval=1d" + + def run_sandbox_request( base_url: str, key: str, ticker: str, + period: str, + period_label: str, period_phrase: str, + start: Optional[str], + end: Optional[str], model: str, max_steps: int, poll_timeout: int, @@ -184,14 +252,19 @@ def run_sandbox_request( "model": model, "instructions": SYSTEM_PROMPT, "input": PROMPT_TEMPLATE.format( + url=yahoo_chart_url(ticker, period, start, end), ticker=ticker.upper(), + period_label=period_label, period_phrase=period_phrase, - start=CSV_START, - end=CSV_END, + csv=CSV_NAME, + png=PNG_NAME, ), "tools": [{"type": "sandbox"}], "background": True, - "max_output_tokens": 4096, + # Headroom: the sandbox spends output tokens writing the code that + # fetches the data and renders the chart; a tight cap can starve the + # file-writing step. + "max_output_tokens": 8192, "max_steps": max_steps, } status, body = _request( @@ -205,60 +278,53 @@ def run_sandbox_request( # --------------------------------------------------------------------------- -# Response parsing +# Files the sandbox produced # --------------------------------------------------------------------------- -def _sandbox_stdout(response: dict) -> str: - """Concatenate stdout from every sandbox execution result.""" - chunks: List[str] = [] - for item in response.get("output", []) or []: - if item.get("type") != "sandbox_results": - continue - # Real shape nests executions under `results`; tolerate a flat shape. - results = item.get("results") - if results: - for res in results: - if res.get("stdout"): - chunks.append(res["stdout"]) - elif item.get("stdout"): - chunks.append(item["stdout"]) - return "\n".join(chunks) - - -def _message_text(response: dict) -> str: - """Concatenate assistant ``output_text`` blocks.""" - chunks: List[str] = [] - for item in response.get("output", []) or []: - if item.get("type") != "message": - continue - for block in item.get("content", []) or []: - if block.get("type") == "output_text" and block.get("text"): - chunks.append(block["text"]) - return "\n".join(chunks) +def shared_files(response: dict, base_url: str, key: str) -> List[Dict[str, str]]: + """List files the sandbox shared, as ``[{filename, url}]``. - -def extract_csv(response: dict) -> Optional[str]: - """Find the fenced CSV in the sandbox stdout, then the message text. - - Returns the CSV body (without fences), or None if nothing usable is found. + Prefers the ``share_file`` items embedded in the response ``output`` (they + carry a ready download ``url``); falls back to ``GET /v1/responses/{id}/ + files`` and constructs the content path. """ - fence = re.compile( - re.escape(CSV_START) + r"\s*(.*?)\s*" + re.escape(CSV_END), re.S + files: List[Dict[str, str]] = [] + for item in response.get("output", []) or []: + if item.get("type") == "share_file" and item.get("url"): + files.append({"filename": item.get("filename", ""), "url": item["url"]}) + if files: + return files + + response_id = response.get("id") + if not response_id: + return files + status, body = _request( + "GET", f"{base_url}{RESPONSES_PATH}/{response_id}/files", key, None, timeout=60 ) - for haystack in (_sandbox_stdout(response), _message_text(response)): - match = fence.search(haystack) - if match and match.group(1).strip(): - return match.group(1).strip() - # Fallback: a fenced ```csv block in the message. - block = re.search(r"```csv\s*(.*?)```", _message_text(response), re.S) - if block: - lines = block.group(1).strip().splitlines() - if lines and "date" in lines[0].lower(): - return block.group(1).strip() + if status >= 400: + return files + for item in body.get("data", []) or []: + if item.get("id"): + files.append({ + "filename": item.get("filename", ""), + "url": f"{RESPONSES_PATH}/{response_id}/files/{item['id']}/content", + }) + return files + + +def pick_file(files: List[Dict[str, str]], suffix: str) -> Optional[Dict[str, str]]: + """Return the first shared file whose name ends with ``suffix``.""" + for f in files: + if f.get("filename", "").lower().endswith(suffix): + return f return None def parse_csv(csv_text: str) -> Tuple[List[datetime], List[float]]: - """Parse `date,close` CSV text into parallel lists, sorted by date.""" + """Parse `date,close` CSV text into parallel lists, sorted by date. + + Used to validate the downloaded CSV and report the series length — the + chart itself is rendered inside the sandbox. + """ reader = csv.DictReader(csv_text.splitlines()) if not reader.fieldnames: raise RuntimeError("Empty CSV.") @@ -287,37 +353,6 @@ def parse_csv(csv_text: str) -> Tuple[List[datetime], List[float]]: return [r[0] for r in rows], [r[1] for r in rows] -def render_chart( - dates: List[datetime], - closes: List[float], - ticker: str, - period_label: str, - png_path: Path, -) -> None: - """Render a closing-price line chart to ``png_path``.""" - import matplotlib - - matplotlib.use("Agg") # headless: no display needed - import matplotlib.pyplot as plt - from matplotlib.dates import AutoDateLocator, ConciseDateFormatter - - fig, ax = plt.subplots(figsize=(10, 5)) - ax.plot(dates, closes, color="#1f77b4", linewidth=1.6) - ax.fill_between(dates, closes, min(closes), color="#1f77b4", alpha=0.08) - ax.set_title(f"{ticker.upper()} closing price — {period_label}") - ax.set_xlabel("Date") - ax.set_ylabel("Close (USD)") - ax.grid(True, linestyle="--", alpha=0.4) - - locator = AutoDateLocator() - ax.xaxis.set_major_locator(locator) - ax.xaxis.set_major_formatter(ConciseDateFormatter(locator)) - - fig.tight_layout() - fig.savefig(png_path, dpi=150) - plt.close(fig) - - def sandbox_invocations(response: dict) -> int: details = (response.get("usage") or {}).get("tool_calls_details") or {} return (details.get("sandbox") or {}).get("invocation", 0) or 0 @@ -347,23 +382,27 @@ def build_period_phrase( return period, PERIOD_PHRASES.get(period, f"the past {period}") -def fetch_price_series( +def fetch_chart( base_url: str, key: str, ticker: str, + period: str, + period_label: str, period_phrase: str, + start: Optional[str], + end: Optional[str], model: str, attempts: int, max_steps: int, poll_timeout: int, on_attempt=None, -) -> Tuple[List[datetime], List[float], str, dict]: - """Run up to ``attempts`` background sandbox calls until a usable CSV parses. +) -> Tuple[bytes, bytes, List[datetime], dict]: + """Run up to ``attempts`` background sandbox calls until both files come back. - Returns ``(dates, closes, csv_text, response)``. Raises ``RuntimeError`` if - no attempt yields a parseable ``date,close`` CSV. ``on_attempt(n, total, - note)`` is an optional progress callback (``note`` is None at the start of - an attempt, or a short failure reason). + Returns ``(csv_bytes, png_bytes, dates, response)``. Raises ``RuntimeError`` + if no attempt yields a downloadable CSV+PNG pair with a usable series. + ``on_attempt(n, total, note)`` is an optional progress callback (``note`` is + None at the start of an attempt, or a short failure reason). """ response: dict = {} for attempt in range(1, attempts + 1): @@ -371,7 +410,8 @@ def fetch_price_series( on_attempt(attempt, attempts, None) try: response = run_sandbox_request( - base_url, key, ticker, period_phrase, model, max_steps, poll_timeout + base_url, key, ticker, period, period_label, period_phrase, + start, end, model, max_steps, poll_timeout, ) except (RuntimeError, TimeoutError) as err: if on_attempt: @@ -383,21 +423,33 @@ def fetch_price_series( on_attempt(attempt, attempts, f"request failed: {response.get('error')}") continue - candidate = extract_csv(response) - if not candidate: + files = shared_files(response, base_url, key) + csv_file = pick_file(files, ".csv") + png_file = pick_file(files, ".png") + if not csv_file or not png_file: + have = ", ".join(f.get("filename", "?") for f in files) or "none" if on_attempt: - on_attempt(attempt, attempts, "no fenced CSV in output") + on_attempt(attempt, attempts, f"missing CSV/PNG (got: {have})") continue + + try: + csv_bytes = _download(base_url, key, csv_file["url"]) + png_bytes = _download(base_url, key, png_file["url"]) + except (urllib.error.URLError, TimeoutError) as err: + if on_attempt: + on_attempt(attempt, attempts, f"download failed: {err}") + continue + try: - dates, closes = parse_csv(candidate) + dates, _ = parse_csv(csv_bytes.decode("utf-8", "replace")) except RuntimeError as err: if on_attempt: on_attempt(attempt, attempts, f"unusable CSV: {err}") continue - return dates, closes, candidate, response + return csv_bytes, png_bytes, dates, response raise RuntimeError( - f"Could not obtain a usable price CSV from the sandbox after " + f"Could not obtain a usable chart from the sandbox after " f"{attempts} attempt(s). Sandbox data fetching is best-effort " "(third-party sources rate-limit); try more attempts or rerun." ) @@ -407,7 +459,8 @@ def main() -> int: parser = argparse.ArgumentParser( description=( "Plot a stock's closing-price history using the Perplexity Agent " - "API sandbox tool (background task), rendered locally." + "API sandbox tool (background task). The sandbox fetches the prices " + "and renders the chart; both come back as downloadable files." ) ) parser.add_argument("ticker", help="Ticker symbol, e.g. AAPL, MSFT, NVDA.") @@ -424,11 +477,11 @@ def main() -> int: "--attempts", type=int, default=3, - help="Max background calls to try until a usable CSV comes back " + help="Max background calls to try until the chart comes back " "(each is a separate sandbox session). Default 3.", ) parser.add_argument( - "--max-steps", type=int, default=25, help="Agent max_steps per attempt." + "--max-steps", type=int, default=15, help="Agent max_steps per attempt." ) parser.add_argument( "--poll-timeout", @@ -468,16 +521,17 @@ def _log(attempt: int, total: int, note: Optional[str]) -> None: if note is None: print( f"[attempt {attempt}/{total}] Asking the sandbox to fetch " - f"{ticker} closing prices over {period_phrase}...", + f"{ticker} closing prices over {period_phrase} and plot them...", file=sys.stderr, ) else: print(f" {note}", file=sys.stderr) try: - dates, closes, csv_text, response = fetch_price_series( - args.base_url, key, ticker, period_phrase, args.model, - args.attempts, args.max_steps, args.poll_timeout, on_attempt=_log, + csv_bytes, png_bytes, dates, response = fetch_chart( + args.base_url, key, ticker, args.period, period_label, period_phrase, + args.start, args.end, args.model, args.attempts, args.max_steps, + args.poll_timeout, on_attempt=_log, ) except RuntimeError as err: print(f"Error: {err}", file=sys.stderr) @@ -486,13 +540,13 @@ def _log(attempt: int, total: int, note: Optional[str]) -> None: if args.keep_json and response: (out_dir / f"{slug}.json").write_text(json.dumps(response, indent=2)) - csv_path.write_text(csv_text + "\n") - render_chart(dates, closes, ticker, period_label, png_path) + csv_path.write_bytes(csv_bytes) + png_path.write_bytes(png_bytes) print(f"\nData points: {len(dates)} " f"({dates[0]:%Y-%m-%d} → {dates[-1]:%Y-%m-%d})") print(f"CSV: {csv_path}") - print(f"Chart: {png_path}") + print(f"Chart: {png_path} (fetched and rendered in the sandbox)") print(f"Sandbox invocations: {sandbox_invocations(response)}") cost = total_cost(response) if cost is not None: diff --git a/docs/examples/finance-chart-sandbox/requirements.txt b/docs/examples/finance-chart-sandbox/requirements.txt index 632e087..d1d59d8 100644 --- a/docs/examples/finance-chart-sandbox/requirements.txt +++ b/docs/examples/finance-chart-sandbox/requirements.txt @@ -1,2 +1,5 @@ -# The Agent API is called over raw HTTP (stdlib urllib) — no SDK needed. -matplotlib>=3.7 +# No third-party dependencies. +# +# The Agent API is called over raw HTTP (stdlib urllib). The sandbox both +# fetches the prices and renders the chart, so the CSV and PNG are downloaded +# as files — this CLI needs nothing beyond the Python standard library (3.9+). diff --git a/docs/examples/finance-chart-sandbox/webapp/app.py b/docs/examples/finance-chart-sandbox/webapp/app.py index 0f3fd53..427b821 100644 --- a/docs/examples/finance-chart-sandbox/webapp/app.py +++ b/docs/examples/finance-chart-sandbox/webapp/app.py @@ -8,7 +8,8 @@ Phase 1 (data) A *background* Agent API request gives the model the ``sandbox`` tool, which resolves the ticker + period from the question, fetches the daily closing prices inside an - isolated container, and prints a META + ``date,close`` CSV. + isolated container, and **writes them to a CSV file** + (downloaded here) plus a tiny META block on stdout. Phase 2 (answer) A *streaming* request (no sandbox) writes a short natural-language analysis of that series, token by token. @@ -26,7 +27,7 @@ Execution runs in a worker thread and writes incremental state onto the job, so the SSE stream merely *tails* that state — reconnects never re-run the work. -CSV-extraction/parsing helpers are reused from the CLI module +CSV parsing and shared-file helpers are reused from the CLI module (``finance_chart_sandbox``); only the API call differs (SDK here, raw HTTP there). """ @@ -59,34 +60,40 @@ _LOCK = threading.Lock() DEFAULT_MODEL = "openai/gpt-5.5" +POLL_TIMEOUT = 300 META_START = "===META_START===" META_END = "===META_END===" -# Phase 1: data-gathering inside the sandbox. +# Phase 1: data-gathering AND charting inside the sandbox. DATA_PROMPT = f"""You answer natural-language questions about a stock's recent -price history by producing a chart-ready CSV. - -You have the `sandbox` tool — an isolated Python environment that includes the -`perplexity` SDK (web search and URL fetch) plus pandas and the standard -library. - -Do this: -1. Read the user's question and determine the stock TICKER (resolve a company - name to its symbol, e.g. "apple" -> AAPL) and the time PERIOD they asked - about (default to the last 6 months if none is given). -2. Use the sandbox to obtain the DAILY closing prices for that ticker over that - period: search the web and/or fetch a historical-price page with a clean - date/close table. If a source fails or is rate-limited, try another. Never - fabricate or interpolate prices — use only values you actually retrieved. -3. Print to stdout, in exactly this order and nothing else: +price history by producing a CSV file and a line-chart PNG. + +You have the `sandbox` tool — an isolated Python environment with +`urllib`/`requests`, pandas, matplotlib, the standard library, and a writable +working directory. + +The files `{fcs.CSV_NAME}` and `{fcs.PNG_NAME}` ARE the deliverable; they are +returned to the caller as downloadable artifacts. The task is complete only +once both exist. Do this: +1. Read the question and determine the stock TICKER (resolve a company name to + its symbol, e.g. "apple" -> AAPL) and the PERIOD as a Yahoo range token + (1mo, 3mo, 6mo, 1y, 2y, 5y; default 6mo). +2. Fetch the daily closing prices from Yahoo Finance's v8 chart JSON at exactly + `https://query1.finance.yahoo.com/v8/finance/chart/?range=&interval=1d` + with a browser `User-Agent` header (e.g. `Mozilla/5.0`). Parse + `result.timestamp` (epoch) with `result.indicators.quote[0].close`; drop + null closes. If it is rate-limited, fall back to the Stooq daily CSV + (`https://stooq.com/q/d/l/?s=.us&i=d`). Never fabricate prices. +3. Write `{fcs.CSV_NAME}`: header `date,close`; ascending; dates YYYY-MM-DD; + close a plain number. +4. Render a line chart and save `{fcs.PNG_NAME}` (~10x5in @150dpi; line #1f77b4 + with a light fill; dashed grid; x-label "Date", y-label "Close (USD)"; + title " closing price —