ltcai 9.9.3 → 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 +43 -38
- package/docs/CHANGELOG.md +65 -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/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/retrieval.py +61 -12
- package/lattice_brain/graph/retrieval_policy.py +174 -0
- package/lattice_brain/graph/retrieval_vector.py +122 -2
- 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/chat.py +6 -0
- package/latticeai/api/chat_agent_http.py +175 -6
- 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 +264 -9
- 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 +243 -0
- package/latticeai/core/workspace_os.py +1 -1
- 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/package.json +1 -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/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-BCmTU0E2.js +1 -0
- package/static/app/assets/{Brain-DEY9jLVt.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-CVZ09QXi.js → Capture-B6vBhFa3.js} +1 -1
- package/static/app/assets/{CommandPalette-DepwOQFv.js → CommandPalette-90u9FWFH.js} +1 -1
- package/static/app/assets/{Library-Bp0n-HlW.js → Library-pKCK0_tk.js} +1 -1
- package/static/app/assets/{LivingBrain-DxP4efJF.js → LivingBrain-Jlf2wFqI.js} +1 -1
- package/static/app/assets/{ProductFlow-DRbm7NEq.js → ProductFlow-yg1fKP1P.js} +1 -1
- package/static/app/assets/{ReviewCard-C4HAO7A3.js → ReviewCard-DWvD7n9h.js} +1 -1
- package/static/app/assets/{System-ByQcmJW-.js → System-BAEuHqNY.js} +1 -1
- package/static/app/assets/{bot-BNDyZLR7.js → bot-nB_buEZD.js} +1 -1
- package/static/app/assets/circle-pause-DLNw6Ucp.js +1 -0
- package/static/app/assets/{circle-play-BkhdcHgd.js → circle-play-B-IsFL1y.js} +1 -1
- package/static/app/assets/{cpu-C6jjYm6i.js → cpu-CEPBHaBl.js} +1 -1
- package/static/app/assets/{folder-open-DjGIvDBQ.js → folder-open-hmN0N9cX.js} +1 -1
- package/static/app/assets/{hard-drive-BlSbwSaT.js → hard-drive-CBV_B_Yd.js} +1 -1
- package/static/app/assets/{index-Bge3DXW7.css → index-7FAfYm4v.css} +1 -1
- package/static/app/assets/{index-CHu7cgj3.js → index-DrmOCySv.js} +3 -3
- package/static/app/assets/{input-DVDI0YR3.js → input-gtVCg-ll.js} +1 -1
- package/static/app/assets/{navigation-BddhEWA0.js → navigation-Bot0hvuv.js} +1 -1
- package/static/app/assets/{network-pYQt5oBu.js → network-jE42eKfT.js} +1 -1
- package/static/app/assets/{primitives-D7gCdEvS.js → primitives-CX2Komon.js} +1 -1
- package/static/app/assets/{shield-alert-K9RKGQeg.js → shield-alert-BftATuAA.js} +1 -1
- package/static/app/assets/{textarea-sqQmoBKL.js → textarea-CiMJfOSI.js} +1 -1
- package/static/app/assets/{useFocusTrap-7EV9dFP2.js → useFocusTrap-B7RPGfFy.js} +1 -1
- 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-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
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import asyncio
|
|
5
6
|
import json
|
|
6
7
|
import logging
|
|
7
|
-
from typing import Any, AsyncIterator, Dict, Optional
|
|
8
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Optional
|
|
8
9
|
|
|
9
10
|
from fastapi.responses import JSONResponse, StreamingResponse
|
|
10
11
|
|
|
@@ -37,6 +38,74 @@ def agent_payload_stream(
|
|
|
37
38
|
return _stream()
|
|
38
39
|
|
|
39
40
|
|
|
41
|
+
def agent_live_stream(
|
|
42
|
+
start: Callable[[Callable[[Dict[str, Any]], None]], Awaitable[Dict[str, Any]]],
|
|
43
|
+
*,
|
|
44
|
+
router: Any,
|
|
45
|
+
model_id: Optional[str] = None,
|
|
46
|
+
finalize: Optional[Callable[[Dict[str, Any]], str]] = None,
|
|
47
|
+
) -> AsyncIterator[str]:
|
|
48
|
+
"""Live SSE for an agent run (review Wave 1.1).
|
|
49
|
+
|
|
50
|
+
``start(observer)`` runs the agent with a per-run step observer; every
|
|
51
|
+
observed step is emitted immediately as a named ``agent_step`` frame, so
|
|
52
|
+
the user watches the loop work instead of staring at silence. When the
|
|
53
|
+
run finishes, ``finalize(result)`` (history/funnel side effects) produces
|
|
54
|
+
the answer text and the classic final payload frames follow — clients
|
|
55
|
+
that ignore named events see exactly the historical stream shape.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
async def _stream() -> AsyncIterator[str]:
|
|
59
|
+
response_model = model_id or router.current_model_id
|
|
60
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
61
|
+
|
|
62
|
+
def observer(event: Dict[str, Any]) -> None:
|
|
63
|
+
# Called synchronously from the agent coroutine on this loop.
|
|
64
|
+
queue.put_nowait(event)
|
|
65
|
+
|
|
66
|
+
task = asyncio.create_task(start(observer))
|
|
67
|
+
try:
|
|
68
|
+
while True:
|
|
69
|
+
getter = asyncio.create_task(queue.get())
|
|
70
|
+
done, _ = await asyncio.wait(
|
|
71
|
+
{getter, task}, return_when=asyncio.FIRST_COMPLETED
|
|
72
|
+
)
|
|
73
|
+
if getter in done:
|
|
74
|
+
step = getter.result()
|
|
75
|
+
yield (
|
|
76
|
+
"event: agent_step\n"
|
|
77
|
+
f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
|
|
78
|
+
)
|
|
79
|
+
continue
|
|
80
|
+
getter.cancel()
|
|
81
|
+
break
|
|
82
|
+
while not queue.empty():
|
|
83
|
+
step = queue.get_nowait()
|
|
84
|
+
yield (
|
|
85
|
+
"event: agent_step\n"
|
|
86
|
+
f"data: {json.dumps(step, ensure_ascii=False)}\n\n"
|
|
87
|
+
)
|
|
88
|
+
result = task.result()
|
|
89
|
+
except Exception as exc: # noqa: BLE001 — already streaming a 200
|
|
90
|
+
logging.warning("agent live stream failed: %s", exc)
|
|
91
|
+
detail = getattr(exc, "detail", None) or str(exc)
|
|
92
|
+
yield f"data: {json.dumps({'error': str(detail), 'model': response_model}, ensure_ascii=False)}\n\n"
|
|
93
|
+
yield "data: [DONE]\n\n"
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
answer = str(result.get("response") or "작업을 완료했습니다.")
|
|
97
|
+
if finalize is not None:
|
|
98
|
+
try:
|
|
99
|
+
answer = finalize(result)
|
|
100
|
+
except Exception as exc: # noqa: BLE001 — side effects must not kill the stream
|
|
101
|
+
logging.warning("agent live stream finalize failed: %s", exc)
|
|
102
|
+
yield f"data: {json.dumps({'chunk': answer, 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
103
|
+
yield f"data: {json.dumps({'chunk': '', 'model': response_model, 'agent': result}, ensure_ascii=False)}\n\n"
|
|
104
|
+
yield "data: [DONE]\n\n"
|
|
105
|
+
|
|
106
|
+
return _stream()
|
|
107
|
+
|
|
108
|
+
|
|
40
109
|
async def stream_chat(
|
|
41
110
|
req: Any,
|
|
42
111
|
context: str,
|
|
@@ -136,4 +205,9 @@ async def stream_chat(
|
|
|
136
205
|
yield "data: [DONE]\n\n"
|
|
137
206
|
|
|
138
207
|
|
|
139
|
-
__all__ = [
|
|
208
|
+
__all__ = [
|
|
209
|
+
"agent_live_stream",
|
|
210
|
+
"agent_payload_stream",
|
|
211
|
+
"single_answer_response",
|
|
212
|
+
"stream_chat",
|
|
213
|
+
]
|
|
@@ -7,7 +7,7 @@ data endpoints back the v3 SPA (Files, Hybrid Search, graph explorer).
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from pathlib import Path
|
|
10
|
-
from typing import Any, Callable, Dict, Optional
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
11
|
|
|
12
12
|
from fastapi import APIRouter, HTTPException, Request
|
|
13
13
|
from pydantic import BaseModel
|
|
@@ -38,6 +38,11 @@ class CurateNoiseRequest(BaseModel):
|
|
|
38
38
|
max_removals: int = 200
|
|
39
39
|
|
|
40
40
|
|
|
41
|
+
class PromotionActionRequest(BaseModel):
|
|
42
|
+
# None applies/rejects every pending promotion; otherwise only these ids.
|
|
43
|
+
ids: Optional[List[str]] = None
|
|
44
|
+
|
|
45
|
+
|
|
41
46
|
def _workspace_scope_from_request(request: Request) -> Optional[str]:
|
|
42
47
|
header = request.headers.get("X-Workspace-Id")
|
|
43
48
|
if header and header.strip():
|
|
@@ -173,6 +178,35 @@ def create_knowledge_graph_router(
|
|
|
173
178
|
max_removals=req.max_removals,
|
|
174
179
|
)
|
|
175
180
|
|
|
181
|
+
@router.get("/knowledge-graph/promotions")
|
|
182
|
+
async def knowledge_graph_promotions(request: Request):
|
|
183
|
+
"""Pending curator promotions awaiting human review (review Wave 4).
|
|
184
|
+
|
|
185
|
+
Populated when ``curate()`` runs in review mode (explicit
|
|
186
|
+
``review_mode=True`` or the LATTICEAI_GRAPH_PROMOTION_REVIEW env
|
|
187
|
+
opt-in). Administrative like ``/knowledge-graph/curate``: the queue
|
|
188
|
+
governs the shared graph.
|
|
189
|
+
"""
|
|
190
|
+
(require_admin or require_user)(request)
|
|
191
|
+
pending = graph().pending_promotions()
|
|
192
|
+
return {"pending": pending, "total": len(pending)}
|
|
193
|
+
|
|
194
|
+
@router.post("/knowledge-graph/promotions/apply")
|
|
195
|
+
async def knowledge_graph_promotions_apply(
|
|
196
|
+
req: PromotionActionRequest, request: Request
|
|
197
|
+
):
|
|
198
|
+
"""Apply pending promotions (all when ``ids`` is omitted)."""
|
|
199
|
+
(require_admin or require_user)(request)
|
|
200
|
+
return graph().apply_pending_promotions(ids=req.ids)
|
|
201
|
+
|
|
202
|
+
@router.post("/knowledge-graph/promotions/reject")
|
|
203
|
+
async def knowledge_graph_promotions_reject(
|
|
204
|
+
req: PromotionActionRequest, request: Request
|
|
205
|
+
):
|
|
206
|
+
"""Reject pending promotions without writing (all when ``ids`` omitted)."""
|
|
207
|
+
(require_admin or require_user)(request)
|
|
208
|
+
return graph().reject_pending_promotions(ids=req.ids)
|
|
209
|
+
|
|
176
210
|
@router.get("/knowledge-graph/provenance/coverage")
|
|
177
211
|
async def knowledge_graph_provenance_coverage(request: Request):
|
|
178
212
|
require_user(request)
|
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,97 @@ 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
|
+
|
|
230
375
|
@dataclass(frozen=True)
|
|
231
376
|
class PhaseBudgets:
|
|
232
377
|
"""Per-phase token budgets for the agent loop.
|
|
@@ -329,6 +474,16 @@ class AgentDeps:
|
|
|
329
474
|
# environment once at first use; tests inject a fixed PhaseBudgets.
|
|
330
475
|
phase_budgets: Optional[PhaseBudgets] = None
|
|
331
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
|
+
|
|
332
487
|
|
|
333
488
|
class SingleAgentRuntime:
|
|
334
489
|
"""Drives the agent state machine over injected :class:`AgentDeps`."""
|
|
@@ -336,6 +491,7 @@ class SingleAgentRuntime:
|
|
|
336
491
|
def __init__(self, deps: AgentDeps) -> None:
|
|
337
492
|
self.deps = deps
|
|
338
493
|
self._env_phase_budgets: Optional[PhaseBudgets] = None
|
|
494
|
+
self._env_transcript_budget: Optional[TranscriptBudget] = None
|
|
339
495
|
|
|
340
496
|
@property
|
|
341
497
|
def phase_budgets(self) -> PhaseBudgets:
|
|
@@ -348,6 +504,34 @@ class SingleAgentRuntime:
|
|
|
348
504
|
self._env_phase_budgets = PhaseBudgets.from_env()
|
|
349
505
|
return self._env_phase_budgets
|
|
350
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)
|
|
534
|
+
|
|
351
535
|
def boundary(self) -> Dict[str, Any]:
|
|
352
536
|
return runtime_boundary_contract(
|
|
353
537
|
name="SingleAgentRuntime",
|
|
@@ -412,6 +596,12 @@ class SingleAgentRuntime:
|
|
|
412
596
|
"estimated_steps": plan.get("estimated_steps", 1),
|
|
413
597
|
**({"plan_fixes": plan_fixes} if plan_fixes else {}),
|
|
414
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
|
+
)
|
|
415
605
|
ctx.state = AgentState.WAITING_APPROVAL
|
|
416
606
|
|
|
417
607
|
# ── APPROVAL ─────────────────────────────────────────────────────
|
|
@@ -467,6 +657,7 @@ class SingleAgentRuntime:
|
|
|
467
657
|
})
|
|
468
658
|
decision = "human_approved" if requires and approved_by_human else ("blocked_pending_approval" if requires else "auto_approved")
|
|
469
659
|
ctx.trace.decision("approve", decision=decision, non_auto_steps=len(non_auto))
|
|
660
|
+
self._emit_step(ctx, "approval", "decision", decision=decision)
|
|
470
661
|
d.audit(
|
|
471
662
|
"agent_approval", user_email=current_user,
|
|
472
663
|
requires_approval=requires,
|
|
@@ -530,6 +721,7 @@ class SingleAgentRuntime:
|
|
|
530
721
|
"state": AgentState.EXECUTING.value, "action": "final", "thoughts": thoughts,
|
|
531
722
|
})
|
|
532
723
|
ctx.trace.decision("execute", decision="final")
|
|
724
|
+
self._emit_step(ctx, "execute", "final")
|
|
533
725
|
ctx.state = AgentState.VERIFYING
|
|
534
726
|
return
|
|
535
727
|
|
|
@@ -540,6 +732,7 @@ class SingleAgentRuntime:
|
|
|
540
732
|
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
541
733
|
})
|
|
542
734
|
ctx.trace.decision("execute", decision="loop_detected", tool=name)
|
|
735
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="loop_detected")
|
|
543
736
|
break
|
|
544
737
|
|
|
545
738
|
if name == "clear_history":
|
|
@@ -548,6 +741,7 @@ class SingleAgentRuntime:
|
|
|
548
741
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
549
742
|
"thoughts": thoughts, "args": args, "result": result,
|
|
550
743
|
})
|
|
744
|
+
self._emit_step(ctx, "execute", "tool", action=name, ok=True)
|
|
551
745
|
continue
|
|
552
746
|
|
|
553
747
|
policy = d.policy_for(name, args)
|
|
@@ -576,10 +770,13 @@ class SingleAgentRuntime:
|
|
|
576
770
|
) -> str:
|
|
577
771
|
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
578
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:]
|
|
579
776
|
corrections_hint = (
|
|
580
777
|
"\n\nCritic corrections from previous attempt:\n"
|
|
581
|
-
+ "\n".join(f"- {c}" for c in
|
|
582
|
-
) if
|
|
778
|
+
+ "\n".join(f"- {c}" for c in active_corrections)
|
|
779
|
+
) if active_corrections else ""
|
|
583
780
|
|
|
584
781
|
recent_kwargs = {
|
|
585
782
|
"conversation_id": req.conversation_id,
|
|
@@ -588,6 +785,12 @@ class SingleAgentRuntime:
|
|
|
588
785
|
if request_workspace is not None:
|
|
589
786
|
recent_kwargs["workspace_id"] = request_workspace
|
|
590
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
|
+
)
|
|
591
794
|
return (
|
|
592
795
|
f"{d.executor_prompt}\n\n"
|
|
593
796
|
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
@@ -595,7 +798,7 @@ class SingleAgentRuntime:
|
|
|
595
798
|
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
596
799
|
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
597
800
|
f"User request: {req.message}{corrections_hint}\n\n"
|
|
598
|
-
f"Execution transcript:\n{json.dumps(
|
|
801
|
+
f"Execution transcript:\n{json.dumps(bounded_transcript, ensure_ascii=False, indent=2)}"
|
|
599
802
|
)
|
|
600
803
|
|
|
601
804
|
def _note_parse_failure(
|
|
@@ -608,8 +811,10 @@ class SingleAgentRuntime:
|
|
|
608
811
|
})
|
|
609
812
|
if parse_failures >= 3:
|
|
610
813
|
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
814
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=False)
|
|
611
815
|
return True
|
|
612
816
|
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
817
|
+
self._emit_step(ctx, "execute", "parse_error", recovered=True)
|
|
613
818
|
# Weak models often need one concrete reminder of the wire
|
|
614
819
|
# format; feed it through the corrections channel and retry
|
|
615
820
|
# instead of aborting the whole run on the first slip.
|
|
@@ -665,6 +870,7 @@ class SingleAgentRuntime:
|
|
|
665
870
|
if verdict is not None and verdict.get("decision") == "proposed":
|
|
666
871
|
proposal = verdict.get("proposal") or {}
|
|
667
872
|
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
873
|
+
self._emit_step(ctx, "execute", "proposed", action=name)
|
|
668
874
|
ctx.transcript.append({
|
|
669
875
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
670
876
|
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
@@ -691,6 +897,7 @@ class SingleAgentRuntime:
|
|
|
691
897
|
d = self.deps
|
|
692
898
|
if policy["risk"] == "destructive":
|
|
693
899
|
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
900
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="destructive")
|
|
694
901
|
ctx.transcript.append({
|
|
695
902
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
696
903
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -713,6 +920,7 @@ class SingleAgentRuntime:
|
|
|
713
920
|
args={k: v for k, v in args.items() if k != "content"},
|
|
714
921
|
)
|
|
715
922
|
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
923
|
+
self._emit_step(ctx, "execute", "blocked", action=name, reason="approval")
|
|
716
924
|
ctx.transcript.append({
|
|
717
925
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
718
926
|
"thoughts": thoughts, "args": args, "risk": risk,
|
|
@@ -748,6 +956,11 @@ class SingleAgentRuntime:
|
|
|
748
956
|
"artifact_repair" if meta.get("repaired") else "artifact_sanitize"
|
|
749
957
|
],
|
|
750
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
|
+
)
|
|
751
964
|
try:
|
|
752
965
|
d.check_role(name, current_user)
|
|
753
966
|
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
@@ -763,6 +976,10 @@ class SingleAgentRuntime:
|
|
|
763
976
|
"risk": risk, "governance": dict(policy), "result": result,
|
|
764
977
|
**({"content_sanitize": sanitize_meta} if sanitize_meta else {}),
|
|
765
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
|
+
)
|
|
766
983
|
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
767
984
|
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
768
985
|
ctx.transcript.append({
|
|
@@ -770,6 +987,10 @@ class SingleAgentRuntime:
|
|
|
770
987
|
"thoughts": thoughts, "args": args,
|
|
771
988
|
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
772
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
|
+
)
|
|
773
994
|
|
|
774
995
|
# ── VERIFY ───────────────────────────────────────────────────────
|
|
775
996
|
def _has_execution_evidence(self, ctx: AgentRunContext) -> bool:
|
|
@@ -798,12 +1019,18 @@ class SingleAgentRuntime:
|
|
|
798
1019
|
NEEDS_REVIEW so the user is told to check the result themselves.
|
|
799
1020
|
"""
|
|
800
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
|
+
)
|
|
801
1028
|
context = (
|
|
802
1029
|
f"{d.critic_prompt}\n\n"
|
|
803
1030
|
f"[LANGUAGE HINT: {lang_hint}]\n\n"
|
|
804
1031
|
f"Original request: {req.message}\n"
|
|
805
1032
|
f"Plan goal: {ctx.plan.get('goal', req.message)}\n\n"
|
|
806
|
-
f"Full transcript:\n{json.dumps(
|
|
1033
|
+
f"Full transcript:\n{json.dumps(verify_transcript, ensure_ascii=False, indent=2)}"
|
|
807
1034
|
)
|
|
808
1035
|
raw = await d.generate_as(
|
|
809
1036
|
model_id,
|
|
@@ -857,6 +1084,7 @@ class SingleAgentRuntime:
|
|
|
857
1084
|
"verify", decision="verification_unavailable",
|
|
858
1085
|
verifier_available=False, verdict_valid=False, evidence=has_evidence,
|
|
859
1086
|
)
|
|
1087
|
+
self._emit_step(ctx, "verify", "verdict", verdict="UNAVAILABLE")
|
|
860
1088
|
ctx.final_message = (
|
|
861
1089
|
"검증을 완료하지 못했습니다 — 검증 모델의 응답을 해석할 수 없었습니다. "
|
|
862
1090
|
"실행 결과를 직접 확인해 주시고, 필요하면 다시 시도해 주세요."
|
|
@@ -885,6 +1113,10 @@ class SingleAgentRuntime:
|
|
|
885
1113
|
"verify", decision=str(verdict.get("verdict", "")), next_state=next_s,
|
|
886
1114
|
verifier_available=True, verdict_valid=True, evidence=has_evidence,
|
|
887
1115
|
)
|
|
1116
|
+
self._emit_step(
|
|
1117
|
+
ctx, "verify", "verdict",
|
|
1118
|
+
verdict=str(verdict.get("verdict", "")), next_state=next_s,
|
|
1119
|
+
)
|
|
888
1120
|
if verdict.get("verdict") == "PASS":
|
|
889
1121
|
# DONE requires both: a validly parsed PASS verdict AND
|
|
890
1122
|
# deterministic execution evidence in the transcript. A PASS over
|
|
@@ -966,16 +1198,33 @@ class SingleAgentRuntime:
|
|
|
966
1198
|
else "롤백을 시도했으나 복구할 파일이 없거나 git이 초기화되지 않았습니다."
|
|
967
1199
|
)
|
|
968
1200
|
d.audit("agent_rollback", user_email=current_user, rolled_back=rolled)
|
|
1201
|
+
self._emit_step(ctx, "rollback", "rolled_back", recovered=len(recovered))
|
|
969
1202
|
# Rollback is a recovery from a failed verification — terminal state is FAILED
|
|
970
1203
|
ctx.state = AgentState.FAILED
|
|
971
1204
|
|
|
972
1205
|
# ── MEMORY ───────────────────────────────────────────────────────
|
|
973
1206
|
async def memory_update(self, ctx: AgentRunContext, req: Any, current_user: str) -> None:
|
|
974
|
-
"""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
|
+
"""
|
|
975
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
|
+
)
|
|
976
1224
|
context = (
|
|
977
1225
|
f"{d.memory_updater_prompt}\n\n"
|
|
978
|
-
f"
|
|
1226
|
+
f"Task: {req.message}\n"
|
|
1227
|
+
f"Terminal status: {terminal}. {outcome_hint}\n\n"
|
|
979
1228
|
f"Last 5 transcript steps:\n{json.dumps(ctx.transcript[-5:], ensure_ascii=False)}"
|
|
980
1229
|
)
|
|
981
1230
|
try:
|
|
@@ -987,6 +1236,11 @@ class SingleAgentRuntime:
|
|
|
987
1236
|
kept_learnings = filter_learnings(mem.get("learnings") or [])
|
|
988
1237
|
if mem.get("save_to_knowledge") and kept_learnings:
|
|
989
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")
|
|
990
1244
|
if d.brain_memory is not None:
|
|
991
1245
|
# This runtime is LLM-driven — its learnings are real
|
|
992
1246
|
# experiences and enter the brain with provenance.
|
|
@@ -995,7 +1249,7 @@ class SingleAgentRuntime:
|
|
|
995
1249
|
learnings,
|
|
996
1250
|
run={
|
|
997
1251
|
"mode": "llm",
|
|
998
|
-
"status":
|
|
1252
|
+
"status": status_label,
|
|
999
1253
|
"agent_id": "agent:executor",
|
|
1000
1254
|
"steps": len(ctx.transcript),
|
|
1001
1255
|
},
|
|
@@ -1036,3 +1290,4 @@ class SingleAgentRuntime:
|
|
|
1036
1290
|
ctx.state = AgentState.FAILED
|
|
1037
1291
|
|
|
1038
1292
|
ctx.state_history.append(ctx.state.value)
|
|
1293
|
+
self._emit_step(ctx, "terminal", "state", state=ctx.state.value)
|
|
@@ -52,6 +52,11 @@ class EnterpriseCapability(str, Enum):
|
|
|
52
52
|
DLP_POLICY = "dlp_policy"
|
|
53
53
|
EDISCOVERY = "ediscovery"
|
|
54
54
|
ADMIN_POLICY_PACKS = "admin_policy_packs"
|
|
55
|
+
# Human review-before-promote governance for knowledge-graph concept
|
|
56
|
+
# promotions (review 2026-07-25 Wave 4). Community users can opt in per
|
|
57
|
+
# deployment via LATTICEAI_GRAPH_PROMOTION_REVIEW; this capability is the
|
|
58
|
+
# seam for Enterprise policy packs that mandate reviewed promotions.
|
|
59
|
+
GRAPH_PROMOTION_REVIEW = "graph_promotion_review"
|
|
55
60
|
|
|
56
61
|
|
|
57
62
|
@runtime_checkable
|