ltcai 9.6.0 → 9.7.0
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 +52 -42
- package/auto_setup.py +11 -1
- package/docs/CHANGELOG.md +54 -0
- package/docs/COMMUNITY_AND_PLUGINS.md +1 -1
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/PERFORMANCE.md +106 -0
- package/docs/TRUST_MODEL.md +1 -1
- package/docs/WHY_LATTICE.md +1 -1
- package/docs/kg-schema.md +1 -1
- package/kg_schema.py +11 -1
- package/knowledge_graph.py +12 -2
- package/knowledge_graph_api.py +11 -1
- package/lattice_brain/__init__.py +1 -1
- package/lattice_brain/graph/proactive.py +583 -0
- package/lattice_brain/graph/retrieval.py +211 -7
- package/lattice_brain/graph/retrieval_vector.py +102 -0
- package/lattice_brain/ingestion.py +360 -4
- package/lattice_brain/quality.py +44 -9
- package/lattice_brain/runtime/multi_agent.py +38 -2
- package/latticeai/__init__.py +1 -1
- package/latticeai/api/brain_intelligence.py +27 -2
- package/latticeai/api/change_proposals.py +32 -3
- package/latticeai/api/review_queue.py +66 -2
- package/latticeai/app_factory.py +3 -0
- package/latticeai/core/agent.py +193 -135
- package/latticeai/core/agent_eval.py +155 -4
- package/latticeai/core/legacy_compatibility.py +15 -1
- package/latticeai/core/marketplace.py +1 -1
- package/latticeai/core/workspace_os.py +1 -1
- package/latticeai/runtime/router_registration.py +2 -0
- package/latticeai/services/architecture_readiness.py +1 -1
- package/latticeai/services/brain_intelligence.py +97 -1
- package/latticeai/services/change_proposals.py +56 -4
- package/latticeai/services/product_readiness.py +1 -1
- package/latticeai/services/review_queue.py +43 -2
- package/llm_router.py +10 -1
- package/local_knowledge_api.py +10 -1
- package/ltcai_cli.py +11 -1
- package/mcp_registry.py +10 -1
- package/p_reinforce.py +10 -1
- package/package.json +1 -1
- package/scripts/brain_quality_eval.py +1 -1
- package/scripts/check_current_release_docs.mjs +1 -1
- package/scripts/profile_kg.py +360 -0
- package/setup_wizard.py +10 -1
- 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 +10 -10
- package/static/app/assets/{Act-BkOEmwBi.js → Act-B6c39ays.js} +2 -1
- package/static/app/assets/{Brain-C9ITUsQ_.js → Brain-D7Qg4k6M.js} +1 -1
- package/static/app/assets/{Capture-C-ppTeud.js → Capture-VF_di68r.js} +1 -1
- package/static/app/assets/{Library-CGQbgWTu.js → Library-D_Gis2PA.js} +1 -1
- package/static/app/assets/{System-djmj0n2_.js → System-C5s5H2ov.js} +1 -1
- package/static/app/assets/{index-AF0-4XVv.js → index-DJC_2oub.js} +3 -3
- package/static/app/assets/{primitives-jbb2qv4Q.js → primitives-DL4Nip8C.js} +1 -1
- package/static/app/assets/{textarea-CFoo0OxJ.js → textarea-woZfCXHy.js} +1 -1
- package/static/app/index.html +1 -1
- package/static/sw.js +1 -1
|
@@ -13,10 +13,17 @@ from __future__ import annotations
|
|
|
13
13
|
from typing import Callable, Optional
|
|
14
14
|
|
|
15
15
|
from fastapi import APIRouter, HTTPException, Request
|
|
16
|
+
from pydantic import BaseModel
|
|
16
17
|
|
|
17
18
|
from latticeai.services.change_proposals import ChangeProposalService
|
|
18
19
|
|
|
19
20
|
|
|
21
|
+
class RejectProposalRequest(BaseModel):
|
|
22
|
+
"""Optional rejection context — kept in the item's provenance."""
|
|
23
|
+
|
|
24
|
+
reason: str = ""
|
|
25
|
+
|
|
26
|
+
|
|
20
27
|
def create_change_proposals_router(
|
|
21
28
|
*,
|
|
22
29
|
service: ChangeProposalService,
|
|
@@ -32,6 +39,23 @@ def create_change_proposals_router(
|
|
|
32
39
|
scope = gate_read(request)
|
|
33
40
|
return service.pending(user_email=user, workspace_id=scope)
|
|
34
41
|
|
|
42
|
+
# NOTE: declared before /api/proposals/{item_id} so "counts" never
|
|
43
|
+
# resolves as an item id.
|
|
44
|
+
@router.get("/api/proposals/counts")
|
|
45
|
+
async def proposal_counts(request: Request):
|
|
46
|
+
user = require_user(request)
|
|
47
|
+
scope = gate_read(request)
|
|
48
|
+
return service.counts(user_email=user, workspace_id=scope)
|
|
49
|
+
|
|
50
|
+
@router.get("/api/proposals/{item_id}")
|
|
51
|
+
async def get_proposal(item_id: str, request: Request):
|
|
52
|
+
require_user(request)
|
|
53
|
+
scope = gate_read(request)
|
|
54
|
+
try:
|
|
55
|
+
return service.get_proposal(item_id, workspace_id=scope)
|
|
56
|
+
except (KeyError, FileNotFoundError) as exc:
|
|
57
|
+
raise HTTPException(status_code=404, detail=str(exc))
|
|
58
|
+
|
|
35
59
|
@router.post("/api/proposals/{item_id}/approve")
|
|
36
60
|
async def approve_proposal(item_id: str, request: Request):
|
|
37
61
|
user = require_user(request)
|
|
@@ -46,15 +70,20 @@ def create_change_proposals_router(
|
|
|
46
70
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
47
71
|
|
|
48
72
|
@router.post("/api/proposals/{item_id}/reject")
|
|
49
|
-
async def reject_proposal(
|
|
73
|
+
async def reject_proposal(
|
|
74
|
+
item_id: str, request: Request, req: Optional[RejectProposalRequest] = None
|
|
75
|
+
):
|
|
50
76
|
user = require_user(request)
|
|
51
77
|
scope = gate_write(request)
|
|
52
78
|
try:
|
|
53
|
-
return service.reject(
|
|
79
|
+
return service.reject(
|
|
80
|
+
item_id, user_email=user, workspace_id=scope,
|
|
81
|
+
reason=(req.reason if req else ""),
|
|
82
|
+
)
|
|
54
83
|
except (KeyError, FileNotFoundError) as exc:
|
|
55
84
|
raise HTTPException(status_code=404, detail=str(exc))
|
|
56
85
|
|
|
57
86
|
return router
|
|
58
87
|
|
|
59
88
|
|
|
60
|
-
__all__ = ["create_change_proposals_router"]
|
|
89
|
+
__all__ = ["create_change_proposals_router", "RejectProposalRequest"]
|
|
@@ -56,6 +56,18 @@ class SnoozeRequest(BaseModel):
|
|
|
56
56
|
until: str
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class DismissRequest(BaseModel):
|
|
60
|
+
"""Optional dismissal context (e.g. why a change proposal was rejected)."""
|
|
61
|
+
|
|
62
|
+
reason: str = ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class ReviewCounts(BaseModel):
|
|
66
|
+
pending: int = 0
|
|
67
|
+
snoozed: int = 0
|
|
68
|
+
pending_by_source: Dict[str, int] = Field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
|
|
59
71
|
def create_review_queue_router(
|
|
60
72
|
*,
|
|
61
73
|
service: ReviewQueueService,
|
|
@@ -64,7 +76,13 @@ def create_review_queue_router(
|
|
|
64
76
|
gate_write: Callable[[Request], Optional[str]],
|
|
65
77
|
run_review_item: Callable[..., Any],
|
|
66
78
|
append_audit_event: Callable[..., None],
|
|
79
|
+
change_proposals: Any = None,
|
|
67
80
|
) -> APIRouter:
|
|
81
|
+
"""``change_proposals`` (optional) closes the governance loop: approving a
|
|
82
|
+
``change_proposal`` item from the Review Center applies the staged content
|
|
83
|
+
via :class:`~latticeai.services.change_proposals.ChangeProposalService`
|
|
84
|
+
instead of merely flipping the status — the same single application path
|
|
85
|
+
the /api/proposals surface uses."""
|
|
68
86
|
router = APIRouter()
|
|
69
87
|
|
|
70
88
|
@router.get("/automation/reviews", response_model=ReviewItemList)
|
|
@@ -95,6 +113,14 @@ def create_review_queue_router(
|
|
|
95
113
|
append_audit_event("review_item_created", user_email=user, item_id=item["id"])
|
|
96
114
|
return item
|
|
97
115
|
|
|
116
|
+
# NOTE: declared before /automation/reviews/{item_id} so "counts" never
|
|
117
|
+
# resolves as an item id.
|
|
118
|
+
@router.get("/automation/reviews/counts", response_model=ReviewCounts)
|
|
119
|
+
async def review_counts(request: Request):
|
|
120
|
+
user = require_user(request)
|
|
121
|
+
scope = gate_read(request)
|
|
122
|
+
return service.counts(workspace_id=scope, user_email=user)
|
|
123
|
+
|
|
98
124
|
@router.get("/automation/reviews/{item_id}", response_model=ReviewItem)
|
|
99
125
|
async def get_item(item_id: str, request: Request):
|
|
100
126
|
require_user(request)
|
|
@@ -106,11 +132,49 @@ def create_review_queue_router(
|
|
|
106
132
|
|
|
107
133
|
@router.post("/automation/reviews/{item_id}/approve", response_model=ReviewItem)
|
|
108
134
|
async def approve_item(item_id: str, request: Request):
|
|
135
|
+
user = require_user(request)
|
|
136
|
+
scope = gate_write(request)
|
|
137
|
+
# change_proposal items must apply the staged content on approve —
|
|
138
|
+
# otherwise the item flips to "approved" while nothing hits disk.
|
|
139
|
+
if change_proposals is not None:
|
|
140
|
+
try:
|
|
141
|
+
stored = service.get(item_id, workspace_id=scope)
|
|
142
|
+
except FileNotFoundError as exc:
|
|
143
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
144
|
+
if stored.get("source") == "change_proposal":
|
|
145
|
+
if stored.get("effective_status") not in ("pending", "snoozed"):
|
|
146
|
+
raise HTTPException(
|
|
147
|
+
status_code=409,
|
|
148
|
+
detail=f"cannot 'approve' a review item in status {stored.get('status')!r}",
|
|
149
|
+
)
|
|
150
|
+
try:
|
|
151
|
+
applied = change_proposals.approve_and_apply(
|
|
152
|
+
item_id, user_email=user, workspace_id=scope
|
|
153
|
+
)
|
|
154
|
+
except (KeyError, FileNotFoundError) as exc:
|
|
155
|
+
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
|
156
|
+
except ValueError as exc:
|
|
157
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
158
|
+
append_audit_event("review_item_approve", user_email=user, item_id=item_id)
|
|
159
|
+
return applied["item"]
|
|
109
160
|
return _act(request, item_id, "approve")
|
|
110
161
|
|
|
111
162
|
@router.post("/automation/reviews/{item_id}/dismiss", response_model=ReviewItem)
|
|
112
|
-
async def dismiss_item(
|
|
113
|
-
|
|
163
|
+
async def dismiss_item(
|
|
164
|
+
item_id: str, request: Request, req: Optional[DismissRequest] = None
|
|
165
|
+
):
|
|
166
|
+
user = require_user(request)
|
|
167
|
+
scope = gate_write(request)
|
|
168
|
+
try:
|
|
169
|
+
item = service.dismiss(
|
|
170
|
+
item_id, workspace_id=scope, reason=(req.reason if req else None)
|
|
171
|
+
)
|
|
172
|
+
except FileNotFoundError as exc:
|
|
173
|
+
raise HTTPException(status_code=404, detail="review item not found") from exc
|
|
174
|
+
except InvalidReviewTransition as exc:
|
|
175
|
+
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
|
176
|
+
append_audit_event("review_item_dismiss", user_email=user, item_id=item_id)
|
|
177
|
+
return item
|
|
114
178
|
|
|
115
179
|
@router.post("/automation/reviews/{item_id}/snooze", response_model=ReviewItem)
|
|
116
180
|
async def snooze_item(item_id: str, req: SnoozeRequest, request: Request):
|
package/latticeai/app_factory.py
CHANGED
|
@@ -1194,6 +1194,9 @@ def _build(config: "Optional[Config]" = None) -> Dict[str, Any]:
|
|
|
1194
1194
|
gate_write=PLATFORM.gate_write,
|
|
1195
1195
|
run_review_item=run_review_item,
|
|
1196
1196
|
append_audit_event=append_audit_event,
|
|
1197
|
+
# Approving a change_proposal from the Review Center applies the
|
|
1198
|
+
# staged content through the same service the agent governor uses.
|
|
1199
|
+
change_proposals=CHANGE_PROPOSALS,
|
|
1197
1200
|
create_browser_router=create_browser_router,
|
|
1198
1201
|
ingestion_pipeline=INGESTION_PIPELINE,
|
|
1199
1202
|
workspace_service=WORKSPACE_SERVICE,
|
package/latticeai/core/agent.py
CHANGED
|
@@ -319,28 +319,8 @@ class SingleAgentRuntime:
|
|
|
319
319
|
parse_failures = 0
|
|
320
320
|
|
|
321
321
|
for _ in range(budget):
|
|
322
|
-
corrections_hint = (
|
|
323
|
-
"\n\nCritic corrections from previous attempt:\n"
|
|
324
|
-
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
325
|
-
) if ctx.corrections else ""
|
|
326
|
-
|
|
327
322
|
request_workspace = getattr(req, "workspace_id", None)
|
|
328
|
-
|
|
329
|
-
"conversation_id": req.conversation_id,
|
|
330
|
-
"user_email": current_user or None,
|
|
331
|
-
}
|
|
332
|
-
if request_workspace is not None:
|
|
333
|
-
recent_kwargs["workspace_id"] = request_workspace
|
|
334
|
-
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
335
|
-
context = (
|
|
336
|
-
f"{d.executor_prompt}\n\n"
|
|
337
|
-
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
338
|
-
f"Workspace root: {d.agent_root}\n\n"
|
|
339
|
-
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
340
|
-
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
341
|
-
f"User request: {req.message}{corrections_hint}\n\n"
|
|
342
|
-
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
343
|
-
)
|
|
323
|
+
context = self._executor_context(ctx, req, lang_hint, current_user, request_workspace)
|
|
344
324
|
raw = await d.generate_as(
|
|
345
325
|
model_id,
|
|
346
326
|
message="Execute the next step.",
|
|
@@ -352,33 +332,8 @@ class SingleAgentRuntime:
|
|
|
352
332
|
ctx.trace.repair("execute", repairs=exec_repairs)
|
|
353
333
|
except ValueError as exc:
|
|
354
334
|
parse_failures += 1
|
|
355
|
-
|
|
356
|
-
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
357
|
-
"raw": str(raw)[:400], "error": str(exc),
|
|
358
|
-
})
|
|
359
|
-
if parse_failures >= 3:
|
|
360
|
-
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
335
|
+
if self._note_parse_failure(ctx, raw, exc, parse_failures):
|
|
361
336
|
break
|
|
362
|
-
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
363
|
-
# Weak models often need one concrete reminder of the wire
|
|
364
|
-
# format; feed it through the corrections channel and retry
|
|
365
|
-
# instead of aborting the whole run on the first slip.
|
|
366
|
-
hint = (
|
|
367
|
-
'Your last reply was not a single JSON action object. Reply with '
|
|
368
|
-
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
369
|
-
'"tool_name", "args": {...}} and nothing else.'
|
|
370
|
-
)
|
|
371
|
-
if parse_failures >= 2:
|
|
372
|
-
# Escalate: name the valid tools so the model stops
|
|
373
|
-
# inventing action names or prose.
|
|
374
|
-
valid = ", ".join(sorted(d.tool_governance.keys()))
|
|
375
|
-
hint = (
|
|
376
|
-
f"{hint} Valid action values are: {valid}, final. "
|
|
377
|
-
'Use {"action": "final", "message": "..."} to finish.'
|
|
378
|
-
)
|
|
379
|
-
if hint not in ctx.corrections:
|
|
380
|
-
ctx.corrections.append(hint)
|
|
381
|
-
ctx.trace.correction("execute", hint=hint)
|
|
382
337
|
continue
|
|
383
338
|
|
|
384
339
|
name = action.get("action")
|
|
@@ -402,14 +357,7 @@ class SingleAgentRuntime:
|
|
|
402
357
|
return
|
|
403
358
|
|
|
404
359
|
# Loop guard
|
|
405
|
-
|
|
406
|
-
last = exec_steps[-1] if exec_steps else None
|
|
407
|
-
if (
|
|
408
|
-
name in d.file_create_actions and last
|
|
409
|
-
and last.get("action") == name
|
|
410
|
-
and (last.get("args") or {}) == args
|
|
411
|
-
and "result" in last
|
|
412
|
-
):
|
|
360
|
+
if self._is_repeated_create(ctx, name, args):
|
|
413
361
|
ctx.transcript.append({
|
|
414
362
|
"state": AgentState.EXECUTING.value, "action": name,
|
|
415
363
|
"error": "LOOP_DETECTED: identical action+args repeated — halted.",
|
|
@@ -428,93 +376,203 @@ class SingleAgentRuntime:
|
|
|
428
376
|
policy = d.policy_for(name, args)
|
|
429
377
|
risk = d.risk_level(policy)
|
|
430
378
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
name, args, policy=dict(policy),
|
|
437
|
-
user_email=current_user, workspace_id=request_workspace,
|
|
438
|
-
)
|
|
439
|
-
if verdict is not None and verdict.get("decision") == "proposed":
|
|
440
|
-
proposal = verdict.get("proposal") or {}
|
|
441
|
-
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
442
|
-
ctx.transcript.append({
|
|
443
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
444
|
-
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
445
|
-
"risk": risk, "governance": dict(policy),
|
|
446
|
-
"result": {
|
|
447
|
-
"proposed": True,
|
|
448
|
-
"proposal_id": proposal.get("id"),
|
|
449
|
-
"note": "기존 내용을 바꾸는 작업이라 변경 제안으로 저장했습니다. 검토함에서 승인하면 적용됩니다.",
|
|
450
|
-
},
|
|
451
|
-
})
|
|
452
|
-
d.audit(
|
|
453
|
-
"agent_change_proposed", user_email=current_user,
|
|
454
|
-
action=name, proposal_id=proposal.get("id"),
|
|
455
|
-
change_class=(verdict.get("classification") or {}).get("change_class"),
|
|
456
|
-
)
|
|
457
|
-
continue
|
|
458
|
-
governor_allows_additive = (
|
|
459
|
-
verdict is not None and verdict.get("decision") == "allow_additive"
|
|
460
|
-
)
|
|
461
|
-
|
|
462
|
-
if policy["risk"] == "destructive":
|
|
463
|
-
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
464
|
-
ctx.transcript.append({
|
|
465
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
466
|
-
"thoughts": thoughts, "args": args, "risk": risk,
|
|
467
|
-
"governance": dict(policy),
|
|
468
|
-
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
469
|
-
})
|
|
470
|
-
d.audit(
|
|
471
|
-
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
472
|
-
action=name, reason="destructive", governance=dict(policy),
|
|
473
|
-
)
|
|
379
|
+
proposed, governor_allows_additive = self._governor_review(
|
|
380
|
+
ctx, name, thoughts, args, policy, risk, current_user, request_workspace,
|
|
381
|
+
conversation_id=getattr(req, "conversation_id", None),
|
|
382
|
+
)
|
|
383
|
+
if proposed:
|
|
474
384
|
continue
|
|
475
385
|
|
|
476
|
-
if
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
shell=policy["shell"], network=policy["network"],
|
|
481
|
-
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
482
|
-
rollback=policy["rollback"],
|
|
483
|
-
args={k: v for k, v in args.items() if k != "content"},
|
|
484
|
-
)
|
|
485
|
-
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
486
|
-
ctx.transcript.append({
|
|
487
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
488
|
-
"thoughts": thoughts, "args": args, "risk": risk,
|
|
489
|
-
"governance": dict(policy),
|
|
490
|
-
"error": f"BLOCKED: action '{name}' requires explicit approval.",
|
|
491
|
-
})
|
|
386
|
+
if self._blocked_by_gates(
|
|
387
|
+
ctx, req, name, thoughts, args, policy, risk,
|
|
388
|
+
current_user, governor_allows_additive,
|
|
389
|
+
):
|
|
492
390
|
continue
|
|
493
391
|
|
|
494
|
-
|
|
495
|
-
d.check_role(name, current_user)
|
|
496
|
-
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
497
|
-
result = dispatch_tool(
|
|
498
|
-
d.hooks, name, args,
|
|
499
|
-
lambda: d.execute_tool(name, args),
|
|
500
|
-
user_email=current_user, source="agent",
|
|
501
|
-
)
|
|
502
|
-
ctx.trace.tool("execute", name=name, outcome="ok", risk=risk)
|
|
503
|
-
ctx.transcript.append({
|
|
504
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
505
|
-
"thoughts": thoughts, "args": args,
|
|
506
|
-
"risk": risk, "governance": dict(policy), "result": result,
|
|
507
|
-
})
|
|
508
|
-
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
509
|
-
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
510
|
-
ctx.transcript.append({
|
|
511
|
-
"state": AgentState.EXECUTING.value, "action": name,
|
|
512
|
-
"thoughts": thoughts, "args": args,
|
|
513
|
-
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
514
|
-
})
|
|
392
|
+
self._dispatch_step(ctx, name, thoughts, args, policy, risk, current_user)
|
|
515
393
|
|
|
516
394
|
ctx.state = AgentState.VERIFYING
|
|
517
395
|
|
|
396
|
+
def _executor_context(
|
|
397
|
+
self, ctx: AgentRunContext, req: Any, lang_hint: str,
|
|
398
|
+
current_user: str, request_workspace: Optional[str],
|
|
399
|
+
) -> str:
|
|
400
|
+
"""Assemble one executor turn's prompt (plan, corrections, recent chat)."""
|
|
401
|
+
d = self.deps
|
|
402
|
+
corrections_hint = (
|
|
403
|
+
"\n\nCritic corrections from previous attempt:\n"
|
|
404
|
+
+ "\n".join(f"- {c}" for c in ctx.corrections)
|
|
405
|
+
) if ctx.corrections else ""
|
|
406
|
+
|
|
407
|
+
recent_kwargs = {
|
|
408
|
+
"conversation_id": req.conversation_id,
|
|
409
|
+
"user_email": current_user or None,
|
|
410
|
+
}
|
|
411
|
+
if request_workspace is not None:
|
|
412
|
+
recent_kwargs["workspace_id"] = request_workspace
|
|
413
|
+
recent_conversation = d.recent_chat_context(**recent_kwargs) or "(none)"
|
|
414
|
+
return (
|
|
415
|
+
f"{d.executor_prompt}\n\n"
|
|
416
|
+
f"[LANGUAGE HINT: {lang_hint}]\n"
|
|
417
|
+
f"Workspace root: {d.agent_root}\n\n"
|
|
418
|
+
f"PLAN:\n{json.dumps(ctx.plan, ensure_ascii=False)}\n\n"
|
|
419
|
+
f"Recent conversation:\n{recent_conversation}\n\n"
|
|
420
|
+
f"User request: {req.message}{corrections_hint}\n\n"
|
|
421
|
+
f"Execution transcript:\n{json.dumps(ctx.transcript, ensure_ascii=False, indent=2)}"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
def _note_parse_failure(
|
|
425
|
+
self, ctx: AgentRunContext, raw: Any, exc: ValueError, parse_failures: int,
|
|
426
|
+
) -> bool:
|
|
427
|
+
"""Record one executor parse slip; True when the run should stop retrying."""
|
|
428
|
+
ctx.transcript.append({
|
|
429
|
+
"state": AgentState.EXECUTING.value, "action": "parse_error",
|
|
430
|
+
"raw": str(raw)[:400], "error": str(exc),
|
|
431
|
+
})
|
|
432
|
+
if parse_failures >= 3:
|
|
433
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=False)
|
|
434
|
+
return True
|
|
435
|
+
ctx.trace.parse_error("execute", error=str(exc), recovered=True)
|
|
436
|
+
# Weak models often need one concrete reminder of the wire
|
|
437
|
+
# format; feed it through the corrections channel and retry
|
|
438
|
+
# instead of aborting the whole run on the first slip.
|
|
439
|
+
hint = (
|
|
440
|
+
'Your last reply was not a single JSON action object. Reply with '
|
|
441
|
+
'EXACTLY one JSON object like {"thoughts": "...", "action": '
|
|
442
|
+
'"tool_name", "args": {...}} and nothing else.'
|
|
443
|
+
)
|
|
444
|
+
if parse_failures >= 2:
|
|
445
|
+
# Escalate: name the valid tools so the model stops
|
|
446
|
+
# inventing action names or prose.
|
|
447
|
+
valid = ", ".join(sorted(self.deps.tool_governance.keys()))
|
|
448
|
+
hint = (
|
|
449
|
+
f"{hint} Valid action values are: {valid}, final. "
|
|
450
|
+
'Use {"action": "final", "message": "..."} to finish.'
|
|
451
|
+
)
|
|
452
|
+
if hint not in ctx.corrections:
|
|
453
|
+
ctx.corrections.append(hint)
|
|
454
|
+
ctx.trace.correction("execute", hint=hint)
|
|
455
|
+
return False
|
|
456
|
+
|
|
457
|
+
def _is_repeated_create(self, ctx: AgentRunContext, name: Any, args: dict) -> bool:
|
|
458
|
+
"""Loop guard: the same file-create action+args re-issued right after a result."""
|
|
459
|
+
exec_steps = [s for s in ctx.transcript if s.get("state") == AgentState.EXECUTING.value]
|
|
460
|
+
last = exec_steps[-1] if exec_steps else None
|
|
461
|
+
return bool(
|
|
462
|
+
name in self.deps.file_create_actions and last
|
|
463
|
+
and last.get("action") == name
|
|
464
|
+
and (last.get("args") or {}) == args
|
|
465
|
+
and "result" in last
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
def _governor_review(
|
|
469
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
470
|
+
policy: dict, risk: str, current_user: str, request_workspace: Optional[str],
|
|
471
|
+
conversation_id: Optional[str] = None,
|
|
472
|
+
) -> Tuple[bool, bool]:
|
|
473
|
+
"""Central change-class governance: create-new runs with minimal
|
|
474
|
+
friction, change/delete-existing becomes a review proposal.
|
|
475
|
+
|
|
476
|
+
Returns ``(proposed, governor_allows_additive)``: ``proposed`` means the
|
|
477
|
+
step was staged as a proposal (skip execution); ``allows_additive`` lets
|
|
478
|
+
an additive create pass the classic approval gate.
|
|
479
|
+
"""
|
|
480
|
+
d = self.deps
|
|
481
|
+
if d.change_governor is None:
|
|
482
|
+
return False, False
|
|
483
|
+
verdict = d.change_governor.review(
|
|
484
|
+
name, args, policy=dict(policy),
|
|
485
|
+
user_email=current_user, workspace_id=request_workspace,
|
|
486
|
+
conversation_id=conversation_id,
|
|
487
|
+
)
|
|
488
|
+
if verdict is not None and verdict.get("decision") == "proposed":
|
|
489
|
+
proposal = verdict.get("proposal") or {}
|
|
490
|
+
ctx.trace.tool("execute", name=name, outcome="proposed", risk=risk)
|
|
491
|
+
ctx.transcript.append({
|
|
492
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
493
|
+
"thoughts": thoughts, "args": {k: v for k, v in args.items() if k != "content"},
|
|
494
|
+
"risk": risk, "governance": dict(policy),
|
|
495
|
+
"result": {
|
|
496
|
+
"proposed": True,
|
|
497
|
+
"proposal_id": proposal.get("id"),
|
|
498
|
+
"note": "기존 내용을 바꾸는 작업이라 변경 제안으로 저장했습니다. 검토함에서 승인하면 적용됩니다.",
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
d.audit(
|
|
502
|
+
"agent_change_proposed", user_email=current_user,
|
|
503
|
+
action=name, proposal_id=proposal.get("id"),
|
|
504
|
+
change_class=(verdict.get("classification") or {}).get("change_class"),
|
|
505
|
+
)
|
|
506
|
+
return True, False
|
|
507
|
+
return False, (verdict is not None and verdict.get("decision") == "allow_additive")
|
|
508
|
+
|
|
509
|
+
def _blocked_by_gates(
|
|
510
|
+
self, ctx: AgentRunContext, req: Any, name: str, thoughts: str, args: dict,
|
|
511
|
+
policy: dict, risk: str, current_user: str, governor_allows_additive: bool,
|
|
512
|
+
) -> bool:
|
|
513
|
+
"""Classic destructive / explicit-approval gates; True when the step was blocked."""
|
|
514
|
+
d = self.deps
|
|
515
|
+
if policy["risk"] == "destructive":
|
|
516
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_destructive", risk=risk)
|
|
517
|
+
ctx.transcript.append({
|
|
518
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
519
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
520
|
+
"governance": dict(policy),
|
|
521
|
+
"error": f"BLOCKED: destructive action '{name}' not permitted in agent mode.",
|
|
522
|
+
})
|
|
523
|
+
d.audit(
|
|
524
|
+
"agent_blocked", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
525
|
+
action=name, reason="destructive", governance=dict(policy),
|
|
526
|
+
)
|
|
527
|
+
return True
|
|
528
|
+
|
|
529
|
+
if not policy["auto_approve"] and not ctx.approved_by_human and not governor_allows_additive:
|
|
530
|
+
d.audit(
|
|
531
|
+
"agent_exec", user_email=current_user, source=getattr(req, "source", None) or "agent",
|
|
532
|
+
state=AgentState.EXECUTING.value, action=name, risk=risk,
|
|
533
|
+
shell=policy["shell"], network=policy["network"],
|
|
534
|
+
destructive=policy["destructive"], sandbox=policy["sandbox"],
|
|
535
|
+
rollback=policy["rollback"],
|
|
536
|
+
args={k: v for k, v in args.items() if k != "content"},
|
|
537
|
+
)
|
|
538
|
+
ctx.trace.tool("execute", name=name, outcome="blocked_approval", risk=risk)
|
|
539
|
+
ctx.transcript.append({
|
|
540
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
541
|
+
"thoughts": thoughts, "args": args, "risk": risk,
|
|
542
|
+
"governance": dict(policy),
|
|
543
|
+
"error": f"BLOCKED: action '{name}' requires explicit approval.",
|
|
544
|
+
})
|
|
545
|
+
return True
|
|
546
|
+
return False
|
|
547
|
+
|
|
548
|
+
def _dispatch_step(
|
|
549
|
+
self, ctx: AgentRunContext, name: str, thoughts: str, args: dict,
|
|
550
|
+
policy: dict, risk: str, current_user: str,
|
|
551
|
+
) -> None:
|
|
552
|
+
"""Role check + shared tool lifecycle, recorded on the transcript either way."""
|
|
553
|
+
d = self.deps
|
|
554
|
+
try:
|
|
555
|
+
d.check_role(name, current_user)
|
|
556
|
+
# Shared tool lifecycle: pre_tool (may block) → execute → post_tool.
|
|
557
|
+
result = dispatch_tool(
|
|
558
|
+
d.hooks, name, args,
|
|
559
|
+
lambda: d.execute_tool(name, args),
|
|
560
|
+
user_email=current_user, source="agent",
|
|
561
|
+
)
|
|
562
|
+
ctx.trace.tool("execute", name=name, outcome="ok", risk=risk)
|
|
563
|
+
ctx.transcript.append({
|
|
564
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
565
|
+
"thoughts": thoughts, "args": args,
|
|
566
|
+
"risk": risk, "governance": dict(policy), "result": result,
|
|
567
|
+
})
|
|
568
|
+
except (ToolError, KeyError, TypeError, PermissionError) as exc:
|
|
569
|
+
ctx.trace.tool("execute", name=name, outcome="error", risk=risk)
|
|
570
|
+
ctx.transcript.append({
|
|
571
|
+
"state": AgentState.EXECUTING.value, "action": name,
|
|
572
|
+
"thoughts": thoughts, "args": args,
|
|
573
|
+
"risk": risk, "governance": dict(policy), "error": str(exc),
|
|
574
|
+
})
|
|
575
|
+
|
|
518
576
|
# ── VERIFY ───────────────────────────────────────────────────────
|
|
519
577
|
async def verify(
|
|
520
578
|
self, ctx: AgentRunContext, req: Any, lang_hint: str, current_user: str,
|