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
34 changes: 34 additions & 0 deletions src/server/agent_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@ async def _handle_control_request(self, msg: dict) -> None:
if subtype == "get_context_usage":
self._reply(request_id, self._context_usage())
return
if subtype == "compact":
await self._do_compact(request_id, inner.get("instructions"))
return
# Unknown subtype — error back so a correlating client doesn't hang.
if isinstance(request_id, str):
self._emit({
Expand Down Expand Up @@ -302,6 +305,37 @@ def _context_usage(self) -> dict:
out["error"] = str(exc)
return out

async def _do_compact(self, request_id: object, instructions: object) -> None:
"""Manually compact the conversation (the original's /compact). Idle-only:
the worker thread mutates the conversation during a turn, so refuse
mid-turn rather than race the message list."""
with self._lock:
active = self._current_abort is not None
if active:
self._reply(request_id, {"ok": False, "error": "cannot compact during an active turn"})
return
try:
from src.compact_service.service import compact_conversation

model = getattr(self.provider, "model", None) or self.config.model or ""
instr = instructions if isinstance(instructions, str) and instructions.strip() else None
res = await compact_conversation(
self.session.conversation,
self.provider,
model,
custom_instructions=instr,
trigger="manual",
)
self._reply(request_id, {
"ok": True,
"tokens_saved": res.tokens_saved,
"pre_compact_count": res.pre_compact_count,
"post_compact_count": res.post_compact_count,
})
except Exception as exc: # noqa: BLE001
logger.exception("[agent-server] compact failed")
self._reply(request_id, {"ok": False, "error": str(exc)})

def _resolve_permission(self, msg: dict) -> None:
response = msg.get("response")
if not isinstance(response, dict):
Expand Down
26 changes: 26 additions & 0 deletions ui-tui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,32 @@ export function App({ transport, serverLabel }: Props): React.ReactElement {
}
return true
}
case 'compact': {
if (client) {
addEntry({ kind: 'system', text: 'Compacting conversation…' })
void client
.requestControl('compact', arg ? { instructions: arg } : {}, 120_000)
.then((r) => {
if (r && r['ok']) {
const saved = Number(r['tokens_saved']) || 0
const pre = Number(r['pre_compact_count']) || 0
const post = Number(r['post_compact_count']) || 0
const sv = saved >= 1000 ? `${(saved / 1000).toFixed(1)}k` : String(saved)
addEntry({
kind: 'system',
text: `Compacted ${pre} → ${post} messages · saved ${sv} tokens`,
})
void client.requestControl('get_context_usage').then(applyContextUsage)
} else {
addEntry({
kind: 'error',
text: `compact failed: ${r && r['error'] ? String(r['error']) : 'no response'}`,
})
}
})
}
return true
}
case 'control': {
if (!arg) {
addEntry({ kind: 'system', text: `usage: ${cmd.name} <value>` })
Expand Down
3 changes: 2 additions & 1 deletion ui-tui/src/slashCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface SlashCommand {
name: string
description: string
/** how the command is handled when submitted. */
kind: 'clear' | 'help' | 'quit' | 'control' | 'context' | 'send'
kind: 'clear' | 'help' | 'quit' | 'control' | 'context' | 'compact' | 'send'
/** for kind:'control' — the control_request subtype. */
control?: string
}
Expand All @@ -23,6 +23,7 @@ export const SLASH_COMMANDS: SlashCommand[] = [
control: 'set_permission_mode',
},
{ name: '/context', description: 'Show context-window usage by category', kind: 'context' },
{ name: '/compact', description: 'Summarize & compact the conversation: /compact [instructions]', kind: 'compact' },
{ name: '/quit', description: 'Exit the TUI', kind: 'quit' },
]

Expand Down