Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 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 @@ -617,6 +617,11 @@ async def resume_session_stream(
active_session, last_message_id = await stream_registry.get_active_session(
session_id, user_id
)
logger.info(
f"[STREAM_DIAG] resume_session_stream: session={session_id}, "
f"has_active={active_session is not None}, "
f"status={active_session.status if active_session else 'N/A'}"
)

if not active_session:
return Response(status_code=204)
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
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,12 @@ async def _execute_async(
exc_info=True,
)

logger.info(
f"[STREAM_DIAG] stream_fn loop ended, calling mark_session_completed, "
f"session_id={entry.session_id}, cancel={cancel.is_set()}, "
f"turn_id={entry.turn_id}"
)

error_message = "Operation cancelled" if cancel.is_set() else None
await stream_registry.mark_session_completed(
entry.session_id, error_message=error_message
Expand Down
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
105 changes: 94 additions & 11 deletions autogpt_platform/backend/backend/copilot/sdk/security_hooks_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,19 +208,22 @@ def test_bash_builtin_blocked_message_clarity():

@pytest.fixture()
def _hooks():
"""Create security hooks and return the PreToolUse handler."""
"""Create security hooks and return (pre, post, post_failure) handlers."""
from .security_hooks import create_security_hooks

hooks = create_security_hooks(user_id="u1", sdk_cwd=SDK_CWD, max_subtasks=2)
pre = hooks["PreToolUse"][0].hooks[0]
return pre
post = hooks["PostToolUse"][0].hooks[0]
post_failure = hooks["PostToolUseFailure"][0].hooks[0]
return pre, post, post_failure


@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
@pytest.mark.asyncio
async def test_task_background_blocked(_hooks):
"""Task with run_in_background=true must be denied."""
result = await _hooks(
pre, _, _ = _hooks
result = await pre(
{"tool_name": "Task", "tool_input": {"run_in_background": True, "prompt": "x"}},
tool_use_id=None,
context={},
Expand All @@ -233,9 +236,10 @@ async def test_task_background_blocked(_hooks):
@pytest.mark.asyncio
async def test_task_foreground_allowed(_hooks):
"""Task without run_in_background should be allowed."""
result = await _hooks(
pre, _, _ = _hooks
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "do stuff"}},
tool_use_id=None,
tool_use_id="tu-1",
context={},
)
assert not _is_denied(result)
Expand All @@ -245,25 +249,102 @@ async def test_task_foreground_allowed(_hooks):
@pytest.mark.asyncio
async def test_task_limit_enforced(_hooks):
"""Task spawns beyond max_subtasks should be denied."""
pre, _, _ = _hooks
# First two should pass
for _ in range(2):
result = await _hooks(
for i in range(2):
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "ok"}},
tool_use_id=None,
tool_use_id=f"tu-limit-{i}",
context={},
)
assert not _is_denied(result)

# Third should be denied (limit=2)
result = await _hooks(
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "over limit"}},
tool_use_id=None,
tool_use_id="tu-limit-2",
context={},
)
assert _is_denied(result)
assert "Maximum" in _reason(result)


@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
@pytest.mark.asyncio
async def test_task_slot_released_on_completion(_hooks):
"""Completing a Task should free a slot so new Tasks can be spawned."""
pre, post, _ = _hooks
# Fill both slots
for i in range(2):
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "ok"}},
tool_use_id=f"tu-comp-{i}",
context={},
)
assert not _is_denied(result)

# Third should be denied — at capacity
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "over"}},
tool_use_id="tu-comp-2",
context={},
)
assert _is_denied(result)

# Complete first task — frees a slot
await post(
{"tool_name": "Task", "tool_input": {}},
tool_use_id="tu-comp-0",
context={},
)

# Now a new Task should be allowed
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "after release"}},
tool_use_id="tu-comp-3",
context={},
)
assert not _is_denied(result)


@pytest.mark.skipif(not _sdk_available(), reason="claude_agent_sdk not installed")
@pytest.mark.asyncio
async def test_task_slot_released_on_failure(_hooks):
"""A failed Task should also free its concurrency slot."""
pre, _, post_failure = _hooks
# Fill both slots
for i in range(2):
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "ok"}},
tool_use_id=f"tu-fail-{i}",
context={},
)
assert not _is_denied(result)

# At capacity
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "over"}},
tool_use_id="tu-fail-2",
context={},
)
assert _is_denied(result)

# Fail first task — should free a slot
await post_failure(
{"tool_name": "Task", "tool_input": {}, "error": "something broke"},
tool_use_id="tu-fail-0",
context={},
)

# New Task should be allowed
result = await pre(
{"tool_name": "Task", "tool_input": {"prompt": "after failure"}},
tool_use_id="tu-fail-3",
context={},
)
assert not _is_denied(result)


# -- _is_tool_error_or_denial ------------------------------------------------


Expand Down Expand Up @@ -298,7 +379,9 @@ def test_background_task_denial(self):
def test_subtask_limit_denial(self):
assert (
_is_tool_error_or_denial(
"Maximum 2 sub-tasks per session. Please continue in the main conversation."
"Maximum 2 concurrent sub-tasks. "
"Wait for running sub-tasks to finish, "
"or continue in the main conversation."
)
is True
)
Expand Down
28 changes: 28 additions & 0 deletions autogpt_platform/backend/backend/copilot/sdk/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,25 @@ async def _next_msg() -> Any:
- len(adapter.resolved_tool_calls),
)

# Log ResultMessage details for debugging
if isinstance(sdk_msg, ResultMessage):
logger.info(
"[SDK] [%s] Received: ResultMessage %s "
"(unresolved=%d, current=%d, resolved=%d)",
session_id[:12],
sdk_msg.subtype,
len(adapter.current_tool_calls)
- len(adapter.resolved_tool_calls),
len(adapter.current_tool_calls),
len(adapter.resolved_tool_calls),
)
if sdk_msg.subtype in ("error", "error_during_execution"):
logger.error(
"[SDK] [%s] SDK execution failed with error: %s",
session_id[:12],
sdk_msg.result or "(no error message provided)",
)

for response in adapter.convert_message(sdk_msg):
if isinstance(response, StreamStart):
continue
Expand All @@ -749,6 +768,15 @@ async def _next_msg() -> Any:
extra,
)

# Log errors being sent to frontend
if isinstance(response, StreamError):
logger.error(
"[SDK] [%s] Sending error to frontend: %s (code=%s)",
session_id[:12],
response.errorText,
response.code,
)

yield response

if isinstance(response, StreamTextDelta):
Expand Down
4 changes: 4 additions & 0 deletions autogpt_platform/backend/backend/copilot/stream_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,10 @@ async def mark_session_completed(
True if session was newly marked completed, False if already completed/failed
"""
status: Literal["completed", "failed"] = "failed" if error_message else "completed"
logger.info(
f"[STREAM_DIAG] mark_session_completed called, session={session_id}, "
f"status={status}, error={error_message!r}"
)

redis = await get_redis_async()
meta_key = _get_session_meta_key(session_id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,15 @@ export function useChatSession() {
// resume which fires before hydration).
const hasActiveStream = useMemo(() => {
if (sessionQuery.data?.status !== 200) return false;
return !!sessionQuery.data.data.active_stream;
}, [sessionQuery.data]);
const active = !!sessionQuery.data.data.active_stream;
console.info("[STREAM_DIAG] session data", {
sessionId,
hasActiveStream: active,
messageCount: sessionQuery.data.data.messages?.length ?? 0,
ts: Date.now(),
});
return active;
}, [sessionQuery.data, sessionId]);

// Memoize so the effect in useCopilotPage doesn't infinite-loop on a new
// array reference every render. Re-derives only when query data changes.
Expand Down Expand Up @@ -119,5 +126,6 @@ export function useChatSession() {
isSessionError: sessionQuery.isError,
createSession,
isCreatingSession,
refetchSession: sessionQuery.refetch,
};
}
Loading