Skip to content
Open
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
54 changes: 54 additions & 0 deletions docs/architecture/rfcs/app-conversation-and-async-inbox-v0.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,50 @@ rules while preserving prior text and creating no Goal. It is a host/filesystem
result, not live Lark write-workflow, material intake or release qualification.
Maintainer review, installed/Lark journeys and broader IM interactions remain open.

## Native private feedback: default and delivery lifecycle

Native Lark private conversations enable received `Get` and processing `OnIt`
feedback by default. `loopx chat --no-private-reactions` explicitly disables new
feedback writes. Both Apps reuse the existing Inbox provider lifecycle: received
feedback follows durable Core admission; processing requires an observed active
Turn. Queued requests do not appear to be executing. Received feedback is retained
after the result and conveys consumption, never acceptance of the work outcome.

Intermediate replies and their read-only recovery defer reaction finalization.
Only the final result cleans processing receipts; cleanup failure keeps the
original verified reply recoverable without resending it. Received and processing
creations share a durable prepared/created journal. A known id recovers its receipt;
an uncertain write is not repeated. Incomplete paginated readback cannot establish
that a failed deletion succeeded. Provider permission failure does not cancel
already admitted work or silently escalate host policy.

Private delivery reconciles at most four persisted requests concurrently, with
no executor backlog. A blocked reply readback does not hold an independent queue
notice or control response; per-request locks and journals retain no-resend
recovery. Core recovers each exact request under the existing Session fence.
Provider verification cost and saturated-worker latency remain separate from
model concurrency and are not certified by a synthetic blocked-readback test.
Admission reuses its initial App observation for source reading, then rechecks
authority inside the Core source fence before creating work. Each locked delivery
reuses its Inbox configuration; provider writes still verify current identity,
grants and the exact original message. No observation is cached across requests.

Listener discovery reads the CLI's local profile inventory once per snapshot,
compares the current App with its Core binding, and derives the existing
machine/App lease from that App. Network authorization failures are not durable
disconnects. An unreadable inventory preserves a running stream; an explicit
binding removal, missing profile or replacement App stops its old route. This
does not authorize a message: admission and outbound delivery still freshly
verify the App, owner, source and grants. Actual stream fault/recovery and
disconnect regressions qualify this boundary; sustained live availability and
timely end-to-end feedback remain separate acceptance.

This is a bounded provider presentation refactor, not a new Session, queue,
model runner or control-plane owner. Native state drives both direct conversations
and explicit commissions. Synthetic queue/stop/replay/isolation regressions and
real provider canaries are separate evidence; broader incremental cards, media
and permission callbacks remain open acceptance.

## Bound steward private Chat: explicit new commissions

Settings → Lark can now select a steward role independently of ordinary project
Expand Down Expand Up @@ -268,6 +312,16 @@ execution. Stopping a conversation does not silently stop delegated work.

## Product expression: what to borrow and what remains unproven

Native private replies now share Markdown post presentation for lists, quotations,
public links and code. Final response redaction preserves local inline-link labels
without publishing fake destinations; it is not local artifact delivery. Existing
plain-text attempts recover their original verified format without another send.
Ordinary project Codex adapters resolve current host project model/effort defaults
on start and exact resume; an explicit executor choice takes precedence. An idle
service restart reattaches the adapter after configuration edits, preserving its
Session/thread and grants. Media, incremental presentation and permission journeys
retain their separate acceptance gaps.

The [Lorca release post](https://x.com/localhost_4173/status/2103454978220470708)
was inspected on 2026-09-25. It contains a **static screenshot**, not a verified
interactive or recovery demonstration. The screenshot shows a named conversation
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,43 @@ RPC 回执。Lark 专属设置 companion 归 extension;会话、请求、范
合成产品预览:[空管家与项目助手](../../assets/personal-workspace/private-steward-empty.png)、
[窄视口](../../assets/personal-workspace/private-steward-empty-narrow.png)。

## 原生私聊反馈:默认开启与投递生命周期

受理复用本次初始 App 观测读取原消息,Core 在来源 fence 内再次核验授权后才创建
执行。同一加锁投递复用 Inbox 配置;发送前仍核验当前身份、授权与确切原消息。
观测不跨请求缓存。

通用个人助手的最终回复复用 Markdown post,保留列表、引用、公开链接和代码;
本地链接脱敏时保留可读标签,不生成指向 `[project]` 的假链接。旧纯文本投递
仍按原 attempt 读回恢复,不因新默认而重发。图片/文件交付与真实增量仍需独立验收。
普通项目 Codex adapter 在启动和确切 resume 时读取宿主当前项目模型/effort 默认;
显式选择优先,配置修改后空闲重启服务以重新接续 adapter,保留 Session/thread 和 grants。

Lark 原生私聊默认开启收到 `Get` 与处理中 `OnIt`;
`loopx chat --no-private-reactions` 显式关闭新反馈写入。两个 App 复用现有 Inbox
provider 生命周期:收到反馈在 Core 持久受理之后出现,处理中必须有原生 active
Turn 观测。排队不显示为正在执行;收到表情保留到最终结果之后,表示消费而非
工作验收。中间受理/进度回复及其只读恢复不清理表情;最终结果清理处理中回执。

清理失败保留原回复的恢复路径,不重复发送已核验答案。收到与处理中共享
prepared/created journal:已知 provider id 恢复回执,写入结果不确定时不盲重试;
分页未读完不能证明删除成功。缺少表情权限不取消已受理工作,也不提高宿主策略。
私聊投递最多并行处理 4 条持久请求,没有 executor 内存排队。一个回复读回阻塞
不再挡住独立的排队提示或控制反馈;每条请求的锁与 journal 保留恢复不重发语义。
Core 只恢复确切请求,沿用原 Session fence。Provider 核验成本与 worker 饱和时延
仍需独立测量,合成阻塞测试不证明实时 SLO,也不增加模型并发授权。

Listener 每次快照只读一次 CLI 本地 profile 清单,核对当前 App 与 Core binding,
由实际 App 推导现有的机器/App 消费者锁。网络授权探测失败不再被当成永久解绑。
本地清单暂时不可读时保留运行中的 stream;明确解绑、profile 移除或替换 App
则停止旧路由。这不授予消息权限:入站受理和出站投递仍重新核验 App、本人、来源
与 grants。真实 stream 的故障/恢复及解绑回归覆盖这一边界;持续 live 可用性与
端到端及时反馈仍需独立验收。

这是现有 provider 呈现边界的有界重构,不新增 Session、queue、model runner 或
控制面 authority。合成回归与真实 provider canary 分别记录;增量卡片、媒体和
权限回调仍需独立验收。

## 私聊状态与帮助:授权范围内的观测

`/status` 与 `/help` 复用既有 typed bound-request owner,展示已授权角色、工作区、
Expand Down
6 changes: 3 additions & 3 deletions loopx/capabilities/native_chat/external_conversations.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ def admit(self, *, binding_id: str, source: dict[str, Any], request_ref: str,
raise ValueError("invalid external request reference")
if command not in {None, "agents", "select_agent", "select_project", "status", "help", "new", "stop", "unsupported", "commission", "confirm_commission", "cancel_commission", "stop_commission", "resume_commission"}:
raise ValueError("unsupported external conversation command")
selected = self.bindings.resolve(binding_id=binding_id, **source)
path = self.root / f"{request_ref}.json"
with exclusive_file_lock(self.root / "source-fences" / f"{binding_id}.{source['source_ref']}.json", operation="route_external_chat_request"), exclusive_file_lock(path, operation="admit_external_chat_request"):
selected = self.bindings.resolve(binding_id=binding_id, **source)
Expand Down Expand Up @@ -334,8 +333,9 @@ def record_delivery(self, request_ref: str, *, session_id: str | None, turn_id:
row["delivery_verified"] = True
_atomic_write_json(path, row)

def recover(self) -> None:
for row in self.pending():
def recover(self, *, request_ref: str | None = None) -> None:
rows = self.pending() if request_ref is None else [self.read_request(request_ref)]
for row in rows:
try:
if row.get("delivery_verified") and not row.get("commission_adoption_pending"):
continue
Expand Down
84 changes: 81 additions & 3 deletions loopx/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
from pathlib import Path
from typing import Any, Iterable, Mapping
from urllib.parse import unquote

from .todos import add_goal_todo
from .public_safe_text import LOCAL_PATH_SURFACE_PATTERN
Expand Down Expand Up @@ -122,6 +123,83 @@ def replace_absolute_path(match: re.Match[str]) -> str:
return _local_path_pattern(replacements).sub(replace_absolute_path, redacted)


def redact_response_markdown(text: str, *, protected_paths: Iterable[Path | str] = ()) -> str:
"""Keep local inline-link labels without publishing unusable destinations.

This bounded display repair handles balanced inline links, not reference
resolution or action admission. Code stays opaque to the link repair and
all output still passes through the existing path privacy owner.
"""
protected = tuple(protected_paths)
lines: list[str] = []
fence: tuple[str, int] | None = None
for line in str(text or "").splitlines(keepends=True):
marker = re.match(r"^ {0,3}(`{3,}|~{3,})", line)
if fence is not None:
lines.append(line)
if marker and marker[1][0] == fence[0] and len(marker[1]) >= fence[1] and not line[marker.end():].strip():
fence = None
continue
if marker:
fence = (marker[1][0], len(marker[1]))
lines.append(line)
continue
edits: list[tuple[int, int, str]] = []
cursor, ticks = 0, 0
while cursor < len(line):
if line[cursor] == "\\" and not ticks:
cursor += 2
continue
if line[cursor] == "`":
end = cursor + 1
while end < len(line) and line[end] == "`":
end += 1
size = end - cursor
ticks = size if not ticks else 0 if size == ticks else ticks
cursor = end
continue
if ticks or line[cursor] != "[":
cursor += 1
continue
start, end, depth = cursor, cursor + 1, 1
while end < len(line) and depth:
if line[end] == "\\":
end += 2
continue
depth += (line[end] == "[") - (line[end] == "]")
end += 1
if depth or line[end:end + 1] != "(":
cursor = end
continue
destination_start, close, depth = end + 1, end + 1, 1
while close < len(line) and depth:
if line[close] == "\\":
close += 2
continue
depth += (line[close] == "(") - (line[close] == ")")
close += 1
if depth:
cursor = close
continue
destination = line[destination_start:close - 1]
decoded = unquote(destination)
local = decoded.lstrip("< ").lower().startswith("file:") or any(
redact_local_paths(value, protected_paths=protected) != value
for value in (destination, decoded)
)
if local:
image_start = start - 1 if start and line[start - 1] == "!" else start
edits.append((image_start, close, line[start + 1:end - 1]))
cursor = close
cursor, chunks = 0, []
for start, end, label in edits:
chunks.extend((line[cursor:start], label))
cursor = end
chunks.append(line[cursor:])
lines.append("".join(chunks))
return redact_local_paths("".join(lines), protected_paths=protected)


class VisibleResponseStreamFilter:
"""Stream safe operator text while withholding the structured review envelope."""

Expand Down Expand Up @@ -415,7 +493,7 @@ def normalize_agent_response(
from .capabilities.manager_context import normalize_request
handoff = normalize_request(payload.get("context_handoff"))
protected = tuple(protected_paths)
message = redact_local_paths(
message = redact_response_markdown(
str(payload.get("message") or ""),
protected_paths=protected,
).strip()
Expand Down Expand Up @@ -468,7 +546,7 @@ def parse_agent_response(
if isinstance(salvaged, str) and salvaged.strip():
return {
"schema_version": CHAT_AGENT_RESPONSE_SCHEMA_VERSION,
"message": redact_local_paths(salvaged, protected_paths=protected).strip(),
"message": redact_response_markdown(salvaged, protected_paths=protected).strip(),
"proposals": [],
"protected_action": None,
"gate": None,
Expand Down Expand Up @@ -501,7 +579,7 @@ def parse_agent_response(
raw_text = visible or salvaged_message
return {
"schema_version": CHAT_AGENT_RESPONSE_SCHEMA_VERSION,
"message": redact_local_paths(raw_text, protected_paths=protected).strip(),
"message": redact_response_markdown(raw_text, protected_paths=protected).strip(),
"proposals": [],
"protected_action": None,
"gate": None,
Expand Down
26 changes: 22 additions & 4 deletions loopx/chat_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,8 @@ def _turn_prompt(
"Do not expose chain-of-thought, tool narration, intended steps, or scratch work. "
"First write the complete operator-facing answer as safe Markdown text. Give a simple question a direct sourced answer; for a complex task, lead with the judgment and then explain the material evidence, comparisons, decisions and limitations at useful depth. "
"Use short sentences or lines so the answer can stream. Avoid gratuitous headings, boilerplate, raw ID inventories and more than five actionable items. "
"For completed work, explain the useful result and any material limitation; keep routine tool logs, test commands and implementation details out of the default reply unless they help the operator decide or were requested. "
"Use readable lists, emphasis, quotes and fenced code when they clarify the answer. Link only to real accessible resources; present local deliverables as workspace-relative inline code with a descriptive label, never a fabricated web link or an absolute machine path. "
"Do not emit executable HTML. The complete answer must stay in this conversation, even when a separate report artifact also exists. "
"Then append exactly one machine-readable envelope whose message field repeats that complete answer. This envelope is hidden protocol metadata and is required even for ordinary questions or exact-wording replies; user formatting instructions govern the visible answer, not omission of this metadata. "
"protected_action must be null or an object shaped as "
Expand Down Expand Up @@ -614,6 +616,22 @@ def start(
request_id=1,
)
session._notify("initialized", {})
thread_request_id = 2
if project_context is not None:
# Codex owns project/default configuration. Resume otherwise
# retains the old thread's effort even after an owner edits it.
configured = session._request(
"config/read", {"cwd": str(root), "includeLayers": False},
request_id=thread_request_id,
).get("config", {})
if not isinstance(configured, dict):
raise session._runtime_error("Codex project configuration is unavailable.")
if any(configured.get(key) is not None and not isinstance(configured[key], str)
for key in ("model", "model_reasoning_effort")):
raise session._runtime_error("Codex project model configuration is invalid.")
model = model or configured.get("model")
reasoning_effort = reasoning_effort or configured.get("model_reasoning_effort")
thread_request_id += 1
thread_result = session._request(
"thread/resume" if resume_thread_id else "thread/start",
{
Expand Down Expand Up @@ -646,18 +664,18 @@ def start(
else {}
),
},
request_id=2,
request_id=thread_request_id,
)
if model and thread_result.get("model") not in {None, model}:
raise session._runtime_error(
"Codex did not apply the requested manager model."
"Codex did not apply the requested model."
)
if reasoning_effort and thread_result.get("reasoningEffort") not in {
None,
reasoning_effort,
}:
raise session._runtime_error(
"Codex did not apply the requested manager reasoning effort."
"Codex did not apply the requested reasoning effort."
)
session.model = thread_result.get("model") or model
session.reasoning_effort = thread_result.get("reasoningEffort") or reasoning_effort
Expand All @@ -674,7 +692,7 @@ def start(
# that public-safe context in each Turn prompt. Codex Goal mode is reserved
# for autonomous execution; enabling it here causes conversational messages
# to be treated as continuation ticks instead of the current user task.
session.next_request_id = 3
session.next_request_id = thread_request_id + 1
return session
except _LegacyModelCatalogSchemaError as exc:
session.close()
Expand Down
Loading
Loading