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.
- package/README.md +44 -39
- package/docs/CHANGELOG.md +109 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/OPERATIONS.md +1 -1
- package/docs/SURFACE_PARITY.md +47 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/_kg_common.py +335 -0
- package/lattice_brain/graph/curator.py +141 -0
- package/lattice_brain/graph/discovery_index.py +8 -1
- package/lattice_brain/graph/documents.py +111 -0
- package/lattice_brain/graph/fusion.py +179 -0
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +382 -37
- package/lattice_brain/graph/retrieval.py +71 -4
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/ingestion.py +66 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/automation_intelligence.py +159 -0
- package/latticeai/api/browser.py +15 -4
- package/latticeai/api/chat.py +33 -2
- package/latticeai/api/chat_agent_http.py +363 -6
- package/latticeai/api/chat_contracts.py +7 -1
- package/latticeai/api/chat_helpers.py +147 -0
- package/latticeai/api/chat_intents.py +217 -10
- package/latticeai/api/chat_stream.py +93 -3
- package/latticeai/api/funnel_metrics.py +31 -0
- package/latticeai/api/knowledge_graph.py +65 -1
- package/latticeai/api/local_files.py +90 -0
- package/latticeai/api/setup.py +134 -1
- package/latticeai/api/tools.py +33 -3
- package/latticeai/app_factory.py +14 -0
- package/latticeai/core/agent.py +355 -18
- package/latticeai/core/agent_eval.py +107 -0
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +404 -2
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/models/router.py +25 -12
- package/latticeai/runtime/persistence_runtime.py +19 -1
- package/latticeai/runtime/router_registration.py +8 -1
- package/latticeai/services/app_context.py +3 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/automation_execution.py +266 -0
- package/latticeai/services/automation_intelligence.py +7 -0
- package/latticeai/services/command_center.py +110 -2
- package/latticeai/services/folder_watch.py +396 -0
- package/latticeai/services/funnel_metrics.py +210 -0
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +51 -5
- package/latticeai/setup/demo_corpus.py +98 -0
- package/latticeai/tools/filesystem.py +38 -1
- package/package.json +1 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/bench_models.py +234 -5
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/src-tauri/Cargo.lock +1 -1
- package/src-tauri/Cargo.toml +1 -1
- package/src-tauri/tauri.conf.json +1 -1
- package/static/app/asset-manifest.json +29 -27
- package/static/app/assets/Act-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-BnGbytHa.js → Brain-CTnjox7w.js} +2 -2
- package/static/app/assets/BrainHome-BJ3sFNX0.js +2 -0
- package/static/app/assets/BrainSignals-C52lwZVD.js +1 -0
- package/static/app/assets/{Capture-CAKnLcb0.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/CommandPalette-90u9FWFH.js +1 -0
- package/static/app/assets/Library-pKCK0_tk.js +1 -0
- package/static/app/assets/{LivingBrain-C3VbogVo.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-BLau7c7N.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/ReviewCard-DWvD7n9h.js +3 -0
- package/static/app/assets/{System-DRHc_6Su.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-DZkDdh-A.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/circle-play-B-IsFL1y.js +1 -0
- package/static/app/assets/{cpu-DyvsDBbj.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-CffoIr9t.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-C1oojrHb.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/index-7FAfYm4v.css +2 -0
- package/static/app/assets/{index-BS_3aN1n.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-B_U7zvbR.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-xG-gyjbe.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-BF13HJJF.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-DsuKh3M8.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/shield-alert-BftATuAA.js +1 -0
- package/static/app/assets/{textarea-DsDL4ET8.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/useFocusTrap-B7RPGfFy.js +1 -0
- package/static/app/assets/utils-SJUNVOj5.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-Cx_gZNbE.js +0 -1
- package/static/app/assets/BrainHome-CRUMJL93.js +0 -2
- package/static/app/assets/BrainSignals-BP8-Qh2g.js +0 -1
- package/static/app/assets/CommandPalette-BoUTXBfm.js +0 -1
- package/static/app/assets/Library-PcfVUco6.js +0 -1
- package/static/app/assets/ReviewCard-CX5RqRqx.js +0 -1
- package/static/app/assets/download-C7yxYHvz.js +0 -1
- package/static/app/assets/index-DU78Kogz.css +0 -2
- package/static/app/assets/sunrise-CIP2ELJ1.js +0 -1
- 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())
|