Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,24 @@ try {
assert.equal(await english.getByRole('button', {name: 'Load next page', exact: true}).count(), 0);
assert.equal(await page.evaluate(() => document.activeElement?.textContent), '13 / 13 shown');
await english.getByRole('button', {name: 'Awaiting follow-up', exact: true}).click(); await rows(11);
// A useful reply can retain actual work even after delivery. Its later
// append-only result resolves this request, without settling its neighbors.
await fixture('report-partial');
await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click();
await english.getByRole('searchbox', {name: 'Search delegations'}).fill('核对账单导出的字段映射');
await english.getByRole('searchbox', {name: 'Search delegations'}).press('Enter');
await rows(1);
await english.getByText('Partial result returned', {exact: true}).waitFor();
await english.locator('.conversation-request-row > summary').click();
await english.getByRole('heading', {name: 'Remaining work', exact: true}).waitFor();
assert.ok(await english.getByText('在隔离安装包中验证账单字段映射与失败恢复。', {exact: true}).isVisible());
await fixture('complete-partial');
await english.getByRole('button', {name: 'Refresh delegations', exact: true}).click(); await rows(0);
await english.getByRole('button', {name: 'All records', exact: true}).click(); await rows(1);
await english.getByText('Returned', {exact: true}).waitFor();
assert.equal(writes.some(path => /request-(?:inventory|control)|team-work/.test(path)), false);
assert.deepEqual(errors, []);
console.log('PASS: packaged search, pre-pagination match, count, complete original across real pages, long body, corrupt journal and later-page failure/recovery, close/reopen, clear/paging, changed-snapshot More, returned history, stale response, Escape/focus, workspace isolation, narrow layout, English, source refusal/recovery and read-only receipts; original-reader POST delta zero, Chat/receipt state unchanged and no new Turns.');
console.log('PASS: packaged search, pre-pagination match, count, complete original across real pages, long body, corrupt journal and later-page failure/recovery, close/reopen, clear/paging, changed-snapshot More, partial/final returned history, stale response, Escape/focus, workspace isolation, narrow layout, English and source refusal/recovery; explicit fixture reports update return state, original-reader POST delta zero and no new Turns.');
}
} catch (error) {
if (page) {await page.screenshot({path: resolve(output, 'failure.png')}).catch(() => {}); await writeFile(resolve(output, 'failure.txt'), await page.locator('body').innerText());}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import {requestJson} from "./chat";
import {requestBriefSchema} from "./request-control";

const rowSchema = z.object({request_id: z.string(), goal_id: z.string(), agent_id: z.string(),
unfinished: z.boolean(), agenda: z.object({step: z.string()}).passthrough().nullable(),
unfinished: z.boolean(), agenda: z.object({step: z.string(),
pending_return_count: z.number().int().nonnegative().optional(),
remaining_work: z.object({summary: z.string(), resume_when: z.string()}).optional()}).passthrough().nullable(),
control_state: z.string().nullable(),
brief: requestBriefSchema.nullable().optional(), source_question: z.string(), request_revision: z.number().int().positive().nullable(),
read_status: z.string(), decision: z.string(), decision_reason: z.string().nullable().optional(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ function requestStage(row: ConversationRequest, zh: boolean) {
case "reconcile_reservation": return zh ? "取消待收尾" : "Cancellation settling";
case "await_return_delivery": return zh ? "待送达" : "Awaiting delivery";
case "consume_peer_result": case "await_peer_consumption": return zh ? "结果待核对" : "Result awaiting readback";
default: return row.decision === "adopt" ? (zh ? "待结论" : "Awaiting conclusion")
default: return row.agenda?.remaining_work ? ((row.agenda.pending_return_count ?? 0) > 0
? (zh ? "部分结果待送达" : "Partial result awaiting delivery") : (zh ? "已回部分结果" : "Partial result returned"))
: row.decision === "adopt" ? (zh ? "待结论" : "Awaiting conclusion")
: row.decision === "defer" ? (zh ? "已暂缓" : "Deferred")
: (zh ? "待回复" : "Awaiting reply");
}
Expand Down Expand Up @@ -386,6 +388,10 @@ export function ConversationRequests({sessionId, ownerFallback, refreshKey}: {se
<summary><ChevronRight className="conversation-request-toggle" aria-hidden size={15}/><strong>{row.brief?.purpose ?? (row.read_status === "unavailable" ? (zh ? "交办内容待恢复" : "Brief needs recovery") : row.source_question)}</strong><span>{requestStage(row, zh)}</span>
{row.last_event_at ? <time dateTime={row.last_event_at} aria-label={zh ? "最近记录" : "Latest record"}>{new Date(row.last_event_at).toLocaleString(locale, {month: "short", day: "numeric", hour: "2-digit", minute: "2-digit"})}</time> : null}</summary>
<div className="conversation-request-detail">
{row.agenda?.remaining_work ? <section>
<h4>{zh ? "仍需完成" : "Remaining work"}</h4><p>{row.agenda.remaining_work.summary}</p>
<p>{zh ? "继续条件:" : "Continue when: "}{row.agenda.remaining_work.resume_when}</p>
</section> : null}
{row.decision_reason ? <p>{row.decision_reason}</p> : null}
{row.brief ? <><p>{row.brief.context}</p>
{row.brief.constraints.length ? <><h4>{zh ? "约束" : "Constraints"}</h4><ul>{row.brief.constraints.map((value, index) => <li key={index}>{value}</li>)}</ul></> : null}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
decisions and return delivery below use the production owners in a temp runtime.
Commands on stdin: inspect, fault, fault-window, deliver-window, report-window, recover,
deliver, source-unavailable, source-recover, local-source-unavailable,
local-source-recover, history-corrupt/history-recover (--long-history only), stop. Window faults/returns apply
local-source-recover, history-corrupt/history-recover (--long-history only),
report-partial, complete-partial, stop. Window faults/returns apply
after the first page to challenge multi-window refresh readback.
"""
from __future__ import annotations
Expand Down Expand Up @@ -222,6 +223,20 @@ def source_hashes():
drain(runtime, registry, store, lambda *_: None)
elif command == "deliver":
drain(runtime, registry, store, lambda *_: None)
elif command == "report-partial":
prior = _root(runtime) / "replies" / first_receipt["request_id"] / "conclusion.json"
report(runtime, "billing-exports", "builder", first_receipt["request_id"], "conclusion",
"字段映射的修改已完成,安装后的核验仍需继续。", registry=registry,
caller_goal_ref=first_receipt["goal_ref"],
update_id="remaining-installed-check" if prior.exists() else None,
remaining_work={"summary": "在隔离安装包中验证账单字段映射与失败恢复。",
"resume_when": "隔离安装包就绪后,由原负责人继续核验并返回实际结果。"})
drain(runtime, registry, store, lambda *_: None)
elif command == "complete-partial":
report(runtime, "billing-exports", "builder", first_receipt["request_id"], "conclusion",
"隔离包的字段映射与失败恢复已核验,原检查记录保留。", registry=registry,
caller_goal_ref=first_receipt["goal_ref"], update_id="qualified-installed-check")
drain(runtime, registry, store, lambda *_: None)
elif command == "conversation-arrival":
store.append_message(session["session_id"], role="assistant", text="新的委派阅读测试消息。", origin="manager_followup")
elif command == "source-unavailable":
Expand Down
45 changes: 43 additions & 2 deletions loopx/capabilities/manager_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,8 +622,9 @@ before the last position. Peer results retain their separate consumption flow
and are not paginated by this cursor. `status --offset/--limit` remains separate.

After `acknowledge`, the request remains visible until the answer obligation is
resolved. `pending.items` retains its existing receipt-only semantics: it stops
including a request once its conclusion exists. The separate unfinished agenda
resolved. `pending.items` describes the current reply obligation: an ordinary
conclusion resolves it, while an explicitly reported remainder keeps it open.
Neither case certifies fulfillment of the user's requirements. The unfinished agenda
keeps undelivered conclusions visible, and keeps peer work until the requester
explicitly consumes the current result. The worker uses `link` for canonical Todo/evidence lineage
and `report` to publish the answer intended for the original audience:
Expand Down Expand Up @@ -716,6 +717,46 @@ conclude with the adopted/rejected planning decision; deferred or blocked work
must explain the concrete condition and next action. Completion of this exchange
is separate from completion of the receiving Goal.

A partial conclusion can return a useful result while explicitly retaining work
on this request. Use the existing result publication, without creating a Todo:

```sh
loopx manager-inbox report --goal-id research --agent-id worker \
--request-id <id> --reply-text 'The implementation is ready; installed validation remains.' \
--remaining-work 'Validate the repair in the isolated installed package.' \
--resume-when 'The isolated package is available to the receiving Agent.'
```

MCP uses `return_result(..., remaining_work={"summary": "...", "resume_when": "..."})`.
Both fields are required, each at most 600 Unicode characters; the rendered reply
still fits the existing 20,000-character budget. They belong to the receiver's
immutable result for its exact request revision. They do not transfer ownership,
schedule execution or create a second completion state. The receiver retains the
remainder under its existing authority; linked Todo status cannot resolve it.
`resume_when` here describes a condition, unlike a typed Todo scheduler rule.

The pending Inbox, Turn agenda and App delegation reader retain this request
after the partial reply is delivered or consumed. The original conversation and
provider reply also show the remainder and continuation condition. Queued replies
remain distinct from delivered ones. A peer can consume the partial result once
and still await the final result. Other requests sharing the same Todo retain
their independent scope. Source correction and cancellation keep their existing
request-control rules; an old revision's remainder cannot settle a newer request.

When the remaining scope is actually resolved, append a conclusion using a stable
`--update-id` and omit `--remaining-work`/`--resume-when`. This resolves the reply
obligation after normal return processing, not independent acceptance. Retry with
the same text and remainder: changing either under a committed result key is a
conflict. Valid unmarked legacy results retain their ordinary reply state and
unknown fulfillment qualification; unreadable or conflicting result chains stay
unavailable. This path does not batch-close old work.
Before rolling back to a reader without remainder support, stop new partial
publications and reconcile the retained result chains with the compatible
reader. Do not rewrite their receipts or treat an older reader's empty queue as
fulfilled work. No installation, host wake or permission change is implied.
普通短答仍可直接返回,无需创建任务;阶段结果保留原负责人和具体续接条件,
不能用 Todo done、回传或送达替代原要求的验收。

If a returned blocker or draft is followed by a materially changed fact, append
a conclusion with a stable `--update-id`; do not replace the first conclusion or
create another request merely to return its result:
Expand Down
7 changes: 6 additions & 1 deletion loopx/capabilities/manager_context/roundtrip.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import logging
import json
import threading
from itertools import islice
from contextlib import contextmanager, ExitStack
Expand Down Expand Up @@ -179,6 +180,7 @@ def report(
attachment_refs=None,
workspace=None,
request_revision=None,
remaining_work=None,
):
"""Chat audience adapter; the shared Inbox owns result validation/persistence."""
if scope is None and registry is not None:
Expand All @@ -204,13 +206,15 @@ def report(
attachment_refs=attachment_refs,
workspace=workspace,
request_revision=request_revision,
remaining_work=remaining_work,
)
row = _entry(root, goal_id, agent_id, request_id, scope=scope)
route = _route(root, row, scope=scope)
if route.get("kind") == "peer" and phase != "conclusion":
raise ValueError("peer replies require a conclusion; report a concrete result or blocker")
audience_text = text + ("\n" + json.dumps(remaining_work, ensure_ascii=False) if remaining_work is not None else "")
if (route["channel_id"] != "peer" and not conversation_scope(route)["private_conversation"]
and not scan_public_boundary_text(text)["ok"]):
and not scan_public_boundary_text(audience_text)["ok"]):
raise ValueError("reply contains private boundary material; write an audience-safe conclusion")
from ...control_plane.collaboration.inbox import record_result
attachments = None
Expand All @@ -234,6 +238,7 @@ def report(
update_id=update_id,
attachments=attachments,
request_revision=request_revision,
remaining_work=remaining_work,
),
"status": (
"queued_for_requester"
Expand Down
8 changes: 8 additions & 0 deletions loopx/cli_commands/manager_inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ def register_manager_inbox(subparsers, add_format):
"--phase", choices=("decision", "conclusion"), default="conclusion"
)
parser.add_argument("--reply-text")
parser.add_argument("--remaining-work", help="For report: accepted work still owed after this partial conclusion; pair with --resume-when. No Todo or execution is created.")
parser.add_argument("--resume-when", help="For report: concrete continuation condition for --remaining-work; descriptive only, not a scheduler rule.")
parser.add_argument("--attachment-ref", action="append", default=[],
help="For report: explicit relative file in the registered Goal workspace; bound-owner App only, at most four files.")
parser.add_argument("--update-id", help="For report: stable identity for a later conclusion; retry the same id and text.")
Expand All @@ -95,6 +97,11 @@ def register_manager_inbox(subparsers, add_format):

def handle_manager_inbox(args, registry_path, runtime_root):
try:
remainder = getattr(args, "remaining_work", None)
resume = getattr(args, "resume_when", None)
if remainder is not None or resume is not None:
if args.manager_inbox_action != "report" or remainder is None or resume is None:
raise ValueError("--remaining-work and --resume-when require report and each other")
if args.manager_inbox_action == "original-conversation":
from ..control_plane.collaboration.peers import original_conversation_request
if not args.conversation_operation or not args.conversation_file:
Expand Down Expand Up @@ -295,6 +302,7 @@ def handle_manager_inbox(args, registry_path, runtime_root):
update_id=getattr(args, "update_id", None),
attachment_refs=getattr(args, "attachment_ref", None),
request_revision=getattr(args, "request_revision", None),
remaining_work={"summary": remainder, "resume_when": resume} if remainder is not None else None,
workspace=Path.cwd(),
registry=registry_path,
)
Expand Down
8 changes: 7 additions & 1 deletion loopx/collaboration_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,13 +322,18 @@ def request_peer(
"For a bound-owner App return, attachment_refs explicitly attaches up to four "
"relative Goal-workspace files; a filename in text does not attach a file. "
"Neither a blocker nor a returned result certifies completion of the work. "
"For a partial result, supply remaining_work with summary and resume_when; "
"it stays visible for the receiver without creating a Todo or wakeup. "
+ conversation_answer_instruction()
))
def return_result(request_id: str, text: str, update_id: str | None = None,
attachment_refs: list[str] | None = None, request_revision: int | None = None) -> dict:
attachment_refs: list[str] | None = None, request_revision: int | None = None,
remaining_work: dict[str, str] | None = None) -> dict:
"""Return a conclusion to the original requester. For a later changed fact,
append an update with a stable update_id; retry with the same id and text.
Neither a blocker nor a returned result certifies completion of the work.
For a partial result, remaining_work contains summary and resume_when;
the receiving Agent still owns this remainder. It creates no Todo or wakeup.
"""
check_scope()
# The host adapter selects Chat/Lark transport; the shared collaboration
Expand All @@ -345,6 +350,7 @@ def return_result(request_id: str, text: str, update_id: str | None = None,
update_id=update_id,
attachment_refs=attachment_refs,
request_revision=request_revision,
remaining_work=remaining_work,
workspace=workspace,
registry=registry,
caller_goal_ref=caller_goal_ref,
Expand Down
5 changes: 4 additions & 1 deletion loopx/control_plane/collaboration/inbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,7 @@ def record_result(
update_id: str | None = None,
attachments: list[dict[str, Any]] | None = None,
request_revision: int | None = None,
remaining_work: dict[str, str] | None = None,
):
"""Persist a receiver conclusion; the transport adapter validates its audience."""
request_id = row["request_id"]
Expand Down Expand Up @@ -462,6 +463,7 @@ def record_result(
"decision": decision.get("decision"), "update_id": update_id,
"request_revision": request_revision, "legacy_assessment": decision or None,
"attachments": attachments,
"remaining_work": remaining_work,
"results": observations,
})
except EffectRuntimeRejected as exc:
Expand Down Expand Up @@ -499,6 +501,7 @@ def inspect_result_identity(reply, row, path):
"request": row, "phase": reply.get("phase"), "text": reply.get("text"),
"decision": reply.get("decision"), "update_id": reply.get("update_id"),
"attachments": reply.get("attachments"),
"remaining_work": reply.get("remaining_work"),
"request_revision": reply.get("request_revision"), "legacy_assessment": legacy.get("value"),
"results": observations,
})
Expand All @@ -521,7 +524,7 @@ def _result_observation(key, value):
"value": {field: value[field] for field in (
"request_id", "goal_id", "agent_id", "source_id", "goal_ref", "phase",
"decision", "result_key", "previous_result_key", "update_id", "attachments",
"request_revision", "request_revision_digest", "recipient", "requester",
"request_revision", "request_revision_digest", "recipient", "requester", "remaining_work",
) if field in value},
"text_chars": len(text),
"text_sha256": hashlib.sha256(text.encode()).hexdigest(),
Expand Down
Loading
Loading