feat: release 1.1.0 with responsive task control
This commit is contained in:
1 parent
057f0c3050
commit
bfcd828fa8
37 files changed
+1601
-151
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Cooperative, session-scoped task guidance and device lease parking."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -48,22 +49,39 @@ def request_resume(session_id: str) -> None:
|
||||
|
||||
def add_instruction(
|
||||
session_id: str, instruction: str, *, release_loop: bool = False, source: str = "user"
|
||||
) -> None:
|
||||
) -> dict:
|
||||
if not instruction.strip() or len(instruction) > 10000:
|
||||
raise ValueError("指导内容必须在 1–10000 字之间")
|
||||
directory = control_dir(session_id) / "instructions"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Unique names make simultaneous web and MCP submissions additive, never last-write-wins.
|
||||
path = directory / f"{time.time_ns():020d}-{uuid.uuid4().hex}.json"
|
||||
_write_atomic(
|
||||
path,
|
||||
{
|
||||
"instruction": instruction.strip(),
|
||||
"release_loop": bool(release_loop),
|
||||
"source": source,
|
||||
"timestamp": time.time(),
|
||||
},
|
||||
)
|
||||
record = {
|
||||
"id": path.stem,
|
||||
"instruction": instruction.strip(),
|
||||
"release_loop": bool(release_loop),
|
||||
"source": source,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
_write_atomic(path, record)
|
||||
return record
|
||||
|
||||
|
||||
def list_instructions(session_id: str) -> list[dict]:
|
||||
"""Show submissions separately from guidance the runner actually consumed."""
|
||||
directory = control_dir(session_id) / "instructions"
|
||||
records = []
|
||||
for folder, status in ((directory, "pending"), (directory / "consumed", "consumed")):
|
||||
for path in folder.glob("*.json") if folder.exists() else []:
|
||||
try:
|
||||
record = json.loads(path.read_text(encoding="utf-8"))
|
||||
if isinstance(record, dict) and record.get("instruction"):
|
||||
records.append(
|
||||
{**record, "id": record.get("id") or path.stem, "status": status}
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return sorted(records, key=lambda record: (record.get("timestamp", 0), record["id"]))
|
||||
|
||||
|
||||
def consume_instructions(base_dir: str | Path) -> dict | None:
|
||||
@@ -73,8 +91,11 @@ def consume_instructions(base_dir: str | Path) -> dict | None:
|
||||
try:
|
||||
claimed = path.with_suffix(".reading")
|
||||
path.rename(claimed)
|
||||
found.append(json.loads(claimed.read_text(encoding="utf-8")))
|
||||
record = json.loads(claimed.read_text(encoding="utf-8"))
|
||||
record["consumed_at"] = time.time()
|
||||
_write_atomic(directory / "consumed" / path.name, record)
|
||||
claimed.unlink()
|
||||
found.append(record)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
legacy = Path(base_dir) / "injected_instruction.json"
|
||||
@@ -151,6 +172,31 @@ class TaskControl:
|
||||
self._state("running")
|
||||
return True
|
||||
|
||||
async def run_interruptibly[T](
|
||||
self, operation: Awaitable[T], *, on_pause=None, on_resume=None
|
||||
) -> tuple[bool, T | None]:
|
||||
"""Park a task during a long model/helper call, then discard its stale result.
|
||||
|
||||
Only use for operations whose cancellation cannot leave a device action
|
||||
half executed. The caller must return to fresh perception after resume.
|
||||
"""
|
||||
running = asyncio.ensure_future(operation)
|
||||
pause = self.directory / "pause.request"
|
||||
try:
|
||||
while True:
|
||||
if pause.exists():
|
||||
running.cancel()
|
||||
await asyncio.gather(running, return_exceptions=True)
|
||||
await self.checkpoint(on_pause=on_pause, on_resume=on_resume)
|
||||
return True, None
|
||||
if running.done():
|
||||
return False, running.result()
|
||||
await asyncio.wait({running}, timeout=0.2)
|
||||
finally:
|
||||
if not running.done():
|
||||
running.cancel()
|
||||
await asyncio.gather(running, return_exceptions=True)
|
||||
|
||||
def release(self) -> None:
|
||||
self.device_lock.release()
|
||||
(self.directory / "task_control_state.json").unlink(missing_ok=True)
|
||||
Reference in new issue
Block a user