diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fa157a6a..5fb7f1787 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ release; the candidate will be stabilized before the final `v2.2.0` release. - Routed deep research now uses explicit source-router, structured planner, concurrent researcher, and writer roles, with bounded source-tool batching and no research-plan approval step - Report follow-up supports answers over a completed report, child-job cosmetic rewrites, and delta research that carries the parent report forward as context - Clarification is more targeted: it can search for context before asking the user to narrow scope or choose an output shape +- The writer produces figures through an on-demand `chart-generation` skill in the new `visualization` skill collection, rendering a sandbox PNG artifact when a sandbox is available and an inline chart spec otherwise; the skill ships enabled only in the skills and sandbox example configs (`config_domain_routing_and_skills`, `config_openshell`), while every other config presents chart-worthy data as a Markdown table, and a writer that wants inline charts must be assigned the `visualization` collection (charts are not sandbox-gated) **Sources and integrations** diff --git a/configs/config_domain_routing_and_skills.yml b/configs/config_domain_routing_and_skills.yml index 1c3a12cce..19f468c47 100644 --- a/configs/config_domain_routing_and_skills.yml +++ b/configs/config_domain_routing_and_skills.yml @@ -179,7 +179,7 @@ functions: _type: deep_research_skills agents: researcher-agent: [research] - writer-agent: [synthesis] + writer-agent: [synthesis, visualization] require_sandbox: - research diff --git a/configs/config_openshell.yml b/configs/config_openshell.yml index e0f3384df..bf978bce8 100644 --- a/configs/config_openshell.yml +++ b/configs/config_openshell.yml @@ -148,7 +148,7 @@ functions: _type: deep_research_skills agents: researcher-agent: [research] - writer-agent: [synthesis, research] + writer-agent: [synthesis, research, visualization] require_sandbox: - research diff --git a/docs/source/architecture/agents/deep-researcher.md b/docs/source/architecture/agents/deep-researcher.md index 3c5893515..2093c00d7 100644 --- a/docs/source/architecture/agents/deep-researcher.md +++ b/docs/source/architecture/agents/deep-researcher.md @@ -137,10 +137,11 @@ runtime dependencies, not additional agents: | Inference and source tools | LLM calls, source-tool calls, credentials, orchestration state, and `/shared/` remain in the AI-Q process. Only generated code and job-workspace files cross the sandbox boundary. | The shipped `config_domain_routing_and_skills.yml` profile assigns the -`research` collection to researcher workers and the `synthesis` collection to -the writer. The research collection currently includes chart generation, -table analysis, forecast analysis, and lightweight calculations. The synthesis -collection includes long-form and prediction report writers. A skill provides +`research` collection to researcher workers and the `synthesis` and +`visualization` collections to the writer. The research collection currently +includes table analysis, forecast analysis, and lightweight calculations. The +synthesis collection includes long-form and prediction report writers, and the +visualization collection provides chart generation. A skill provides instructions; only skills that invoke `execute` require the optional sandbox. Modal and OpenShell implement the same provider-neutral job-scoped contract. diff --git a/docs/source/customization/configuration-reference.md b/docs/source/customization/configuration-reference.md index 19a1bef99..130e43110 100644 --- a/docs/source/customization/configuration-reference.md +++ b/docs/source/customization/configuration-reference.md @@ -540,6 +540,16 @@ ordered `preferred_tools` and `fallback_tools` guidance on each `ResearchQuery`; request-filtered callable set. Refer to [Tools and Sources](./tools-and-sources.md#automatic-source-routing) and the [`config_domain_routing_and_skills.yml`](../../../configs/config_domain_routing_and_skills.yml) reference profile. +```{note} +**Migration: `chart-generation` moved to the `visualization` collection.** The built-in +`chart-generation` skill previously lived in the `research` collection; it now lives in its own +`visualization` collection, and charts are no longer sandbox-gated. The `visualization` skill ships +enabled only in the skills and sandbox example configs (`config_domain_routing_and_skills.yml` and +`config_openshell.yml`); every other shipped config presents chart-worthy data as a Markdown table. +A writer that wants inline charts must be assigned the `visualization` collection in its +`deep_research_skills` assignment. +``` + --- ## `workflow` Section diff --git a/docs/source/examples/skills-sandbox/index.md b/docs/source/examples/skills-sandbox/index.md index 4b5313985..21a47a555 100644 --- a/docs/source/examples/skills-sandbox/index.md +++ b/docs/source/examples/skills-sandbox/index.md @@ -33,8 +33,9 @@ The built-in collections currently expose these role-oriented skills: | Collection | Default assignment | Skills | | ---------- | ------------------ | ------ | -| `research` | `researcher-agent` | `chart-generation`, `data-table-analysis`, `forecast-analysis`, `lightweight-calculation` | +| `research` | `researcher-agent` | `data-table-analysis`, `forecast-analysis`, `lightweight-calculation` | | `synthesis` | `writer-agent` | `long-form-report-writer`, `prediction-report-writer` | +| `visualization` | `writer-agent` | `chart-generation` | The assignment is configurable. Skill definitions stay host-side and read-only; only workflows that invoke `execute` require a sandbox. @@ -78,6 +79,7 @@ functions: - research writer-agent: - synthesis + - visualization require_sandbox: - research @@ -104,7 +106,7 @@ functions: sandbox: deep_research_sandbox ``` -AI-Q validates the public skill collection names (`research`, `synthesis`) and resolves them to DeepAgents source paths internally. When skills are configured, AI-Q mounts the configured built-in skill collections into the DeepAgents virtual filesystem. When the sandbox ref is present, DeepAgents `execute` calls run in the configured provider. Modal creates a fresh sandbox named for the job. +AI-Q validates the public skill collection names (`research`, `synthesis`, `visualization`) and resolves them to DeepAgents source paths internally. When skills are configured, AI-Q mounts the configured built-in skill collections into the DeepAgents virtual filesystem. When the sandbox ref is present, DeepAgents `execute` calls run in the configured provider. Modal creates a fresh sandbox named for the job. In the reference async API flow, artifact capture uses the job database configured by `general.front_end.db_url` (`NAT_JOB_STORE_DB_URL`) for metadata. Artifact bytes use SQL BLOB storage in the job database diff --git a/src/aiq_agent/agents/deep_researcher/deepagents_runtime.py b/src/aiq_agent/agents/deep_researcher/deepagents_runtime.py index 03d412f1e..6e89310cb 100644 --- a/src/aiq_agent/agents/deep_researcher/deepagents_runtime.py +++ b/src/aiq_agent/agents/deep_researcher/deepagents_runtime.py @@ -46,6 +46,7 @@ BUILTIN_SKILL_SOURCE = "/skills/" SHARED_ROUTE = "/shared/" SKILL_AGENT_NAMES = frozenset({"researcher-agent", "writer-agent"}) +CHART_SKILL_NAME = "chart-generation" DEFAULT_WORKDIR = "/workspace" @@ -267,6 +268,18 @@ def skill_sources_for(self, agent_name: str) -> list[str] | None: sources = self._skill_sources_by_agent.get(agent_name) return list(sources) if sources else None + def agent_has_chart_skill(self, agent_name: str) -> bool: + """Return true when the agent's assigned collections provide the chart skill on disk.""" + sources = self._skill_sources_by_agent.get(agent_name) + if not sources: + return False + collection_for_source = {source: name for name, source in discover_skill_collections().items()} + return any( + (BUILTIN_SKILLS_DIR / collection_for_source[source] / CHART_SKILL_NAME / "SKILL.md").exists() + for source in sources + if source in collection_for_source + ) + @property def workdir(self) -> str: """Job-scoped sandbox working directory (or the default when no sandbox).""" diff --git a/src/aiq_agent/agents/deep_researcher/factory.py b/src/aiq_agent/agents/deep_researcher/factory.py index 1ce8eec3e..77c87c2c7 100644 --- a/src/aiq_agent/agents/deep_researcher/factory.py +++ b/src/aiq_agent/agents/deep_researcher/factory.py @@ -532,7 +532,10 @@ def build_deep_research_subagents(context: DeepResearchGraphContext) -> list[dic TodoSuppressionMiddleware(), RequiredOutputFileMiddleware(tracker=context.final_report_tracker), ], - prompt_values={"parent_report_context_available": context.parent_report_context_available}, + prompt_values={ + "parent_report_context_available": context.parent_report_context_available, + "chart_skill_enabled": context.runtime.agent_has_chart_skill(WRITER_AGENT), + }, skills=context.skill_sources(WRITER_AGENT), ), ) diff --git a/src/aiq_agent/agents/deep_researcher/prompts/writer.j2 b/src/aiq_agent/agents/deep_researcher/prompts/writer.j2 index 82b70047e..51e160e1d 100644 --- a/src/aiq_agent/agents/deep_researcher/prompts/writer.j2 +++ b/src/aiq_agent/agents/deep_researcher/prompts/writer.j2 @@ -75,15 +75,22 @@ When synthesizing: - Do not expose internal `evidence_judgment` scores or rationale in the final answer unless the user explicitly asks for methodology. - Err on the side of more useful information rather than less, while staying focused on the requested answer shape. -{% if execution_enabled %}## Figures +{% if chart_skill_enabled %}## Figures and Charts -When `answer_strategy.required_components` calls for a chart, GENERATE it yourself with the chart skill and embed it once, where it is discussed, with `![](artifact://)`. Read the relevant chart `SKILL.md` first, then write the plotting script to `{{ sandbox_workdir }}/make_chart.py`. The script must accept its artifact directory as its first argument; run it exactly as `python3 {{ sandbox_workdir }}/make_chart.py {{ sandbox_artifact_dir }}` so it writes the image plus `manifest.json` under the only directory the runtime harvests. Never put a literal `` or `` placeholder into code or a command. After execution, use the exact filename confirmed by the artifact checkpoint in the report reference. +Charts are available for this answer. When `answer_strategy.required_components` calls for a chart (a ranking or top-N, a distribution across categories, a trend over an ordered axis, gains vs losses, or a single headline value), consult the `chart-generation` skill and read its `SKILL.md` first. The skill is the controlling procedure: it owns the full chart contract, both delivery modes, and the mode decision, so this base prompt carries no chart syntax or steps. -Each `execute` runs in a fresh shell, so `cd` does NOT persist between calls and a standalone `cd` accomplishes nothing — never navigate with `cd`; put absolute paths in every command (or chain in one line as `cd && `). +A figure is earned: chart only source-anchored, reasonably complete data. If a series is mostly undisclosed or mixes metric definitions, present the table (which shows the gaps) and state the limitation in one sentence instead of a misleading chart. -NEVER paste the plotting code, a sandbox file path (e.g. `{{ sandbox_artifact_dir }}/chart.png`), or base64 image data into the report as prose - the `artifact://` token is the ONLY way a figure renders. Do not call `read_file` on a generated PNG just to verify it; that injects the image's base64 bytes into model context. Verify the manifest and use the artifact-checkpoint response instead. A "plotting code" or "chart plan" section is not a substitute for the actual figure. Precede each embed with a one-sentence description of what it shows. +Runtime context for the skill: +{% if execution_enabled %} +- A sandbox is available, so the skill renders a PNG artifact. Your per-job working directory is `{{ sandbox_workdir }}` and the harvested artifact directory is `{{ sandbox_artifact_dir }}`; give the skill these exact paths where it asks for `sandbox_workdir` and `sandbox_artifact_dir`. +{% else %} +- There is no sandbox, so the skill delivers the chart inline in the report. +{% endif %} + +{% else %}## Figures and Charts -A figure is earned: generate or embed one only when its data is source-anchored and reasonably complete. If a quantitative series is mostly undisclosed or mixes metric definitions, present the table (which shows the gaps) and state the limitation in one sentence instead of a misleading chart. +When a required component calls for a chart, present the values as a compact Markdown table and state the takeaway in one sentence. {% endif %}## Citations - Citations are mandatory for any sourced final answer. Every material source-derived factual claim, number, date, quote, source-specific caveat, or source claim must have an inline citation like `[1]`. @@ -102,37 +109,7 @@ A figure is earned: generate or embed one only when its data is source-anchored - Do not place bare URLs in the report body; URLs belong only in the `## Sources` section. - Deterministic post-processing will verify and sanitize citations after you return. Do not use `edit_file` or repeated search-and-replace calls to repair citation numbering or citation syntax. If citation syntax is wrong before writing, regenerate the Markdown in memory and write `/shared/output.md` once. -{% if not execution_enabled %}## Presenting Data (Charts) -When the report presents numbers that compare multiple things (a ranking or top-N across entities, a distribution or counts across categories, a trend over an ordered/time axis, or gains vs losses), present them as a chart, not just prose or a table. Lead with a one-sentence verdict, then the chart. - -- Emit the chart as a fenced code block tagged `chart` holding a SINGLE line of valid JSON (no comments, no trailing commas), right after the sentence that introduces it. -- Chart to reveal the pattern and state the verdict in prose. Inline `chart` blocks render only in the web app, so ALSO place a compact markdown table of the same values immediately after each chart, keeping PDF, Markdown, API, and CLI exports readable. -- Use the ACTUAL numbers from the evidence (top ~10 rows); never invent, pad, or round away data. If you show only the top rows of a larger set, say so. -- At most 3 charts per section, and put each chart before any table. -- A single value or a one-entity yes/no result is NOT a chart: emit a KPI-only block, a fenced `chart` block whose JSON has just `title` and `kpis`. - -Chart types: `bar` (category magnitudes), `hbar` (rankings with long text labels), `line`/`area` (a trend across an ordered axis), `grouped-bar` (2-4 series per category), `delta` (gains vs losses around zero). - -Spec fields: `type`; `title` (short) and optional `subtitle`; `x` = `{ "key": "", "label": "optional" }`; optional `y` = `{ "label": "optional unit", "format": "number | compact | percent | currency" }`; `series` = `[ { "key": "", "label": "optional", "color": "green | blue | amber | red" } ]`; `data` = rows as objects with raw numbers (fractions 0-1 for `percent`); optional `kpis` = `[ { "label": "...", "value": "preformatted", "tone": "accent | warn | alarm" } ]`. - -Example (ranking): -```chart -{"type":"hbar","title":"Top suppliers by late shipments","x":{"key":"supplier"},"y":{"format":"number"},"series":[{"key":"late","color":"amber"}],"data":[{"supplier":"Acme","late":42},{"supplier":"Globex","late":31},{"supplier":"Initech","late":19}]} -``` - -Example (single value, KPI-only): -```chart -{"title":"On-time delivery rate","kpis":[{"label":"On-time","value":"92.4%","tone":"accent"}]} -``` - -For several related trends over time, emit one fenced `chart-carousel` block holding a SINGLE line of JSON with at least two line-chart specs: `{ "title": "...", "charts": [ , ... ] }`. - -Example (related trends, carousel): -```chart-carousel -{"title":"Quarterly delivery trends","charts":[{"type":"line","title":"On-time delivery rate","x":{"key":"quarter"},"y":{"format":"percent"},"series":[{"key":"rate","color":"green"}],"data":[{"quarter":"Q1","rate":0.88},{"quarter":"Q2","rate":0.90},{"quarter":"Q3","rate":0.93}]},{"type":"line","title":"Late shipments","x":{"key":"quarter"},"y":{"format":"number"},"series":[{"key":"late","color":"amber"}],"data":[{"quarter":"Q1","late":52},{"quarter":"Q2","late":41},{"quarter":"Q3","late":28}]}]} -``` - -{% endif %}## Final Output +## Final Output Write the final Markdown to `/shared/output.md`. Then return only this short completion marker as your final response: `Wrote /shared/output.md`. diff --git a/src/aiq_agent/agents/deep_researcher/skills/research/chart-generation/SKILL.md b/src/aiq_agent/agents/deep_researcher/skills/visualization/chart-generation/SKILL.md similarity index 51% rename from src/aiq_agent/agents/deep_researcher/skills/research/chart-generation/SKILL.md rename to src/aiq_agent/agents/deep_researcher/skills/visualization/chart-generation/SKILL.md index 76847076f..397c1a511 100644 --- a/src/aiq_agent/agents/deep_researcher/skills/research/chart-generation/SKILL.md +++ b/src/aiq_agent/agents/deep_researcher/skills/visualization/chart-generation/SKILL.md @@ -2,39 +2,41 @@ name: chart-generation description: > Use this skill to turn researched or computed numeric data into source-grounded - charts (PNG) plus the underlying CSV, by writing Python/matplotlib code and running - it in the job-scoped sandbox. The chart is harvested as a durable artifact and - embedded in the final report. + charts. It has two delivery modes and picks one from the tools available to you. + When an `execute` tool (sandbox) is available, render a PNG chart plus its CSV with + Python/matplotlib and embed it as a durable `artifact://` reference. When there is + no sandbox, emit the chart as an inline fenced `chart` (or `chart-carousel`) JSON + spec that the web UI renders deterministically, followed by a portable Markdown table. Triggers: "chart", "plot", "graph", "bar chart", "line chart", "visualize", - "trend over time", "compare visually", "figure". - Outputs: a PNG chart artifact, a CSV of the plotted data, and a manifest describing them. + "trend over time", "compare visually", "figure", "ranking", "top-N", "distribution". + Outputs: either a PNG chart artifact (plus CSV and manifest) or an inline chart spec. --- # Chart Generation Skill -Produce accurate, source-grounded charts using Python/matplotlib, save them as durable -artifacts, and embed them in the report by reference (never by pasting image data). +Produce accurate, source-grounded charts from researched or computed data. This skill +has two delivery modes; choose the one that matches the tools you were given, then +follow the matching section below. -## Required Execution Standard +## Choose your mode -1. **Ground the data:** build the plotted rows from researched facts or `/shared/...` - inputs. Keep source URLs/notes alongside the values. -2. **Normalize units** before plotting (currencies, magnitudes, periods). -3. **Render with code:** call `execute` to run Python/matplotlib. Do not hand-draw or - fabricate charts. -4. **Write to the artifact directory:** save the PNG and its CSV under the exact - `sandbox_artifact_dir` given in your instructions (a per-job path such as - `/sandbox//aiq-artifacts`). Use that value verbatim - do NOT write to a bare - `/sandbox/aiq-artifacts`; the runtime only harvests files under `sandbox_artifact_dir`. -5. **Write a manifest** so the chart is harvested reliably (see below). -6. **Reference, do not embed bytes:** in the report, link the chart with - `![caption](artifact://.png)`. The runtime resolves this to the durable - artifact; never paste base64 image data into the report. +1. **Sandbox mode (an `execute` tool is available):** render a PNG with Python/matplotlib, + save it as a durable artifact, and embed it by reference. Follow **Sandbox Mode (PNG + artifact)** below. +2. **Inline mode (no `execute` tool / no sandbox):** emit the chart as an inline fenced + `chart` JSON spec that the web app renders, plus a portable Markdown table. Follow + **Inline Mode (chart spec)** below. Do NOT attempt to run code or produce a PNG. -## Data sufficiency (earn the chart) +The two modes are mutually exclusive and are selected only by tool availability: pick exactly +one and emit only that output path. When an `execute` tool (sandbox) is available you must use +Sandbox mode and must not emit an inline `chart` spec; only when no `execute` tool exists do you +use Inline mode. Never produce both a PNG artifact and an inline spec for the same figure. -A chart confers authority, so it must be earned - never give unreliable data a cleaner -outfit. A polished chart of wrong or sparse numbers misleads more than it informs. +Both modes share the same discipline: a chart confers authority, so it must be earned. + +## Data sufficiency (earn the chart, both modes) + +A polished chart of wrong or sparse numbers misleads more than it informs. 1. **Source-anchored points only:** every plotted value must trace to a specific source (the as-reported figure and its URL). Never plot a fabricated, guessed, or inferred @@ -44,11 +46,38 @@ outfit. A polished chart of wrong or sparse numbers misleads more than it inform leases"), do NOT produce a trend chart. Present the table (which shows the gaps) and state the limitation in one sentence instead. 3. **Show gaps honestly:** never interpolate or connect across missing periods. Plot only - the periods a series actually reports, and render estimates distinctly (e.g. hollow or - dashed markers) so they do not read as reported values. + the periods a series actually reports, and render estimates distinctly so they do not + read as reported values. 4. **Prefer gap-tolerant forms:** grouped bars show missing periods as absent bars; favor them over a connected line when series are uneven, since a line drawn across gaps - implies a trend that the data does not support. + implies a trend the data does not support. +5. **Use the ACTUAL numbers** from the evidence (top ~10 rows); never invent, pad, or round + away data. If you show only the top rows of a larger set, say so. + +--- + +# Sandbox Mode (PNG artifact) + +Render with Python/matplotlib, save the chart as a durable artifact, and embed it in the +report by reference (never by pasting image data). + +## Required Execution Standard + +1. **Ground the data:** build the plotted rows from researched facts or `/shared/...` + inputs. Keep source URLs/notes alongside the values. +2. **Normalize units** before plotting (currencies, magnitudes, periods). +3. **Render with code:** call `execute` to run Python/matplotlib. Do not hand-draw or + fabricate charts. +4. **Write to the artifact directory:** save the PNG and its CSV under the exact + `sandbox_artifact_dir` given in your instructions (a per-job path such as + `/sandbox//aiq-artifacts`). Use that value verbatim - do NOT write to a bare + `/sandbox/aiq-artifacts`; the runtime only harvests files under `sandbox_artifact_dir`. +5. **Write a manifest** to carry the chart's title, caption, and inline flag and to checkpoint + it mid-run (see below). It is preferred, not strictly required: a chart left in + `sandbox_artifact_dir` is still captured by the terminal directory scan without one. +6. **Reference, do not embed bytes:** in the report, link the chart with + `![caption](artifact://.png)`. The runtime resolves this to the durable + artifact; never paste base64 image data into the report. ## Execution Flow @@ -62,7 +91,9 @@ outfit. A polished chart of wrong or sparse numbers misleads more than it inform `python3 /sandbox/JOB/make_chart.py /sandbox/JOB/aiq-artifacts`. Never execute a literal `` or `` token. `sandbox_workdir` is already per-job, so scripts there cannot collide with another job's - leftovers. Only ever execute a script you wrote this session. The script must: + leftovers. Only ever execute a script you wrote this session. Each `execute` runs in a + fresh shell, so `cd` does NOT persist between calls; put absolute paths in every command + (or chain in one line as `cd && `). The script must: - import pandas and matplotlib (use the non-interactive `Agg` backend), - build the DataFrame, compute any derived metrics, - set a single `ARTIFACT_DIR` to your `sandbox_artifact_dir` and write the chart @@ -84,17 +115,24 @@ Each figure must appear where it is discussed, not buried in a file list: 3. **Reference by filename, never a raw path:** the way to show a figure is the `![caption](artifact://.png)` token. Do NOT instead write the sandbox path (e.g. `/.png`) as prose and expect it to render - a bare path - is not an image. + is not an image. Never paste the plotting code or base64 image data into the report; do + not `read_file` a generated PNG just to verify it (that injects base64 bytes into context). 4. **One embed per artifact:** list supporting files (CSVs, manifests) by name in an appendix if useful, but the chart itself must be embedded inline as above. ## Manifest Write a `manifest.json` in your `sandbox_artifact_dir` so the runtime captures the chart -with metadata. Manifest `path` values must be absolute and inside your `sandbox_artifact_dir` -(the per-job path from your instructions). Construct every manifest path from the runtime -argument as shown below; do not hand-copy an angle-bracket placeholder into JSON. Set -`inline: true` only for a raster image intended to appear in the report. +with its metadata. The manifest is the preferred path, not a hard requirement: a successful +`execute` checkpoints the manifest-declared artifacts immediately, and the manifest carries the +`title`, `caption`, and `inline` flag that let the chart render inline with a caption. If no +valid manifest is written, the terminal directory scan still captures any file left in +`sandbox_artifact_dir` as a successful fallback, but with default metadata (no title or caption, +and not auto-inlined), so the manifest is how you get an inline, captioned chart. Manifest +`path` values must be absolute and inside your `sandbox_artifact_dir` (the per-job path from +your instructions). Construct every manifest path from the runtime argument as shown below; do +not hand-copy an angle-bracket placeholder into JSON. Set `inline: true` only for a raster image +intended to appear in the report. ## Example Script @@ -156,7 +194,7 @@ must be the real absolute artifact directory, not an angle-bracket placeholder. artifact-checkpoint response after `execute` as authoritative: reference the exact confirmed filename in the report and do not invent or rename it later. -## Notes and Limitations +## Sandbox notes and limitations - Use the `Agg` backend; the sandbox has no display. - Keep charts legible: labeled axes, a title, and a legend when multiple series are shown. @@ -167,3 +205,54 @@ filename in the report and do not invent or rename it later. than fabricating a chart. - Reference charts only by `artifact://`; the runtime assigns the durable id and rewrites the reference for the UI, PDF export, and the packaged skill CLI. + +--- + +# Inline Mode (chart spec) + +When there is no `execute` tool, present numbers that compare multiple things (a ranking or +top-N across entities, a distribution or counts across categories, a trend over an +ordered/time axis, or gains vs losses) as an inline chart, not just prose or a table. Lead +with a one-sentence verdict, then the chart. + +- Emit the chart as a fenced code block tagged `chart` holding a SINGLE line of valid JSON + (no comments, no trailing commas), right after the sentence that introduces it. +- Chart to reveal the pattern and state the verdict in prose. Inline `chart` blocks render + only in the web app, so ALSO place a compact markdown table of the same values immediately + after each chart, keeping PDF, Markdown, API, and CLI exports readable. +- At most 3 charts per section, and put each chart before any table. +- A single value or a one-entity yes/no result is NOT a chart: emit a KPI-only block, a + fenced `chart` block whose JSON has just `title` and `kpis`. + +Chart types: `bar` (category magnitudes), `hbar` (rankings with long text labels), +`line`/`area` (a trend across an ordered axis), `grouped-bar` (2-4 series per category), +`delta` (gains vs losses around zero). + +Spec fields: `type`; `title` (short) and optional `subtitle`; `x` = `{ "key": "", "label": "optional" }`; optional `y` = `{ "label": "optional unit", "format": "number | +compact | percent | currency" }`; `series` = `[ { "key": "", "label": "optional", +"color": "green | blue | amber | red" } ]`; `data` = rows as objects with raw numbers (fractions +0-1 for `percent`); optional `kpis` = `[ { "label": "...", "value": "preformatted", "tone": "accent +| warn | alarm" } ]`. A `delta` chart encodes exactly one series. + +Example (ranking): + +```chart +{"type":"hbar","title":"Top suppliers by late shipments","x":{"key":"supplier"},"y":{"format":"number"},"series":[{"key":"late","color":"amber"}],"data":[{"supplier":"Acme","late":42},{"supplier":"Globex","late":31},{"supplier":"Initech","late":19}]} +``` + +Example (single value, KPI-only): + +```chart +{"title":"On-time delivery rate","kpis":[{"label":"On-time","value":"92.4%","tone":"accent"}]} +``` + +For several related trends over time, emit one fenced `chart-carousel` block holding a SINGLE +line of JSON with at least two line-chart specs: `{ "title": "...", "charts": [ , ... ] }`. + +Example (related trends, carousel): + +```chart-carousel +{"title":"Quarterly delivery trends","charts":[{"type":"line","title":"On-time delivery rate","x":{"key":"quarter"},"y":{"format":"percent"},"series":[{"key":"rate","color":"green"}],"data":[{"quarter":"Q1","rate":0.88},{"quarter":"Q2","rate":0.90},{"quarter":"Q3","rate":0.93}]},{"type":"line","title":"Late shipments","x":{"key":"quarter"},"y":{"format":"number"},"series":[{"key":"late","color":"amber"}],"data":[{"quarter":"Q1","late":52},{"quarter":"Q2","late":41},{"quarter":"Q3","late":28}]}]} +``` diff --git a/tests/aiq_agent/agents/deep_researcher/test_chart_artifact_contract.py b/tests/aiq_agent/agents/deep_researcher/test_chart_artifact_contract.py index e0015a4e5..cfd40d5d8 100644 --- a/tests/aiq_agent/agents/deep_researcher/test_chart_artifact_contract.py +++ b/tests/aiq_agent/agents/deep_researcher/test_chart_artifact_contract.py @@ -13,7 +13,7 @@ def test_chart_skill_uses_runtime_argument_instead_of_executable_placeholder() -> None: - skill = (_AGENT_ROOT / "skills" / "research" / "chart-generation" / "SKILL.md").read_text(encoding="utf-8") + skill = (_AGENT_ROOT / "skills" / "visualization" / "chart-generation" / "SKILL.md").read_text(encoding="utf-8") assert 'ARTIFACT_DIR = ""' not in skill assert '"path": "' not in skill @@ -21,11 +21,12 @@ def test_chart_skill_uses_runtime_argument_instead_of_executable_placeholder() - assert 'ARTIFACT_DIR / "manifest.json"' in skill -def test_writer_runs_chart_script_with_rendered_per_job_paths() -> None: +def test_writer_provides_rendered_per_job_paths_as_chart_skill_context() -> None: prompt = (_AGENT_ROOT / "prompts" / "writer.j2").read_text(encoding="utf-8") rendered = render_prompt_template( prompt, current_datetime="2026-07-09", + chart_skill_enabled=True, execution_enabled=True, parent_report_context_available=False, sandbox_workdir="/sandbox/job-123", @@ -33,5 +34,7 @@ def test_writer_runs_chart_script_with_rendered_per_job_paths() -> None: user_info=None, ) - assert "python3 /sandbox/job-123/make_chart.py /sandbox/job-123/aiq-artifacts" in rendered - assert "Never put a literal `` or ``" in rendered + assert "/sandbox/job-123" in rendered + assert "/sandbox/job-123/aiq-artifacts" in rendered + assert "" not in rendered + assert "" not in rendered diff --git a/tests/aiq_agent/agents/deep_researcher/test_deepagents_runtime.py b/tests/aiq_agent/agents/deep_researcher/test_deepagents_runtime.py index 3e46a6c52..4092e15be 100644 --- a/tests/aiq_agent/agents/deep_researcher/test_deepagents_runtime.py +++ b/tests/aiq_agent/agents/deep_researcher/test_deepagents_runtime.py @@ -79,8 +79,17 @@ def test_frontier_profile_uses_validated_gpt_role_split() -> None: } -def test_openshell_workflow_only_diverges_for_sandbox_wiring() -> None: - """Keep the OpenShell workflow aligned with the standard web config.""" +def test_openshell_workflow_only_diverges_for_skills_and_sandbox_wiring() -> None: + """Keep the OpenShell workflow aligned with the standard web config. + + The visualization chart skill now ships only in the skills and sandbox + example configs, so the standard web config wires no deep_research_skills + at all and renders chart-worthy data as Markdown tables. OpenShell layers + the sandbox-gated research and synthesis collections plus the on-demand + visualization skill on top of a sandbox, so the skills function, the + sandbox function, and their two agent refs are the only divergence from + the standard config. + """ def load(path: str) -> dict[str, Any]: text = Path(path).read_text(encoding="utf-8") @@ -89,16 +98,29 @@ def load(path: str) -> dict[str, Any]: standard = load("configs/config_web_default_llamaindex.yml") openshell = load("configs/config_openshell.yml") + + standard_functions = standard["functions"].copy() openshell_functions = openshell["functions"].copy() - openshell_functions.pop("deep_research_skills") + + assert "deep_research_skills" not in standard_functions + assert "deep_research_sandbox" not in standard_functions + assert "skills" not in standard_functions["deep_research_agent"] + assert "sandbox" not in standard_functions["deep_research_agent"] + + openshell_skills = openshell_functions.pop("deep_research_skills") + assert openshell_skills["_type"] == "deep_research_skills" + assert openshell_skills["agents"]["researcher-agent"] == ["research"] + assert "visualization" in openshell_skills["agents"]["writer-agent"] + assert "research" in openshell_skills["require_sandbox"] openshell_functions.pop("deep_research_sandbox") - openshell_functions["deep_research_agent"] = openshell_functions["deep_research_agent"].copy() - openshell_functions["deep_research_agent"].pop("skills") - openshell_functions["deep_research_agent"].pop("sandbox") + + openshell_agent = openshell_functions["deep_research_agent"] = openshell_functions["deep_research_agent"].copy() + assert openshell_agent.pop("skills") == "deep_research_skills" + assert openshell_agent.pop("sandbox") == "deep_research_sandbox" assert openshell["general"] == standard["general"] assert openshell["llms"] == standard["llms"] - assert openshell_functions == standard["functions"] + assert openshell_functions == standard_functions assert openshell["workflow"] == standard["workflow"] @@ -134,6 +156,7 @@ def test_builtin_skill_collections_are_discovered(self) -> None: assert collections["research"] == "/skills/research/" assert collections["synthesis"] == "/skills/synthesis/" + assert collections["visualization"] == "/skills/visualization/" def test_nested_skill_collections_are_discovered(self, tmp_path) -> None: skill_dir = tmp_path / "finance" / "earnings" / "quarterly-summary" @@ -176,6 +199,17 @@ def test_skills_only_adds_skills_route(self) -> None: assert isinstance(backend.routes[BUILTIN_SKILL_SOURCE], FilesystemBackend) assert runtime.skill_sources_for("writer-agent") == [SYNTHESIS_SKILL_SOURCE] + def test_agent_has_chart_skill_tracks_visualization_collection(self) -> None: + runtime = DeepAgentsRuntime( + skills=DeepResearchSkillsConfig( + agents={"writer-agent": ("visualization",), "researcher-agent": ("synthesis",)}, + ), + ) + + assert runtime.agent_has_chart_skill("writer-agent") is True + assert runtime.agent_has_chart_skill("researcher-agent") is False + assert runtime.agent_has_chart_skill("planner-agent") is False + def test_sandbox_only_adds_shared_route(self) -> None: fake_sandbox = MagicMock() with patch( diff --git a/tests/aiq_agent/agents/test_result_chart_prompt.py b/tests/aiq_agent/agents/test_result_chart_prompt.py index f1a9db2ac..c5d032d9d 100644 --- a/tests/aiq_agent/agents/test_result_chart_prompt.py +++ b/tests/aiq_agent/agents/test_result_chart_prompt.py @@ -12,12 +12,17 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Guards the result-chart contract embedded in the researcher/writer prompts. +"""Guards the result-chart contract wherever the agents document it. -The shallow researcher and deep-research writer both instruct the agent to emit -declarative ``chart`` specs that the UI's ResultChart renderer parses. These -tests ensure the worked examples in those prompts stay valid JSON that matches -the renderer's schema, so the documented contract cannot silently drift. +The inline ``chart`` spec used to be embedded in the deep-research writer prompt. +It now lives in the on-demand ``chart-generation`` skill, so the always-on writer +prompt stays short and both delivery modes are driven by the skill. The shallow +researcher keeps its own inline copy because the shallow path has no skill runtime. + +These tests ensure the worked examples in every source that still documents the +contract (the shallow prompt and the chart skill) stay valid JSON that matches the +UI ResultChart zod schema, and that the writer prompt now delegates to the skill +instead of embedding the contract. """ import json @@ -29,52 +34,87 @@ from aiq_agent.common import render_prompt_template _AGENTS = Path(__file__).resolve().parents[3] / "src" / "aiq_agent" / "agents" -_PROMPTS = { +_WRITER = _AGENTS / "deep_researcher" / "prompts" / "writer.j2" +_CHART_SKILL = _AGENTS / "deep_researcher" / "skills" / "visualization" / "chart-generation" / "SKILL.md" + +# The inline ``chart`` contract now lives in exactly these two places: the shallow +# researcher prompt (no skill runtime) and the chart-generation skill (both the +# deep researcher and writer read it on demand). +_CONTRACT_SOURCES = { "shallow": _AGENTS / "shallow_researcher" / "prompts" / "researcher.j2", - "writer": _AGENTS / "deep_researcher" / "prompts" / "writer.j2", + "skill": _CHART_SKILL, } -# Kept in lockstep with CHART_TYPES in the UI's ResultChart/types.ts. +# Kept in lockstep with the UI ResultChart schema in types.ts. _CHART_TYPES = {"bar", "hbar", "line", "area", "grouped-bar", "delta"} +_CHART_COLORS = {"green", "blue", "amber", "red", "neutral"} +_VALUE_FORMATS = {"number", "compact", "percent", "currency"} +_KPI_TONES = {"default", "accent", "warn", "alarm"} + _CHART_BLOCK = re.compile(r"```chart\n(.*?)\n```", re.DOTALL) _CAROUSEL_BLOCK = re.compile(r"```chart-carousel\n(.*?)\n```", re.DOTALL) -def _chart_examples(prompt_key: str) -> list[dict]: - text = _PROMPTS[prompt_key].read_text() +def _chart_examples(source_key: str) -> list[dict]: + text = _CONTRACT_SOURCES[source_key].read_text() return [json.loads(block) for block in _CHART_BLOCK.findall(text)] -@pytest.mark.parametrize("prompt_key", list(_PROMPTS)) -def test_prompt_defines_the_chart_contract(prompt_key: str) -> None: - text = _PROMPTS[prompt_key].read_text() - assert "## Presenting Data (Charts)" in text +def _assert_kpis_match_schema(kpis: list) -> None: + assert 1 <= len(kpis) <= 4 + for kpi in kpis: + assert kpi["label"] and kpi["value"] + if "tone" in kpi: + assert kpi["tone"] in _KPI_TONES + + +def _assert_full_chart_matches_schema(spec: dict) -> None: + """Mirror the ResultChart zod ChartSpecSchema shape.""" + assert spec["type"] in _CHART_TYPES + assert spec["title"] + assert spec["x"]["key"] + if "y" in spec and spec["y"].get("format") is not None: + assert spec["y"]["format"] in _VALUE_FORMATS + assert 1 <= len(spec["series"]) <= 6 + for series in spec["series"]: + assert series["key"] + if series.get("color") is not None: + assert series["color"] in _CHART_COLORS + # A delta chart colors bars by sign with no legend, so it encodes one series. + if spec["type"] == "delta": + assert len(spec["series"]) == 1 + assert 1 <= len(spec["data"]) <= 60 + assert all(isinstance(row, dict) for row in spec["data"]) + if "kpis" in spec: + _assert_kpis_match_schema(spec["kpis"]) + + +@pytest.mark.parametrize("source_key", list(_CONTRACT_SOURCES)) +def test_contract_source_defines_the_chart_contract(source_key: str) -> None: + text = _CONTRACT_SOURCES[source_key].read_text() assert "chart-carousel" in text for chart_type in _CHART_TYPES: - assert chart_type in text, f"{prompt_key} prompt omits chart type {chart_type!r}" + assert chart_type in text, f"{source_key} omits chart type {chart_type!r}" -@pytest.mark.parametrize("prompt_key", list(_PROMPTS)) -def test_prompt_carousel_examples_match_the_schema(prompt_key: str) -> None: - text = _PROMPTS[prompt_key].read_text() +@pytest.mark.parametrize("source_key", list(_CONTRACT_SOURCES)) +def test_contract_source_carousel_examples_match_the_schema(source_key: str) -> None: + text = _CONTRACT_SOURCES[source_key].read_text() blocks = _CAROUSEL_BLOCK.findall(text) - assert blocks, f"{prompt_key} prompt has no ```chart-carousel example" + assert blocks, f"{source_key} has no ```chart-carousel example" for block in blocks: carousel = json.loads(block) assert carousel["title"] - assert len(carousel["charts"]) >= 2 + assert 2 <= len(carousel["charts"]) <= 12 for chart in carousel["charts"]: assert chart["type"] == "line" - assert chart["title"], f"{prompt_key} carousel child is missing a non-empty title" - assert chart["x"]["key"] - assert chart["series"] and all(s["key"] for s in chart["series"]) - assert chart["data"] and all(isinstance(row, dict) for row in chart["data"]) + _assert_full_chart_matches_schema(chart) -@pytest.mark.parametrize("prompt_key", list(_PROMPTS)) -def test_prompt_chart_examples_match_the_schema(prompt_key: str) -> None: - examples = _chart_examples(prompt_key) - assert examples, f"{prompt_key} prompt has no ```chart example" +@pytest.mark.parametrize("source_key", list(_CONTRACT_SOURCES)) +def test_contract_source_chart_examples_match_the_schema(source_key: str) -> None: + examples = _chart_examples(source_key) + assert examples, f"{source_key} has no ```chart example" saw_full_chart = False saw_kpi_only = False @@ -83,16 +123,26 @@ def test_prompt_chart_examples_match_the_schema(prompt_key: str) -> None: assert spec["title"] if "type" in spec: saw_full_chart = True - assert spec["type"] in _CHART_TYPES - assert spec["x"]["key"] - assert spec["series"] and all(s["key"] for s in spec["series"]) - assert spec["data"] and all(isinstance(row, dict) for row in spec["data"]) + _assert_full_chart_matches_schema(spec) else: saw_kpi_only = True - assert spec["kpis"] and all(k["label"] and k["value"] for k in spec["kpis"]) + _assert_kpis_match_schema(spec["kpis"]) + + assert saw_full_chart, f"{source_key} should show a full chart example" + assert saw_kpi_only, f"{source_key} should show a KPI-only example" - assert saw_full_chart, f"{prompt_key} prompt should show a full chart example" - assert saw_kpi_only, f"{prompt_key} prompt should show a KPI-only example" + +def test_chart_skill_covers_both_delivery_modes() -> None: + """The single skill drives sandbox (PNG artifact) and non-sandbox (inline spec) charts.""" + skill = _CHART_SKILL.read_text() + assert "## Choose your mode" in skill + # Sandbox mode markers. + assert "artifact://" in skill + assert "make_chart.py" in skill + assert "matplotlib" in skill + # Inline mode markers. + assert "```chart" in skill + assert "chart-carousel" in skill _WRITER_RENDER_CONTEXT = { @@ -104,21 +154,33 @@ def test_prompt_chart_examples_match_the_schema(prompt_key: str) -> None: } -def _render_writer(*, execution_enabled: bool) -> str: +def _render_writer(*, chart_skill_enabled: bool, execution_enabled: bool) -> str: return render_prompt_template( - _PROMPTS["writer"].read_text(), + _WRITER.read_text(), + chart_skill_enabled=chart_skill_enabled, execution_enabled=execution_enabled, **_WRITER_RENDER_CONTEXT, ) -def test_writer_gates_inline_charts_to_the_non_sandbox_path() -> None: - with_sandbox = _render_writer(execution_enabled=True) - assert "## Figures" in with_sandbox - assert "## Presenting Data" not in with_sandbox - assert "```chart" not in with_sandbox +def test_writer_prompt_no_longer_embeds_the_inline_chart_contract() -> None: + text = _WRITER.read_text() + assert "## Presenting Data (Charts)" not in text + assert "```chart" not in text + + +@pytest.mark.parametrize("execution_enabled", [True, False]) +def test_writer_delegates_charts_to_the_skill_when_chart_skill_enabled(execution_enabled: bool) -> None: + rendered = _render_writer(chart_skill_enabled=True, execution_enabled=execution_enabled) + assert "## Figures and Charts" in rendered + assert "chart-generation" in rendered + # The contract is delivered on demand by the skill, never inlined here. + assert "```chart" not in rendered + - without_sandbox = _render_writer(execution_enabled=False) - assert "## Presenting Data (Charts)" in without_sandbox - assert "```chart" in without_sandbox - assert "## Figures" not in without_sandbox +def test_writer_degrades_to_a_table_without_the_chart_skill() -> None: + rendered = _render_writer(chart_skill_enabled=False, execution_enabled=False) + assert "## Figures and Charts" in rendered + assert "chart-generation" not in rendered + assert "compact Markdown table" in rendered + assert "```chart" not in rendered