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
20 changes: 4 additions & 16 deletions apps/presentation/dashboard/src/views/dashboard-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,6 @@ import {
sessionInvalidatedByPayload,
todoNoWriteReceiptFromPayload,
todoReceiptLabel,
isTeamPlanPreviewProposal,
isTodoProposal,
type ChatSessionSnapshot,
type ChatSessionSummary,
Expand Down Expand Up @@ -2229,22 +2228,11 @@ function PersonalGoalHome({
text: visibleAgentMessage(response.message || streamedText.trim())
|| `${answerIdentityLabel(targetContextId, selectedRoute.label)} 已完成分析。`,
});
const teamPlanPreviews = response.proposals.filter(isTeamPlanPreviewProposal);
if (teamPlanPreviews.length > 0) {
// The steward's team plan is offered as the typed card the manager
// channel stored for the Goal it staffs, so the owner confirms the plan
// there instead of typing the request again.
const goals = [...new Set(teamPlanPreviews
.map((preview) => String(preview.preview.goal_id ?? ""))
.filter(Boolean))];
updateManagerAssistantMessage(targetContextId, streamingMessageId, {
lines: [goals.length > 0
? `团队计划已生成可确认卡片(${goals.join("、")}),确认后才会创建 lane。`
: "团队计划已生成可确认卡片,确认后才会创建 lane。"],
});
}
const todoProposals = response.proposals.filter(isTodoProposal);
if (todoProposals.length > 0 && !targetGoal && teamPlanPreviews.length === 0) {
// The channel already states where a team plan is confirmed: its answer
// names the Goal whose workspace holds the card, so a manager-channel
// proposal here is only ever a Todo the owner has to be sent to.
if (todoProposals.length > 0 && !targetGoal) {
updateManagerAssistantMessage(targetContextId, streamingMessageId, {
lines: ["请进入要修改的 Goal,预览并确认具体变更。"],
});
Expand Down
27 changes: 19 additions & 8 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -615,14 +615,25 @@ Shipped enforcement, in delivery order:
would not grant. A Turn response now carries the preview beside its Todo
proposals, so the surfaces read one response shape instead of deciding which
kinds of answer may arrive.

The confirmation surface the owner's own channel needs is shipped: the admitted
preview reaches the typed action store, and the card it produces renders the
same click path a single-lane `team.plan` card already had. What is still
missing is the per-audience coverage behind it: a remote manager audience (the
Lark manager channel) keeps the preview in its answer text and has no card on
its own surface yet, so no card is written on its behalf. Traceability is
recorded rather than implied: the settlement reads
7. **The answer says where the confirmation is.** Every manager answer that
admitted a preview carries one channel-authored pointer line naming the Goal
whose workspace holds the card and the fact that no lane exists before that
confirmation. It is a receipt, not a rewrite: the steward's prose is
preserved, and a Goal channel is never annotated. A remote manager audience
receives the pointer as well, which is what makes one manager answer
actionable even where the audience that asked cannot itself confirm.

The owner's own channel ships both the card and the sentence that explains it:
the admitted preview reaches the typed action store, the card renders the same
click path a single-lane `team.plan` card already had, and the answer the owner
reads names the Goal whose workspace holds that card. That sentence is the
channel's, not the model's, and a remote manager audience receives it too --
one manager answer is therefore actionable for every audience that can read it,
because the audience that asked may not be the audience that can confirm.
What is still missing is the remote audience's *own* confirmation surface: a
Lark manager channel has no card of its own, so nothing is written on its
behalf, and its owner confirms in the LoopX workspace the answer named.
Traceability is recorded rather than implied: the settlement reads
the Goal's canonical source basis before it writes, and the receipt carries it as
a bounded `intent_basis`, so each lane Todo can be tied to the revision it was
meant to advance even though the Todo row itself does not carry the field. The
Expand Down
24 changes: 15 additions & 9 deletions docs/architecture/rfcs/harness-selection-dsh-pi-v0.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -472,15 +472,21 @@ Agent 注册、Todo 创建、quota 或 goal policy——复用预览点名的身
计划点名的那个 Goal。投影按计划幂等:重放的 Turn 或重复的请求复用同一张卡片,而不会叠出
第二张;它不创建工作,也不授予任何"确认卡片"之外的东西。Turn 响应现在把预览与 Todo 提案
放在同一形状里,因此产品面读一种响应形状,而不是自己判断"哪种答案可能出现"。

业主自己那条通道所需的确认面已经落地:准入预览会到达类型化 action store,它产出的卡片走的
正是单 lane `team.plan` 卡片本就有的点击路径。仍然缺的是它背后的**按受众覆盖**:远端管家
受众(Lark 管家通道)仍只在回答正文里带着预览,在它自己的面上还没有卡片,因此不会替它写下
卡片。可追溯性已经被记录而不是被暗示:结算在写入**之前**读取该 Goal 的规范 source basis,
回执以有界字段 `intent_basis` 携带它,因此每条 lane Todo 都能被追溯回它本应推进的那个修订
——尽管 Todo 行本身还不携带该字段。回读也不再是缺口:落地会把这次确保的每一条 lane Todo 以
有界字段 `lane_todo_ids` 发布出去;这两个字段都是那个封闭且持久化的回执字段集的加性例外,
因此早前写下的回执仍然通过校验,而团队计划回执不带 monitor key——计划不是 monitor。
7. **回答本身说明去哪确认。** 每一份准入过团队预览的管家回答,都会带上**一句由通道写入**的
指引:点名那张卡片所在的 Goal,并说明确认之前不存在任何 lane。它是回执,不是改写:管家的
正文原样保留,Goal 通道永远不会被加上这句。远端管家受众同样会收到这句指引——这正是让
"同一份管家回答"在"提问的受众自己无法确认"时仍然可行动的原因。

业主自己那条通道已经把卡片和解释卡片的那句话一起落地:准入预览到达类型化 action store,
卡片走的正是单 lane `team.plan` 卡片本就有的点击路径,而业主读到的回答会点名那张卡片所在的
Goal。这句话属于**通道**而不是模型,远端管家受众同样收得到——因为提问的受众未必是能确认的
受众,一句管家回答要对每个能读到它的受众都可行动。仍然缺的是远端受众**自己的确认面**:
Lark 管家通道在它自己的面上还没有卡片,因此不会替它写下卡片,它的业主按回答点名的 Goal 到
LoopX 工作区确认。可追溯性已经被记录而不是被暗示:结算在写入**之前**读取该 Goal 的规范
source basis,回执以有界字段 `intent_basis` 携带它,因此每条 lane Todo 都能被追溯回它本应
推进的那个修订——尽管 Todo 行本身还不携带该字段。回读也不再是缺口:落地会把这次确保的每一条
lane Todo 以有界字段 `lane_todo_ids` 发布出去;这两个字段都是那个封闭且持久化的回执字段集的
加性例外,因此早前写下的回执仍然通过校验,而团队计划回执不带 monitor key——计划不是 monitor。

### 与 multi-agent / shared authority 契约的关系

Expand Down
92 changes: 82 additions & 10 deletions loopx/capabilities/manager_context/team_plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from __future__ import annotations

from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any, Protocol

Expand Down Expand Up @@ -122,15 +122,7 @@ def project_team_plan_preview(

if projector is None or str(session.get("channel_id") or "") != "manager":
return
for proposal in response.get("proposals") or []:
if (
not isinstance(proposal, Mapping)
or str(proposal.get("kind") or "") != STEWARD_TEAM_PLAN_PREVIEW_KIND
):
continue
preview = proposal.get("preview")
if not isinstance(preview, Mapping):
continue
for preview in team_plan_previews(response):
try:
projected = projector(preview)
except (OSError, ValueError, TypeError, KeyError) as exc:
Expand All @@ -155,9 +147,89 @@ def project_team_plan_preview(
)


def team_plan_previews(response: Mapping[str, Any]) -> list[Mapping[str, Any]]:
"""The admitted team previews an answer carries, in the order it carried them."""

previews: list[Mapping[str, Any]] = []
for proposal in response.get("proposals") or []:
if (
not isinstance(proposal, Mapping)
or str(proposal.get("kind") or "") != STEWARD_TEAM_PLAN_PREVIEW_KIND
):
continue
preview = proposal.get("preview")
if isinstance(preview, Mapping):
previews.append(preview)
return previews


def confirmation_pointer(goals: Sequence[str]) -> str:
"""The one line that makes a preview actionable from the surface that asked.

A manager audience may be a Lark group with no confirmation card of its own,
and even on the owner's own channel the card lives under the Goal the plan
staffs rather than in the manager conversation. The answer therefore names
that Goal and states what confirming there does, so a plan the owner cannot
click is at least a plan they know how to confirm.
"""

named = "、".join(goals)
return (
f"已为 {named} 准备好可确认的团队计划卡片:在 LoopX 工作区的该 Goal 下确认后,"
"才会为每条就绪 lane 创建它的首个有界 Todo;确认前不会创建任何 lane。"
)


def offer_team_plan_confirmation(
*,
store: TurnEventSink,
session: Mapping[str, Any],
session_id: str,
turn_id: str,
response: Mapping[str, Any],
projector: TeamPlanProjector | None,
) -> dict[str, Any]:
"""Make each admitted team preview actionable for the audience that asked.

Admission decides whether a preview may be *shown*; this is what turns it into
something the owner can act on, and it does exactly two things for a manager
channel: it appends one typed pointer line naming the Goal whose workspace
holds the card, and -- for the owner's own local channel only -- it stores
that card. A remote audience's confirmation surface is not this store, so it
receives the pointer and no card is written on its behalf.

The steward's prose is preserved: the added line is an operational receipt
from the channel, in the same way the delegation path states its own receipt,
not a rewrite of what the model answered. Nothing here creates work.
"""

previews = team_plan_previews(response)
if not previews or not is_manager_channel(str(session.get("channel_id") or "")):
return dict(response)
project_team_plan_preview(
store=store,
session=session,
session_id=session_id,
turn_id=turn_id,
response=response,
projector=projector,
)
goals = sorted({str(preview.get("goal_id") or "") for preview in previews} - {""})
if not goals:
return dict(response)
message = str(response.get("message") or "").strip()
return {
**response,
"message": f"{message}\n\n{confirmation_pointer(goals)}".strip(),
}


__all__ = [
"TeamPlanProjector",
"TurnEventSink",
"confirmation_pointer",
"offer_team_plan_confirmation",
"project_team_plan_preview",
"team_plan_previews",
"team_plan_admission_context",
]
4 changes: 2 additions & 2 deletions loopx/chat_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
)
from .capabilities.manager_context.team_plan import (
TeamPlanProjector,
project_team_plan_preview,
offer_team_plan_confirmation,
team_plan_admission_context,
)
from .capabilities.steward_executor import load_effective_steward_executor_defaults
Expand Down Expand Up @@ -1229,7 +1229,7 @@ def scope_valid() -> bool:
except (OSError, ValueError):
response = {**response, "proposals": [], "gate": None,
"message": "材料尚未转交:目标绑定、来源授权或持久收件回读未通过。管家需要修复交接链路;没有改动任务或优先级。"}
project_team_plan_preview(
response = offer_team_plan_confirmation(
store=self.store,
session=session,
session_id=session_id,
Expand Down
4 changes: 2 additions & 2 deletions loopx/web/chat/asset-retention.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2",
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-D8O4R6-3.js",
"assets/index-Cb7S1yHW.js",
"assets/index-DWmuPX72.css"
],
[
Expand All @@ -28,7 +28,7 @@
"assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2",
"assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2",
"assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2",
"assets/index-DWWKKUgg.js",
"assets/index-D8O4R6-3.js",
"assets/index-DWmuPX72.css"
]
]
Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-D8O4R6-3.js"></script>
<script type="module" crossorigin src="/chat/assets/index-Cb7S1yHW.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-DWmuPX72.css">
</head>
<body>
Expand Down
90 changes: 65 additions & 25 deletions tests/test_steward_team_plan_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -468,60 +468,100 @@ def test_the_owner_channel_projects_an_admitted_preview_onto_the_action_surface(
"""

from loopx.capabilities.manager_context.team_plan import (
project_team_plan_preview,
offer_team_plan_confirmation,
)
from loopx.chat_store import ChatSessionStore

store = ChatSessionStore(tmp_path / "runtime")
preview = {"goal_id": "authorized-goal", "lanes": [{"lane_id": "lane-alpha"}]}
answer = "Here is the plan"
response = {
"message": "Here is the plan",
"message": answer,
"proposals": [{"kind": "steward_team_plan_preview", "preview": preview}],
"gate": None,
}
projected: list[dict] = []
projector = lambda value: ( # noqa: E731 - one inline surface owner
projected.append(dict(value)) or {"proposal_id": "proposal-team-plan"}
)

def project(session: dict, *, projector=None) -> None:
project_team_plan_preview(
def offer(session: dict, *, projector=None, turn_id: str = "turn-1") -> dict:
return offer_team_plan_confirmation(
store=store,
session=session,
session_id="session-1",
turn_id="turn-1",
turn_id=turn_id,
response=response,
projector=projector,
)

# Without a surface owner the answer is unchanged: the Controller does not
# invent a second place where cards live.
project({"channel_id": "manager"})
# A Goal channel has no team plan to offer, so its answer is untouched even
# when a surface owner is available.
untouched = offer({"channel_id": "goal.authorized-goal"}, projector=projector)
assert untouched == response
assert projected == []

projector = lambda value: ( # noqa: E731 - one inline surface owner
projected.append(dict(value)) or {"proposal_id": "proposal-team-plan"}
)
project({"channel_id": "manager"}, projector=projector)
# The owner's own channel stores exactly one card and says where it lives.
owner = offer({"channel_id": "manager"}, projector=projector)
assert projected == [preview]

# A remote manager channel and a Goal channel both keep the answer only.
for channel_id in ("manager.external." + "a" * 24, "goal.authorized-goal"):
project({"channel_id": channel_id}, projector=projector)
assert owner["message"].startswith(answer)
assert "authorized-goal" in owner["message"]
assert "确认前不会创建任何 lane" in owner["message"]
assert owner["proposals"] == response["proposals"]

# A remote manager audience has no card of its own, so none is written on its
# behalf -- but it still learns the exact Goal whose workspace holds one,
# instead of reading a plan it has no way to confirm.
remote = offer(
{"channel_id": "manager.external." + "a" * 24},
projector=projector,
turn_id="turn-2",
)
assert projected == [preview]
assert remote["message"].startswith(answer)
assert "authorized-goal" in remote["message"]

# A surface that cannot store the card does not fail the answer: the gap is
# typed on the Turn instead.
# typed on the Turn instead, and the pointer still names the Goal.
def refuse(_value: object) -> dict:
raise ValueError("the typed action store is unavailable")

project({"channel_id": "manager"}, projector=refuse)
kinds = [
event["kind"]
for event in store.events_after("session-1", "turn-1", None)
]
# The successful projection named the card it stored and the Goal it staffs;
# the refused one reported a typed code instead of failing the answer.
assert kinds == ["team_plan.projected", "team_plan.projection_failed"]
refused = offer({"channel_id": "manager"}, projector=refuse, turn_id="turn-3")
assert "authorized-goal" in refused["message"]
assert [
event["kind"] for event in store.events_after("session-1", "turn-1", None)
] == ["team_plan.projected"]
assert [
event["kind"] for event in store.events_after("session-1", "turn-3", None)
] == ["team_plan.projection_failed"]
projected_event = store.events_after("session-1", "turn-1", None)[0]
assert projected_event["payload"] == {
"goal_id": "authorized-goal",
"proposal_id": "proposal-team-plan",
}


def test_an_answer_without_a_team_plan_is_never_annotated() -> None:
"""The pointer belongs to a team plan, not to every manager answer."""

from loopx.capabilities.manager_context.team_plan import (
offer_team_plan_confirmation,
)

class _Store:
def append_event(self, *_args, **_kwargs): # pragma: no cover - not reached
raise AssertionError("no event is written without a team plan")

response = {
"message": "只回答问题的普通回复。",
"proposals": [{"kind": "todo", "text": "Do one thing"}],
"gate": None,
}
assert offer_team_plan_confirmation(
store=_Store(),
session={"channel_id": "manager"},
session_id="session-1",
turn_id="turn-1",
response=response,
projector=lambda _preview: {"proposal_id": "unused"},
) == response
Loading