Skip to content

Commit 54efbf3

Browse files
committed
fix: deliver stranded follow-ups and surface message-queue metadata
1 parent 04c802b commit 54efbf3

9 files changed

Lines changed: 674 additions & 30 deletions

File tree

apps/cli/screens/chat.py

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -635,17 +635,22 @@ async def on_user_submitted(self, event: UserSubmitted) -> None: # noqa: C901
635635
# Mid-run: route to queue. `>>` prefix = steering, plain text = follow-up.
636636
# `!` keeps meaning "shell command" regardless of agent state.
637637
if is_running and queue is not None and not text.startswith("!"):
638-
if text.startswith(">>"):
639-
steer_text = text[2:].strip()
640-
if steer_text:
641-
await queue.steer(steer_text)
642-
preview = steer_text[:40] + ("…" if len(steer_text) > 40 else "")
643-
app.notify(f"steering queued: {preview}")
644-
self._increment_queue_badge(steering=True)
645-
else:
646-
await queue.follow_up(text)
647-
app.notify("follow-up queued")
648-
self._increment_queue_badge(steering=False)
638+
from pydantic_deep.features.message_queue import QueueFullError
639+
640+
try:
641+
if text.startswith(">>"):
642+
steer_text = text[2:].strip()
643+
if steer_text:
644+
await queue.steer(steer_text)
645+
preview = steer_text[:40] + ("…" if len(steer_text) > 40 else "")
646+
app.notify(f"steering queued: {preview}")
647+
self._increment_queue_badge(steering=True)
648+
else:
649+
await queue.follow_up(text)
650+
app.notify("follow-up queued")
651+
self._increment_queue_badge(steering=False)
652+
except QueueFullError as exc:
653+
app.notify(str(exc), severity="error", timeout=8)
649654
return
650655

651656
if text.startswith("!"):
@@ -1325,19 +1330,47 @@ def _parse_args(raw: Any) -> dict[str, Any]:
13251330
severity="warning",
13261331
timeout=6,
13271332
)
1328-
# When the run was cancelled, follow-ups referring to the cancelled
1329-
# task are likely stale too. Discard with a count-only notification.
1333+
from pydantic_deep.features.message_queue import (
1334+
format_follow_up as _fmt_fu,
1335+
)
1336+
from pydantic_deep.features.message_queue import (
1337+
queued_source,
1338+
)
1339+
1340+
# Follow-ups a human typed for the cancelled task are stale; one
1341+
# submitted from outside was not, and its sender cannot see a silent drop.
13301342
if _run_cancelled:
1331-
stale_fu = await _stale_queue.drain_follow_up()
1332-
if stale_fu:
1333-
n = len(stale_fu)
1343+
discarded = await _stale_queue.discard_follow_up(
1344+
keep=lambda m: queued_source(m) is not None
1345+
)
1346+
if discarded:
1347+
n = len(discarded)
13341348
label = "follow-up" if n == 1 else "follow-ups"
13351349
with contextlib.suppress(Exception):
13361350
app.notify(
13371351
f"{n} {label} discarded - run cancelled",
13381352
severity="warning",
13391353
timeout=6,
13401354
)
1355+
1356+
# A message queued after the post-run drain still saw the run as
1357+
# active, so it landed as a follow-up nothing else will deliver.
1358+
if not _follow_up_scheduled:
1359+
stranded = await _stale_queue.drain_follow_up()
1360+
if stranded:
1361+
stranded_text = _fmt_fu(stranded)
1362+
msg_list.append_user_message(stranded_text)
1363+
self._decrement_queue_badge(len(stranded))
1364+
_follow_up_scheduled = True
1365+
self.call_later(self._run_agent, stranded_text)
1366+
if _run_cancelled:
1367+
n = len(stranded)
1368+
label = "follow-up" if n == 1 else "follow-ups"
1369+
with contextlib.suppress(Exception):
1370+
app.notify(
1371+
f"{n} external {label} kept - starting a new turn",
1372+
timeout=6,
1373+
)
13411374
if not _follow_up_scheduled:
13421375
self._reset_queue_badge()
13431376
else:

docs/advanced/message-queue.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,55 @@ await queue.steer("context line 2", delivery_mode="all")
107107

108108
The mode is read from the *head* message, and an `"all"` head never swallows a later message that asked to be delivered on its own — so you can mix the two freely.
109109

110+
## Say where it came from
111+
112+
Every message takes free-form `metadata`, and one key is special: `source`. Name the channel a message arrived on and the queue carries that through to the label the agent reads.
113+
114+
```python
115+
await queue.steer("also check MR 123", metadata={"source": "slack", "ts": "1784850124.802469"})
116+
await queue.follow_up("then close PIPE-1234", metadata={"source": "jira"})
117+
```
118+
119+
The agent sees `[steering via slack] also check MR 123` and `[follow-up via jira] then close PIPE-1234`. That matters once more than one thing can talk to your agent: a human leaning in and a CI monitor shouting about a red build deserve different weight, and the label is what lets the model tell them apart.
120+
121+
Messages with no `source` are treated as local — a follow-up you typed yourself is delivered verbatim, exactly as before.
122+
123+
The label also lands in logs and on the enclosing span (`pydantic_deep.message_queue.steering.sources`), so you can see in a trace which channel changed the agent's mind. The rest of `metadata` is yours: it never reaches the model, so `ts`, `message_id` and friends are for your own dedup and logging.
124+
125+
!!! warning "`source` is sanitized"
126+
It ends up inside a bracketed label in the prompt, so whatever you pass is
127+
reduced to word characters plus `.`, `:` and `-`, then truncated. An external
128+
system can't smuggle prompt text in through the channel name.
129+
130+
## Backpressure
131+
132+
The queue holds at most `DEFAULT_MAX_PENDING` (100) pending messages per priority. Past that, `steer()` and `follow_up()` raise [`QueueFullError`][pydantic_deep.features.message_queue.QueueFullError] instead of accepting the message:
133+
134+
```python
135+
from pydantic_deep.features.message_queue import MessageQueue, QueueFullError
136+
137+
queue = MessageQueue(max_pending=20) # or None to remove the cap
138+
139+
try:
140+
await queue.follow_up(reply.text, metadata={"source": "slack"})
141+
except QueueFullError:
142+
await slack.react(reply, "warning") # tell the sender it didn't land
143+
```
144+
145+
It fails loudly on purpose. A retrying webhook or a busy thread can produce messages faster than the agent consumes them, and every one that lands eventually costs tokens — so a bridge needs to know a submission was refused rather than watch it vanish.
146+
147+
### Pruning what a cancelled run made stale
148+
149+
When a run is cancelled, the follow-ups queued for it are usually stale — but not all of them. Use [`discard_follow_up`][pydantic_deep.features.message_queue.MessageQueue.discard_follow_up] with a predicate to keep the ones that were never about that run:
150+
151+
```python
152+
from pydantic_deep.features.message_queue import queued_source
153+
154+
discarded = await queue.discard_follow_up(keep=lambda m: queued_source(m) is not None)
155+
```
156+
157+
This is what the CLI does when you hit `Esc`: what you typed for the cancelled task is dropped, while anything submitted from outside survives and starts a fresh turn. Its sender has no way to learn it was dropped, so dropping it would read as the agent ignoring them.
158+
110159
## Subagents can steer the parent
111160

112161
Because the queue lives on `DeepAgentDeps`, anything with `ctx.deps` can use it — including a subagent. By default `clone_for_subagent()` hands subagents the *same* queue, so a child can talk back to the parent:
@@ -130,6 +179,8 @@ Want isolation instead? Give the cloned deps a fresh `MessageQueue()` and the ch
130179
- Share one queue with both `create_deep_agent(message_queue=…)` and `DeepAgentDeps(message_queue=…)`.
131180
- Steering works on plain `agent.run`; follow-ups need [`run_with_queue`][pydantic_deep.features.message_queue.run_with_queue] to re-enter the loop.
132181
- `delivery_mode="all"` batches messages; the default drip-feeds one at a time.
182+
- `metadata={"source": …}` labels the delivery for the model (`[steering via slack]`) and shows up in logs and span attributes.
183+
- The queue is bounded (100 per priority); enqueueing past the cap raises `QueueFullError` so the sender learns the message was refused.
133184

134185
Where to go next:
135186

docs/api/message-queue.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,15 @@ otherwise stop). Pass one via `message_queue=` on
2323
::: pydantic_deep.features.message_queue.run_with_queue
2424
options:
2525
show_source: false
26+
27+
## queued_source
28+
29+
::: pydantic_deep.features.message_queue.queued_source
30+
options:
31+
show_source: false
32+
33+
## QueueFullError
34+
35+
::: pydantic_deep.features.message_queue.QueueFullError
36+
options:
37+
show_source: false

pydantic_deep/features/message_queue/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,25 @@
55
"""
66

77
from pydantic_deep.features.message_queue.capability import (
8+
DEFAULT_MAX_PENDING,
89
MessageQueue,
910
MessageQueueCapability,
1011
QueuedMessage,
12+
QueueFullError,
1113
format_follow_up,
1214
format_steering,
15+
queued_source,
1316
run_with_queue,
1417
)
1518

1619
__all__ = [
20+
"DEFAULT_MAX_PENDING",
1721
"MessageQueue",
1822
"MessageQueueCapability",
23+
"QueueFullError",
1924
"QueuedMessage",
2025
"format_follow_up",
2126
"format_steering",
27+
"queued_source",
2228
"run_with_queue",
2329
]

0 commit comments

Comments
 (0)