feat(500): one end-of-turn request - the completion-section check folds into the reply check
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 53s
CI & Build / integration (push) Successful in 1m7s
CI & Build / Python tests (push) Failing after 1m25s
CI & Build / Build & push image (push) Skipped
The Stop hook sent the finished reply twice: scribe_report_check.sh checked a task-closing reply for the completion sections in shell and reported to /report-check, and scribe_reply_check.sh sent the same reply to /reply-rules for the rule hold. Now the reply goes once. When the turn closed a task the hook adds the close count and ids, and the server runs the section check (services/report_check, the same three patterns) beside the reply hold, folding both into one reason. The report_check adherence log is still written for every checked reply (milestone 409's number). A section hold marks the session, so its rewrite is sent back once with rewrite:true to record how it came out, and is never held. The block reason carries the completion shape's one line and points at list_reply_shapes rather than at the skill. #5496 (step 4 of milestone 500). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+40
-49
@@ -367,28 +367,58 @@ async def reply_rules():
|
||||
the reply asks something), and the reply text against every rule's
|
||||
trigger — the backstop for whatever the earlier arms missed.
|
||||
|
||||
Body: `{"reply": "<text>"}`. Query: `repo` / `project_id` for the scope;
|
||||
ONE END-OF-TURN REQUEST (milestone 500 step 4): a reply that closed tasks
|
||||
is also checked here for the completion sections (services/report_check),
|
||||
which used to be a Stop hook and an endpoint of its own. Both holds fold
|
||||
into one `reason`; `report_check` says what the section check recorded, so
|
||||
the hook knows the rewrite is one to report.
|
||||
|
||||
Body: `{"reply": "<text>", "closed": 1, "closed_task_ids": [41],
|
||||
"rewrite": false}` — the last three only when the turn closed a task.
|
||||
`closed` is the count and decides whether the check runs (a task created
|
||||
already done has no id to list); `rewrite` marks the reply written after a
|
||||
section hold, which is recorded and never held.
|
||||
Query: `repo` / `project_id` for the scope;
|
||||
`held_rule_ids` (opened this session — exempt, as at the act checkpoint);
|
||||
`exclude_rule_ids` (named this session — not re-counted as surfaced);
|
||||
`stopped_rule_ids` (already held a reply or an act this session — a rule
|
||||
holds once, and the per-session cap counts these).
|
||||
|
||||
Returns `reason` — the words the hook blocks with, empty when nothing
|
||||
holds — plus `rule_ids` for the hook's ledger and `moments` reached.
|
||||
holds — plus `rule_ids` for the hook's ledger, `moments` reached and
|
||||
`report_check` (the recorded outcome, or "" when nothing was checked).
|
||||
"""
|
||||
data = await request.get_json(silent=True) or {}
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
reply = str(data.get("reply") or "")
|
||||
# `type(...) is int`, not isinstance: JSON `true` would otherwise read as 1.
|
||||
closed = data.get("closed") if type(data.get("closed")) is int else 0
|
||||
ids = data.get("closed_task_ids")
|
||||
task_ids = [i for i in ids if type(i) is int and i > 0][:20] if isinstance(ids, list) else []
|
||||
rewrite = data.get("rewrite") is True
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
held = await moment_delivery_svc.reply_hold(
|
||||
g.user.id, reply, project_id=project_id,
|
||||
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
|
||||
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
|
||||
stopped=frozenset(_int_list(request.args.get("stopped_rule_ids"))),
|
||||
)
|
||||
|
||||
checked: dict = {}
|
||||
if closed > 0:
|
||||
checked = await report_check_svc.check_reply(
|
||||
g.user.id, reply, task_ids=task_ids, rewrite=rewrite, project_id=project_id or None,
|
||||
)
|
||||
# The rewrite after a hold goes out as written: recorded above, held by nothing.
|
||||
held: dict = {}
|
||||
if not rewrite:
|
||||
held = await moment_delivery_svc.reply_hold(
|
||||
g.user.id, reply, project_id=project_id,
|
||||
exclude=frozenset(_int_list(request.args.get("exclude_rule_ids"))),
|
||||
held=frozenset(_int_list(request.args.get("held_rule_ids"))),
|
||||
stopped=frozenset(_int_list(request.args.get("stopped_rule_ids"))),
|
||||
)
|
||||
reasons = [r for r in (checked.get("reason", ""), held.get("reason", "")) if r]
|
||||
return jsonify({
|
||||
"reason": held.get("reason", ""),
|
||||
"reason": "\n\n".join(reasons),
|
||||
"rule_ids": held.get("rule_ids", []),
|
||||
"moments": held.get("moments", []),
|
||||
"report_check": checked.get("outcome", ""),
|
||||
})
|
||||
|
||||
|
||||
@@ -504,45 +534,6 @@ def _parse_shapes(raw: str) -> list[tuple[str, str]]:
|
||||
return out
|
||||
|
||||
|
||||
@plugin_bp.get("/report-check")
|
||||
@login_required
|
||||
async def report_check():
|
||||
"""Record what a Stop hook found in a reply that closed a task (milestone 409 step 5).
|
||||
|
||||
The hook decides which completion sections the reply lacks — a local check
|
||||
of text it can read — and reports the outcome here. For `blocked` the
|
||||
response carries the `reason` to send the agent back with: the words are
|
||||
the server's, so every client's hook says the same thing (plugin/PACKAGING.md).
|
||||
A hook blocks only on a `reason` it received, which means only on a block
|
||||
that was recorded.
|
||||
|
||||
A GET for the reason every plugin endpoint is one: a read-scoped key must
|
||||
be enough to run the plugin, and this records telemetry the way /retrieve
|
||||
records a retrieval log.
|
||||
|
||||
Query:
|
||||
outcome (str) — passed | blocked | passed_after_rewrite |
|
||||
missing_after_rewrite. Anything else is a 400.
|
||||
missing (opt) — comma-separated sections the reply lacked:
|
||||
"where it sits", "needs you", "next".
|
||||
task_ids (opt) — comma-separated ids of the tasks the turn closed.
|
||||
repo (opt) — working repo remote, resolved like the other arms.
|
||||
"""
|
||||
outcome = (request.args.get("outcome") or "").strip()
|
||||
if outcome not in report_check_svc.OUTCOMES:
|
||||
return jsonify({"error": f"outcome must be one of {list(report_check_svc.OUTCOMES)}"}), 400
|
||||
missing = [m for m in (request.args.get("missing") or "").split(",") if m.strip()]
|
||||
task_ids = _int_list(request.args.get("task_ids"))[:20]
|
||||
project_id, _repo, _unbound = await _project_scope()
|
||||
await report_check_svc.record_report_check(
|
||||
g.user.id, outcome, missing=missing, task_ids=task_ids, project_id=project_id or None,
|
||||
)
|
||||
body: dict = {"status": "ok"}
|
||||
if outcome == "blocked":
|
||||
body["reason"] = report_check_svc.block_reason(missing)
|
||||
return jsonify(body)
|
||||
|
||||
|
||||
@plugin_bp.get("/shape-check")
|
||||
@login_required
|
||||
async def shape_check():
|
||||
@@ -557,7 +548,7 @@ async def shape_check():
|
||||
recorded, and never on the stop that follows one (`phase=after`).
|
||||
|
||||
A GET like every plugin endpoint: a read-scoped key runs the plugin, and
|
||||
this records telemetry the way /report-check does. It changes no ledger
|
||||
this records telemetry the way /reply-rules records its section check. It changes no ledger
|
||||
row — the agent's own classify_shapes call does that.
|
||||
|
||||
Query:
|
||||
|
||||
Reference in New Issue
Block a user