Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
08d5a2a
fix(backend): convert subtask limit from lifetime cap to concurrency …
majdyz Feb 25, 2026
db3d10f
fix(frontend): auto-reconnect copilot stream on disconnect
majdyz Feb 25, 2026
142198d
fix(backend): release subtask slot on Task failure
majdyz Feb 25, 2026
3c7545c
fix(backend): track subtask slots by tool_use_id
majdyz Feb 25, 2026
fea5e7d
Merge branch 'dev' into fix/copilot-subtask-concurrency-limit
majdyz Feb 25, 2026
cff210f
fix(frontend): reset refs on session switch, toast on reconnect failure
majdyz Feb 25, 2026
bfd98aa
Merge branch 'dev' into fix/copilot-subtask-concurrency-limit
majdyz Feb 25, 2026
ecc38f8
fix(backend): only consume subtask slot when tool_use_id is present
majdyz Feb 25, 2026
5d06ef5
fix(backend): use proper typing instead of getattr in SDK logging
majdyz Feb 25, 2026
dba39cd
refactor(backend): simplify toolName access in SDK logging
majdyz Feb 25, 2026
963eff0
Merge branch 'dev' into fix/copilot-subtask-concurrency-limit
majdyz Feb 25, 2026
c99b7aa
revert: keep defensive getattr for toolName logging
majdyz Feb 25, 2026
82074fd
feat(backend/frontend): add stream diagnostics and stall detection
majdyz Feb 25, 2026
2eb3641
fix(frontend): reconnect on network errors & increase SSE route timeout
majdyz Feb 26, 2026
ce58b12
fix(backend/frontend): stream reconnect, stall detection, dedup, thin…
majdyz Feb 26, 2026
84334a0
fix(frontend): prevent duplicates on reconnect, remove unused var
majdyz Feb 26, 2026
d6760a4
fix(frontend): clear stall timer on session switch (sentry review)
majdyz Feb 26, 2026
c88d360
fix(backend): improve error handling and persistence in copilot
majdyz Feb 26, 2026
9cea623
fix(backend): handle SDK cleanup RuntimeError during cancellation
majdyz Feb 26, 2026
4fe8954
fix(platform): improve copilot error handling and stream reliability
majdyz Feb 26, 2026
3ee6938
refactor(backend): consolidate exception handling, remove duplicate m…
majdyz Feb 26, 2026
3bbd5ee
fix(backend): RuntimeError handling in SDK service
majdyz Feb 26, 2026
670e557
fix(backend): use actual error message in StreamError
majdyz Feb 26, 2026
a66311a
fix(frontend): keep messages visible during reconnect
majdyz Feb 26, 2026
a56374a
fix(frontend): refetch session on tab focus, defer message clearing
majdyz Feb 26, 2026
73ea149
fix(frontend): refetch session when switching between chats
majdyz Feb 26, 2026
0a76a04
fix(backend): make exception handling order consistent
majdyz Feb 26, 2026
4dd0b36
fix(frontend): clear hasResumed flag when switching chats
majdyz Feb 26, 2026
2f42961
refactor: remove diagnostic logging (STREAM_DIAG)
majdyz Feb 26, 2026
52caa84
fix(backend): persist error for RuntimeError cancel scope issue
majdyz Feb 26, 2026
8f3eaa1
refactor(backend): simplify cancel scope RuntimeError handling
majdyz Feb 26, 2026
70a8c2a
fix(backend): persist session messages when execution is stopped
majdyz Feb 26, 2026
1f0442b
fix(backend): catch BaseException to handle CancelledError properly
majdyz Feb 26, 2026
adeb089
fix(backend/frontend): address PR review comments
majdyz Feb 26, 2026
d4b3c6c
refactor(backend): consolidate session completion logic
majdyz Feb 26, 2026
5855a87
fix(backend): use new event loop in on_run_done callback
majdyz Feb 26, 2026
d613c4c
refactor(backend): move mark_session_completed to finally block
majdyz Feb 26, 2026
4c376fb
refactor(backend): use temporary loop in finally block
majdyz Feb 26, 2026
460f793
refactor(backend): move mark_session_completed to processor finally
majdyz Feb 26, 2026
69254df
fix(backend): restore info log level for session persistence
majdyz Feb 26, 2026
79a0cc0
fix(backend): preserve error message in session completion
majdyz Feb 26, 2026
a94b0ab
refactor(frontend): simplify reconnect logic and consolidate effects
majdyz Feb 26, 2026
50c0b0c
fix(backend): prevent duplicate assistant message on reconnect
majdyz Feb 26, 2026
213de50
Revert "fix(backend): prevent duplicate assistant message on reconnect"
majdyz Feb 26, 2026
394cac5
fix(frontend): prevent duplicate messages on reconnect
majdyz Feb 26, 2026
ec0511d
fix(frontend): improve message deduplication on reconnect
majdyz Feb 26, 2026
3eecd6b
refactor(frontend): simplify reconnect and deduplication logic
majdyz Feb 26, 2026
3b30234
fix(frontend): deduplicate first assistant message across sources
majdyz Feb 26, 2026
de693f0
debug(frontend): add logging to investigate duplicate messages
majdyz Feb 26, 2026
286f8c6
Revert "debug(frontend): add logging to investigate duplicate messages"
majdyz Feb 26, 2026
205274a
refactor(frontend): remove ineffective content-based deduplication
majdyz Feb 26, 2026
792674a
fix(frontend): reset prevStatusRef on session switch
majdyz Feb 26, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -621,11 +621,10 @@ async def resume_session_stream(
if not active_session:
return Response(status_code=204)

# Subscribe from the beginning ("0-0") to replay all chunks for this turn.
Comment thread
majdyz marked this conversation as resolved.
# This is necessary because hydrated messages filter out incomplete tool calls
# to avoid "No tool invocation found" errors. The resume stream delivers
# those tool calls fresh with proper SDK state.
# The AI SDK's deduplication will handle any duplicate chunks.
# Always replay from the beginning ("0-0") on resume.
# We can't use last_message_id because it's the latest ID in the backend
# stream, not the latest the frontend received — the gap causes lost
# messages. The frontend deduplicates replayed content.
subscriber_queue = await stream_registry.subscribe_to_session(
session_id=session_id,
user_id=user_id,
Expand Down
2 changes: 1 addition & 1 deletion autogpt_platform/backend/backend/copilot/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class ChatConfig(BaseSettings):
)
claude_agent_max_subtasks: int = Field(
default=10,
description="Max number of sub-agent Tasks the SDK can spawn per session.",
description="Max number of concurrent sub-agent Tasks the SDK can run per session.",
)
claude_agent_use_resume: bool = Field(
default=True,
Expand Down
10 changes: 8 additions & 2 deletions autogpt_platform/backend/backend/copilot/executor/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
RabbitMQ and processes them using a thread pool, following the graph executor pattern.
"""

import asyncio
Comment thread
majdyz marked this conversation as resolved.
import logging
import os
import threading
Expand Down Expand Up @@ -409,14 +410,19 @@ def ack_message(reject: bool, requeue: bool):

def on_run_done(f: Future):
logger.info(f"Run completed for {session_id}")
error_msg = None
try:
if exec_error := f.exception():
logger.error(f"Execution for {session_id} failed: {exec_error}")
error_msg = str(exec_error) or type(exec_error).__name__
logger.error(f"Execution for {session_id} failed: {error_msg}")
ack_message(reject=True, requeue=False)
else:
ack_message(reject=False, requeue=False)
except asyncio.CancelledError:
logger.info(f"Run completion callback cancelled for {session_id}")
except BaseException as e:
logger.exception(f"Error in run completion callback: {e}")
error_msg = str(e) or type(e).__name__
logger.exception(f"Error in run completion callback: {error_msg}")
finally:
# Release the cluster lock
if session_id in self._task_locks:
Expand Down
90 changes: 43 additions & 47 deletions autogpt_platform/backend/backend/copilot/executor/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,10 @@ def cleanup(self):
)
future.result(timeout=5)
except Exception as e:
logger.warning(f"[CoPilotExecutor] Worker {self.tid} cleanup error: {e}")
error_msg = str(e) or type(e).__name__
logger.warning(
f"[CoPilotExecutor] Worker {self.tid} cleanup error: {error_msg}"
)

# Stop the event loop
self.execution_loop.call_soon_threadsafe(self.execution_loop.stop)
Expand Down Expand Up @@ -157,47 +160,30 @@ def execute(

start_time = time.monotonic()

try:
# Run the async execution in our event loop
future = asyncio.run_coroutine_threadsafe(
self._execute_async(entry, cancel, cluster_lock, log),
self.execution_loop,
)

# Wait for completion, checking cancel periodically
while not future.done():
try:
future.result(timeout=1.0)
except asyncio.TimeoutError:
if cancel.is_set():
log.info("Cancellation requested")
future.cancel()
break
# Refresh cluster lock to maintain ownership
cluster_lock.refresh()
# Run the async execution in our event loop
future = asyncio.run_coroutine_threadsafe(
self._execute_async(entry, cancel, cluster_lock, log),
self.execution_loop,
)

if not future.cancelled():
# Get result to propagate any exceptions
future.result()
# Wait for completion, checking cancel periodically
while not future.done():
try:
future.result(timeout=1.0)
except asyncio.TimeoutError:
if cancel.is_set():
log.info("Cancellation requested")
future.cancel()
break
# Refresh cluster lock to maintain ownership
cluster_lock.refresh()

elapsed = time.monotonic() - start_time
log.info(f"Execution completed in {elapsed:.2f}s")
if not future.cancelled():
# Get result to propagate any exceptions
future.result()

except BaseException as e:
elapsed = time.monotonic() - start_time
log.error(f"Execution failed after {elapsed:.2f}s: {e}")
# Safety net: if _execute_async's error handler failed to mark
# the session (e.g. RuntimeError from SDK cleanup), do it here.
try:
asyncio.run_coroutine_threadsafe(
stream_registry.mark_session_completed(
entry.session_id, error_message=str(e) or "Unknown error"
),
self.execution_loop,
).result(timeout=5.0)
except Exception as cleanup_err:
log.error(f"Safety net mark_session_completed failed: {cleanup_err}")
raise
elapsed = time.monotonic() - start_time
log.info(f"Execution completed in {elapsed:.2f}s")

async def _execute_async(
self,
Expand All @@ -219,6 +205,7 @@ async def _execute_async(
"""
last_refresh = time.monotonic()
refresh_interval = 30.0 # Refresh lock every 30 seconds
error_msg = None

try:
# Choose service based on LaunchDarkly flag
Expand Down Expand Up @@ -264,17 +251,26 @@ async def _execute_async(
exc_info=True,
)

error_message = "Operation cancelled" if cancel.is_set() else None
await stream_registry.mark_session_completed(
entry.session_id, error_message=error_message
)
# Stream loop completed
if cancel.is_set():
log.info("Stream cancelled by user")

except BaseException as e:
log.error(f"Turn failed: {e}")
# Handle all exceptions (including CancelledError) with appropriate logging
if isinstance(e, asyncio.CancelledError):
log.info("Turn cancelled")
error_msg = "Operation cancelled"
else:
error_msg = str(e) or type(e).__name__
log.error(f"Turn failed: {error_msg}")
Comment thread
majdyz marked this conversation as resolved.
raise
finally:
# If no exception but user cancelled, still mark as cancelled
if not error_msg and cancel.is_set():
error_msg = "Operation cancelled"
try:
await stream_registry.mark_session_completed(
entry.session_id, error_message=str(e) or "Unknown error"
entry.session_id, error_message=error_msg
)
except Exception as mark_err:
log.error(f"mark_session_completed also failed: {mark_err}")
raise
log.error(f"Failed to mark session completed: {mark_err}")
36 changes: 28 additions & 8 deletions autogpt_platform/backend/backend/copilot/sdk/security_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ def create_security_hooks(
Args:
user_id: Current user ID for isolation validation
sdk_cwd: SDK working directory for workspace-scoped tool validation
max_subtasks: Maximum Task (sub-agent) spawns allowed per session
max_subtasks: Maximum concurrent Task (sub-agent) spawns allowed per session
on_stop: Callback ``(transcript_path, sdk_session_id)`` invoked when
the SDK finishes processing — used to read the JSONL transcript
before the CLI process exits.
Comment thread
majdyz marked this conversation as resolved.
Expand All @@ -172,16 +172,16 @@ def create_security_hooks(
from claude_agent_sdk import HookMatcher
from claude_agent_sdk.types import HookContext, HookInput, SyncHookJSONOutput

# Per-session counter for Task sub-agent spawns
task_spawn_count = 0
# Per-session tracking for Task sub-agent concurrency.
# Set of tool_use_ids that consumed a slot — len() is the active count.
task_tool_use_ids: set[str] = set()

async def pre_tool_use_hook(
input_data: HookInput,
tool_use_id: str | None,
context: HookContext,
) -> SyncHookJSONOutput:
"""Combined pre-tool-use validation hook."""
nonlocal task_spawn_count
_ = context # unused but required by signature
tool_name = cast(str, input_data.get("tool_name", ""))
tool_input = cast(dict[str, Any], input_data.get("tool_input", {}))
Expand All @@ -200,18 +200,18 @@ async def pre_tool_use_hook(
"(remove the run_in_background parameter)."
),
)
if task_spawn_count >= max_subtasks:
if len(task_tool_use_ids) >= max_subtasks:
logger.warning(
f"[SDK] Task limit reached ({max_subtasks}), user={user_id}"
)
return cast(
SyncHookJSONOutput,
_deny(
f"Maximum {max_subtasks} sub-tasks per session. "
"Please continue in the main conversation."
f"Maximum {max_subtasks} concurrent sub-tasks. "
"Wait for running sub-tasks to finish, "
"or continue in the main conversation."
),
)
task_spawn_count += 1

# Strip MCP prefix for consistent validation
is_copilot_tool = tool_name.startswith(MCP_TOOL_PREFIX)
Expand All @@ -229,9 +229,24 @@ async def pre_tool_use_hook(
if result:
return cast(SyncHookJSONOutput, result)

# Reserve the Task slot only after all validations pass
if tool_name == "Task" and tool_use_id is not None:
task_tool_use_ids.add(tool_use_id)

logger.debug(f"[SDK] Tool start: {tool_name}, user={user_id}")
return cast(SyncHookJSONOutput, {})

def _release_task_slot(tool_name: str, tool_use_id: str | None) -> None:
"""Release a Task concurrency slot if one was reserved."""
if tool_name == "Task" and tool_use_id in task_tool_use_ids:
task_tool_use_ids.discard(tool_use_id)
logger.info(
"[SDK] Task slot released, active=%d/%d, user=%s",
len(task_tool_use_ids),
max_subtasks,
user_id,
)

async def post_tool_use_hook(
input_data: HookInput,
tool_use_id: str | None,
Expand All @@ -246,6 +261,8 @@ async def post_tool_use_hook(
"""
_ = context
tool_name = cast(str, input_data.get("tool_name", ""))

_release_task_slot(tool_name, tool_use_id)
is_builtin = not tool_name.startswith(MCP_TOOL_PREFIX)
logger.info(
"[SDK] PostToolUse: %s (builtin=%s, tool_use_id=%s)",
Expand Down Expand Up @@ -289,6 +306,9 @@ async def post_tool_failure_hook(
f"[SDK] Tool failed: {tool_name}, error={error}, "
f"user={user_id}, tool_use_id={tool_use_id}"
)

_release_task_slot(tool_name, tool_use_id)

return cast(SyncHookJSONOutput, {})

async def pre_compact_hook(
Expand Down
Loading