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
package/latticeai/core/agent.py
CHANGED
|
@@ -27,12 +27,16 @@ import re
|
|
|
27
27
|
from dataclasses import dataclass
|
|
28
28
|
from enum import Enum
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Optional, Tuple
|
|
30
|
+
from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Optional, Tuple
|
|
31
31
|
|
|
32
32
|
from lattice_brain.runtime.hooks import dispatch_tool
|
|
33
33
|
from lattice_brain.runtime.contracts import runtime_boundary_contract, single_agent_contract
|
|
34
34
|
from latticeai.core.agent_trace import LoopTrace
|
|
35
|
-
from latticeai.core.file_generation import
|
|
35
|
+
from latticeai.core.file_generation import (
|
|
36
|
+
infer_file_target,
|
|
37
|
+
infer_project_manifest,
|
|
38
|
+
sanitize_write_content,
|
|
39
|
+
)
|
|
36
40
|
from latticeai.core.tool_registry import SCOPED_KNOWLEDGE_TOOLS
|
|
37
41
|
from latticeai.tools import ToolError
|
|
38
42
|
|
|
@@ -62,7 +66,8 @@ class AgentRunContext:
|
|
|
62
66
|
"""Mutable state carrier passed through all agent phases."""
|
|
63
67
|
__slots__ = ("state", "plan", "transcript", "retry_count",
|
|
64
68
|
"state_history", "corrections", "final_message", "rollback_log",
|
|
65
|
-
"executing_model", "reviewing_model", "approved_by_human", "trace"
|
|
69
|
+
"executing_model", "reviewing_model", "approved_by_human", "trace",
|
|
70
|
+
"on_step")
|
|
66
71
|
|
|
67
72
|
def __init__(self) -> None:
|
|
68
73
|
self.state: AgentState = AgentState.IDLE
|
|
@@ -77,6 +82,10 @@ class AgentRunContext:
|
|
|
77
82
|
self.executing_model: Optional[str] = None
|
|
78
83
|
self.reviewing_model: Optional[str] = None
|
|
79
84
|
self.approved_by_human: bool = False
|
|
85
|
+
# Per-run step observer (review Wave 1.1): the HTTP layer attaches a
|
|
86
|
+
# callback here so live SSE clients see progress while EXECUTING.
|
|
87
|
+
# Never serialized; a broken observer never breaks the loop.
|
|
88
|
+
self.on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
80
89
|
|
|
81
90
|
|
|
82
91
|
_THINK_BLOCK_RE = re.compile(
|
|
@@ -142,6 +151,30 @@ def extract_action(raw: str) -> Dict:
|
|
|
142
151
|
return action
|
|
143
152
|
|
|
144
153
|
|
|
154
|
+
_FILE_CREATE_PLAN_ACTIONS = frozenset({"write_file", "generate_file"})
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _plan_misses_manifest(steps: List[Dict[str, Any]], manifest: Dict[str, Any]) -> bool:
|
|
158
|
+
"""True when a pure file-writing plan fails to cover the manifest's file types.
|
|
159
|
+
|
|
160
|
+
Only pure file-creation plans are candidates for rewriting — a plan with
|
|
161
|
+
read/search steps reflects real planner intent and stays untouched.
|
|
162
|
+
"""
|
|
163
|
+
if any(s.get("action") not in _FILE_CREATE_PLAN_ACTIONS for s in steps):
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
def _ext(path: Any) -> str:
|
|
167
|
+
text = str(path or "")
|
|
168
|
+
dot = text.rfind(".")
|
|
169
|
+
return text[dot:].lower() if dot >= 0 else ""
|
|
170
|
+
|
|
171
|
+
planned_exts = {
|
|
172
|
+
_ext((s.get("args") or {}).get("path")) for s in steps
|
|
173
|
+
}
|
|
174
|
+
manifest_exts = {_ext(spec.get("path")) for spec in manifest.get("files", [])}
|
|
175
|
+
return not manifest_exts.issubset(planned_exts)
|
|
176
|
+
|
|
177
|
+
|
|
145
178
|
def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[str]]:
|
|
146
179
|
"""Enforce the minimal plan schema so execution never starts adrift.
|
|
147
180
|
|
|
@@ -173,6 +206,27 @@ def normalize_plan(plan: Any, user_message: str) -> Tuple[Dict[str, Any], List[s
|
|
|
173
206
|
]
|
|
174
207
|
if raw_steps and steps != raw_steps:
|
|
175
208
|
fixes.append("steps_filtered")
|
|
209
|
+
|
|
210
|
+
# Manifest-aware planning (review Wave 0.4): when the request is a
|
|
211
|
+
# recognized multi-file project, the deterministic manifest — not the
|
|
212
|
+
# planner's improvisation — decides the file set, exactly like the direct
|
|
213
|
+
# chat path. Rewrites apply only when the plan is empty or is a pure
|
|
214
|
+
# file-writing plan that misses part of the manifest, so a planner that
|
|
215
|
+
# already covered every requested file type is left untouched.
|
|
216
|
+
manifest = infer_project_manifest(user_message)
|
|
217
|
+
if manifest:
|
|
218
|
+
manifest_steps = [{
|
|
219
|
+
"action": "write_file",
|
|
220
|
+
"args": {"path": spec["path"]},
|
|
221
|
+
"description": spec["brief"],
|
|
222
|
+
} for spec in manifest["files"]]
|
|
223
|
+
if not steps:
|
|
224
|
+
steps = manifest_steps
|
|
225
|
+
fixes.append("manifest_steps")
|
|
226
|
+
elif _plan_misses_manifest(steps, manifest):
|
|
227
|
+
steps = manifest_steps
|
|
228
|
+
fixes.append("manifest_rewrite")
|
|
229
|
+
|
|
176
230
|
if not steps:
|
|
177
231
|
inferred = infer_file_target(user_message)
|
|
178
232
|
if inferred:
|
|
@@ -227,6 +281,135 @@ def filter_learnings(learnings: List[Any]) -> List[str]:
|
|
|
227
281
|
return kept
|
|
228
282
|
|
|
229
283
|
|
|
284
|
+
def _truncate_strings(value: Any, limit: int) -> Any:
|
|
285
|
+
"""Deep-copy ``value`` with every string capped at ``limit`` chars.
|
|
286
|
+
|
|
287
|
+
Long tool outputs (file bodies, command output) dominate executor prompt
|
|
288
|
+
size without adding decision-relevant signal. The cap keeps the head of
|
|
289
|
+
each string and names how much was dropped, so the model still sees what
|
|
290
|
+
the value was — never a silent hole.
|
|
291
|
+
"""
|
|
292
|
+
if isinstance(value, str):
|
|
293
|
+
if len(value) <= limit:
|
|
294
|
+
return value
|
|
295
|
+
return value[:limit] + f"…[+{len(value) - limit} chars]"
|
|
296
|
+
if isinstance(value, dict):
|
|
297
|
+
return {k: _truncate_strings(v, limit) for k, v in value.items()}
|
|
298
|
+
if isinstance(value, list):
|
|
299
|
+
return [_truncate_strings(v, limit) for v in value]
|
|
300
|
+
return value
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def compact_transcript(
|
|
304
|
+
transcript: List[Dict[str, Any]],
|
|
305
|
+
*,
|
|
306
|
+
window: int = 8,
|
|
307
|
+
result_chars: int = 700,
|
|
308
|
+
) -> List[Dict[str, Any]]:
|
|
309
|
+
"""Bounded executor view of a transcript (review Wave 0.3).
|
|
310
|
+
|
|
311
|
+
The executor prompt previously embedded the *entire* transcript JSON every
|
|
312
|
+
step — O(steps²) token growth that starved :class:`PhaseBudgets` on long
|
|
313
|
+
runs and buried weak models in stale detail. This view keeps the most
|
|
314
|
+
recent ``window`` steps in full (with string values capped at
|
|
315
|
+
``result_chars``) and reduces every older step to a one-line summary, so
|
|
316
|
+
the prompt stays bounded while no step disappears entirely.
|
|
317
|
+
"""
|
|
318
|
+
steps = list(transcript or [])
|
|
319
|
+
if len(steps) <= window:
|
|
320
|
+
return [_truncate_strings(step, result_chars) for step in steps]
|
|
321
|
+
older, recent = steps[:-window], steps[-window:]
|
|
322
|
+
summarized: List[Dict[str, Any]] = [{
|
|
323
|
+
"summarized_older_steps": len(older),
|
|
324
|
+
"note": "older steps compacted — full detail retained in the run record",
|
|
325
|
+
}]
|
|
326
|
+
for step in older:
|
|
327
|
+
entry: Dict[str, Any] = {"state": step.get("state")}
|
|
328
|
+
for key in ("action", "verdict", "retry_attempt"):
|
|
329
|
+
if step.get(key) is not None:
|
|
330
|
+
entry[key] = step.get(key)
|
|
331
|
+
if step.get("error"):
|
|
332
|
+
entry["error"] = str(step["error"])[:160]
|
|
333
|
+
elif isinstance(step.get("result"), dict):
|
|
334
|
+
entry["ok"] = True
|
|
335
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
336
|
+
if path:
|
|
337
|
+
entry["path"] = str(path)
|
|
338
|
+
summarized.append(entry)
|
|
339
|
+
summarized.extend(_truncate_strings(step, result_chars) for step in recent)
|
|
340
|
+
return summarized
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
@dataclass(frozen=True)
|
|
344
|
+
class TranscriptBudget:
|
|
345
|
+
"""Executor/critic prompt shaping caps (review Wave 0.3).
|
|
346
|
+
|
|
347
|
+
``window`` full recent steps for the executor; per-string caps keep tool
|
|
348
|
+
output bodies from dominating either prompt. Overridable through the same
|
|
349
|
+
``Config.from_env`` pattern as :class:`PhaseBudgets`.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
window: int = 8
|
|
353
|
+
result_chars: int = 700
|
|
354
|
+
verify_chars: int = 1200
|
|
355
|
+
|
|
356
|
+
@classmethod
|
|
357
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "TranscriptBudget":
|
|
358
|
+
from latticeai.core.config import _int
|
|
359
|
+
|
|
360
|
+
if env is None:
|
|
361
|
+
import os
|
|
362
|
+
|
|
363
|
+
env = os.environ
|
|
364
|
+
|
|
365
|
+
def cap(key: str, default: int, floor: int) -> int:
|
|
366
|
+
return max(floor, _int(env, key, default))
|
|
367
|
+
|
|
368
|
+
return cls(
|
|
369
|
+
window=cap("LATTICEAI_AGENT_TRANSCRIPT_WINDOW", cls.window, 2),
|
|
370
|
+
result_chars=cap("LATTICEAI_AGENT_TRANSCRIPT_CHARS", cls.result_chars, 120),
|
|
371
|
+
verify_chars=cap("LATTICEAI_AGENT_VERIFY_CHARS", cls.verify_chars, 200),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@dataclass(frozen=True)
|
|
376
|
+
class PhaseBudgets:
|
|
377
|
+
"""Per-phase token budgets for the agent loop.
|
|
378
|
+
|
|
379
|
+
One shared budget let a weak model burn everything on planning prose and
|
|
380
|
+
reach EXECUTE with nothing left. Each role phase now has its own cap, so
|
|
381
|
+
a verbose planner can never starve execution or verification. Defaults
|
|
382
|
+
match the historical hardcoded values; every cap is overridable through
|
|
383
|
+
the ``Config.from_env`` environment pattern.
|
|
384
|
+
"""
|
|
385
|
+
|
|
386
|
+
plan_tokens: int = 1024
|
|
387
|
+
execute_tokens: int = 4096
|
|
388
|
+
verify_tokens: int = 512
|
|
389
|
+
memory_tokens: int = 256
|
|
390
|
+
|
|
391
|
+
@classmethod
|
|
392
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "PhaseBudgets":
|
|
393
|
+
from latticeai.core.config import _int
|
|
394
|
+
|
|
395
|
+
if env is None:
|
|
396
|
+
import os
|
|
397
|
+
|
|
398
|
+
env = os.environ
|
|
399
|
+
|
|
400
|
+
def cap(key: str, default: int) -> int:
|
|
401
|
+
# A misconfigured/absurd value must not brick the loop: floor at a
|
|
402
|
+
# budget that still fits one JSON action object.
|
|
403
|
+
return max(128, _int(env, key, default))
|
|
404
|
+
|
|
405
|
+
return cls(
|
|
406
|
+
plan_tokens=cap("LATTICEAI_AGENT_PLAN_TOKENS", cls.plan_tokens),
|
|
407
|
+
execute_tokens=cap("LATTICEAI_AGENT_EXECUTE_TOKENS", cls.execute_tokens),
|
|
408
|
+
verify_tokens=cap("LATTICEAI_AGENT_VERIFY_TOKENS", cls.verify_tokens),
|
|
409
|
+
memory_tokens=cap("LATTICEAI_AGENT_MEMORY_TOKENS", cls.memory_tokens),
|
|
410
|
+
)
|
|
411
|
+
|
|
412
|
+
|
|
230
413
|
@dataclass
|
|
231
414
|
class AgentDeps:
|
|
232
415
|
"""The ports a :class:`SingleAgentRuntime` needs from the outside world.
|
|
@@ -286,12 +469,68 @@ class AgentDeps:
|
|
|
286
469
|
# returning None (fall through to the classic gates) or a verdict dict.
|
|
287
470
|
change_governor: Any = None
|
|
288
471
|
|
|
472
|
+
# ── phase budgets (optional) ─────────────────────────────────────
|
|
473
|
+
# Per-phase token caps (plan/execute/verify/memory). None reads the
|
|
474
|
+
# environment once at first use; tests inject a fixed PhaseBudgets.
|
|
475
|
+
phase_budgets: Optional[PhaseBudgets] = None
|
|
476
|
+
|
|
477
|
+
# ── transcript shaping (optional) ────────────────────────────────
|
|
478
|
+
# Executor/critic prompt window caps. None reads the environment once;
|
|
479
|
+
# tests inject a fixed TranscriptBudget.
|
|
480
|
+
transcript_budget: Optional["TranscriptBudget"] = None
|
|
481
|
+
|
|
482
|
+
# ── step observer port (optional) ────────────────────────────────
|
|
483
|
+
# Default per-runtime observer for live step events; a per-run observer
|
|
484
|
+
# can also be attached on AgentRunContext.on_step. Both are advisory.
|
|
485
|
+
on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
486
|
+
|
|
289
487
|
|
|
290
488
|
class SingleAgentRuntime:
|
|
291
489
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
292
490
|
|
|
293
491
|
def __init__(self, deps: AgentDeps) -> None:
|
|
294
492
|
self.deps = deps
|
|
493
|
+
self._env_phase_budgets: Optional[PhaseBudgets] = None
|
|
494
|
+
self._env_transcript_budget: Optional[TranscriptBudget] = None
|
|
495
|
+
|
|
496
|
+
@property
|
|
497
|
+
def phase_budgets(self) -> PhaseBudgets:
|
|
498
|
+
# getattr twice: partially-constructed runtimes/deps (tests build them
|
|
499
|
+
# via __new__ or minimal fakes) still get working default budgets.
|
|
500
|
+
injected = getattr(self.deps, "phase_budgets", None)
|
|
501
|
+
if injected is not None:
|
|
502
|
+
return injected
|
|
503
|
+
if getattr(self, "_env_phase_budgets", None) is None:
|
|
504
|
+
self._env_phase_budgets = PhaseBudgets.from_env()
|
|
505
|
+
return self._env_phase_budgets
|
|
506
|
+
|
|
507
|
+
@property
|
|
508
|
+
def transcript_budget(self) -> TranscriptBudget:
|
|
509
|
+
injected = getattr(self.deps, "transcript_budget", None)
|
|
510
|
+
if injected is not None:
|
|
511
|
+
return injected
|
|
512
|
+
if getattr(self, "_env_transcript_budget", None) is None:
|
|
513
|
+
self._env_transcript_budget = TranscriptBudget.from_env()
|
|
514
|
+
return self._env_transcript_budget
|
|
515
|
+
|
|
516
|
+
def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
|
|
517
|
+
"""Fire the per-run / deps step observers (review Wave 1.1).
|
|
518
|
+
|
|
519
|
+
Observers power the live step timeline in the UI. They are pure
|
|
520
|
+
telemetry: any observer failure is logged and swallowed — the loop
|
|
521
|
+
itself must never notice.
|
|
522
|
+
"""
|
|
523
|
+
payload: Dict[str, Any] = {"phase": phase, "event": event}
|
|
524
|
+
for key, value in details.items():
|
|
525
|
+
if value is not None:
|
|
526
|
+
payload[key] = value
|
|
527
|
+
for observer in (getattr(ctx, "on_step", None), getattr(self.deps, "on_step", None)):
|
|
528
|
+
if observer is None:
|
|
529
|
+
continue
|
|
530
|
+
try:
|
|
531
|
+
observer(dict(payload))
|
|
532
|
+
except Exception as exc: # noqa: BLE001 — observers are advisory
|
|
533
|
+
logging.warning("agent step observer failed: %s", exc)
|
|
295
534
|
|
|
296
535
|
def boundary(self) -> Dict[str, Any]:
|
|
297
536
|
return runtime_boundary_contract(
|
|
@@ -331,7 +570,7 @@ class SingleAgentRuntime:
|
|
|
331
570
|
raw = await d.generate_as(
|
|
332
571
|
model_id,
|
|
333
572
|
message="Produce a JSON execution plan for this request.",
|
|
334
|
-
context=context, max_tokens=
|
|
573
|
+
context=context, max_tokens=self.phase_budgets.plan_tokens, temperature=0.1,
|
|
335
574
|
)
|
|
336
575
|
ctx.trace.llm_call("plan", model=model_id)
|
|
337
576
|
try:
|
|
@@ -357,11 +596,23 @@ class SingleAgentRuntime:
|
|
|
357
596
|
"estimated_steps": plan.get("estimated_steps", 1),
|
|
358
597
|
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
359
598
|
})
|
|
599
|
+
self._emit_step(
|
|
600
|
+
ctx, "plan", "planned",
|
|
601
|
+
goal=str(plan.get("goal") or "")[:200],
|
|
602
|
+
steps=len(plan.get("steps") or []),
|
|
603
|
+
requires_approval=bool(plan.get("requires_approval", False)),
|
|
604
|
+
)
|
|
360
605
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
361
606
|
|
|
362
607
|
# ── APPROVAL ─────────────────────────────────────────────────────
|
|
363
|
-
def
|
|
364
|
-
"""
|
|
608
|
+
def approval_requirements(self, ctx: AgentRunContext) -> Dict[str, Any]:
|
|
609
|
+
"""Read-only preview of the approval gate for a planned run.
|
|
610
|
+
|
|
611
|
+
Shares the exact predicate :meth:`approve` enforces, so the HTTP
|
|
612
|
+
layer can pause a run as ``awaiting_approval`` (with a plan summary
|
|
613
|
+
for the user) instead of letting it fail closed — without ever
|
|
614
|
+
weakening the gate itself.
|
|
615
|
+
"""
|
|
365
616
|
d = self.deps
|
|
366
617
|
auto_approve_tools = {name for name, p in d.tool_governance.items() if p["auto_approve"]}
|
|
367
618
|
# Governor-managed tools never hard-block the plan: each call is
|
|
@@ -377,7 +628,26 @@ class SingleAgentRuntime:
|
|
|
377
628
|
if s.get("action") not in auto_approve_tools
|
|
378
629
|
and s.get("action") not in governed_tools
|
|
379
630
|
]
|
|
380
|
-
requires = ctx.plan.get("requires_approval", False) or bool(non_auto)
|
|
631
|
+
requires = bool(ctx.plan.get("requires_approval", False)) or bool(non_auto)
|
|
632
|
+
lines = [
|
|
633
|
+
f"{index}. {step.get('description') or step.get('action') or '?'}"
|
|
634
|
+
for index, step in enumerate(steps, start=1)
|
|
635
|
+
]
|
|
636
|
+
summary = str(ctx.plan.get("goal") or "").strip()
|
|
637
|
+
if lines:
|
|
638
|
+
summary = (summary + "\n" if summary else "") + "\n".join(lines)
|
|
639
|
+
return {
|
|
640
|
+
"requires_approval": requires,
|
|
641
|
+
"non_auto_steps": non_auto,
|
|
642
|
+
"plan_summary": summary,
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
def approve(self, ctx: AgentRunContext, current_user: str, *, approved_by_human: bool = False) -> None:
|
|
646
|
+
"""APPROVAL: Check governance, log decision, auto-approve (future: UI prompt)."""
|
|
647
|
+
d = self.deps
|
|
648
|
+
requirements = self.approval_requirements(ctx)
|
|
649
|
+
non_auto = requirements["non_auto_steps"]
|
|
650
|
+
requires = requirements["requires_approval"]
|
|
381
651
|
|
|
382
652
|
ctx.transcript.append({
|
|
383
653
|
"state": AgentState.WAITING_APPROVAL.value,
|
|
@@ -387,6 +657,7 @@ class SingleAgentRuntime:
|
|
|
387
657
|
})
|
|
388
658
|
decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
|
|
389
659
|
ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
|
|
660
|
+
self._emit_step(ctx, "approval", "decision", decision=decision)
|
|
390
661
|
d.audit(
|
|
391
662
|
"agent_approval", user_email=current_user,
|
|
392
663
|
requires_approval=requires,
|
|
@@ -420,7 +691,8 @@ class SingleAgentRuntime:
|
|
|
420
691
|
raw = await d.generate_as(
|
|
421
692
|
model_id,
|
|
422
693
|
message="Execute the next step.",
|
|
423
|
-
context=context, max_tokens=
|
|
694
|
+
context=context, max_tokens=self.phase_budgets.execute_tokens,
|
|
695
|
+
temperature=req.temperature,
|
|
424
696
|
)
|
|
425
697
|
ctx.trace.llm_call("execute", model=model_id)
|
|
426
698
|
try:
|
|
@@ -449,6 +721,7 @@ class SingleAgentRuntime:
|
|
|
449
721
|
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
450
722
|
})
|
|
451
723
|
ctx.trace.decision("execute", decision="final")
|
|
724
|
+
self._emit_step(ctx, "execute", "final")
|
|
452
725
|
ctx.state = AgentState.VERIFYING
|
|
453
726
|
return
|
|
454
727
|
|
|
@@ -459,6 +732,7 @@ class SingleAgentRuntime:
|
|
|
459
732
|
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
460
733
|
})
|
|
461
734
|
ctx.trace.decision("execute", decision="loop_detected", tool=name)
|
|
735
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
|
|
462
736
|
break
|
|
463
737
|
|
|
464
738
|
if name == "clear_history":
|
|
@@ -467,6 +741,7 @@ class SingleAgentRuntime:
|
|
|
467
741
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
468
742
|
"thoughts": thoughts, "args": args, "result": result,
|
|
469
743
|
})
|
|
744
|
+
self._emit_step(ctx, "execute", "tool", action=name, ok=True)
|
|
470
745
|
continue
|
|
471
746
|
|
|
472
747
|
policy = d.policy_for(name, args)
|
|
@@ -495,10 +770,13 @@ class SingleAgentRuntime:
|
|
|
495
770
|
) -> str:
|
|
496
771
|
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
497
772
|
d = self.deps
|
|
773
|
+
# Only the latest corrections steer the next attempt — stale hints
|
|
774
|
+
# from earlier retries dilute weak models (review Wave 0.3).
|
|
775
|
+
active_corrections = ctx.corrections[-3:]
|
|
498
776
|
corrections_hint = (
|
|
499
777
|
"\n\nCritic corrections from previous attempt:\n"
|
|
500
|
-
+ "\n".join(f"- {c}" for c in
|
|
501
|
-
) if
|
|
778
|
+
+ "\n".join(f"- {c}" for c in active_corrections)
|
|
779
|
+
) if active_corrections else ""
|
|
502
780
|
|
|
503
781
|
recent_kwargs = {
|
|
504
782
|
"conversation_id": req.conversation_id,
|
|
@@ -507,6 +785,12 @@ class SingleAgentRuntime:
|
|
|
507
785
|
if request_workspace is not None:
|
|
508
786
|
recent_kwargs["workspace_id"] = request_workspace
|
|
509
787
|
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
788
|
+
budget = self.transcript_budget
|
|
789
|
+
bounded_transcript = compact_transcript(
|
|
790
|
+
ctx.transcript,
|
|
791
|
+
window=budget.window,
|
|
792
|
+
result_chars=budget.result_chars,
|
|
793
|
+
)
|
|
510
794
|
return (
|
|
511
795
|
f"{d.executor_prompt}\n\n"
|
|
512
796
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
@@ -514,7 +798,7 @@ class SingleAgentRuntime:
|
|
|
514
798
|
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
515
799
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
516
800
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
517
|
-
f"Execution transcript:\n{json.dumps(
|
|
801
|
+
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
518
802
|
)
|
|
519
803
|
|
|
520
804
|
def _note_parse_failure(
|
|
@@ -527,8 +811,10 @@ class SingleAgentRuntime:
|
|
|
527
811
|
})
|
|
528
812
|
if parse_failures >= 3:
|
|
529
813
|
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
814
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
530
815
|
return True
|
|
531
816
|
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
817
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=True)
|
|
532
818
|
# Weak models often need one concrete reminder of the wire
|
|
533
819
|
# format; feed it through the corrections channel and retry
|
|
534
820
|
# instead of aborting the whole run on the first slip.
|
|
@@ -584,6 +870,7 @@ class SingleAgentRuntime:
|
|
|
584
870
|
if verdict is not None and verdict.get("decision") == "proposed":
|
|
585
871
|
proposal = verdict.get("proposal") or {}
|
|
586
872
|
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
873
|
+
self._emit_step(ctx, "execute", "proposed", action=name)
|
|
587
874
|
ctx.transcript.append({
|
|
588
875
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
589
876
|
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
@@ -610,6 +897,7 @@ class SingleAgentRuntime:
|
|
|
610
897
|
d = self.deps
|
|
611
898
|
if policy["risk"] == "destructive":
|
|
612
899
|
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
900
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
613
901
|
ctx.transcript.append({
|
|
614
902
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
615
903
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -632,6 +920,7 @@ class SingleAgentRuntime:
|
|
|
632
920
|
args={k: v for k, v in args.items() if k != "content"},
|
|
633
921
|
)
|
|
634
922
|
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
923
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
|
|
635
924
|
ctx.transcript.append({
|
|
636
925
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
637
926
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -667,6 +956,11 @@ class SingleAgentRuntime:
|
|
|
667
956
|
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
668
957
|
],
|
|
669
958
|
)
|
|
959
|
+
step_index = 1 + sum(
|
|
960
|
+
1 for s in ctx.transcript
|
|
961
|
+
if s.get("state") == AgentState.EXECUTING.value
|
|
962
|
+
and s.get("action") not in (None, "final", "parse_error")
|
|
963
|
+
)
|
|
670
964
|
try:
|
|
671
965
|
d.check_role(name, current_user)
|
|
672
966
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -682,6 +976,10 @@ class SingleAgentRuntime:
|
|
|
682
976
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
683
977
|
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
684
978
|
})
|
|
979
|
+
self._emit_step(
|
|
980
|
+
ctx, "execute", "tool", action=name, ok=True, step=step_index,
|
|
981
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
982
|
+
)
|
|
685
983
|
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
686
984
|
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
687
985
|
ctx.transcript.append({
|
|
@@ -689,6 +987,10 @@ class SingleAgentRuntime:
|
|
|
689
987
|
"thoughts": thoughts, "args": args,
|
|
690
988
|
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
691
989
|
})
|
|
990
|
+
self._emit_step(
|
|
991
|
+
ctx, "execute", "tool", action=name, ok=False, step=step_index,
|
|
992
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
993
|
+
)
|
|
692
994
|
|
|
693
995
|
# ── VERIFY ───────────────────────────────────────────────────────
|
|
694
996
|
def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
|
|
@@ -717,17 +1019,23 @@ class SingleAgentRuntime:
|
|
|
717
1019
|
NEEDS_REVIEW so the user is told to check the result themselves.
|
|
718
1020
|
"""
|
|
719
1021
|
d = self.deps
|
|
1022
|
+
# The critic must see every step (evidence completeness), but not
|
|
1023
|
+
# every byte of tool output — long bodies are capped per string so
|
|
1024
|
+
# verification stays affordable on long runs (review Wave 0.3).
|
|
1025
|
+
verify_transcript = _truncate_strings(
|
|
1026
|
+
ctx.transcript, self.transcript_budget.verify_chars
|
|
1027
|
+
)
|
|
720
1028
|
context = (
|
|
721
1029
|
f"{d.critic_prompt}\n\n"
|
|
722
1030
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
723
1031
|
f"Original request: {req.message}\n"
|
|
724
1032
|
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
725
|
-
f"Full transcript:\n{json.dumps(
|
|
1033
|
+
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
726
1034
|
)
|
|
727
1035
|
raw = await d.generate_as(
|
|
728
1036
|
model_id,
|
|
729
1037
|
message="Review the execution transcript and return your verdict JSON.",
|
|
730
|
-
context=context, max_tokens=
|
|
1038
|
+
context=context, max_tokens=self.phase_budgets.verify_tokens, temperature=0.1,
|
|
731
1039
|
)
|
|
732
1040
|
ctx.trace.llm_call("verify", model=model_id)
|
|
733
1041
|
verdict: Optional[Dict[str, Any]] = None
|
|
@@ -749,7 +1057,8 @@ class SingleAgentRuntime:
|
|
|
749
1057
|
raw = await d.generate_as(
|
|
750
1058
|
model_id,
|
|
751
1059
|
message="Return your verdict as one strict JSON object.",
|
|
752
|
-
context=strict_context, max_tokens=
|
|
1060
|
+
context=strict_context, max_tokens=self.phase_budgets.verify_tokens,
|
|
1061
|
+
temperature=0.0,
|
|
753
1062
|
)
|
|
754
1063
|
ctx.trace.llm_call("verify", model=model_id)
|
|
755
1064
|
try:
|
|
@@ -775,6 +1084,7 @@ class SingleAgentRuntime:
|
|
|
775
1084
|
"verify", decision="verification_unavailable",
|
|
776
1085
|
verifier_available=False, verdict_valid=False, evidence=has_evidence,
|
|
777
1086
|
)
|
|
1087
|
+
self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
|
|
778
1088
|
ctx.final_message = (
|
|
779
1089
|
"검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
|
|
780
1090
|
"실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
|
|
@@ -803,6 +1113,10 @@ class SingleAgentRuntime:
|
|
|
803
1113
|
"verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
|
|
804
1114
|
verifier_available=True, verdict_valid=True, evidence=has_evidence,
|
|
805
1115
|
)
|
|
1116
|
+
self._emit_step(
|
|
1117
|
+
ctx, "verify", "verdict",
|
|
1118
|
+
verdict=str(verdict.get("verdict", "")), next_state=next_s,
|
|
1119
|
+
)
|
|
806
1120
|
if verdict.get("verdict") == "PASS":
|
|
807
1121
|
# DONE requires both: a validly parsed PASS verdict AND
|
|
808
1122
|
# deterministic execution evidence in the transcript. A PASS over
|
|
@@ -884,27 +1198,49 @@ class SingleAgentRuntime:
|
|
|
884
1198
|
else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
|
|
885
1199
|
)
|
|
886
1200
|
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
1201
|
+
self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
|
|
887
1202
|
# Rollback is a recovery from a failed verification — terminal state is FAILED
|
|
888
1203
|
ctx.state = AgentState.FAILED
|
|
889
1204
|
|
|
890
1205
|
# ── MEMORY ───────────────────────────────────────────────────────
|
|
891
1206
|
async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
|
|
892
|
-
"""Background: Memory Updater role extracts learnings
|
|
1207
|
+
"""Background: Memory Updater role extracts learnings from a terminal run.
|
|
1208
|
+
|
|
1209
|
+
Terminal-state learning policy (review §4.2 L6): DONE runs record what
|
|
1210
|
+
worked; FAILED / NEEDS_REVIEW runs record what went wrong — failure is
|
|
1211
|
+
exactly the experience worth remembering. The run status stored with
|
|
1212
|
+
the experience is the *actual* terminal state, never a blanket "ok".
|
|
1213
|
+
"""
|
|
893
1214
|
d = self.deps
|
|
1215
|
+
terminal = ctx.state.value if ctx.state in AGENT_TERMINAL_STATES else "UNKNOWN"
|
|
1216
|
+
outcome_hint = (
|
|
1217
|
+
"The task completed successfully."
|
|
1218
|
+
if ctx.state == AgentState.DONE
|
|
1219
|
+
else (
|
|
1220
|
+
f"The task ended as {terminal} — extract what went wrong and "
|
|
1221
|
+
"what to do differently next time, not a success story."
|
|
1222
|
+
)
|
|
1223
|
+
)
|
|
894
1224
|
context = (
|
|
895
1225
|
f"{d.memory_updater_prompt}\n\n"
|
|
896
|
-
f"
|
|
1226
|
+
f"Task: {req.message}\n"
|
|
1227
|
+
f"Terminal status: {terminal}. {outcome_hint}\n\n"
|
|
897
1228
|
f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
|
|
898
1229
|
)
|
|
899
1230
|
try:
|
|
900
1231
|
raw = await d.generate(
|
|
901
1232
|
message="Extract learnings from this completed task.",
|
|
902
|
-
context=context, max_tokens=
|
|
1233
|
+
context=context, max_tokens=self.phase_budgets.memory_tokens, temperature=0.1,
|
|
903
1234
|
)
|
|
904
1235
|
mem = extract_action(str(raw))
|
|
905
1236
|
kept_learnings = filter_learnings(mem.get("learnings") or [])
|
|
906
1237
|
if mem.get("save_to_knowledge") and kept_learnings:
|
|
907
1238
|
learnings = "\n".join(kept_learnings)
|
|
1239
|
+
status_label = {
|
|
1240
|
+
AgentState.DONE: "ok",
|
|
1241
|
+
AgentState.NEEDS_REVIEW: "needs_review",
|
|
1242
|
+
AgentState.FAILED: "failed",
|
|
1243
|
+
}.get(ctx.state, "unknown")
|
|
908
1244
|
if d.brain_memory is not None:
|
|
909
1245
|
# This runtime is LLM-driven — its learnings are real
|
|
910
1246
|
# experiences and enter the brain with provenance.
|
|
@@ -913,7 +1249,7 @@ class SingleAgentRuntime:
|
|
|
913
1249
|
learnings,
|
|
914
1250
|
run={
|
|
915
1251
|
"mode": "llm",
|
|
916
|
-
"status":
|
|
1252
|
+
"status": status_label,
|
|
917
1253
|
"agent_id": "agent:executor",
|
|
918
1254
|
"steps": len(ctx.transcript),
|
|
919
1255
|
},
|
|
@@ -954,3 +1290,4 @@ class SingleAgentRuntime:
|
|
|
954
1290
|
ctx.state = AgentState.FAILED
|
|
955
1291
|
|
|
956
1292
|
ctx.state_history.append(ctx.state.value)
|
|
1293
|
+
self._emit_step(ctx, "terminal", "state", state=ctx.state.value)
|