diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx index 40205b5ad..953fa3c22 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/AgentChat.tsx @@ -22,7 +22,9 @@ import { Textarea } from '@/components/ui/textarea' import { consumeSSEStream } from '@/lib/sse' import { buildChatHistoryFromTurns, + type ChatHistoryEntry, chatWithAgent, + fetchAgentHistory, type OpenClawStreamEvent, } from './useOpenClaw' @@ -43,6 +45,75 @@ interface ChatTurn { userText: string parts: AssistantPart[] done: boolean + source?: string +} + +function cleanUserText(text: string): string { + return text + .replace(/^\[cron:[^\]]*\]\s*/i, '') + .replace(/Current time:.*$/gm, '') + .trim() +} + +function historyToTurns(entries: ChatHistoryEntry[]): ChatTurn[] { + const sorted = [...entries].sort((a, b) => { + const aTs = a.messages[0]?.timestamp ?? 0 + const bTs = b.messages[0]?.timestamp ?? 0 + return aTs - bTs + }) + + const turns: ChatTurn[] = [] + + for (const entry of sorted) { + // Limit to last 20 messages per session to avoid huge histories + const msgs = entry.messages.slice(-20) + let current: ChatTurn | null = null + + for (const msg of msgs) { + if (msg.role === 'user') { + if (current) turns.push(current) + const rawText = msg.content + .filter((b) => b.type === 'text' && b.text) + .map((b) => b.text ?? '') + .join('\n') + + const userText = cleanUserText(rawText) + if (!userText) { + current = null + continue + } + + current = { + id: crypto.randomUUID(), + userText, + parts: [], + done: true, + source: entry.session.source, + } + } else if (msg.role === 'assistant') { + // If no current turn (e.g. user message was filtered), create + // one without user text — this handles proactive/cron responses + if (!current) { + current = { + id: crypto.randomUUID(), + userText: '', + parts: [], + done: true, + source: entry.session.source, + } + } + for (const block of msg.content) { + if (block.type === 'text' && block.text) { + current.parts.push({ kind: 'text', text: block.text }) + } + } + } + } + + if (current && current.parts.length > 0) turns.push(current) + } + + return turns } interface AgentChatProps { @@ -59,6 +130,7 @@ export const AgentChat: FC = ({ const [turns, setTurns] = useState([]) const [input, setInput] = useState('') const [streaming, setStreaming] = useState(false) + const [historyLoaded, setHistoryLoaded] = useState(false) const scrollRef = useRef(null) const sessionKeyRef = useRef(crypto.randomUUID()) const streamAbortRef = useRef(null) @@ -75,6 +147,23 @@ export const AgentChat: FC = ({ scrollToBottom() }, [turns]) + useEffect(() => { + let active = true + fetchAgentHistory(agentId) + .then((entries) => { + if (!active) return + const historicTurns = historyToTurns(entries) + if (historicTurns.length > 0) setTurns(historicTurns) + setHistoryLoaded(true) + }) + .catch(() => { + if (active) setHistoryLoaded(true) + }) + return () => { + active = false + } + }, [agentId]) + useEffect(() => { return () => { streamAbortRef.current?.abort() @@ -263,6 +352,22 @@ export const AgentChat: FC = ({ } } + if (!historyLoaded) { + return ( +
+
+ +

{agentName}

+
+
+ +
+
+ ) + } + return (
@@ -275,14 +380,27 @@ export const AgentChat: FC = ({
{turns.map((turn) => (
- {/* User message */} - - -
-                  {turn.userText}
-                
-
-
+ {turn.source && + turn.source !== 'user-chat' && + turn.source !== 'other' && ( +
+ {turn.source === 'cron' + ? 'Scheduled Task' + : turn.source === 'hook' + ? 'Hook' + : turn.source} +
+ )} + {/* User message (skip if empty — proactive/cron response) */} + {turn.userText && ( + + +
+                    {turn.userText}
+                  
+
+
+ )} {/* Assistant response — all parts grouped */} {turn.parts.length > 0 && ( diff --git a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts index 859851ce0..1e96b33df 100644 --- a/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts +++ b/packages/browseros-agent/apps/agent/entrypoints/app/agents/useOpenClaw.ts @@ -361,6 +361,50 @@ export function buildChatHistoryFromTurns( return messages } +export interface ChatHistoryBlock { + type: 'text' | 'toolCall' | 'thinking' + text?: string + name?: string + arguments?: unknown + thinking?: string +} + +export interface ChatHistoryMessage { + role: 'user' | 'assistant' | 'toolResult' + content: ChatHistoryBlock[] + timestamp?: number + usage?: { input: number; output: number } + stopReason?: string + toolName?: string + isError?: boolean +} + +export interface ChatHistorySession { + key: string + updatedAt: number + sessionId: string + agentId: string + source: string +} + +export interface ChatHistoryEntry { + session: ChatHistorySession + messages: ChatHistoryMessage[] +} + +export async function fetchAgentHistory( + agentId: string, + limit = 10, +): Promise { + const baseUrl = await getAgentServerUrl() + const res = await fetch( + `${baseUrl}/claw/agents/${agentId}/history?limit=${limit}`, + ) + if (!res.ok) return [] + const data = (await res.json()) as { entries: ChatHistoryEntry[] } + return data.entries ?? [] +} + export async function chatWithAgent( agentId: string, message: string, diff --git a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts index 3c2f7b70f..aeb4c2250 100644 --- a/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts +++ b/packages/browseros-agent/apps/server/src/api/routes/openclaw.ts @@ -20,9 +20,73 @@ import { OpenClawInvalidAgentNameError, OpenClawProtectedAgentError, } from '../services/openclaw/errors' +import type { OpenClawChatMessage } from '../services/openclaw/openclaw-cli-client' import { isUnsupportedOpenClawProviderError } from '../services/openclaw/openclaw-provider-map' import { getOpenClawService } from '../services/openclaw/openclaw-service' +/** + * Filter non-user-facing messages from chat.history. + * + * OpenClaw's session history contains three kinds of noise: + * + * 1. Context replays — When a new message arrives in an existing session, + * OpenClaw bundles the entire prior conversation into a single user + * message prefixed with "[Chat messages since your last reply]". + * This is internal context for the model, not user input. + * + * 2. System events — Cron/heartbeat triggers formatted as user messages + * starting with "System: [timestamp]" and containing + * "Handle this reminder internally". + * + * 3. Heartbeat responses — Assistant messages that are just "HEARTBEAT_OK", + * which is OpenClaw's standard heartbeat acknowledgment token. + */ +function filterSystemMessages( + messages: OpenClawChatMessage[], +): OpenClawChatMessage[] { + const result: OpenClawChatMessage[] = [] + + for (const msg of messages) { + const text = msg.content + .filter((b) => b.type === 'text') + .map((b) => b.text ?? '') + .join('') + .trim() + + // Skip heartbeat responses + if (msg.role === 'assistant' && text.startsWith('HEARTBEAT')) continue + + // Skip system event triggers (cron/heartbeat) + if (msg.role === 'user' && text.includes('Handle this reminder internally')) + continue + + // Context-replay messages: extract the actual new user message + if ( + msg.role === 'user' && + text.startsWith('[Chat messages since your last reply') + ) { + const marker = '[Current message - respond to this]' + const idx = text.indexOf(marker) + if (idx >= 0) { + let actual = text.slice(idx + marker.length).trim() + // Strip "User: " prefix + actual = actual.replace(/^User:\s*/i, '') + if (actual) { + result.push({ + ...msg, + content: [{ type: 'text', text: actual }], + }) + } + } + continue + } + + result.push(msg) + } + + return result +} + function getCreateAgentValidationError(body: { name?: string }): string | null { if (!body.name?.trim()) { return 'Name is required' @@ -344,6 +408,52 @@ export function createOpenClawRoutes() { } }) + .get('/agents/:id/history', async (c) => { + const { id } = c.req.param() + const limit = Number(c.req.query('limit')) || 10 + + try { + const allSessions = await getOpenClawService().listSessions(id) + const filtered = allSessions + .filter((s) => s.agentId === id || s.key.includes(`agent:${id}:`)) + .sort((a, b) => b.updatedAt - a.updatedAt) + .slice(0, limit) + + const classifySource = (key: string): string => { + if (key.includes(':cron:')) return 'cron' + if (key.includes(':hook:')) return 'hook' + if (key.includes('openai-user:browseros')) return 'user-chat' + if (key.includes('qa-channel')) return 'channel' + return 'other' + } + + const entries = await Promise.all( + filtered.map(async (s) => { + const source = classifySource(s.key) + try { + const rawMessages = await getOpenClawService().getChatHistory( + s.key, + ) + + const messages = filterSystemMessages(rawMessages) + + return { session: { ...s, source }, messages } + } catch { + return { session: { ...s, source }, messages: [] } + } + }), + ) + + // Filter out entries with no meaningful messages + const nonEmpty = entries.filter((e) => e.messages.length > 0) + + return c.json({ entries: nonEmpty }) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return c.json({ error: message }, 500) + } + }) + .get('/logs', async (c) => { try { const logs = await getOpenClawService().getLogs() diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts index 93216791b..5d8881353 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-cli-client.ts @@ -31,6 +31,37 @@ export interface OpenClawAgentRecord { model?: string } +export interface OpenClawSessionEntry { + key: string + updatedAt: number + sessionId: string + agentId: string + kind: string + status?: string + totalTokens?: number + model?: string + modelProvider?: string +} + +export interface OpenClawChatBlock { + type: 'text' | 'toolCall' | 'thinking' + text?: string + name?: string + arguments?: unknown + thinking?: string +} + +export interface OpenClawChatMessage { + role: 'user' | 'assistant' | 'toolResult' + content: OpenClawChatBlock[] + timestamp?: number + usage?: { input: number; output: number } + stopReason?: string + toolName?: string + toolCallId?: string + isError?: boolean +} + export class OpenClawCliClient { constructor(private readonly executor: ContainerExecutor) {} @@ -191,6 +222,55 @@ export class OpenClawCliClient { await this.listAgents() } + async listSessions(agentId?: string): Promise { + const args = ['sessions', '--json'] + if (agentId) { + args.push('--agent', agentId) + } else { + args.push('--all-agents') + } + + const output = await this.runCommand(args) + const parsed = parseFirstMatchingJson< + { sessions?: unknown[]; count?: number } | unknown[] + >(output, isSessionListPayload) + + if (parsed === null) { + throw new Error( + `Failed to parse OpenClaw sessions output: ${output.slice(0, 200)}`, + ) + } + + const entries = Array.isArray(parsed) ? parsed : (parsed.sessions ?? []) + + return entries.map(toSessionEntry) + } + + async getChatHistory(sessionKey: string): Promise { + const output = await this.runCommand([ + 'gateway', + 'call', + 'chat.history', + '--params', + JSON.stringify({ sessionKey }), + '--json', + ]) + + const parsed = parseFirstMatchingJson<{ messages?: unknown[] }>( + output, + (value) => isPlainObject(value) && 'messages' in value, + ) + + if (parsed === null) { + throw new Error( + `Failed to parse OpenClaw chat history output: ${output.slice(0, 200)}`, + ) + } + + const rawMessages = parsed.messages ?? [] + return rawMessages.map(toChatMessage) + } + private agentWorkspace(name: string): string { return name === 'main' ? `${OPENCLAW_CONTAINER_HOME}/workspace` @@ -405,3 +485,75 @@ function isStructuredLogPayload(value: unknown): boolean { (typeof value.message === 'string' || typeof value.msg === 'string') ) } + +function isSessionListPayload(value: unknown): boolean { + if (Array.isArray(value)) return true + if (!isPlainObject(value)) return false + return 'sessions' in value || 'count' in value +} + +function toSessionEntry(raw: unknown): OpenClawSessionEntry { + const record = raw as Record + return { + key: String(record.key ?? ''), + updatedAt: typeof record.updatedAt === 'number' ? record.updatedAt : 0, + sessionId: String(record.sessionId ?? ''), + agentId: String(record.agentId ?? ''), + kind: String(record.kind ?? ''), + status: typeof record.status === 'string' ? record.status : undefined, + totalTokens: + typeof record.totalTokens === 'number' ? record.totalTokens : undefined, + model: typeof record.model === 'string' ? record.model : undefined, + modelProvider: + typeof record.modelProvider === 'string' + ? record.modelProvider + : undefined, + } +} + +function toChatMessage(raw: unknown): OpenClawChatMessage { + const record = raw as Record + const role = String(record.role ?? 'assistant') as OpenClawChatMessage['role'] + + const blocks: OpenClawChatBlock[] = [] + const rawContent = record.content + + if (Array.isArray(rawContent)) { + for (const block of rawContent) { + if (!isPlainObject(block)) continue + const type = String(block.type ?? 'text') as OpenClawChatBlock['type'] + const entry: OpenClawChatBlock = { type } + + if (type === 'text' && typeof block.text === 'string') { + entry.text = block.text + } else if (type === 'toolCall') { + if (typeof block.name === 'string') entry.name = block.name + if (block.arguments !== undefined) entry.arguments = block.arguments + } else if (type === 'thinking' && typeof block.thinking === 'string') { + entry.thinking = block.thinking + } + + blocks.push(entry) + } + } else if (typeof rawContent === 'string') { + blocks.push({ type: 'text', text: rawContent }) + } + + const message: OpenClawChatMessage = { role, content: blocks } + + if (typeof record.timestamp === 'number') message.timestamp = record.timestamp + if (isPlainObject(record.usage)) { + const usage = record.usage as Record + if (typeof usage.input === 'number' && typeof usage.output === 'number') { + message.usage = { input: usage.input, output: usage.output } + } + } + if (typeof record.stopReason === 'string') + message.stopReason = record.stopReason + if (typeof record.toolName === 'string') message.toolName = record.toolName + if (typeof record.toolCallId === 'string') + message.toolCallId = record.toolCallId + if (typeof record.isError === 'boolean') message.isError = record.isError + + return message +} diff --git a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts index d23362655..c73eb81e6 100644 --- a/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts +++ b/packages/browseros-agent/apps/server/src/api/services/openclaw/openclaw-service.ts @@ -30,8 +30,11 @@ import { } from './errors' import { type OpenClawAgentRecord, + type OpenClawChatBlock, + type OpenClawChatMessage, OpenClawCliClient, type OpenClawConfigBatchEntry, + type OpenClawSessionEntry, } from './openclaw-cli-client' import { getHostWorkspaceDir, @@ -91,6 +94,7 @@ export interface OpenClawStatusResponse { } export type OpenClawAgentEntry = OpenClawAgentRecord +export type { OpenClawChatBlock, OpenClawChatMessage, OpenClawSessionEntry } export interface SetupInput { providerType?: string @@ -573,6 +577,19 @@ export class OpenClawService { return this.runControlPlaneCall(() => this.cliClient.listAgents()) } + async listSessions(agentId?: string): Promise { + logger.debug('Listing OpenClaw sessions', { agentId }) + return this.cliClient.listSessions(agentId) + } + + async getChatHistory(sessionKey: string): Promise { + await this.assertGatewayReady() + logger.debug('Fetching OpenClaw chat history', { sessionKey }) + return this.runControlPlaneCall(() => + this.cliClient.getChatHistory(sessionKey), + ) + } + // ── Chat Stream (HTTP) ─────────────────────────────────────────────── async chatStream(