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

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 16 additions & 1 deletion examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1893,13 +1893,28 @@ async function main() {
const returnText = "处理结论:已核验新约束并关联现有计划,无需再次追问。";
page.__loopxRuntime.messages.get(returnSessionId).push({
message_id: "handoff.browser-fixture", turn_id: "original-delegation",
role: "agent", origin: "manager_followup", text: returnText,
role: "agent", origin: "manager_followup", text: `${returnText}\n\n- **已完成**:核验新约束\n- 下一步:继续现有计划\n\n1. 核对证据\n2. 汇报结论`,
created_at: "2026-08-13T01:00:03Z",
});
await page.getByText(returnText, { exact: true }).waitFor({ state: "visible", timeout: 10_000 });
const richConclusion = page.locator(".personal-channel-timeline .personal-message").filter({ hasText: returnText });
if (await richConclusion.locator("ul > li").count() !== 2
|| await richConclusion.locator(".personal-md strong").innerText() !== "已完成") {
throw new Error("Worker conclusion displayed raw Markdown instead of a list and emphasis");
}
const listStyles = await richConclusion.locator(".personal-md").evaluate((node) => ({
unordered: getComputedStyle(node.querySelector("ul")).listStyleType,
ordered: getComputedStyle(node.querySelector("ol")).listStyleType,
itemDisplay: getComputedStyle(node.querySelector("li")).display,
}));
if (listStyles.unordered !== "disc" || listStyles.ordered !== "decimal" || listStyles.itemDisplay !== "list-item") {
throw new Error(`Markdown list markers were reset by global styles: ${JSON.stringify(listStyles)}`);
}
await richConclusion.scrollIntoViewIfNeeded();
await page.screenshot({ path: resolve(outputDir, "manager-automatic-conclusion.png"), fullPage: false, animations: "disabled" });
await page.setViewportSize({ width: 390, height: 844 });
await page.getByText(returnText, { exact: true }).waitFor({ state: "visible" });
await richConclusion.scrollIntoViewIfNeeded();
await page.screenshot({ path: resolve(outputDir, "manager-automatic-conclusion-mobile.png"), fullPage: false, animations: "disabled" });
await new Promise((resolveWait) => setTimeout(resolveWait, 3500));
if (await page.getByText(returnText, { exact: true }).count() !== 1) throw new Error("Worker conclusion duplicated on the next transcript refresh");
Expand Down
5 changes: 3 additions & 2 deletions loopx/capabilities/manager_context/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,9 @@ calendar day through collection time, separate from current Todo freshness.
Accounting rows are excluded before the presentation cap. Completed Todo titles
help explain recorded deliveries; archive coverage and omitted rows are explicit.
Reported outcomes and evidence-bearing receipts remain distinct, and neither
means the referenced artifact was inspected. Lark text replies preserve paragraphs
and use plain-text report formatting.
means the referenced artifact was inspected. Manager Lark replies preserve paragraphs,
lists and emphasis through Markdown posts. Structured mentions and posts exceeding
the rich-message request limit retain the existing text path without truncation.

### Manager-directed Core inspection

Expand Down
2 changes: 1 addition & 1 deletion loopx/chat_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"Synthesize concrete results and counterevidence across receipts; do not replace them with counts, IDs, follow-up plans, or generic missing-evidence disclaimers. "
"A checkpoint reason is an Agent's explanation, not independent proof. Respect field_coverage and evidence_coverage; hashed evidence refs are lineage, not fetchable artifacts. "
"When artifact_read_status is not_read, distinguish the useful recorded finding from verification still missing instead of discarding the finding. "
"Prefer short paragraphs or bullets to large tables. For Lark use plain text paragraphs and bullets without Markdown bold, code fences or tables. "
"Prefer short paragraphs or bullets to large tables. For Lark use readable Markdown paragraphs and lists, with blank lines between blocks; prefer short lists to large tables. "
"Default to intent delegation: for an explicit request to pass context, objectives or constraints to another Agent, use context_handoff "
"with the exact goal_id and agent_id from the supplied context_delegation catalog. This is already authorized "
"context delivery, not a Todo proposal: do not ask for another confirmation, set priority, change a plan, "
Expand Down
15 changes: 15 additions & 0 deletions loopx/extensions/lark/docs/lark-event-inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,21 @@ inside that source topic. Existing configs without the field retain the legacy
`source_thread` policy. `reply.editorial_style=bullet_points_preferred` projects
an operator hint for structured replies; the command preserves line breaks.

Manager answers and delegated conclusions use a rich `post` with one Markdown
node, preserving paragraphs, lists and code indentation. The extension supplies
exact JSON to the existing CLI transport: it does not fetch Markdown images or
rewrite the source text. Preview and readback verify the post type and content;
plain-text lookalikes do not count as rich delivery. The frontend continues to
render the same stored Markdown through its existing message renderer.

Ordinary inbox CLI replies/notifications retain their text behavior. Structured
mentions keep the existing identity-verified text path. If the provider preview
exceeds the 30 KB rich-post request limit, the manager falls back **before any
send** to the existing 150 KB text transport and reports `format_fallback` as
`post_size_limit`; it does not truncate the answer. Format is bound into rich
reply idempotency keys. This changes presentation only, not conversation scope,
reply placement, authorization or ACK semantics.

```bash
loopx lark-inbox reply \
--project . \
Expand Down
1 change: 1 addition & 0 deletions loopx/extensions/lark/goal_topic_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,7 @@ def process_lark_goal_topic_event(
config_path=config_path,
message_id=message_id,
text=reply_text,
content_format="markdown" if route.get("conversation_kind") == "manager" else "text",
execute=True,
runner=reply_runner,
)
Expand Down
53 changes: 43 additions & 10 deletions loopx/extensions/lark/inbox_reply.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
)
from .inbox_reactions import complete_lark_event_inbox_reactions
from .outbound import (
LARK_POST_REQUEST_MAX_BYTES,
expected_lark_mention_identities,
lark_markdown_post_content,
lark_markdown_preview_matches,
lark_markdown_readback_matches,
lark_member_identities,
lark_provider_preview_matches_outbound,
lark_readback_matches_outbound,
Expand Down Expand Up @@ -198,6 +202,7 @@ def _deliver_lark_inbox_outbound(
config_path: str | Path,
message_id: str | None,
text: str,
content_format: str = "text",
execute: bool = False,
provider_preflight: bool = False,
runner: CommandRunner = _default_runner,
Expand Down Expand Up @@ -230,8 +235,13 @@ def _deliver_lark_inbox_outbound(
raise ValueError(
"lark inbox reply source message is not captured by this inbox"
)
if content_format not in {"text", "markdown"}:
raise ValueError("unsupported Lark reply content format")
# Structured mentions retain the existing identity-verified text transport.
markdown = content_format == "markdown" and not expected_lark_mention_identities(text)
reply_text = normalize_lark_outbound_text(
text, limit=None if source_event is not None else 1200,
preserve_format=markdown,
)
# Reject an oversized content lower bound before building a CLI argument.
# The full rendered request body is checked again after provider preview.
Expand Down Expand Up @@ -269,6 +279,7 @@ def _deliver_lark_inbox_outbound(
"message_id": source_message_id,
"placement": placement,
"text": reply_text,
**({"content_format": "markdown"} if markdown else {}),
},
ensure_ascii=False,
sort_keys=True,
Expand Down Expand Up @@ -394,23 +405,25 @@ def _deliver_lark_inbox_outbound(
format_preflight_passed=True,
)

content_args = (
["--msg-type", "post", "--content", lark_markdown_post_content(reply_text)]
if markdown else ["--text", reply_text]
)
destination = (
[
"im",
"+messages-send",
"--chat-id",
chat_id,
"--text",
reply_text,
*content_args,
]
if placement == "chat_root"
else [
"im",
"+messages-reply",
"--message-id",
source_message_id,
"--text",
reply_text,
*content_args,
"--reply-in-thread",
]
)
Expand All @@ -429,9 +442,11 @@ def _deliver_lark_inbox_outbound(
preview = _call(runner, provider_args + ["--dry-run"])
provider_preview_verified = bool(
preview.get("returncode") == 0
and lark_provider_preview_matches_outbound(
outbound_text=reply_text,
payload=_json_object(preview.get("stdout")),
and (
lark_markdown_preview_matches(text=reply_text, payload=_json_object(preview.get("stdout")))
if markdown else lark_provider_preview_matches_outbound(
outbound_text=reply_text, payload=_json_object(preview.get("stdout")),
)
)
)
if not provider_preview_verified:
Expand All @@ -455,6 +470,17 @@ def _deliver_lark_inbox_outbound(
for call in api_calls if isinstance(api_calls, list) else []:
if isinstance(call, Mapping) and isinstance(call.get("body"), Mapping):
validate_lark_text_request_size(call["body"])
body_bytes = len(json.dumps(call["body"], ensure_ascii=False, separators=(",", ":")).encode("utf-8"))
if markdown and body_bytes > LARK_POST_REQUEST_MAX_BYTES:
# No write has occurred. Preserve the full answer via the existing
# larger text transport rather than truncate or retry after send.
result = _deliver_lark_inbox_outbound(
project=project, config_path=config_path, message_id=message_id,
text=text, content_format="text", execute=execute,
provider_preflight=provider_preflight, runner=runner, before_send=before_send,
)
result.update(content_format="text", format_fallback="post_size_limit")
return result
guidance = None
if before_send is not None:
# Bind review to destination/profile as well as content and placement.
Expand Down Expand Up @@ -552,7 +578,8 @@ def _deliver_lark_inbox_outbound(
verified = bool(
readback.get("returncode") == 0
and readback_message is not None
and lark_readback_matches_outbound(
and (lark_markdown_readback_matches(text=reply_text, message=readback_message)
if markdown else lark_readback_matches_outbound(
outbound_text=reply_text,
message=readback_message,
# mget may report a bot's app_id instead of its member_id. Only
Expand All @@ -562,7 +589,7 @@ def _deliver_lark_inbox_outbound(
for app_id, identities in bot_alias_candidates.items()
if len(identities) == 1 and next(iter(identities)) in expected_mentions
},
)
))
)
reaction_cleanup = (
complete_lark_event_inbox_reactions(
Expand Down Expand Up @@ -621,24 +648,30 @@ def reply_lark_event_inbox(
config_path: str | Path,
message_id: str,
text: str,
content_format: str = "text",
execute: bool = False,
provider_preflight: bool = False,
runner: CommandRunner = _default_runner,
before_send: Callable[[str], Mapping[str, Any]] | None = None,
) -> dict[str, Any]:
"""Reply with the explicit inbox-configured bot and placement policy."""

return _deliver_lark_inbox_outbound(
result = _deliver_lark_inbox_outbound(
project=project,
config_path=config_path,
message_id=message_id,
text=text,
content_format=content_format,
execute=execute,
provider_preflight=provider_preflight,
runner=runner,
before_send=before_send,
)

result.setdefault("content_format", "markdown" if content_format == "markdown"
and not expected_lark_mention_identities(text) else "text")
return result


def send_lark_inbox_message(
*,
Expand Down
1 change: 1 addition & 0 deletions loopx/extensions/lark/manager_returns.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ def before_send(_intent):
config_path=config_path,
message_id=message_id,
text=text,
content_format="markdown",
execute=True,
before_send=before_send,
**({"runner": runner} if runner else {}),
Expand Down
68 changes: 66 additions & 2 deletions loopx/extensions/lark/outbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,17 @@ def normalized_lark_lines(value: Any) -> str:
# Text request bodies: https://open.feishu.cn/document/server-docs/im-v1/message/reply
# Use decimal KB conservatively; count UTF-8 JSON bytes, not display characters.
LARK_TEXT_REQUEST_MAX_BYTES = 150_000
# Rich post messages have a separate provider request-body limit.
LARK_POST_REQUEST_MAX_BYTES = 30_000


class LarkOutboundTextError(ValueError):
"""A local text-format failure before any provider write."""


def normalize_lark_outbound_text(value: Any, *, limit: int | None = 1200) -> str:
def normalize_lark_outbound_text(
value: Any, *, limit: int | None = 1200, preserve_format: bool = False,
) -> str:
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n")
outside_code = FENCED_CODE_PATTERN.sub("", text)
if r"\n" in outside_code:
Expand All @@ -175,7 +179,7 @@ def normalize_lark_outbound_text(value: Any, *, limit: int | None = 1200) -> str
"Lark outbound notification contains a literal @ mention; resolve the "
"member identity and use a structured <at ...> node"
)
normalized = normalized_lark_lines(text)
normalized = text.strip() if preserve_format else normalized_lark_lines(text)
if limit is not None and len(normalized) > limit:
raise LarkOutboundTextError(
f"Lark outbound text exceeds the {limit}-character delivery limit"
Expand Down Expand Up @@ -282,3 +286,63 @@ def lark_readback_matches_outbound(
return False
actual_text = actual_text.replace(rendered_candidates[0], token)
return normalized_lark_lines(actual_text) == expected_text


def lark_markdown_post_content(text: str) -> str:
"""Preserve authored Markdown without the CLI's image-fetch/rewrite pass."""
return json.dumps({"zh_cn": {"content": [[{"tag": "md", "text": text}]]}},
ensure_ascii=False, separators=(",", ":"))


def _single_markdown_post(value: Any) -> str | None:
if isinstance(value, str):
try:
value = json.loads(value)
except json.JSONDecodeError:
return None
if not isinstance(value, Mapping) or set(value) != {"zh_cn"}:
return None
locale = value["zh_cn"]
if not isinstance(locale, Mapping) or set(locale) - {"title", "content"}:
return None
if locale.get("title", "") != "":
return None
rows = locale.get("content")
if not isinstance(rows, list) or len(rows) != 1:
return None
row = rows[0]
if not isinstance(row, list) or len(row) != 1:
return None
node = row[0]
if not isinstance(node, Mapping) or set(node) != {"tag", "text"}:
return None
return node["text"] if node["tag"] == "md" and isinstance(node["text"], str) else None


def lark_markdown_preview_matches(*, text: str, payload: Mapping[str, Any]) -> bool:
data = payload.get("data")
calls = payload.get("api")
if calls is None and isinstance(data, Mapping):
calls = data.get("api")
if not isinstance(calls, list) or len(calls) != 1:
return False
call = calls[0]
body = call.get("body") if isinstance(call, Mapping) else None
return (isinstance(body, Mapping) and body.get("msg_type") == "post"
and _single_markdown_post(body.get("content")) == text)


def lark_markdown_readback_matches(*, text: str, message: Mapping[str, Any]) -> bool:
"""Accept the raw post or CLI's md text, never a plain-text lookalike."""
if message.get("msg_type", message.get("message_type")) != "post":
return False
if message.get("mentions") not in (None, []):
return False
body = message.get("body")
if isinstance(body, Mapping):
actual = _single_markdown_post(body.get("content"))
else:
actual = message.get("content")
if not isinstance(actual, str):
actual = _single_markdown_post(actual)
return isinstance(actual, str) and actual.replace("\r\n", "\n").strip() == text
129 changes: 129 additions & 0 deletions loopx/web/chat/assets/index-B88OPx8f.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions loopx/web/chat/assets/index-DYYo3B8I.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions loopx/web/chat/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
content="LoopX 个人 Agent 工作区:在同一个频道里查看、纠偏并推进 Goal。"
/>
<title>LoopX 个人 Agent 工作区</title>
<script type="module" crossorigin src="/chat/assets/index-o5yb9gCp.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-BCCJib1i.css">
<script type="module" crossorigin src="/chat/assets/index-B88OPx8f.js"></script>
<link rel="stylesheet" crossorigin href="/chat/assets/index-DYYo3B8I.css">
</head>
<body>
<div id="root">
Expand Down
Loading
Loading