nexo-brain 2.6.21 → 3.0.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/.claude-plugin/plugin.json +1 -1
- package/README.md +72 -20
- package/hooks/hooks.json +79 -0
- package/package.json +1 -1
- package/src/agent_runner.py +296 -8
- package/src/cli.py +209 -4
- package/src/client_preferences.py +115 -0
- package/src/client_sync.py +202 -2
- package/src/cognitive/__init__.py +1 -1
- package/src/cognitive/_search.py +39 -19
- package/src/dashboard/app.py +264 -0
- package/src/dashboard/templates/base.html +4 -0
- package/src/dashboard/templates/dashboard.html +59 -1
- package/src/dashboard/templates/protocol.html +199 -0
- package/src/db/__init__.py +23 -1
- package/src/db/_learnings.py +31 -4
- package/src/db/_personal_scripts.py +12 -0
- package/src/db/_protocol.py +303 -0
- package/src/db/_schema.py +248 -0
- package/src/db/_watchers.py +173 -0
- package/src/db/_workflow.py +952 -0
- package/src/doctor/providers/runtime.py +1095 -3
- package/src/evolution_cycle.py +62 -0
- package/src/hook_guardrails.py +308 -0
- package/src/hooks/protocol-guardrail.sh +10 -0
- package/src/nexo_sdk.py +103 -0
- package/src/plugins/cognitive_memory.py +18 -0
- package/src/plugins/cortex.py +55 -35
- package/src/plugins/guard.py +132 -16
- package/src/plugins/protocol.py +911 -0
- package/src/plugins/schedule.py +40 -6
- package/src/plugins/simple_api.py +103 -0
- package/src/plugins/skills.py +67 -0
- package/src/plugins/state_watchers.py +79 -0
- package/src/plugins/workflow.py +588 -0
- package/src/public_contribution.py +86 -12
- package/src/script_registry.py +142 -0
- package/src/scripts/deep-sleep/apply_findings.py +482 -2
- package/src/scripts/deep-sleep/collect.py +49 -4
- package/src/scripts/nexo-agent-run.py +2 -0
- package/src/scripts/nexo-daily-self-audit.py +843 -5
- package/src/scripts/nexo-evolution-run.py +343 -1
- package/src/server.py +92 -6
- package/src/skills_runtime.py +151 -0
- package/src/state_watchers_runtime.py +334 -0
- package/src/tools_learnings.py +345 -7
- package/src/tools_sessions.py +183 -0
- package/templates/CLAUDE.md.template +9 -1
- package/templates/CODEX.AGENTS.md.template +10 -2
|
@@ -0,0 +1,911 @@
|
|
|
1
|
+
"""Protocol discipline plugin — persistent task contracts for NEXO."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import hashlib
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
from db import (
|
|
12
|
+
close_protocol_task,
|
|
13
|
+
create_followup,
|
|
14
|
+
create_protocol_debt,
|
|
15
|
+
create_protocol_task,
|
|
16
|
+
get_db,
|
|
17
|
+
get_protocol_task,
|
|
18
|
+
list_workflow_goals,
|
|
19
|
+
list_workflow_runs,
|
|
20
|
+
list_protocol_debts,
|
|
21
|
+
log_change,
|
|
22
|
+
resolve_protocol_debts,
|
|
23
|
+
)
|
|
24
|
+
from plugins.cortex import evaluate_cortex_state
|
|
25
|
+
from plugins.guard import handle_guard_check
|
|
26
|
+
from tools_sessions import handle_heartbeat
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
ACTION_TASKS = {"edit", "execute", "delegate"}
|
|
30
|
+
RESPONSE_TASKS = {"answer", "analyze"}
|
|
31
|
+
HIGH_STAKES_KEYWORDS = {
|
|
32
|
+
"medical",
|
|
33
|
+
"legal",
|
|
34
|
+
"financial",
|
|
35
|
+
"billing",
|
|
36
|
+
"invoice",
|
|
37
|
+
"payment",
|
|
38
|
+
"credential",
|
|
39
|
+
"password",
|
|
40
|
+
"security",
|
|
41
|
+
"production",
|
|
42
|
+
"deploy",
|
|
43
|
+
"release",
|
|
44
|
+
"delete",
|
|
45
|
+
"migration",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _parse_list(value) -> list[str]:
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
52
|
+
if not value:
|
|
53
|
+
return []
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
stripped = value.strip()
|
|
56
|
+
if not stripped:
|
|
57
|
+
return []
|
|
58
|
+
try:
|
|
59
|
+
parsed = json.loads(stripped)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
parsed = None
|
|
62
|
+
if isinstance(parsed, list):
|
|
63
|
+
return [str(item).strip() for item in parsed if str(item).strip()]
|
|
64
|
+
return [item.strip() for item in stripped.split(",") if item.strip()]
|
|
65
|
+
return [str(value).strip()]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_bool(value) -> bool:
|
|
69
|
+
if isinstance(value, bool):
|
|
70
|
+
return value
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
return value.strip().lower() in {"1", "true", "yes", "y", "on"}
|
|
73
|
+
return bool(value)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _detect_high_stakes(*parts: str) -> bool:
|
|
77
|
+
combined = " ".join((part or "").strip().lower() for part in parts if part)
|
|
78
|
+
return any(keyword in combined for keyword in HIGH_STAKES_KEYWORDS)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def evaluate_response_confidence(
|
|
82
|
+
*,
|
|
83
|
+
goal: str,
|
|
84
|
+
task_type: str,
|
|
85
|
+
area: str = "",
|
|
86
|
+
context_hint: str = "",
|
|
87
|
+
constraints=None,
|
|
88
|
+
evidence_refs=None,
|
|
89
|
+
unknowns=None,
|
|
90
|
+
verification_step: str = "",
|
|
91
|
+
stakes: str = "",
|
|
92
|
+
) -> dict:
|
|
93
|
+
evidence_refs = _parse_list(evidence_refs)
|
|
94
|
+
unknowns = _parse_list(unknowns)
|
|
95
|
+
constraints = _parse_list(constraints)
|
|
96
|
+
explicit_stakes = (stakes or "").strip().lower()
|
|
97
|
+
high_stakes = explicit_stakes == "high" or _detect_high_stakes(
|
|
98
|
+
goal,
|
|
99
|
+
area,
|
|
100
|
+
context_hint,
|
|
101
|
+
" ".join(constraints),
|
|
102
|
+
explicit_stakes,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
reasons: list[str] = []
|
|
106
|
+
score = 85
|
|
107
|
+
if unknowns:
|
|
108
|
+
score -= 35
|
|
109
|
+
reasons.append(f"{len(unknowns)} unknown(s) still unresolved")
|
|
110
|
+
if not evidence_refs:
|
|
111
|
+
score -= 25
|
|
112
|
+
reasons.append("no evidence_refs supplied")
|
|
113
|
+
if not verification_step.strip():
|
|
114
|
+
score -= 10
|
|
115
|
+
reasons.append("no verification_step defined")
|
|
116
|
+
if high_stakes:
|
|
117
|
+
score -= 20
|
|
118
|
+
reasons.append("high-stakes context detected")
|
|
119
|
+
|
|
120
|
+
mode = "answer"
|
|
121
|
+
if task_type in RESPONSE_TASKS:
|
|
122
|
+
if high_stakes and (unknowns or not evidence_refs):
|
|
123
|
+
mode = "defer"
|
|
124
|
+
elif unknowns:
|
|
125
|
+
mode = "ask"
|
|
126
|
+
elif high_stakes or not evidence_refs or not verification_step.strip():
|
|
127
|
+
mode = "verify"
|
|
128
|
+
|
|
129
|
+
next_action = {
|
|
130
|
+
"answer": "You may answer directly, but stay within the evidence you actually have.",
|
|
131
|
+
"verify": "Verify the claim with concrete evidence before answering.",
|
|
132
|
+
"ask": "Ask for the missing information instead of guessing.",
|
|
133
|
+
"defer": "Do not answer yet. Defer until you have evidence and a verification path.",
|
|
134
|
+
}[mode]
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
"mode": mode,
|
|
138
|
+
"confidence": max(0, min(100, score)),
|
|
139
|
+
"high_stakes": high_stakes,
|
|
140
|
+
"reasons": reasons,
|
|
141
|
+
"next_action": next_action,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _guard_excerpt(text: str, max_lines: int = 12) -> str:
|
|
146
|
+
lines = [line for line in (text or "").splitlines() if line.strip()]
|
|
147
|
+
return "\n".join(lines[:max_lines])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _extract_guard_blocking_ids(guard_summary: str) -> list[int]:
|
|
151
|
+
ids: list[int] = []
|
|
152
|
+
in_blocking = False
|
|
153
|
+
for raw_line in (guard_summary or "").splitlines():
|
|
154
|
+
line = raw_line.strip()
|
|
155
|
+
if line.startswith("BLOCKING RULES"):
|
|
156
|
+
in_blocking = True
|
|
157
|
+
continue
|
|
158
|
+
if in_blocking and not line:
|
|
159
|
+
break
|
|
160
|
+
if in_blocking:
|
|
161
|
+
match = re.search(r"#(\d+)", line)
|
|
162
|
+
if match:
|
|
163
|
+
ids.append(int(match.group(1)))
|
|
164
|
+
return ids
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _auto_followup_id() -> str:
|
|
168
|
+
return f"NF-PROTOCOL-{int(time.time())}-{secrets.randbelow(100000)}"
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _ensure_followup(description: str, *, verification: str = "", reasoning: str = "") -> dict:
|
|
172
|
+
conn = get_db()
|
|
173
|
+
row = conn.execute(
|
|
174
|
+
"""SELECT id
|
|
175
|
+
FROM followups
|
|
176
|
+
WHERE status NOT LIKE 'COMPLETED%'
|
|
177
|
+
AND status NOT IN ('DELETED', 'archived', 'blocked', 'waiting')
|
|
178
|
+
AND description = ?
|
|
179
|
+
LIMIT 1""",
|
|
180
|
+
(description,),
|
|
181
|
+
).fetchone()
|
|
182
|
+
if row:
|
|
183
|
+
return {"id": row["id"], "created": False}
|
|
184
|
+
followup_id = f"NF-PROTOCOL-{hashlib.sha1(description.encode('utf-8')).hexdigest()[:10].upper()}"
|
|
185
|
+
result = create_followup(
|
|
186
|
+
followup_id,
|
|
187
|
+
description,
|
|
188
|
+
verification=verification,
|
|
189
|
+
reasoning=reasoning,
|
|
190
|
+
)
|
|
191
|
+
if result and "error" not in result:
|
|
192
|
+
return {"id": result.get("id", followup_id), "created": True}
|
|
193
|
+
return {"id": "", "created": False, "error": result.get("error", "followup create failed") if isinstance(result, dict) else "followup create failed"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _attention_snapshot(session_id: str) -> dict:
|
|
197
|
+
goals = [goal for goal in list_workflow_goals(include_closed=False, limit=50) if goal.get("session_id") == session_id]
|
|
198
|
+
runs = [run for run in list_workflow_runs(include_closed=False, limit=50) if run.get("session_id") == session_id]
|
|
199
|
+
|
|
200
|
+
active_goals = [goal for goal in goals if goal.get("status") == "active"]
|
|
201
|
+
blocked_goals = [goal for goal in goals if goal.get("status") == "blocked"]
|
|
202
|
+
waiting_runs = [run for run in runs if run.get("status") in {"blocked", "waiting_approval"}]
|
|
203
|
+
|
|
204
|
+
status = "focused"
|
|
205
|
+
warnings: list[str] = []
|
|
206
|
+
recommended_action = "Current focus load is acceptable."
|
|
207
|
+
|
|
208
|
+
if len(active_goals) >= 4 or len(runs) >= 5:
|
|
209
|
+
status = "overloaded"
|
|
210
|
+
warnings.append("Too many active goals or open workflow runs are competing for attention.")
|
|
211
|
+
recommended_action = "Finish, block, or abandon one active goal before opening more execution work."
|
|
212
|
+
elif len(active_goals) >= 2 or len(runs) >= 3 or len(waiting_runs) >= 2:
|
|
213
|
+
status = "split"
|
|
214
|
+
warnings.append("Attention is split across multiple active goals or waiting workflow runs.")
|
|
215
|
+
recommended_action = "Narrow focus and make one next action explicit before expanding scope."
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"status": status,
|
|
219
|
+
"active_goals": len(active_goals),
|
|
220
|
+
"blocked_goals": len(blocked_goals),
|
|
221
|
+
"open_runs": len(runs),
|
|
222
|
+
"waiting_runs": len(waiting_runs),
|
|
223
|
+
"warnings": warnings,
|
|
224
|
+
"recommended_action": recommended_action,
|
|
225
|
+
"top_goal_titles": [goal.get("title", "") for goal in active_goals[:3]],
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _preview_prospective_triggers(goal: str, context_hint: str, files_list: list[str]) -> list[dict]:
|
|
230
|
+
text = " | ".join(part for part in [goal, context_hint, " ".join(files_list)] if part).strip()
|
|
231
|
+
if not text:
|
|
232
|
+
return []
|
|
233
|
+
try:
|
|
234
|
+
import cognitive
|
|
235
|
+
except Exception:
|
|
236
|
+
return []
|
|
237
|
+
try:
|
|
238
|
+
matches = cognitive.preview_triggers(text, use_semantic=False)
|
|
239
|
+
except Exception:
|
|
240
|
+
return []
|
|
241
|
+
return [
|
|
242
|
+
{
|
|
243
|
+
"id": match["id"],
|
|
244
|
+
"pattern": match["pattern"],
|
|
245
|
+
"action": match["action"],
|
|
246
|
+
"context": match.get("context", ""),
|
|
247
|
+
"match_type": match.get("match_type", "keyword"),
|
|
248
|
+
}
|
|
249
|
+
for match in matches
|
|
250
|
+
]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _create_preventive_followup(goal: str, *, attention: dict, warnings: list[dict]) -> dict | None:
|
|
254
|
+
warning_lines: list[str] = []
|
|
255
|
+
for match in warnings[:2]:
|
|
256
|
+
action = str(match.get("action") or "").strip()
|
|
257
|
+
if action:
|
|
258
|
+
warning_lines.append(action[:120])
|
|
259
|
+
if attention.get("warnings"):
|
|
260
|
+
warning_lines.append(str(attention["warnings"][0])[:120])
|
|
261
|
+
warning_lines = [line for idx, line in enumerate(warning_lines) if line and line not in warning_lines[:idx]]
|
|
262
|
+
if not warning_lines:
|
|
263
|
+
return None
|
|
264
|
+
description = (
|
|
265
|
+
f"Preventive followup before continuing '{goal[:90]}': "
|
|
266
|
+
+ " | ".join(warning_lines[:3])
|
|
267
|
+
)
|
|
268
|
+
reasoning = (
|
|
269
|
+
"Created automatically during task_open because NEXO detected pre-failure warning signals "
|
|
270
|
+
"before execution started."
|
|
271
|
+
)
|
|
272
|
+
verification = (
|
|
273
|
+
"Pre-failure warning resolved or explicitly acknowledged through durable goals/workflows before continuing"
|
|
274
|
+
)
|
|
275
|
+
return _ensure_followup(description, verification=verification, reasoning=reasoning)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _create_missing_learning_followup(task: dict, task_id: str, effective_files: list[str]) -> dict:
|
|
279
|
+
target = ", ".join(effective_files[:3]) if effective_files else (task.get("goal", "")[:120] or task_id)
|
|
280
|
+
description = (
|
|
281
|
+
f"Capture reusable learning from corrected task {task_id}: "
|
|
282
|
+
f"turn the fix around {target} into one canonical learning and supersede conflicting rules if needed."
|
|
283
|
+
)
|
|
284
|
+
reasoning = (
|
|
285
|
+
f"Protocol task {task_id} was marked as corrected but closed without a reusable learning. "
|
|
286
|
+
f"Prevent losing the fix or leaving contradictory active rules behind."
|
|
287
|
+
)
|
|
288
|
+
return create_followup(
|
|
289
|
+
(_auto_followup_id()).strip(),
|
|
290
|
+
description,
|
|
291
|
+
verification="Learning captured and conflicting rule lifecycle resolved",
|
|
292
|
+
reasoning=reasoning,
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _capture_learning(
|
|
297
|
+
task: dict,
|
|
298
|
+
task_id: str,
|
|
299
|
+
effective_files: list[str],
|
|
300
|
+
*,
|
|
301
|
+
category: str,
|
|
302
|
+
title: str,
|
|
303
|
+
content: str,
|
|
304
|
+
reasoning: str,
|
|
305
|
+
priority: str = "high",
|
|
306
|
+
) -> dict:
|
|
307
|
+
from tools_learnings import find_conflicting_active_learning, handle_learning_add
|
|
308
|
+
|
|
309
|
+
clean_title = (title or "").strip()[:120]
|
|
310
|
+
clean_content = (content or "").strip()
|
|
311
|
+
clean_reasoning = (reasoning or f"Captured from protocol task {task_id}").strip()
|
|
312
|
+
applies_to = ",".join(effective_files)
|
|
313
|
+
if not clean_title or not clean_content:
|
|
314
|
+
return {"ok": False, "error": "insufficient context for learning capture"}
|
|
315
|
+
|
|
316
|
+
conflicting = find_conflicting_active_learning(
|
|
317
|
+
category=category,
|
|
318
|
+
title=clean_title,
|
|
319
|
+
content=clean_content,
|
|
320
|
+
applies_to=applies_to,
|
|
321
|
+
)
|
|
322
|
+
supersedes_id = int(conflicting["id"]) if conflicting else 0
|
|
323
|
+
response = handle_learning_add(
|
|
324
|
+
category=category,
|
|
325
|
+
title=clean_title,
|
|
326
|
+
content=clean_content,
|
|
327
|
+
reasoning=clean_reasoning,
|
|
328
|
+
applies_to=applies_to,
|
|
329
|
+
priority=priority,
|
|
330
|
+
supersedes_id=supersedes_id,
|
|
331
|
+
)
|
|
332
|
+
match = re.search(r"Learning #(\d+) added", response)
|
|
333
|
+
if match:
|
|
334
|
+
return {
|
|
335
|
+
"ok": True,
|
|
336
|
+
"id": int(match.group(1)),
|
|
337
|
+
"response": response,
|
|
338
|
+
"superseded_id": supersedes_id or None,
|
|
339
|
+
}
|
|
340
|
+
return {
|
|
341
|
+
"ok": False,
|
|
342
|
+
"error": response,
|
|
343
|
+
"conflicting_learning_id": supersedes_id or None,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _auto_capture_learning(task: dict, task_id: str, effective_files: list[str], *,
|
|
348
|
+
clean_evidence: str, change_summary: str, change_why: str,
|
|
349
|
+
outcome_notes: str) -> dict:
|
|
350
|
+
title_seed = (change_summary or task.get("goal") or f"Protocol correction {task_id}").strip()
|
|
351
|
+
content_parts = []
|
|
352
|
+
if change_why.strip():
|
|
353
|
+
content_parts.append(change_why.strip())
|
|
354
|
+
elif task.get("goal"):
|
|
355
|
+
content_parts.append(str(task.get("goal", "")).strip())
|
|
356
|
+
if outcome_notes.strip():
|
|
357
|
+
content_parts.append(outcome_notes.strip())
|
|
358
|
+
if clean_evidence.strip():
|
|
359
|
+
content_parts.append(f"Verification evidence: {clean_evidence.strip()}")
|
|
360
|
+
if effective_files:
|
|
361
|
+
content_parts.append(f"Affected files: {', '.join(effective_files[:5])}")
|
|
362
|
+
|
|
363
|
+
title = title_seed[:120]
|
|
364
|
+
content = " ".join(part for part in content_parts if part).strip()
|
|
365
|
+
return _capture_learning(
|
|
366
|
+
task,
|
|
367
|
+
task_id,
|
|
368
|
+
effective_files,
|
|
369
|
+
category=(task.get("area") or "nexo-ops"),
|
|
370
|
+
title=title,
|
|
371
|
+
content=content,
|
|
372
|
+
reasoning=f"Auto-captured from corrected protocol task {task_id}.",
|
|
373
|
+
priority="high",
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _record_debt(session_id: str, task_id: str, debt_type: str, *, severity: str, evidence: str, debts: list[dict]):
|
|
378
|
+
debt = create_protocol_debt(
|
|
379
|
+
session_id,
|
|
380
|
+
debt_type,
|
|
381
|
+
severity=severity,
|
|
382
|
+
task_id=task_id,
|
|
383
|
+
evidence=evidence,
|
|
384
|
+
)
|
|
385
|
+
debts.append(
|
|
386
|
+
{
|
|
387
|
+
"id": debt.get("id"),
|
|
388
|
+
"debt_type": debt_type,
|
|
389
|
+
"severity": severity,
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def handle_confidence_check(
|
|
395
|
+
goal: str,
|
|
396
|
+
task_type: str = "answer",
|
|
397
|
+
area: str = "",
|
|
398
|
+
context_hint: str = "",
|
|
399
|
+
constraints: str = "[]",
|
|
400
|
+
evidence_refs: str = "[]",
|
|
401
|
+
unknowns: str = "[]",
|
|
402
|
+
verification_step: str = "",
|
|
403
|
+
stakes: str = "",
|
|
404
|
+
) -> str:
|
|
405
|
+
"""Return the metacognitive response mode: answer, verify, ask, or defer."""
|
|
406
|
+
clean_goal = (goal or "").strip()
|
|
407
|
+
if not clean_goal:
|
|
408
|
+
return json.dumps({"ok": False, "error": "goal is required"}, ensure_ascii=False, indent=2)
|
|
409
|
+
clean_type = task_type if task_type in {"answer", "analyze", "edit", "execute", "delegate"} else "answer"
|
|
410
|
+
result = evaluate_response_confidence(
|
|
411
|
+
goal=clean_goal,
|
|
412
|
+
task_type=clean_type,
|
|
413
|
+
area=(area or "").strip(),
|
|
414
|
+
context_hint=(context_hint or "").strip(),
|
|
415
|
+
constraints=_parse_list(constraints),
|
|
416
|
+
evidence_refs=_parse_list(evidence_refs),
|
|
417
|
+
unknowns=_parse_list(unknowns),
|
|
418
|
+
verification_step=(verification_step or "").strip(),
|
|
419
|
+
stakes=(stakes or "").strip(),
|
|
420
|
+
)
|
|
421
|
+
return json.dumps({"ok": True, **result}, ensure_ascii=False, indent=2)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def handle_task_open(
|
|
425
|
+
sid: str,
|
|
426
|
+
goal: str,
|
|
427
|
+
task_type: str = "answer",
|
|
428
|
+
area: str = "",
|
|
429
|
+
files: str = "",
|
|
430
|
+
project_hint: str = "",
|
|
431
|
+
plan: str = "[]",
|
|
432
|
+
known_facts: str = "[]",
|
|
433
|
+
unknowns: str = "[]",
|
|
434
|
+
constraints: str = "[]",
|
|
435
|
+
evidence_refs: str = "[]",
|
|
436
|
+
verification_step: str = "",
|
|
437
|
+
stakes: str = "",
|
|
438
|
+
context_hint: str = "",
|
|
439
|
+
) -> str:
|
|
440
|
+
"""Open a protocol task with heartbeat, guard, rules, and Cortex already captured.
|
|
441
|
+
|
|
442
|
+
Use this as the default entry point for any non-trivial work. For edit/execute/delegate
|
|
443
|
+
tasks it becomes the contract that later must be closed with `nexo_task_close`.
|
|
444
|
+
"""
|
|
445
|
+
clean_goal = (goal or "").strip()
|
|
446
|
+
if not sid.strip():
|
|
447
|
+
return json.dumps({"ok": False, "error": "sid is required"}, ensure_ascii=False, indent=2)
|
|
448
|
+
if not clean_goal:
|
|
449
|
+
return json.dumps({"ok": False, "error": "goal is required"}, ensure_ascii=False, indent=2)
|
|
450
|
+
|
|
451
|
+
clean_type = task_type if task_type in {"answer", "analyze", "edit", "execute", "delegate"} else "answer"
|
|
452
|
+
files_list = _parse_list(files)
|
|
453
|
+
state = {
|
|
454
|
+
"goal": clean_goal,
|
|
455
|
+
"task_type": clean_type,
|
|
456
|
+
"plan": _parse_list(plan),
|
|
457
|
+
"known_facts": _parse_list(known_facts),
|
|
458
|
+
"unknowns": _parse_list(unknowns),
|
|
459
|
+
"constraints": _parse_list(constraints),
|
|
460
|
+
"evidence_refs": _parse_list(evidence_refs),
|
|
461
|
+
"verification_step": (verification_step or "").strip(),
|
|
462
|
+
}
|
|
463
|
+
response_contract = evaluate_response_confidence(
|
|
464
|
+
goal=clean_goal,
|
|
465
|
+
task_type=clean_type,
|
|
466
|
+
area=area.strip(),
|
|
467
|
+
context_hint=context_hint.strip(),
|
|
468
|
+
constraints=state["constraints"],
|
|
469
|
+
evidence_refs=state["evidence_refs"],
|
|
470
|
+
unknowns=state["unknowns"],
|
|
471
|
+
verification_step=state["verification_step"],
|
|
472
|
+
stakes=stakes,
|
|
473
|
+
)
|
|
474
|
+
heartbeat_result = handle_heartbeat(sid, clean_goal[:120], context_hint=context_hint[:500])
|
|
475
|
+
attention = _attention_snapshot(sid.strip())
|
|
476
|
+
anticipatory_warnings = _preview_prospective_triggers(clean_goal, context_hint.strip(), files_list)
|
|
477
|
+
preventive_followup = None
|
|
478
|
+
|
|
479
|
+
guard_summary = ""
|
|
480
|
+
guard_has_blocking = False
|
|
481
|
+
opened_with_guard = False
|
|
482
|
+
debts_created: list[dict] = []
|
|
483
|
+
if clean_type in ACTION_TASKS and (files_list or area.strip()):
|
|
484
|
+
opened_with_guard = True
|
|
485
|
+
guard_summary = handle_guard_check(files=",".join(files_list), area=area.strip())
|
|
486
|
+
guard_has_blocking = (
|
|
487
|
+
"[BLOCKING]" in guard_summary
|
|
488
|
+
or "WARNINGS — resolve before editing" in guard_summary
|
|
489
|
+
or "BLOCKING RULES" in guard_summary
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
cortex = evaluate_cortex_state(state)
|
|
493
|
+
must_verify = clean_type in ACTION_TASKS or response_contract["mode"] == "verify"
|
|
494
|
+
must_change_log = clean_type in {"edit", "execute"} and bool(files_list)
|
|
495
|
+
must_learning_if_corrected = True
|
|
496
|
+
must_write_diary_on_close = clean_type in ACTION_TASKS
|
|
497
|
+
|
|
498
|
+
task = create_protocol_task(
|
|
499
|
+
sid,
|
|
500
|
+
clean_goal,
|
|
501
|
+
task_type=clean_type,
|
|
502
|
+
area=area.strip(),
|
|
503
|
+
project_hint=project_hint.strip(),
|
|
504
|
+
context_hint=context_hint.strip(),
|
|
505
|
+
files=files_list,
|
|
506
|
+
plan=state["plan"],
|
|
507
|
+
known_facts=state["known_facts"],
|
|
508
|
+
unknowns=state["unknowns"],
|
|
509
|
+
constraints=state["constraints"],
|
|
510
|
+
evidence_refs=state["evidence_refs"],
|
|
511
|
+
verification_step=state["verification_step"],
|
|
512
|
+
cortex_mode=cortex["mode"],
|
|
513
|
+
cortex_check_id=cortex["check_id"],
|
|
514
|
+
cortex_blocked_reason=cortex.get("blocked_reason") or "",
|
|
515
|
+
cortex_warnings=cortex.get("warnings") or [],
|
|
516
|
+
cortex_rules=cortex.get("injected_rules") or [],
|
|
517
|
+
opened_with_guard=opened_with_guard,
|
|
518
|
+
opened_with_rules=True,
|
|
519
|
+
guard_has_blocking=guard_has_blocking,
|
|
520
|
+
guard_summary=guard_summary,
|
|
521
|
+
must_verify=must_verify,
|
|
522
|
+
must_change_log=must_change_log,
|
|
523
|
+
must_learning_if_corrected=must_learning_if_corrected,
|
|
524
|
+
must_write_diary_on_close=must_write_diary_on_close,
|
|
525
|
+
response_mode=response_contract["mode"],
|
|
526
|
+
response_confidence=response_contract["confidence"],
|
|
527
|
+
response_reasons=response_contract["reasons"],
|
|
528
|
+
response_high_stakes=response_contract["high_stakes"],
|
|
529
|
+
)
|
|
530
|
+
blocking_rule_ids = _extract_guard_blocking_ids(guard_summary) if guard_has_blocking else []
|
|
531
|
+
if guard_has_blocking:
|
|
532
|
+
_record_debt(
|
|
533
|
+
task["session_id"],
|
|
534
|
+
task["task_id"],
|
|
535
|
+
"unacknowledged_guard_blocking",
|
|
536
|
+
severity="error",
|
|
537
|
+
evidence=_guard_excerpt(guard_summary),
|
|
538
|
+
debts=debts_created,
|
|
539
|
+
)
|
|
540
|
+
elif clean_type in ACTION_TASKS and (anticipatory_warnings or attention["status"] in {"split", "overloaded"}):
|
|
541
|
+
preventive_followup = _create_preventive_followup(
|
|
542
|
+
clean_goal,
|
|
543
|
+
attention=attention,
|
|
544
|
+
warnings=anticipatory_warnings,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
if guard_has_blocking:
|
|
548
|
+
next_action = "Resolve the blocking guard warnings before editing."
|
|
549
|
+
elif response_contract["mode"] == "defer":
|
|
550
|
+
next_action = response_contract["next_action"]
|
|
551
|
+
elif response_contract["mode"] == "ask" and clean_type in RESPONSE_TASKS:
|
|
552
|
+
next_action = response_contract["next_action"]
|
|
553
|
+
elif response_contract["mode"] == "verify" and clean_type in RESPONSE_TASKS:
|
|
554
|
+
next_action = response_contract["next_action"]
|
|
555
|
+
elif attention["status"] == "overloaded":
|
|
556
|
+
next_action = attention["recommended_action"]
|
|
557
|
+
elif anticipatory_warnings:
|
|
558
|
+
next_action = "Review the anticipatory warnings before proceeding."
|
|
559
|
+
elif cortex["mode"] == "ask":
|
|
560
|
+
next_action = "Ask for the missing information before acting."
|
|
561
|
+
elif cortex["mode"] == "propose":
|
|
562
|
+
next_action = "Propose the plan or verification path before acting."
|
|
563
|
+
else:
|
|
564
|
+
next_action = "Proceed with the task and close it with nexo_task_close before claiming completion."
|
|
565
|
+
|
|
566
|
+
response = {
|
|
567
|
+
"ok": True,
|
|
568
|
+
"task_id": task["task_id"],
|
|
569
|
+
"session_id": sid,
|
|
570
|
+
"goal": clean_goal,
|
|
571
|
+
"task_type": clean_type,
|
|
572
|
+
"mode": cortex["mode"],
|
|
573
|
+
"check_id": cortex["check_id"],
|
|
574
|
+
"blocked_reason": cortex.get("blocked_reason"),
|
|
575
|
+
"warnings": cortex.get("warnings") or [],
|
|
576
|
+
"applicable_rules": cortex.get("injected_rules") or [],
|
|
577
|
+
"guard": {
|
|
578
|
+
"ran": opened_with_guard,
|
|
579
|
+
"has_blocking": guard_has_blocking,
|
|
580
|
+
"blocking_rule_ids": blocking_rule_ids,
|
|
581
|
+
"summary_excerpt": _guard_excerpt(guard_summary),
|
|
582
|
+
},
|
|
583
|
+
"attention": attention,
|
|
584
|
+
"anticipation": {
|
|
585
|
+
"warning_count": len(anticipatory_warnings),
|
|
586
|
+
"warnings": anticipatory_warnings,
|
|
587
|
+
"recommended_action": (
|
|
588
|
+
"Review these anticipatory warnings before proceeding."
|
|
589
|
+
if anticipatory_warnings
|
|
590
|
+
else "No anticipatory warnings."
|
|
591
|
+
),
|
|
592
|
+
},
|
|
593
|
+
"response_contract": response_contract,
|
|
594
|
+
"contract": {
|
|
595
|
+
"must_verify": must_verify,
|
|
596
|
+
"must_change_log": must_change_log,
|
|
597
|
+
"must_learning_if_corrected": must_learning_if_corrected,
|
|
598
|
+
"must_write_diary_on_close": must_write_diary_on_close,
|
|
599
|
+
},
|
|
600
|
+
"session_touch": heartbeat_result.splitlines()[0] if heartbeat_result else "",
|
|
601
|
+
"open_debts": debts_created,
|
|
602
|
+
"preventive_followup": preventive_followup,
|
|
603
|
+
"next_action": next_action,
|
|
604
|
+
}
|
|
605
|
+
return json.dumps(response, ensure_ascii=False, indent=2)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def handle_task_close(
|
|
609
|
+
sid: str,
|
|
610
|
+
task_id: str,
|
|
611
|
+
outcome: str,
|
|
612
|
+
evidence: str = "",
|
|
613
|
+
files_changed: str = "",
|
|
614
|
+
correction_happened: bool = False,
|
|
615
|
+
change_summary: str = "",
|
|
616
|
+
change_why: str = "",
|
|
617
|
+
change_risks: str = "",
|
|
618
|
+
change_verify: str = "",
|
|
619
|
+
triggered_by: str = "",
|
|
620
|
+
followup_needed: bool = False,
|
|
621
|
+
followup_id: str = "",
|
|
622
|
+
followup_description: str = "",
|
|
623
|
+
followup_date: str = "",
|
|
624
|
+
followup_verification: str = "",
|
|
625
|
+
followup_reasoning: str = "",
|
|
626
|
+
learning_category: str = "",
|
|
627
|
+
learning_title: str = "",
|
|
628
|
+
learning_content: str = "",
|
|
629
|
+
learning_reasoning: str = "",
|
|
630
|
+
outcome_notes: str = "",
|
|
631
|
+
) -> str:
|
|
632
|
+
"""Close a protocol task and automatically record the required discipline artifacts."""
|
|
633
|
+
task = get_protocol_task(task_id.strip())
|
|
634
|
+
if not task:
|
|
635
|
+
return json.dumps({"ok": False, "error": f"Unknown task_id: {task_id}"}, ensure_ascii=False, indent=2)
|
|
636
|
+
if sid.strip() and task.get("session_id") and task["session_id"] != sid.strip():
|
|
637
|
+
return json.dumps(
|
|
638
|
+
{"ok": False, "error": f"Task {task_id} belongs to {task['session_id']}, not {sid}"},
|
|
639
|
+
ensure_ascii=False,
|
|
640
|
+
indent=2,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
clean_outcome = outcome if outcome in {"done", "partial", "blocked", "failed", "cancelled"} else "failed"
|
|
644
|
+
clean_evidence = (evidence or "").strip()
|
|
645
|
+
files_changed_list = _parse_list(files_changed)
|
|
646
|
+
planned_files = _parse_list(task.get("files") or "[]")
|
|
647
|
+
effective_files = files_changed_list or planned_files
|
|
648
|
+
correction = _parse_bool(correction_happened)
|
|
649
|
+
followup_required = _parse_bool(followup_needed)
|
|
650
|
+
|
|
651
|
+
change_log_id = None
|
|
652
|
+
learning_id = None
|
|
653
|
+
created_followup_id = ""
|
|
654
|
+
debts_created: list[dict] = []
|
|
655
|
+
|
|
656
|
+
if task.get("must_verify") and clean_outcome == "done":
|
|
657
|
+
if clean_evidence:
|
|
658
|
+
resolve_protocol_debts(
|
|
659
|
+
task_id=task_id,
|
|
660
|
+
debt_types=["claimed_done_without_evidence"],
|
|
661
|
+
resolution="Verification evidence supplied during task_close",
|
|
662
|
+
)
|
|
663
|
+
else:
|
|
664
|
+
_record_debt(
|
|
665
|
+
task["session_id"],
|
|
666
|
+
task_id,
|
|
667
|
+
"claimed_done_without_evidence",
|
|
668
|
+
severity="error",
|
|
669
|
+
evidence=f"Task closed as done without evidence. Goal: {task.get('goal','')}",
|
|
670
|
+
debts=debts_created,
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if task.get("must_change_log") and clean_outcome in {"done", "partial", "failed"}:
|
|
674
|
+
if effective_files:
|
|
675
|
+
change = log_change(
|
|
676
|
+
task["session_id"],
|
|
677
|
+
", ".join(effective_files),
|
|
678
|
+
(change_summary or f"Protocol task {task_id}: {task.get('goal', '')}")[:500],
|
|
679
|
+
(change_why or task.get("goal", ""))[:500],
|
|
680
|
+
(triggered_by or task_id)[:200],
|
|
681
|
+
task.get("area", "")[:200],
|
|
682
|
+
(change_risks or "")[:500],
|
|
683
|
+
(change_verify or clean_evidence)[:500],
|
|
684
|
+
)
|
|
685
|
+
if "error" in change:
|
|
686
|
+
_record_debt(
|
|
687
|
+
task["session_id"],
|
|
688
|
+
task_id,
|
|
689
|
+
"missing_change_log",
|
|
690
|
+
severity="warn",
|
|
691
|
+
evidence=f"change_log failed: {change['error']}",
|
|
692
|
+
debts=debts_created,
|
|
693
|
+
)
|
|
694
|
+
else:
|
|
695
|
+
change_log_id = change.get("id")
|
|
696
|
+
resolve_protocol_debts(
|
|
697
|
+
task_id=task_id,
|
|
698
|
+
debt_types=["missing_change_log"],
|
|
699
|
+
resolution="Change log created by nexo_task_close",
|
|
700
|
+
)
|
|
701
|
+
else:
|
|
702
|
+
_record_debt(
|
|
703
|
+
task["session_id"],
|
|
704
|
+
task_id,
|
|
705
|
+
"missing_change_log",
|
|
706
|
+
severity="warn",
|
|
707
|
+
evidence="Task required change_log but no changed files were supplied or recorded.",
|
|
708
|
+
debts=debts_created,
|
|
709
|
+
)
|
|
710
|
+
|
|
711
|
+
if correction:
|
|
712
|
+
if (learning_title or "").strip() and (learning_content or "").strip():
|
|
713
|
+
learning = _capture_learning(
|
|
714
|
+
task,
|
|
715
|
+
task_id,
|
|
716
|
+
effective_files,
|
|
717
|
+
category=(learning_category or task.get("area") or "nexo-ops"),
|
|
718
|
+
title=learning_title.strip(),
|
|
719
|
+
content=learning_content.strip(),
|
|
720
|
+
reasoning=(learning_reasoning or f"Captured from protocol task {task_id}").strip(),
|
|
721
|
+
priority="high",
|
|
722
|
+
)
|
|
723
|
+
if not learning.get("ok"):
|
|
724
|
+
_record_debt(
|
|
725
|
+
task["session_id"],
|
|
726
|
+
task_id,
|
|
727
|
+
"missing_learning_after_correction",
|
|
728
|
+
severity="warn",
|
|
729
|
+
evidence=f"learning_add failed: {learning.get('error', 'unknown error')}",
|
|
730
|
+
debts=debts_created,
|
|
731
|
+
)
|
|
732
|
+
else:
|
|
733
|
+
learning_id = learning.get("id")
|
|
734
|
+
resolve_protocol_debts(
|
|
735
|
+
task_id=task_id,
|
|
736
|
+
debt_types=["missing_learning_after_correction"],
|
|
737
|
+
resolution="Learning captured during task_close",
|
|
738
|
+
)
|
|
739
|
+
if learning.get("superseded_id"):
|
|
740
|
+
resolve_protocol_debts(
|
|
741
|
+
task_id=task_id,
|
|
742
|
+
debt_types=["unacknowledged_guard_blocking"],
|
|
743
|
+
resolution=f"Guard blocking rule superseded by canonical learning #{learning_id}",
|
|
744
|
+
)
|
|
745
|
+
else:
|
|
746
|
+
auto_learning = _auto_capture_learning(
|
|
747
|
+
task,
|
|
748
|
+
task_id,
|
|
749
|
+
effective_files,
|
|
750
|
+
clean_evidence=clean_evidence,
|
|
751
|
+
change_summary=change_summary,
|
|
752
|
+
change_why=change_why,
|
|
753
|
+
outcome_notes=outcome_notes,
|
|
754
|
+
)
|
|
755
|
+
if auto_learning.get("ok"):
|
|
756
|
+
learning_id = auto_learning.get("id")
|
|
757
|
+
resolve_protocol_debts(
|
|
758
|
+
task_id=task_id,
|
|
759
|
+
debt_types=["missing_learning_after_correction"],
|
|
760
|
+
resolution="Learning auto-captured during task_close",
|
|
761
|
+
)
|
|
762
|
+
if auto_learning.get("superseded_id"):
|
|
763
|
+
resolve_protocol_debts(
|
|
764
|
+
task_id=task_id,
|
|
765
|
+
debt_types=["unacknowledged_guard_blocking"],
|
|
766
|
+
resolution=f"Guard blocking rule superseded by canonical learning #{learning_id}",
|
|
767
|
+
)
|
|
768
|
+
else:
|
|
769
|
+
_record_debt(
|
|
770
|
+
task["session_id"],
|
|
771
|
+
task_id,
|
|
772
|
+
"missing_learning_after_correction",
|
|
773
|
+
severity="warn",
|
|
774
|
+
evidence=f"Task was marked as corrected but reusable learning capture failed: {auto_learning.get('error', 'missing payload')}",
|
|
775
|
+
debts=debts_created,
|
|
776
|
+
)
|
|
777
|
+
auto_followup = _create_missing_learning_followup(task, task_id, effective_files)
|
|
778
|
+
if "error" not in auto_followup and not created_followup_id:
|
|
779
|
+
created_followup_id = auto_followup.get("id", "")
|
|
780
|
+
|
|
781
|
+
if followup_required:
|
|
782
|
+
description = (followup_description or "").strip()
|
|
783
|
+
if description:
|
|
784
|
+
followup = create_followup(
|
|
785
|
+
(followup_id or _auto_followup_id()).strip(),
|
|
786
|
+
description,
|
|
787
|
+
date=(followup_date or None),
|
|
788
|
+
verification=(followup_verification or "").strip(),
|
|
789
|
+
reasoning=(followup_reasoning or f"Created from protocol task {task_id}").strip(),
|
|
790
|
+
)
|
|
791
|
+
if "error" in followup:
|
|
792
|
+
_record_debt(
|
|
793
|
+
task["session_id"],
|
|
794
|
+
task_id,
|
|
795
|
+
"missing_followup_payload",
|
|
796
|
+
severity="warn",
|
|
797
|
+
evidence=f"followup create failed: {followup['error']}",
|
|
798
|
+
debts=debts_created,
|
|
799
|
+
)
|
|
800
|
+
else:
|
|
801
|
+
created_followup_id = followup.get("id", "")
|
|
802
|
+
else:
|
|
803
|
+
_record_debt(
|
|
804
|
+
task["session_id"],
|
|
805
|
+
task_id,
|
|
806
|
+
"missing_followup_payload",
|
|
807
|
+
severity="warn",
|
|
808
|
+
evidence="followup_needed=true but no followup_description was supplied.",
|
|
809
|
+
debts=debts_created,
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
task = close_protocol_task(
|
|
813
|
+
task_id,
|
|
814
|
+
outcome=clean_outcome,
|
|
815
|
+
evidence=clean_evidence,
|
|
816
|
+
files_changed=effective_files,
|
|
817
|
+
correction_happened=correction,
|
|
818
|
+
change_log_id=change_log_id,
|
|
819
|
+
learning_id=learning_id,
|
|
820
|
+
followup_id=created_followup_id,
|
|
821
|
+
outcome_notes=outcome_notes,
|
|
822
|
+
)
|
|
823
|
+
open_debts = list_protocol_debts(status="open", task_id=task_id, limit=20)
|
|
824
|
+
|
|
825
|
+
response = {
|
|
826
|
+
"ok": True,
|
|
827
|
+
"task_id": task_id,
|
|
828
|
+
"outcome": clean_outcome,
|
|
829
|
+
"change_log_id": change_log_id,
|
|
830
|
+
"learning_id": learning_id,
|
|
831
|
+
"followup_id": created_followup_id,
|
|
832
|
+
"debts_created": debts_created,
|
|
833
|
+
"open_debts": [
|
|
834
|
+
{
|
|
835
|
+
"id": debt.get("id"),
|
|
836
|
+
"debt_type": debt.get("debt_type"),
|
|
837
|
+
"severity": debt.get("severity"),
|
|
838
|
+
}
|
|
839
|
+
for debt in open_debts
|
|
840
|
+
],
|
|
841
|
+
"status": "clean" if not open_debts else "debt-open",
|
|
842
|
+
"next_action": (
|
|
843
|
+
"Do not claim completion yet. Resolve the open protocol debt first."
|
|
844
|
+
if open_debts else
|
|
845
|
+
"Task closed cleanly."
|
|
846
|
+
),
|
|
847
|
+
}
|
|
848
|
+
return json.dumps(response, ensure_ascii=False, indent=2)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def handle_task_acknowledge_guard(
|
|
852
|
+
sid: str,
|
|
853
|
+
task_id: str,
|
|
854
|
+
learning_ids: str = "",
|
|
855
|
+
note: str = "",
|
|
856
|
+
) -> str:
|
|
857
|
+
"""Acknowledge blocking guard rules for an open protocol task."""
|
|
858
|
+
task = get_protocol_task(task_id.strip())
|
|
859
|
+
if not task:
|
|
860
|
+
return json.dumps({"ok": False, "error": f"Unknown task_id: {task_id}"}, ensure_ascii=False, indent=2)
|
|
861
|
+
if sid.strip() and task.get("session_id") and task["session_id"] != sid.strip():
|
|
862
|
+
return json.dumps(
|
|
863
|
+
{"ok": False, "error": f"Task {task_id} belongs to {task['session_id']}, not {sid}"},
|
|
864
|
+
ensure_ascii=False,
|
|
865
|
+
indent=2,
|
|
866
|
+
)
|
|
867
|
+
if not task.get("guard_has_blocking"):
|
|
868
|
+
return json.dumps(
|
|
869
|
+
{"ok": False, "error": f"Task {task_id} has no blocking guard rules to acknowledge."},
|
|
870
|
+
ensure_ascii=False,
|
|
871
|
+
indent=2,
|
|
872
|
+
)
|
|
873
|
+
|
|
874
|
+
expected = _extract_guard_blocking_ids(task.get("guard_summary") or "")
|
|
875
|
+
provided = sorted({int(item) for item in _parse_list(learning_ids) if str(item).strip().isdigit()})
|
|
876
|
+
if expected and sorted(expected) != provided:
|
|
877
|
+
return json.dumps(
|
|
878
|
+
{
|
|
879
|
+
"ok": False,
|
|
880
|
+
"error": "learning_ids must acknowledge every blocking rule on the task.",
|
|
881
|
+
"expected_ids": expected,
|
|
882
|
+
"provided_ids": provided,
|
|
883
|
+
},
|
|
884
|
+
ensure_ascii=False,
|
|
885
|
+
indent=2,
|
|
886
|
+
)
|
|
887
|
+
|
|
888
|
+
resolved = resolve_protocol_debts(
|
|
889
|
+
task_id=task_id,
|
|
890
|
+
debt_types=["unacknowledged_guard_blocking"],
|
|
891
|
+
resolution=(note or f"Guard rules acknowledged: {provided}").strip(),
|
|
892
|
+
)
|
|
893
|
+
return json.dumps(
|
|
894
|
+
{
|
|
895
|
+
"ok": True,
|
|
896
|
+
"task_id": task_id,
|
|
897
|
+
"acknowledged_rule_ids": provided,
|
|
898
|
+
"resolved_debts": resolved,
|
|
899
|
+
"next_action": "Proceed with the task and close it with nexo_task_close once evidence is available.",
|
|
900
|
+
},
|
|
901
|
+
ensure_ascii=False,
|
|
902
|
+
indent=2,
|
|
903
|
+
)
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
TOOLS = [
|
|
907
|
+
(handle_confidence_check, "nexo_confidence_check", "Decide whether a non-trivial answer should be answered, verified, asked, or deferred before replying."),
|
|
908
|
+
(handle_task_open, "nexo_task_open", "Open a non-trivial task with heartbeat, guard, rules, and Cortex captured as one protocol contract."),
|
|
909
|
+
(handle_task_acknowledge_guard, "nexo_task_acknowledge_guard", "Acknowledge blocking guard rules on an open protocol task before proceeding."),
|
|
910
|
+
(handle_task_close, "nexo_task_close", "Close a protocol task, auto-record evidence/change-log/followup artifacts, and open protocol debt when discipline is missing."),
|
|
911
|
+
]
|