From 52c134fdb8ac1b9299334b89f964cfdb5ab5feb8 Mon Sep 17 00:00:00 2001 From: Sun Tao <2605127667@qq.com> Date: Tue, 24 Feb 2026 15:20:22 +0800 Subject: [PATCH 01/11] update --- .github/PULL_REQUEST_TEMPLATE.md | 2 + backend/app/agent/listen_chat_agent.py | 51 ++++++++++++++++++++++++- backend/app/service/chat_service.py | 2 + backend/app/service/task.py | 17 +++++++++ server/tests/test_auth.py | 8 ++-- server/tests/test_chat_share.py | 2 - server/tests/test_proxy_controller.py | 4 +- src/store/chatStore.ts | 8 ++++ src/types/chatbox.d.ts | 3 ++ src/types/constants.ts | 1 + test/unit/store/chatStore.test.ts | 53 ++++++++++++++++++++++++++ 11 files changed, 139 insertions(+), 12 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 762f852b3..f711a60c9 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,7 +15,9 @@ Closes # ### Testing Evidence (REQUIRED) + + - [ ] I have included human-verified testing evidence in this PR. diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 6bdb61ca5..fc8ae53ec 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -13,6 +13,7 @@ # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= import asyncio +import inspect import json import logging from collections.abc import Callable @@ -42,6 +43,7 @@ ActionBudgetNotEnough, ActionDeactivateAgentData, ActionDeactivateToolkitData, + ActionRequestUsageData, get_task_lock, set_process_task, ) @@ -93,6 +95,17 @@ def __init__( step_timeout: float | None = 1800, # 30 minutes **kwargs: Any, ) -> None: + self.api_task_id = api_task_id + self.agent_name = agent_name + self._request_usage_events_enabled = False + self._user_on_request_usage = kwargs.pop("on_request_usage", None) + if ( + "on_request_usage" + in inspect.signature(ChatAgent.__init__).parameters + ): + kwargs["on_request_usage"] = self._on_request_usage + self._request_usage_events_enabled = True + super().__init__( system_message=system_message, model=model, @@ -116,11 +129,41 @@ def __init__( step_timeout=step_timeout, **kwargs, ) - self.api_task_id = api_task_id - self.agent_name = agent_name process_task_id: str = "" + def _on_request_usage(self, payload: dict[str, Any]) -> Any: + request_usage = payload.get("request_usage") or {} + step_usage = payload.get("step_usage") or {} + request_tokens = int(request_usage.get("total_tokens") or 0) + if request_tokens > 0: + task_lock = get_task_lock(self.api_task_id) + _schedule_async_task( + task_lock.put_queue( + ActionRequestUsageData( + data={ + "agent_name": self.agent_name, + "process_task_id": self.process_task_id, + "agent_id": self.agent_id, + "tokens": request_tokens, + "request_index": int( + payload.get("request_index") or 0 + ), + "response_id": str( + payload.get("response_id") or "" + ), + "step_total_tokens": int( + step_usage.get("total_tokens") or 0 + ), + } + ) + ) + ) + + if self._user_on_request_usage is not None: + return self._user_on_request_usage(payload) + return None + def _send_agent_deactivate(self, message: str, tokens: int) -> None: """Send agent deactivation event to the frontend. @@ -291,6 +334,8 @@ def step( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) + if self._request_usage_events_enabled: + total_tokens = 0 assert message is not None @@ -394,6 +439,8 @@ async def astep( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) + if self._request_usage_events_enabled: + total_tokens = 0 # Send deactivation for all non-streaming cases (success or error) # Streaming responses handle deactivation in _astream_chunks diff --git a/backend/app/service/chat_service.py b/backend/app/service/chat_service.py index b3a24d1f4..1aaf25514 100644 --- a/backend/app/service/chat_service.py +++ b/backend/app/service/chat_service.py @@ -1475,6 +1475,8 @@ def on_stream_text(chunk): yield sse_json("activate_agent", item.data) elif item.action == Action.deactivate_agent: yield sse_json("deactivate_agent", dict(item.data)) + elif item.action == Action.request_usage: + yield sse_json("request_usage", dict(item.data)) elif item.action == Action.assign_task: yield sse_json("assign_task", item.data) elif item.action == Action.activate_toolkit: diff --git a/backend/app/service/task.py b/backend/app/service/task.py index 604fbc717..eee60bfb4 100644 --- a/backend/app/service/task.py +++ b/backend/app/service/task.py @@ -49,6 +49,7 @@ class Action(str, Enum): create_agent = "create_agent" # backend -> user activate_agent = "activate_agent" # backend -> user deactivate_agent = "deactivate_agent" # backend -> user + request_usage = "request_usage" # backend -> user assign_task = "assign_task" # backend -> user activate_toolkit = "activate_toolkit" # backend -> user deactivate_toolkit = "deactivate_toolkit" # backend -> user @@ -155,6 +156,21 @@ class ActionDeactivateAgentData(BaseModel): data: DataDict +class RequestUsageDataDict(TypedDict): + agent_name: str + agent_id: str + process_task_id: str + tokens: int + request_index: int + response_id: str + step_total_tokens: int + + +class ActionRequestUsageData(BaseModel): + action: Literal[Action.request_usage] = Action.request_usage + data: RequestUsageDataDict + + class ActionAssignTaskData(BaseModel): action: Literal[Action.assign_task] = Action.assign_task data: dict[ @@ -288,6 +304,7 @@ class ActionSkipTaskData(BaseModel): | ActionCreateAgentData | ActionActivateAgentData | ActionDeactivateAgentData + | ActionRequestUsageData | ActionAssignTaskData | ActionActivateToolkitData | ActionDeactivateToolkitData diff --git a/server/tests/test_auth.py b/server/tests/test_auth.py index ffcb27946..9ffb1aecc 100644 --- a/server/tests/test_auth.py +++ b/server/tests/test_auth.py @@ -47,7 +47,7 @@ def test_auth_must_raises_on_none_token(self): """auth_must should raise TokenException immediately when token is None, not pass it to jwt.decode().""" import asyncio - from unittest.mock import MagicMock, patch + from unittest.mock import MagicMock from app.component.auth import auth_must from app.exception.exception import TokenException @@ -88,8 +88,7 @@ def test_list_snapshots_requires_auth_dependency(self): sig = inspect.signature(list_chat_snapshots) param_names = list(sig.parameters.keys()) assert "auth" in param_names, ( - "list_chat_snapshots is missing the 'auth' parameter — " - "unauthenticated users can list all snapshots" + "list_chat_snapshots is missing the 'auth' parameter — unauthenticated users can list all snapshots" ) def test_get_snapshot_requires_auth_dependency(self): @@ -130,8 +129,7 @@ def test_create_share_link_requires_auth_dependency(): sig = inspect.signature(create_share_link) param_names = list(sig.parameters.keys()) assert "auth" in param_names, ( - "create_share_link is missing the 'auth' parameter — " - "unauthenticated users can generate share tokens" + "create_share_link is missing the 'auth' parameter — unauthenticated users can generate share tokens" ) diff --git a/server/tests/test_chat_share.py b/server/tests/test_chat_share.py index 5af72b43e..6657f3451 100644 --- a/server/tests/test_chat_share.py +++ b/server/tests/test_chat_share.py @@ -15,8 +15,6 @@ import os from unittest.mock import patch -import pytest - class TestChatShareSecretKey: """Tests for ChatShare secret key generation. diff --git a/server/tests/test_proxy_controller.py b/server/tests/test_proxy_controller.py index c2ca19df2..1f2fa4242 100644 --- a/server/tests/test_proxy_controller.py +++ b/server/tests/test_proxy_controller.py @@ -12,9 +12,7 @@ # limitations under the License. # ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= -from urllib.parse import quote_plus, urlparse, parse_qs - -import pytest +from urllib.parse import parse_qs, quote_plus, urlparse class TestGoogleSearchUrlEncoding: diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 547f9af4e..dcb1ffb75 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -1345,6 +1345,14 @@ const chatStore = (initial?: Partial) => return; } + // Request-level token usage updates (non-stream mode) + if (agentMessages.step === AgentStep.REQUEST_USAGE) { + if (agentMessages.data.tokens) { + addTokens(currentTaskId, agentMessages.data.tokens); + } + return; + } + // Activate agent if ( agentMessages.step === AgentStep.ACTIVATE_AGENT || diff --git a/src/types/chatbox.d.ts b/src/types/chatbox.d.ts index 40c7d1a72..238258aac 100644 --- a/src/types/chatbox.d.ts +++ b/src/types/chatbox.d.ts @@ -139,6 +139,9 @@ declare global { agent?: string; file_path?: string; process_task_id?: string; + request_index?: number; + response_id?: string; + step_total_tokens?: number; output?: string; result?: string; tools?: string[]; diff --git a/src/types/constants.ts b/src/types/constants.ts index eba956f65..dd6aef033 100644 --- a/src/types/constants.ts +++ b/src/types/constants.ts @@ -26,6 +26,7 @@ export const AgentStep = { TASK_STATE: 'task_state', ACTIVATE_AGENT: 'activate_agent', DEACTIVATE_AGENT: 'deactivate_agent', + REQUEST_USAGE: 'request_usage', ASSIGN_TASK: 'assign_task', ACTIVATE_TOOLKIT: 'activate_toolkit', DEACTIVATE_TOOLKIT: 'deactivate_toolkit', diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 1634667bb..793167946 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -579,6 +579,59 @@ describe('ChatStore - Core Functionality', () => { }); }); + describe('SSE request usage events', () => { + it('should accumulate tokens from request_usage event in non-stream mode', async () => { + vi.mocked(proxyFetchGet).mockImplementation((url: string) => + url?.includes?.('snapshots') + ? Promise.resolve([]) + : Promise.resolve({ + value: '', + api_url: '', + items: [], + warning_code: null, + }) + ); + + const mockFetchEventSource = vi.mocked(fetchEventSource); + mockFetchEventSource.mockImplementation(async (_url, opts) => { + opts.onmessage?.({ + data: JSON.stringify({ + step: 'request_usage', + data: { tokens: 11 }, + }), + } as any); + opts.onmessage?.({ + data: JSON.stringify({ + step: 'deactivate_agent', + data: { tokens: 0 }, + }), + } as any); + return Promise.resolve(); + }); + + const { result } = renderHook(() => useChatStore()); + let taskId!: string; + await act(async () => { + taskId = result.current.getState().create(); + result.current.getState().setActiveTaskId(taskId); + result.current.getState().setHasMessages(taskId, true); + result.current.getState().addMessages(taskId, { + id: generateUniqueId(), + role: 'user', + content: 'Test message', + }); + }); + + await act(async () => { + await result.current + .getState() + .startTask(taskId, 'replay', undefined, 0.2); + }); + + expect(result.current.getState().tasks[taskId].tokens).toBe(11); + }); + }); + describe('Replay', () => { const replayProjectState = () => ({ activeProjectId: 'proj-replay', From 6268ac0140c5ebbdf1e254825e73fa5d622e9d3a Mon Sep 17 00:00:00 2001 From: Sun Tao <2605127667@qq.com> Date: Tue, 24 Feb 2026 17:08:23 +0800 Subject: [PATCH 02/11] Update PULL_REQUEST_TEMPLATE.md --- .github/PULL_REQUEST_TEMPLATE.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f711a60c9..e7456f18f 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,8 @@ -### Related Issue +# Pull Request + +## Related Issue @@ -8,11 +10,11 @@ Closes # -### Description +## Description -### Testing Evidence (REQUIRED) +## Testing Evidence (REQUIRED) @@ -24,13 +26,13 @@ Closes # - [ ] This PR includes frontend/UI changes, and I attached screenshot(s) or screen recording(s). - [ ] No frontend/UI changes in this PR. -### What is the purpose of this pull request? +## What is the purpose of this pull request? - [ ] Bug fix - [ ] New Feature - [ ] Documentation update - [ ] Other -### Contribution Guidelines Acknowledgement +## Contribution Guidelines Acknowledgement - [ ] I have read and agree to the [Eigent Contribution Guideline](https://github.com/eigent-ai/eigent/blob/main/CONTRIBUTING.md#eigent-contribution-guideline) From d242c5b04902e0dda8b2d44e939aaddbb0d907d9 Mon Sep 17 00:00:00 2001 From: Wendong-Fan Date: Thu, 5 Mar 2026 00:55:28 +0800 Subject: [PATCH 03/11] fix and enhance --- backend/app/agent/listen_chat_agent.py | 32 ++++++++++++-------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 524199dbb..1cc79c17e 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -59,6 +59,10 @@ class ListenChatAgent(ChatAgent): threading.Lock() ) # Protects CDP URL mutation during clone + _camel_has_request_usage: bool = ( + "on_request_usage" in inspect.signature(ChatAgent.__init__).parameters + ) + def __init__( self, api_task_id: str, @@ -102,14 +106,9 @@ def __init__( ) -> None: self.api_task_id = api_task_id self.agent_name = agent_name - self._request_usage_events_enabled = False self._user_on_request_usage = kwargs.pop("on_request_usage", None) - if ( - "on_request_usage" - in inspect.signature(ChatAgent.__init__).parameters - ): + if self._camel_has_request_usage: kwargs["on_request_usage"] = self._on_request_usage - self._request_usage_events_enabled = True super().__init__( system_message=system_message, @@ -142,21 +141,16 @@ def _on_request_usage(self, payload: dict[str, Any]) -> Any: step_usage = payload.get("step_usage") or {} request_tokens = int(request_usage.get("total_tokens") or 0) if request_tokens > 0: - task_lock = get_task_lock(self.api_task_id) _schedule_async_task( - task_lock.put_queue( + get_task_lock(self.api_task_id).put_queue( ActionRequestUsageData( data={ "agent_name": self.agent_name, "process_task_id": self.process_task_id, "agent_id": self.agent_id, "tokens": request_tokens, - "request_index": int( - payload.get("request_index") or 0 - ), - "response_id": str( - payload.get("response_id") or "" - ), + "request_index": payload.get("request_index", 0), + "response_id": payload.get("response_id", ""), "step_total_tokens": int( step_usage.get("total_tokens") or 0 ), @@ -164,7 +158,6 @@ def _on_request_usage(self, payload: dict[str, Any]) -> Any: ) ) ) - if self._user_on_request_usage is not None: return self._user_on_request_usage(payload) return None @@ -339,7 +332,7 @@ def step( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) - if self._request_usage_events_enabled: + if self._camel_has_request_usage: total_tokens = 0 assert message is not None @@ -444,7 +437,7 @@ async def astep( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) - if self._request_usage_events_enabled: + if self._camel_has_request_usage: total_tokens = 0 # Send deactivation for all non-streaming cases (success or error) @@ -801,6 +794,10 @@ def clone(self, with_memory: bool = False) -> ChatAgent: else: cloned_tools, toolkits_to_register = self._clone_tools() + clone_kwargs: dict[str, Any] = {} + if self._user_on_request_usage is not None: + clone_kwargs["on_request_usage"] = self._user_on_request_usage + new_agent = ListenChatAgent( api_task_id=self.api_task_id, agent_name=self.agent_name, @@ -828,6 +825,7 @@ def clone(self, with_memory: bool = False) -> ChatAgent: enable_snapshot_clean=self._enable_snapshot_clean, step_timeout=self.step_timeout, stream_accumulate=self.stream_accumulate, + **clone_kwargs, ) new_agent.process_task_id = self.process_task_id From 7d80b1e74b3c0b5448ca592d0a80ddee3010975d Mon Sep 17 00:00:00 2001 From: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:09:40 +0000 Subject: [PATCH 04/11] enhance: add_request_level_token_callback PR1362 (#1439) --- backend/app/agent/listen_chat_agent.py | 24 ++++++------------------ 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index 1cc79c17e..5d301d829 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -169,6 +169,8 @@ def _send_agent_deactivate(self, message: str, tokens: int) -> None: message: The accumulated message content tokens: The total token count used """ + if self._camel_has_request_usage: + tokens = 0 task_lock = get_task_lock(self.api_task_id) _schedule_async_task( task_lock.put_queue( @@ -332,24 +334,10 @@ def step( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) - if self._camel_has_request_usage: - total_tokens = 0 assert message is not None - _schedule_async_task( - task_lock.put_queue( - ActionDeactivateAgentData( - data={ - "agent_name": self.agent_name, - "process_task_id": self.process_task_id, - "agent_id": self.agent_id, - "message": message, - "tokens": total_tokens, - }, - ) - ) - ) + self._send_agent_deactivate(message, total_tokens) if error_info is not None: raise error_info @@ -437,8 +425,6 @@ async def astep( f"Agent {self.agent_name} completed step, " f"tokens used: {total_tokens}" ) - if self._camel_has_request_usage: - total_tokens = 0 # Send deactivation for all non-streaming cases (success or error) # Streaming responses handle deactivation in _astream_chunks @@ -452,7 +438,9 @@ async def astep( "process_task_id": self.process_task_id, "agent_id": self.agent_id, "message": message, - "tokens": total_tokens, + "tokens": 0 + if self._camel_has_request_usage + else total_tokens, }, ) ) From e0678dd2568283ddbfe9a2ce4041de07cc4cd98f Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Tue, 7 Jul 2026 23:39:58 +0100 Subject: [PATCH 05/11] fix: request streaming token usage so request-level accounting isn't zeroed Request-level token reporting relies on CAMEL's on_request_usage callback, but OpenAI-family providers omit usage from streamed responses unless `stream_options.include_usage` is set. Since the step-level deactivate is zeroed once request-level reporting is active, streaming steps (the task_agent runs streaming by default) were counted as 0 tokens. Inject `stream_options.include_usage=True` in agent_model when streaming, except for native-SDK platforms (Anthropic, Bedrock, etc.) that reject the OpenAI-specific param and report streaming usage on their own. Verified against a real model (openai-compatible, streaming): - before: request_usage fires with 0 tokens -> step counted 0 - after: request_usage tokens=366, deactivate_agent tokens=0 (exact, no double count) - anthropic task_agent: stream=True, no stream_options injected (unchanged) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/agent/agent_model.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index c7c40099f..31b007861 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -36,6 +36,24 @@ from app.utils.event_loop_utils import _schedule_async_task +# OpenAI chat-completions streaming only returns token usage when +# `stream_options.include_usage` is requested. Without it the request-level +# usage callback (on_request_usage) fires with 0 tokens, and because the +# step-level deactivate is zeroed once request-level reporting is active, +# streaming steps end up uncounted. These platforms use native (non-OpenAI) +# SDKs that reject `stream_options` and surface streaming usage on their own, +# so they are excluded from the injection below. +_NATIVE_STREAM_USAGE_PLATFORMS = { + "anthropic", + "aws-bedrock", + "aws-bedrock-converse", + "cohere", + "mistral", + "reka", + "watsonx", +} + + def agent_model( agent_name: str, system_message: str | BaseMessage, @@ -217,6 +235,17 @@ def build_model(force_refresh: bool = False): if model_config.get("max_tokens") is None: model_config["max_tokens"] = 128000 + # Ensure streaming steps still report token usage. OpenAI-family + # providers omit usage from streamed responses unless include_usage + # is set, which would otherwise make request-level accounting count 0. + if model_config.get("stream") and ( + effective_config["model_platform"].lower() + not in _NATIVE_STREAM_USAGE_PLATFORMS + ): + stream_options = model_config.setdefault("stream_options", {}) + if isinstance(stream_options, dict): + stream_options.setdefault("include_usage", True) + return ModelFactory.create( model_platform=effective_config["model_platform"], model_type=effective_config["model_type"], From 5712b47c9985438976f3f1f0b298be582801e7a3 Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Wed, 8 Jul 2026 00:17:55 +0100 Subject: [PATCH 06/11] fix: preserve token signals broken by zeroed deactivate_agent.tokens Once request-level usage reporting is active, _send_agent_deactivate zeroes deactivate_agent.tokens. Two consumers relied on that per-event field: - single-agent SSE: _action_to_sse did not forward Action.request_usage, so in single-agent mode request-level events were consumed and dropped while the deactivate fallback was zeroed -> no real-time token updates (only the final end.tokens backfilled the total). Forward request_usage like chat_service does. - question_confirm quick-reply detection (chatStore): read the zeroed deactivate_agent.tokens, so hasTokens was always false and the quick-reply "mark trigger execution Completed" branch never ran. Base it on the task's accumulated total (getTokens) instead. Verified: - _action_to_sse(ActionRequestUsageData(tokens=123)) now emits `data: {"step": "request_usage", ...}` (was None/dropped) - backend tests: test_listen_chat_agent (15 passed, 1 skipped), test_single_agent_service (1 passed) - frontend chatStore tests unchanged (5 pre-existing getProjectById failures, request_usage case passes) Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/app/service/single_agent_service.py | 2 ++ src/store/chatStore.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/backend/app/service/single_agent_service.py b/backend/app/service/single_agent_service.py index 6d963c9a7..a7994f0b2 100644 --- a/backend/app/service/single_agent_service.py +++ b/backend/app/service/single_agent_service.py @@ -175,6 +175,8 @@ def _action_to_sse(item: ActionData) -> str | None: return sse_json("activate_agent", item.data) if item.action == Action.deactivate_agent: return sse_json("deactivate_agent", item.data) + if item.action == Action.request_usage: + return sse_json("request_usage", item.data) if item.action == Action.assign_task: return sse_json("assign_task", item.data) if item.action == Action.activate_toolkit: diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index a866f5fc0..5e495d831 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -2771,8 +2771,12 @@ const chatStore = (initial?: Partial) => // and tokens are used (indicating actual response generation, not just classification) const isQuestionConfirmAgent = agentMessages.data.agent_name === 'question_confirm_agent'; - const hasTokens = - agentMessages.data.tokens && agentMessages.data.tokens > 0; + // Use the task's accumulated total rather than the per-event + // deactivate_agent.tokens: once request-level usage reporting is + // active the backend zeroes that per-event field (tokens are + // reported via request_usage instead), so relying on it here would + // always be false. + const hasTokens = getTokens(currentTaskId) > 0; const isNotClassificationAnswer = agentMessages.data.message && agentMessages.data.message.trim().toLowerCase() !== 'yes' && From c4854e8a904d1b65b38d703831b532d98d695041 Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Mon, 13 Jul 2026 22:05:01 +0200 Subject: [PATCH 07/11] style: fix ruff I001 import-block spacing in agent_model.py Co-Authored-By: Claude Fable 5 --- backend/app/agent/agent_model.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index 31b007861..4f9a2f748 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -35,7 +35,6 @@ from app.service.task import ActionCreateAgentData, Agents, get_task_lock from app.utils.event_loop_utils import _schedule_async_task - # OpenAI chat-completions streaming only returns token usage when # `stream_options.include_usage` is requested. Without it the request-level # usage callback (on_request_usage) fires with 0 tokens, and because the From 7c203ceff6aef49ca1a8f216a283ebf86df51db8 Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Mon, 13 Jul 2026 23:49:35 +0200 Subject: [PATCH 08/11] fix: address review findings on request-level token reporting - chatStore: restore per-step semantics for the question_confirm quick-reply check by tracking each agent's request_usage tokens per step, instead of the task's accumulated total which misclassified errored steps as replies - listen_chat_agent: resolve the task lock defensively in _send_agent_deactivate and _on_request_usage so a task stopped while a model call is in flight no longer raises from step()/astep() or silently skips the chained usage callback - chatStore.test: reset the fetchEventSource mock implementation after the request_usage describe so it cannot leak into later startTask tests Co-Authored-By: Claude Fable 5 --- backend/app/agent/listen_chat_agent.py | 21 ++++++++++++++--- src/store/chatStore.ts | 31 +++++++++++++++++++++----- test/unit/store/chatStore.test.ts | 9 +++++++- 3 files changed, 51 insertions(+), 10 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index c97b195dc..c97734fc0 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -46,6 +46,7 @@ ActionDeactivateToolkitData, ActionRequestUsageData, get_task_lock, + get_task_lock_if_exists, set_process_task, ) from app.utils.event_loop_utils import _schedule_async_task @@ -145,9 +146,13 @@ def _on_request_usage(self, payload: dict[str, Any]) -> Any: request_usage = payload.get("request_usage") or {} step_usage = payload.get("step_usage") or {} request_tokens = int(request_usage.get("total_tokens") or 0) - if request_tokens > 0: + # The task lock may already be gone when a model request completes + # after the user stopped the task; CAMEL swallows exceptions raised + # here, which would also silently skip the chained user callback. + task_lock = get_task_lock_if_exists(self.api_task_id) + if request_tokens > 0 and task_lock is not None: _schedule_async_task( - get_task_lock(self.api_task_id).put_queue( + task_lock.put_queue( ActionRequestUsageData( data={ "agent_name": self.agent_name, @@ -231,7 +236,17 @@ def _send_agent_deactivate(self, message: str, tokens: int) -> None: """ if self._camel_has_request_usage: tokens = 0 - task_lock = get_task_lock(self.api_task_id) + # The lock is gone when the task was stopped while the model call was + # in flight; the pre-refactor inline enqueues used the lock captured + # at step start, so a missing lock must not fail the whole step. + task_lock = get_task_lock_if_exists(self.api_task_id) + if task_lock is None: + logger.warning( + "Task lock %s missing; dropping deactivate event for %s", + self.api_task_id, + self.agent_name, + ) + return _schedule_async_task( task_lock.put_queue( ActionDeactivateAgentData( diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 5e495d831..280c317a1 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -95,6 +95,12 @@ const hasApiCode = (value: unknown, code: string) => let _host: AppHost | null = null; +// request_usage tokens for the step currently running on each agent, keyed +// by `${taskId}:${agentId}`. Once request-level reporting is active the +// backend zeroes deactivate_agent.tokens, so this preserves the per-step +// "did this agent actually generate a response" signal. +const requestUsageStepTokens = new Map(); + export function injectHost(host: AppHost | null): void { _host = host; } @@ -2678,6 +2684,13 @@ const chatStore = (initial?: Partial) => if (agentMessages.step === AgentStep.REQUEST_USAGE) { if (agentMessages.data.tokens) { addTokens(currentTaskId, agentMessages.data.tokens); + const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`; + requestUsageStepTokens.set( + stepKey, + agentMessages.data.step_total_tokens || + (requestUsageStepTokens.get(stepKey) || 0) + + agentMessages.data.tokens + ); } return; } @@ -2771,12 +2784,18 @@ const chatStore = (initial?: Partial) => // and tokens are used (indicating actual response generation, not just classification) const isQuestionConfirmAgent = agentMessages.data.agent_name === 'question_confirm_agent'; - // Use the task's accumulated total rather than the per-event - // deactivate_agent.tokens: once request-level usage reporting is - // active the backend zeroes that per-event field (tokens are - // reported via request_usage instead), so relying on it here would - // always be false. - const hasTokens = getTokens(currentTaskId) > 0; + // deactivate_agent.tokens is zeroed once request-level usage + // reporting is active, so fall back to the tokens this agent's + // current step reported via request_usage. Keeping the check + // per-step (not the task's accumulated total) is what lets an + // errored/empty step be told apart from a real reply. + const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`; + const stepTokens = + agentMessages.data.tokens || + requestUsageStepTokens.get(stepKey) || + 0; + requestUsageStepTokens.delete(stepKey); + const hasTokens = stepTokens > 0; const isNotClassificationAnswer = agentMessages.data.message && agentMessages.data.message.trim().toLowerCase() !== 'yes' && diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 19f0c6384..2a37d057b 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -23,7 +23,7 @@ */ import { act, renderHook } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // Mock dependencies - moved to top before other imports vi.mock('@/api/http', async () => { @@ -1001,6 +1001,13 @@ describe('ChatStore - Core Functionality', () => { }); describe('SSE request usage events', () => { + // The file-level beforeEach only clears calls (vi.clearAllMocks), so the + // event-pushing implementation installed below would leak into every + // later startTask test without this reset. + afterEach(() => { + vi.mocked(fetchEventSource).mockReset(); + }); + it('should accumulate tokens from request_usage event in non-stream mode', async () => { vi.mocked(proxyFetchGet).mockImplementation((url: string) => url?.includes?.('snapshots') From ff99f066e5c366f7605b24f77535c5a431132f0e Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Mon, 13 Jul 2026 23:53:39 +0200 Subject: [PATCH 09/11] style: tighten review-fix comments Co-Authored-By: Claude Fable 5 --- backend/app/agent/listen_chat_agent.py | 8 ++------ src/store/chatStore.ts | 13 ++++--------- test/unit/store/chatStore.test.ts | 4 +--- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index c97734fc0..e9ed23ca0 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -146,9 +146,7 @@ def _on_request_usage(self, payload: dict[str, Any]) -> Any: request_usage = payload.get("request_usage") or {} step_usage = payload.get("step_usage") or {} request_tokens = int(request_usage.get("total_tokens") or 0) - # The task lock may already be gone when a model request completes - # after the user stopped the task; CAMEL swallows exceptions raised - # here, which would also silently skip the chained user callback. + # Lock may be gone if the task was stopped mid-request. task_lock = get_task_lock_if_exists(self.api_task_id) if request_tokens > 0 and task_lock is not None: _schedule_async_task( @@ -236,9 +234,7 @@ def _send_agent_deactivate(self, message: str, tokens: int) -> None: """ if self._camel_has_request_usage: tokens = 0 - # The lock is gone when the task was stopped while the model call was - # in flight; the pre-refactor inline enqueues used the lock captured - # at step start, so a missing lock must not fail the whole step. + # A missing lock (task stopped mid-step) must not fail the step. task_lock = get_task_lock_if_exists(self.api_task_id) if task_lock is None: logger.warning( diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 280c317a1..64d206594 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -95,10 +95,8 @@ const hasApiCode = (value: unknown, code: string) => let _host: AppHost | null = null; -// request_usage tokens for the step currently running on each agent, keyed -// by `${taskId}:${agentId}`. Once request-level reporting is active the -// backend zeroes deactivate_agent.tokens, so this preserves the per-step -// "did this agent actually generate a response" signal. +// Per-step request_usage tokens keyed by `${taskId}:${agentId}`; needed +// because deactivate_agent.tokens is zeroed under request-level reporting. const requestUsageStepTokens = new Map(); export function injectHost(host: AppHost | null): void { @@ -2784,11 +2782,8 @@ const chatStore = (initial?: Partial) => // and tokens are used (indicating actual response generation, not just classification) const isQuestionConfirmAgent = agentMessages.data.agent_name === 'question_confirm_agent'; - // deactivate_agent.tokens is zeroed once request-level usage - // reporting is active, so fall back to the tokens this agent's - // current step reported via request_usage. Keeping the check - // per-step (not the task's accumulated total) is what lets an - // errored/empty step be told apart from a real reply. + // Per-step tokens (not the task total) so an errored/empty + // step is not mistaken for a real reply. const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`; const stepTokens = agentMessages.data.tokens || diff --git a/test/unit/store/chatStore.test.ts b/test/unit/store/chatStore.test.ts index 2a37d057b..948502516 100644 --- a/test/unit/store/chatStore.test.ts +++ b/test/unit/store/chatStore.test.ts @@ -1001,9 +1001,7 @@ describe('ChatStore - Core Functionality', () => { }); describe('SSE request usage events', () => { - // The file-level beforeEach only clears calls (vi.clearAllMocks), so the - // event-pushing implementation installed below would leak into every - // later startTask test without this reset. + // clearAllMocks doesn't reset implementations; avoid leaking into later tests. afterEach(() => { vi.mocked(fetchEventSource).mockReset(); }); From 9c4d0e43e6af484d5285ac4f76290425995c5773 Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Tue, 14 Jul 2026 00:06:33 +0200 Subject: [PATCH 10/11] fix: address codex audit findings - chatStore: consume request_usage step tokens before the taskAssigning early-returns and sweep leftovers on the end event, so entries for hidden agents no longer accumulate in the module-level map - agent_model: allow opting out of the stream_options injection via extra_params stream_options=false for OpenAI-compatible endpoints that reject the parameter Co-Authored-By: Claude Fable 5 --- backend/app/agent/agent_model.py | 6 +++++- src/store/chatStore.ts | 27 +++++++++++++++++++++------ 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/backend/app/agent/agent_model.py b/backend/app/agent/agent_model.py index 4f9a2f748..d352cd91c 100644 --- a/backend/app/agent/agent_model.py +++ b/backend/app/agent/agent_model.py @@ -237,7 +237,11 @@ def build_model(force_refresh: bool = False): # Ensure streaming steps still report token usage. OpenAI-family # providers omit usage from streamed responses unless include_usage # is set, which would otherwise make request-level accounting count 0. - if model_config.get("stream") and ( + # `stream_options: false` in extra_params opts out entirely, for + # endpoints that reject the parameter (e.g. older vLLM/Azure). + if model_config.get("stream_options") is False: + model_config.pop("stream_options") + elif model_config.get("stream") and ( effective_config["model_platform"].lower() not in _NATIVE_STREAM_USAGE_PLATFORMS ): diff --git a/src/store/chatStore.ts b/src/store/chatStore.ts index 64d206594..fdd66ad6f 100644 --- a/src/store/chatStore.ts +++ b/src/store/chatStore.ts @@ -99,6 +99,14 @@ let _host: AppHost | null = null; // because deactivate_agent.tokens is zeroed under request-level reporting. const requestUsageStepTokens = new Map(); +const clearRequestUsageStepTokens = (taskId: string) => { + for (const key of requestUsageStepTokens.keys()) { + if (key.startsWith(`${taskId}:`)) { + requestUsageStepTokens.delete(key); + } + } +}; + export function injectHost(host: AppHost | null): void { _host = host; } @@ -2703,6 +2711,18 @@ const chatStore = (initial?: Partial) => if (agentMessages.data.tokens) { addTokens(currentTaskId, agentMessages.data.tokens); } + // Consume the step's request_usage tokens before any early + // return below, so entries are cleaned up even for agents that + // never appear in taskAssigning. + let stepTokens = 0; + if (agentMessages.step === AgentStep.DEACTIVATE_AGENT) { + const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`; + stepTokens = + agentMessages.data.tokens || + requestUsageStepTokens.get(stepKey) || + 0; + requestUsageStepTokens.delete(stepKey); + } const { state, agent_id, process_task_id } = agentMessages.data; if (!state && !agent_id && !process_task_id) return; const agentIndex = taskAssigning.findIndex( @@ -2784,12 +2804,6 @@ const chatStore = (initial?: Partial) => agentMessages.data.agent_name === 'question_confirm_agent'; // Per-step tokens (not the task total) so an errored/empty // step is not mistaken for a real reply. - const stepKey = `${currentTaskId}:${agentMessages.data.agent_id}`; - const stepTokens = - agentMessages.data.tokens || - requestUsageStepTokens.get(stepKey) || - 0; - requestUsageStepTokens.delete(stepKey); const hasTokens = stepTokens > 0; const isNotClassificationAnswer = agentMessages.data.message && @@ -3517,6 +3531,7 @@ const chatStore = (initial?: Partial) => if (endTokens > 0 && getTokens(currentTaskId) === 0) { addTokens(currentTaskId, endTokens); } + clearRequestUsageStepTokens(currentTaskId); if (!currentTaskId || !tasks[currentTaskId]) return; const endMessage = resolveEndMessageText( From 78a62e96142f3c93e36916b213249fa1d21ac561 Mon Sep 17 00:00:00 2001 From: Xiangyu Shi Date: Tue, 14 Jul 2026 11:49:01 +0200 Subject: [PATCH 11/11] Merge remote-tracking branch 'origin/main' into update_token_logic --- backend/app/agent/listen_chat_agent.py | 20 ++++++- .../app/agent/toolkit/screenshot_toolkit.py | 5 +- src/components/ProjectPageSidebar/index.tsx | 59 ++++++++++++++----- src/store/chatStore.ts | 13 ++++ src/style/markdown-styles.css | 10 +++- 5 files changed, 90 insertions(+), 17 deletions(-) diff --git a/backend/app/agent/listen_chat_agent.py b/backend/app/agent/listen_chat_agent.py index e9ed23ca0..d4cf64ebf 100644 --- a/backend/app/agent/listen_chat_agent.py +++ b/backend/app/agent/listen_chat_agent.py @@ -37,6 +37,7 @@ from camel.types.agents import ToolCallingRecord from pydantic import BaseModel +from app.component.environment import env from app.service.task import ( Action, ActionActivateAgentData, @@ -55,6 +56,21 @@ logger = logging.getLogger("agent") +# Default 30 minutes; long agent turns (e.g. writing many chapters in one +# run) can legitimately exceed it, so allow tuning without a rebuild. +# A non-positive value disables the per-step timeout entirely. +def default_step_timeout() -> float | None: + raw = env("AGENT_STEP_TIMEOUT_SECONDS", "1800") + try: + value = float(raw) + except (TypeError, ValueError): + logger.warning( + "Invalid AGENT_STEP_TIMEOUT_SECONDS value %r; using 1800", raw + ) + return 1800.0 + return value if value > 0 else None + + class ListenChatAgent(ChatAgent): _cdp_clone_lock = ( threading.Lock() @@ -102,7 +118,7 @@ def __init__( pause_event: asyncio.Event | None = None, prune_tool_calls_from_memory: bool = False, enable_snapshot_clean: bool = False, - step_timeout: float | None = 1800, # 30 minutes + step_timeout: float | None = None, model_reload_callback: ( Callable[[], BaseModelBackend | ModelManager] | None ) = None, @@ -114,6 +130,8 @@ def __init__( if self._camel_has_request_usage: kwargs["on_request_usage"] = self._on_request_usage + if step_timeout is None: + step_timeout = default_step_timeout() super().__init__( system_message=system_message, model=model, diff --git a/backend/app/agent/toolkit/screenshot_toolkit.py b/backend/app/agent/toolkit/screenshot_toolkit.py index 77f6aa29b..43f4459ed 100644 --- a/backend/app/agent/toolkit/screenshot_toolkit.py +++ b/backend/app/agent/toolkit/screenshot_toolkit.py @@ -20,6 +20,7 @@ from camel.toolkits import ScreenshotToolkit as BaseScreenshotToolkit from PIL import Image +from app.agent.listen_chat_agent import default_step_timeout from app.agent.toolkit.abstract_toolkit import AbstractToolkit from app.component.environment import env from app.utils.listen.toolkit_listen import auto_listen_toolkit @@ -86,7 +87,9 @@ def read_image( tools=[], toolkits_to_register_agent=None, external_tools=None, - step_timeout=getattr(self.agent, "step_timeout", 1800), + step_timeout=getattr( + self.agent, "step_timeout", default_step_timeout() + ), ) response = vision_agent.step(message) if getattr(response, "msg", None) is not None: diff --git a/src/components/ProjectPageSidebar/index.tsx b/src/components/ProjectPageSidebar/index.tsx index f8506721c..5d2d1d3c4 100644 --- a/src/components/ProjectPageSidebar/index.tsx +++ b/src/components/ProjectPageSidebar/index.tsx @@ -67,6 +67,8 @@ export interface ProjectPageSidebarProps { className?: string; } +let didAttemptBootSessionResume = false; + export default function ProjectPageSidebar({ chatStore: _chatStore, className, @@ -314,6 +316,35 @@ export default function ProjectPageSidebar({ ] ); + // Boot-time session resume: reopen the last visited Project (the way an + // editor reopens its last workspace) instead of landing on the empty home + // tab. One-shot per renderer boot (launch or reload; the flag is module + // scoped so sidebar remounts within a session never re-trigger it), and + // only from the pristine boot state (default tab, no active Project), so + // deliberately navigating home later is never hijacked. Uses the same + // path as clicking the Project in the sidebar. + useEffect(() => { + if (didAttemptBootSessionResume) return; + didAttemptBootSessionResume = true; + + if (activeWorkspaceTab !== 'workforce') return; + if (projectStore.activeProjectId) return; + if (!activeSpaceId) return; + const lastVisitedId = + useSpaceStore.getState().lastVisitedProjectBySpace[activeSpaceId]; + if (!lastVisitedId) return; + const lastVisitedMeta = projectMetasForActiveSpace.find( + (project) => project.id === lastVisitedId + ); + if (!lastVisitedMeta || !shouldShowProjectInNavList(lastVisitedMeta)) { + return; + } + void selectProject(lastVisitedId); + // One-shot boot effect: later changes to these values must not + // re-trigger a resume. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + const navProjects = useMemo( () => projectMetasForActiveSpace @@ -641,14 +672,14 @@ export default function ProjectPageSidebar({