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 ``. 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
- ``. 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
+ ``. 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
`` 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