mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 02:15:06 +00:00
fix: better snapshot and gate
This commit is contained in:
1 parent
29f661f3c7
commit
3f88dc7b57
12 files changed
+449
-22
No files matched your search
@@ -886,6 +886,31 @@ class ToolExecutor:
|
||||
)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _advertisable_action_names(tools_dict: Dict) -> set:
|
||||
"""Action names a ``tools_dict`` would expose, mirroring pass 1 of
|
||||
:meth:`prepare_tools_for_llm`.
|
||||
|
||||
The model calls ACTION names (``run_code``), never tool names
|
||||
(``code_executor``), so a fallback built from ``tool["name"]`` names
|
||||
strings that cannot resolve — feeding the very retry loop the
|
||||
correctable error exists to break.
|
||||
"""
|
||||
names = set()
|
||||
for tool in (tools_dict or {}).values():
|
||||
tool = tool or {}
|
||||
if tool.get("name") == "api_tool":
|
||||
actions = (tool.get("config") or {}).get("actions") or {}
|
||||
actions = actions.values() if isinstance(actions, dict) else actions
|
||||
else:
|
||||
actions = tool.get("actions") or []
|
||||
for action in actions:
|
||||
if not isinstance(action, dict) or not action.get("active", True):
|
||||
continue
|
||||
if action.get("name"):
|
||||
names.add(str(action["name"]))
|
||||
return names
|
||||
|
||||
def _available_tool_names(self, tools_dict: Dict, exclude: Optional[str] = None) -> str:
|
||||
"""Render the names the model can actually call, for a correctable error.
|
||||
|
||||
@@ -909,9 +934,7 @@ class ToolExecutor:
|
||||
if not scope or tool_id in scope
|
||||
)
|
||||
if not names:
|
||||
names = sorted(
|
||||
{(tool or {}).get("name") or tool_id for tool_id, tool in (tools_dict or {}).items()}
|
||||
)
|
||||
names = sorted(self._advertisable_action_names(tools_dict or {}))
|
||||
names = [str(name) for name in names if name != exclude]
|
||||
if len(names) > self.MAX_ADVERTISED_TOOL_NAMES:
|
||||
hidden = len(names) - self.MAX_ADVERTISED_TOOL_NAMES
|
||||
|
||||
@@ -27,13 +27,17 @@ config = context.config
|
||||
if settings.POSTGRES_URI:
|
||||
config.set_main_option("sqlalchemy.url", settings.POSTGRES_URI)
|
||||
|
||||
if config.config_file_name is not None:
|
||||
# ``disable_existing_loggers`` defaults to True, which would switch off
|
||||
# every ``application.*`` logger already imported by the time migrations
|
||||
# run. ``app.py`` calls ``setup_logging()`` (line 16) BEFORE
|
||||
# ``ensure_database_ready()`` (line 58), so the web tier never
|
||||
# re-enables them and loses all application logging for the life of the
|
||||
# process — on exactly the boot where a schema upgrade happened.
|
||||
if config.config_file_name is not None and config.attributes.get("configure_logger", True):
|
||||
# ``fileConfig`` reconfigures the ROOT logger from ``[logger_root]``
|
||||
# (level WARNING, a stderr handler), which drops every ``application.*``
|
||||
# INFO record, detaches the OTLP handler and the request/trace filter that
|
||||
# ``setup_logging()`` installed, and cannot be undone by
|
||||
# ``disable_existing_loggers=False`` — that flag only governs whether
|
||||
# existing loggers are switched off. ``app.py`` calls ``setup_logging()``
|
||||
# before ``ensure_database_ready()``, so an in-process migration would
|
||||
# silence the web tier for the life of the process, on exactly the boot
|
||||
# where a schema upgrade happened. In-process callers opt out via
|
||||
# ``cfg.attributes``; the alembic CLI still gets its configured logging.
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
|
||||
|
||||
@@ -1437,6 +1437,25 @@ class LLMHandler(ABC):
|
||||
tool_calls[call.index] = call
|
||||
else:
|
||||
existing = tool_calls[call.index]
|
||||
# Decide BEFORE the id/name overwrite below: a gateway
|
||||
# that reuses one index for sequential DISTINCT calls
|
||||
# is indistinguishable from a restatement once the
|
||||
# identity is gone. Restatement frames carry neither
|
||||
# id nor name, so a differing one means a new call.
|
||||
is_new_call = bool(
|
||||
(call.id and existing.id and call.id != existing.id)
|
||||
or (call.name and existing.name and call.name != existing.name)
|
||||
)
|
||||
if is_new_call:
|
||||
logger.warning(
|
||||
"tool_call_index_reused_by_distinct_call",
|
||||
extra={
|
||||
"index": call.index,
|
||||
"previous_call": f"{existing.id}/{existing.name}",
|
||||
"incoming_call": f"{call.id}/{call.name}",
|
||||
"dropped_prefix": str(existing.arguments)[:120],
|
||||
},
|
||||
)
|
||||
if call.id:
|
||||
existing.id = call.id
|
||||
if call.name:
|
||||
@@ -1447,8 +1466,11 @@ class LLMHandler(ABC):
|
||||
elif isinstance(existing.arguments, str) and isinstance(
|
||||
call.arguments, str
|
||||
):
|
||||
if _is_restated_payload(
|
||||
existing.arguments, call.arguments
|
||||
if (
|
||||
_is_restated_payload(
|
||||
existing.arguments, call.arguments
|
||||
)
|
||||
and not is_new_call
|
||||
):
|
||||
# Some OpenAI-compatible gateways restate
|
||||
# the WHOLE argument payload on the finish
|
||||
|
||||
@@ -400,6 +400,10 @@ def _run_migrations(log: logging.Logger) -> None:
|
||||
|
||||
cfg = Config(str(alembic_ini))
|
||||
cfg.set_main_option("script_location", str(alembic_ini.parent / "alembic"))
|
||||
# We migrate in-process, after setup_logging() has configured the root
|
||||
# logger. env.py honours this by skipping fileConfig, which would
|
||||
# otherwise replace root's handlers and level for the rest of the process.
|
||||
cfg.attributes["configure_logger"] = False
|
||||
|
||||
# Cheap pre-check: if we're already at head, say so explicitly.
|
||||
try:
|
||||
|
||||
@@ -176,7 +176,12 @@ class PendingToolStateRepository:
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
def release_claim(self, conversation_id: str, user_id: str) -> bool:
|
||||
def release_claim(
|
||||
self,
|
||||
conversation_id: str,
|
||||
user_id: str,
|
||||
ttl_extension_seconds: int = PENDING_STATE_TTL_SECONDS,
|
||||
) -> bool:
|
||||
"""Flip a ``resuming`` row back to ``pending`` so a retry can claim it.
|
||||
|
||||
The inverse of :meth:`mark_resuming`, for the process that TOOK the
|
||||
@@ -187,8 +192,16 @@ class PendingToolStateRepository:
|
||||
:meth:`load_state` only sees ``pending`` rows.
|
||||
|
||||
Deliberately narrow: it matches on ``status = 'resuming'`` only, so it
|
||||
can never resurrect a row another request has since deleted, and it
|
||||
does not touch ``expires_at`` (the TTL is unrelated to the claim).
|
||||
can never resurrect a row another request has since deleted.
|
||||
|
||||
Extends ``expires_at`` like :meth:`revert_stale_resuming` does. A
|
||||
resume claimed near the end of the TTL that fails minutes later would
|
||||
otherwise be handed back already expired: ``load_state`` and
|
||||
``mark_resuming`` both gate on ``expires_at > clock_timestamp()``, and
|
||||
flipping to ``pending`` also hides the row from
|
||||
:meth:`revert_stale_resuming`, which matches ``resuming`` only — so
|
||||
releasing the claim would REMOVE the rescue instead of speeding it up.
|
||||
``GREATEST`` so a failed resume can never shorten a healthy TTL.
|
||||
|
||||
Returns:
|
||||
True when a claim was released.
|
||||
@@ -197,13 +210,22 @@ class PendingToolStateRepository:
|
||||
text(
|
||||
"""
|
||||
UPDATE pending_tool_state
|
||||
SET status = 'pending', resumed_at = NULL
|
||||
SET status = 'pending',
|
||||
resumed_at = NULL,
|
||||
expires_at = GREATEST(
|
||||
expires_at,
|
||||
clock_timestamp() + make_interval(secs => :ttl)
|
||||
)
|
||||
WHERE conversation_id = CAST(:conv_id AS uuid)
|
||||
AND user_id = :user_id
|
||||
AND status = 'resuming'
|
||||
"""
|
||||
),
|
||||
{"conv_id": conversation_id, "user_id": user_id},
|
||||
{
|
||||
"conv_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"ttl": ttl_extension_seconds,
|
||||
},
|
||||
)
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
Reference in new issue
Block a user