squackit 0.8.4__tar.gz → 0.9.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {squackit-0.8.4 → squackit-0.9.0}/CHANGELOG.md +37 -0
- {squackit-0.8.4 → squackit-0.9.0}/PKG-INFO +1 -1
- {squackit-0.8.4 → squackit-0.9.0}/pyproject.toml +1 -1
- {squackit-0.8.4 → squackit-0.9.0}/squackit/server.py +122 -3
- squackit-0.9.0/tests/test_structured_output.py +191 -0
- {squackit-0.8.4 → squackit-0.9.0}/.github/workflows/release.yml +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/.gitignore +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/.mcp.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/.readthedocs.yaml +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/.readthedocs.yml +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/CLAUDE.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/LICENSE +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/README.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/agent-system-prompt.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/architecture.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/configuration.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/index.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/prompts.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/proposals/2026-06-04-response-type-improvements.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/quickstart.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/resources.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-10-phase1-handoff.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-10-squawkit-extraction.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-cli-tool-exposure.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-session-handoff.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-16-session-handoff.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-26-named-fts-collections.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/pluck-chains/SKILL.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/using-squackit/SKILL.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-10-squawkit-design.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-12-pluckit-tool-integration-design.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/docs/tools.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/mkdocs.yml +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/EXPERIMENT_LOG.md +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/benchmark_models.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/cascade_prototype.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/combo_eval.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/generate_examples.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/grid_generate_examples.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/deepseek-coder-v2_16b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_0.5b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_3b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/qwen2.5-coder_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/qwen3.5_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/qwen3_8b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/scoring_input.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_task_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/hard_tasks.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/harvest_grid.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_eval.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary_deepseek_plain.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary_distilled_xml.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary_editor_3b_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary_fulldocs.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/iterative_results/summary_plaintext_3b7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/codegemma_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/codegemma_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/deepseek-coder-v2_16b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/deepseek-llm_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/gemma_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/glm4_9b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/granite-code_3b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/granite3.1-dense_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/granite3.3_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/llama2-uncensored_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/llama3.2-vision_11b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/llama3.2_1b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/llama3.2_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/phi3_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/phi4-mini_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_0.5b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_1.5b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_3b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5-coder_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_1.5b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_3b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2.5_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen2_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_0.8b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_4b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3.5_9b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3_0.6b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/qwen3_8b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/smollm2_latest.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_card_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/model_cards.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/deepseek-coder-v2_16b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/qwen2.5-coder_3b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/qwen2.5-coder_7b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/qwen3.5_2b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/qwen3_8b.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/multiline_tasks.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/qm_retrieval_eval.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/quartermaster_eval.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/quartermaster_results/retrieval_summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/quartermaster_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/selector_eval.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/scripts/selector_results/summary.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/__init__.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/__main__.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/cli.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/data/examples.json +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/db.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/defaults.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/formatting.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/lackpy_integration.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/prompts.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/runtime.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/session.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/tool_config.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/tools.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/squackit/workflows.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/conftest.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/fixtures/has_call_filter.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_cli_tools.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_config_consumption.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_defaults.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_error_visibility.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_formatting_json.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_fts_root.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_prompts.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_resources.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_runtime_config.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_sandbox_anchoring.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_session.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_smoke.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_tool_config.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_tools.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_truncation.py +0 -0
- {squackit-0.8.4 → squackit-0.9.0}/tests/test_workflows.py +0 -0
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
### Added — structured output for programmatic callers (#18)
|
|
6
|
+
|
|
7
|
+
Every tool accepts `as_json=true` and returns one envelope:
|
|
8
|
+
|
|
9
|
+
{"rows": [...], "columns": [...], "omitted": <int>}
|
|
10
|
+
|
|
11
|
+
squackit renders for a human reader — markdown tables and newline-joined text.
|
|
12
|
+
That is right for an agent reading a briefing and close to useless to a program
|
|
13
|
+
composing calls. Measured by a session driving four local models through
|
|
14
|
+
lackpy: **0/24 correct while 17/24 called the correct tool**. `len()` on
|
|
15
|
+
`find_names`' newline-joined string gives 67 (characters) where the answer is
|
|
16
|
+
8; on `find`'s table it gives 2239.
|
|
17
|
+
|
|
18
|
+
The structure was never lost, only discarded at the last step: the executors
|
|
19
|
+
already return DuckDB relations and View objects, and the CLI already had
|
|
20
|
+
`--json`. This exposes that over MCP, on both the macro and pluckit wrappers.
|
|
21
|
+
|
|
22
|
+
Deliberate choices:
|
|
23
|
+
|
|
24
|
+
- **One envelope for every tool**, so a caller learns the shape once.
|
|
25
|
+
- **`omitted` rather than a bare array.** A truncated array is
|
|
26
|
+
indistinguishable from a complete one, and shipping silent truncation on the
|
|
27
|
+
programmatic path would reproduce the defect this mode exists to fix.
|
|
28
|
+
- **The shape is stated in every tool's description.** The cost of the
|
|
29
|
+
envelope is that `len()` of it is 3, not the row count — the same misread
|
|
30
|
+
that produced the 0/24 — so it is named beside the signature rather than left
|
|
31
|
+
to be inferred.
|
|
32
|
+
- **An empty result is `[]`**, not the prose `(no results)`.
|
|
33
|
+
- **The default is unchanged.** Existing callers see exactly what they saw
|
|
34
|
+
before; `as_json` is opt-in, so this is additive rather than a migration.
|
|
35
|
+
|
|
36
|
+
The `compact_columns` projection and `verbose` still apply, so a JSON caller
|
|
37
|
+
sees the same columns a markdown caller would unless it asks for more.
|
|
38
|
+
|
|
39
|
+
|
|
3
40
|
## 0.8.4
|
|
4
41
|
|
|
5
42
|
A review of 0.8.1–0.8.3 found both fixes had reached the macro-tool layer and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: squackit
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: Semi-QUalified Agent Companion Kit — the stateful intelligence + MCP server layer for fledgling-equipped agents.
|
|
5
5
|
Project-URL: Homepage, https://github.com/teaguesterling/squackit
|
|
6
6
|
Project-URL: Documentation, https://squackit.readthedocs.io
|
|
@@ -21,6 +21,7 @@ Usage::
|
|
|
21
21
|
from __future__ import annotations
|
|
22
22
|
|
|
23
23
|
import inspect
|
|
24
|
+
import json as _json
|
|
24
25
|
import threading
|
|
25
26
|
from collections import OrderedDict
|
|
26
27
|
from pathlib import Path
|
|
@@ -246,6 +247,8 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
246
247
|
elif v is not None:
|
|
247
248
|
verbose = bool(v)
|
|
248
249
|
|
|
250
|
+
as_json = _as_json_requested(kwargs)
|
|
251
|
+
|
|
249
252
|
filtered = {k: v for k, v in kwargs.items() if v is not None}
|
|
250
253
|
|
|
251
254
|
try:
|
|
@@ -259,6 +262,16 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
259
262
|
# View -> markdown (truncate by blocks if too many)
|
|
260
263
|
if hasattr(result, 'markdown') and hasattr(result, 'blocks'):
|
|
261
264
|
blocks = result.blocks
|
|
265
|
+
if as_json:
|
|
266
|
+
tabular = getattr(result, "tabular", None)
|
|
267
|
+
if tabular is not None:
|
|
268
|
+
vcols, vrows = tabular
|
|
269
|
+
kept, omitted = _limit_rows(list(vrows), max_limit)
|
|
270
|
+
return _json_envelope(
|
|
271
|
+
[dict(zip(vcols, r)) for r in kept], vcols, omitted)
|
|
272
|
+
kept, omitted = _limit_rows(list(blocks), max_limit)
|
|
273
|
+
return _json_envelope(
|
|
274
|
+
[b.markdown for b in kept if b.markdown], (), omitted)
|
|
262
275
|
if max_limit > 0 and len(blocks) > max_limit:
|
|
263
276
|
kept = blocks[:max_limit]
|
|
264
277
|
omitted = len(blocks) - max_limit
|
|
@@ -269,6 +282,13 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
269
282
|
if hasattr(result, 'columns') and hasattr(result, 'fetchall'):
|
|
270
283
|
cols = result.columns
|
|
271
284
|
rows = result.fetchall()
|
|
285
|
+
if as_json:
|
|
286
|
+
# Projection still applies, so a JSON caller gets the same
|
|
287
|
+
# columns a markdown caller would unless it asks for verbose.
|
|
288
|
+
jcols, jrows = _project(cols, rows, presentation, verbose)
|
|
289
|
+
kept, omitted = _limit_rows(jrows, max_limit)
|
|
290
|
+
return _json_envelope(
|
|
291
|
+
[dict(zip(jcols, r)) for r in kept], jcols, omitted)
|
|
272
292
|
if not rows:
|
|
273
293
|
return "(no results)"
|
|
274
294
|
omission = None
|
|
@@ -312,6 +332,9 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
312
332
|
return text
|
|
313
333
|
# list -> joined (truncate)
|
|
314
334
|
if isinstance(result, list):
|
|
335
|
+
if as_json:
|
|
336
|
+
kept, omitted = _limit_rows(list(result), max_limit)
|
|
337
|
+
return _json_envelope(kept, (), omitted)
|
|
315
338
|
if not result:
|
|
316
339
|
return "(no results)"
|
|
317
340
|
if max_limit > 0 and len(result) > max_limit:
|
|
@@ -324,7 +347,7 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
324
347
|
|
|
325
348
|
tool_fn.__name__ = tool_name
|
|
326
349
|
tool_fn.__qualname__ = tool_name
|
|
327
|
-
tool_fn.__doc__ = description
|
|
350
|
+
tool_fn.__doc__ = description + _AS_JSON_NOTE
|
|
328
351
|
|
|
329
352
|
required_set = set(presentation.required)
|
|
330
353
|
annotations = {}
|
|
@@ -354,6 +377,13 @@ def _register_executor_tool(mcp, presentation: ToolPresentation):
|
|
|
354
377
|
"verbose", inspect.Parameter.KEYWORD_ONLY,
|
|
355
378
|
default=None, annotation=Optional[bool],
|
|
356
379
|
))
|
|
380
|
+
# Every one of these tools returns data, so the structured mode is offered
|
|
381
|
+
# unconditionally rather than per-tool.
|
|
382
|
+
annotations["as_json"] = Optional[bool]
|
|
383
|
+
sig_params.append(inspect.Parameter(
|
|
384
|
+
"as_json", inspect.Parameter.KEYWORD_ONLY,
|
|
385
|
+
default=None, annotation=Optional[bool],
|
|
386
|
+
))
|
|
357
387
|
tool_fn.__annotations__ = {**annotations, "return": str}
|
|
358
388
|
tool_fn.__signature__ = inspect.Signature(
|
|
359
389
|
sig_params, return_annotation=str,
|
|
@@ -448,6 +478,76 @@ def _fts_con_for_root(mcp, root: str):
|
|
|
448
478
|
return con
|
|
449
479
|
|
|
450
480
|
|
|
481
|
+
#: Appended to every tool's description. A caller that has to GUESS the return
|
|
482
|
+
#: shape guesses wrong: measured at 0/24 correct while calling the right tool
|
|
483
|
+
#: 17/24 times, because `len()` on a rendered table counts characters. Naming
|
|
484
|
+
#: the shape beside the signature is the cheapest fix for that, and it is the
|
|
485
|
+
#: reason the envelope is a stable object rather than a bare array.
|
|
486
|
+
_AS_JSON_NOTE = (
|
|
487
|
+
"\n\nPass as_json=true for structured output: "
|
|
488
|
+
'{"rows": [...], "columns": [...], "omitted": <int>}. '
|
|
489
|
+
"`omitted` is how many rows the limit dropped — it is 0 when the result is "
|
|
490
|
+
"complete, so a truncated result can never be mistaken for a whole one."
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _limit_rows(rows, max_limit):
|
|
495
|
+
"""Apply the presentation limit, returning (kept, omitted_count)."""
|
|
496
|
+
if max_limit and max_limit > 0 and len(rows) > max_limit:
|
|
497
|
+
return rows[:max_limit], len(rows) - max_limit
|
|
498
|
+
return rows, 0
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _project(cols, rows, presentation, verbose):
|
|
502
|
+
"""Apply the compact_columns projection, if the tool declares one.
|
|
503
|
+
|
|
504
|
+
Shared so JSON and markdown callers see the same columns; a caller opts
|
|
505
|
+
out of both with verbose=True.
|
|
506
|
+
"""
|
|
507
|
+
if not presentation.compact_columns or verbose:
|
|
508
|
+
return list(cols), list(rows)
|
|
509
|
+
keep_idx = [i for i, name in enumerate(cols)
|
|
510
|
+
if name in presentation.compact_columns]
|
|
511
|
+
order = {name: pos for pos, name in enumerate(presentation.compact_columns)}
|
|
512
|
+
keep_idx.sort(key=lambda i: order.get(cols[i], len(order)))
|
|
513
|
+
if not keep_idx:
|
|
514
|
+
return list(cols), list(rows)
|
|
515
|
+
return ([cols[i] for i in keep_idx],
|
|
516
|
+
[tuple(row[i] for i in keep_idx) for row in rows])
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _json_envelope(rows, columns=(), omitted: int = 0) -> str:
|
|
520
|
+
"""The one shape every tool returns under `as_json=True`.
|
|
521
|
+
|
|
522
|
+
{"rows": [...], "columns": [...], "omitted": <int>}
|
|
523
|
+
|
|
524
|
+
One envelope for every tool, so a caller learns the shape once instead of
|
|
525
|
+
per tool. `omitted` exists so truncation stays REPRESENTABLE: a bare array
|
|
526
|
+
that had been cut is indistinguishable from a complete one, and shipping
|
|
527
|
+
silent truncation on the programmatic path would reproduce the exact defect
|
|
528
|
+
this output mode was added to fix.
|
|
529
|
+
|
|
530
|
+
The trade is that `len()` of the envelope is 3, not the row count — which
|
|
531
|
+
is the misread that motivated this ("0/24 correct while 17/24 called the
|
|
532
|
+
right tool"). That is why the shape is stated in the tool description
|
|
533
|
+
rather than left to be guessed.
|
|
534
|
+
"""
|
|
535
|
+
return _json.dumps(
|
|
536
|
+
{"rows": list(rows), "columns": list(columns), "omitted": int(omitted)},
|
|
537
|
+
indent=2, default=str,
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _as_json_requested(kwargs) -> bool:
|
|
542
|
+
"""Pop `as_json`, accepting the string forms MCP clients send."""
|
|
543
|
+
if "as_json" not in kwargs:
|
|
544
|
+
return False
|
|
545
|
+
v = kwargs.pop("as_json")
|
|
546
|
+
if isinstance(v, str):
|
|
547
|
+
return v.lower() in ("true", "1", "yes")
|
|
548
|
+
return bool(v) if v is not None else False
|
|
549
|
+
|
|
550
|
+
|
|
451
551
|
def _register_tool(
|
|
452
552
|
mcp, # FastMCP type annotation removed to avoid import at module level
|
|
453
553
|
con,
|
|
@@ -510,6 +610,10 @@ def _register_tool(
|
|
|
510
610
|
max_rows = 0
|
|
511
611
|
|
|
512
612
|
# Remove None values; coerce known numeric params to int.
|
|
613
|
+
# Pop before building macro args: `as_json` is a presentation choice,
|
|
614
|
+
# not a macro parameter, and passing it through raises a binder error.
|
|
615
|
+
as_json = _as_json_requested(kwargs)
|
|
616
|
+
|
|
513
617
|
# Only numeric_params are coerced — blanket isdigit() would
|
|
514
618
|
# break git SHAs like "1234567".
|
|
515
619
|
filtered = {}
|
|
@@ -587,10 +691,18 @@ def _register_tool(
|
|
|
587
691
|
elapsed = (_time.time() - t0) * 1000
|
|
588
692
|
access_log.record(tool_name, cache_args, 0,
|
|
589
693
|
cached=False, elapsed_ms=elapsed)
|
|
590
|
-
|
|
694
|
+
# A program needs an empty list it can iterate, not a sentence.
|
|
695
|
+
return _json_envelope([], cols, 0) if as_json else _empty_msg()
|
|
591
696
|
|
|
592
697
|
total_rows = len(rows)
|
|
593
698
|
|
|
699
|
+
if as_json:
|
|
700
|
+
# Structured callers get every row the limit allows plus an
|
|
701
|
+
# explicit count of what was dropped — never a quietly short list.
|
|
702
|
+
kept, omitted = _limit_rows(list(rows), max_rows if limit_param else 0)
|
|
703
|
+
return _json_envelope([dict(zip(cols, r)) for r in kept],
|
|
704
|
+
cols, omitted)
|
|
705
|
+
|
|
594
706
|
# Apply truncation
|
|
595
707
|
omission = None
|
|
596
708
|
if limit_param and max_rows > 0:
|
|
@@ -669,7 +781,7 @@ def _register_tool(
|
|
|
669
781
|
"DIFFERENT repository, pass `root=<repository directory>` (an absolute path) — the "
|
|
670
782
|
"repo is indexed on first use and cached."
|
|
671
783
|
)
|
|
672
|
-
tool_fn.__doc__ = description
|
|
784
|
+
tool_fn.__doc__ = description + _AS_JSON_NOTE
|
|
673
785
|
|
|
674
786
|
# Build parameter annotations for FastMCP schema generation.
|
|
675
787
|
# All params get default=None so FastMCP validation passes even when
|
|
@@ -697,6 +809,13 @@ def _register_tool(
|
|
|
697
809
|
"root", inspect.Parameter.KEYWORD_ONLY, default=None,
|
|
698
810
|
annotation=Optional[str],
|
|
699
811
|
))
|
|
812
|
+
# Every one of these tools returns data, so the structured mode is offered
|
|
813
|
+
# unconditionally rather than per-tool.
|
|
814
|
+
annotations["as_json"] = Optional[bool]
|
|
815
|
+
sig_params.append(inspect.Parameter(
|
|
816
|
+
"as_json", inspect.Parameter.KEYWORD_ONLY,
|
|
817
|
+
default=None, annotation=Optional[bool],
|
|
818
|
+
))
|
|
700
819
|
tool_fn.__annotations__ = {**annotations, "return": str}
|
|
701
820
|
|
|
702
821
|
tool_fn.__signature__ = inspect.Signature(
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Tools can return structured data instead of rendered prose.
|
|
2
|
+
|
|
3
|
+
squackit's tools render for a human reader: tabular results become markdown
|
|
4
|
+
tables, line-oriented ones become newline-joined text. That is right for an
|
|
5
|
+
agent reading a briefing and close to useless to a program composing calls.
|
|
6
|
+
|
|
7
|
+
Measured by a session running local models against the suite through lackpy,
|
|
8
|
+
which generates one restricted-Python program per intent:
|
|
9
|
+
|
|
10
|
+
0/24 correct while 17/24 called the correct tool
|
|
11
|
+
|
|
12
|
+
The models picked the right tool and misread the return shape. `len()` on
|
|
13
|
+
`find_names`' newline-joined string gives 67 (characters) where the answer is
|
|
14
|
+
8; on `find`'s markdown table it gives 2239. A wrong guess still validates,
|
|
15
|
+
still runs, and answers confidently.
|
|
16
|
+
|
|
17
|
+
The structure is not lost — it is discarded at the last step. The executors
|
|
18
|
+
already return DuckDB relations and View objects, and the CLI already has
|
|
19
|
+
`--json` using the same formatter. This exposes that over MCP.
|
|
20
|
+
|
|
21
|
+
## The shape
|
|
22
|
+
|
|
23
|
+
`as_json=True` returns ONE envelope for every tool:
|
|
24
|
+
|
|
25
|
+
{"rows": [...], "columns": [...], "omitted": <int>}
|
|
26
|
+
|
|
27
|
+
Chosen deliberately over a bare array:
|
|
28
|
+
|
|
29
|
+
* it is the same shape for every tool, so a caller learns it once
|
|
30
|
+
* `omitted` makes truncation REPRESENTABLE. A bare array that had been cut
|
|
31
|
+
would be indistinguishable from a complete one — silent truncation is the
|
|
32
|
+
defect this suite has been chasing all week, and returning it in the
|
|
33
|
+
programmatic path would be a poor joke.
|
|
34
|
+
* `columns` means a caller does not have to guess key names
|
|
35
|
+
|
|
36
|
+
The cost is that `len(result)` on the envelope is 3, not the row count — the
|
|
37
|
+
same trap that produced the 0/24. That is why the shape is stated in the tool
|
|
38
|
+
description rather than left to be inferred.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
import json
|
|
42
|
+
|
|
43
|
+
import pytest
|
|
44
|
+
|
|
45
|
+
from squackit.defaults import ProjectDefaults
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import fastmcp # noqa: F401
|
|
49
|
+
HAS_FASTMCP = True
|
|
50
|
+
except ImportError:
|
|
51
|
+
HAS_FASTMCP = False
|
|
52
|
+
|
|
53
|
+
requires_fastmcp = pytest.mark.skipif(
|
|
54
|
+
not HAS_FASTMCP, reason="fastmcp not installed"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
import asyncio
|
|
58
|
+
import subprocess
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_async(coro):
|
|
62
|
+
loop = asyncio.new_event_loop()
|
|
63
|
+
try:
|
|
64
|
+
return loop.run_until_complete(coro)
|
|
65
|
+
finally:
|
|
66
|
+
loop.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@pytest.fixture(scope="module")
|
|
70
|
+
def repo(tmp_path_factory):
|
|
71
|
+
"""Eight functions, so a count is unambiguous."""
|
|
72
|
+
root = tmp_path_factory.mktemp("structured") / "proj"
|
|
73
|
+
(root / "src").mkdir(parents=True)
|
|
74
|
+
body = "\n".join(f"def fn_{i}(a):\n return {i}\n" for i in range(8))
|
|
75
|
+
(root / "src" / "mod.py").write_text(body)
|
|
76
|
+
(root / "docs").mkdir()
|
|
77
|
+
(root / "docs" / "guide.md").write_text("# Guide\n\n## Section\n\ntext\n")
|
|
78
|
+
subprocess.run(["git", "init", "-q"], cwd=root, check=True)
|
|
79
|
+
subprocess.run(["git", "add", "-A"], cwd=root, check=True)
|
|
80
|
+
subprocess.run(["git", "-c", "user.email=t@e", "-c", "user.name=t",
|
|
81
|
+
"commit", "-qm", "initial"], cwd=root, check=True)
|
|
82
|
+
# Absolute: a relative source roots at the process cwd by design (0.8.2),
|
|
83
|
+
# which is not the served corpus. A caller addressing a served repo names
|
|
84
|
+
# it in full, and that is the shape worth testing.
|
|
85
|
+
global SRC
|
|
86
|
+
SRC = str(root / "src" / "mod.py")
|
|
87
|
+
return root
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
SRC = None # set by the repo fixture: an ABSOLUTE source path.
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.fixture
|
|
94
|
+
def mcp(repo):
|
|
95
|
+
from squackit.server import create_server
|
|
96
|
+
return create_server(root=str(repo), init=False)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _call(mcp, tool, args):
|
|
100
|
+
return _run_async(mcp.call_tool(tool, args)).content[0].text
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@requires_fastmcp
|
|
104
|
+
class TestFindNamesJson:
|
|
105
|
+
"""The sharpest measured case: a count that was 67 instead of 8."""
|
|
106
|
+
|
|
107
|
+
def test_default_is_unchanged(self, mcp):
|
|
108
|
+
"""Existing callers must see exactly what they saw before."""
|
|
109
|
+
text = _call(mcp, "find_names", {"source": SRC,
|
|
110
|
+
"selector": ".function"})
|
|
111
|
+
assert not text.lstrip().startswith("{")
|
|
112
|
+
assert "fn_0" in text
|
|
113
|
+
|
|
114
|
+
def test_as_json_parses(self, mcp):
|
|
115
|
+
payload = json.loads(_call(mcp, "find_names", {
|
|
116
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
117
|
+
assert isinstance(payload, dict)
|
|
118
|
+
|
|
119
|
+
def test_len_of_rows_is_the_answer(self, mcp):
|
|
120
|
+
"""This is the number the models got wrong."""
|
|
121
|
+
payload = json.loads(_call(mcp, "find_names", {
|
|
122
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
123
|
+
assert len(payload["rows"]) == 8, payload
|
|
124
|
+
|
|
125
|
+
def test_rows_are_the_names_themselves(self, mcp):
|
|
126
|
+
payload = json.loads(_call(mcp, "find_names", {
|
|
127
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
128
|
+
assert "fn_0" in payload["rows"]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@requires_fastmcp
|
|
132
|
+
class TestRelationToolsJson:
|
|
133
|
+
|
|
134
|
+
def test_find_returns_rows_of_objects(self, mcp):
|
|
135
|
+
payload = json.loads(_call(mcp, "find", {
|
|
136
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
137
|
+
assert len(payload["rows"]) == 8
|
|
138
|
+
assert isinstance(payload["rows"][0], dict)
|
|
139
|
+
|
|
140
|
+
def test_columns_are_declared(self, mcp):
|
|
141
|
+
payload = json.loads(_call(mcp, "find", {
|
|
142
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
143
|
+
assert "name" in payload["columns"]
|
|
144
|
+
assert set(payload["rows"][0]) <= set(payload["columns"])
|
|
145
|
+
|
|
146
|
+
def test_a_macro_tool_also_supports_it(self, mcp):
|
|
147
|
+
"""Not just the pluckit tools — both wrappers."""
|
|
148
|
+
payload = json.loads(_call(mcp, "doc_outline", {"as_json": True}))
|
|
149
|
+
assert isinstance(payload["rows"], list)
|
|
150
|
+
assert isinstance(payload["columns"], list)
|
|
151
|
+
|
|
152
|
+
def test_default_markdown_is_unchanged(self, mcp):
|
|
153
|
+
text = _call(mcp, "find", {"source": SRC,
|
|
154
|
+
"selector": ".function"})
|
|
155
|
+
assert text.lstrip().startswith("|")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@requires_fastmcp
|
|
159
|
+
class TestTruncationStaysVisible:
|
|
160
|
+
"""A cut array that looks complete is the defect, not the feature."""
|
|
161
|
+
|
|
162
|
+
def test_omitted_is_reported(self, mcp):
|
|
163
|
+
payload = json.loads(_call(mcp, "find", {
|
|
164
|
+
"source": SRC, "selector": ".function",
|
|
165
|
+
"max_results": 3, "as_json": True}))
|
|
166
|
+
assert len(payload["rows"]) == 3
|
|
167
|
+
assert payload["omitted"] == 5, payload
|
|
168
|
+
|
|
169
|
+
def test_omitted_is_zero_when_complete(self, mcp):
|
|
170
|
+
payload = json.loads(_call(mcp, "find", {
|
|
171
|
+
"source": SRC, "selector": ".function", "as_json": True}))
|
|
172
|
+
assert payload["omitted"] == 0
|
|
173
|
+
|
|
174
|
+
def test_no_markdown_omission_line_leaks_into_json(self, mcp):
|
|
175
|
+
"""Appending '--- omitted N rows ---' to JSON would break parsing."""
|
|
176
|
+
raw = _call(mcp, "find", {"source": SRC,
|
|
177
|
+
"selector": ".function",
|
|
178
|
+
"max_results": 3, "as_json": True})
|
|
179
|
+
assert "---" not in raw
|
|
180
|
+
json.loads(raw) # must not raise
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@requires_fastmcp
|
|
184
|
+
class TestEmptyAndErrorCases:
|
|
185
|
+
|
|
186
|
+
def test_an_empty_result_is_an_empty_array_not_a_message(self, mcp):
|
|
187
|
+
"""`(no results)` is prose; a program needs [] it can iterate."""
|
|
188
|
+
payload = json.loads(_call(mcp, "find_names", {
|
|
189
|
+
"source": SRC, "selector": ".class", "as_json": True}))
|
|
190
|
+
assert payload["rows"] == []
|
|
191
|
+
assert payload["omitted"] == 0
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-11-phase3-pluckit-rewire.md
RENAMED
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-12-pluckit-tool-integration.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-26-named-fts-collections.md
RENAMED
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/plans/2026-04-27-kibitzer-fts-doc-retrieval.md
RENAMED
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-code/SKILL.md
RENAMED
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/exploring-unfamiliar-docs/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/skills/refactoring-with-squackit/SKILL.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-12-cli-tool-exposure-design.md
RENAMED
|
File without changes
|
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-16-training-data-pipeline-design.md
RENAMED
|
File without changes
|
{squackit-0.8.4 → squackit-0.9.0}/docs/superpowers/specs/2026-04-26-named-fts-collections-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|