ltcai 9.9.3 → 9.9.5
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 +39 -38
- package/docs/CHANGELOG.md +100 -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 +53 -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/discovery_index.py +8 -1
- package/lattice_brain/graph/ingest.py +43 -9
- package/lattice_brain/graph/projection.py +221 -37
- package/lattice_brain/graph/rerank.py +160 -0
- package/lattice_brain/graph/retrieval.py +90 -455
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_reads.py +459 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- package/lattice_brain/graph/store.py +2 -0
- package/lattice_brain/portability.py +41 -12
- package/lattice_brain/quality.py +26 -6
- package/lattice_brain/runtime/multi_agent.py +1 -1
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +262 -52
- package/latticeai/api/chat_contracts.py +1 -1
- package/latticeai/api/chat_intents.py +37 -18
- package/latticeai/api/chat_stream.py +76 -2
- package/latticeai/api/knowledge_graph.py +35 -1
- package/latticeai/core/agent.py +420 -27
- package/latticeai/core/enterprise.py +5 -0
- package/latticeai/core/file_generation.py +130 -5
- package/latticeai/core/legacy_compatibility.py +1 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/run_store.py +245 -0
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/integrations/telegram_bot.py +82 -23
- package/latticeai/models/router.py +25 -12
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/command_center.py +90 -1
- package/latticeai/services/folder_watch.py +5 -0
- package/latticeai/services/funnel_metrics.py +9 -1
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/search_service.py +43 -13
- package/latticeai/services/tool_dispatch.py +56 -0
- package/package.json +2 -1
- package/scripts/bench_agent_smoke.py +410 -0
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/funnel_soft_gate.py +192 -0
- package/scripts/run_sidecar_e2e.mjs +159 -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 -28
- package/static/app/assets/Act-DmxfbqBp.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.js → Brain-DK_zKCda.js} +2 -2
- package/static/app/assets/BrainHome-CCE_0hni.js +2 -0
- package/static/app/assets/BrainSignals-r_J68_zh.js +1 -0
- package/static/app/assets/{Capture-CVZ09QXi.js → Capture-CvxwzpJZ.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-CVLiFIQC.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-4As9VUvy.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-BVWk58MW.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-B-Tz0Um4.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-Va3fPQuj.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-DSR2zKWd.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-VmP0kkeA.js} +1 -1
- package/static/app/assets/circle-pause-T9POo4qy.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-Bz5iTD0p.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CJhjRtNq.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-B2K_22VI.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CjJtYqHf.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-CEu0Wqjl.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-CSi6OBJ9.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-BPQqEQwr.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-Ck1nqc-v.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-B2f9N7Dh.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-yA_Y8lOB.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-C4wM0D-Z.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-CsnIhsDJ.js} +1 -1
- package/static/app/assets/utils-BqoznzGP.js +7 -0
- package/static/app/index.html +4 -4
- package/static/sw.js +1 -1
- package/static/app/assets/Act-DmdruVKV.js +0 -1
- package/static/app/assets/BrainHome-CeNaxjP1.js +0 -2
- package/static/app/assets/BrainSignals-CStjIqYi.js +0 -1
- package/static/app/assets/utils-uQYKXNeq.js +0 -7
package/latticeai/core/agent.py
CHANGED
|
@@ -32,7 +32,11 @@ from typing import Any, Awaitable, Callable, Dict, FrozenSet, List, Mapping, Opt
|
|
|
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,168 @@ 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
|
+
def files_written(
|
|
344
|
+
transcript: List[Dict[str, Any]],
|
|
345
|
+
file_create_actions: FrozenSet[str],
|
|
346
|
+
) -> List[str]:
|
|
347
|
+
"""Ordered unique paths of files this run successfully wrote (review L5).
|
|
348
|
+
|
|
349
|
+
Later executor steps get this as explicit context, so "만들고 이어서
|
|
350
|
+
설명해" multi-step work sees its own output instead of a stale
|
|
351
|
+
workspace picture.
|
|
352
|
+
"""
|
|
353
|
+
seen: List[str] = []
|
|
354
|
+
for step in transcript:
|
|
355
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
356
|
+
continue
|
|
357
|
+
if step.get("action") not in file_create_actions:
|
|
358
|
+
continue
|
|
359
|
+
if not isinstance(step.get("result"), dict):
|
|
360
|
+
continue
|
|
361
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
362
|
+
if path and str(path) not in seen:
|
|
363
|
+
seen.append(str(path))
|
|
364
|
+
return seen
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def artifact_checklist(
|
|
368
|
+
transcript: List[Dict[str, Any]],
|
|
369
|
+
file_create_actions: FrozenSet[str],
|
|
370
|
+
) -> List[Dict[str, Any]]:
|
|
371
|
+
"""Deterministic artifact facts for the critic (review L4).
|
|
372
|
+
|
|
373
|
+
The critic previously judged file work from prose alone; this surfaces
|
|
374
|
+
the sanitize/repair honesty flags per written file so a repaired
|
|
375
|
+
placeholder can never pass as a fulfilled request unchecked.
|
|
376
|
+
"""
|
|
377
|
+
checklist: List[Dict[str, Any]] = []
|
|
378
|
+
for step in transcript:
|
|
379
|
+
if step.get("state") != AgentState.EXECUTING.value:
|
|
380
|
+
continue
|
|
381
|
+
if step.get("action") not in file_create_actions:
|
|
382
|
+
continue
|
|
383
|
+
if not isinstance(step.get("result"), dict):
|
|
384
|
+
continue
|
|
385
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path")
|
|
386
|
+
if not path:
|
|
387
|
+
continue
|
|
388
|
+
sanitize_meta = step.get("content_sanitize") or {}
|
|
389
|
+
checklist.append({
|
|
390
|
+
"path": str(path),
|
|
391
|
+
"sanitized": bool(sanitize_meta.get("sanitized")),
|
|
392
|
+
"repaired": bool(sanitize_meta.get("repaired")),
|
|
393
|
+
})
|
|
394
|
+
return checklist
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _format_artifact_checklist(checklist: List[Dict[str, Any]]) -> str:
|
|
398
|
+
lines = []
|
|
399
|
+
for item in checklist:
|
|
400
|
+
state = (
|
|
401
|
+
"auto-REPAIRED scaffold" if item["repaired"]
|
|
402
|
+
else ("sanitized model output" if item["sanitized"] else "written as produced")
|
|
403
|
+
)
|
|
404
|
+
lines.append(f"- {item['path']}: {state}")
|
|
405
|
+
return (
|
|
406
|
+
"Artifact checklist (deterministic, from the transcript):\n"
|
|
407
|
+
+ "\n".join(lines)
|
|
408
|
+
+ "\nVerify each artifact actually fulfills the user's request. An "
|
|
409
|
+
"auto-repaired scaffold is NOT completion unless its content "
|
|
410
|
+
"satisfies what was asked."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
@dataclass(frozen=True)
|
|
415
|
+
class TranscriptBudget:
|
|
416
|
+
"""Executor/critic prompt shaping caps (review Wave 0.3).
|
|
417
|
+
|
|
418
|
+
``window`` full recent steps for the executor; per-string caps keep tool
|
|
419
|
+
output bodies from dominating either prompt. Overridable through the same
|
|
420
|
+
``Config.from_env`` pattern as :class:`PhaseBudgets`.
|
|
421
|
+
"""
|
|
422
|
+
|
|
423
|
+
window: int = 8
|
|
424
|
+
result_chars: int = 700
|
|
425
|
+
verify_chars: int = 1200
|
|
426
|
+
|
|
427
|
+
@classmethod
|
|
428
|
+
def from_env(cls, env: Optional[Mapping[str, str]] = None) -> "TranscriptBudget":
|
|
429
|
+
from latticeai.core.config import _int
|
|
430
|
+
|
|
431
|
+
if env is None:
|
|
432
|
+
import os
|
|
433
|
+
|
|
434
|
+
env = os.environ
|
|
435
|
+
|
|
436
|
+
def cap(key: str, default: int, floor: int) -> int:
|
|
437
|
+
return max(floor, _int(env, key, default))
|
|
438
|
+
|
|
439
|
+
return cls(
|
|
440
|
+
window=cap("LATTICEAI_AGENT_TRANSCRIPT_WINDOW", cls.window, 2),
|
|
441
|
+
result_chars=cap("LATTICEAI_AGENT_TRANSCRIPT_CHARS", cls.result_chars, 120),
|
|
442
|
+
verify_chars=cap("LATTICEAI_AGENT_VERIFY_CHARS", cls.verify_chars, 200),
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
|
|
230
446
|
@dataclass(frozen=True)
|
|
231
447
|
class PhaseBudgets:
|
|
232
448
|
"""Per-phase token budgets for the agent loop.
|
|
@@ -305,6 +521,16 @@ class AgentDeps:
|
|
|
305
521
|
# state machine does not shell out directly. Tests can pass a recorder.
|
|
306
522
|
rollback_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
307
523
|
|
|
524
|
+
# ── snapshot rollback ports (optional, review L7) ────────────────
|
|
525
|
+
# git-only rollback left non-git workspaces and newly created files
|
|
526
|
+
# unrecoverable. ``snapshot_file(path)`` captures pre-write state
|
|
527
|
+
# ({"existed", "content", "too_large"}) before a file-create action;
|
|
528
|
+
# ``restore_snapshot(path, content)`` restores it (content=None deletes
|
|
529
|
+
# a file the run created). Both are production-wired with workspace
|
|
530
|
+
# path safety; tests pass recorders.
|
|
531
|
+
snapshot_file: Optional[Callable[[str], Dict[str, Any]]] = None
|
|
532
|
+
restore_snapshot: Optional[Callable[[str, Optional[str]], Dict[str, Any]]] = None
|
|
533
|
+
|
|
308
534
|
# ── lifecycle hooks port (optional) ──────────────────────────────
|
|
309
535
|
# When present, every tool execution fires the shared pre_tool/post_tool
|
|
310
536
|
# lifecycle, so the agent tool path no longer bypasses hooks.
|
|
@@ -329,6 +555,16 @@ class AgentDeps:
|
|
|
329
555
|
# environment once at first use; tests inject a fixed PhaseBudgets.
|
|
330
556
|
phase_budgets: Optional[PhaseBudgets] = None
|
|
331
557
|
|
|
558
|
+
# ── transcript shaping (optional) ────────────────────────────────
|
|
559
|
+
# Executor/critic prompt window caps. None reads the environment once;
|
|
560
|
+
# tests inject a fixed TranscriptBudget.
|
|
561
|
+
transcript_budget: Optional["TranscriptBudget"] = None
|
|
562
|
+
|
|
563
|
+
# ── step observer port (optional) ────────────────────────────────
|
|
564
|
+
# Default per-runtime observer for live step events; a per-run observer
|
|
565
|
+
# can also be attached on AgentRunContext.on_step. Both are advisory.
|
|
566
|
+
on_step: Optional[Callable[[Dict[str, Any]], None]] = None
|
|
567
|
+
|
|
332
568
|
|
|
333
569
|
class SingleAgentRuntime:
|
|
334
570
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -336,6 +572,7 @@ class SingleAgentRuntime:
|
|
|
336
572
|
def __init__(self, deps: AgentDeps) -> None:
|
|
337
573
|
self.deps = deps
|
|
338
574
|
self._env_phase_budgets: Optional[PhaseBudgets] = None
|
|
575
|
+
self._env_transcript_budget: Optional[TranscriptBudget] = None
|
|
339
576
|
|
|
340
577
|
@property
|
|
341
578
|
def phase_budgets(self) -> PhaseBudgets:
|
|
@@ -348,6 +585,34 @@ class SingleAgentRuntime:
|
|
|
348
585
|
self._env_phase_budgets = PhaseBudgets.from_env()
|
|
349
586
|
return self._env_phase_budgets
|
|
350
587
|
|
|
588
|
+
@property
|
|
589
|
+
def transcript_budget(self) -> TranscriptBudget:
|
|
590
|
+
injected = getattr(self.deps, "transcript_budget", None)
|
|
591
|
+
if injected is not None:
|
|
592
|
+
return injected
|
|
593
|
+
if getattr(self, "_env_transcript_budget", None) is None:
|
|
594
|
+
self._env_transcript_budget = TranscriptBudget.from_env()
|
|
595
|
+
return self._env_transcript_budget
|
|
596
|
+
|
|
597
|
+
def _emit_step(self, ctx: AgentRunContext, phase: str, event: str, **details: Any) -> None:
|
|
598
|
+
"""Fire the per-run / deps step observers (review Wave 1.1).
|
|
599
|
+
|
|
600
|
+
Observers power the live step timeline in the UI. They are pure
|
|
601
|
+
telemetry: any observer failure is logged and swallowed — the loop
|
|
602
|
+
itself must never notice.
|
|
603
|
+
"""
|
|
604
|
+
payload: Dict[str, Any] = {"phase": phase, "event": event}
|
|
605
|
+
for key, value in details.items():
|
|
606
|
+
if value is not None:
|
|
607
|
+
payload[key] = value
|
|
608
|
+
for observer in (getattr(ctx, "on_step", None), getattr(self.deps, "on_step", None)):
|
|
609
|
+
if observer is None:
|
|
610
|
+
continue
|
|
611
|
+
try:
|
|
612
|
+
observer(dict(payload))
|
|
613
|
+
except Exception as exc: # noqa: BLE001 — observers are advisory
|
|
614
|
+
logging.warning("agent step observer failed: %s", exc)
|
|
615
|
+
|
|
351
616
|
def boundary(self) -> Dict[str, Any]:
|
|
352
617
|
return runtime_boundary_contract(
|
|
353
618
|
name="SingleAgentRuntime",
|
|
@@ -412,6 +677,12 @@ class SingleAgentRuntime:
|
|
|
412
677
|
"estimated_steps": plan.get("estimated_steps", 1),
|
|
413
678
|
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
414
679
|
})
|
|
680
|
+
self._emit_step(
|
|
681
|
+
ctx, "plan", "planned",
|
|
682
|
+
goal=str(plan.get("goal") or "")[:200],
|
|
683
|
+
steps=len(plan.get("steps") or []),
|
|
684
|
+
requires_approval=bool(plan.get("requires_approval", False)),
|
|
685
|
+
)
|
|
415
686
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
416
687
|
|
|
417
688
|
# ── APPROVAL ─────────────────────────────────────────────────────
|
|
@@ -467,6 +738,7 @@ class SingleAgentRuntime:
|
|
|
467
738
|
})
|
|
468
739
|
decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
|
|
469
740
|
ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
|
|
741
|
+
self._emit_step(ctx, "approval", "decision", decision=decision)
|
|
470
742
|
d.audit(
|
|
471
743
|
"agent_approval", user_email=current_user,
|
|
472
744
|
requires_approval=requires,
|
|
@@ -530,6 +802,7 @@ class SingleAgentRuntime:
|
|
|
530
802
|
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
531
803
|
})
|
|
532
804
|
ctx.trace.decision("execute", decision="final")
|
|
805
|
+
self._emit_step(ctx, "execute", "final")
|
|
533
806
|
ctx.state = AgentState.VERIFYING
|
|
534
807
|
return
|
|
535
808
|
|
|
@@ -540,6 +813,7 @@ class SingleAgentRuntime:
|
|
|
540
813
|
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
541
814
|
})
|
|
542
815
|
ctx.trace.decision("execute", decision="loop_detected", tool=name)
|
|
816
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
|
|
543
817
|
break
|
|
544
818
|
|
|
545
819
|
if name == "clear_history":
|
|
@@ -548,6 +822,7 @@ class SingleAgentRuntime:
|
|
|
548
822
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
549
823
|
"thoughts": thoughts, "args": args, "result": result,
|
|
550
824
|
})
|
|
825
|
+
self._emit_step(ctx, "execute", "tool", action=name, ok=True)
|
|
551
826
|
continue
|
|
552
827
|
|
|
553
828
|
policy = d.policy_for(name, args)
|
|
@@ -576,10 +851,13 @@ class SingleAgentRuntime:
|
|
|
576
851
|
) -> str:
|
|
577
852
|
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
578
853
|
d = self.deps
|
|
854
|
+
# Only the latest corrections steer the next attempt — stale hints
|
|
855
|
+
# from earlier retries dilute weak models (review Wave 0.3).
|
|
856
|
+
active_corrections = ctx.corrections[-3:]
|
|
579
857
|
corrections_hint = (
|
|
580
858
|
"\n\nCritic corrections from previous attempt:\n"
|
|
581
|
-
+ "\n".join(f"- {c}" for c in
|
|
582
|
-
) if
|
|
859
|
+
+ "\n".join(f"- {c}" for c in active_corrections)
|
|
860
|
+
) if active_corrections else ""
|
|
583
861
|
|
|
584
862
|
recent_kwargs = {
|
|
585
863
|
"conversation_id": req.conversation_id,
|
|
@@ -588,14 +866,27 @@ class SingleAgentRuntime:
|
|
|
588
866
|
if request_workspace is not None:
|
|
589
867
|
recent_kwargs["workspace_id"] = request_workspace
|
|
590
868
|
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
869
|
+
budget = self.transcript_budget
|
|
870
|
+
bounded_transcript = compact_transcript(
|
|
871
|
+
ctx.transcript,
|
|
872
|
+
window=budget.window,
|
|
873
|
+
result_chars=budget.result_chars,
|
|
874
|
+
)
|
|
875
|
+
# Mid-run workspace awareness (review L5): later steps must see what
|
|
876
|
+
# this run already produced instead of a stale workspace picture.
|
|
877
|
+
written = files_written(ctx.transcript, d.file_create_actions)
|
|
878
|
+
written_hint = (
|
|
879
|
+
"\n\nFiles written by this run so far (they exist in the workspace now):\n"
|
|
880
|
+
+ "\n".join(f"- {path}" for path in written)
|
|
881
|
+
) if written else ""
|
|
591
882
|
return (
|
|
592
883
|
f"{d.executor_prompt}\n\n"
|
|
593
884
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
594
885
|
f"Workspace root: {d.agent_root}\n\n"
|
|
595
|
-
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
886
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}{written_hint}\n\n"
|
|
596
887
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
597
888
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
598
|
-
f"Execution transcript:\n{json.dumps(
|
|
889
|
+
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
599
890
|
)
|
|
600
891
|
|
|
601
892
|
def _note_parse_failure(
|
|
@@ -608,8 +899,10 @@ class SingleAgentRuntime:
|
|
|
608
899
|
})
|
|
609
900
|
if parse_failures >= 3:
|
|
610
901
|
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
902
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
611
903
|
return True
|
|
612
904
|
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
905
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=True)
|
|
613
906
|
# Weak models often need one concrete reminder of the wire
|
|
614
907
|
# format; feed it through the corrections channel and retry
|
|
615
908
|
# instead of aborting the whole run on the first slip.
|
|
@@ -665,6 +958,7 @@ class SingleAgentRuntime:
|
|
|
665
958
|
if verdict is not None and verdict.get("decision") == "proposed":
|
|
666
959
|
proposal = verdict.get("proposal") or {}
|
|
667
960
|
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
961
|
+
self._emit_step(ctx, "execute", "proposed", action=name)
|
|
668
962
|
ctx.transcript.append({
|
|
669
963
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
670
964
|
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
@@ -691,6 +985,7 @@ class SingleAgentRuntime:
|
|
|
691
985
|
d = self.deps
|
|
692
986
|
if policy["risk"] == "destructive":
|
|
693
987
|
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
988
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
694
989
|
ctx.transcript.append({
|
|
695
990
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
696
991
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -713,6 +1008,7 @@ class SingleAgentRuntime:
|
|
|
713
1008
|
args={k: v for k, v in args.items() if k != "content"},
|
|
714
1009
|
)
|
|
715
1010
|
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
1011
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
|
|
716
1012
|
ctx.transcript.append({
|
|
717
1013
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
718
1014
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -748,6 +1044,27 @@ class SingleAgentRuntime:
|
|
|
748
1044
|
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
749
1045
|
],
|
|
750
1046
|
)
|
|
1047
|
+
step_index = 1 + sum(
|
|
1048
|
+
1 for s in ctx.transcript
|
|
1049
|
+
if s.get("state") == AgentState.EXECUTING.value
|
|
1050
|
+
and s.get("action") not in (None, "final", "parse_error")
|
|
1051
|
+
)
|
|
1052
|
+
if (
|
|
1053
|
+
name in d.file_create_actions
|
|
1054
|
+
and d.snapshot_file is not None
|
|
1055
|
+
and args.get("path")
|
|
1056
|
+
):
|
|
1057
|
+
# Pre-write snapshot (review L7): the first capture per path is
|
|
1058
|
+
# the true pre-run state — later writes to the same path must
|
|
1059
|
+
# not overwrite it. Best-effort: a snapshot failure never
|
|
1060
|
+
# blocks the write, it only narrows rollback options.
|
|
1061
|
+
path_str = str(args["path"])
|
|
1062
|
+
if not any(entry.get("path") == path_str for entry in ctx.rollback_log):
|
|
1063
|
+
try:
|
|
1064
|
+
pre = d.snapshot_file(path_str)
|
|
1065
|
+
ctx.rollback_log.append({"path": path_str, **(pre or {})})
|
|
1066
|
+
except Exception as exc: # noqa: BLE001
|
|
1067
|
+
logging.warning("pre-write snapshot failed for %s: %s", path_str, exc)
|
|
751
1068
|
try:
|
|
752
1069
|
d.check_role(name, current_user)
|
|
753
1070
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -763,6 +1080,10 @@ class SingleAgentRuntime:
|
|
|
763
1080
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
764
1081
|
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
765
1082
|
})
|
|
1083
|
+
self._emit_step(
|
|
1084
|
+
ctx, "execute", "tool", action=name, ok=True, step=step_index,
|
|
1085
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
1086
|
+
)
|
|
766
1087
|
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
767
1088
|
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
768
1089
|
ctx.transcript.append({
|
|
@@ -770,6 +1091,10 @@ class SingleAgentRuntime:
|
|
|
770
1091
|
"thoughts": thoughts, "args": args,
|
|
771
1092
|
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
772
1093
|
})
|
|
1094
|
+
self._emit_step(
|
|
1095
|
+
ctx, "execute", "tool", action=name, ok=False, step=step_index,
|
|
1096
|
+
path=str(args.get("path")) if args.get("path") else None,
|
|
1097
|
+
)
|
|
773
1098
|
|
|
774
1099
|
# ── VERIFY ───────────────────────────────────────────────────────
|
|
775
1100
|
def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
|
|
@@ -798,12 +1123,24 @@ class SingleAgentRuntime:
|
|
|
798
1123
|
NEEDS_REVIEW so the user is told to check the result themselves.
|
|
799
1124
|
"""
|
|
800
1125
|
d = self.deps
|
|
1126
|
+
# The critic must see every step (evidence completeness), but not
|
|
1127
|
+
# every byte of tool output — long bodies are capped per string so
|
|
1128
|
+
# verification stays affordable on long runs (review Wave 0.3).
|
|
1129
|
+
verify_transcript = _truncate_strings(
|
|
1130
|
+
ctx.transcript, self.transcript_budget.verify_chars
|
|
1131
|
+
)
|
|
1132
|
+
# Deterministic artifact facts (review L4): the critic sees the
|
|
1133
|
+
# sanitize/repair honesty flags per written file, not just prose.
|
|
1134
|
+
checklist = artifact_checklist(ctx.transcript, d.file_create_actions)
|
|
1135
|
+
checklist_hint = (
|
|
1136
|
+
f"\n\n{_format_artifact_checklist(checklist)}" if checklist else ""
|
|
1137
|
+
)
|
|
801
1138
|
context = (
|
|
802
1139
|
f"{d.critic_prompt}\n\n"
|
|
803
1140
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
804
1141
|
f"Original request: {req.message}\n"
|
|
805
|
-
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
806
|
-
f"Full transcript:\n{json.dumps(
|
|
1142
|
+
f"Plan goal: {ctx.plan.get('goal', req.message)}{checklist_hint}\n\n"
|
|
1143
|
+
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
807
1144
|
)
|
|
808
1145
|
raw = await d.generate_as(
|
|
809
1146
|
model_id,
|
|
@@ -857,6 +1194,7 @@ class SingleAgentRuntime:
|
|
|
857
1194
|
"verify", decision="verification_unavailable",
|
|
858
1195
|
verifier_available=False, verdict_valid=False, evidence=has_evidence,
|
|
859
1196
|
)
|
|
1197
|
+
self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
|
|
860
1198
|
ctx.final_message = (
|
|
861
1199
|
"검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
|
|
862
1200
|
"실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
|
|
@@ -885,6 +1223,10 @@ class SingleAgentRuntime:
|
|
|
885
1223
|
"verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
|
|
886
1224
|
verifier_available=True, verdict_valid=True, evidence=has_evidence,
|
|
887
1225
|
)
|
|
1226
|
+
self._emit_step(
|
|
1227
|
+
ctx, "verify", "verdict",
|
|
1228
|
+
verdict=str(verdict.get("verdict", "")), next_state=next_s,
|
|
1229
|
+
)
|
|
888
1230
|
if verdict.get("verdict") == "PASS":
|
|
889
1231
|
# DONE requires both: a validly parsed PASS verdict AND
|
|
890
1232
|
# deterministic execution evidence in the transcript. A PASS over
|
|
@@ -930,52 +1272,97 @@ class SingleAgentRuntime:
|
|
|
930
1272
|
ctx.state = AgentState.FAILED
|
|
931
1273
|
|
|
932
1274
|
# ── ROLLBACK ─────────────────────────────────────────────────────
|
|
1275
|
+
def _snapshot_for(self, ctx: AgentRunContext, path: str) -> Optional[Dict[str, Any]]:
|
|
1276
|
+
for entry in ctx.rollback_log:
|
|
1277
|
+
if entry.get("path") == path:
|
|
1278
|
+
return entry
|
|
1279
|
+
return None
|
|
1280
|
+
|
|
1281
|
+
def _rollback_one(self, ctx: AgentRunContext, path: str, gov: Dict[str, Any]) -> Dict[str, Any]:
|
|
1282
|
+
"""Recover one path: git when governed and available, else the
|
|
1283
|
+
pre-write snapshot, else an honest ``mode="none"`` (review L7)."""
|
|
1284
|
+
d = self.deps
|
|
1285
|
+
if gov.get("rollback") == "git" and d.rollback_file is not None:
|
|
1286
|
+
try:
|
|
1287
|
+
result = dict(d.rollback_file(str(path)))
|
|
1288
|
+
except Exception as exc: # noqa: BLE001
|
|
1289
|
+
result = {"path": path, "ok": False, "error": str(exc)}
|
|
1290
|
+
if result.get("ok"):
|
|
1291
|
+
result["mode"] = "git"
|
|
1292
|
+
return result
|
|
1293
|
+
snapshot = self._snapshot_for(ctx, str(path))
|
|
1294
|
+
if snapshot is not None and d.restore_snapshot is not None and not snapshot.get("too_large"):
|
|
1295
|
+
content = snapshot.get("content") if snapshot.get("existed") else None
|
|
1296
|
+
try:
|
|
1297
|
+
restored = dict(d.restore_snapshot(str(path), content))
|
|
1298
|
+
except Exception as exc: # noqa: BLE001
|
|
1299
|
+
restored = {"path": path, "ok": False, "error": str(exc)}
|
|
1300
|
+
restored.setdefault("path", path)
|
|
1301
|
+
restored["mode"] = "snapshot"
|
|
1302
|
+
return restored
|
|
1303
|
+
return {
|
|
1304
|
+
"path": path, "ok": False, "mode": "none",
|
|
1305
|
+
"error": "no rollback available (git not applicable, no usable snapshot)",
|
|
1306
|
+
}
|
|
1307
|
+
|
|
933
1308
|
def rollback(self, ctx: AgentRunContext, current_user: str) -> None:
|
|
934
|
-
"""ROLLBACK:
|
|
1309
|
+
"""ROLLBACK: recover written files (git → snapshot → none), then FAILED."""
|
|
935
1310
|
d = self.deps
|
|
936
1311
|
rolled: List[dict] = []
|
|
1312
|
+
seen_paths: set = set()
|
|
937
1313
|
for step in ctx.transcript:
|
|
938
1314
|
if step.get("state") != AgentState.EXECUTING.value:
|
|
939
1315
|
continue
|
|
940
|
-
|
|
941
|
-
if gov.get("rollback") != "git":
|
|
1316
|
+
if not isinstance(step.get("result"), dict):
|
|
942
1317
|
continue
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
path = result.get("path") or (step.get("args") or {}).get("path", "")
|
|
947
|
-
if not path:
|
|
1318
|
+
gov = step.get("governance", {}) or {}
|
|
1319
|
+
path = step["result"].get("path") or (step.get("args") or {}).get("path", "")
|
|
1320
|
+
if not path or str(path) in seen_paths:
|
|
948
1321
|
continue
|
|
949
|
-
if
|
|
950
|
-
rolled.append({"path": path, "ok": False, "error": "rollback_file port is not configured"})
|
|
1322
|
+
if gov.get("rollback") != "git" and step.get("action") not in d.file_create_actions:
|
|
951
1323
|
continue
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
except Exception as exc:
|
|
955
|
-
rolled.append({"path": path, "ok": False, "error": str(exc)})
|
|
1324
|
+
seen_paths.add(str(path))
|
|
1325
|
+
rolled.append(self._rollback_one(ctx, str(path), gov))
|
|
956
1326
|
|
|
957
1327
|
ctx.transcript.append({"state": AgentState.ROLLBACK.value, "rolled_back": rolled})
|
|
958
1328
|
ctx.trace.decision(
|
|
959
1329
|
"rollback", decision="rolled_back",
|
|
960
1330
|
attempted=len(rolled), recovered=sum(1 for r in rolled if r.get("ok")),
|
|
961
1331
|
)
|
|
962
|
-
recovered = [r[
|
|
1332
|
+
recovered = [f"{r['path']} ({r.get('mode')})" for r in rolled if r.get("ok")]
|
|
963
1333
|
ctx.final_message = (
|
|
964
1334
|
f"실행 실패로 롤백했습니다. 복구 파일: {recovered}"
|
|
965
1335
|
if recovered
|
|
966
|
-
else "롤백을 시도했으나 복구할 파일이 없거나 git
|
|
1336
|
+
else "롤백을 시도했으나 복구할 파일이 없거나 git/스냅샷 복구 수단이 없습니다."
|
|
967
1337
|
)
|
|
968
1338
|
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
1339
|
+
self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
|
|
969
1340
|
# Rollback is a recovery from a failed verification — terminal state is FAILED
|
|
970
1341
|
ctx.state = AgentState.FAILED
|
|
971
1342
|
|
|
972
1343
|
# ── MEMORY ───────────────────────────────────────────────────────
|
|
973
1344
|
async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
|
|
974
|
-
"""Background: Memory Updater role extracts learnings
|
|
1345
|
+
"""Background: Memory Updater role extracts learnings from a terminal run.
|
|
1346
|
+
|
|
1347
|
+
Terminal-state learning policy (review §4.2 L6): DONE runs record what
|
|
1348
|
+
worked; FAILED / NEEDS_REVIEW runs record what went wrong — failure is
|
|
1349
|
+
exactly the experience worth remembering. The run status stored with
|
|
1350
|
+
the experience is the *actual* terminal state, never a blanket "ok".
|
|
1351
|
+
"""
|
|
975
1352
|
d = self.deps
|
|
1353
|
+
terminal = ctx.state.value if ctx.state in AGENT_TERMINAL_STATES else "UNKNOWN"
|
|
1354
|
+
outcome_hint = (
|
|
1355
|
+
"The task completed successfully."
|
|
1356
|
+
if ctx.state == AgentState.DONE
|
|
1357
|
+
else (
|
|
1358
|
+
f"The task ended as {terminal} — extract what went wrong and "
|
|
1359
|
+
"what to do differently next time, not a success story."
|
|
1360
|
+
)
|
|
1361
|
+
)
|
|
976
1362
|
context = (
|
|
977
1363
|
f"{d.memory_updater_prompt}\n\n"
|
|
978
|
-
f"
|
|
1364
|
+
f"Task: {req.message}\n"
|
|
1365
|
+
f"Terminal status: {terminal}. {outcome_hint}\n\n"
|
|
979
1366
|
f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
|
|
980
1367
|
)
|
|
981
1368
|
try:
|
|
@@ -987,6 +1374,11 @@ class SingleAgentRuntime:
|
|
|
987
1374
|
kept_learnings = filter_learnings(mem.get("learnings") or [])
|
|
988
1375
|
if mem.get("save_to_knowledge") and kept_learnings:
|
|
989
1376
|
learnings = "\n".join(kept_learnings)
|
|
1377
|
+
status_label = {
|
|
1378
|
+
AgentState.DONE: "ok",
|
|
1379
|
+
AgentState.NEEDS_REVIEW: "needs_review",
|
|
1380
|
+
AgentState.FAILED: "failed",
|
|
1381
|
+
}.get(ctx.state, "unknown")
|
|
990
1382
|
if d.brain_memory is not None:
|
|
991
1383
|
# This runtime is LLM-driven — its learnings are real
|
|
992
1384
|
# experiences and enter the brain with provenance.
|
|
@@ -995,7 +1387,7 @@ class SingleAgentRuntime:
|
|
|
995
1387
|
learnings,
|
|
996
1388
|
run={
|
|
997
1389
|
"mode": "llm",
|
|
998
|
-
"status":
|
|
1390
|
+
"status": status_label,
|
|
999
1391
|
"agent_id": "agent:executor",
|
|
1000
1392
|
"steps": len(ctx.transcript),
|
|
1001
1393
|
},
|
|
@@ -1036,3 +1428,4 @@ class SingleAgentRuntime:
|
|
|
1036
1428
|
ctx.state = AgentState.FAILED
|
|
1037
1429
|
|
|
1038
1430
|
ctx.state_history.append(ctx.state.value)
|
|
1431
|
+
self._emit_step(ctx, "terminal", "state", state=ctx.state.value)
|