ltcai 9.9.2 → 9.9.4

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.
Files changed (109) hide show
  1. package/README.md +44 -39
  2. package/docs/CHANGELOG.md +109 -0
  3. package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
  4. package/docs/DEVELOPMENT.md +1 -1
  5. package/docs/ONBOARDING.md +1 -1
  6. package/docs/OPERATIONS.md +1 -1
  7. package/docs/SURFACE_PARITY.md +47 -0
  8. package/docs/TRUST_MODEL.md +1 -1
  9. package/docs/WHY_LATTICE.md +1 -1
  10. package/docs/kg-schema.md +1 -1
  11. package/lattice_brain/__init__.py +1 -1
  12. package/lattice_brain/graph/_kg_common.py +335 -0
  13. package/lattice_brain/graph/curator.py +141 -0
  14. package/lattice_brain/graph/discovery_index.py +8 -1
  15. package/lattice_brain/graph/documents.py +111 -0
  16. package/lattice_brain/graph/fusion.py +179 -0
  17. package/lattice_brain/graph/ingest.py +43 -9
  18. package/lattice_brain/graph/projection.py +382 -37
  19. package/lattice_brain/graph/retrieval.py +71 -4
  20. package/lattice_brain/graph/retrieval_policy.py +174 -0
  21. package/lattice_brain/graph/retrieval_vector.py +122 -2
  22. package/lattice_brain/ingestion.py +66 -0
  23. package/lattice_brain/portability.py +41 -12
  24. package/lattice_brain/runtime/multi_agent.py +1 -1
  25. package/latticeai/__init__.py +1 -1
  26. package/latticeai/api/automation_intelligence.py +159 -0
  27. package/latticeai/api/browser.py +15 -4
  28. package/latticeai/api/chat.py +33 -2
  29. package/latticeai/api/chat_agent_http.py +363 -6
  30. package/latticeai/api/chat_contracts.py +7 -1
  31. package/latticeai/api/chat_helpers.py +147 -0
  32. package/latticeai/api/chat_intents.py +217 -10
  33. package/latticeai/api/chat_stream.py +93 -3
  34. package/latticeai/api/funnel_metrics.py +31 -0
  35. package/latticeai/api/knowledge_graph.py +65 -1
  36. package/latticeai/api/local_files.py +90 -0
  37. package/latticeai/api/setup.py +134 -1
  38. package/latticeai/api/tools.py +33 -3
  39. package/latticeai/app_factory.py +14 -0
  40. package/latticeai/core/agent.py +355 -18
  41. package/latticeai/core/agent_eval.py +107 -0
  42. package/latticeai/core/enterprise.py +5 -0
  43. package/latticeai/core/file_generation.py +404 -2
  44. package/latticeai/core/legacy_compatibility.py +1 -1
  45. package/latticeai/core/marketplace.py +1 -1
  46. package/latticeai/core/run_store.py +243 -0
  47. package/latticeai/core/workspace_os.py +1 -1
  48. package/latticeai/models/router.py +25 -12
  49. package/latticeai/runtime/persistence_runtime.py +19 -1
  50. package/latticeai/runtime/router_registration.py +8 -1
  51. package/latticeai/services/app_context.py +3 -0
  52. package/latticeai/services/architecture_readiness.py +1 -1
  53. package/latticeai/services/automation_execution.py +266 -0
  54. package/latticeai/services/automation_intelligence.py +7 -0
  55. package/latticeai/services/command_center.py +110 -2
  56. package/latticeai/services/folder_watch.py +396 -0
  57. package/latticeai/services/funnel_metrics.py +210 -0
  58. package/latticeai/services/product_readiness.py +1 -1
  59. package/latticeai/services/search_service.py +51 -5
  60. package/latticeai/setup/demo_corpus.py +98 -0
  61. package/latticeai/tools/filesystem.py +38 -1
  62. package/package.json +1 -1
  63. package/scripts/bench_agent_smoke.py +410 -0
  64. package/scripts/bench_models.py +234 -5
  65. package/scripts/check_current_release_docs.mjs +1 -1
  66. package/scripts/funnel_soft_gate.py +192 -0
  67. package/src-tauri/Cargo.lock +1 -1
  68. package/src-tauri/Cargo.toml +1 -1
  69. package/src-tauri/tauri.conf.json +1 -1
  70. package/static/app/asset-manifest.json +29 -27
  71. package/static/app/assets/Act-BCmTU0E2.js +1 -0
  72. package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
  73. package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
  74. package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
  75. package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
  76. package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
  77. package/static/app/assets/Library-pKCK0_tk.js +1 -0
  78. package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
  79. package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
  80. package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
  81. package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
  82. package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
  83. package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
  84. package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
  85. package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
  86. package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
  87. package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
  88. package/static/app/assets/index-7FAfYm4v.css +2 -0
  89. package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
  90. package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
  91. package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
  92. package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
  93. package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
  94. package/static/app/assets/shield-alert-BftATuAA.js +1 -0
  95. package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
  96. package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
  97. package/static/app/assets/utils-SJUNVOj5.js +7 -0
  98. package/static/app/index.html +4 -4
  99. package/static/sw.js +1 -1
  100. package/static/app/assets/Act-Cx_gZNbE.js +0 -1
  101. package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
  102. package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
  103. package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
  104. package/static/app/assets/Library-PcfVUco6.js +0 -1
  105. package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
  106. package/static/app/assets/download-C7yxYHvz.js +0 -1
  107. package/static/app/assets/index-DU78Kogz.css +0 -2
  108. package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
  109. package/static/app/assets/utils-Dh-Dwtkb.js +0 -7
@@ -0,0 +1,410 @@
1
+ #!/usr/bin/env python3
2
+ """Weekly real-model agent-loop smoke (review Wave 3.4) — FAIL-OPEN.
3
+
4
+ What it measures
5
+ ================
6
+ ``scripts/agent_eval.py`` proves the loop's state machine against *scripted*
7
+ model replies. This harness closes the remaining gap: it drives a small set of
8
+ canonical agent tasks through the REAL :class:`latticeai.core.agent.
9
+ SingleAgentRuntime` (plan → approve → execute → verify), with the generation
10
+ port wired to each *locally installed* MLX model — real inference, no HTTP
11
+ server. Per model and task it reports:
12
+
13
+ * ``final_state`` — the loop's honest terminal state (DONE / NEEDS_REVIEW /
14
+ FAILED), plus the ``agent_eval`` result-class bucket;
15
+ * ``steps`` — LLM calls the run needed;
16
+ * parse repairs — ``parse_errors`` / ``parse_recovered`` and the LoopTrace
17
+ ``repairs`` histogram (how hard the loop worked to keep this model on rails);
18
+ * ``duration_s`` — true end-to-end wall time including inference.
19
+
20
+ Model access pattern
21
+ ====================
22
+ Identical to ``scripts/bench_models.py --filegen``: installed gemma/qwen/llama
23
+ MLX models are discovered through the product's own model catalog + on-disk HF
24
+ checks, loaded with the real ``LLMRouter``, and unloaded afterwards. The tool
25
+ port is in-memory (canned results, nothing touches disk or network) — this is
26
+ a smoke of the LOOP over real model output, not of the tool implementations.
27
+
28
+ FAIL-OPEN by design
29
+ ===================
30
+ This is a scheduled/weekly report, never a CI gate:
31
+
32
+ * no models installed (or catalog import fails, e.g. on a CI runner) →
33
+ an honest "no models available — skipped (fail-open)" report, exit 0;
34
+ * a model that fails to load → a per-model skip entry, exit 0;
35
+ * any run-level crash → a skip report naming the error, exit 0;
36
+ * weak results are the finding, never a failure.
37
+
38
+ Non-zero exits happen only for real script errors (bad flags → argparse's 2).
39
+
40
+ Usage
41
+ =====
42
+ .venv/bin/python scripts/bench_agent_smoke.py # human table
43
+ .venv/bin/python scripts/bench_agent_smoke.py --json # JSON to stdout
44
+ .venv/bin/python scripts/bench_agent_smoke.py --tasks 1 --max-steps 6
45
+ .venv/bin/python scripts/bench_agent_smoke.py --model mlx-community/...
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ import argparse
51
+ import asyncio
52
+ import json
53
+ import sys
54
+ import tempfile
55
+ import time
56
+ from pathlib import Path
57
+ from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
58
+
59
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
60
+
61
+ from latticeai.core.agent import ( # noqa: E402
62
+ AgentDeps,
63
+ AgentRunContext,
64
+ AgentState,
65
+ SingleAgentRuntime,
66
+ )
67
+ from latticeai.core.agent_eval import classify_result # noqa: E402
68
+ from latticeai.core.agent_prompts import ( # noqa: E402
69
+ CRITIC_PROMPT,
70
+ EXECUTOR_PROMPT,
71
+ MEMORY_UPDATER_PROMPT,
72
+ PLANNER_PROMPT,
73
+ )
74
+ from latticeai.tools import ToolError # noqa: E402
75
+
76
+
77
+ # ── canonical smoke tasks ────────────────────────────────────────────────
78
+ # Small, file-flavored tasks users actually ask for. The tool port is canned,
79
+ # so the measurement is "can this model steer the real loop to completion",
80
+ # not "is the artifact beautiful".
81
+ SMOKE_TASKS: List[Tuple[str, str]] = [
82
+ ("haiku-file", "Write a short haiku about autumn into haiku.txt."),
83
+ ("hello-script", "Create a Python file hello.py that prints hello."),
84
+ (
85
+ "list-then-summarize",
86
+ "List the files in the workspace, then summarize what you see in one sentence.",
87
+ ),
88
+ ]
89
+
90
+ _SMOKE_MODEL_FAMILIES = ("gemma", "qwen", "llama")
91
+
92
+ _AUTO_POLICY = {
93
+ "auto_approve": True, "risk": "low", "shell": False, "network": False,
94
+ "destructive": False, "sandbox": False, "rollback": "none",
95
+ }
96
+
97
+
98
+ def discover_agent_models() -> List[Dict[str, str]]:
99
+ """Installed local MLX models — the exact discovery pattern of
100
+ ``scripts/bench_models.py --filegen`` (product catalog + on-disk HF
101
+ checks). Any import/probe failure is fail-open: an empty list, never an
102
+ exception. Messages go to stderr so ``--json`` stdout stays parseable."""
103
+ try:
104
+ from latticeai.models.router import (
105
+ _looks_like_hf_model_dir,
106
+ hf_cache_model_dir,
107
+ hf_model_dir,
108
+ )
109
+ from latticeai.services.model_catalog import ENGINE_MODEL_CATALOG
110
+ except Exception as exc: # noqa: BLE001 - discovery must never crash the report
111
+ print(
112
+ f"agent-smoke: model catalog unavailable ({exc}); no models discovered",
113
+ file=sys.stderr,
114
+ )
115
+ return []
116
+ found: List[Dict[str, str]] = []
117
+ for entry in ENGINE_MODEL_CATALOG.get("local_mlx", []):
118
+ model_id = str(entry.get("id") or "")
119
+ lowered = model_id.lower()
120
+ family = next((f for f in _SMOKE_MODEL_FAMILIES if f in lowered), None)
121
+ if not family:
122
+ continue
123
+ try:
124
+ downloaded = (
125
+ _looks_like_hf_model_dir(hf_model_dir(model_id))
126
+ or hf_cache_model_dir(model_id) is not None
127
+ )
128
+ except Exception: # noqa: BLE001
129
+ downloaded = False
130
+ if downloaded:
131
+ found.append({"id": model_id, "family": family})
132
+ return found
133
+
134
+
135
+ # ── HTTP-less single-agent runtime over an in-memory tool port ───────────
136
+
137
+ class _SmokeReq:
138
+ conversation_id = None
139
+ temperature = 0.2
140
+ workspace_id = None
141
+ source = "agent_smoke"
142
+
143
+ def __init__(self, message: str) -> None:
144
+ self.message = message
145
+
146
+
147
+ def _build_smoke_deps(
148
+ generate_as: Callable[..., Awaitable[Any]],
149
+ tool_log: List[Dict[str, Any]],
150
+ ) -> AgentDeps:
151
+ """Real production prompts + a canned in-memory tool port.
152
+
153
+ The port mirrors the fake in ``latticeai.core.agent_eval``: it records
154
+ calls, fails a pathless write like the real dispatcher, and never touches
155
+ disk or network. All tools are auto-approve — governance behaviour is
156
+ agent_eval's job; this smoke measures loop mechanics over real inference.
157
+ """
158
+
159
+ async def generate(**kwargs):
160
+ return '{"action": "noop"}'
161
+
162
+ def execute_tool(name: str, args: dict) -> dict:
163
+ if name in ("write_file", "generate_file") and not args.get("path"):
164
+ raise ToolError(f"{name} requires args.path")
165
+ tool_log.append({"name": name, "args": args})
166
+ if name == "list_dir":
167
+ return {"ok": True, "entries": ["README.md", "haiku.txt", "hello.py"]}
168
+ if name == "read_file":
169
+ return {"ok": True, "path": args.get("path", ""), "content": "smoke fixture file"}
170
+ return {"ok": True, "path": args.get("path", "")}
171
+
172
+ return AgentDeps(
173
+ generate_as=generate_as,
174
+ generate=generate,
175
+ execute_tool=execute_tool,
176
+ policy_for=lambda name, args: dict(_AUTO_POLICY),
177
+ risk_level=lambda p: p["risk"],
178
+ check_role=lambda name, user: None,
179
+ tool_governance={
180
+ "write_file": dict(_AUTO_POLICY),
181
+ "read_file": dict(_AUTO_POLICY),
182
+ "list_dir": dict(_AUTO_POLICY),
183
+ "generate_file": dict(_AUTO_POLICY),
184
+ "knowledge_graph_ingest": dict(_AUTO_POLICY),
185
+ "knowledge_graph_search": dict(_AUTO_POLICY),
186
+ },
187
+ file_create_actions=frozenset({"write_file", "generate_file"}),
188
+ recent_chat_context=lambda **kw: "",
189
+ clear_history=lambda keep: {"ok": True},
190
+ knowledge_save=lambda *a, **kw: None,
191
+ audit=lambda *a, **kw: None,
192
+ planner_prompt=PLANNER_PROMPT,
193
+ executor_prompt=EXECUTOR_PROMPT,
194
+ critic_prompt=CRITIC_PROMPT,
195
+ memory_updater_prompt=MEMORY_UPDATER_PROMPT,
196
+ agent_root=Path(tempfile.gettempdir()) / "agent-smoke",
197
+ )
198
+
199
+
200
+ async def _run_one_task(
201
+ generate_as: Callable[..., Awaitable[Any]],
202
+ task_id: str,
203
+ prompt: str,
204
+ *,
205
+ max_steps: int = 8,
206
+ model_id: Optional[str] = None,
207
+ ) -> Dict[str, Any]:
208
+ """Drive one task through the real state machine; reduce to a report row."""
209
+ tool_log: List[Dict[str, Any]] = []
210
+ runtime = SingleAgentRuntime(_build_smoke_deps(generate_as, tool_log))
211
+ ctx = AgentRunContext()
212
+ ctx.state = AgentState.PLANNING
213
+ ctx.executing_model = model_id
214
+ ctx.reviewing_model = model_id
215
+ req = _SmokeReq(prompt)
216
+
217
+ t0 = time.perf_counter()
218
+ await runtime.plan(ctx, req, "en", "smoke@local", model_id=model_id)
219
+ # The operator launched this run deliberately — the human approval the
220
+ # gate asks for. The gate itself (and denial paths) are covered by
221
+ # agent_eval; a real model's free-form plan must not dead-end the smoke.
222
+ runtime.approve(ctx, "smoke@local", approved_by_human=True)
223
+ if ctx.state == AgentState.EXECUTING:
224
+ await runtime.run_to_completion(
225
+ ctx, req, "en", "smoke@local", max_steps=max_steps, max_retry=2
226
+ )
227
+ duration = round(time.perf_counter() - t0, 2)
228
+
229
+ summary = ctx.trace.summary()
230
+ executed = [call["name"] for call in tool_log]
231
+ return {
232
+ "task": task_id,
233
+ "final_state": ctx.state.value,
234
+ "result_class": classify_result(ctx.state.value, ctx.trace.events, summary, executed),
235
+ "steps": summary["llm_calls"],
236
+ "parse_errors": summary["parse_errors"],
237
+ "parse_recovered": summary["parse_recovered"],
238
+ "repairs_total": sum((summary.get("repairs") or {}).values()),
239
+ "repairs": summary.get("repairs") or {},
240
+ "tool_calls": len(tool_log),
241
+ "duration_s": duration,
242
+ }
243
+
244
+
245
+ def _make_router_generate_as(router: Any, model_id: str) -> Callable[..., Awaitable[str]]:
246
+ """Bridge the runtime's generate_as port to the real LLMRouter (the same
247
+ ``generate_as`` call shape bench_models' filegen mode uses)."""
248
+
249
+ async def generate_as(_model_id, message, context, max_tokens, temperature):
250
+ return str(
251
+ await router.generate_as(
252
+ _model_id or model_id,
253
+ message=message,
254
+ context=context,
255
+ max_tokens=max_tokens,
256
+ temperature=temperature,
257
+ )
258
+ )
259
+
260
+ return generate_as
261
+
262
+
263
+ async def _smoke_run_models(
264
+ models: List[Dict[str, str]],
265
+ tasks: List[Tuple[str, str]],
266
+ max_steps: int,
267
+ ) -> List[Dict[str, Any]]:
268
+ from latticeai.models.router import LLMRouter
269
+
270
+ router = LLMRouter()
271
+ results: List[Dict[str, Any]] = []
272
+ for model in models:
273
+ model_id = model["id"]
274
+ try:
275
+ await router.load_model(model_id)
276
+ except Exception as exc: # noqa: BLE001 - fail-open per model
277
+ results.append({
278
+ "model": model_id, "family": model["family"],
279
+ "skipped": True, "reason": f"load failed: {exc}",
280
+ })
281
+ continue
282
+ generate_as = _make_router_generate_as(router, model_id)
283
+ rows: List[Dict[str, Any]] = []
284
+ for task_id, prompt in tasks:
285
+ try:
286
+ rows.append(await _run_one_task(
287
+ generate_as, task_id, prompt,
288
+ max_steps=max_steps, model_id=model_id,
289
+ ))
290
+ except Exception as exc: # noqa: BLE001 - one broken task never sinks the report
291
+ rows.append({
292
+ "task": task_id, "final_state": "HARNESS_ERROR",
293
+ "result_class": "failed", "error": str(exc),
294
+ })
295
+ results.append({
296
+ "model": model_id,
297
+ "family": model["family"],
298
+ "tasks": rows,
299
+ "tasks_total": len(rows),
300
+ "completed": sum(1 for r in rows if r.get("final_state") == "DONE"),
301
+ "duration_s": round(sum(float(r.get("duration_s") or 0.0) for r in rows), 2),
302
+ })
303
+ try:
304
+ router.unload_model(model_id)
305
+ except Exception: # noqa: BLE001
306
+ pass
307
+ return results
308
+
309
+
310
+ def run_agent_smoke(
311
+ models: Optional[List[Dict[str, str]]] = None,
312
+ tasks: Optional[List[Tuple[str, str]]] = None,
313
+ max_steps: int = 8,
314
+ ) -> Dict[str, Any]:
315
+ """Weekly per-model agent-loop report. Fail-open: never raises, never gates."""
316
+ if models is None:
317
+ models = discover_agent_models()
318
+ selected_tasks = list(tasks or SMOKE_TASKS)
319
+ if not models:
320
+ return {
321
+ "mode": "agent-smoke",
322
+ "status": "skipped",
323
+ "reason": (
324
+ "no models available — install a local gemma/qwen/llama model "
325
+ "via the app's model picker, then re-run"
326
+ ),
327
+ "models": [],
328
+ }
329
+ try:
330
+ results = asyncio.run(_smoke_run_models(models, selected_tasks, max_steps))
331
+ except Exception as exc: # noqa: BLE001 - fail-open at the run level too
332
+ return {
333
+ "mode": "agent-smoke", "status": "skipped",
334
+ "reason": f"smoke run failed: {exc}", "models": [],
335
+ }
336
+ return {"mode": "agent-smoke", "status": "ok", "models": results}
337
+
338
+
339
+ def format_smoke_report(report: Dict[str, Any]) -> str:
340
+ lines = [
341
+ "Weekly agent-loop smoke (real models through SingleAgentRuntime)",
342
+ "=" * 72,
343
+ ]
344
+ if report.get("status") == "skipped":
345
+ lines.append(f"no models available — skipped (fail-open): {report.get('reason')}")
346
+ lines.append("=" * 72)
347
+ return "\n".join(lines)
348
+ for model in report.get("models", []):
349
+ if model.get("skipped"):
350
+ lines.append(f" {model['model']}: SKIPPED — {model.get('reason')}")
351
+ continue
352
+ lines.append(
353
+ f" {model['model']} "
354
+ f"(completed {model['completed']}/{model['tasks_total']}, "
355
+ f"{model['duration_s']}s)"
356
+ )
357
+ for row in model.get("tasks", []):
358
+ if row.get("final_state") == "HARNESS_ERROR":
359
+ lines.append(f" {row['task']:<22} HARNESS_ERROR {row.get('error')}")
360
+ continue
361
+ lines.append(
362
+ f" {row['task']:<22} {row['final_state']:<13} "
363
+ f"steps={row['steps']} parse_errors={row['parse_errors']} "
364
+ f"repairs={row['repairs_total']} tools={row['tool_calls']} "
365
+ f"{row['duration_s']}s"
366
+ )
367
+ lines.append("=" * 72)
368
+ lines.append(
369
+ "note: fail-open weekly report — weak results are the finding, never a failure"
370
+ )
371
+ return "\n".join(lines)
372
+
373
+
374
+ def main(argv: List[str] | None = None) -> int:
375
+ parser = argparse.ArgumentParser(
376
+ description="Weekly real-model agent-loop smoke (fail-open, never a gate)"
377
+ )
378
+ parser.add_argument(
379
+ "--json", dest="json_out", action="store_true",
380
+ help="print the machine-readable report JSON to stdout",
381
+ )
382
+ parser.add_argument(
383
+ "--tasks", type=int, default=len(SMOKE_TASKS),
384
+ help=f"how many smoke tasks to run per model (1-{len(SMOKE_TASKS)}, default all)",
385
+ )
386
+ parser.add_argument(
387
+ "--max-steps", type=int, default=8, help="agent-loop step budget per task",
388
+ )
389
+ parser.add_argument(
390
+ "--model", help="restrict the run to one installed model id",
391
+ )
392
+ args = parser.parse_args(argv)
393
+
394
+ count = max(1, min(int(args.tasks), len(SMOKE_TASKS)))
395
+ models = discover_agent_models()
396
+ if args.model:
397
+ models = [m for m in models if m["id"] == args.model]
398
+ report = run_agent_smoke(
399
+ models=models, tasks=SMOKE_TASKS[:count], max_steps=max(2, int(args.max_steps)),
400
+ )
401
+ if args.json_out:
402
+ print(json.dumps(report, ensure_ascii=False, indent=2))
403
+ else:
404
+ print(format_smoke_report(report))
405
+ # FAIL-OPEN: missing or weak models never produce a non-zero exit.
406
+ return 0
407
+
408
+
409
+ if __name__ == "__main__":
410
+ raise SystemExit(main())