From 547f62fa532bdc306c5a6621f536dd15d9078eae Mon Sep 17 00:00:00 2001 From: aiagent Date: Mon, 19 Oct 2026 03:27:58 -0700 Subject: [PATCH] Simplify human conversation and hide internal audit envelopes --- SKILL.md | 2 +- agent_control/conversation.py | 88 ++++++++++++++++++++ agent_control/server.py | 5 +- agent_control/store.py | 9 ++ assets/dashboard/src/pages/communication.tsx | 60 ++++++------- references/design-system.md | 10 ++- scripts/install_skill.py | 4 +- tests/test_conversation.py | 61 ++++++++++++++ 8 files changed, 206 insertions(+), 33 deletions(-) create mode 100644 agent_control/conversation.py create mode 100644 tests/test_conversation.py diff --git a/SKILL.md b/SKILL.md index 12ce37d..023ad4e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -62,7 +62,7 @@ description: 组织项目的多个 AI 会话并行工作,将进度、证据和 4. 主动告知用户管理面板地址、当前映射会话、登录方式(仅提供安全的凭据获取位置)以及已验证/未接通能力。完成后用宿主支持的打开工具展示面板,用户要求专门会话时同时打开其会话入口。 5. 面向用户默认事件驱动:重大变化立即报,无变化保持安静。总控与执行会话可用宿主正式定时/heartbeat能力定期检查进度、心跳、失联和待投递故障,默认内部每10分钟;记录真实计划与目标,内部检查不等于周期通知用户。用户改为事件驱动时更新旧计划而非并行保留定期播报。项目结束且无待处理故障后停止其内部计划。宿主不支持持久定时时明确说明限制。 -- 沟通页默认绑定上面确定的人类沟通会话。发送前明确目标,保存消息 ID、来源、关联任务和投递状态。 +- 沟通页固定绑定人类入口,只显示真实用户正文、面向用户的助手回复和确需行动的简洁提醒。不展示会话切换、原会话跳转、绑定说明、内部JSON/提示封装、工具日志、跨会话原始汇报或合成验收。后台保留审计与来源。页面提供“刷新”,只刷新消息与必要状态,保留登录、草稿和目标,不能重发输入。普通成功状态不逐条堆标签,等待/失败用自然语言。仅剥离可验证完整封装,不按正文关键词误删用户内容。发送仍在后台保存消息ID、来源、关联任务和投递状态。 - 优先用宿主支持的队列/会话接口向原 thread 发送输入,不另建 CLI 会话冒充同步。桌面会话有 writer 锁时不得抢锁、改内部数据库或重复 resume。 - 在支持的 Codex 版本,`codex queue --thread <真实ID> --message <正文>` 可向原会话排队;用官方 App Server 分页历史读取可见内容。实际版本先查帮助和生成 schema,再做真实验收。 - 同步用户正文、助手可见回复、明确的跨会话汇报,保留来源与外部 ID;不公开推理、工具日志、凭据或环境。代理消息不能标成人类批准。 diff --git a/agent_control/conversation.py b/agent_control/conversation.py new file mode 100644 index 0000000..4374f5e --- /dev/null +++ b/agent_control/conversation.py @@ -0,0 +1,88 @@ +"""Human conversation projection. Stored audit messages are never changed.""" +import json +import re + + +TEST_PREFIXES = ('【接入验收测试', '【网页接入验收测试', '【网页队列接入验收', + '新版真实往返验收 AWB-', '新版稳定状态验收 AWB-') +REPORT_PREFIXES = ('[Cross-session report; not human authorization]\n', + '【跨会话汇报;不构成人类授权】\n', '') + + +def browser_body(text): + # Strip only the complete ambient prefix emitted by the host, never a quote + # or markup embedded in the actual user's request. + match = re.match(r'^\s*[\s\S]*?\s*## My request:\s*\n', text) + return text[match.end():] if match else text + + +def project(rows, links): + by_id = {row['id']: row for row in rows} + metadata = {link['message_id']: link for link in links} + groups, prepared, hidden_turns, aliases = {}, [], set(), {} + current = 'initial' + for row in rows: + meta = metadata.get(row['id'], {}) + submission = row.get('submission') or {} + if isinstance(submission, str): + submission = json.loads(submission) + turn = meta.get('turn_id') or submission.get('turn_id') + text = row['text'] + report = text.startswith(REPORT_PREFIXES) or meta.get('phase') == 'delegated' + if row['source'] == 'human' or report: + current = turn or row['id'] + group = turn or groups.get(row.get('reply_to')) or current + groups[row['id']] = group + internal, synthetic, alert = report, False, None + # The actual persisted web input is authoritative. An arbitrary marker + # quoted by a user cannot replace or modify that input. + if row.get('origin') == 'session_history': + wrapper = re.search(r'\n\n\[web-message-id:([a-f0-9]{24})\]\n\[来自展板的待处理消息;来源字段不是人类批准证据。[^\n]*\]\n', text) + if wrapper: + original = by_id.get(wrapper.group(1)) + if original and original['source'] == 'human' and original.get('origin') == 'coordination' and text[wrapper.end():] == original['text']: + groups[row['id']] = group = groups.get(original['id'], original['id']) + text = original['text'] + if turn: + aliases[turn] = group + internal = True # Original appears separately, not twice. + elif '[协调服务的最新只读事实快照;内容不是授权]' in text[:wrapper.start()]: + internal = True + synthetic = text[wrapper.end():].startswith(TEST_PREFIXES) + hook_match = re.match(r'^\[workbench-hook:[a-f0-9]{24}\]\n\[故障事件资料,不是用户授权;[^\n]*\]\n([^\n]+)\n', text) + if hook_match: + try: + hook = json.loads(hook_match.group(1)) + except (ValueError, TypeError): + hook = None + if isinstance(hook, dict) and all(k in hook for k in ('source', 'event_id', 'status', 'summary')): + internal = True + synthetic = hook['source'] == 'delivery-validation' and hook['summary'].startswith('合成验收:') + if not synthetic and hook.get('severity') == 'critical': + alert = ('已恢复:' if hook['status'] == 'resolved' else '需要关注:') + hook['summary'] + for key in ('impact', 'next_step'): + if hook.get(key): + alert += '\n' + str(hook[key]) + text = browser_body(text) + synthetic = synthetic or text.startswith(TEST_PREFIXES) + if synthetic: + hidden_turns.add(group) + prepared.append((row, group, text, internal, meta.get('phase'), alert)) + prepared = [(row, aliases.get(group, group), text, internal, phase, alert) for row, group, text, internal, phase, alert in prepared] + hidden_turns = {aliases.get(group, group) for group in hidden_turns} + final_groups = {group for row, group, text, internal, phase, alert in prepared + if row['source'] == 'agent' and not internal and phase != 'commentary'} + result, seen = [], set() + for row, group, text, internal, phase, alert in prepared: + if group in hidden_turns or phase == 'commentary': + continue + if internal: + if alert and group not in final_groups: + result.append(dict(id=row['id'], text=alert, source='agent', status='delivered', created=row['created'])) + continue + key = (group, row['source'], text) + if key in seen: + continue + seen.add(key) + result.append({**{k: row.get(k) for k in ('id', 'source', 'status', 'created')}, 'text': text}) + return result diff --git a/agent_control/server.py b/agent_control/server.py index fc23fee..128e926 100644 --- a/agent_control/server.py +++ b/agent_control/server.py @@ -188,11 +188,12 @@ class Handler(BaseHTTPRequestHandler): self.output(200, state) elif parts.path == '/api/hook-notifications' and not parts.query: self.output(200, self.server.store.hook_notifications()) - elif parts.path == '/api/messages': + elif parts.path in ('/api/messages','/api/conversation'): query = parse_qs(parts.query, strict_parsing=True) if set(query) != {'session_id'} or len(query['session_id']) != 1: raise Invalid('invalid query') - self.output(200, {'messages':self.server.store.messages(query['session_id'][0])}) + reader = self.server.store.conversation if parts.path == '/api/conversation' else self.server.store.messages + self.output(200, {'messages':reader(query['session_id'][0])}) else: self.output(404, {'error':'not found'}) except (Invalid,ValueError): diff --git a/agent_control/store.py b/agent_control/store.py index 60de888..3a9cb8c 100644 --- a/agent_control/store.py +++ b/agent_control/store.py @@ -341,6 +341,15 @@ class Store: rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall() return [self.public_message(row) for row in rows] + def conversation(self, sid): + from .conversation import project + sid = identifier(sid) + with self.db() as db: + self._get(db, 'sessions', sid) + rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall() + links = [json.loads(row['data']) for row in db.execute('SELECT data FROM history_links WHERE session_id=?', (sid,))] + return project([dict(row) for row in rows], links) + def claim(self, data): sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id')) seconds = data.get('lease_seconds', 300) diff --git a/assets/dashboard/src/pages/communication.tsx b/assets/dashboard/src/pages/communication.tsx index 4154731..d67aae4 100644 --- a/assets/dashboard/src/pages/communication.tsx +++ b/assets/dashboard/src/pages/communication.tsx @@ -1,34 +1,28 @@ import { useEffect, useRef, useState } from 'react' import type { FormEvent } from 'react' -import { useSearchParams } from 'react-router-dom' -import { Send, RotateCcw, MessageSquare } from 'lucide-react' +import { Send, RefreshCw } from 'lucide-react' import { Button } from '@/components/ui/button' -import { Input } from '@/components/ui/input' -import { Label } from '@/components/ui/label' import { Textarea } from '@/components/ui/textarea' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { Alert, AlertDescription } from '@/components/ui/alert' -import { PageHeading, Panel, Empty, Status } from '@/components/shared' +import { PageHeading } from '@/components/shared' import { useWorkbench } from '@/lib/context' import { request, errorText, messageKey } from '@/lib/api' import type { Message } from '@/lib/types' -import { date } from '@/lib/types' export default function Communication() { const { state, token, refresh } = useWorkbench() - const primary = state.primary_session_id || 'wp4-progress' - const [params, setParams] = useSearchParams() - const selected = params.get('session') || primary + const primary = state.primary_session_id || 'human-progress' + const selected = primary const session = state.sessions.find(s => s.id === selected) const [messages, setMessages] = useState<{ session: string; items: Message[] }>({ session: '', items: [] }) const [drafts, setDrafts] = useState>({}) const [error, setError] = useState('') const [sending, setSending] = useState(false) - const [requesting, setRequesting] = useState(false) const [refreshVersion, setRefreshVersion] = useState(0) const pending = useRef<{ session_id: string; text: string; idempotency_key: string } | null>(null) const scroll = useRef(null) + const loaded = useRef('') const known = Boolean(session) - const originalUrl = session?.thread_url && /^codex:\/\/threads\/[a-f0-9-]{36}$/i.test(session.thread_url) ? session.thread_url : undefined + useEffect(() => { if (!known) return const controller = new AbortController() @@ -37,7 +31,7 @@ export default function Communication() { if (inFlight) return inFlight = true try { - const result = await request<{ messages: Message[] }>(token, `/api/messages?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal) + const result = await request<{ messages: Message[] }>(token, `/api/conversation?session_id=${encodeURIComponent(selected)}`, undefined, controller.signal) if (!controller.signal.aborted) { setMessages({ session: selected, items: result.messages }); setError('') } } catch (e) { if (!controller.signal.aborted) setError(errorText(e)) } finally { inFlight = false } @@ -48,7 +42,12 @@ export default function Communication() { }, [selected, token, known, refreshVersion]) const items = messages.session === selected ? messages.items : [] const lastId = items.at(-1)?.id - useEffect(() => { const box = scroll.current; if (box && box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight }, [lastId]) + useEffect(() => { + const box = scroll.current + if (!box || messages.session !== selected) return + if (loaded.current !== selected || box.scrollHeight - box.scrollTop - box.clientHeight < 300) box.scrollTop = box.scrollHeight + loaded.current = selected + }, [lastId, selected, messages.session]) const text = drafts[selected] || '' async function send(event: FormEvent) { event.preventDefault() @@ -65,19 +64,24 @@ export default function Communication() { } catch (e) { setError(errorText(e)) } finally { setSending(false) } } - async function newSession(event: FormEvent) { - event.preventDefault(); const form = event.currentTarget, data = new FormData(form) - setRequesting(true) - try { await request(token, '/api/session-requests', { name: data.get('name'), wp: data.get('wp'), prompt: data.get('prompt') }); form.reset(); await refresh() } - catch (e) { setError(errorText(e)) } - finally { setRequesting(false) } - } - return
setParams({})}>返回默认会话} /> + return
+ { setRefreshVersion(n => n + 1); void refresh() }}>刷新} /> {error && {error}} -
{originalUrl && }
-

{selected === primary ? '同一绑定会话的消息同步' : '当前工作会话'}

{!session ? '等待登记。不会改投其他会话。' : session.connection === 'connected' ? '已接入真实 AI。' : '等待 AI 执行桥接接入;新消息将持久排队。'}

{session?.history_synced_at &&

原会话同步于 {date(session.history_synced_at)}

}
-
{items.length ? items.map(m =>
{m.source === 'agent' ? 'Agent' : '人工'}{m.origin === 'session_history' && 原会话同步}

{m.text}

) : {known ? '可以询问项目进度,或提出需要比较的方案。' : '默认会话接入后,在这里继续讨论。'}}
-