Compare commits

..

16 Commits

Author SHA1 Message Date
Zamil Majdy
80104fbb3b Merge branch 'dev' of github.com:Significant-Gravitas/AutoGPT into feat/ask-question-tool 2026-04-02 15:55:52 +02:00
Zamil Majdy
6b031085bd feat(platform): add generic ask_question copilot tool (#12647)
### Why / What / How

**Why:** The copilot can ask clarifying questions in plain text, but
that text gets collapsed into hidden "reasoning" UI when the LLM also
calls tools in the same turn. This makes clarification questions
invisible to users. The existing `ClarificationNeededResponse` model and
`ClarificationQuestionsCard` UI component were built for this purpose
but had no tool wiring them up.

**What:** Adds a generic `ask_question` tool that produces a visible,
interactive clarification card instead of collapsible plain text. Unlike
the agent-generation-specific `clarify_agent_request` proposed in
#12601, this tool is workflow-agnostic — usable for agent building,
editing, troubleshooting, or any flow needing user input.

**How:** 
- Backend: New `AskQuestionTool` reuses existing
`ClarificationNeededResponse` model. Registered in `TOOL_REGISTRY` and
`ToolName` permissions.
- Frontend: New `AskQuestion/` renderer reuses
`ClarificationQuestionsCard` from CreateAgent. Registered in
`CUSTOM_TOOL_TYPES` (prevents collapse into reasoning) and
`MessagePartRenderer`.
- Guide: `agent_generation_guide.md` updated to reference `ask_question`
for the clarification step.

### Changes 🏗️

- **`copilot/tools/ask_question.py`** — New generic tool: takes
`question`, optional `options[]` and `keyword`, returns
`ClarificationNeededResponse`
- **`copilot/tools/__init__.py`** — Register `ask_question` in
`TOOL_REGISTRY`
- **`copilot/permissions.py`** — Add `ask_question` to `ToolName`
literal
- **`copilot/sdk/agent_generation_guide.md`** — Reference `ask_question`
tool in clarification step
- **`ChatMessagesContainer/helpers.ts`** — Add `tool-ask_question` to
`CUSTOM_TOOL_TYPES`
- **`MessagePartRenderer.tsx`** — Add switch case for
`tool-ask_question`
- **`AskQuestion/AskQuestion.tsx`** — Renderer reusing
`ClarificationQuestionsCard`
- **`AskQuestion/helpers.ts`** — Output parsing and animation text

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Backend format + pyright pass
  - [x] Frontend lint + types pass
  - [x] Pre-commit hooks pass
- [ ] Manual test: copilot uses `ask_question` and card renders visibly
(not collapsed)
2026-04-02 12:56:48 +00:00
Zamil Majdy
988edd6fe9 fix(platform): validate options/keyword params and pass sessionId to ClarificationQuestionsCard
- Validate options is actually a list (LLM may send string); coerce gracefully
- Validate keyword is a string
- Pass sessionId prop to ClarificationQuestionsCard for localStorage persistence
- Add test for invalid options coercion
2026-04-02 14:34:58 +02:00
Zamil Majdy
078e89f8a6 fix(backend): validate non-empty question in ask_question tool
Reject empty/whitespace-only question strings with ValueError instead
of producing an empty clarification card.
2026-04-02 14:18:58 +02:00
Zamil Majdy
9990e9e841 fix(backend): update prompting_test to match renamed guide section
The clarification section was renamed from "Clarifying Before Building"
to "Clarifying — Before or During Building". Update assertions and add
a test verifying ask_question is referenced.
2026-04-02 14:14:57 +02:00
Zamil Majdy
032fb061bb refactor(platform): consolidate clarification on ask_question tool
Remove dead clarification handling from CreateAgent and EditAgent —
both the isClarificationNeededOutput type guard and the
ClarificationQuestionsCard rendering. All clarification now goes
through the generic ask_question tool.

Update agent_generation_guide.md to allow ask_question at any point
in the workflow (not just before building), covering mid-flow
ambiguity during block discovery or JSON generation.
2026-04-02 14:04:33 +02:00
Zamil Majdy
5706c78341 fix(frontend): use normalized keywords in handleAnswers to match answer keys
The ClarificationQuestionsCard normalizes question keywords (dedup
suffixes), so answers are keyed by normalized keywords. handleAnswers
must use the same normalized questions to look up answers correctly.
2026-04-02 13:58:13 +02:00
Zamil Majdy
ffa5a5b0a7 fix(frontend): align isErrorOutput guard with parseOutput logic
Match the `"error" in output` check used in `parseOutput` so error
payloads without `type: "error"` are consistently recognized.
2026-04-02 13:50:40 +02:00
Zamil Majdy
2f50facfa9 refactor(frontend): remove re-export shim, import ClarificationQuestionsCard directly
Update CreateAgent and EditAgent to import ClarificationQuestionsCard
from the shared copilot/components/ location directly. Delete the
re-export shim to comply with no-barrel-files guideline.
2026-04-02 13:49:12 +02:00
Zamil Majdy
48b849934f fix(platform): address PR review — add tests, remove unused import, lift shared component
- Remove unused `logger` import from ask_question.py
- Add colocated ask_question_test.py with 3 tests covering options,
  no-options, and keyword-only cases
- Move ClarificationQuestionsCard to shared copilot/components/ location
  so AskQuestion imports from there instead of CreateAgent internals
2026-04-02 13:45:51 +02:00
Zamil Majdy
9678c4a86d feat(platform): add generic ask_question copilot tool
Add a generic `ask_question` tool that lets the copilot ask the user
clarifying questions via a dedicated UI card instead of plain text
(which gets collapsed into hidden reasoning). Reuses the existing
`ClarificationNeededResponse` model and `ClarificationQuestionsCard`
component.

Backend:
- New `AskQuestionTool` in `copilot/tools/ask_question.py`
- Registered in `TOOL_REGISTRY` and `ToolName` permissions literal
- Updated `agent_generation_guide.md` to reference `ask_question`

Frontend:
- Added `tool-ask_question` to `CUSTOM_TOOL_TYPES` (prevents collapse)
- New `AskQuestion/` renderer reusing `ClarificationQuestionsCard`
- Registered in `MessagePartRenderer` switch
2026-04-02 13:40:09 +02:00
Toran Bruce Richards
11b846dd49 fix(blocks): rename placeholder_values to options on AgentDropdownInputBlock (#12595)
## Summary

Resolves [REQ-78](https://linear.app/autogpt/issue/REQ-78): The
`placeholder_values` field on `AgentDropdownInputBlock` is misleadingly
named. In every major UI framework "placeholder" means non-binding hint
text that disappears on focus, but this field actually creates a
dropdown selector that restricts the user to only those values.

## Changes

### Core rename (`autogpt_platform/backend/backend/blocks/io.py`)
- Renamed `placeholder_values` → `options` on
`AgentDropdownInputBlock.Input`
- Added clear field description: *"If provided, renders the input as a
dropdown selector restricted to these values. Leave empty for free-text
input."*
- Updated class docstring to describe actual behavior
- Overrode `model_construct()` to remap legacy `placeholder_values` →
`options` for **backward compatibility** with existing persisted agent
JSON

### Tests (`autogpt_platform/backend/backend/blocks/test/test_block.py`)
- Updated existing tests to use canonical `options` field name
- Added 2 new backward-compat tests verifying legacy
`placeholder_values` still works through both `model_construct()` and
`Graph._generate_schema()` paths

### Documentation
- Updated
`autogpt_platform/backend/backend/copilot/sdk/agent_generation_guide.md`
— changed field name in CoPilot SDK guide
- Updated `docs/integrations/block-integrations/basic.md` — changed
field name and description in public docs

### Load tests
(`autogpt_platform/backend/load-tests/tests/api/graph-execution-test.js`)
- Removed spurious `placeholder_values: {}` from AgentInputBlock node
(this field never existed on AgentInputBlock)
- Fixed execution input to use `value` instead of `placeholder_values`

## Backward Compatibility

Existing agents with `placeholder_values` in their persisted
`input_default` JSON will continue to work — the `model_construct()`
override transparently remaps the old key to `options`. No database
migration needed since the field is stored inside a JSON blob, not as a
dedicated column.

## Testing

- All existing tests updated and passing
- 2 new backward-compat tests added
- No frontend changes needed (frontend reads `enum` from generated JSON
Schema, not the field name directly)

---------

Co-authored-by: Zamil Majdy <zamil.majdy@agpt.co>
2026-04-02 05:56:17 +00:00
Zamil Majdy
b9e29c96bd fix(backend/copilot): detect prompt-too-long in AssistantMessage content and ResultMessage success subtype (#12642)
## Why

PR #12625 fixed the prompt-too-long retry mechanism for most paths, but
two SDK-specific paths were still broken. The dev session `d2f7cba3`
kept accumulating synthetic "Prompt is too long" error entries on every
turn, growing the transcript from 2.5 MB → 3.2 MB, making recovery
impossible.

Root causes identified from production logs (`[T25]`, `[T28]`):

**Path 1 — AssistantMessage content check:**
When the Claude API rejects a prompt, the SDK surfaces it as
`AssistantMessage(error="invalid_request", content=[TextBlock("Prompt is
too long")])`. Our check only inspected `error_text = str(sdk_error)`
which is `"invalid_request"` — not a prompt-too-long pattern. The
content was then streamed out as `StreamText`, setting `events_yielded =
1`, which blocked retry even when the ResultMessage fired.

**Path 2 — ResultMessage success subtype:**
After the SDK auto-compacts internally (via `PreCompact` hook) and the
compacted transcript is _still_ too long, the SDK returns
`ResultMessage(subtype="success", result="Prompt is too long")`. Our
check only ran for `subtype="error"`. With `subtype="success"`, the
stream "completed normally", appended the synthetic error entry to the
transcript via `transcript_builder`, and uploaded it to GCS — causing
the transcript to grow on each failed turn.

## What

- **AssistantMessage handler**: when `sdk_error` is set, also check the
content text. `sdk_error` being non-`None` confirms this is an API error
message (not user-generated content), so content inspection is safe.
- **ResultMessage handler**: check `result` for prompt-too-long patterns
regardless of `subtype`, covering the SDK auto-compact path where
`subtype="success"` with `result="Prompt is too long"`.

## How

Two targeted one-line condition expansions in `_run_stream_attempt`,
plus two new integration tests in `retry_scenarios_test.py` that
reproduce each broken path and verify retry fires correctly.

## Changes

- `backend/copilot/sdk/service.py`: fix AssistantMessage content check +
ResultMessage subtype-independent check
- `backend/copilot/sdk/retry_scenarios_test.py`: add 2 integration tests
for the new scenarios

## Checklist

- [x] Tests added for both new scenarios (45 total, all pass)
- [x] Formatted (`poetry run format`)
- [x] No false-positive risk: AssistantMessage check gated behind
`sdk_error is not None`
- [x] Root cause verified from production pod logs
2026-04-01 22:32:09 +00:00
Zamil Majdy
4ac0ba570a fix(backend): fix copilot credential loading across event loops (#12628)
## Why

CoPilot autopilot sessions are inconsistently failing to load user
credentials (specifically GitHub OAuth). Some sessions proceed normally,
some show "provide credentials" prompts despite the user having valid
creds, and some are completely blocked.

Production logs confirmed the root cause: `RuntimeError: Task got Future
<Future pending> attached to a different loop` in the credential refresh
path, cascading into null-cache poisoning that blocks credential lookups
for 60 seconds.

## What

Three interrelated bugs in the credential system:

1. **`refresh_if_needed` always acquired Redis locks even with
`lock=False`** — The `lock` parameter only controlled the inner
credential lock, but the outer "refresh" scope lock was always acquired.
The copilot executor uses multiple worker threads with separate event
loops; the `asyncio.Lock` inside `AsyncRedisKeyedMutex` was bound to one
loop and failed on others.

2. **Stale event loop in `locks()` singleton** — Both
`IntegrationCredentialsManager` and `IntegrationCredentialsStore` cached
their `AsyncRedisKeyedMutex` without tracking which event loop created
it. When a different worker thread (with a different loop) reused the
singleton, it got the "Future attached to different loop" error.

3. **Null-cache poisoning on refresh failure** — When OAuth refresh
failed (due to the event loop error), the code fell through to cache "no
credentials found" for 60 seconds via `_null_cache`. This blocked ALL
subsequent credential lookups for that user+provider, even though the
credentials existed and could refresh fine on retry.

## How

- Split `refresh_if_needed` into `_refresh_locked` / `_refresh_unlocked`
so `lock=False` truly skips ALL Redis locking (safe for copilot's
best-effort background injection)
- Added event loop tracking to `locks()` in both
`IntegrationCredentialsManager` and `IntegrationCredentialsStore` —
recreates the mutex when the running loop changes
- Only populate `_null_cache` when the user genuinely has no
credentials; skip caching when OAuth refresh failed transiently
- Updated existing test to verify null-cache is not poisoned on refresh
failure

## Test plan

- [x] All 14 existing `integration_creds_test.py` tests pass
- [x] Updated
`test_oauth2_refresh_failure_returns_none_without_null_cache` verifies
null-cache is not populated on refresh failure
- [x] Format, lint, and typecheck pass
- [ ] Deploy to staging and verify copilot sessions consistently load
GitHub credentials
2026-04-02 00:11:38 +07:00
Zamil Majdy
d61a2c6cd0 Revert "fix(backend/copilot): detect prompt-too-long in AssistantMessage content and ResultMessage success subtype"
This reverts commit 1c301b4b61.
2026-04-01 18:59:38 +02:00
Zamil Majdy
1c301b4b61 fix(backend/copilot): detect prompt-too-long in AssistantMessage content and ResultMessage success subtype
The SDK returns AssistantMessage(error="invalid_request", content=[TextBlock("Prompt is too long")])
followed by ResultMessage(subtype="success", result="Prompt is too long") when the transcript is
rejected after internal auto-compaction. Both paths bypassed the retry mechanism:

- AssistantMessage handler only checked error_text ("invalid_request"), not the content which
  holds the actual error description. The content was then streamed as text, setting events_yielded=1,
  which blocked retry even when ResultMessage fired.
- ResultMessage handler only triggered prompt-too-long detection for subtype="error", not
  subtype="success". The stream "completed normally", stored the synthetic error entry in the
  transcript, and uploaded it — causing the transcript to grow unboundedly on each failed turn.

Fixes:
1. AssistantMessage handler: when sdk_error is set (confirmed error message), also check content
   text. sdk_error being set guarantees this is an API error, not user-generated content, so
   content inspection is safe.
2. ResultMessage handler: check result for prompt-too-long regardless of subtype, covering the
   case where the SDK auto-compacts internally but the result is still too long.

Adds integration tests for both new scenarios.
2026-04-01 18:28:46 +02:00
30 changed files with 1023 additions and 309 deletions

View File

@@ -2,6 +2,8 @@ import copy
from datetime import date, time
from typing import Any, Optional
from pydantic import AliasChoices, Field
from backend.blocks._base import (
Block,
BlockCategory,
@@ -467,7 +469,8 @@ class AgentFileInputBlock(AgentInputBlock):
class AgentDropdownInputBlock(AgentInputBlock):
"""
A specialized text input block that relies on placeholder_values to present a dropdown.
A specialized text input block that presents a dropdown selector
restricted to a fixed set of values.
"""
class Input(AgentInputBlock.Input):
@@ -477,16 +480,23 @@ class AgentDropdownInputBlock(AgentInputBlock):
advanced=False,
title="Default Value",
)
placeholder_values: list = SchemaField(
description="Possible values for the dropdown.",
# Use Field() directly (not SchemaField) to pass validation_alias,
# which handles backward compat for legacy "placeholder_values" across
# all construction paths (model_construct, __init__, model_validate).
options: list = Field(
default_factory=list,
advanced=False,
title="Dropdown Options",
description=(
"If provided, renders the input as a dropdown selector "
"restricted to these values. Leave empty for free-text input."
),
validation_alias=AliasChoices("options", "placeholder_values"),
json_schema_extra={"advanced": False, "secret": False},
)
def generate_schema(self):
schema = super().generate_schema()
if possible_values := self.placeholder_values:
if possible_values := self.options:
schema["enum"] = possible_values
return schema
@@ -504,13 +514,13 @@ class AgentDropdownInputBlock(AgentInputBlock):
{
"value": "Option A",
"name": "dropdown_1",
"placeholder_values": ["Option A", "Option B", "Option C"],
"options": ["Option A", "Option B", "Option C"],
"description": "Dropdown example 1",
},
{
"value": "Option C",
"name": "dropdown_2",
"placeholder_values": ["Option A", "Option B", "Option C"],
"options": ["Option A", "Option B", "Option C"],
"description": "Dropdown example 2",
},
],

View File

@@ -300,13 +300,27 @@ def test_agent_input_block_ignores_legacy_placeholder_values():
def test_dropdown_input_block_produces_enum():
"""Verify AgentDropdownInputBlock.Input.generate_schema() produces enum."""
options = ["Option A", "Option B"]
"""Verify AgentDropdownInputBlock.Input.generate_schema() produces enum
using the canonical 'options' field name."""
opts = ["Option A", "Option B"]
instance = AgentDropdownInputBlock.Input.model_construct(
name="choice", value=None, placeholder_values=options
name="choice", value=None, options=opts
)
schema = instance.generate_schema()
assert schema.get("enum") == options
assert schema.get("enum") == opts
def test_dropdown_input_block_legacy_placeholder_values_produces_enum():
"""Verify backward compat: passing legacy 'placeholder_values' to
AgentDropdownInputBlock still produces enum via model_construct remap."""
opts = ["Option A", "Option B"]
instance = AgentDropdownInputBlock.Input.model_construct(
name="choice", value=None, placeholder_values=opts
)
schema = instance.generate_schema()
assert (
schema.get("enum") == opts
), "Legacy placeholder_values should be remapped to options"
def test_generate_schema_integration_legacy_placeholder_values():
@@ -329,11 +343,11 @@ def test_generate_schema_integration_legacy_placeholder_values():
def test_generate_schema_integration_dropdown_produces_enum():
"""Test the full Graph._generate_schema path with AgentDropdownInputBlock
— verifies enum IS produced for dropdown blocks."""
— verifies enum IS produced for dropdown blocks using canonical field name."""
dropdown_input_default = {
"name": "color",
"value": None,
"placeholder_values": ["Red", "Green", "Blue"],
"options": ["Red", "Green", "Blue"],
}
result = BaseGraph._generate_schema(
(AgentDropdownInputBlock.Input, dropdown_input_default),
@@ -344,3 +358,36 @@ def test_generate_schema_integration_dropdown_produces_enum():
"Green",
"Blue",
], "Graph schema should contain enum from AgentDropdownInputBlock"
def test_generate_schema_integration_dropdown_legacy_placeholder_values():
"""Test the full Graph._generate_schema path with AgentDropdownInputBlock
using legacy 'placeholder_values' — verifies backward compat produces enum."""
legacy_dropdown_input_default = {
"name": "color",
"value": None,
"placeholder_values": ["Red", "Green", "Blue"],
}
result = BaseGraph._generate_schema(
(AgentDropdownInputBlock.Input, legacy_dropdown_input_default),
)
color_props = result["properties"]["color"]
assert color_props.get("enum") == [
"Red",
"Green",
"Blue",
], "Legacy placeholder_values should still produce enum via model_construct remap"
def test_dropdown_input_block_init_legacy_placeholder_values():
"""Verify backward compat: constructing AgentDropdownInputBlock.Input via
model_validate with legacy 'placeholder_values' correctly maps to 'options'."""
opts = ["Option A", "Option B"]
instance = AgentDropdownInputBlock.Input.model_validate(
{"name": "choice", "value": None, "placeholder_values": opts}
)
assert (
instance.options == opts
), "Legacy placeholder_values should be remapped to options via model_validate"
schema = instance.generate_schema()
assert schema.get("enum") == opts

View File

@@ -123,6 +123,7 @@ async def get_provider_token(user_id: str, provider: str) -> str | None:
[c for c in creds_list if c.type == "oauth2"],
key=lambda c: 0 if "repo" in (cast(OAuth2Credentials, c).scopes or []) else 1,
)
refresh_failed = False
for creds in oauth2_creds:
if creds.type == "oauth2":
try:
@@ -141,6 +142,7 @@ async def get_provider_token(user_id: str, provider: str) -> str | None:
# Do NOT fall back to the stale token — it is likely expired
# or revoked. Returning None forces the caller to re-auth,
# preventing the LLM from receiving a non-functional token.
refresh_failed = True
continue
_token_cache[cache_key] = token
return token
@@ -152,8 +154,12 @@ async def get_provider_token(user_id: str, provider: str) -> str | None:
_token_cache[cache_key] = token
return token
# No credentials found — cache to avoid repeated DB hits.
_null_cache[cache_key] = True
# Only cache "not connected" when the user truly has no credentials for this
# provider. If we had OAuth credentials but refresh failed (e.g. transient
# network error, event-loop mismatch), do NOT cache the negative result —
# the next call should retry the refresh instead of being blocked for 60 s.
if not refresh_failed:
_null_cache[cache_key] = True
return None

View File

@@ -129,8 +129,15 @@ class TestGetProviderToken:
assert result == "oauth-tok"
@pytest.mark.asyncio(loop_scope="session")
async def test_oauth2_refresh_failure_returns_none(self):
"""On refresh failure, return None instead of caching a stale token."""
async def test_oauth2_refresh_failure_returns_none_without_null_cache(self):
"""On refresh failure, return None but do NOT cache in null_cache.
The user has credentials — they just couldn't be refreshed right now
(e.g. transient network error or event-loop mismatch in the copilot
executor). Caching a negative result would block all credential
lookups for 60 s even though the creds exist and may refresh fine
on the next attempt.
"""
oauth_creds = _make_oauth2_creds("stale-oauth-tok")
mock_manager = MagicMock()
mock_manager.store.get_creds_by_provider = AsyncMock(return_value=[oauth_creds])
@@ -141,6 +148,8 @@ class TestGetProviderToken:
# Stale tokens must NOT be returned — forces re-auth.
assert result is None
# Must NOT cache negative result when refresh failed — next call retries.
assert (_USER, _PROVIDER) not in _null_cache
@pytest.mark.asyncio(loop_scope="session")
async def test_no_credentials_caches_null_entry(self):
@@ -176,6 +185,96 @@ class TestGetProviderToken:
assert _NULL_CACHE_TTL < _TOKEN_CACHE_TTL
class TestThreadSafetyLocks:
"""Bug reproduction: shared AsyncRedisKeyedMutex across threads caused
'Future attached to a different loop' when copilot workers accessed
credentials from different event loops."""
@pytest.mark.asyncio(loop_scope="session")
async def test_store_locks_returns_per_thread_instance(self):
"""IntegrationCredentialsStore.locks() must return different instances
for different threads (via @thread_cached)."""
import asyncio
import concurrent.futures
from backend.integrations.credentials_store import IntegrationCredentialsStore
store = IntegrationCredentialsStore()
async def get_locks_id():
mock_redis = AsyncMock()
with patch(
"backend.integrations.credentials_store.get_redis_async",
return_value=mock_redis,
):
locks = await store.locks()
return id(locks)
# Get locks from main thread
main_id = await get_locks_id()
# Get locks from a worker thread
def run_in_thread():
loop = asyncio.new_event_loop()
try:
return loop.run_until_complete(get_locks_id())
finally:
loop.close()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
worker_id = await asyncio.get_event_loop().run_in_executor(
pool, run_in_thread
)
assert main_id != worker_id, (
"Store.locks() returned the same instance across threads. "
"This would cause 'Future attached to a different loop' errors."
)
@pytest.mark.asyncio(loop_scope="session")
async def test_manager_delegates_to_store_locks(self):
"""IntegrationCredentialsManager.locks() should delegate to store."""
from backend.integrations.creds_manager import IntegrationCredentialsManager
manager = IntegrationCredentialsManager()
mock_redis = AsyncMock()
with patch(
"backend.integrations.credentials_store.get_redis_async",
return_value=mock_redis,
):
locks = await manager.locks()
# Should have gotten it from the store
assert locks is not None
class TestRefreshUnlockedPath:
"""Bug reproduction: copilot worker threads need lock-free refresh because
Redis-backed asyncio.Lock created on one event loop can't be used on another."""
@pytest.mark.asyncio(loop_scope="session")
async def test_refresh_if_needed_lock_false_skips_redis(self):
"""refresh_if_needed(lock=False) must not touch Redis locks at all."""
from backend.integrations.creds_manager import IntegrationCredentialsManager
manager = IntegrationCredentialsManager()
creds = _make_oauth2_creds()
mock_handler = MagicMock()
mock_handler.needs_refresh = MagicMock(return_value=False)
with patch(
"backend.integrations.creds_manager._get_provider_oauth_handler",
new_callable=AsyncMock,
return_value=mock_handler,
):
result = await manager.refresh_if_needed(_USER, creds, lock=False)
# Should return credentials without touching locks
assert result.id == creds.id
class TestGetIntegrationEnvVars:
@pytest.mark.asyncio(loop_scope="session")
async def test_injects_all_env_vars_for_provider(self):

View File

@@ -66,6 +66,7 @@ from pydantic import BaseModel, PrivateAttr
ToolName = Literal[
# Platform tools (must match keys in TOOL_REGISTRY)
"add_understanding",
"ask_question",
"bash_exec",
"browser_act",
"browser_navigate",

View File

@@ -6,16 +6,23 @@ from pathlib import Path
class TestAgentGenerationGuideContainsClarifySection:
"""The agent generation guide must include the clarification section."""
def test_guide_includes_clarify_before_building(self):
def test_guide_includes_clarify_section(self):
guide_path = Path(__file__).parent / "sdk" / "agent_generation_guide.md"
content = guide_path.read_text(encoding="utf-8")
assert "Clarifying Before Building" in content
assert "Before or During Building" in content
def test_guide_mentions_find_block_for_clarification(self):
guide_path = Path(__file__).parent / "sdk" / "agent_generation_guide.md"
content = guide_path.read_text(encoding="utf-8")
# find_block must appear in the clarification section (before the workflow)
clarify_section = content.split("Clarifying Before Building")[1].split(
clarify_section = content.split("Before or During Building")[1].split(
"### Workflow"
)[0]
assert "find_block" in clarify_section
def test_guide_mentions_ask_question_tool(self):
guide_path = Path(__file__).parent / "sdk" / "agent_generation_guide.md"
content = guide_path.read_text(encoding="utf-8")
clarify_section = content.split("Before or During Building")[1].split(
"### Workflow"
)[0]
assert "ask_question" in clarify_section

View File

@@ -3,17 +3,25 @@
You can create, edit, and customize agents directly. You ARE the brain —
generate the agent JSON yourself using block schemas, then validate and save.
### Clarifying Before Building
### Clarifying Before or During Building
Before starting the workflow below, check whether the user's goal is
**ambiguous** — missing the output format, delivery channel, data source,
or trigger. If so:
1. Call `find_block` with a query targeting the ambiguous dimension to
discover what the platform actually supports.
2. Ask the user **one concrete question** grounded in the discovered
Use `ask_question` whenever the user's intent is ambiguous — whether
that's before starting or midway through the workflow. Common moments:
- **Before building**: output format, delivery channel, data source, or
trigger is unspecified.
- **During block discovery**: multiple blocks could fit and the user
should choose.
- **During JSON generation**: a wiring decision depends on user
preference.
Steps:
1. Call `find_block` (or another discovery tool) to learn what the
platform actually supports for the ambiguous dimension.
2. Call `ask_question` with a concrete question listing the discovered
options (e.g. "The platform supports Gmail, Slack, and Google Docs —
which should the agent use for delivery?").
3. **Wait for the user's answer** before proceeding.
3. **Wait for the user's answer** before continuing.
**Skip this** when the goal already specifies all dimensions (e.g.
"scrape prices from Amazon and email me daily").
@@ -89,8 +97,8 @@ These define the agent's interface — what it accepts and what it produces.
**AgentDropdownInputBlock** (ID: `655d6fdf-a334-421c-b733-520549c07cd1`):
- Specialized input block that presents a dropdown/select to the user
- Required `input_default` fields: `name` (str), `placeholder_values` (list of options, must have at least one)
- Optional: `title`, `description`, `value` (default selection)
- Required `input_default` fields: `name` (str)
- Optional: `options` (list of dropdown values; when omitted/empty, input behaves as free-text), `title`, `description`, `value` (default selection)
- Output: `result` — the user-selected value at runtime
- Use this instead of AgentInputBlock when the user should pick from a fixed set of options

View File

@@ -29,6 +29,7 @@ from backend.copilot.response_model import (
StreamToolOutputAvailable,
)
from .compaction import compaction_events
from .response_adapter import SDKResponseAdapter
from .tool_adapter import MCP_TOOL_PREFIX
from .tool_adapter import _pending_tool_outputs as _pto
@@ -689,3 +690,102 @@ def test_already_resolved_tool_skipped_in_user_message():
assert (
len(output_events) == 0
), "Already-resolved tool should not emit duplicate output"
# -- _end_text_if_open before compaction -------------------------------------
def test_end_text_if_open_emits_text_end_before_finish_step():
"""StreamTextEnd must be emitted before StreamFinishStep during compaction.
When ``emit_end_if_ready`` fires compaction events while a text block is
still open, ``_end_text_if_open`` must close it first. If StreamFinishStep
arrives before StreamTextEnd, the Vercel AI SDK clears ``activeTextParts``
and raises "Received text-end for missing text part".
"""
adapter = _adapter()
# Open a text block by processing an AssistantMessage with text
msg = AssistantMessage(content=[TextBlock(text="partial response")], model="test")
adapter.convert_message(msg)
assert adapter.has_started_text
assert not adapter.has_ended_text
# Simulate what service.py does before yielding compaction events
pre_close: list[StreamBaseResponse] = []
adapter._end_text_if_open(pre_close)
combined = pre_close + list(compaction_events("Compacted transcript"))
text_end_idx = next(
(i for i, e in enumerate(combined) if isinstance(e, StreamTextEnd)), None
)
finish_step_idx = next(
(i for i, e in enumerate(combined) if isinstance(e, StreamFinishStep)), None
)
assert text_end_idx is not None, "StreamTextEnd must be present"
assert finish_step_idx is not None, "StreamFinishStep must be present"
assert text_end_idx < finish_step_idx, (
f"StreamTextEnd (idx={text_end_idx}) must precede "
f"StreamFinishStep (idx={finish_step_idx}) — otherwise the Vercel AI SDK "
"clears activeTextParts before text-end arrives"
)
def test_step_open_must_reset_after_compaction_finish_step():
"""Adapter step_open must be reset when compaction emits StreamFinishStep.
Compaction events bypass the adapter, so service.py must explicitly clear
step_open after yielding a StreamFinishStep from compaction. Without this,
the next AssistantMessage skips StreamStartStep because the adapter still
thinks a step is open.
"""
adapter = _adapter()
# Open a step + text block via an AssistantMessage
msg = AssistantMessage(content=[TextBlock(text="thinking...")], model="test")
adapter.convert_message(msg)
assert adapter.step_open is True
# Simulate what service.py does: close text, then check compaction events
pre_close: list[StreamBaseResponse] = []
adapter._end_text_if_open(pre_close)
events = list(compaction_events("Compacted transcript"))
if any(isinstance(ev, StreamFinishStep) for ev in events):
adapter.step_open = False
assert (
adapter.step_open is False
), "step_open must be False after compaction emits StreamFinishStep"
# Next AssistantMessage must open a new step
msg2 = AssistantMessage(content=[TextBlock(text="continued")], model="test")
results = adapter.convert_message(msg2)
assert any(
isinstance(r, StreamStartStep) for r in results
), "A new StreamStartStep must be emitted after compaction closed the step"
def test_end_text_if_open_no_op_when_no_text_open():
"""_end_text_if_open emits nothing when no text block is open."""
adapter = _adapter()
results: list[StreamBaseResponse] = []
adapter._end_text_if_open(results)
assert results == []
def test_end_text_if_open_no_op_after_text_already_ended():
"""_end_text_if_open emits nothing when the text block is already closed."""
adapter = _adapter()
msg = AssistantMessage(content=[TextBlock(text="hello")], model="test")
adapter.convert_message(msg)
# Close it once
first: list[StreamBaseResponse] = []
adapter._end_text_if_open(first)
assert len(first) == 1
assert isinstance(first[0], StreamTextEnd)
# Second call must be a no-op
second: list[StreamBaseResponse] = []
adapter._end_text_if_open(second)
assert second == []

View File

@@ -1487,3 +1487,188 @@ class TestStreamChatCompletionRetryIntegration:
errors = [e for e in events if isinstance(e, StreamError)]
assert not errors, f"Unexpected StreamError: {errors}"
assert any(isinstance(e, StreamStart) for e in events)
@pytest.mark.asyncio
async def test_result_message_success_subtype_prompt_too_long_triggers_compaction(
self,
):
"""CLI returns ResultMessage(subtype="success") with result="Prompt is too long".
The SDK internally compacts but the transcript is still too long. It
returns subtype="success" (process completed) with result="Prompt is
too long" (the actual rejection message). The retry loop must detect
this as a context-length error and trigger compaction — the subtype
"success" must not fool it into treating this as a real response.
"""
import contextlib
from claude_agent_sdk import ResultMessage
from backend.copilot.response_model import StreamError, StreamStart
from backend.copilot.sdk.service import stream_chat_completion_sdk
session = self._make_session()
success_result = self._make_result_message()
attempt_count = [0]
error_result = ResultMessage(
subtype="success",
result="Prompt is too long",
duration_ms=100,
duration_api_ms=0,
is_error=False,
num_turns=1,
session_id="test-session-id",
)
def _client_factory(*args, **kwargs):
attempt_count[0] += 1
async def _receive_error():
yield error_result
async def _receive_success():
yield success_result
client = MagicMock()
client._transport = MagicMock()
client._transport.write = AsyncMock()
client.query = AsyncMock()
if attempt_count[0] == 1:
client.receive_response = _receive_error
else:
client.receive_response = _receive_success
cm = AsyncMock()
cm.__aenter__.return_value = client
cm.__aexit__.return_value = None
return cm
original_transcript = _build_transcript(
[("user", "prior question"), ("assistant", "prior answer")]
)
compacted_transcript = _build_transcript(
[("user", "[summary]"), ("assistant", "summary reply")]
)
patches = _make_sdk_patches(
session,
original_transcript=original_transcript,
compacted_transcript=compacted_transcript,
client_side_effect=_client_factory,
)
events = []
with contextlib.ExitStack() as stack:
for target, kwargs in patches:
stack.enter_context(patch(target, **kwargs))
async for event in stream_chat_completion_sdk(
session_id="test-session-id",
message="hello",
is_user_message=True,
user_id="test-user",
session=session,
):
events.append(event)
assert attempt_count[0] == 2, (
f"Expected 2 SDK attempts (subtype='success' with 'Prompt is too long' "
f"result should trigger compaction retry), got {attempt_count[0]}"
)
errors = [e for e in events if isinstance(e, StreamError)]
assert not errors, f"Unexpected StreamError: {errors}"
assert any(isinstance(e, StreamStart) for e in events)
@pytest.mark.asyncio
async def test_assistant_message_error_content_prompt_too_long_triggers_compaction(
self,
):
"""AssistantMessage.error="invalid_request" with content "Prompt is too long".
The SDK returns error type "invalid_request" but puts the actual
rejection message ("Prompt is too long") in the content blocks.
The retry loop must detect this via content inspection (sdk_error
being set confirms it's an error message, not user content).
"""
import contextlib
from claude_agent_sdk import AssistantMessage, ResultMessage, TextBlock
from backend.copilot.response_model import StreamError, StreamStart
from backend.copilot.sdk.service import stream_chat_completion_sdk
session = self._make_session()
success_result = self._make_result_message()
attempt_count = [0]
def _client_factory(*args, **kwargs):
attempt_count[0] += 1
async def _receive_error():
# SDK returns invalid_request with "Prompt is too long" in content.
# ResultMessage.result is a non-PTL value ("done") to isolate
# the AssistantMessage content detection path exclusively.
yield AssistantMessage(
content=[TextBlock(text="Prompt is too long")],
model="<synthetic>",
error="invalid_request",
)
yield ResultMessage(
subtype="success",
result="done",
duration_ms=100,
duration_api_ms=0,
is_error=False,
num_turns=1,
session_id="test-session-id",
)
async def _receive_success():
yield success_result
client = MagicMock()
client._transport = MagicMock()
client._transport.write = AsyncMock()
client.query = AsyncMock()
if attempt_count[0] == 1:
client.receive_response = _receive_error
else:
client.receive_response = _receive_success
cm = AsyncMock()
cm.__aenter__.return_value = client
cm.__aexit__.return_value = None
return cm
original_transcript = _build_transcript(
[("user", "prior question"), ("assistant", "prior answer")]
)
compacted_transcript = _build_transcript(
[("user", "[summary]"), ("assistant", "summary reply")]
)
patches = _make_sdk_patches(
session,
original_transcript=original_transcript,
compacted_transcript=compacted_transcript,
client_side_effect=_client_factory,
)
events = []
with contextlib.ExitStack() as stack:
for target, kwargs in patches:
stack.enter_context(patch(target, **kwargs))
async for event in stream_chat_completion_sdk(
session_id="test-session-id",
message="hello",
is_user_message=True,
user_id="test-user",
session=session,
):
events.append(event)
assert attempt_count[0] == 2, (
f"Expected 2 SDK attempts (AssistantMessage error content 'Prompt is "
f"too long' should trigger compaction retry), got {attempt_count[0]}"
)
errors = [e for e in events if isinstance(e, StreamError)]
assert not errors, f"Unexpected StreamError: {errors}"
assert any(isinstance(e, StreamStart) for e in events)

View File

@@ -1310,10 +1310,16 @@ async def _run_stream_attempt(
# AssistantMessage.error (not as a Python exception).
# Re-raise so the outer retry loop can compact the
# transcript and retry with reduced context.
# Only check error_text (the error field), not the
# content preview — content may contain arbitrary text
# that false-positives the pattern match.
if _is_prompt_too_long(Exception(error_text)):
# Check both error_text and error_preview: sdk_error
# being set confirms this is an error message (not user
# content), so checking content is safe. The actual
# error description (e.g. "Prompt is too long") may be
# in the content, not the error type field
# (e.g. error="invalid_request", content="Prompt is
# too long").
if _is_prompt_too_long(Exception(error_text)) or _is_prompt_too_long(
Exception(error_preview)
):
logger.warning(
"%s Prompt-too-long detected via AssistantMessage "
"error — raising for retry",
@@ -1414,13 +1420,16 @@ async def _run_stream_attempt(
ctx.log_prefix,
sdk_msg.result or "(no error message provided)",
)
# If the CLI itself rejected the prompt as too long
# (pre-API check, duration_api_ms=0), re-raise as an
# exception so the retry loop can trigger compaction.
# Without this, the ResultMessage is silently consumed
# and the retry/compaction mechanism is never invoked.
if _is_prompt_too_long(RuntimeError(sdk_msg.result or "")):
raise RuntimeError("Prompt is too long")
# Check for prompt-too-long regardless of subtype — the
# SDK may return subtype="success" with result="Prompt is
# too long" when the CLI rejects the prompt before calling
# the API (cost_usd=0, no tokens consumed). If we only
# check the "error" subtype path, the stream appears to
# complete normally, the synthetic error text is stored
# in the transcript, and the session grows without bound.
if _is_prompt_too_long(RuntimeError(sdk_msg.result or "")):
raise RuntimeError("Prompt is too long")
# Capture token usage from ResultMessage.
# Anthropic reports cached tokens separately:
@@ -1453,6 +1462,23 @@ async def _run_stream_attempt(
# Emit compaction end if SDK finished compacting.
# Sync TranscriptBuilder with the CLI's active context.
compact_result = await ctx.compaction.emit_end_if_ready(ctx.session)
if compact_result.events:
# Compaction events end with StreamFinishStep, which maps to
# Vercel AI SDK's "finish-step" — that clears activeTextParts.
# Close any open text block BEFORE the compaction events so
# the text-end arrives before finish-step, preventing
# "text-end for missing text part" errors on the frontend.
pre_close: list[StreamBaseResponse] = []
state.adapter._end_text_if_open(pre_close)
# Compaction events bypass the adapter, so sync step state
# when a StreamFinishStep is present — otherwise the adapter
# will skip StreamStartStep on the next AssistantMessage.
if any(
isinstance(ev, StreamFinishStep) for ev in compact_result.events
):
state.adapter.step_open = False
for r in pre_close:
yield r
for ev in compact_result.events:
yield ev
entries_replaced = False

View File

@@ -10,6 +10,7 @@ from backend.copilot.tracking import track_tool_called
from .add_understanding import AddUnderstandingTool
from .agent_browser import BrowserActTool, BrowserNavigateTool, BrowserScreenshotTool
from .agent_output import AgentOutputTool
from .ask_question import AskQuestionTool
from .base import BaseTool
from .bash_exec import BashExecTool
from .connect_integration import ConnectIntegrationTool
@@ -55,6 +56,7 @@ logger = logging.getLogger(__name__)
# Single source of truth for all tools
TOOL_REGISTRY: dict[str, BaseTool] = {
"add_understanding": AddUnderstandingTool(),
"ask_question": AskQuestionTool(),
"create_agent": CreateAgentTool(),
"customize_agent": CustomizeAgentTool(),
"edit_agent": EditAgentTool(),

View File

@@ -0,0 +1,93 @@
"""AskQuestionTool - Ask the user a clarifying question before proceeding."""
from typing import Any
from backend.copilot.model import ChatSession
from .base import BaseTool
from .models import ClarificationNeededResponse, ClarifyingQuestion, ToolResponseBase
class AskQuestionTool(BaseTool):
"""Ask the user a clarifying question and wait for their answer.
Use this tool when the user's request is ambiguous and you need more
information before proceeding. Call find_block or other discovery tools
first to ground your question in real platform options, then call this
tool with a concrete question listing those options.
"""
@property
def name(self) -> str:
return "ask_question"
@property
def description(self) -> str:
return (
"Ask the user a clarifying question. Use when the request is "
"ambiguous and you need to confirm intent, choose between options, "
"or gather missing details before proceeding."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"question": {
"type": "string",
"description": (
"The concrete question to ask the user. Should list "
"real options when applicable."
),
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": (
"Options for the user to choose from "
"(e.g. ['Email', 'Slack', 'Google Docs'])."
),
},
"keyword": {
"type": "string",
"description": "Short label identifying what the question is about.",
},
},
"required": ["question"],
}
@property
def requires_auth(self) -> bool:
return False
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs: Any,
) -> ToolResponseBase:
del user_id # unused; required by BaseTool contract
question_raw = kwargs.get("question")
if not isinstance(question_raw, str) or not question_raw.strip():
raise ValueError("ask_question requires a non-empty 'question' string")
question = question_raw.strip()
raw_options = kwargs.get("options", [])
if not isinstance(raw_options, list):
raw_options = []
options: list[str] = [str(o) for o in raw_options if o]
raw_keyword = kwargs.get("keyword", "")
keyword: str = str(raw_keyword) if raw_keyword else ""
session_id = session.session_id if session else None
example = ", ".join(options) if options else None
clarifying_question = ClarifyingQuestion(
question=question,
keyword=keyword,
example=example,
)
return ClarificationNeededResponse(
message=question,
session_id=session_id,
questions=[clarifying_question],
)

View File

@@ -0,0 +1,99 @@
"""Tests for AskQuestionTool."""
import pytest
from backend.copilot.model import ChatSession
from backend.copilot.tools.ask_question import AskQuestionTool
from backend.copilot.tools.models import ClarificationNeededResponse
@pytest.fixture()
def tool() -> AskQuestionTool:
return AskQuestionTool()
@pytest.fixture()
def session() -> ChatSession:
return ChatSession.new(user_id="test-user", dry_run=False)
@pytest.mark.asyncio
async def test_execute_with_options(tool: AskQuestionTool, session: ChatSession):
result = await tool._execute(
user_id=None,
session=session,
question="Which channel?",
options=["Email", "Slack", "Google Docs"],
keyword="channel",
)
assert isinstance(result, ClarificationNeededResponse)
assert result.message == "Which channel?"
assert result.session_id == session.session_id
assert len(result.questions) == 1
q = result.questions[0]
assert q.question == "Which channel?"
assert q.keyword == "channel"
assert q.example == "Email, Slack, Google Docs"
@pytest.mark.asyncio
async def test_execute_without_options(tool: AskQuestionTool, session: ChatSession):
result = await tool._execute(
user_id=None,
session=session,
question="What format do you want?",
)
assert isinstance(result, ClarificationNeededResponse)
assert result.message == "What format do you want?"
assert len(result.questions) == 1
q = result.questions[0]
assert q.question == "What format do you want?"
assert q.keyword == ""
assert q.example is None
@pytest.mark.asyncio
async def test_execute_with_keyword_only(tool: AskQuestionTool, session: ChatSession):
result = await tool._execute(
user_id=None,
session=session,
question="How often should it run?",
keyword="trigger",
)
assert isinstance(result, ClarificationNeededResponse)
q = result.questions[0]
assert q.keyword == "trigger"
assert q.example is None
@pytest.mark.asyncio
async def test_execute_rejects_empty_question(
tool: AskQuestionTool, session: ChatSession
):
with pytest.raises(ValueError, match="non-empty"):
await tool._execute(user_id=None, session=session, question="")
with pytest.raises(ValueError, match="non-empty"):
await tool._execute(user_id=None, session=session, question=" ")
@pytest.mark.asyncio
async def test_execute_coerces_invalid_options(
tool: AskQuestionTool, session: ChatSession
):
"""LLM may send options as a string instead of a list; should not crash."""
result = await tool._execute(
user_id=None,
session=session,
question="Pick one",
options="not-a-list", # type: ignore[arg-type]
)
assert isinstance(result, ClarificationNeededResponse)
q = result.questions[0]
assert q.example is None

View File

@@ -19,6 +19,7 @@ from backend.data.model import (
UserPasswordCredentials,
)
from backend.data.redis_client import get_redis_async
from backend.util.cache import thread_cached
from backend.util.settings import Settings
settings = Settings()
@@ -304,15 +305,12 @@ def is_system_provider(provider: str) -> bool:
class IntegrationCredentialsStore:
def __init__(self):
self._locks = None
@thread_cached
async def locks(self) -> AsyncRedisKeyedMutex:
if self._locks:
return self._locks
self._locks = AsyncRedisKeyedMutex(await get_redis_async())
return self._locks
# Per-thread: copilot executor runs worker threads with separate event
# loops; AsyncRedisKeyedMutex's internal asyncio.Lock is bound to the
# loop it was created on.
return AsyncRedisKeyedMutex(await get_redis_async())
@property
def db_manager(self):

View File

@@ -8,7 +8,6 @@ from autogpt_libs.utils.synchronize import AsyncRedisKeyedMutex
from redis.asyncio.lock import Lock as AsyncRedisLock
from backend.data.model import Credentials, OAuth2Credentials
from backend.data.redis_client import get_redis_async
from backend.integrations.credentials_store import (
IntegrationCredentialsStore,
provider_matches,
@@ -106,14 +105,13 @@ class IntegrationCredentialsManager:
def __init__(self):
self.store = IntegrationCredentialsStore()
self._locks = None
async def locks(self) -> AsyncRedisKeyedMutex:
if self._locks:
return self._locks
self._locks = AsyncRedisKeyedMutex(await get_redis_async())
return self._locks
# Delegate to store's @thread_cached locks. Manager uses these for
# fine-grained per-credential locking (refresh, acquire); the store
# uses its own for coarse per-user integrations locking. Same mutex
# type, different key spaces — no collision.
return await self.store.locks()
async def create(self, user_id: str, credentials: Credentials) -> None:
result = await self.store.add_creds(user_id, credentials)
@@ -188,35 +186,74 @@ class IntegrationCredentialsManager:
async def refresh_if_needed(
self, user_id: str, credentials: OAuth2Credentials, lock: bool = True
) -> OAuth2Credentials:
# When lock=False, skip ALL Redis locking (both the outer "refresh" scope
# lock and the inner credential lock). This is used by the copilot's
# integration_creds module which runs across multiple threads with separate
# event loops; acquiring a Redis lock whose asyncio.Lock() was created on
# a different loop raises "Future attached to a different loop".
if lock:
return await self._refresh_locked(user_id, credentials)
return await self._refresh_unlocked(user_id, credentials)
async def _get_oauth_handler(
self, credentials: OAuth2Credentials
) -> "BaseOAuthHandler":
"""Resolve the appropriate OAuth handler for the given credentials."""
if provider_matches(credentials.provider, ProviderName.MCP.value):
return create_mcp_oauth_handler(credentials)
return await _get_provider_oauth_handler(credentials.provider)
async def _refresh_locked(
self, user_id: str, credentials: OAuth2Credentials
) -> OAuth2Credentials:
async with self._locked(user_id, credentials.id, "refresh"):
if provider_matches(credentials.provider, ProviderName.MCP.value):
oauth_handler = create_mcp_oauth_handler(credentials)
else:
oauth_handler = await _get_provider_oauth_handler(credentials.provider)
oauth_handler = await self._get_oauth_handler(credentials)
if oauth_handler.needs_refresh(credentials):
logger.debug(
f"Refreshing '{credentials.provider}' credentials #{credentials.id}"
"Refreshing '%s' credentials #%s",
credentials.provider,
credentials.id,
)
_lock = None
if lock:
# Wait until the credentials are no longer in use anywhere
_lock = await self._acquire_lock(user_id, credentials.id)
# Wait until the credentials are no longer in use anywhere
_lock = await self._acquire_lock(user_id, credentials.id)
try:
fresh_credentials = await oauth_handler.refresh_tokens(credentials)
await self.store.update_creds(user_id, fresh_credentials)
_invoke_creds_changed_hook(user_id, fresh_credentials.provider)
credentials = fresh_credentials
finally:
if (await _lock.locked()) and (await _lock.owned()):
try:
await _lock.release()
except Exception:
logger.warning(
"Failed to release OAuth refresh lock",
exc_info=True,
)
return credentials
fresh_credentials = await oauth_handler.refresh_tokens(credentials)
await self.store.update_creds(user_id, fresh_credentials)
# Notify listeners so the refreshed token is picked up immediately.
_invoke_creds_changed_hook(user_id, fresh_credentials.provider)
if _lock and (await _lock.locked()) and (await _lock.owned()):
try:
await _lock.release()
except Exception:
logger.warning(
"Failed to release OAuth refresh lock",
exc_info=True,
)
async def _refresh_unlocked(
self, user_id: str, credentials: OAuth2Credentials
) -> OAuth2Credentials:
"""Best-effort token refresh without any Redis locking.
credentials = fresh_credentials
Safe for use from multi-threaded contexts (e.g. copilot workers) where
each thread has its own event loop and sharing Redis-backed asyncio locks
is not possible. Concurrent refreshes are tolerated: the last writer
wins, and stale tokens are overwritten.
"""
oauth_handler = await self._get_oauth_handler(credentials)
if oauth_handler.needs_refresh(credentials):
logger.debug(
"Refreshing '%s' credentials #%s (lock-free)",
credentials.provider,
credentials.id,
)
fresh_credentials = await oauth_handler.refresh_tokens(credentials)
await self.store.update_creds(user_id, fresh_credentials)
_invoke_creds_changed_hook(user_id, fresh_credentials.provider)
credentials = fresh_credentials
return credentials
async def update(self, user_id: str, updated: Credentials) -> None:
@@ -264,7 +301,6 @@ class IntegrationCredentialsManager:
async def release_all_locks(self):
"""Call this on process termination to ensure all locks are released"""
await (await self.locks()).release_all_locks()
await (await self.store.locks()).release_all_locks()

View File

@@ -22,7 +22,6 @@ function generateTestGraph(name = null) {
input_default: {
name: "Load Test Input",
description: "Test input for load testing",
placeholder_values: {},
},
input_nodes: [],
output_nodes: ["output_node"],
@@ -59,11 +58,7 @@ function generateExecutionInputs() {
"Load Test Input": {
name: "Load Test Input",
description: "Test input for load testing",
placeholder_values: {
test_data: `Test execution at ${new Date().toISOString()}`,
test_parameter: Math.random().toString(36).substr(2, 9),
numeric_value: Math.floor(Math.random() * 1000),
},
value: `Test execution at ${new Date().toISOString()}`,
},
};
}

View File

@@ -5,12 +5,8 @@ import { Button } from "@/components/atoms/Button/Button";
import type { UserRateLimitResponse } from "@/app/api/__generated__/models/userRateLimitResponse";
import { UsageBar } from "../../components/UsageBar";
/** Extend generated type with optional fields returned by the backend
* but not yet present in the generated OpenAPI schema on this branch. */
type RateLimitData = UserRateLimitResponse & { user_email?: string | null };
interface Props {
data: RateLimitData;
data: UserRateLimitResponse;
onReset: (resetWeekly: boolean) => Promise<void>;
/** Override the outer container classes (default: bordered card). */
className?: string;

View File

@@ -49,23 +49,17 @@ export function useRateLimitManager() {
setRateLimitData(null);
try {
// The backend accepts either user_id or email, but the generated type
// only knows about user_id — cast to satisfy the compiler until the
// OpenAPI spec on this branch is updated.
const params = looksLikeEmail(trimmed)
? ({ email: trimmed } as unknown as { user_id: string })
? { email: trimmed }
: { user_id: trimmed };
const response = await getV2GetUserRateLimit(params);
if (response.status !== 200) {
throw new Error("Failed to fetch rate limit");
}
setRateLimitData(response.data);
const data = response.data as typeof response.data & {
user_email?: string | null;
};
setSelectedUser({
user_id: data.user_id,
user_email: data.user_email ?? data.user_id,
user_id: response.data.user_id,
user_email: response.data.user_email ?? response.data.user_id,
});
} catch (error) {
console.error("Error fetching rate limit:", error);

View File

@@ -3,6 +3,7 @@ import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { ExclamationMarkIcon } from "@phosphor-icons/react";
import { ToolUIPart, UIDataTypes, UIMessage, UITools } from "ai";
import { useState } from "react";
import { AskQuestionTool } from "../../../tools/AskQuestion/AskQuestion";
import { ConnectIntegrationTool } from "../../../tools/ConnectIntegrationTool/ConnectIntegrationTool";
import { CreateAgentTool } from "../../../tools/CreateAgent/CreateAgent";
import { EditAgentTool } from "../../../tools/EditAgent/EditAgent";
@@ -129,6 +130,8 @@ export function MessagePartRenderer({
</MessageResponse>
);
}
case "tool-ask_question":
return <AskQuestionTool key={key} part={part as ToolUIPart} />;
case "tool-find_block":
return <FindBlocksTool key={key} part={part as ToolUIPart} />;
case "tool-find_agent":

View File

@@ -13,6 +13,7 @@ export type RenderSegment =
| { kind: "collapsed-group"; parts: ToolUIPart[] };
const CUSTOM_TOOL_TYPES = new Set([
"tool-ask_question",
"tool-find_block",
"tool-find_agent",
"tool-find_library_agent",

View File

@@ -7,7 +7,7 @@ import { Text } from "@/components/atoms/Text/Text";
import { cn } from "@/lib/utils";
import { ChatTeardropDotsIcon, CheckCircleIcon } from "@phosphor-icons/react";
import { useEffect, useRef, useState } from "react";
import type { ClarifyingQuestion } from "../../clarifying-questions";
import type { ClarifyingQuestion } from "../../tools/clarifying-questions";
interface Props {
questions: ClarifyingQuestion[];

View File

@@ -0,0 +1,68 @@
"use client";
import { ChatTeardropDotsIcon, WarningCircleIcon } from "@phosphor-icons/react";
import type { ToolUIPart } from "ai";
import { ClarificationQuestionsCard } from "../../components/ClarificationQuestionsCard/ClarificationQuestionsCard";
import { useCopilotChatActions } from "../../components/CopilotChatActionsProvider/useCopilotChatActions";
import { MorphingTextAnimation } from "../../components/MorphingTextAnimation/MorphingTextAnimation";
import { normalizeClarifyingQuestions } from "../clarifying-questions";
import {
getAnimationText,
getAskQuestionOutput,
isClarificationOutput,
isErrorOutput,
} from "./helpers";
interface Props {
part: ToolUIPart;
}
export function AskQuestionTool({ part }: Props) {
const text = getAnimationText(part);
const { onSend } = useCopilotChatActions();
const isStreaming =
part.state === "input-streaming" || part.state === "input-available";
const isError = part.state === "output-error";
const output = getAskQuestionOutput(part);
function handleAnswers(answers: Record<string, string>) {
if (!output || !isClarificationOutput(output)) return;
const questions = normalizeClarifyingQuestions(output.questions ?? []);
const message = questions
.map((q) => {
const answer = answers[q.keyword] || "";
return `> ${q.question}\n\n${answer}`;
})
.join("\n\n");
onSend(`**Here are my answers:**\n\n${message}\n\nPlease proceed.`);
}
if (output && isClarificationOutput(output)) {
return (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
sessionId={output.session_id}
onSubmitAnswers={handleAnswers}
/>
);
}
return (
<div className="flex items-center gap-2 py-2 text-sm text-muted-foreground">
{isError || (output && isErrorOutput(output)) ? (
<WarningCircleIcon size={16} className="text-red-500" />
) : isStreaming ? (
<ChatTeardropDotsIcon size={16} className="animate-pulse" />
) : (
<ChatTeardropDotsIcon size={16} />
)}
<MorphingTextAnimation
text={text}
className={isError ? "text-red-500" : undefined}
/>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { ToolUIPart } from "ai";
interface ClarifyingQuestionPayload {
question: string;
keyword: string;
example?: string;
}
export interface AskQuestionOutput {
type: string;
message: string;
questions: ClarifyingQuestionPayload[];
session_id?: string;
}
interface ErrorOutput {
type: "error";
message: string;
error?: string;
}
export type AskQuestionToolOutput = AskQuestionOutput | ErrorOutput;
function parseOutput(output: unknown): AskQuestionToolOutput | null {
if (!output) return null;
if (typeof output === "string") {
try {
return parseOutput(JSON.parse(output) as unknown);
} catch {
return null;
}
}
if (typeof output === "object" && output !== null) {
const obj = output as Record<string, unknown>;
if (
obj.type === ResponseType.agent_builder_clarification_needed ||
"questions" in obj
) {
return obj as unknown as AskQuestionOutput;
}
if (obj.type === "error" || "error" in obj) {
return obj as unknown as ErrorOutput;
}
}
return null;
}
export function getAskQuestionOutput(
part: ToolUIPart,
): AskQuestionToolOutput | null {
return parseOutput(part.output);
}
export function isClarificationOutput(
output: AskQuestionToolOutput,
): output is AskQuestionOutput {
return (
output.type === ResponseType.agent_builder_clarification_needed ||
"questions" in output
);
}
export function isErrorOutput(
output: AskQuestionToolOutput,
): output is ErrorOutput {
return output.type === "error" || "error" in output;
}
export function getAnimationText(part: ToolUIPart): string {
switch (part.state) {
case "input-streaming":
case "input-available":
return "Asking question...";
case "output-available": {
const output = parseOutput(part.output);
if (output && isClarificationOutput(output)) return "Needs your input";
if (output && isErrorOutput(output)) return "Failed to ask question";
return "Asking question...";
}
case "output-error":
return "Failed to ask question";
default:
return "Asking question...";
}
}

View File

@@ -13,13 +13,8 @@ import {
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import { ClarificationQuestionsCard } from "./components/ClarificationQuestionsCard";
import { MiniGame } from "../../components/MiniGame/MiniGame";
import { SuggestedGoalCard } from "./components/SuggestedGoalCard";
import {
buildClarificationAnswersMessage,
normalizeClarifyingQuestions,
} from "../clarifying-questions";
import {
AccordionIcon,
formatMaybeJson,
@@ -27,7 +22,6 @@ import {
getCreateAgentToolOutput,
isAgentPreviewOutput,
isAgentSavedOutput,
isClarificationNeededOutput,
isErrorOutput,
isSuggestedGoalOutput,
ToolIcon,
@@ -66,15 +60,6 @@ function getAccordionMeta(output: CreateAgentToolOutput | null) {
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
};
}
if (isClarificationNeededOutput(output)) {
const questions = output.questions ?? [];
return {
icon,
title: "Needs clarification",
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
expanded: true,
};
}
if (isSuggestedGoalOutput(output)) {
return {
icon,
@@ -107,15 +92,6 @@ export function CreateAgentTool({ part }: Props) {
onSend(`Please create an agent with this goal: ${goal}`);
}
function handleClarificationAnswers(answers: Record<string, string>) {
const questions =
output && isClarificationNeededOutput(output)
? (output.questions ?? [])
: [];
onSend(buildClarificationAnswersMessage(answers, questions, "create"));
}
return (
<div className="py-2">
{isOperating && (
@@ -148,44 +124,42 @@ export function CreateAgentTool({ part }: Props) {
/>
)}
{hasExpandableContent &&
!(output && isClarificationNeededOutput(output)) &&
!(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{hasExpandableContent && !(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
{output && isSuggestedGoalOutput(output) && (
<SuggestedGoalCard
message={output.message}
suggestedGoal={output.suggested_goal}
reason={output.reason}
goalType={output.goal_type ?? "vague"}
onUseSuggestedGoal={handleUseSuggestedGoal}
/>
)}
</ToolAccordion>
)}
{output && isSuggestedGoalOutput(output) && (
<SuggestedGoalCard
message={output.message}
suggestedGoal={output.suggested_goal}
reason={output.reason}
goalType={output.goal_type ?? "vague"}
onUseSuggestedGoal={handleUseSuggestedGoal}
/>
)}
</ToolAccordion>
)}
{output && isAgentSavedOutput(output) && (
<AgentSavedCard
@@ -195,14 +169,6 @@ export function CreateAgentTool({ part }: Props) {
agentPageLink={output.agent_page_link}
/>
)}
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import type { SuggestedGoalResponse } from "@/app/api/__generated__/models/suggestedGoalResponse";
@@ -15,7 +14,6 @@ import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
export type CreateAgentToolOutput =
| AgentPreviewResponse
| AgentSavedResponse
| ClarificationNeededResponse
| SuggestedGoalResponse
| ErrorResponse;
@@ -35,7 +33,6 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
if (
type === ResponseType.agent_builder_preview ||
type === ResponseType.agent_builder_saved ||
type === ResponseType.agent_builder_clarification_needed ||
type === ResponseType.suggested_goal ||
type === ResponseType.error
) {
@@ -45,7 +42,6 @@ function parseOutput(output: unknown): CreateAgentToolOutput | null {
return output as AgentPreviewResponse;
if ("agent_id" in output && "library_agent_id" in output)
return output as AgentSavedResponse;
if ("questions" in output) return output as ClarificationNeededResponse;
if ("suggested_goal" in output) return output as SuggestedGoalResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
@@ -77,15 +73,6 @@ export function isAgentSavedOutput(
);
}
export function isClarificationNeededOutput(
output: CreateAgentToolOutput,
): output is ClarificationNeededResponse {
return (
output.type === ResponseType.agent_builder_clarification_needed ||
"questions" in output
);
}
export function isSuggestedGoalOutput(
output: CreateAgentToolOutput,
): output is SuggestedGoalResponse {
@@ -114,7 +101,6 @@ export function getAnimationText(part: {
if (!output) return "Creating a new agent";
if (isAgentSavedOutput(output)) return `Saved ${output.agent_name}`;
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
if (isSuggestedGoalOutput(output)) return "Goal needs refinement";
return "Error creating agent";
}

View File

@@ -14,11 +14,6 @@ import {
ContentMessage,
} from "../../components/ToolAccordion/AccordionContent";
import { ToolAccordion } from "../../components/ToolAccordion/ToolAccordion";
import {
buildClarificationAnswersMessage,
normalizeClarifyingQuestions,
} from "../clarifying-questions";
import { ClarificationQuestionsCard } from "../CreateAgent/components/ClarificationQuestionsCard";
import {
AccordionIcon,
formatMaybeJson,
@@ -26,7 +21,6 @@ import {
getEditAgentToolOutput,
isAgentPreviewOutput,
isAgentSavedOutput,
isClarificationNeededOutput,
isErrorOutput,
ToolIcon,
truncateText,
@@ -69,14 +63,6 @@ function getAccordionMeta(output: EditAgentToolOutput | null): {
description: `${output.node_count} block${output.node_count === 1 ? "" : "s"}`,
};
}
if (isClarificationNeededOutput(output)) {
const questions = output.questions ?? [];
return {
icon,
title: "Needs clarification",
description: `${questions.length} question${questions.length === 1 ? "" : "s"}`,
};
}
return { icon, title: "" };
}
@@ -96,15 +82,6 @@ export function EditAgentTool({ part }: Props) {
// (errors are shown inline so they get replaced when retrying)
const hasExpandableContent = !isError;
function handleClarificationAnswers(answers: Record<string, string>) {
const questions =
output && isClarificationNeededOutput(output)
? (output.questions ?? [])
: [];
onSend(buildClarificationAnswersMessage(answers, questions, "edit"));
}
return (
<div className="py-2">
{isOperating && (
@@ -132,34 +109,32 @@ export function EditAgentTool({ part }: Props) {
/>
)}
{hasExpandableContent &&
!(output && isClarificationNeededOutput(output)) &&
!(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{hasExpandableContent && !(output && isAgentSavedOutput(output)) && (
<ToolAccordion {...getAccordionMeta(output)}>
{isOperating && (
<ContentGrid>
<MiniGame />
<ContentHint>
This could take a few minutes play while you wait!
</ContentHint>
</ContentGrid>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
</ToolAccordion>
)}
{output && isAgentPreviewOutput(output) && (
<ContentGrid>
<ContentMessage>{output.message}</ContentMessage>
{output.description?.trim() && (
<ContentCardDescription>
{output.description}
</ContentCardDescription>
)}
<ContentCodeBlock>
{truncateText(formatMaybeJson(output.agent_json), 1600)}
</ContentCodeBlock>
</ContentGrid>
)}
</ToolAccordion>
)}
{output && isAgentSavedOutput(output) && (
<AgentSavedCard
@@ -169,14 +144,6 @@ export function EditAgentTool({ part }: Props) {
agentPageLink={output.agent_page_link}
/>
)}
{output && isClarificationNeededOutput(output) && (
<ClarificationQuestionsCard
questions={normalizeClarifyingQuestions(output.questions ?? [])}
message={output.message}
onSubmitAnswers={handleClarificationAnswers}
/>
)}
</div>
);
}

View File

@@ -1,6 +1,5 @@
import type { AgentPreviewResponse } from "@/app/api/__generated__/models/agentPreviewResponse";
import type { AgentSavedResponse } from "@/app/api/__generated__/models/agentSavedResponse";
import type { ClarificationNeededResponse } from "@/app/api/__generated__/models/clarificationNeededResponse";
import type { ErrorResponse } from "@/app/api/__generated__/models/errorResponse";
import { ResponseType } from "@/app/api/__generated__/models/responseType";
import {
@@ -14,7 +13,6 @@ import { ScaleLoader } from "../../components/ScaleLoader/ScaleLoader";
export type EditAgentToolOutput =
| AgentPreviewResponse
| AgentSavedResponse
| ClarificationNeededResponse
| ErrorResponse;
function parseOutput(output: unknown): EditAgentToolOutput | null {
@@ -33,7 +31,6 @@ function parseOutput(output: unknown): EditAgentToolOutput | null {
if (
type === ResponseType.agent_builder_preview ||
type === ResponseType.agent_builder_saved ||
type === ResponseType.agent_builder_clarification_needed ||
type === ResponseType.error
) {
return output as EditAgentToolOutput;
@@ -42,7 +39,6 @@ function parseOutput(output: unknown): EditAgentToolOutput | null {
return output as AgentPreviewResponse;
if ("agent_id" in output && "library_agent_id" in output)
return output as AgentSavedResponse;
if ("questions" in output) return output as ClarificationNeededResponse;
if ("error" in output || "details" in output)
return output as ErrorResponse;
}
@@ -73,15 +69,6 @@ export function isAgentSavedOutput(
);
}
export function isClarificationNeededOutput(
output: EditAgentToolOutput,
): output is ClarificationNeededResponse {
return (
output.type === ResponseType.agent_builder_clarification_needed ||
"questions" in output
);
}
export function isErrorOutput(
output: EditAgentToolOutput,
): output is ErrorResponse {
@@ -102,7 +89,6 @@ export function getAnimationText(part: {
if (!output) return "Editing the agent";
if (isAgentSavedOutput(output)) return `Saved "${output.agent_name}"`;
if (isAgentPreviewOutput(output)) return `Preview "${output.agent_name}"`;
if (isClarificationNeededOutput(output)) return "Needs clarification";
return "Error editing agent";
}
case "output-error":

View File

@@ -95,8 +95,7 @@ export function useChatSession() {
async function createSession() {
if (sessionId) return sessionId;
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const response = await (createSessionMutation as any)({ data: null });
const response = await createSessionMutation({ data: null });
if (response.status !== 200 || !response.data?.id) {
const error = new Error("Failed to create session");
Sentry.captureException(error, {

View File

@@ -19,7 +19,6 @@ import {
const RECONNECT_BASE_DELAY_MS = 1_000;
const RECONNECT_MAX_ATTEMPTS = 3;
const STREAM_TIMEOUT_MS = 60_000;
/** Minimum time the page must have been hidden to trigger a wake re-sync. */
const WAKE_RESYNC_THRESHOLD_MS = 30_000;
@@ -103,11 +102,6 @@ export function useCopilotStream({
// Set when the user explicitly clicks stop — prevents onError from
// triggering a reconnect cycle for the resulting AbortError.
const isUserStoppingRef = useRef(false);
// Timer that fires when no SSE events arrive for STREAM_TIMEOUT_MS during
// an active stream — auto-cancels the stream to avoid "Reasoning..." forever.
const streamTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Ref to the latest stop() so the timeout callback never uses a stale closure.
const stopRef = useRef<() => void>(() => {});
// Set when all reconnect attempts are exhausted — prevents hasActiveStream
// from keeping the UI blocked forever when the backend is slow to clear it.
// Must be state (not ref) so that setting it triggers a re-render and
@@ -251,12 +245,8 @@ export function useCopilotStream({
// Wrap AI SDK's stop() to also cancel the backend executor task.
// sdkStop() aborts the SSE fetch instantly (UI feedback), then we fire
// the cancel API to actually stop the executor and wait for confirmation.
// Also kept in stopRef so the stream-timeout callback always calls the
// latest version without needing it in the effect dependency array.
async function stop() {
isUserStoppingRef.current = true;
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = undefined;
sdkStop();
// Resolve pending tool calls and inject a cancellation marker so the UI
// shows "You manually stopped this chat" immediately (the backend writes
@@ -305,7 +295,6 @@ export function useCopilotStream({
});
}
}
stopRef.current = stop;
// Keep a ref to sessionId so the async wake handler can detect staleness.
const sessionIdRef = useRef(sessionId);
@@ -386,8 +375,6 @@ export function useCopilotStream({
useEffect(() => {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = undefined;
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = undefined;
reconnectAttemptsRef.current = 0;
isReconnectScheduledRef.current = false;
setIsReconnectScheduled(false);
@@ -400,8 +387,6 @@ export function useCopilotStream({
return () => {
clearTimeout(reconnectTimerRef.current);
reconnectTimerRef.current = undefined;
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = undefined;
};
}, [sessionId]);
@@ -483,41 +468,6 @@ export function useCopilotStream({
}
}, [hasActiveStream]);
// Stream timeout guard: if no SSE events arrive for STREAM_TIMEOUT_MS while
// the stream is active, auto-cancel to avoid the UI stuck in "Reasoning..."
// indefinitely (e.g. when the SSE connection dies silently without a
// disconnect event).
useEffect(() => {
// rawMessages is intentionally in the dependency array: each SSE event
// updates rawMessages, which re-runs this effect and resets the timer.
// Referencing its length here satisfies the exhaustive-deps rule.
void rawMessages.length;
const isActive = status === "streaming" || status === "submitted";
if (!isActive) {
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = undefined;
return;
}
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = setTimeout(() => {
streamTimeoutRef.current = undefined;
toast({
title: "Connection lost",
description:
"No response received — please try sending your message again.",
variant: "destructive",
});
stopRef.current();
}, STREAM_TIMEOUT_MS);
return () => {
clearTimeout(streamTimeoutRef.current);
streamTimeoutRef.current = undefined;
};
}, [status, rawMessages]);
// True while reconnecting or backend has active stream but we haven't connected yet.
// Suppressed when the user explicitly stopped or when all reconnect attempts
// are exhausted — the backend may be slow to clear active_stream but the UI

View File

@@ -169,7 +169,7 @@ Block for dropdown text selection.
### How it works
<!-- MANUAL: how_it_works -->
This block provides a dropdown selection input for users interacting with your agent. You define the available options using placeholder_values, and users select one option from the list at runtime.
This block provides a dropdown selection input for users interacting with your agent. You define the available options using the `options` field, and users select one option from the list at runtime.
This is ideal when you want to constrain user input to a predefined set of choices, ensuring valid input and simplifying the user experience. The selected value is passed to downstream blocks in your workflow.
<!-- END MANUAL -->
@@ -184,7 +184,7 @@ This is ideal when you want to constrain user input to a predefined set of choic
| description | The description of the input. | str | No |
| advanced | Whether to show the input in the advanced section, if the field is not required. | bool | No |
| secret | Whether the input should be treated as a secret. | bool | No |
| placeholder_values | Possible values for the dropdown. | List[Any] | No |
| options | If provided, renders the input as a dropdown selector restricted to these values. Leave empty for free-text input. | List[Any] | No |
### Outputs