@arthovlabs/liber 0.0.0-stage → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +348 -2
- package/bin/liber +706 -0
- package/bin/liber-tui +244 -0
- package/bin/liber-tui.py +825 -0
- package/bin/liberd-entry +17 -0
- package/liber.yaml +84 -0
- package/package.json +43 -4
- package/resources/icons/appIcon.png +0 -0
- package/resources/icons/appIcon.svg +20 -0
- package/resources/icons/appIcon@512.png +0 -0
- package/resources/icons/atom-transparent-256.png +0 -0
- package/resources/icons/atom-transparent.png +0 -0
- package/resources/icons/atom-transparent.svg +19 -0
- package/resources/icons/make-atom.js +168 -0
- package/src/cli/ui.js +520 -0
- package/src/daemon/core/auth.js +217 -0
- package/src/daemon/core/automations.js +130 -0
- package/src/daemon/core/budget.js +145 -0
- package/src/daemon/core/circuit-breaker.js +31 -0
- package/src/daemon/core/config-apply.js +223 -0
- package/src/daemon/core/connectors.js +131 -0
- package/src/daemon/core/engine.js +1029 -0
- package/src/daemon/core/evidence.js +306 -0
- package/src/daemon/core/executor.js +10 -0
- package/src/daemon/core/grounding.js +420 -0
- package/src/daemon/core/injection.js +24 -0
- package/src/daemon/core/journal.js +81 -0
- package/src/daemon/core/liber-soul.js +157 -0
- package/src/daemon/core/lock.js +32 -0
- package/src/daemon/core/memory.js +106 -0
- package/src/daemon/core/model-switch.js +58 -0
- package/src/daemon/core/policy.js +102 -0
- package/src/daemon/core/providers.js +503 -0
- package/src/daemon/core/registry.js +356 -0
- package/src/daemon/core/secrets.js +35 -0
- package/src/daemon/core/skills.js +264 -0
- package/src/daemon/core/tools/browser-tool.js +280 -0
- package/src/daemon/core/tools/file-tools.js +277 -0
- package/src/daemon/core/tools/memory-tool.js +127 -0
- package/src/daemon/core/tools/skill-tool.js +44 -0
- package/src/daemon/core/tools/task-tool.js +41 -0
- package/src/daemon/core/tools/terminal-tool.js +449 -0
- package/src/daemon/core/tools/todo-tool.js +100 -0
- package/src/daemon/core/tools/web-tool.js +181 -0
- package/src/daemon/core/trace.js +40 -0
- package/src/daemon/core/validate.js +105 -0
- package/src/daemon/core/vault.js +158 -0
- package/src/daemon/core/verifier.js +98 -0
- package/src/daemon/core/voice.js +83 -0
- package/src/daemon/core/workspace.js +199 -0
- package/src/daemon/liberd.js +1343 -0
- package/src/daemon/llm/adapters/anthropic-adapter.js +190 -0
- package/src/daemon/llm/adapters/gemini-adapter.js +148 -0
- package/src/daemon/llm/adapters/ollama-adapter.js +74 -0
- package/src/daemon/llm/adapters/openai-adapter.js +148 -0
- package/src/daemon/llm/base.js +31 -0
- package/src/daemon/llm/manager.js +134 -0
- package/src/utils/config-loader.js +109 -0
- package/src/utils/logger.js +56 -0
package/bin/liber-tui.py
ADDED
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
LIBER :: Autonomous Agent Harness TUI (Tactical Cyber-Obsidian)
|
|
4
|
+
Theme: Tactical Cyber-Obsidian
|
|
5
|
+
Stack: Textual + Rich Renderables + Async Engine Integration
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import json
|
|
11
|
+
import asyncio
|
|
12
|
+
import subprocess
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional, AsyncGenerator, Dict, Any
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
from rich.text import Text
|
|
18
|
+
from rich.panel import Panel
|
|
19
|
+
from rich.syntax import Syntax
|
|
20
|
+
from rich.table import Table
|
|
21
|
+
|
|
22
|
+
from textual.app import App, ComposeResult
|
|
23
|
+
from textual.containers import Horizontal, Vertical
|
|
24
|
+
from textual.reactive import reactive
|
|
25
|
+
from textual.widgets import Footer, RichLog, Static, Input, OptionList
|
|
26
|
+
from textual.binding import Binding
|
|
27
|
+
from textual.screen import ModalScreen
|
|
28
|
+
from textual import work
|
|
29
|
+
|
|
30
|
+
DAEMON_URL = "http://127.0.0.1:4567"
|
|
31
|
+
ROOT_DIR = Path(__file__).resolve().parent.parent
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def load_local_facts() -> Dict[str, Any]:
|
|
35
|
+
"""Read version + tuning facts from the repo itself — never hardcode them."""
|
|
36
|
+
import re
|
|
37
|
+
facts = {"version": "0.0.0", "window_size": None, "max_loops": None}
|
|
38
|
+
try:
|
|
39
|
+
pkg_text = (ROOT_DIR / "package.json").read_text(encoding="utf-8")
|
|
40
|
+
m = re.search(r'"version"\s*:\s*"([^"]+)"', pkg_text)
|
|
41
|
+
if m:
|
|
42
|
+
facts["version"] = m.group(1)
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
try:
|
|
46
|
+
yaml_text = (ROOT_DIR / "liber.yaml").read_text(encoding="utf-8")
|
|
47
|
+
m = re.search(r"window_size\s*:\s*(\d+)", yaml_text)
|
|
48
|
+
if m:
|
|
49
|
+
facts["window_size"] = int(m.group(1))
|
|
50
|
+
m = re.search(r"max_loops\s*:\s*(\d+)", yaml_text)
|
|
51
|
+
if m:
|
|
52
|
+
facts["max_loops"] = int(m.group(1))
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
return facts
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
LOCAL_FACTS = load_local_facts()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def auth_headers() -> Dict[str, str]:
|
|
62
|
+
"""Bearer token written by the daemon at boot (.liber/daemon.token)."""
|
|
63
|
+
token = os.environ.get("LIBER_TOKEN", "")
|
|
64
|
+
if not token:
|
|
65
|
+
try:
|
|
66
|
+
token = (Path.cwd() / ".liber" / "daemon.token").read_text(encoding="utf-8").strip()
|
|
67
|
+
except OSError:
|
|
68
|
+
token = ""
|
|
69
|
+
return {"Authorization": f"Bearer {token}"} if token else {}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# =====================================================================
|
|
73
|
+
# 1. VISUAL CUE & DESIGN SYSTEM HELPER
|
|
74
|
+
# =====================================================================
|
|
75
|
+
|
|
76
|
+
class LiberCue:
|
|
77
|
+
"""Helper untuk merender visual cue, pill badges, dan dynamic meters."""
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def badge(label: str, style: str) -> Text:
|
|
81
|
+
"""Membuat badge inverted pill dengan padding rapat."""
|
|
82
|
+
return Text.assemble(
|
|
83
|
+
(" ", style),
|
|
84
|
+
(f"{label.upper()}", f"bold {style}"),
|
|
85
|
+
(" ", style),
|
|
86
|
+
(" ", "default")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@staticmethod
|
|
90
|
+
def context_bar(used: int, total: int, width: int = 14) -> Text:
|
|
91
|
+
"""Merender bar kapasitas context window dengan transisi warna cerdas."""
|
|
92
|
+
total = max(total, 1)
|
|
93
|
+
pct = min(max(used / total, 0.0), 1.0)
|
|
94
|
+
filled = int(pct * width)
|
|
95
|
+
empty = width - filled
|
|
96
|
+
|
|
97
|
+
if pct < 0.60:
|
|
98
|
+
color = "#10B981" # Mint Green
|
|
99
|
+
elif pct < 0.85:
|
|
100
|
+
color = "#F59E0B" # Cyber Amber
|
|
101
|
+
else:
|
|
102
|
+
color = "bold #EF4444" # Crimson Overflow Warning
|
|
103
|
+
|
|
104
|
+
bar = "█" * filled + "░" * empty
|
|
105
|
+
return Text.assemble(
|
|
106
|
+
("CTX [", "#52525B"),
|
|
107
|
+
(bar, color),
|
|
108
|
+
(f"] {int(pct * 100)}% ({used // 1000}k/{total // 1000}k)", "dim #E2E8F0")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def tool_dispatch_box(tool_name: str, cmd: str) -> Panel:
|
|
113
|
+
"""Panel khusus blok eksekusi tool/sandbox."""
|
|
114
|
+
body = Text.assemble(
|
|
115
|
+
("⚡ COMMAND: ", "bold #00F0FF"),
|
|
116
|
+
(f"{cmd}\n", "bright_white"),
|
|
117
|
+
("↳ ENV: ", "dim #71717A"),
|
|
118
|
+
("local://powershell-sandbox ", "#10B981"),
|
|
119
|
+
("│ TIMEOUT: ", "dim #71717A"),
|
|
120
|
+
("30s", "white")
|
|
121
|
+
)
|
|
122
|
+
return Panel(
|
|
123
|
+
body,
|
|
124
|
+
title=f"[bold #00F0FF]TOOL DISPATCH :: {tool_name.upper()}[/bold #00F0FF]",
|
|
125
|
+
border_style="#00F0FF",
|
|
126
|
+
padding=(0, 1)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# =====================================================================
|
|
131
|
+
# 2. CUSTOM REACTIVE WIDGETS
|
|
132
|
+
# =====================================================================
|
|
133
|
+
|
|
134
|
+
class HeaderBar(Static):
|
|
135
|
+
"""Header status bar dengan visual telemetry global."""
|
|
136
|
+
# Honest idle values: zeros until the daemon reports real numbers.
|
|
137
|
+
latency = reactive(0)
|
|
138
|
+
tok_speed = reactive(0.0)
|
|
139
|
+
ctx_used = reactive(0)
|
|
140
|
+
ctx_total = 32000
|
|
141
|
+
model_name = reactive("—")
|
|
142
|
+
state_label = reactive("IDLE")
|
|
143
|
+
|
|
144
|
+
def render(self) -> Panel:
|
|
145
|
+
state_color = "#10B981" if self.state_label == "IDLE" else "#00F0FF"
|
|
146
|
+
|
|
147
|
+
left_text = Text.assemble(
|
|
148
|
+
(" ╭─ ", "#27272A"),
|
|
149
|
+
("LIBER", "bold #00FF87"),
|
|
150
|
+
(" // AGENTIC HARNESS ", "dim white"),
|
|
151
|
+
("[STATE: ", "#52525B"),
|
|
152
|
+
(self.state_label, f"bold {state_color}"),
|
|
153
|
+
("] ", "#52525B"),
|
|
154
|
+
("MODEL: ", "dim white"),
|
|
155
|
+
(self.model_name, "bold white")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
ctx_render = LiberCue.context_bar(self.ctx_used, self.ctx_total)
|
|
159
|
+
|
|
160
|
+
right_text = Text.assemble(
|
|
161
|
+
(f"⚡ {self.tok_speed:.1f} t/s ", "bold #00F0FF"),
|
|
162
|
+
("│ ", "#27272A"),
|
|
163
|
+
(f"⏱ {self.latency}ms ", "#10B981"),
|
|
164
|
+
("│ ", "#27272A"),
|
|
165
|
+
ctx_render,
|
|
166
|
+
(" ─╮", "#27272A")
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
grid = Table.grid(expand=True)
|
|
170
|
+
grid.add_column(justify="left")
|
|
171
|
+
grid.add_column(justify="right")
|
|
172
|
+
grid.add_row(left_text, right_text)
|
|
173
|
+
|
|
174
|
+
return Panel(grid, style="on #08090A", border_style="#00FF87")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class TelemetrySidebar(Static):
|
|
178
|
+
"""Sidebar kanan berisi metrik performa, daftar agen nyata, dan memory bank."""
|
|
179
|
+
tok_velocity = reactive(" ▂▃▅▇█▇▆▅ — t/s")
|
|
180
|
+
cost_accum = reactive(0.0)
|
|
181
|
+
agents = reactive([])
|
|
182
|
+
gateway = reactive("LiberDaemon:4567")
|
|
183
|
+
|
|
184
|
+
def render(self) -> Panel:
|
|
185
|
+
content = Text()
|
|
186
|
+
|
|
187
|
+
# Real agent fleet, straight from the daemon — no invented topology.
|
|
188
|
+
content.append("⎔ AGENT FLEET\n", "bold #8B5CF6")
|
|
189
|
+
agents = self.agents or []
|
|
190
|
+
if not agents:
|
|
191
|
+
content.append(" └─ (no agents reported)\n\n", "dim white")
|
|
192
|
+
else:
|
|
193
|
+
for i, a in enumerate(agents):
|
|
194
|
+
branch = " └─" if i == len(agents) - 1 else " ├─"
|
|
195
|
+
running = a.get("status") == "RUNNING"
|
|
196
|
+
icon, color = ("●", "#10B981") if running else ("○", "#52525B")
|
|
197
|
+
content.append(f" {branch} [{icon}] ", color)
|
|
198
|
+
content.append(f"{a.get('id', '?')} ({a.get('status', '?')})\n", "white")
|
|
199
|
+
content.append("\n", "default")
|
|
200
|
+
|
|
201
|
+
# Telemetry Data
|
|
202
|
+
content.append("⎔ RUNTIME TELEMETRY\n", "bold #00F0FF")
|
|
203
|
+
content.append(" • Velocity : ", "dim white")
|
|
204
|
+
content.append(f"{self.tok_velocity}\n", "bright_white")
|
|
205
|
+
content.append(" • Cost Acc : ", "dim white")
|
|
206
|
+
content.append(f"${self.cost_accum:.4f}\n", "bold #F59E0B")
|
|
207
|
+
content.append(" • Gateway : ", "dim white")
|
|
208
|
+
content.append(f"{DAEMON_URL.split('//')[-1]}\n", "#38BDF8")
|
|
209
|
+
content.append(" • Isolation: ", "dim white")
|
|
210
|
+
content.append("Pattern policy\n", "#F59E0B")
|
|
211
|
+
content.append(" ", "dim white")
|
|
212
|
+
content.append("(no OS sandbox)\n\n", "dim #52525B")
|
|
213
|
+
|
|
214
|
+
# Memory Slots
|
|
215
|
+
content.append("⎔ MEMORY POOL (Cognitive)\n", "bold #F59E0B")
|
|
216
|
+
content.append(" • Working Memory : ", "dim white")
|
|
217
|
+
win = LOCAL_FACTS.get("window_size")
|
|
218
|
+
content.append(f"sliding-window ({win})" if win else "sliding-window\n", "white")
|
|
219
|
+
content.append("\n", "white")
|
|
220
|
+
content.append(" • Active Context : ", "dim white")
|
|
221
|
+
content.append("Evidence Ledger [ON]\n", "white")
|
|
222
|
+
content.append(" • Circuit Breaker : ", "dim white")
|
|
223
|
+
loops = LOCAL_FACTS.get("max_loops")
|
|
224
|
+
content.append(f"{loops} loops max\n" if loops else "—\n", "dim #52525B")
|
|
225
|
+
|
|
226
|
+
return Panel(
|
|
227
|
+
content,
|
|
228
|
+
title="[bold #8B5CF6]MISSION INSPECTOR[/bold #8B5CF6]",
|
|
229
|
+
border_style="#27272A",
|
|
230
|
+
padding=(1, 1)
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class HitlGate(Static):
|
|
235
|
+
"""Dock panel for Human-In-The-Loop interventions."""
|
|
236
|
+
hitl_active = reactive(False)
|
|
237
|
+
action_target = reactive("system_call")
|
|
238
|
+
|
|
239
|
+
def render(self) -> Panel:
|
|
240
|
+
if not self.hitl_active:
|
|
241
|
+
msg = Text.assemble(
|
|
242
|
+
("● MODE: ", "#52525B"),
|
|
243
|
+
("AUTONOMOUS EXECUTION", "bold #10B981"),
|
|
244
|
+
(" │ Press ", "dim white"),
|
|
245
|
+
("[SPACE]", "bold white"),
|
|
246
|
+
(" to pause execution loop. Type instructions below.", "dim white")
|
|
247
|
+
)
|
|
248
|
+
return Panel(msg, border_style="#27272A", style="on #08090A")
|
|
249
|
+
|
|
250
|
+
msg = Text.assemble(
|
|
251
|
+
("⚠️ HITL INTERCEPT ACTIVE: ", "bold black on #F59E0B"),
|
|
252
|
+
(f" Agent requested operator authorization for: `{self.action_target}`\n", "bold white"),
|
|
253
|
+
("❯ Choose Action: ", "bold #F59E0B"),
|
|
254
|
+
("[A]", "bold #10B981"), (" Approve ", "white"),
|
|
255
|
+
("[D]", "bold #EF4444"), (" Deny & Abort ", "white"),
|
|
256
|
+
("[SPACE]", "bold #00F0FF"), (" Toggle Pause", "white")
|
|
257
|
+
)
|
|
258
|
+
return Panel(msg, border_style="#F59E0B", style="on #0D1117")
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# =====================================================================
|
|
262
|
+
# 3. ROOT TUI APPLICATION
|
|
263
|
+
# =====================================================================
|
|
264
|
+
|
|
265
|
+
# (label, model, provider, endpoint) — daemon validates and persists the choice.
|
|
266
|
+
MODEL_CATALOG = [
|
|
267
|
+
("Ollama Cloud · glm-5.3-flash", "glm-5.3-flash", "openai", "https://ollama.com/v1"),
|
|
268
|
+
("Ollama Cloud · kimi-k2.7-code", "kimi-k2.7-code", "openai", "https://ollama.com/v1"),
|
|
269
|
+
("OpenAI · gpt-4.1-mini", "gpt-4.1-mini", "openai", "https://api.openai.com/v1"),
|
|
270
|
+
("Groq · llama-3.3-70b-versatile", "llama-3.3-70b-versatile", "openai", "https://api.groq.com/openai/v1"),
|
|
271
|
+
("Mistral · mistral-large-latest", "mistral-large-latest", "openai", "https://api.mistral.ai/v1"),
|
|
272
|
+
("DeepSeek · deepseek-chat", "deepseek-chat", "openai", "https://api.deepseek.com/v1"),
|
|
273
|
+
("Anthropic · claude-sonnet-4-5", "claude-sonnet-4-5", "anthropic", "https://api.anthropic.com"),
|
|
274
|
+
("Google · gemini-2.5-pro", "gemini-2.5-pro", "gemini", "https://generativelanguage.googleapis.com"),
|
|
275
|
+
("Ollama local · llama3.2", "llama3.2", "ollama", "http://localhost:11434"),
|
|
276
|
+
]
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class ModelSelectScreen(ModalScreen):
|
|
280
|
+
"""Picker modal: memilih model dari katalog dan meneruskan ke daemon."""
|
|
281
|
+
|
|
282
|
+
def compose(self) -> ComposeResult:
|
|
283
|
+
with Vertical():
|
|
284
|
+
yield Static("Select AI model — the provider key must exist in .env", classes="model-hint")
|
|
285
|
+
yield OptionList(*[label for label, *_ in MODEL_CATALOG], id="model-options")
|
|
286
|
+
|
|
287
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
288
|
+
self.dismiss(str(event.option.id))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class LiberApp(App):
|
|
292
|
+
"""Aplikasi TUI Utama Harness Liber."""
|
|
293
|
+
|
|
294
|
+
CSS = """
|
|
295
|
+
Screen {
|
|
296
|
+
background: #08090A;
|
|
297
|
+
color: #E2E8F0;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
HeaderBar {
|
|
301
|
+
height: 3;
|
|
302
|
+
margin: 0;
|
|
303
|
+
padding: 0;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
#main-container {
|
|
307
|
+
height: 1fr;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
#stream-pane {
|
|
311
|
+
width: 70%;
|
|
312
|
+
border: round #27272A;
|
|
313
|
+
background: #08090A;
|
|
314
|
+
margin-right: 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
#sidebar-pane {
|
|
318
|
+
width: 30%;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
HitlGate {
|
|
322
|
+
height: 4;
|
|
323
|
+
margin-top: 0;
|
|
324
|
+
margin-bottom: 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
#prompt-input {
|
|
328
|
+
dock: bottom;
|
|
329
|
+
height: 3;
|
|
330
|
+
border: round #00F0FF;
|
|
331
|
+
background: #0D1117;
|
|
332
|
+
color: #E2E8F0;
|
|
333
|
+
padding: 0 1;
|
|
334
|
+
margin-bottom: 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
RichLog {
|
|
338
|
+
background: #08090A;
|
|
339
|
+
color: #E2E8F0;
|
|
340
|
+
scrollbar-gutter: stable;
|
|
341
|
+
padding: 0 1;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
ModelSelectScreen {
|
|
345
|
+
align: center middle;
|
|
346
|
+
background: rgba(8, 9, 10, 0.85);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
ModelSelectScreen Vertical {
|
|
350
|
+
width: 52;
|
|
351
|
+
height: auto;
|
|
352
|
+
max-height: 26;
|
|
353
|
+
border: round #8B5CF6;
|
|
354
|
+
background: #0D1117;
|
|
355
|
+
padding: 0 1;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#model-options {
|
|
359
|
+
height: auto;
|
|
360
|
+
max-height: 18;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.model-hint {
|
|
364
|
+
color: #94A3B8;
|
|
365
|
+
padding: 1 1 0 1;
|
|
366
|
+
}
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
BINDINGS = [
|
|
370
|
+
Binding("space", "toggle_pause", "Pause/Resume"),
|
|
371
|
+
Binding("m", "switch_model", "Model"),
|
|
372
|
+
Binding("a", "approve_action", "Approve HITL"),
|
|
373
|
+
Binding("d", "deny_action", "Deny HITL"),
|
|
374
|
+
Binding("c", "clear_logs", "Clear Logs"),
|
|
375
|
+
Binding("q", "quit", "Quit Liber")
|
|
376
|
+
]
|
|
377
|
+
|
|
378
|
+
def compose(self) -> ComposeResult:
|
|
379
|
+
yield HeaderBar(id="header-bar")
|
|
380
|
+
with Horizontal(id="main-container"):
|
|
381
|
+
with Vertical(id="stream-pane"):
|
|
382
|
+
yield RichLog(id="agent-log", wrap=True, highlight=False, markup=True)
|
|
383
|
+
with Vertical(id="sidebar-pane"):
|
|
384
|
+
yield TelemetrySidebar(id="telemetry-sidebar")
|
|
385
|
+
yield HitlGate(id="hitl-gate")
|
|
386
|
+
yield Input(placeholder="Type instructions for agent (Press Enter to send)...", id="prompt-input")
|
|
387
|
+
yield Footer()
|
|
388
|
+
|
|
389
|
+
async def on_mount(self) -> None:
|
|
390
|
+
"""Initialize daemon connection and mount widgets."""
|
|
391
|
+
self.log_widget = self.query_one("#agent-log", RichLog)
|
|
392
|
+
self.hitl_widget = self.query_one("#hitl-gate", HitlGate)
|
|
393
|
+
self.sidebar_widget = self.query_one("#telemetry-sidebar", TelemetrySidebar)
|
|
394
|
+
self.header_widget = self.query_one("#header-bar", HeaderBar)
|
|
395
|
+
self.prompt_input = self.query_one("#prompt-input", Input)
|
|
396
|
+
|
|
397
|
+
self.hitl_signal: Optional[asyncio.Event] = None
|
|
398
|
+
self.hitl_verdict: bool = False
|
|
399
|
+
self.agent_busy: bool = False
|
|
400
|
+
|
|
401
|
+
# Welcome operator in stream log
|
|
402
|
+
self.log_widget.write(
|
|
403
|
+
Text.assemble(
|
|
404
|
+
LiberCue.badge("SYSTEM", "black on #00FF87"),
|
|
405
|
+
(f" Liber Agentic Harness v{LOCAL_FACTS['version']} active. Engine: ", "bold white"),
|
|
406
|
+
(f"{DAEMON_URL}\n", "#38BDF8"),
|
|
407
|
+
(" Type commands in input bar below or press ", "dim white"),
|
|
408
|
+
("[SPACE]", "bold white"),
|
|
409
|
+
(" to toggle pause.\n", "dim white")
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# Check daemon availability
|
|
414
|
+
asyncio.create_task(self.check_and_ensure_daemon())
|
|
415
|
+
|
|
416
|
+
async def check_and_ensure_daemon(self) -> None:
|
|
417
|
+
"""Verify daemon status and fetch active agent profile."""
|
|
418
|
+
try:
|
|
419
|
+
async with httpx.AsyncClient(timeout=2.0) as client:
|
|
420
|
+
res = await client.get(f"{DAEMON_URL}/ps", headers=auth_headers())
|
|
421
|
+
if res.status_code == 200:
|
|
422
|
+
data = res.json()
|
|
423
|
+
agents = data.get("data", [])
|
|
424
|
+
if agents:
|
|
425
|
+
agent = agents[0]
|
|
426
|
+
model = agent.get("model") or agent.get("config", {}).get("engine", {}).get("model", "—")
|
|
427
|
+
self.header_widget.model_name = model
|
|
428
|
+
self.sidebar_widget.agents = agents
|
|
429
|
+
self.log_widget.write(
|
|
430
|
+
Text.assemble(
|
|
431
|
+
LiberCue.badge("DAEMON", "black on #00F0FF"),
|
|
432
|
+
(f" Connected to agent: `{agent.get('id', 'dev-bot')}` [ONLINE]\n", "#10B981")
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
return
|
|
436
|
+
except Exception:
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
# Offline guidance
|
|
440
|
+
self.log_widget.write(
|
|
441
|
+
Text.assemble(
|
|
442
|
+
LiberCue.badge("OFFLINE", "black on #F59E0B"),
|
|
443
|
+
(" Daemon is offline on port 4567. Run 'liber spawn' in terminal or wait for auto-spawn...", "dim #F59E0B")
|
|
444
|
+
)
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
# -----------------------------------------------------------------
|
|
448
|
+
# Event-Driven Backend Streaming Queue
|
|
449
|
+
# -----------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
async def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
452
|
+
"""Triggered when the operator submits an instruction in the prompt bar."""
|
|
453
|
+
prompt = event.value.strip()
|
|
454
|
+
if not prompt or self.agent_busy:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
self.prompt_input.value = ""
|
|
458
|
+
asyncio.create_task(self.dispatch_agent_cycle(prompt))
|
|
459
|
+
|
|
460
|
+
async def dispatch_agent_cycle(self, prompt: str) -> None:
|
|
461
|
+
"""Connects prompt to backend engine real-time SSE stream."""
|
|
462
|
+
self.agent_busy = True
|
|
463
|
+
self.header_widget.state_label = "EXECUTING"
|
|
464
|
+
self.query_one("#stream-pane").styles.border = ("round", "#00F0FF")
|
|
465
|
+
|
|
466
|
+
# Display operator prompt in log stream
|
|
467
|
+
self.log_widget.write(
|
|
468
|
+
Text.assemble(
|
|
469
|
+
LiberCue.badge("OPERATOR", "black on #00F0FF"),
|
|
470
|
+
(f" {prompt}", "bold bright_white")
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
try:
|
|
475
|
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
|
476
|
+
# Streaming POST ke /chat/stream
|
|
477
|
+
async with client.stream(
|
|
478
|
+
"POST",
|
|
479
|
+
f"{DAEMON_URL}/chat/stream",
|
|
480
|
+
json={"agentId": "dev-bot", "message": prompt},
|
|
481
|
+
headers=auth_headers()
|
|
482
|
+
) as response:
|
|
483
|
+
if response.status_code != 200:
|
|
484
|
+
self.log_widget.write(
|
|
485
|
+
Text.assemble(
|
|
486
|
+
LiberCue.badge("ERROR", "white on #EF4444"),
|
|
487
|
+
(f" Gateway HTTP {response.status_code}: Gagal memulai stream.", "red")
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
current_event = ""
|
|
493
|
+
async for line in response.aiter_lines():
|
|
494
|
+
line = line.strip()
|
|
495
|
+
if not line:
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
if line.startswith("event:"):
|
|
499
|
+
current_event = line.replace("event:", "").strip()
|
|
500
|
+
continue
|
|
501
|
+
|
|
502
|
+
if line.startswith("data:"):
|
|
503
|
+
raw_data = line.replace("data:", "").strip()
|
|
504
|
+
try:
|
|
505
|
+
payload = json.loads(raw_data)
|
|
506
|
+
except Exception:
|
|
507
|
+
payload = {"content": raw_data}
|
|
508
|
+
|
|
509
|
+
await self.handle_backend_event(current_event, payload)
|
|
510
|
+
|
|
511
|
+
except httpx.ConnectError:
|
|
512
|
+
self.log_widget.write(
|
|
513
|
+
Text.assemble(
|
|
514
|
+
LiberCue.badge("ERROR", "white on #EF4444"),
|
|
515
|
+
(" Unable to connect to Liber daemon (127.0.0.1:4567). Ensure daemon is running.", "#EF4444")
|
|
516
|
+
)
|
|
517
|
+
)
|
|
518
|
+
except Exception as e:
|
|
519
|
+
self.log_widget.write(
|
|
520
|
+
Text.assemble(
|
|
521
|
+
LiberCue.badge("EXCEPTION", "white on #EF4444"),
|
|
522
|
+
(f" {str(e)}", "red")
|
|
523
|
+
)
|
|
524
|
+
)
|
|
525
|
+
finally:
|
|
526
|
+
self.agent_busy = False
|
|
527
|
+
self.header_widget.state_label = "IDLE"
|
|
528
|
+
self.query_one("#stream-pane").styles.border = ("round", "#27272A")
|
|
529
|
+
|
|
530
|
+
async def handle_backend_event(self, event: str, data: Dict[str, Any]) -> None:
|
|
531
|
+
"""
|
|
532
|
+
Event Parser for agent harness:
|
|
533
|
+
- thought -> LiberCue.badge("THOUGHT", ...)
|
|
534
|
+
- tool_dispatch -> LiberCue.tool_dispatch_box(tool_name, cmd)
|
|
535
|
+
- hitl_intercept -> Trigger breakpoint HITL
|
|
536
|
+
- observation -> LiberCue.badge("OBSERVE", ...)
|
|
537
|
+
- telemetry -> Reactive updates on header and sidebar
|
|
538
|
+
- final -> Output final answer
|
|
539
|
+
"""
|
|
540
|
+
# 0. Live draft stream (raw model output, not yet audited)
|
|
541
|
+
if event == "draft_start":
|
|
542
|
+
self.header_widget.state_label = "DRAFTING"
|
|
543
|
+
return
|
|
544
|
+
if event == "draft":
|
|
545
|
+
return
|
|
546
|
+
if event == "draft_end":
|
|
547
|
+
self.header_widget.state_label = "IDLE"
|
|
548
|
+
if data.get("failed"):
|
|
549
|
+
self.log_widget.write(
|
|
550
|
+
Text.assemble(
|
|
551
|
+
LiberCue.badge("DRAFT", "white on #F59E0B"),
|
|
552
|
+
(" stream interrupted — falling back to a complete audited answer", "dim #F59E0B")
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
else:
|
|
556
|
+
self.log_widget.write(
|
|
557
|
+
Text.assemble(
|
|
558
|
+
LiberCue.badge("DRAFT", "white on #8B5CF6"),
|
|
559
|
+
(f" {data.get('chars', 0)} chars streamed · UNVERIFIED until the audit passes", "dim #A78BFA")
|
|
560
|
+
)
|
|
561
|
+
)
|
|
562
|
+
return
|
|
563
|
+
|
|
564
|
+
if event == "subagent_start":
|
|
565
|
+
self.log_widget.write(
|
|
566
|
+
Text.assemble(
|
|
567
|
+
LiberCue.badge("SUBAGENT", "black on #06B6D4"),
|
|
568
|
+
(f" {data.get('id', '')} · {data.get('goal', '')}", "dim #67E8F9")
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return
|
|
572
|
+
if event == "subagent_end":
|
|
573
|
+
self.log_widget.write(
|
|
574
|
+
Text.assemble(
|
|
575
|
+
LiberCue.badge("SUBAGENT", "white on #0891B2"),
|
|
576
|
+
(f" {data.get('id', '')} · {data.get('status', 'done')}", "dim #67E8F9")
|
|
577
|
+
)
|
|
578
|
+
)
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
# 1. Thought / Reasoning
|
|
582
|
+
if event == "thought":
|
|
583
|
+
thought_text = data.get("content", "")
|
|
584
|
+
self.log_widget.write(
|
|
585
|
+
Text.assemble(
|
|
586
|
+
LiberCue.badge("REASONING", "black on #8B5CF6"),
|
|
587
|
+
(f" {thought_text}", "italic #E2E8F0")
|
|
588
|
+
)
|
|
589
|
+
)
|
|
590
|
+
|
|
591
|
+
# 2. Tool Dispatch
|
|
592
|
+
elif event == "tool_dispatch":
|
|
593
|
+
tool_name = data.get("tool", "sandbox_exec")
|
|
594
|
+
cmd = data.get("command", "")
|
|
595
|
+
self.log_widget.write(LiberCue.tool_dispatch_box(tool_name, cmd))
|
|
596
|
+
|
|
597
|
+
# 3. Sensitive Action -> HITL Breakpoint
|
|
598
|
+
elif event == "hitl_intercept":
|
|
599
|
+
target = data.get("target", "command")
|
|
600
|
+
self.hitl_widget.action_target = target
|
|
601
|
+
self.hitl_widget.hitl_active = True
|
|
602
|
+
self.query_one("#stream-pane").styles.border = ("round", "#F59E0B")
|
|
603
|
+
|
|
604
|
+
self.log_widget.write(
|
|
605
|
+
Text.assemble(
|
|
606
|
+
LiberCue.badge("CRIT/WARN", "black on #F59E0B"),
|
|
607
|
+
(f" [BREAKPOINT ACTIVATED] Policy requires manual operator authorization for: `{target}`", "bold #F59E0B")
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
# Signal barrier
|
|
612
|
+
self.hitl_signal = asyncio.Event()
|
|
613
|
+
await self.hitl_signal.wait()
|
|
614
|
+
|
|
615
|
+
# Send verdict back to daemon backend
|
|
616
|
+
verdict_str = "approve" if self.hitl_verdict else "deny"
|
|
617
|
+
try:
|
|
618
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
619
|
+
await client.post(f"{DAEMON_URL}/hitl/verdict", json={"verdict": verdict_str}, headers=auth_headers())
|
|
620
|
+
except Exception:
|
|
621
|
+
pass
|
|
622
|
+
|
|
623
|
+
# 4. Observation Stage
|
|
624
|
+
elif event == "observation":
|
|
625
|
+
output = data.get("output", "")
|
|
626
|
+
exit_code = data.get("exit_code", 0)
|
|
627
|
+
duration_ms = data.get("duration_ms", 0)
|
|
628
|
+
approved = data.get("approved", True)
|
|
629
|
+
|
|
630
|
+
color = "#10B981" if approved and exit_code == 0 else "#EF4444"
|
|
631
|
+
self.log_widget.write(
|
|
632
|
+
Text.assemble(
|
|
633
|
+
LiberCue.badge("OBSERVE", "white on #27272A"),
|
|
634
|
+
(f" STDOUT: {output}\n", color),
|
|
635
|
+
(f"↳ EXIT: {exit_code} │ DURATION: {duration_ms}ms │ POLICY: OK", "dim #71717A")
|
|
636
|
+
)
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
# 5. Reactive Telemetry Updates
|
|
640
|
+
elif event == "telemetry":
|
|
641
|
+
if "tok_speed" in data:
|
|
642
|
+
self.header_widget.tok_speed = float(data["tok_speed"])
|
|
643
|
+
if "ctx_used" in data:
|
|
644
|
+
self.header_widget.ctx_used = int(data["ctx_used"])
|
|
645
|
+
if "ctx_total" in data:
|
|
646
|
+
self.header_widget.ctx_total = int(data["ctx_total"])
|
|
647
|
+
if "latency" in data:
|
|
648
|
+
self.header_widget.latency = int(data["latency"])
|
|
649
|
+
if "cost_accum" in data:
|
|
650
|
+
self.sidebar_widget.cost_accum = float(data["cost_accum"])
|
|
651
|
+
|
|
652
|
+
# 5b. Grounding gate events (zero-hallucination layer)
|
|
653
|
+
elif event == "grounding_audit":
|
|
654
|
+
codes = ", ".join(sorted({v.get("code", "?") for v in data.get("violations", [])}))
|
|
655
|
+
self.log_widget.write(
|
|
656
|
+
Text.assemble(
|
|
657
|
+
LiberCue.badge("GROUNDING", "black on #F59E0B"),
|
|
658
|
+
(f" repair {data.get('attempt', 1)}/{data.get('max_repairs', 2)} — blocked unverified claims: {codes}", "bold #F59E0B")
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
elif event == "grounding_warning":
|
|
663
|
+
n = len(data.get("violations", []))
|
|
664
|
+
self.log_widget.write(
|
|
665
|
+
Text.assemble(
|
|
666
|
+
LiberCue.badge("UNVERIFIED", "white on #EF4444"),
|
|
667
|
+
(f" {n} claim(s) shipped without receipts — see GROUNDING AUDIT footer", "bold #EF4444")
|
|
668
|
+
)
|
|
669
|
+
)
|
|
670
|
+
|
|
671
|
+
elif event == "grounding_pass":
|
|
672
|
+
self.log_widget.write(
|
|
673
|
+
Text.assemble(
|
|
674
|
+
LiberCue.badge("GROUNDED", "black on #10B981"),
|
|
675
|
+
(f" {data.get('claims_checked', 0)} claim(s) bound to {data.get('receipts', 0)} receipt(s)", "dim #10B981")
|
|
676
|
+
)
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
# 6. Final Agent Response
|
|
680
|
+
elif event == "final":
|
|
681
|
+
final_text = data.get("response", "")
|
|
682
|
+
duration = data.get("durationMs", 0)
|
|
683
|
+
iterations = data.get("iterations", 1)
|
|
684
|
+
grounding = data.get("grounding") or {}
|
|
685
|
+
status = grounding.get("status", "DISABLED")
|
|
686
|
+
|
|
687
|
+
if status == "PASS":
|
|
688
|
+
cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns │ GROUNDED · {len(grounding.get('ledger', {}).get('receipts', []))} receipts]"
|
|
689
|
+
cycle_style = "dim #10B981"
|
|
690
|
+
elif status == "REPAIR":
|
|
691
|
+
cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns │ ⚠ {len(grounding.get('violations', []))} UNVERIFIED CLAIM(S)]"
|
|
692
|
+
cycle_style = "bold #F59E0B"
|
|
693
|
+
else:
|
|
694
|
+
cycle = f"↳ [CYCLE COMPLETE: {duration}ms │ {iterations} turns]"
|
|
695
|
+
cycle_style = "dim #10B981"
|
|
696
|
+
|
|
697
|
+
self.log_widget.write(
|
|
698
|
+
Text.assemble(
|
|
699
|
+
LiberCue.badge("RESPONSE", "black on #00FF87"),
|
|
700
|
+
(f" {final_text}\n", "bright_white"),
|
|
701
|
+
(cycle, cycle_style)
|
|
702
|
+
)
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# 7. Error event
|
|
706
|
+
elif event == "error":
|
|
707
|
+
err_msg = data.get("error", "Unknown error")
|
|
708
|
+
self.log_widget.write(
|
|
709
|
+
Text.assemble(
|
|
710
|
+
LiberCue.badge("ERROR", "white on #EF4444"),
|
|
711
|
+
(f" {err_msg}", "bold #EF4444")
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
# -----------------------------------------------------------------
|
|
716
|
+
# Key Action Handlers (Operator Interventions)
|
|
717
|
+
# -----------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
def action_switch_model(self) -> None:
|
|
720
|
+
"""Buka modal pemilihan model AI."""
|
|
721
|
+
self.push_screen(ModelSelectScreen(), self.on_model_selected)
|
|
722
|
+
|
|
723
|
+
def on_model_selected(self, label: Optional[str]) -> None:
|
|
724
|
+
if not label:
|
|
725
|
+
return
|
|
726
|
+
preset = next((p for p in MODEL_CATALOG if p[0] == label), None)
|
|
727
|
+
if not preset:
|
|
728
|
+
return
|
|
729
|
+
_, model, provider, endpoint = preset
|
|
730
|
+
self.run_worker(self.apply_model_switch(model, provider, endpoint))
|
|
731
|
+
|
|
732
|
+
@work
|
|
733
|
+
async def apply_model_switch(self, model: str, provider: str, endpoint: str) -> None:
|
|
734
|
+
"""POST /model/switch dan perbarui header dari respons daemon."""
|
|
735
|
+
try:
|
|
736
|
+
async with httpx.AsyncClient(timeout=8.0) as client:
|
|
737
|
+
res = await client.post(
|
|
738
|
+
f"{DAEMON_URL}/model/switch",
|
|
739
|
+
json={"model": model, "provider": provider, "endpoint": endpoint},
|
|
740
|
+
headers=auth_headers(),
|
|
741
|
+
)
|
|
742
|
+
if res.status_code == 200:
|
|
743
|
+
data = res.json()
|
|
744
|
+
if data.get("success"):
|
|
745
|
+
active = data.get("active") or {}
|
|
746
|
+
self.header_widget.model_name = active.get("model", model)
|
|
747
|
+
self.sidebar_widget.gateway = active.get("endpoint", "").replace("https://", "").replace("http://", "") or self.sidebar_widget.gateway
|
|
748
|
+
self.log_widget.write(
|
|
749
|
+
Text.assemble(
|
|
750
|
+
LiberCue.badge("MODEL", "black on #8B5CF6"),
|
|
751
|
+
(f" switched to {active.get('model', model)} via {active.get('provider', provider)}", "#10B981"),
|
|
752
|
+
)
|
|
753
|
+
)
|
|
754
|
+
return
|
|
755
|
+
try:
|
|
756
|
+
detail = res.json().get("error", "")
|
|
757
|
+
except Exception:
|
|
758
|
+
detail = f"HTTP {res.status_code}"
|
|
759
|
+
self.log_widget.write(
|
|
760
|
+
Text.assemble(
|
|
761
|
+
LiberCue.badge("MODEL", "white on #EF4444"),
|
|
762
|
+
(f" switch failed: {detail}", "red"),
|
|
763
|
+
)
|
|
764
|
+
)
|
|
765
|
+
except Exception as exc: # noqa: BLE001
|
|
766
|
+
self.log_widget.write(
|
|
767
|
+
Text.assemble(
|
|
768
|
+
LiberCue.badge("MODEL", "white on #EF4444"),
|
|
769
|
+
(f" switch failed: {exc}", "red"),
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
def action_toggle_pause(self) -> None:
|
|
774
|
+
"""Pause or resume the execution loop."""
|
|
775
|
+
self.hitl_widget.hitl_active = not self.hitl_widget.hitl_active
|
|
776
|
+
if self.hitl_widget.hitl_active:
|
|
777
|
+
self.query_one("#stream-pane").styles.border = ("round", "#F59E0B")
|
|
778
|
+
self.log_widget.write(
|
|
779
|
+
Text.assemble(LiberCue.badge("PAUSED", "black on #F59E0B"), (" Execution halted by operator.", "white"))
|
|
780
|
+
)
|
|
781
|
+
else:
|
|
782
|
+
self.query_one("#stream-pane").styles.border = ("round", "#27272A")
|
|
783
|
+
self.log_widget.write(
|
|
784
|
+
Text.assemble(LiberCue.badge("RESUMED", "black on #10B981"), (" Resuming agent loop cycle...", "white"))
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
def action_approve_action(self) -> None:
|
|
788
|
+
"""Approve sensitive tool/command execution."""
|
|
789
|
+
if self.hitl_widget.hitl_active:
|
|
790
|
+
self.hitl_widget.hitl_active = False
|
|
791
|
+
self.hitl_verdict = True
|
|
792
|
+
self.query_one("#stream-pane").styles.border = ("round", "#10B981")
|
|
793
|
+
self.log_widget.write(
|
|
794
|
+
Text.assemble(
|
|
795
|
+
LiberCue.badge("APPROVED", "black on #10B981"),
|
|
796
|
+
(f" Authorization granted for `{self.hitl_widget.action_target}`. Executing...", "white")
|
|
797
|
+
)
|
|
798
|
+
)
|
|
799
|
+
if self.hitl_signal:
|
|
800
|
+
self.hitl_signal.set()
|
|
801
|
+
|
|
802
|
+
def action_deny_action(self) -> None:
|
|
803
|
+
"""Deny sensitive action and require agent to re-plan."""
|
|
804
|
+
if self.hitl_widget.hitl_active:
|
|
805
|
+
self.hitl_widget.hitl_active = False
|
|
806
|
+
self.hitl_verdict = False
|
|
807
|
+
self.query_one("#stream-pane").styles.border = ("round", "#EF4444")
|
|
808
|
+
self.log_widget.write(
|
|
809
|
+
Text.assemble(
|
|
810
|
+
LiberCue.badge("REJECTED", "white on #EF4444"),
|
|
811
|
+
(f" Action `{self.hitl_widget.action_target}` denied by operator. Sending feedback to agent.", "bold #EF4444")
|
|
812
|
+
)
|
|
813
|
+
)
|
|
814
|
+
if self.hitl_signal:
|
|
815
|
+
self.hitl_signal.set()
|
|
816
|
+
|
|
817
|
+
def action_clear_logs(self) -> None:
|
|
818
|
+
"""Clear log pane."""
|
|
819
|
+
self.log_widget.clear()
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
if __name__ == "__main__":
|
|
823
|
+
app = LiberApp()
|
|
824
|
+
app.run()
|
|
825
|
+
|