Skip to content

Commit af4cabf

Browse files
authored
Merge pull request #100 from vstorm-co/feat/message-delivery
feat: add mid-run message delivery queues (steering & follow-up)
2 parents 733f7d2 + 8b9e9ba commit af4cabf

13 files changed

Lines changed: 1180 additions & 4 deletions

File tree

apps/cli/agent.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from apps.cli.reminder import _build_reminder_config
1313
from pydantic_deep.agent import DEFAULT_INSTRUCTIONS, create_deep_agent
1414
from pydantic_deep.capabilities.hooks import Hook, HookEvent, HookInput, HookResult
15+
from pydantic_deep.capabilities.message_queue import MessageQueue
1516
from pydantic_deep.deps import DeepAgentDeps
1617

1718

@@ -356,6 +357,8 @@ def create_cli_agent( # noqa: C901
356357
stacklevel=2,
357358
)
358359

360+
queue = MessageQueue()
361+
359362
agent = create_deep_agent(
360363
model=effective_model,
361364
instructions=instructions,
@@ -422,6 +425,8 @@ def create_cli_agent( # noqa: C901
422425
middleware=middleware or None,
423426
toolsets=[local_context] if local_context else None,
424427
capabilities=extra_capabilities or None,
428+
# Message queue for mid-run steering and follow-up delivery
429+
message_queue=queue,
425430
# Periodic reminder
426431
periodic_reminder=_build_reminder_config(
427432
periodic_reminder, reminder_mode, config, on_reminder, reminder_model
@@ -435,6 +440,7 @@ def create_cli_agent( # noqa: C901
435440
deps = DeepAgentDeps(
436441
backend=effective_backend,
437442
context_middleware=context_mw,
443+
message_queue=queue,
438444
)
439445
deps._task_manager = task_mgr # type: ignore[attr-defined]
440446
return agent, deps

apps/cli/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ def __init__(
8686
self.message_history: list[ModelMessage] = message_history or []
8787
self.last_response: str = ""
8888
self._startup_error = startup_error
89+
self.queue = getattr(deps, "message_queue", None)
8990

9091
# Register custom themes
9192
from apps.cli.styles.themes import register_themes
@@ -229,6 +230,7 @@ def reconfigure_agent(self, model: str | None = None) -> None:
229230
)
230231
self.agent = agent
231232
self.deps = deps
233+
self.queue = getattr(deps, "message_queue", None)
232234
self._startup_error = None
233235
self.model_name = effective
234236

apps/cli/screens/chat.py

Lines changed: 91 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from apps.cli.widgets.input_area import InputArea
3636
from apps.cli.widgets.message_list import MessageList
3737
from apps.cli.widgets.notification import notify_success, notify_warning
38+
from apps.cli.widgets.queued_panel import QueuedWidget
3839
from apps.cli.widgets.side_panel import SidePanel
3940
from apps.cli.widgets.status_bar import StatusBar
4041
from pydantic_deep.deps import DEFAULT_USAGE_LIMITS
@@ -383,20 +384,45 @@ def _show_welcome(self) -> None:
383384

384385
# ── User input handling ───────────────────────────────────────
385386

386-
def on_user_submitted(self, event: UserSubmitted) -> None:
387+
async def on_user_submitted(self, event: UserSubmitted) -> None:
387388
"""Handle user submitting a prompt."""
388389
text = event.text
389390

391+
app = self.app
392+
queue = app.queue
393+
task = app._agent_task
394+
is_running = task is not None and not task.done()
395+
396+
# Mid-run: route to queue. `>>` prefix = steering, plain text = follow-up.
397+
# `!` keeps meaning "shell command" regardless of agent state.
398+
if is_running and queue is not None and not text.startswith("!"):
399+
if text.startswith(">>"):
400+
steer_text = text[2:].strip()
401+
if steer_text:
402+
await queue.steer(steer_text)
403+
preview = steer_text[:40] + ("…" if len(steer_text) > 40 else "")
404+
app.notify(f"steering queued: {preview}")
405+
self._increment_queue_badge(steering=True)
406+
else:
407+
await queue.follow_up(text)
408+
app.notify("follow-up queued")
409+
self._increment_queue_badge(steering=False)
410+
return
411+
390412
# Shell command
391413
if text.startswith("!"):
392-
self.app.run_shell_command(text[1:]) # type: ignore[attr-defined]
414+
app.run_shell_command(text[1:]) # type: ignore[attr-defined]
393415
return
394416

395417
# Slash command (but not things like "I used /path/to/file")
396418
if text.startswith("/") and not text.startswith("//"):
397-
self.app.handle_command(text) # type: ignore[attr-defined]
419+
app.handle_command(text) # type: ignore[attr-defined]
398420
return
399421

422+
# User typed `>>foo` while idle — strip the steering prefix and run as a normal prompt
423+
if text.startswith(">>"):
424+
text = text[2:].lstrip()
425+
400426
# Expand @file references — read files and append content to prompt
401427
text = self._expand_file_refs(text)
402428

@@ -445,6 +471,7 @@ def _run_agent(self, text: str) -> None:
445471
msg_list = self.query_one(MessageList)
446472

447473
header.is_streaming = True
474+
self.query_one(InputArea).is_agent_running = True
448475
app.last_response = "" # type: ignore
449476
assistant = msg_list.begin_assistant_message()
450477

@@ -491,6 +518,7 @@ async def _agent_stream_worker( # noqa: C901
491518

492519
log.info("Agent run started", prompt_length=len(text), history_messages=len(history))
493520

521+
_follow_up_scheduled = False
494522
pending: dict[str, tuple[dict[str, Any], float]] = {}
495523
_run_cancelled = False
496524
_TODO_TOOLS: frozenset[str] = frozenset() # Show all tool calls in UI
@@ -787,6 +815,21 @@ def _parse_args(raw: Any) -> dict[str, Any]:
787815
# Auto-save session
788816
self._save_session()
789817

818+
# Drain follow-up queue and schedule next run if pending.
819+
_queue = app.queue
820+
if _queue is not None:
821+
_follow_up_msgs = await _queue.drain_follow_up()
822+
if _follow_up_msgs:
823+
from pydantic_deep.capabilities.message_queue import (
824+
format_follow_up as _fmt_fu,
825+
)
826+
827+
_follow_up_text = _fmt_fu(_follow_up_msgs)
828+
msg_list.append_user_message(_follow_up_text)
829+
self._decrement_queue_badge(len(_follow_up_msgs))
830+
_follow_up_scheduled = True
831+
self.call_later(self._run_agent, _follow_up_text)
832+
790833
except asyncio.CancelledError:
791834
_run_cancelled = True
792835
log.info("Agent run cancelled")
@@ -814,13 +857,58 @@ def _parse_args(raw: Any) -> dict[str, Any]:
814857
app.is_streaming = False
815858
header.is_streaming = False
816859
header.is_thinking = False
860+
with contextlib.suppress(Exception):
861+
self.query_one(InputArea).is_agent_running = False
817862
msg_list.end_assistant_message()
818863
with contextlib.suppress(Exception):
819864
self.query_one(InputArea).focus_input()
820865
with contextlib.suppress(Exception):
821866
self.query_one(HintsBar).reset()
822867
with contextlib.suppress(Exception):
823868
msg_list.scroll_end(animate=False)
869+
_stale_queue = app.queue
870+
if _stale_queue is not None:
871+
stale = await _stale_queue.drain_steering()
872+
if stale:
873+
n = len(stale)
874+
label = "steering message" if n == 1 else "steering messages"
875+
with contextlib.suppress(Exception):
876+
app.notify(
877+
f"{n} {label} not delivered — agent finished before next LLM call",
878+
severity="warning",
879+
timeout=6,
880+
)
881+
# When the run was cancelled, follow-ups referring to the cancelled
882+
# task are likely stale too. Discard with a count-only notification.
883+
if _run_cancelled:
884+
stale_fu = await _stale_queue.drain_follow_up()
885+
if stale_fu:
886+
n = len(stale_fu)
887+
label = "follow-up" if n == 1 else "follow-ups"
888+
with contextlib.suppress(Exception):
889+
app.notify(
890+
f"{n} {label} discarded — run cancelled",
891+
severity="warning",
892+
timeout=6,
893+
)
894+
if not _follow_up_scheduled:
895+
self._reset_queue_badge()
896+
else:
897+
with contextlib.suppress(Exception):
898+
self.query_one(QueuedWidget).clear_steering()
899+
900+
def _increment_queue_badge(self, *, steering: bool) -> None:
901+
with contextlib.suppress(Exception):
902+
w = self.query_one(QueuedWidget)
903+
w.increment_steering() if steering else w.increment_follow_up()
904+
905+
def _decrement_queue_badge(self, follow_up_count: int = 1) -> None:
906+
with contextlib.suppress(Exception):
907+
self.query_one(QueuedWidget).decrement_follow_up(follow_up_count)
908+
909+
def _reset_queue_badge(self) -> None:
910+
with contextlib.suppress(Exception):
911+
self.query_one(QueuedWidget).reset()
824912

825913
def _expand_file_refs(self, text: str) -> str:
826914
"""Expand @file references in the prompt with file contents."""

apps/cli/widgets/input_area.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ class InputArea(Vertical):
203203
"""
204204

205205
is_multiline: reactive[bool] = reactive(False)
206+
is_agent_running: reactive[bool] = reactive(False)
206207

207208
class ExitMultiline(Message):
208209
"""Request to exit multiline mode."""
@@ -213,6 +214,19 @@ def compose(self) -> ComposeResult:
213214
yield PromptInput()
214215
yield HintsBar()
215216

217+
@staticmethod
218+
def _running_hints() -> str:
219+
return "[dim]>>[/dim] steer write to queue [dim]Esc[/dim] interrupt"
220+
221+
def watch_is_agent_running(self, running: bool) -> None:
222+
if self.is_multiline:
223+
return
224+
hints = self.query_one(HintsBar)
225+
if running:
226+
hints.update(self._running_hints())
227+
else:
228+
hints.reset()
229+
216230
def watch_is_multiline(self, multiline: bool) -> None:
217231
prompt_rows = self.query("PromptRow")
218232
multi = self.query("MultilineInput")

apps/cli/widgets/queued_panel.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Queued messages display widget for the side panel."""
2+
3+
from __future__ import annotations
4+
5+
from textual.app import ComposeResult
6+
from textual.css.query import NoMatches
7+
from textual.reactive import reactive
8+
from textual.widget import Widget
9+
from textual.widgets import Static
10+
11+
12+
class QueuedWidget(Widget):
13+
"""Pending steering / follow-up message counts."""
14+
15+
DEFAULT_CSS = """
16+
QueuedWidget {
17+
height: auto;
18+
padding: 1;
19+
border: tall $surface-lighten-2;
20+
margin: 1 0 0 0;
21+
}
22+
"""
23+
24+
steering_count: reactive[int] = reactive(0)
25+
follow_up_count: reactive[int] = reactive(0)
26+
27+
def compose(self) -> ComposeResult:
28+
yield Static("[bold]Queued[/bold]", id="queued-title")
29+
yield Static("", id="queued-list")
30+
31+
def watch_steering_count(self) -> None:
32+
self._refresh()
33+
34+
def watch_follow_up_count(self) -> None:
35+
self._refresh()
36+
37+
def increment_steering(self) -> None:
38+
self.steering_count += 1
39+
40+
def increment_follow_up(self) -> None:
41+
self.follow_up_count += 1
42+
43+
def decrement_follow_up(self, n: int = 1) -> None:
44+
self.follow_up_count = max(0, self.follow_up_count - n)
45+
46+
def reset(self) -> None:
47+
self.steering_count = 0
48+
self.follow_up_count = 0
49+
50+
def clear_steering(self) -> None:
51+
self.steering_count = 0
52+
53+
def _refresh(self) -> None:
54+
try:
55+
content = self.query_one("#queued-list", Static)
56+
except NoMatches:
57+
return
58+
59+
lines: list[str] = []
60+
if self.steering_count > 0:
61+
lines.append(f" {self.steering_count} steering")
62+
if self.follow_up_count > 0:
63+
lines.append(f" {self.follow_up_count} follow-up")
64+
65+
if lines:
66+
content.update("\n".join(lines))
67+
else:
68+
content.update("[dim]No queued messages[/dim]")

apps/cli/widgets/side_panel.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from textual.app import ComposeResult
66
from textual.containers import Vertical
77

8+
from apps.cli.widgets.queued_panel import QueuedWidget
89
from apps.cli.widgets.subagents_panel import SubagentsWidget
910
from apps.cli.widgets.todos_panel import TodosWidget
1011

@@ -29,6 +30,7 @@ class SidePanel(Vertical):
2930
def compose(self) -> ComposeResult:
3031
yield TodosWidget()
3132
yield SubagentsWidget()
33+
yield QueuedWidget()
3234

3335
def update_for_width(self, width: int) -> None:
3436
"""Show or hide based on terminal width."""

docs/capabilities/message-queue.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# Message Queue
2+
3+
The **Message Queue** lets external code push messages into a running agent loop without cancelling and restarting it — preserving in-flight tool results and the prompt cache.
4+
5+
Two delivery semantics are supported:
6+
7+
| Semantic | When delivered | Use case |
8+
|---|---|---|
9+
| **Steering** | Before the next LLM request, after the current tool batch | "Stop that approach, try X instead" |
10+
| **Follow-up** | When the agent would otherwise stop | "When you're done, also do Y" |
11+
12+
## Quick start
13+
14+
```python
15+
from pydantic_deep import create_deep_agent
16+
from pydantic_deep.capabilities.message_queue import MessageQueue, run_with_queue
17+
from pydantic_deep.deps import DeepAgentDeps
18+
19+
queue = MessageQueue()
20+
agent = create_deep_agent(model="anthropic:claude-sonnet-4-6", message_queue=queue)
21+
deps = DeepAgentDeps(message_queue=queue)
22+
23+
# In another coroutine / task while the agent is running:
24+
await queue.steer("stop digging deeper, summarise what you have")
25+
await queue.follow_up("when done, write a test for the result")
26+
27+
# Run with follow-up support
28+
result = await run_with_queue(agent, "investigate the bug", deps=deps, queue=queue)
29+
```
30+
31+
## Delivery modes
32+
33+
Each message can be queued with one of two `delivery_mode` values:
34+
35+
- **`"one_at_a_time"`** (default) — each `drain_*` call pops exactly one message
36+
- **`"all"`** — the first `drain_*` call empties the entire queue at once (mode is read from the head message)
37+
38+
```python
39+
await queue.steer("first hint")
40+
await queue.steer("second hint")
41+
# drain_steering() returns only "first hint" (one_at_a_time)
42+
43+
await queue.steer("batch A", delivery_mode="all")
44+
await queue.steer("batch B")
45+
# drain_steering() returns both (mode from head message)
46+
```
47+
48+
## Subagent sharing
49+
50+
By default, `DeepAgentDeps.clone_for_subagent()` passes the same `MessageQueue` reference to subagents. A subagent can therefore steer the parent:
51+
52+
```python
53+
# Inside a tool or subagent:
54+
await ctx.deps.message_queue.steer("parent, change your approach")
55+
```
56+
57+
Pass a fresh `MessageQueue()` to `clone_for_subagent()` override on the cloned deps if isolation is needed.
58+
59+
## Delivery sequence
60+
61+
```
62+
External caller MessageQueue Agent loop (pydantic-ai)
63+
| | |
64+
|-- await steer("X") -->| |
65+
| | [steering deque: X] |
66+
| | |
67+
| |<-- before_model_request-|
68+
| | drain_steering() |
69+
| |-- inject UserPromptPart->|
70+
| | |-- LLM sees "[steering] X"
71+
| | |
72+
|-- await follow_up("Y")| |
73+
| | [follow_up deque: Y] |
74+
| | |
75+
| | agent stops
76+
| | |
77+
| run_with_queue() drain_follow_up() |
78+
| |-- "Y" as next prompt -->|
79+
| | |-- new run with Y
80+
```

0 commit comments

Comments
 (0)