feat: release 1.1.0 with responsive task control

This commit is contained in:
chenmingxuan committed 2026-09-24 03:31:17 +08:00
1 parent 057f0c3050
commit bfcd828fa8
37 files changed
+1601 -151

No files matched your search

+15 -1
View File
@@ -18,7 +18,7 @@ from pathlib import Path
from typing import Annotated, Any
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
from langchain_core.tools import BaseTool, StructuredTool
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, ValidationError
class _CyFunctionDetectorMeta(type):
@@ -89,6 +89,20 @@ class LogAnalyzerNode:
original_coroutine = tool.coroutine
async def cached_coroutine(**kwargs):
# The injection runner calls the coroutine directly, bypassing
# StructuredTool's normal Pydantic argument coercion. Normalize
# model-supplied numeric strings before caching or searching logs.
if tool.name in ("read_logs", "search_logs") and tool.args_schema:
try:
validated = tool.args_schema.model_validate(kwargs)
except ValidationError as exc:
return ToolFailure(f"Invalid {tool.name} arguments: {exc}")
kwargs.update(validated.model_dump(exclude_unset=True))
if kwargs.get("lines") is None:
kwargs["lines"] = 200 if tool.name == "read_logs" else 10000
if tool.name == "search_logs" and kwargs.get("context_lines") is None:
kwargs["context_lines"] = 0
if tool.name == "read_logs":
# Intercept read_logs to cache raw logs
lines = kwargs.get("lines", 200)
+58 -2
View File
@@ -13,6 +13,7 @@
# limitations under the License.
import json
from collections.abc import Awaitable
from typing import Any
from uuid import uuid4
@@ -32,6 +33,7 @@ from artemis.graph.state import State
from artemis.graph.visibility import strict_state
from artemis.mcp.action_specs import OPERATOR_SHELL_ORDER, operator_shell_tool
from artemis.llm.usage import usage_from_message
from artemis.runtime.task_control import TaskControl
from artemis.services.llm import acomplete, get_llm, invoke_llm_with_timeout_message
from artemis.tools.command_tool import (
analyze_task_output_wrapper,
@@ -78,6 +80,8 @@ DEFERRING_TOOLS = {
"ask_explorer",
}
INTERRUPTIBLE_HELPERS = {"ask_diagnoser", "ask_explorer", "video_analyzer", "analyze_task_output"}
# Bare key names press_key accepts (case-insensitive, optional KEYCODE_ prefix).
SUPPORTED_PRESS_KEYS = ("ENTER", "BACK", "HOME", "APP_SWITCH")
@@ -478,6 +482,16 @@ class OperatorNode:
except Exception as e:
logger.error(f"Failed to stage transcript turn: {e}")
async def _await_or_pause[T](self, operation: Awaitable[T]) -> tuple[bool, T | None]:
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl):
return await control.run_interruptibly(
operation,
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
)
return False, await operation
async def _invoke_llm_loop(
self,
base_llm,
@@ -496,6 +510,13 @@ class OperatorNode:
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
for iteration in range(max_iterations):
control = getattr(self.ctx, "task_control", None)
if isinstance(control, TaskControl) and await control.checkpoint(
on_pause=getattr(self.ctx, "on_task_pause", None),
on_resume=getattr(self.ctx, "on_task_resume", None),
):
# The device may have changed while another task held its lease.
return None, None, None, False
if iteration == max_iterations - 1:
logger.warning(
"Operator has reached the maximum number of tool calls. Adding reminder."
@@ -512,7 +533,12 @@ class OperatorNode:
)
bound_llm = base_llm.bind_tools(tools=traced_tools)
response = await invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
interrupted, response = await self._await_or_pause(
invoke_llm_with_timeout_message(acomplete(bound_llm, current_messages))
)
if interrupted:
return None, None, None, False
assert response is not None
usage = usage_from_message(response)
# The ledger's compaction thresholds read the operator's own
@@ -655,12 +681,42 @@ class OperatorNode:
"\n".join(native_thoughts) if native_thoughts else None
)
result_obj = await invoke_tool_with_injection(
tool_call = invoke_tool_with_injection(
tool=tool_to_run,
args=args,
tool_call_id=tc["id"],
state=state,
)
if normalize_name(tool_name) in INTERRUPTIBLE_HELPERS:
interrupted, result_obj = await self._await_or_pause(tool_call)
if interrupted:
span.result = "任务暂停;诊断已中断,恢复后重新观察屏幕"
results.append((span.result, "error"))
for remaining in other_calls[len(results) :]:
results.append(("任务暂停,未执行该工具", "error"))
for completed, (content, status) in zip(
other_calls, results
):
current_messages.extend(
tool_result_messages(
completed["id"],
content,
name=normalize_name(completed["name"]),
status=status,
llm=base_llm,
)
)
for action in action_calls:
current_messages.append(
ToolMessage(
tool_call_id=action["id"],
content="任务暂停,未执行屏幕操作",
status="error",
)
)
return None, None, None, False
else:
result_obj = await tool_call
content = get_tool_result_content(result_obj)
# Failure is structural (a ToolMessage status or a