Thinking improvements.
This commit is contained in:
+2
-2
@@ -149,7 +149,7 @@ agents:
|
||||
prompt_file: miranda_chief_of_staff.md
|
||||
provider: vastblueai_thinking # Routing and synthesis benefit from deep reasoning
|
||||
model: ""
|
||||
temperature: 0.6 # Qwen3 thinking-mode recommendation
|
||||
temperature: 1.0 # Qwen3 thinking-mode recommendation
|
||||
max_tokens: 32768 # Synthesizes the longest outputs; needs headroom
|
||||
stateful: true
|
||||
|
||||
@@ -159,7 +159,7 @@ agents:
|
||||
prompt_file: vera_auditor.md
|
||||
provider: vastblueai_thinking # Careful auditing benefits from thinking
|
||||
model: ""
|
||||
temperature: 0.6 # Qwen3 thinking-mode recommendation
|
||||
temperature: 1.0 # Qwen3 thinking-mode recommendation
|
||||
max_tokens: 16384
|
||||
stateful: false
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"session_id": "20260403_101101",
|
||||
"session_start": "2026-04-03T10:11:26",
|
||||
"tasks": {
|
||||
"T-20260403-001-A": {
|
||||
"task_id": "T-20260403-001-A",
|
||||
"directive": "Analyze Evelyn's agent prompt.",
|
||||
"assigned_to": "clio",
|
||||
"brief": "TASK BRIEF\nIssued by: Miranda\nTo: Clio — Director of Analysis\nTask ID: T-20260403-001\nDirective: Analyze Evelyn's agent prompt specifications for the 5 EHS Specialist Agents.\nScope:\n - Review the system prompts designed for Monitor, Tracker, Triage, Validator, and Generator agents\n - Validate alignment with security architecture (T-20260402-003)\n - Identify any gaps in regulatory compliance controls, especially for SCAQMD rule ingestion\n - Confirm the Validator Agent's role as security gate is adequately enforced\nConstraints:\n - Deadline: 2026-04-05 14:00 (2 business days)\n - Depth: Technical review sufficient to confirm or reject prompt readiness for instantiation\n - Assumptions: Evelyn's prompt documents are available in the secure repository\nDependencies: T-20260402-003 (Clio's Architecture Design); Evelyn's Agent Generation System (Evelyn)\nReturn format: Analysis memo with Go/No-Go recommendation for agent instantiation\n```\n\n---\n\n**IMPLICATIONS**\n\nWhile Clio performs this analysis:\n- Cole must hold all EHS construction tasks pending T-20260402-004 approval\n- Evelyn cannot finalize agent instantiation until prompts are validated\n\nI will update the Standing Brief when Clio's analysis returns. Please confirm this routing is acceptable, or specify if you require a different focus for this review.\n\n---\n\n**STANDING BRIEF NOTE**\n\nThis will be recorded at session close. No changes to active tasks yet — T-20260402-004 remains pending Principal Authorization, but now has an additional validation gate before Evelyn can proceed.",
|
||||
"status": "complete",
|
||||
"output": "",
|
||||
"error": "",
|
||||
"created_at": "2026-04-03T10:11:26",
|
||||
"updated_at": "2026-04-03T10:13:18"
|
||||
}
|
||||
}
|
||||
}
|
||||
+77
-11
@@ -526,17 +526,47 @@ All agent calls default to `stream=True`. The flow:
|
||||
2. `call_async_streaming()` on `OpenAIClient` connects with `stream=True` and
|
||||
yields `('thinking', text)` and `('content', text)` tuples as chunks arrive.
|
||||
Thinking tokens come from `delta.reasoning_content` (Qwen3 / DeepSeek R1).
|
||||
3. `stream_agent_output()` in `ui.py` consumes the async generator and renders
|
||||
output live using Rich's `Live` display.
|
||||
3. `stream_agent_output()` in `ui.py` consumes the async generator using a
|
||||
two-phase rendering strategy (see below).
|
||||
|
||||
**Stream modes** (passed as `stream_mode` to `call_agent_async`):
|
||||
|
||||
| Mode | Used for | Behaviour |
|
||||
|------|----------|-----------|
|
||||
| `"deliverable"` | Miranda's routing/synthesis responses | Full panel with thinking + content |
|
||||
| `"deliverable"` | Miranda's routing/synthesis responses | Two-phase thinking + content render |
|
||||
| `"audit"` | Vera's audit memos | Yellow thinking identity; verdict-aware border colour |
|
||||
| `"background"` | Standing brief update | Compact transient spinner; no panel |
|
||||
|
||||
**Two-phase rendering** (`"deliverable"` and `"audit"` modes):
|
||||
|
||||
The generator is consumed in two distinct, non-competing display regions to
|
||||
prevent thinking text from scrolling off the top of the screen as content grows.
|
||||
|
||||
*Phase 1 — Thinking* (`transient=True`, `vertical_overflow="crop"`):
|
||||
- A compact Live display shows the last 8 lines of thinking text with a
|
||||
`+N lines earlier` overflow indicator.
|
||||
- `transient=True`: the display clears from the screen when Phase 1 ends —
|
||||
it does not permanently occupy lines in the scroll buffer.
|
||||
- `vertical_overflow="crop"`: the panel is hard-capped at terminal height and
|
||||
can never grow to push content off screen.
|
||||
- The generator is iterated with manual `__anext__()` calls (not `async for`)
|
||||
so that breaking out of the loop does **not** call `.aclose()` on the
|
||||
generator — the same iterator is resumed in Phase 2.
|
||||
|
||||
*Thinking summary* (printed between phases):
|
||||
After Phase 1 a single persistent line is printed to the scroll buffer:
|
||||
```
|
||||
▸ Miranda — thinking complete [4,821 chars · 47 lines] /thinking to expand
|
||||
```
|
||||
This line is always visible in the terminal's scrollback history.
|
||||
|
||||
*Phase 2 — Content* (`transient=False`, `vertical_overflow="visible"`):
|
||||
- A separate Live display picks up the same iterator and streams content.
|
||||
- The final Markdown panel (with verdict border in audit mode) is rendered
|
||||
inside the Live block before it exits, avoiding any erase/reprint flash.
|
||||
- Content is the only thing in this display — thinking can never compete
|
||||
for vertical space here.
|
||||
|
||||
`_last_stream_rendered` is set to `True` after `stream_agent_output` completes,
|
||||
causing the subsequent `print_deliverable()` / `print_audit_memo()` to become
|
||||
no-ops (avoiding double rendering).
|
||||
@@ -573,7 +603,10 @@ that adding or recoloring an agent in config is reflected automatically.
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `stream_agent_output(agent_name, gen, mode)` | Live streaming render with thinking panel |
|
||||
| `get_directive()` | Interactive `Principal ›` prompt with inline slash-command completion |
|
||||
| `set_completions(entries)` | Register `(name, description)` pairs for the completion dropdown |
|
||||
| `stream_agent_output(agent_name, gen, mode)` | Two-phase live streaming render |
|
||||
| `_build_thinking_panel(text, agent, mode)` | Thinking panel renderable (last 8 lines, height-capped) |
|
||||
| `print_agent_panel(agent_name, content)` | Static Markdown panel for an agent |
|
||||
| `print_deliverable(content, task_id)` | Orchestrator's synthesized output |
|
||||
| `print_audit_memo(content, task_id)` | Vera's memo with verdict-based border |
|
||||
@@ -584,15 +617,46 @@ that adding or recoloring an agent in config is reflected automatically.
|
||||
| `print_session_footer(count)` | Closing rule |
|
||||
| `print_status(...)` | `/status` command output |
|
||||
| `print_agents(configs, provider)` | `/agents` roster table |
|
||||
| `get_directive()` | Styled `Principal ›` input prompt |
|
||||
| `get_last_thinking()` | Returns stored thinking from last response |
|
||||
|
||||
### Slash-command completion
|
||||
|
||||
`get_directive()` uses a `prompt_toolkit` `PromptSession` to provide an
|
||||
inline vertical dropdown as the user types at the `Principal ›` prompt.
|
||||
|
||||
**Behaviour:**
|
||||
- Typing `/` immediately opens a dropdown showing the top 5 matching commands
|
||||
with their descriptions alongside each name.
|
||||
- Each additional character narrows the list in real time (`complete_while_typing=True`).
|
||||
- Arrow keys navigate the list; Enter or Tab accepts a selection; Escape dismisses.
|
||||
- Up/Down arrows outside the dropdown navigate the session's input history
|
||||
(backed by `InMemoryHistory` — persists for the lifetime of the process).
|
||||
- No dropdown appears for non-slash input — the completer only activates when
|
||||
the text starts with `/`.
|
||||
|
||||
**Wiring:**
|
||||
`orchestrator.py` calls `ui.set_completions([(name, desc), ...])` once at
|
||||
module import time, after all `@command` decorators have executed. The list is
|
||||
built from the `COMMANDS` registry so any new `@command` is automatically
|
||||
included with no extra wiring.
|
||||
|
||||
`_completions` is a module-level `list[tuple[str, str]]` sorted alphabetically.
|
||||
The `_SlashCompleter` class reads it at query time, so `set_completions` can be
|
||||
called at any point before the user types.
|
||||
|
||||
**Fallback:** if `prompt_toolkit` is not installed, `get_directive()` falls
|
||||
back to a plain styled `input()` with no completion.
|
||||
|
||||
### Thinking panel behaviour
|
||||
|
||||
During the thinking phase a dim panel shows a rolling 300-character window
|
||||
of the thinking text with an overflow indicator (`+N chars earlier`) when
|
||||
it exceeds the window. When the first content token arrives the panel
|
||||
collapses to a single summary line: `▸ Thinking [N chars · L lines]`.
|
||||
The thinking display is Phase 1 of the two-phase render (see Section 9). It
|
||||
shows the **last 8 lines** of thinking text with a `+N lines earlier` overflow
|
||||
indicator when earlier lines have been clipped. The panel is `transient` and
|
||||
`crop`-overflow so it never grows beyond terminal height and clears cleanly
|
||||
when content begins.
|
||||
|
||||
After Phase 1, a persistent one-line summary is printed to the scroll buffer:
|
||||
`▸ Agent — thinking complete [N chars · L lines] /thinking to expand`.
|
||||
|
||||
### Audit verdict border
|
||||
|
||||
@@ -857,5 +921,7 @@ async def cmd_mycommand(session: Session, args: str) -> bool:
|
||||
return True
|
||||
```
|
||||
|
||||
Commands self-register at import time via `COMMANDS` list; no other
|
||||
wiring needed.
|
||||
Commands self-register at import time via the `COMMANDS` list. The
|
||||
`ui.set_completions(...)` call that follows `_dispatch_command` rebuilds the
|
||||
completion list from `COMMANDS`, so the new command and all its aliases
|
||||
automatically appear in the `Principal ›` dropdown — no extra wiring needed.
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
Principal, the Standing Brief has been updated to Version 3 to reflect the closure of Session 1 and the completion of Task T-20260402-001. Below is the finalized document ready for save.
|
||||
|
||||
```markdown
|
||||
# Standing Brief
|
||||
**Maintained by:** Miranda — Chief of Staff
|
||||
**For:** Principal
|
||||
**Version:** 3 — 2026-04-02
|
||||
**Version:** 1 — [YYYY-MM-DD]
|
||||
|
||||
---
|
||||
|
||||
@@ -47,7 +44,7 @@ Miranda will surface these at the start of each session if unresolved.
|
||||
Completed tasks from recent sessions that may be relevant to ongoing work.
|
||||
Items older than 30 days or with no ongoing relevance are moved to the Archive.
|
||||
|
||||
* **T-20260402-001** — Identity inquiry (Direct response by Miranda).
|
||||
*No recent completions.*
|
||||
|
||||
---
|
||||
|
||||
@@ -64,7 +61,7 @@ Questions identified but not yet formally tasked, or ongoing background inquirie
|
||||
Status of recent Vera reviews. Full audit memos go directly to the Principal —
|
||||
this section tracks coverage only.
|
||||
|
||||
* **T-20260402-001** — Cleared by Vera.
|
||||
*No audit items.*
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +96,7 @@ Not a comprehensive archive — only what is currently load-bearing for decision
|
||||
A brief record of each session. Most recent at the top.
|
||||
Keep only the last 10 sessions; archive older entries.
|
||||
|
||||
* **2026-04-02** — Session 1. Directive: Identity inquiry. Outcome: Completed. Task ID: T-20260402-001.
|
||||
*No sessions recorded yet.*
|
||||
|
||||
---
|
||||
|
||||
@@ -115,4 +112,3 @@ The archive is not loaded into session context by default — request it explici
|
||||
|
||||
*End of Standing Brief*
|
||||
*Next update due: close of next session*
|
||||
```
|
||||
Binary file not shown.
Binary file not shown.
@@ -832,13 +832,9 @@ async def cmd_reload(session: Session, args: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@command(["/thinking"], "Show the thinking output from the last response")
|
||||
@command(["/thinking"], "Browse thinking output history for this session")
|
||||
async def cmd_thinking(session: Session, args: str) -> bool:
|
||||
last = ui.get_last_thinking()
|
||||
if not last:
|
||||
ui.print_system("No thinking output recorded — thinking mode may not be active.")
|
||||
else:
|
||||
ui.print_thinking_expansion(last)
|
||||
await ui.show_thinking_picker()
|
||||
return True
|
||||
|
||||
|
||||
@@ -897,6 +893,11 @@ def _dispatch_command(name: str):
|
||||
return None
|
||||
|
||||
|
||||
# Register all slash command names for tab completion.
|
||||
# This runs once at import time, after all @command decorators have executed.
|
||||
ui.set_completions([(name, desc) for names, desc, _ in COMMANDS for name in names])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Direct Agent Session
|
||||
# Opens an interactive REPL with one named agent.
|
||||
@@ -920,7 +921,7 @@ async def direct_agent_session(agent_name: str, dry_run: bool = False):
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = ui.get_directive()
|
||||
user_input = await ui.get_directive()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
break
|
||||
|
||||
@@ -928,11 +929,7 @@ async def direct_agent_session(agent_name: str, dry_run: bool = False):
|
||||
break
|
||||
|
||||
if user_input.lower() == "/thinking":
|
||||
last = ui.get_last_thinking()
|
||||
if not last:
|
||||
ui.print_system("No thinking output recorded — thinking mode may not be active.")
|
||||
else:
|
||||
ui.print_thinking_expansion(last)
|
||||
await ui.show_thinking_picker()
|
||||
continue
|
||||
|
||||
response = await call_agent_async(agent, user_input, dry_run=dry_run, stream=True)
|
||||
@@ -955,7 +952,7 @@ async def principal_session(dry_run: bool = False):
|
||||
|
||||
while True:
|
||||
try:
|
||||
raw = ui.get_directive()
|
||||
raw = await ui.get_directive()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
ui.console.print()
|
||||
break
|
||||
|
||||
@@ -12,6 +12,7 @@ pyyaml>=6.0.0 # reads config/agents.yaml
|
||||
ruamel.yaml>=0.18 # comment-preserving YAML round-trips (tools.py)
|
||||
openai>=1.50.0 # covers openai, openai_compatible, lmstudio, llamacpp
|
||||
rich>=13.0.0 # terminal UI (Iris)
|
||||
prompt_toolkit>=3.0.0 # interactive input with inline slash-command completion
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider SDKs — install only if using that provider
|
||||
|
||||
+339
-161
@@ -22,6 +22,17 @@ from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import json as _json
|
||||
|
||||
try:
|
||||
from prompt_toolkit import PromptSession as _PromptSession
|
||||
from prompt_toolkit.completion import Completer as _PTCompleter, Completion as _PTCompletion
|
||||
from prompt_toolkit.formatted_text import FormattedText as _FormattedText
|
||||
from prompt_toolkit.history import InMemoryHistory as _InMemoryHistory
|
||||
from prompt_toolkit.shortcuts import CompleteStyle as _CompleteStyle, radiolist_dialog as _radiolist_dialog
|
||||
from prompt_toolkit.styles import Style as _PTStyle
|
||||
_HAS_PROMPT_TOOLKIT = True
|
||||
except ImportError:
|
||||
_HAS_PROMPT_TOOLKIT = False
|
||||
|
||||
import config as _config
|
||||
|
||||
from rich.console import Console, Group
|
||||
@@ -77,9 +88,96 @@ def _build_theme() -> Theme:
|
||||
|
||||
console = Console(theme=_build_theme(), highlight=False)
|
||||
|
||||
_last_thinking: str = "" # Stored from most recent streamed response
|
||||
_last_stream_rendered: bool = False # True when stream_agent_output rendered the final panel
|
||||
|
||||
# Thinking history — most recent appended last, capped at _THINKING_MAX entries.
|
||||
# Each entry: {agent, content, n_chars, n_lines, timestamp, index}
|
||||
_thinking_history: list[dict] = []
|
||||
_THINKING_MAX = 20
|
||||
|
||||
|
||||
def _record_thinking(agent_name: str, content: str) -> None:
|
||||
"""Append a thinking record to the history, trimming to _THINKING_MAX."""
|
||||
if not content:
|
||||
return
|
||||
_thinking_history.append({
|
||||
"agent": agent_name,
|
||||
"content": content,
|
||||
"n_chars": len(content),
|
||||
"n_lines": content.count("\n") + 1,
|
||||
"timestamp": datetime.now().strftime("%H:%M"),
|
||||
"index": len(_thinking_history) + 1,
|
||||
})
|
||||
if len(_thinking_history) > _THINKING_MAX:
|
||||
del _thinking_history[:-_THINKING_MAX]
|
||||
|
||||
# Slash-command completions: list of (command_name, description) pairs.
|
||||
_completions: list[tuple[str, str]] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inline slash-command completion (prompt_toolkit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
if _HAS_PROMPT_TOOLKIT:
|
||||
class _SlashCompleter(_PTCompleter):
|
||||
"""
|
||||
Yields up to 5 matching slash commands as the user types.
|
||||
Only activates when the current input starts with '/'.
|
||||
Each completion shows the command name and its description.
|
||||
"""
|
||||
_MAX = 5
|
||||
|
||||
def get_completions(self, document, complete_event):
|
||||
text = document.text_before_cursor
|
||||
if not text.startswith("/"):
|
||||
return
|
||||
count = 0
|
||||
for word, desc in _completions:
|
||||
if word.startswith(text):
|
||||
yield _PTCompletion(
|
||||
word,
|
||||
start_position=-len(text),
|
||||
display=word,
|
||||
display_meta=desc,
|
||||
)
|
||||
count += 1
|
||||
if count >= self._MAX:
|
||||
break
|
||||
|
||||
_PT_STYLE = _PTStyle.from_dict({
|
||||
# Prompt text
|
||||
"prompt": "bold #ffffff",
|
||||
# Completion dropdown — unselected
|
||||
"completion-menu.completion": "bg:#1e1e1e #aaaaaa",
|
||||
"completion-menu.meta.completion": "bg:#1e1e1e #555555",
|
||||
# Completion dropdown — selected row
|
||||
"completion-menu.completion.current": "bg:#005f87 bold #ffffff",
|
||||
"completion-menu.meta.completion.current": "bg:#005f87 #bbbbbb",
|
||||
# Scrollbar
|
||||
"scrollbar.background": "bg:#1e1e1e",
|
||||
"scrollbar.button": "bg:#4e4e4e",
|
||||
})
|
||||
|
||||
_pt_session: _PromptSession | None = _PromptSession(
|
||||
history=_InMemoryHistory(),
|
||||
completer=_SlashCompleter(),
|
||||
complete_while_typing=True, # popup appears as the user types '/'
|
||||
complete_style=_CompleteStyle.COLUMN,
|
||||
style=_PT_STYLE,
|
||||
)
|
||||
else:
|
||||
_pt_session = None
|
||||
|
||||
|
||||
def set_completions(entries: list[tuple[str, str]]) -> None:
|
||||
"""
|
||||
Register (command_name, description) pairs for inline completion.
|
||||
Call once at startup after all slash commands are defined.
|
||||
"""
|
||||
global _completions
|
||||
_completions = sorted(entries, key=lambda e: e[0])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent visual config
|
||||
@@ -133,10 +231,31 @@ def print_session_footer(task_count: int):
|
||||
# Input prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_directive() -> str:
|
||||
"""Styled input prompt for the Principal."""
|
||||
async def get_directive() -> str:
|
||||
"""
|
||||
Styled input prompt for the Principal.
|
||||
|
||||
When prompt_toolkit is available (the default):
|
||||
- An inline vertical dropdown appears as soon as the user types '/'.
|
||||
- Up to 5 matching commands are shown with their descriptions.
|
||||
- Arrow keys navigate the list; Enter or Tab accepts a selection.
|
||||
- Up/Down arrow keys also navigate input history within the session.
|
||||
|
||||
Uses prompt_async() so it cooperates with the running asyncio event loop
|
||||
instead of trying to nest a second one (which raises RuntimeError).
|
||||
|
||||
Falls back to plain input() if prompt_toolkit is not installed.
|
||||
"""
|
||||
console.print()
|
||||
return console.input("[bold white]Principal ›[/bold white] ").strip()
|
||||
if _pt_session is not None:
|
||||
return (await _pt_session.prompt_async(
|
||||
_FormattedText([("class:prompt", "Principal › ")]),
|
||||
)).strip()
|
||||
# Fallback: plain styled prompt with no completion.
|
||||
console.print("[bold white]Principal ›[/bold white] ", end="")
|
||||
if hasattr(console.file, "flush"):
|
||||
console.file.flush()
|
||||
return input().strip()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System / status messages
|
||||
@@ -451,47 +570,41 @@ def _thinking_panel_title(agent_name: str, mode: str) -> str:
|
||||
return f"[{color} dim]Thinking…[/{color} dim]"
|
||||
|
||||
|
||||
def _build_thinking_renderable(
|
||||
thinking_text: str,
|
||||
active: bool,
|
||||
agent_name: str,
|
||||
mode: str,
|
||||
window: int = 300,
|
||||
) -> object:
|
||||
def _build_thinking_panel(thinking_text: str, agent_name: str, mode: str,
|
||||
visible_lines: int = 8) -> object:
|
||||
"""
|
||||
Build the thinking panel (active) or collapsed summary line (done).
|
||||
Build the live thinking panel for Phase 1 streaming.
|
||||
|
||||
Active: dim panel with a rolling window of the last `window` chars,
|
||||
plus an overflow indicator when earlier content has scrolled off.
|
||||
Collapsed: single dim line — ' ▸ Thinking (N chars, L lines)'.
|
||||
Shows the last `visible_lines` lines of thinking so the panel never
|
||||
grows beyond a predictable height (safe with vertical_overflow="crop").
|
||||
An overflow indicator shows how many earlier lines have scrolled off.
|
||||
"""
|
||||
if not thinking_text:
|
||||
return None
|
||||
|
||||
if active:
|
||||
overflow = len(thinking_text) - window
|
||||
if overflow > 0:
|
||||
preview = thinking_text[-window:]
|
||||
header = Text(f" +{overflow} chars earlier\n", style="dim italic")
|
||||
body = Text(preview, style="dim")
|
||||
content = Group(header, body)
|
||||
else:
|
||||
content = Text(thinking_text, style="dim")
|
||||
|
||||
return Panel(
|
||||
content,
|
||||
title=_thinking_panel_title(agent_name, mode),
|
||||
border_style="dim",
|
||||
padding=(0, 1),
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
else:
|
||||
lines = thinking_text.count("\n") + 1
|
||||
return Text(
|
||||
f" ▸ Thinking [{len(thinking_text):,} chars · {lines} lines]",
|
||||
f" [{_agent_color(agent_name)} dim]{_agent_display_title(agent_name)} thinking…"
|
||||
f"[/{_agent_color(agent_name)} dim]",
|
||||
style="dim",
|
||||
)
|
||||
|
||||
lines = thinking_text.splitlines()
|
||||
hidden = max(0, len(lines) - visible_lines)
|
||||
visible = lines[-visible_lines:] if hidden else lines
|
||||
|
||||
items = []
|
||||
if hidden:
|
||||
items.append(Text(f" +{hidden} line{'s' if hidden != 1 else ''} earlier\n",
|
||||
style="dim italic"))
|
||||
items.append(Text("\n".join(visible), style="dim"))
|
||||
content = Group(*items) if len(items) > 1 else items[0]
|
||||
|
||||
return Panel(
|
||||
content,
|
||||
title=_thinking_panel_title(agent_name, mode),
|
||||
border_style="dim",
|
||||
padding=(0, 1),
|
||||
box=box.SIMPLE,
|
||||
)
|
||||
|
||||
|
||||
async def stream_agent_output(
|
||||
agent_name: str,
|
||||
@@ -502,47 +615,41 @@ async def stream_agent_output(
|
||||
Consume an async generator of ('thinking'|'content', text) chunks and
|
||||
render them live in the terminal.
|
||||
|
||||
Two-phase rendering
|
||||
-------------------
|
||||
Phase 1 — Thinking (transient, cropped to terminal height):
|
||||
A compact Live panel streams thinking text showing the last 8 lines.
|
||||
It is transient — it clears from the screen when Phase 1 ends.
|
||||
A persistent one-line summary is then printed to the scroll buffer:
|
||||
"▸ Agent — thinking complete [N chars · L lines] /thinking to expand"
|
||||
|
||||
Phase 2 — Content (non-transient, persists in scroll buffer):
|
||||
A separate Live display streams the content and renders the final
|
||||
Markdown panel inside the Live block before exiting (no flash).
|
||||
|
||||
This keeps thinking and content in separate, non-competing display
|
||||
regions. Thinking never pushes content off-screen, and the summary
|
||||
line is always visible in the scroll buffer.
|
||||
|
||||
mode values
|
||||
-----------
|
||||
"deliverable" Normal agent response — thinking panel + full styled panel.
|
||||
The Live display is NOT transient: the final Markdown panel
|
||||
is rendered inside the Live block, so there is no erase/
|
||||
reprint flash. Sets _last_stream_rendered = True so that
|
||||
the caller's print_deliverable / print_agent_panel becomes
|
||||
a no-op.
|
||||
|
||||
"audit" Like "deliverable" but uses Vera's yellow identity cues
|
||||
during the thinking phase. Sets _last_stream_rendered so
|
||||
print_audit_memo is a no-op (the verdict-border panel is
|
||||
rendered here instead).
|
||||
|
||||
"background" Housekeeping call (e.g. standing-brief update). A compact
|
||||
transient progress line is shown; no full panel is printed.
|
||||
_last_stream_rendered is set so the caller's print_* is a
|
||||
no-op.
|
||||
|
||||
In all modes the full content string is returned and _last_thinking is
|
||||
updated so /thinking works.
|
||||
|
||||
Thinking tokens stream inside a dim panel. When the first content token
|
||||
arrives the panel collapses to a single dim summary line and content begins
|
||||
streaming in the agent's normal styled panel.
|
||||
|
||||
The full thinking text is stored in _last_thinking and can be retrieved
|
||||
with get_last_thinking() (exposed to the /thinking command).
|
||||
"deliverable" Normal agent response.
|
||||
"audit" Like deliverable but with Vera's identity and verdict border.
|
||||
"background" Housekeeping — compact transient spinner only, no panel.
|
||||
|
||||
In all modes the thinking content is recorded via _record_thinking() so
|
||||
the /thinking picker has a full history.
|
||||
Returns the full content string.
|
||||
"""
|
||||
global _last_thinking, _last_stream_rendered
|
||||
global _last_stream_rendered
|
||||
|
||||
color = _agent_color(agent_name)
|
||||
title_text = _agent_display_title(agent_name)
|
||||
|
||||
# ── Background mode: compact transient progress line ────────────────────
|
||||
# ── Background mode ──────────────────────────────────────────────────────
|
||||
if mode == "background":
|
||||
thinking_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(style="dim"),
|
||||
TextColumn("[dim]Updating standing brief…[/dim]"),
|
||||
@@ -556,20 +663,16 @@ async def stream_agent_output(
|
||||
thinking_parts.append(text)
|
||||
elif chunk_type == "content":
|
||||
content_parts.append(text)
|
||||
|
||||
_last_thinking = "".join(thinking_parts)
|
||||
_record_thinking(agent_name, "".join(thinking_parts))
|
||||
_last_stream_rendered = True
|
||||
return "".join(content_parts)
|
||||
|
||||
# ── Deliverable / audit modes ────────────────────────────────────────────
|
||||
# ── Deliverable / audit modes ─────────────────────────────────────────────
|
||||
thinking_parts: list[str] = []
|
||||
content_parts: list[str] = []
|
||||
phase = "start" # "start" | "thinking" | "content"
|
||||
|
||||
def _build_final_panel(content_text: str) -> Panel:
|
||||
"""Render the agent's finished output as a polished Markdown panel."""
|
||||
if mode == "audit":
|
||||
# Re-derive verdict border from completed content
|
||||
aud_name = _config.AUDITOR_AGENT
|
||||
aud_title = _config.agent_title(aud_name)
|
||||
aud_color = _agent_color(aud_name)
|
||||
@@ -577,120 +680,127 @@ async def stream_agent_output(
|
||||
for line in content_text.splitlines():
|
||||
upper = line.upper()
|
||||
if "VERDICT:" in upper:
|
||||
if "REJECT" in upper:
|
||||
verdict_style = "reject"
|
||||
elif "FLAG" in upper:
|
||||
verdict_style = "flag"
|
||||
elif "PASS WITH NOTES" in upper:
|
||||
verdict_style = "warning"
|
||||
elif "PASS" in upper:
|
||||
verdict_style = "pass"
|
||||
if "REJECT" in upper: verdict_style = "reject"
|
||||
elif "FLAG" in upper: verdict_style = "flag"
|
||||
elif "PASS WITH NOTES" in upper: verdict_style = "warning"
|
||||
elif "PASS" in upper: verdict_style = "pass"
|
||||
break
|
||||
return Panel(
|
||||
Markdown(content_text),
|
||||
title=f"[{aud_color}] {aud_name.capitalize()} — {aud_title} [/{aud_color}]",
|
||||
border_style=verdict_style if verdict_style != "dim" else aud_color,
|
||||
padding=(1, 2),
|
||||
box=box.ROUNDED,
|
||||
padding=(1, 2), box=box.ROUNDED,
|
||||
)
|
||||
else:
|
||||
return Panel(
|
||||
Markdown(content_text),
|
||||
title=f"[{color}] {title_text} [/{color}]",
|
||||
border_style=color,
|
||||
padding=(1, 2),
|
||||
box=box.ROUNDED,
|
||||
)
|
||||
|
||||
def _renderable(final: bool = False):
|
||||
"""
|
||||
Build the live renderable for the current stream state.
|
||||
|
||||
final=True: replace plain Text content with Markdown and apply the
|
||||
verdict-aware border so the Live display ends in its final polished
|
||||
form. This avoids a transient erase + reprint flash.
|
||||
"""
|
||||
thinking_text = "".join(thinking_parts)
|
||||
content_text = "".join(content_parts)
|
||||
items: list = []
|
||||
|
||||
# ── Agent identity line (shown while only thinking, not yet content) ──
|
||||
if phase in ("start", "thinking") and not content_text:
|
||||
items.append(Text(
|
||||
f" {title_text}",
|
||||
style=f"dim {color}",
|
||||
))
|
||||
|
||||
# ── Thinking panel / collapsed summary ──────────────────────────────
|
||||
thinking_active = (phase == "thinking")
|
||||
think_renderable = _build_thinking_renderable(
|
||||
thinking_text, thinking_active, agent_name, mode
|
||||
return Panel(
|
||||
Markdown(content_text),
|
||||
title=f"[{color}] {title_text} [/{color}]",
|
||||
border_style=color,
|
||||
padding=(1, 2), box=box.ROUNDED,
|
||||
)
|
||||
if think_renderable is not None:
|
||||
items.append(think_renderable)
|
||||
|
||||
# ── Content panel ────────────────────────────────────────────────────
|
||||
if content_text or phase == "content":
|
||||
if final and content_text:
|
||||
items.append(_build_final_panel(content_text))
|
||||
else:
|
||||
items.append(Panel(
|
||||
Text(content_text),
|
||||
title=f"[{color}] {title_text} [/{color}]",
|
||||
border_style=color,
|
||||
padding=(1, 2),
|
||||
box=box.ROUNDED,
|
||||
))
|
||||
|
||||
if not items:
|
||||
# Nothing yet — show a faint connecting indicator
|
||||
return Text(
|
||||
f" [{_agent_color(agent_name)} dim]{title_text} …[/{_agent_color(agent_name)} dim]",
|
||||
style="dim",
|
||||
)
|
||||
return Group(*items) if len(items) > 1 else items[0]
|
||||
|
||||
console.print()
|
||||
# transient=False: the Live display persists — we render the final panel
|
||||
# inside it before stopping, so there is no erase/reprint cycle.
|
||||
|
||||
# ── Phase 1: Thinking — transient, height-capped Live display ────────────
|
||||
# We use manual __anext__() instead of `async for` so that breaking out of
|
||||
# the loop does NOT call aclose() on the generator. The same iterator is
|
||||
# then reused in Phase 2 to continue from where we left off.
|
||||
aiter = stream_gen.__aiter__()
|
||||
exhausted = False
|
||||
|
||||
with Live(
|
||||
_renderable(),
|
||||
_build_thinking_panel("", agent_name, mode),
|
||||
console=console,
|
||||
refresh_per_second=15,
|
||||
transient=True, # clears from screen when the block exits
|
||||
vertical_overflow="crop", # never grows beyond terminal height
|
||||
) as live:
|
||||
while True:
|
||||
try:
|
||||
chunk_type, text = await aiter.__anext__()
|
||||
except StopAsyncIteration:
|
||||
exhausted = True
|
||||
break
|
||||
if chunk_type == "thinking":
|
||||
thinking_parts.append(text)
|
||||
live.update(_build_thinking_panel(
|
||||
"".join(thinking_parts), agent_name, mode
|
||||
))
|
||||
elif chunk_type == "content":
|
||||
content_parts.append(text)
|
||||
break # hand off to Phase 2; generator is NOT closed
|
||||
|
||||
# Print a persistent collapsed summary when thinking was present.
|
||||
# This line lives in the terminal's scroll buffer — always scrollable.
|
||||
if thinking_parts:
|
||||
full_thinking = "".join(thinking_parts)
|
||||
n_chars = len(full_thinking)
|
||||
n_lines = full_thinking.count("\n") + 1
|
||||
id_color = _agent_color(_config.AUDITOR_AGENT) if mode == "audit" else color
|
||||
console.print(Text.assemble(
|
||||
(" ▸ ", "dim"),
|
||||
(title_text, f"dim {id_color}"),
|
||||
(" thinking complete ", "dim"),
|
||||
(f"[{n_chars:,} chars · {n_lines} lines]", "dim"),
|
||||
(" /thinking to expand", "dim italic"),
|
||||
))
|
||||
|
||||
# Edge case: generator exhausted with no content (thinking-only response)
|
||||
if exhausted:
|
||||
_record_thinking(agent_name, "".join(thinking_parts))
|
||||
_last_stream_rendered = True
|
||||
return ""
|
||||
|
||||
# ── Phase 2: Content — non-transient, persists in scroll buffer ──────────
|
||||
def _content_renderable(final: bool = False) -> object:
|
||||
content_text = "".join(content_parts)
|
||||
if not content_text:
|
||||
return Text(
|
||||
f" [{color} dim]{title_text} …[/{color} dim]",
|
||||
style="dim",
|
||||
)
|
||||
if final:
|
||||
return _build_final_panel(content_text)
|
||||
return Panel(
|
||||
Text(content_text),
|
||||
title=f"[{color}] {title_text} [/{color}]",
|
||||
border_style=color,
|
||||
padding=(1, 2), box=box.ROUNDED,
|
||||
)
|
||||
|
||||
with Live(
|
||||
_content_renderable(),
|
||||
console=console,
|
||||
refresh_per_second=15,
|
||||
transient=False,
|
||||
vertical_overflow="visible",
|
||||
) as live:
|
||||
async for chunk_type, text in stream_gen:
|
||||
if chunk_type == "thinking":
|
||||
if phase == "start":
|
||||
phase = "thinking"
|
||||
thinking_parts.append(text)
|
||||
elif chunk_type == "content":
|
||||
if phase in ("start", "thinking"):
|
||||
phase = "content"
|
||||
async for chunk_type, text in aiter:
|
||||
if chunk_type == "content":
|
||||
content_parts.append(text)
|
||||
live.update(_renderable())
|
||||
live.update(_content_renderable())
|
||||
live.update(_content_renderable(final=True))
|
||||
|
||||
# Final update: replace plain Text with formatted Markdown panel
|
||||
# (and apply verdict border for audit mode) before the Live context
|
||||
# exits and the display freezes in place.
|
||||
live.update(_renderable(final=True))
|
||||
|
||||
_last_thinking = "".join(thinking_parts)
|
||||
_record_thinking(agent_name, "".join(thinking_parts))
|
||||
_last_stream_rendered = True
|
||||
return "".join(content_parts)
|
||||
|
||||
|
||||
def get_last_thinking() -> str:
|
||||
"""Return the thinking text captured from the most recent streamed response."""
|
||||
return _last_thinking
|
||||
"""Return the thinking text from the most recent response (empty string if none)."""
|
||||
return _thinking_history[-1]["content"] if _thinking_history else ""
|
||||
|
||||
|
||||
def print_thinking_expansion(thinking_text: str):
|
||||
"""Render the stored thinking output in a dim panel."""
|
||||
def _display_thinking_entry(entry: dict) -> None:
|
||||
"""Render one thinking history entry in a dim panel."""
|
||||
title_text = _agent_display_title(entry["agent"])
|
||||
panel = Panel(
|
||||
Text(thinking_text, style="dim"),
|
||||
title="[dim] Thinking — last response [/dim]",
|
||||
Text(entry["content"], style="dim"),
|
||||
title=(
|
||||
f"[dim] {title_text} — thinking "
|
||||
f"#{entry['index']} "
|
||||
f"[{entry['n_chars']:,} chars · {entry['n_lines']} lines] [/dim]"
|
||||
),
|
||||
subtitle=f"[dim]{entry['timestamp']}[/dim]",
|
||||
border_style="dim",
|
||||
padding=(1, 2),
|
||||
box=box.ROUNDED,
|
||||
@@ -699,6 +809,74 @@ def print_thinking_expansion(thinking_text: str):
|
||||
console.print(panel)
|
||||
|
||||
|
||||
async def show_thinking_picker() -> None:
|
||||
"""
|
||||
Browse the thinking history for this session.
|
||||
|
||||
- 0 entries : prints a system notice.
|
||||
- 1 entry : displays it directly.
|
||||
- 2+ entries: opens an interactive radiolist dialog (prompt_toolkit) so the
|
||||
user can navigate with arrow keys and press Enter to view an
|
||||
entry. Falls back to a numbered text list + input() prompt
|
||||
when prompt_toolkit is unavailable.
|
||||
"""
|
||||
if not _thinking_history:
|
||||
print_system("No thinking output recorded — thinking mode may not be active.")
|
||||
return
|
||||
|
||||
if len(_thinking_history) == 1:
|
||||
_display_thinking_entry(_thinking_history[0])
|
||||
return
|
||||
|
||||
if _HAS_PROMPT_TOOLKIT:
|
||||
# Show most-recent-first in the dialog.
|
||||
values = [
|
||||
(
|
||||
entry,
|
||||
(
|
||||
f"#{entry['index']} "
|
||||
f"{entry['agent'].capitalize()} "
|
||||
f"[{entry['n_chars']:,} chars · {entry['n_lines']} lines]"
|
||||
f" {entry['timestamp']}"
|
||||
),
|
||||
)
|
||||
for entry in reversed(_thinking_history)
|
||||
]
|
||||
selected = await _radiolist_dialog(
|
||||
title="Thinking History",
|
||||
text=f"Select a thinking output to view ({len(_thinking_history)} stored):",
|
||||
values=values,
|
||||
style=_PT_STYLE,
|
||||
).run_async()
|
||||
if selected is not None:
|
||||
_display_thinking_entry(selected)
|
||||
else:
|
||||
# Fallback: numbered list + input prompt.
|
||||
console.print()
|
||||
console.rule("[dim]Thinking History[/dim]", style="dim")
|
||||
console.print()
|
||||
for entry in reversed(_thinking_history):
|
||||
console.print(
|
||||
f" [dim]{entry['index']:>2}.[/dim] "
|
||||
f"[white]{entry['agent'].capitalize()}[/white] "
|
||||
f"[dim]{entry['n_chars']:,} chars · {entry['n_lines']} lines"
|
||||
f" {entry['timestamp']}[/dim]"
|
||||
)
|
||||
console.print()
|
||||
try:
|
||||
choice = input(" Enter number to view (or Enter to cancel): ").strip()
|
||||
if choice.isdigit():
|
||||
match = next(
|
||||
(e for e in _thinking_history if e["index"] == int(choice)), None
|
||||
)
|
||||
if match:
|
||||
_display_thinking_entry(match)
|
||||
else:
|
||||
print_system(f"No thinking output #{choice}.")
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dry-run callout
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user