Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions agent.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import asyncio, random, string
import asyncio, random, string, time
import nest_asyncio

nest_asyncio.apply()
Expand Down Expand Up @@ -82,7 +82,10 @@ def __init__(
self.log.context = self
self.paused = paused
self.streaming_agent = streaming_agent
self.last_active_agent: "Agent | None" = None # persists for nudge recovery
self.last_stream_time: float = 0.0 # timestamp of last LLM stream chunk
self.task: DeferredTask | None = None
self._watchdog_task: asyncio.Task | None = None # auto-nudge watchdog
self.created_at = created_at or datetime.now(timezone.utc)
self.type = type
AgentContext._counter += 1
Expand Down Expand Up @@ -222,13 +225,44 @@ def reset(self):
self.paused = False

def nudge(self):
self._stop_watchdog() # stop current watchdog before restarting
self.kill_process()
self.paused = False
self.task = self.run_task(self.get_agent().monologue)
return self.task

def get_agent(self):
return self.streaming_agent or self.agent0
return self.streaming_agent or self.last_active_agent or self.agent0

async def _auto_nudge_watchdog(self):
"""Background task that monitors for stuck LLM streaming and triggers auto-nudge."""
try:
while self.task and self.task.is_alive():
await asyncio.sleep(5) # check every 5 seconds
if not self.config.auto_nudge_enabled:
continue
if self.last_stream_time == 0:
continue # no streaming started yet
elapsed = time.time() - self.last_stream_time
if elapsed > self.config.auto_nudge_timeout:
agent = self.get_agent()
msg = f"Auto-nudge triggered: no LLM response for {elapsed:.0f}s (Agent {agent.number})"
self.log.log(type="warning", content=msg)
self.nudge()
break
except asyncio.CancelledError:
pass # normal shutdown

def _start_watchdog(self):
"""Start the auto-nudge watchdog if enabled."""
if self.config.auto_nudge_enabled and self._watchdog_task is None:
self._watchdog_task = asyncio.create_task(self._auto_nudge_watchdog())

def _stop_watchdog(self):
"""Stop the auto-nudge watchdog."""
if self._watchdog_task:
self._watchdog_task.cancel()
self._watchdog_task = None

def communicate(self, msg: "UserMessage", broadcast_level: int = 1):
self.paused = False # unpause if paused
Expand Down Expand Up @@ -256,7 +290,9 @@ def run_task(
self.task = DeferredTask(
thread_name=self.__class__.__name__,
)
self.last_stream_time = 0.0 # reset for new task
self.task.start_task(func, *args, **kwargs)
self._start_watchdog() # start auto-nudge monitoring
return self.task

# this wrapper ensures that superior agents are called back if the chat was loaded from file and original callstack is gone
Expand Down Expand Up @@ -296,6 +332,8 @@ class AgentConfig:
code_exec_ssh_port: int = 55022
code_exec_ssh_user: str = "root"
code_exec_ssh_pass: str = ""
auto_nudge_enabled: bool = False
auto_nudge_timeout: int = 60 # seconds without LLM streaming before auto-nudge
additional: Dict[str, Any] = field(default_factory=dict)


Expand Down Expand Up @@ -378,6 +416,7 @@ async def monologue(self):
while True:

self.context.streaming_agent = self # mark self as current streamer
self.context.last_active_agent = self # persist for nudge recovery
self.loop_data.iteration += 1
self.loop_data.params_temporary = {} # clear temporary params

Expand All @@ -397,6 +436,7 @@ async def monologue(self):

async def reasoning_callback(chunk: str, full: str):
await self.handle_intervention()
self.context.last_stream_time = time.time() # update for auto-nudge
if chunk == full:
printer.print("Reasoning: ") # start of reasoning
# Pass chunk and full data to extensions for processing
Expand All @@ -414,6 +454,7 @@ async def reasoning_callback(chunk: str, full: str):

async def stream_callback(chunk: str, full: str):
await self.handle_intervention()
self.context.last_stream_time = time.time() # update for auto-nudge
# output the agent response stream
if chunk == full:
printer.print("Response: ") # start of response
Expand Down
114 changes: 114 additions & 0 deletions docs/designs/2025-01-09-nudge-improvement-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# Nudge Functionality Improvement Design

**Date:** 2025-01-09
**Status:** Approved
**Branch:** feat-nudge-improvement

## Problem

The nudge button resets execution to Agent 0 when a subordinate agent is running. Users expect nudge to restart the *current* agent, not the root agent.

**Root cause:** The `nudge()` method calls `get_agent()`, which returns `streaming_agent or agent0`. Since `streaming_agent` is cleared to `None` at the end of each monologue (`agent.py:504`), nudge always falls back to `agent0`.

## Solution

### 1. Core Fix: Track Last Active Agent

Add a `last_active_agent` field to `AgentContext` that persists beyond monologue completion.

**Changes to `agent.py`:**

```python
# In AgentContext.__init__():
self.last_active_agent: Agent | None = None

# In Agent.monologue(), where streaming_agent is set (~line 369):
self.context.last_active_agent = self

# Updated get_agent():
def get_agent(self):
return self.streaming_agent or self.last_active_agent or self.agent0
```

The `nudge()` method remains unchanged—it already calls `get_agent().monologue()`. The fix is in what `get_agent()` returns.

**Why this works:**
- `last_active_agent` is set when any agent starts its monologue
- Unlike `streaming_agent`, it is never cleared
- Existing `_process_chain` mechanics handle bubbling responses back to superior agents

### 2. Auto-Nudge: LLM Streaming Timeout

Detect when the LLM stops responding mid-stream and trigger automatic nudge.

**Configuration (in `AgentConfig`):**

```python
auto_nudge_enabled: bool = False
auto_nudge_timeout: int = 60 # seconds
```

**Implementation:**

```python
# In AgentContext.__init__():
self.last_stream_time: float = 0.0

# In streaming callbacks (reasoning_callback / stream_callback):
self.context.last_stream_time = time.time()

# Background watchdog task:
async def _auto_nudge_watchdog(self):
while self.task and self.task.is_alive():
await asyncio.sleep(5)
if not self.auto_nudge_enabled:
continue
if self.last_stream_time == 0:
continue
elapsed = time.time() - self.last_stream_time
if elapsed > self.auto_nudge_timeout:
self.log.log(type="warning",
content=f"Auto-nudge triggered: no LLM response for {elapsed:.0f}s")
self.nudge()
break
```

**Watchdog lifecycle:**
- Started when `run_task()` begins a new task
- Stops when task completes or nudge triggers
- Only monitors during active LLM streaming

### 3. UI Feedback

Update `python/api/nudge.py` to report which agent was nudged:

```python
agent = context.get_agent()
context.nudge()
msg = f"Agent {agent.number} nudged."
return {
"message": msg,
"ctxid": context.id,
"agent_number": agent.number,
}
```

## Implementation Order

1. **Core fix** - Add `last_active_agent`, update `get_agent()`
2. **Auto-nudge** - Add config, timestamp tracking, watchdog
3. **API update** - Enhanced nudge response

## Files Modified

- `agent.py` - Core changes (~30 lines)
- `python/api/nudge.py` - Enhanced response (~5 lines)

## Testing

| Scenario | Steps | Expected |
|----------|-------|----------|
| Nudge subordinate | Agent 0→1→2 chain, nudge at Agent 2 | Agent 2 resumes |
| Nudge after completion | Agent 2 completes, nudge before Agent 1 responds | Agent 2 restarts |
| Auto-nudge triggers | Enable auto-nudge, 60s+ no chunks | Auto-nudge fires |
| Auto-nudge disabled | Default config, stuck LLM | No auto-nudge |
6 changes: 4 additions & 2 deletions python/api/nudge.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ async def process(self, input: dict, request: Request) -> dict | Response:
raise Exception("No context id provided")

context = self.use_context(ctxid)
agent = context.get_agent()
context.nudge()

msg = "Process reset, agent nudged."
msg = f"Agent {agent.number} nudged."
context.log.log(type="info", content=msg)

return {
"message": msg,
"ctxid": context.id,
"agent_number": agent.number,
}
Loading