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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 69 additions & 68 deletions backend/app/component/error_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,37 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import ast
import json
import re


def _parse_dict(text: str) -> dict | None:
"""Parse a dict string (JSON double-quotes or Python single-quotes)."""
for loader in (json.loads, ast.literal_eval):
try:
result = loader(text)
if isinstance(result, dict):
return result
except Exception: # nosec B112
continue
return None


def _extract_from_dict(d: dict) -> tuple[str | None, str | None, dict]:
"""Pull message / code / error_obj from an OpenAI-shaped dict."""
err = d.get("error") or d
if not isinstance(err, dict):
return None, None, {}
error_obj = {
"message": err.get("message"),
"type": err.get("type"),
"param": err.get("param"),
"code": err.get("code"),
}
return err.get("message"), err.get("code"), error_obj


def normalize_error_to_openai_format(
exception: Exception,
) -> tuple[str, str | None, dict | None]:
Expand All @@ -29,76 +56,50 @@ def normalize_error_to_openai_format(
tuple: (message, error_code, error_object)
"""
raw_msg = str(exception)
error_obj = None
error_code = None
message = raw_msg

# Match "Error code: <code> - {json}"
# 1) Structured attributes (OpenAI SDK exceptions expose .body)
body = getattr(exception, "body", None)
if isinstance(body, dict):
msg, code, obj = _extract_from_dict(body)
if msg:
return msg, code, obj

# 2) Parse "Error code: <status> - {dict}" from str(exception)
m = re.search(r"Error code:\s*(\d+)\s*-\s*(\{.*\})", raw_msg, re.DOTALL)
if m:
error_code = m.group(1)
try:
parsed = json.loads(m.group(2))
err = parsed.get("error") or parsed
if isinstance(err, dict):
error_obj = {
"message": err.get("message"),
"type": err.get("type"),
"param": err.get("param"),
"code": err.get("code"),
}
if err.get("message"):
message = err.get("message")
if err.get("code"):
error_code = err.get("code")
except Exception:
pass
parsed = _parse_dict(m.group(2))
if parsed:
msg, code, obj = _extract_from_dict(parsed)
if msg:
return msg, code or m.group(1), obj

# Heuristics if not parsed
if error_obj is None:
lower = raw_msg.lower()
if (
"invalid_api_key" in lower
or "incorrect api key" in lower
or "unauthorized" in lower
or " 401" in lower
):
error_code = "invalid_api_key"
message = "Invalid key. Validation failed."
error_obj = {
"message": message,
"type": "invalid_request_error",
"param": None,
"code": "invalid_api_key",
}
elif (
"model_not_found" in lower
or "does not exist" in lower
or " 404" in lower
):
error_code = "model_not_found"
message = "Invalid model name. Validation failed."
error_obj = {
"message": message,
"type": "invalid_request_error",
"param": None,
"code": "model_not_found",
}
elif (
"insufficient_quota" in lower
or "quota" in lower
or " 429" in lower
):
error_code = "insufficient_quota"
message = (
"You exceeded your current quota, "
"please check your plan and billing details."
)
error_obj = {
"message": message,
"type": "insufficient_quota",
"param": None,
"code": "insufficient_quota",
}
# 3) Keyword heuristics — classify the error but preserve original text
lower = raw_msg.lower()
if (
"invalid_api_key" in lower
or "incorrect api key" in lower
or "unauthorized" in lower
or " 401" in lower
):
code = "invalid_api_key"
elif (
"model_not_found" in lower
or "does not exist" in lower
or " 404" in lower
):
code = "model_not_found"
elif "insufficient_quota" in lower or "quota" in lower or " 429" in lower:
code = "insufficient_quota"
else:
return raw_msg, None, None

return message, error_code, error_obj
return (
raw_msg,
code,
{
"message": raw_msg,
"type": "invalid_request_error",
"param": None,
"code": code,
},
)
50 changes: 38 additions & 12 deletions backend/app/service/chat_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
from app.agent.toolkit.skill_toolkit import SkillToolkit
from app.agent.toolkit.terminal_toolkit import TerminalToolkit
from app.agent.tools import get_mcp_tools, get_toolkits
from app.component.error_format import normalize_error_to_openai_format
from app.model.chat import Chat, NewAgent, Status, TaskContent, sse_json
from app.service.task import (
Action,
Expand Down Expand Up @@ -553,13 +554,14 @@ async def step_solve(options: Chat, request: Request, task_lock: TaskLock):
)
except Exception as e:
logger.error(f"Error generating simple answer: {e}")
message, error_code, _ = (
normalize_error_to_openai_format(e)
)
yield sse_json(
"wait_confirm",
"error",
{
"content": "I encountered an error"
" while processing "
"your question.",
"question": question,
"message": message,
"error_code": error_code,
},
)

Expand Down Expand Up @@ -1259,13 +1261,14 @@ async def run_decomposition():
"Error generating simple "
f"answer in multi-turn: {e}"
)
message, error_code, _ = (
normalize_error_to_openai_format(e)
)
yield sse_json(
"wait_confirm",
"error",
{
"content": "I encountered an error "
"while processing your "
"question.",
"question": new_task_content,
"message": message,
"error_code": error_code,
},
)

Expand Down Expand Up @@ -1598,6 +1601,21 @@ def on_stream_text(chunk):
},
)

elif item.action == Action.error:
logger.error(
"[LIFECYCLE] ERROR action received "
f"for project {options.project_id}, "
f"task {options.task_id}: "
f"{item.data.get('message', 'Unknown error')}"
)
yield sse_json(
"error",
{
"message": item.data.get("message", "Unknown error"),
"error_code": item.data.get("error_code"),
},
)

elif item.action == Action.end:
logger.info("=" * 80)
logger.info(
Expand Down Expand Up @@ -1820,7 +1838,11 @@ def on_stream_text(chunk):
f"{item.action}: {e}",
exc_info=True,
)
yield sse_json("error", {"message": str(e)})
message, error_code, _ = normalize_error_to_openai_format(e)
yield sse_json(
"error",
{"message": message, "error_code": error_code},
)
if (
"workforce" in locals()
and workforce is not None
Expand All @@ -1834,7 +1856,11 @@ def on_stream_text(chunk):
f"{item.action}: {e}",
exc_info=True,
)
yield sse_json("error", {"message": str(e)})
message, error_code, _ = normalize_error_to_openai_format(e)
yield sse_json(
"error",
{"message": message, "error_code": error_code},
)
# Continue processing other items instead of breaking


Expand Down
7 changes: 7 additions & 0 deletions backend/app/service/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class Action(str, Enum):
resume = "resume" # user -> backend user take control
new_agent = "new_agent" # user -> backend
budget_not_enough = "budget_not_enough" # backend -> user
error = "error" # backend -> user (model/runtime error)
add_task = "add_task" # user -> backend
remove_task = "remove_task" # user -> backend
skip_task = "skip_task" # user -> backend
Expand Down Expand Up @@ -255,6 +256,11 @@ class ActionNewAgent(BaseModel):
custom_model_config: "AgentModelConfig | None" = None


class ActionErrorData(BaseModel):
action: Literal[Action.error] = Action.error
data: dict


class ActionBudgetNotEnough(BaseModel):
action: Literal[Action.budget_not_enough] = Action.budget_not_enough

Expand Down Expand Up @@ -303,6 +309,7 @@ class ActionSkipTaskData(BaseModel):
| ActionTakeControl
| ActionNewAgent
| ActionBudgetNotEnough
| ActionErrorData
| ActionAddTaskData
| ActionRemoveTaskData
| ActionSkipTaskData
Expand Down
23 changes: 23 additions & 0 deletions backend/app/utils/workforce.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@

from app.agent.listen_chat_agent import ListenChatAgent
from app.component import code
from app.component.error_format import normalize_error_to_openai_format
from app.exception.exception import UserException
from app.service.task import (
Action,
ActionAssignTaskData,
ActionEndData,
ActionErrorData,
ActionTaskStateData,
ActionTimeoutData,
get_camel_task,
Expand Down Expand Up @@ -260,6 +262,27 @@ async def eigent_start(self, subtasks: list[Task]):
exc_info=True,
)
self._state = WorkforceState.STOPPED
# Push error event to SSE queue so frontend receives notification
try:
task_lock = get_task_lock(self.api_task_id)
if task_lock is not None:
message, error_code, _ = normalize_error_to_openai_format(
e
)
await task_lock.put_queue(
ActionErrorData(
data={
"message": message,
"error_code": error_code,
},
)
)
except Exception as queue_err:
logger.error(
"[WF-LIFECYCLE] Failed to push error to SSE queue: "
f"{queue_err}",
exc_info=True,
)
raise
finally:
if self._state != WorkforceState.STOPPED:
Expand Down
5 changes: 4 additions & 1 deletion src/api/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ async function getProxyBaseURL() {
}

async function proxyFetchRequest(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
url: string,
data?: Record<string, any>,
customHeaders: Record<string, string> = {}
Expand Down Expand Up @@ -244,6 +244,9 @@ export const proxyFetchPost = (url: string, data?: any, headers?: any) =>
export const proxyFetchPut = (url: string, data?: any, headers?: any) =>
proxyFetchRequest('PUT', url, data, headers);

export const proxyFetchPatch = (url: string, data?: any, headers?: any) =>
proxyFetchRequest('PATCH', url, data, headers);

export const proxyFetchDelete = (url: string, data?: any, headers?: any) =>
proxyFetchRequest('DELETE', url, data, headers);

Expand Down
Loading
Loading