Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f458205d | ||
|
|
4465a2e199 | ||
|
|
c818a09a49 |
No files matched your search
+1
-1
@@ -7,7 +7,7 @@ OPENAI_MODEL=mimo-v2.6-flash
|
||||
# 单次模型 HTTP 请求超时(秒)
|
||||
OPENAI_TIMEOUT_SECONDS=300
|
||||
# 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=25
|
||||
ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120
|
||||
# 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。
|
||||
OPENAI_MAX_RETRIES=5
|
||||
OPENAI_RETRY_INTERVAL_SECONDS=10
|
||||
|
||||
@@ -64,7 +64,12 @@ def get_model_settings() -> dict:
|
||||
"max_retries": settings.OPENAI_MAX_RETRIES,
|
||||
"retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS,
|
||||
"reasoning_effort": settings.OPENAI_REASONING_EFFORT,
|
||||
"model_presets": ["gpt-5.6-luna", "mimo-v2.6-flash"],
|
||||
"model_presets": [
|
||||
"gpt-5.6-luna",
|
||||
"gpt-6-luna",
|
||||
"mimo-v2.6-flash",
|
||||
"mimo-v2.6-pro",
|
||||
],
|
||||
"env_path": str(get_env_file()),
|
||||
}
|
||||
|
||||
|
||||
@@ -632,15 +632,17 @@ def session_videos(report: dict) -> list[tuple[Path, str]]:
|
||||
if not candidate:
|
||||
return []
|
||||
source = Path(str(candidate)).resolve()
|
||||
session_root = (TRACES_PATH / identifier(report["id"])).resolve()
|
||||
if not source.is_relative_to(session_root):
|
||||
trace_root = TRACES_PATH.resolve()
|
||||
# Workers save recordings in their finalized web_<time>_<id>_PASS/FAIL
|
||||
# folder. The recording row already ties that path to this session.
|
||||
if not source.is_relative_to(trace_root):
|
||||
return []
|
||||
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||
# in its parent, with any later segments in sibling resume folders.
|
||||
directory = source.parent
|
||||
if directory.name.startswith("resume_"):
|
||||
directory = directory.parent
|
||||
if not directory.is_dir() or not directory.is_relative_to(session_root):
|
||||
if not directory.is_dir() or not directory.is_relative_to(trace_root):
|
||||
return []
|
||||
paths = [
|
||||
p
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.2.2",
|
||||
"version": "1.2.3",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
+13
-2
@@ -7,7 +7,8 @@ import { CapabilitySettingsComponent } from './capability-settings.component';
|
||||
describe('持久化模型与功能设置', () => {
|
||||
let http: HttpTestingController;
|
||||
const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash',
|
||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', env_path: '/app/.env' };
|
||||
api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high',
|
||||
model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' };
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] });
|
||||
http = TestBed.inject(HttpTestingController);
|
||||
@@ -44,13 +45,23 @@ describe('持久化模型与功能设置', () => {
|
||||
await fixture.whenStable();
|
||||
const root: HTMLElement = fixture.nativeElement;
|
||||
const presets = root.querySelectorAll<HTMLButtonElement>('.model-presets button');
|
||||
expect(presets.length).toBe(2);
|
||||
expect(Array.from(presets, button => button.textContent?.trim())).toEqual([
|
||||
'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'
|
||||
]);
|
||||
presets[0].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
const input = root.querySelector<HTMLInputElement>('#model-name')!;
|
||||
expect(input.value).toBe('gpt-5.6-luna');
|
||||
expect(presets[0].getAttribute('aria-pressed')).toBe('true');
|
||||
presets[1].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(input.value).toBe('gpt-6-luna');
|
||||
presets[3].click();
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
expect(input.value).toBe('mimo-v2.6-pro');
|
||||
expect(input.getAttribute('list')).toBeNull();
|
||||
expect(input.autocomplete).toBe('off');
|
||||
input.value = 'custom/model-v2';
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ModelSettings {
|
||||
max_retries: number;
|
||||
retry_interval_seconds: number;
|
||||
reasoning_effort: string;
|
||||
model_presets: string[];
|
||||
env_path: string;
|
||||
message?: string;
|
||||
}
|
||||
@@ -90,7 +91,7 @@ interface ModelSettings {
|
||||
export class ModelSettingsComponent implements OnInit {
|
||||
private http = inject(HttpClient);
|
||||
saved = output<void>();
|
||||
readonly modelPresets = ['gpt-5.6-luna', 'mimo-v2.6-flash'];
|
||||
modelPresets: string[] = [];
|
||||
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
|
||||
busy = signal<'save' | 'test' | null>(null);
|
||||
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
||||
@@ -112,6 +113,7 @@ export class ModelSettingsComponent implements OnInit {
|
||||
}
|
||||
private apply(config: ModelSettings): void {
|
||||
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
||||
this.modelPresets = config.model_presets;
|
||||
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
||||
}
|
||||
save(): void {
|
||||
|
||||
@@ -55,5 +55,5 @@ Keep in mind that the mobile device is an asynchronous, constantly evolving envi
|
||||
---
|
||||
|
||||
# 6. TERMINATION PROTOCOL (`report_task_status`)
|
||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after you have **visually verified** from the latest screen that the objective is fully achieved.
|
||||
- **Completion (`status="completed"`)**: ONLY call `report_task_status(status="completed", ...)` after the latest screenshot and UI elements support the claimed result. If those sources disagree during a transition, wait and observe again before concluding. Tie each claimed page title or value to an observed screenshot or UI element; mark any missing evidence as unverified.
|
||||
- **Failure (`status="failed"`)**: If the task is genuinely impossible due to paywalls, unrecoverable crashes, or hitting an absolute dead end after sensible fallbacks, call `report_task_status(status="failed", ...)` with the exact blocking reason.
|
||||
@@ -24,6 +24,7 @@ You are the Step Summarizer for an Android UI automation agent. Your task is to
|
||||
- **Target & Action**: Identify what control/button was targeted (referencing the red visual indicator on the BEFORE screen) and the physical action performed.
|
||||
- **Objective Transition**: Describe the physical screen change (e.g., page transitioned, modal dialog opened, list scrolled revealing new items, checkbox/toggle toggled, keyboard appeared).
|
||||
- **Preserve Critical Data & Verifications**: Faithfully transcribe visible text, toast alerts, error banners, verification codes, prices, account names, or tracking numbers that appeared on the AFTER screen.
|
||||
3. **Reconcile both observation sources**: The optional AFTER ACTION UI ELEMENTS block is device hierarchy evidence from the same observation request. During a page animation its contents may be newer than the screenshot. If the image still shows the old page but the hierarchy names the new page, state both observations and that the transition is not yet visually settled; never conclude that the tap did nothing. Do not invent details absent from both sources. A controller's "Tapped" outcome only confirms input dispatch.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ from artemis.controllers.unified_controller import UnifiedMobileController
|
||||
from artemis.data_engine.trace import trace
|
||||
from artemis.graph.perception import _check_injected_instruction_file
|
||||
from artemis.graph.state import State
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.llm.structured import ParseFailure, parse_structured
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.observation import observe
|
||||
@@ -288,7 +289,9 @@ class FlashRunner:
|
||||
logger.warning(f"Failed to get operator LLM from config, using default: {e}")
|
||||
|
||||
return RobustChatModelWrapper(
|
||||
get_openai_llm(model_name=settings.OPENAI_MODEL), self.ctx
|
||||
get_openai_llm(model_name=settings.OPENAI_MODEL),
|
||||
self.ctx,
|
||||
endpoint=ModelEndpoint(model_name=settings.OPENAI_MODEL),
|
||||
)
|
||||
|
||||
def _render_system_prompt(self, tools_declaration: list) -> str:
|
||||
@@ -839,6 +842,8 @@ class FlashRunner:
|
||||
pre_img_bytes=pre_screenshot_bytes,
|
||||
post_img_bytes=post_img_bytes,
|
||||
exec_outcome=exec_result.text_summary,
|
||||
post_ui_elements_text=exec_result.ui_elements_text,
|
||||
observation_settled=(exec_result.metadata or {}).get("observation_settled"),
|
||||
action_key=str(tc_id),
|
||||
data_engine_step_id=recorded_step_id,
|
||||
# The model's own reasoning for this turn steers the lens's
|
||||
|
||||
@@ -36,6 +36,7 @@ from jinja2 import Template
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.memory.step_memory import JobKey, StepMemoryService
|
||||
from artemis.services.llm import RobustChatModelWrapper, get_openai_llm, get_llm
|
||||
from artemis.services.token_meter import record_llm_usage
|
||||
@@ -151,7 +152,7 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
ctx: ArtemisContext,
|
||||
model_name: str | None = None,
|
||||
retry_limit: int = 3,
|
||||
timeout_seconds: float = 25.0,
|
||||
timeout_seconds: float = 120.0,
|
||||
*,
|
||||
max_concurrency: int = 1,
|
||||
flush_timeout_s: float = 30.0,
|
||||
@@ -160,7 +161,9 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
ctx,
|
||||
max_concurrency=max_concurrency,
|
||||
retry_limit=retry_limit,
|
||||
flush_timeout_s=flush_timeout_s,
|
||||
# A pending request must be allowed to reach its own timeout when
|
||||
# the task finishes; otherwise the shorter flush cancels it first.
|
||||
flush_timeout_s=max(flush_timeout_s, timeout_seconds + 1.0),
|
||||
)
|
||||
self._timeout_seconds = timeout_seconds
|
||||
|
||||
@@ -226,6 +229,8 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
action_key: str | None = None,
|
||||
data_engine_step_id: UUID | str | None = None,
|
||||
focus: dict[str, str] | None = None,
|
||||
post_ui_elements_text: str | None = None,
|
||||
observation_settled: bool | None = None,
|
||||
) -> None:
|
||||
"""Dispatches an asynchronous summarization task without blocking the caller.
|
||||
|
||||
@@ -256,6 +261,8 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
"exec_outcome": exec_outcome,
|
||||
"data_engine_step_id": data_engine_step_id,
|
||||
"focus": focus,
|
||||
"post_ui_elements_text": post_ui_elements_text,
|
||||
"observation_settled": observation_settled,
|
||||
}
|
||||
self.submit(key, payload, aliases=aliases)
|
||||
|
||||
@@ -408,6 +415,10 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
if verbatim and not self._phrase_carries(phrase, verbatim):
|
||||
lead_lines.append(f"Action arguments (verbatim): {action_name}({verbatim})")
|
||||
lead_lines.append(f"Controller Outcome: {exec_outcome}")
|
||||
if input_data.get("observation_settled") is False:
|
||||
lead_lines.append(
|
||||
"Observation status: screen still changing; page state is not confirmed."
|
||||
)
|
||||
focus = input_data.get("focus")
|
||||
if isinstance(focus, dict) and focus:
|
||||
lead_lines.extend(["", render_focus_block(focus, dual=dual)])
|
||||
@@ -456,13 +467,23 @@ class VisualStepSummarizer(StepMemoryService):
|
||||
}
|
||||
)
|
||||
|
||||
post_ui = input_data.get("post_ui_elements_text")
|
||||
if post_bytes and isinstance(post_ui, str) and post_ui.strip():
|
||||
content_blocks.append(
|
||||
{
|
||||
"type": "text",
|
||||
"text": "--- [3] AFTER ACTION UI ELEMENTS ---\n" + post_ui[:8000],
|
||||
}
|
||||
)
|
||||
|
||||
messages: list[BaseMessage] = [
|
||||
SystemMessage(content=rendered_prompt),
|
||||
HumanMessage(content=content_blocks),
|
||||
]
|
||||
|
||||
response = await asyncio.wait_for(
|
||||
self._llm.ainvoke(messages), timeout=self._timeout_seconds
|
||||
self._llm.ainvoke(adapt_responses_messages(messages, self._model_name)),
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
self._meter_lens_call(response)
|
||||
summary_raw = response.content if isinstance(response.content, str) else ""
|
||||
|
||||
@@ -371,7 +371,7 @@ class StepSummarizerConfig(BaseModel):
|
||||
description="Lightweight model used for background step state summarization.",
|
||||
)
|
||||
timeout_seconds: float = Field(
|
||||
default=25.0,
|
||||
default=120.0,
|
||||
gt=0,
|
||||
description="Timeout for one optional background visual-summary model request.",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Adapt LangChain messages for Responses-compatible model families."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
|
||||
_USER_ROLE_MODELS = {"mimo-v2.6-pro", "mimo-v2.6-flash"}
|
||||
|
||||
|
||||
def adapt_responses_messages(
|
||||
messages: Sequence[BaseMessage], model_name: str | None
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Send selected models user-role instructions without changing stored history.
|
||||
|
||||
Full Artemis requests to GPT models can fail with system-role input. The
|
||||
equivalent user-role request works with GPT and the two tested Mimo models.
|
||||
Other models keep their existing message roles.
|
||||
"""
|
||||
if not model_name:
|
||||
return messages
|
||||
model = model_name.strip().lower()
|
||||
if not (model.startswith("gpt") or model in _USER_ROLE_MODELS):
|
||||
return messages
|
||||
if not any(isinstance(message, SystemMessage) for message in messages):
|
||||
return messages
|
||||
|
||||
adapted: list[BaseMessage] = []
|
||||
pending_blocks: list[dict[str, Any]] = []
|
||||
for message in messages:
|
||||
if isinstance(message, SystemMessage):
|
||||
content = message.content
|
||||
if isinstance(content, str):
|
||||
pending_blocks.append({"type": "text", "text": content})
|
||||
else:
|
||||
pending_blocks.extend(
|
||||
{"type": "text", "text": block} if isinstance(block, str) else block
|
||||
for block in content
|
||||
)
|
||||
continue
|
||||
|
||||
if pending_blocks:
|
||||
if isinstance(message, HumanMessage):
|
||||
content = message.content
|
||||
blocks = (
|
||||
[{"type": "text", "text": content}]
|
||||
if isinstance(content, str)
|
||||
else list(content)
|
||||
)
|
||||
adapted.append(
|
||||
message.model_copy(
|
||||
update={
|
||||
"content": [
|
||||
*pending_blocks,
|
||||
*blocks,
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
pending_blocks = []
|
||||
continue
|
||||
adapted.append(HumanMessage(content=pending_blocks))
|
||||
pending_blocks = []
|
||||
adapted.append(message)
|
||||
|
||||
if pending_blocks:
|
||||
adapted.append(HumanMessage(content=pending_blocks))
|
||||
return adapted
|
||||
@@ -44,6 +44,7 @@ from artemis.mcp.action_manifest import OPTIONAL_ACTIONS, REQUIRED_ACTIONS
|
||||
from artemis.mcp.action_specs import exception_prefix
|
||||
from artemis.mcp.action_session import ActionSession, get_action_session
|
||||
from artemis.mcp.action_types import ActionCode, ActionResult
|
||||
from artemis.mcp.action_types import ObserveResult
|
||||
from artemis.mcp.actuators.adb import AdbActuator
|
||||
from artemis.agents.validator.tool_declarations import (
|
||||
ToolExecutionResult,
|
||||
@@ -56,6 +57,7 @@ from artemis.utils.coordinates import (
|
||||
parse_swipe_parameters,
|
||||
)
|
||||
from artemis.utils.logger import get_logger
|
||||
from artemis.utils.image_hash import dhash_hex, hamming_distance_hex
|
||||
from artemis.utils.notes import (
|
||||
format_list_notes_failure,
|
||||
format_list_notes_success,
|
||||
@@ -76,6 +78,9 @@ AGENT_TOOL_NAMES: frozenset[str] = (
|
||||
|
||||
#: Actions whose ``target`` is a single point: an element index or an [x, y] pair.
|
||||
_POINT_TARGET_ACTIONS: frozenset[str] = frozenset({"click", "long_press", "input_text"})
|
||||
_SCREEN_CHANGING_ACTIONS: frozenset[str] = frozenset(
|
||||
{"click", "click_sequence", "long_press", "input_text", "press_key", "swipe", "manage_app"}
|
||||
)
|
||||
|
||||
|
||||
class _ArgError(ValueError):
|
||||
@@ -111,6 +116,99 @@ class McpActionExecutor:
|
||||
self._session = await get_action_session(self.ctx, actuator=self.actuator)
|
||||
return self._session
|
||||
|
||||
@staticmethod
|
||||
def _image_hash(observation: ObserveResult) -> str | None:
|
||||
path = observation.screenshot_path
|
||||
if not path:
|
||||
return None
|
||||
try:
|
||||
return dhash_hex(Path(path).read_bytes())
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _same_screen(cls, first: ObserveResult, second: ObserveResult) -> bool:
|
||||
"""Compare two observations without treating a dispatched tap as navigation."""
|
||||
if not first.ok or not second.ok:
|
||||
return False
|
||||
checks: list[bool] = []
|
||||
first_hash, second_hash = cls._image_hash(first), cls._image_hash(second)
|
||||
distance = hamming_distance_hex(first_hash, second_hash)
|
||||
if distance is not None:
|
||||
checks.append(distance <= 4)
|
||||
if first.hierarchy_ok and second.hierarchy_ok:
|
||||
checks.append(first.elements_text == second.elements_text)
|
||||
return bool(checks) and all(checks)
|
||||
|
||||
@staticmethod
|
||||
def _element_signature(elements: list[dict[str, Any]]) -> list[tuple[Any, Any, Any]]:
|
||||
return [(e.get("text"), e.get("resource_id"), e.get("bounds")) for e in elements]
|
||||
|
||||
@classmethod
|
||||
def _stale_image_with_new_hierarchy(
|
||||
cls,
|
||||
observation: ObserveResult,
|
||||
previous_hash: str | None,
|
||||
previous_elements: list[dict[str, Any]] | None,
|
||||
) -> bool:
|
||||
if previous_hash is None or previous_elements is None or not observation.hierarchy_ok:
|
||||
return False
|
||||
distance = hamming_distance_hex(previous_hash, cls._image_hash(observation))
|
||||
return (
|
||||
distance is not None
|
||||
and distance <= 4
|
||||
and cls._element_signature(previous_elements)
|
||||
!= cls._element_signature(observation.elements)
|
||||
)
|
||||
|
||||
async def _observe_after_action(
|
||||
self,
|
||||
session: ActionSession,
|
||||
raw_name: str,
|
||||
previous_screenshot: str | None,
|
||||
previous_elements: list[dict[str, Any]] | None = None,
|
||||
) -> tuple[ObserveResult, bool]:
|
||||
"""Prefer a settled image/tree pair after actions that can change screens.
|
||||
|
||||
The Android helper captures its bitmap and hierarchy concurrently. During a
|
||||
transition the bitmap can still show the old page while the hierarchy already
|
||||
describes the new one. Bounded re-observation waits for adjacent stable
|
||||
frames and rejects a new hierarchy paired with the previous page's bitmap.
|
||||
"""
|
||||
first = await session.observe(settle_ms=400)
|
||||
if not first.ok or raw_name not in _SCREEN_CHANGING_ACTIONS:
|
||||
return first, first.ok
|
||||
|
||||
second = await session.observe(settle_ms=250)
|
||||
if not second.ok:
|
||||
return first, False
|
||||
previous_hash = None
|
||||
if previous_screenshot:
|
||||
try:
|
||||
previous_hash = dhash_hex(Path(previous_screenshot).read_bytes())
|
||||
except OSError:
|
||||
pass
|
||||
if self._same_screen(first, second):
|
||||
distance = hamming_distance_hex(previous_hash, self._image_hash(second))
|
||||
if (distance is None or distance > 4) and not self._stale_image_with_new_hierarchy(
|
||||
second, previous_hash, previous_elements
|
||||
):
|
||||
return second, True
|
||||
|
||||
third = await session.observe(settle_ms=250)
|
||||
if not third.ok:
|
||||
return second, False
|
||||
if self._same_screen(second, third) and not self._stale_image_with_new_hierarchy(
|
||||
third, previous_hash, previous_elements
|
||||
):
|
||||
return third, True
|
||||
fourth = await session.observe(settle_ms=250)
|
||||
if not fourth.ok:
|
||||
return third, False
|
||||
return fourth, self._same_screen(
|
||||
third, fourth
|
||||
) and not self._stale_image_with_new_hierarchy(fourth, previous_hash, previous_elements)
|
||||
|
||||
# --- Public entry ----------------------------------------------------------------
|
||||
|
||||
async def execute(
|
||||
@@ -171,6 +269,7 @@ class McpActionExecutor:
|
||||
xml_list: str | None = None
|
||||
target_semantics: dict[str, Any] = {}
|
||||
target_coordinates: list[int] | None = None
|
||||
observation_settled: bool | None = None
|
||||
with span:
|
||||
try:
|
||||
session = await self._session_or_start()
|
||||
@@ -196,7 +295,12 @@ class McpActionExecutor:
|
||||
message = finalize(res) if finalize else res.message
|
||||
|
||||
if res.ok or self._observe_despite_failure(raw_name, res):
|
||||
obs = await session.observe(settle_ms=400)
|
||||
obs, observation_settled = await self._observe_after_action(
|
||||
session,
|
||||
raw_name,
|
||||
getattr(state, "latest_screenshot", None),
|
||||
getattr(state, "indexed_elements", None),
|
||||
)
|
||||
if obs.ok:
|
||||
shot_path = obs.screenshot_path
|
||||
xml_list = obs.elements_text
|
||||
@@ -217,6 +321,8 @@ class McpActionExecutor:
|
||||
|
||||
if shot_path:
|
||||
state.latest_screenshot = shot_path
|
||||
if observation_settled is False:
|
||||
message = f"{message} Post-action screen is still changing; observe again before concluding."
|
||||
|
||||
status = "success" if res.ok else "error"
|
||||
post_image_name = None
|
||||
@@ -247,6 +353,7 @@ class McpActionExecutor:
|
||||
"normalized_coordinates": res.normalized_coordinates,
|
||||
"target_semantics": target_semantics,
|
||||
"target_coordinates": target_coordinates,
|
||||
"observation_settled": observation_settled,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ def ensure_step_memory(ctx):
|
||||
"retry_limit": cfg.memory.runtime.retry_limit,
|
||||
"max_concurrency": cfg.memory.runtime.max_concurrency,
|
||||
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
|
||||
"timeout_seconds": cfg.flash.step_summarizer.timeout_seconds,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
|
||||
@@ -46,6 +46,7 @@ from uuid import uuid4
|
||||
|
||||
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
|
||||
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.memory.step_memory import JobKey, StepLens, StepMemoryService
|
||||
from artemis.memory.transcript import format_session_offset
|
||||
from artemis.utils.logger import get_logger
|
||||
@@ -503,7 +504,8 @@ class StepCapsuleLens(StepLens):
|
||||
return parsed
|
||||
|
||||
async def _invoke(self, llm: Any, messages: list[BaseMessage], model_label: str):
|
||||
response = await asyncio.wait_for(llm.ainvoke(messages), timeout=90.0)
|
||||
request_messages = adapt_responses_messages(messages, model_label)
|
||||
response = await asyncio.wait_for(llm.ainvoke(request_messages), timeout=90.0)
|
||||
# Raw-model bypass metering (gateway-wrapped models meter themselves);
|
||||
# capsule prompts are small and are kept out of the session meter's
|
||||
# last_prompt_tokens (the compaction base itself is operator-owned on
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ from artemis.llm.reliability import (
|
||||
classify_failure,
|
||||
retry_policy_for,
|
||||
)
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||
from artemis.services.token_meter import record_llm_usage
|
||||
from artemis.llm.structured import (
|
||||
@@ -568,6 +569,25 @@ class RobustChatModelWrapper:
|
||||
args, kwargs = _inject_parent_trace_id(trace_id, *args, **kwargs)
|
||||
return args, kwargs, trace_id
|
||||
|
||||
def _prepare_model_call(self, args: tuple, kwargs: dict) -> tuple[tuple, dict]:
|
||||
model_name = self.endpoint.model_name if self.endpoint else None
|
||||
if (
|
||||
args
|
||||
and isinstance(args[0], (list, tuple))
|
||||
and all(isinstance(message, BaseMessage) for message in args[0])
|
||||
):
|
||||
return (adapt_responses_messages(args[0], model_name), *args[1:]), kwargs
|
||||
if (
|
||||
"input" in kwargs
|
||||
and isinstance(kwargs["input"], (list, tuple))
|
||||
and all(isinstance(message, BaseMessage) for message in kwargs["input"])
|
||||
):
|
||||
return args, {
|
||||
**kwargs,
|
||||
"input": adapt_responses_messages(kwargs["input"], model_name),
|
||||
}
|
||||
return args, kwargs
|
||||
|
||||
def _meter_usage(self, response) -> None:
|
||||
"""Record measured token usage for this call (M0 metering, best-effort)."""
|
||||
engine = self.ctx.data_engine if self.ctx else None
|
||||
@@ -576,6 +596,7 @@ class RobustChatModelWrapper:
|
||||
record_llm_usage(engine, response, source=self._endpoint_key())
|
||||
|
||||
async def ainvoke(self, *args, **kwargs):
|
||||
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||
response = await _run_with_recovery(
|
||||
functools.partial(self.base_model.ainvoke, *args, **kwargs),
|
||||
@@ -597,6 +618,7 @@ class RobustChatModelWrapper:
|
||||
signals the UI to drop it, and retries the whole call per policy;
|
||||
partial or duplicated chunks can never reach message history.
|
||||
"""
|
||||
args, kwargs = self._prepare_model_call(args, kwargs)
|
||||
args, kwargs, _ = self._traced_call(args, kwargs)
|
||||
emit_deltas = bool(self.ctx and self.ctx.data_engine)
|
||||
response = await _run_with_recovery(
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"step_summarizer": {
|
||||
"enabled": "${env:ARTEMIS_STEP_SUMMARIZER_ENABLED:-true}",
|
||||
"model": "${env:OPENAI_MODEL:-mimo-v2.6-flash}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-25}",
|
||||
"timeout_seconds": "${env:ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS:-120}",
|
||||
"prune_history_xml": true
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# ARTEMIS-Eapil 1.2.3
|
||||
|
||||
## 更新
|
||||
|
||||
- 视觉步骤摘要的单次模型请求默认超时从 25 秒延长至 120 秒;任务收尾不会再在 30 秒时提前取消仍在进行的摘要。可通过 `ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS` 调整。
|
||||
- Pro 与 Flash 共用的步骤摘要服务现在都会读取该超时配置。
|
||||
- 本版本还包含尚未发布的 Responses 消息与视频导出修复,以及设备观察稳定性改进。
|
||||
|
||||
## 安装
|
||||
|
||||
下载 `artemis-eapil-1.2.3-source.zip`,解压后运行 `start.bat`,国内网络使用 `start-cn.bat`。附件包含可继续更新的 Git 仓库。已有安装可按原方式运行启动脚本更新;升级后重启 ARTEMIS 服务,并重新加载 MCP。
|
||||
|
||||
验证:Python 默认测试 2207 项通过、2 项跳过、8 项按标记排除;Ruff、质量基线、核心类型检查、前端 149 项测试及构建均通过。
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.2.2"
|
||||
version = "1.2.3"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -544,6 +544,43 @@ def test_task_material_zip_contains_ordered_evidence_and_reports(
|
||||
assert "1.1-all.zip" in response.headers["content-disposition"]
|
||||
|
||||
|
||||
def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||
isolated, tmp_path, monkeypatch
|
||||
):
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
session_id = job["session_id"]
|
||||
recording_dir = reports.TRACES_PATH / f"web_123_{session_id[:8]}_PASS_2026-09-28"
|
||||
recording_dir.mkdir(parents=True)
|
||||
first = recording_dir / "recording.mp4"
|
||||
first.write_bytes(b"first segment")
|
||||
resumed = recording_dir / "resume_0001"
|
||||
resumed.mkdir()
|
||||
second = resumed / "recording.mp4"
|
||||
second.write_bytes(b"second segment")
|
||||
|
||||
repository = Mock()
|
||||
repository.get_video_recording_for_session.return_value = {"local_video_path": str(first)}
|
||||
repository.get_session_by_id.return_value = {"video_filepath": str(first)}
|
||||
monkeypatch.setattr(reports, "SessionRepository", lambda: repository)
|
||||
|
||||
archive_path = reports.export_session_media(session_id, "video")
|
||||
with zipfile.ZipFile(archive_path) as archive:
|
||||
names = archive.namelist()
|
||||
assert names == [
|
||||
"任务录屏/0001_web_123_" + session_id[:8] + "_PASS_2026-09-28_recording.mp4",
|
||||
"任务录屏/0002_resume_0001_recording.mp4",
|
||||
"文件清单.json",
|
||||
]
|
||||
assert archive.read(names[0]) == b"first segment"
|
||||
assert archive.read(names[1]) == b"second segment"
|
||||
|
||||
external = tmp_path / "outside" / "recording.mp4"
|
||||
external.parent.mkdir()
|
||||
external.write_bytes(b"unrelated")
|
||||
repository.get_video_recording_for_session.return_value = {"local_video_path": str(external)}
|
||||
assert reports.session_videos({"id": session_id}) == []
|
||||
|
||||
|
||||
def test_history_filters_all_persisted_rows_and_escapes_like(client, isolated):
|
||||
batch = service.create_batch(payload())
|
||||
first, second = isolated.jobs(batch["id"])
|
||||
|
||||
@@ -70,6 +70,12 @@ def test_new_install_uses_playones_endpoint_without_overriding_saved_url(monkeyp
|
||||
assert service.get_model_settings()["base_url"] == "https://saved.example/v1"
|
||||
|
||||
|
||||
def test_model_presets_include_flash_pro_and_luna_without_restricting_custom_names():
|
||||
presets = service.get_model_settings()["model_presets"]
|
||||
assert presets == ["gpt-5.6-luna", "gpt-6-luna", "mimo-v2.6-flash", "mimo-v2.6-pro"]
|
||||
assert service.ModelSettingsUpdate(model="custom/model-v2").model == "custom/model-v2"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"url",
|
||||
[
|
||||
|
||||
@@ -60,20 +60,21 @@ REFRESHED = [_el(1, "Back", 100, 100), _el(2, "Wi-Fi toggle", 900, 300)]
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""An action session whose observations replay a scripted list sequence."""
|
||||
"""An action session whose screen advances after each device action."""
|
||||
|
||||
started = True
|
||||
|
||||
def __init__(self, observations: list[list[dict]]):
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
self._observations = list(observations)
|
||||
self._observations = observations
|
||||
|
||||
async def call(self, name, args):
|
||||
self.calls.append((name, dict(args)))
|
||||
return ActionResult.success(name, f"{name} dispatched")
|
||||
|
||||
async def observe(self, settle_ms=0):
|
||||
elements = self._observations.pop(0) if self._observations else []
|
||||
index = min(len(self.calls) - 1, len(self._observations) - 1)
|
||||
elements = self._observations[index] if self._observations else []
|
||||
return ObserveResult(
|
||||
ok=True,
|
||||
elements_text="\n".join(f"[{e['index']}] {e['text']}" for e in elements),
|
||||
|
||||
@@ -39,10 +39,20 @@ def mock_context():
|
||||
return ctx
|
||||
|
||||
|
||||
def _user_role_prompt(messages):
|
||||
"""Return the instruction text and observation blocks sent to the model."""
|
||||
assert len(messages) == 1
|
||||
assert isinstance(messages[0], HumanMessage)
|
||||
blocks = messages[0].content
|
||||
return blocks[0]["text"], blocks[1:]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_dispatch_and_caching(mock_context):
|
||||
"""Verify non-blocking dispatch and summary generation."""
|
||||
summarizer = VisualStepSummarizer(mock_context)
|
||||
assert summarizer._timeout_seconds == 120.0
|
||||
assert summarizer._flush_timeout_s > summarizer._timeout_seconds
|
||||
|
||||
mock_llm_response = AIMessage(
|
||||
content="[Step 1 State: Tapped 'Login' button. Home view opened.]"
|
||||
@@ -425,8 +435,8 @@ async def test_single_frame_dispatch_uses_single_image_prompt_variant(mock_conte
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(1)
|
||||
system_text = str(captured[0][0].content)
|
||||
human_text = str(captured[0][1].content)
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
human_text = str(human_blocks)
|
||||
assert "Exactly ONE screenshot" in system_text
|
||||
assert "no independent post-action evidence" in system_text
|
||||
assert "DECISION FRAME" in human_text
|
||||
@@ -460,15 +470,21 @@ async def test_dual_frame_dispatch_keeps_transition_prompt(mock_context):
|
||||
pre_img_bytes=b"pre",
|
||||
post_img_bytes=b"post",
|
||||
exec_outcome="executed",
|
||||
post_ui_elements_text="[1] Text: '存储空间' | Bounds: [0,0][100,100]",
|
||||
observation_settled=False,
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(1)
|
||||
system_text = str(captured[0][0].content)
|
||||
human_text = str(captured[0][1].content)
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
human_text = str(human_blocks)
|
||||
assert "Before/After screenshots" in system_text
|
||||
assert "--- [1] BEFORE ACTION SCREEN (Action Marked Visually in Red) ---" in human_text
|
||||
assert "--- [2] AFTER ACTION SCREEN ---" in human_text
|
||||
assert "--- [3] AFTER ACTION UI ELEMENTS ---" in human_text
|
||||
assert "Text: '存储空间'" in human_text
|
||||
assert "screen still changing" in human_text
|
||||
assert "hierarchy names the new page" in system_text
|
||||
assert "DECISION FRAME" not in human_text
|
||||
|
||||
|
||||
@@ -628,8 +644,8 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
||||
await summarizer.flush()
|
||||
|
||||
assert summarizer.has_summary(3)
|
||||
system_text = str(captured[0][0].content)
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
system_text, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert "# OPERATOR FOCUS" in system_text
|
||||
assert "Absence is a fact" in system_text
|
||||
assert lead_text.startswith(
|
||||
@@ -643,7 +659,7 @@ async def test_focus_block_and_provenance_marked_action_phrase_reach_the_lens(mo
|
||||
assert "User instruction at this step: Do not join any network yet." in lead_text
|
||||
assert "expect it to read On." in lead_text
|
||||
# The focus precedes the frames: context first, evidence second.
|
||||
assert captured[0][1].content[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||
assert human_blocks[1]["text"].startswith("--- [1] BEFORE ACTION SCREEN")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -672,7 +688,8 @@ async def test_dispatch_without_focus_carries_no_focus_block(mock_context):
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert lead_text.startswith("Step 1 Physical Action: Tapped 'Search' at [500, 600]\n")
|
||||
assert "(self-described)" not in lead_text
|
||||
assert FOCUS_BLOCK_HEADER not in lead_text
|
||||
@@ -706,7 +723,8 @@ async def test_single_frame_focus_label_forbids_describing_the_outcome(mock_cont
|
||||
)
|
||||
await summarizer.flush()
|
||||
|
||||
lead_text = captured[0][1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(captured[0])
|
||||
lead_text = human_blocks[0]["text"]
|
||||
assert "the expected outcome is NOT in this frame and must not be described" in lead_text
|
||||
assert "what screen change it expected" not in lead_text
|
||||
# The focus text is released once the summary landed, like the frames.
|
||||
@@ -798,7 +816,8 @@ async def test_verbatim_argument_line_appears_only_when_the_phrase_folds_argumen
|
||||
|
||||
lead_by_step = {}
|
||||
for messages in captured:
|
||||
text = messages[1].content[0]["text"]
|
||||
_, human_blocks = _user_role_prompt(messages)
|
||||
text = human_blocks[0]["text"]
|
||||
lead_by_step[text.split(" ")[1]] = text
|
||||
launch = lead_by_step["1"]
|
||||
assert launch.startswith("Step 1 Physical Action: Launched app 'Settings'\nController Outcome:")
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Page transitions are recaptured until screenshot and hierarchy agree."""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from artemis.mcp.action_executor import McpActionExecutor
|
||||
from artemis.mcp.action_types import ObserveResult
|
||||
|
||||
|
||||
def _screen(tmp_path, name: str, *, reverse: bool = False) -> str:
|
||||
image = Image.new("L", (64, 64))
|
||||
image.putdata([255 - x * 4 if reverse else x * 4 for _y in range(64) for x in range(64)])
|
||||
path = tmp_path / name
|
||||
image.save(path, format="JPEG")
|
||||
return str(path)
|
||||
|
||||
|
||||
def _observation(path: str, page: str) -> ObserveResult:
|
||||
return ObserveResult(
|
||||
ok=True,
|
||||
screenshot_path=path,
|
||||
elements_text=f"[1] {page}",
|
||||
elements=[{"text": page, "resource_id": "page/title", "bounds": [0, 0, 64, 64]}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigation_recaptures_when_image_lags_behind_hierarchy(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(
|
||||
side_effect=[
|
||||
_observation(home, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
]
|
||||
)
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.screenshot_path == detail
|
||||
assert observed.elements_text == "[1] 存储空间"
|
||||
assert settled is True
|
||||
assert session.observe.await_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_navigation_waits_until_new_page_has_two_stable_frames(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
detail = _screen(tmp_path, "detail.jpg", reverse=True)
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(
|
||||
side_effect=[
|
||||
_observation(home, "设置首页"),
|
||||
_observation(home, "设置首页"),
|
||||
_observation(detail, "存储空间"),
|
||||
_observation(detail, "存储空间"),
|
||||
]
|
||||
)
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.screenshot_path == detail
|
||||
assert settled is True
|
||||
assert session.observe.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_old_bitmap_with_new_hierarchy_is_never_marked_settled(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(return_value=_observation(home, "存储空间"))
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(
|
||||
session, "click", home, _observation(home, "设置首页").elements
|
||||
)
|
||||
|
||||
assert observed.elements_text == "[1] 存储空间"
|
||||
assert settled is False
|
||||
assert session.observe.await_count == 4
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_read_only_action_keeps_single_observation(tmp_path):
|
||||
home = _screen(tmp_path, "home.jpg")
|
||||
session = Mock()
|
||||
session.observe = AsyncMock(return_value=_observation(home, "设置首页"))
|
||||
executor = McpActionExecutor(Mock(), actuator=Mock())
|
||||
|
||||
observed, settled = await executor._observe_after_action(session, "wait_for_delay", home)
|
||||
|
||||
assert observed.screenshot_path == home
|
||||
assert settled is True
|
||||
session.observe.assert_awaited_once_with(settle_ms=400)
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk
|
||||
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, SystemMessage
|
||||
import pytest
|
||||
|
||||
from artemis.llm.reliability import LLMExhaustedError, LLMPermanentError
|
||||
from artemis.llm.router import ModelEndpoint
|
||||
from artemis.services import llm as llm_service
|
||||
from artemis.services.llm import (
|
||||
RobustChatModelWrapper,
|
||||
@@ -54,6 +55,30 @@ async def test_mid_stream_failure_retries_without_duplicating_chunks():
|
||||
assert result.content == "Hello world"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gpt_stream_uses_adapted_messages_without_mutating_history():
|
||||
class RecordingStream:
|
||||
messages = None
|
||||
|
||||
async def astream(self, messages, **kwargs):
|
||||
self.messages = messages
|
||||
yield AIMessageChunk(content="OK")
|
||||
|
||||
base = RecordingStream()
|
||||
wrapper = RobustChatModelWrapper(
|
||||
base, endpoint=ModelEndpoint(model_name="gpt-6-luna", max_retries=0)
|
||||
)
|
||||
original = [SystemMessage(content="Use tools"), HumanMessage(content="Inspect screen")]
|
||||
result = await wrapper.complete(original)
|
||||
|
||||
assert result.content == "OK"
|
||||
assert len(base.messages) == 1
|
||||
assert isinstance(base.messages[0], HumanMessage)
|
||||
assert base.messages[0].content[0]["text"] == "Use tools"
|
||||
assert base.messages[0].content[1]["text"] == "Inspect screen"
|
||||
assert isinstance(original[0], SystemMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auth_error_fails_fast_without_retry_or_pause(tmp_path):
|
||||
class AuthFails:
|
||||
|
||||
@@ -16,11 +16,12 @@
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.config import AgentGlobalConfig
|
||||
from artemis.config import AgentGlobalConfig, load_agent_config
|
||||
|
||||
|
||||
def test_memory_defaults():
|
||||
cfg = AgentGlobalConfig.model_validate({})
|
||||
assert cfg.flash.step_summarizer.timeout_seconds == 120.0
|
||||
# 2 per the 2026-09-01 on-device baseline (serial queue at 1 pushed
|
||||
# summary-ready P90 past the scrub-edge grace window).
|
||||
assert cfg.memory.runtime.max_concurrency == 2
|
||||
@@ -41,6 +42,14 @@ def test_memory_defaults():
|
||||
assert cfg.memory.transcript.hard_ratio == 0.9
|
||||
|
||||
|
||||
def test_visual_summary_timeout_config(monkeypatch):
|
||||
monkeypatch.delenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", raising=False)
|
||||
assert load_agent_config().flash.step_summarizer.timeout_seconds == 120.0
|
||||
|
||||
monkeypatch.setenv("ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS", "75")
|
||||
assert load_agent_config().flash.step_summarizer.timeout_seconds == 75.0
|
||||
|
||||
|
||||
def test_transcript_ratio_ladder_is_validated():
|
||||
"""start <= soft <= hard, all within [0, 1]: the defaults pass, a
|
||||
crossed pair is rejected with a clear message."""
|
||||
|
||||
@@ -5,10 +5,11 @@ from unittest.mock import MagicMock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from langchain_core.messages import HumanMessage, ToolMessage
|
||||
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage
|
||||
from pydantic import BaseModel, SecretStr, ValidationError
|
||||
|
||||
from artemis.config.settings import settings
|
||||
from artemis.llm.messages import adapt_responses_messages
|
||||
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
|
||||
from artemis.services.llm import RobustChatModelWrapper
|
||||
|
||||
@@ -85,6 +86,91 @@ async def test_text_and_image_use_responses_transport(transport_model):
|
||||
assert result.usage_metadata["total_tokens"] == 13
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("model_name", "reasoning_effort"),
|
||||
[
|
||||
("mimo-v2.6-pro", "high"),
|
||||
("mimo-v2.6-flash", "high"),
|
||||
("gpt-6-luna", "max"),
|
||||
("gpt-6-luna", "high"),
|
||||
],
|
||||
)
|
||||
async def test_selected_models_put_instructions_in_user_input(
|
||||
transport_model, model_name, reasoning_effort
|
||||
):
|
||||
_, requests, _ = transport_model
|
||||
endpoint = ModelEndpoint(
|
||||
model_name=model_name,
|
||||
reasoning_effort=reasoning_effort,
|
||||
timeout_seconds=23,
|
||||
max_retries=0,
|
||||
)
|
||||
model = ModelFactory.create_model(endpoint)
|
||||
wrapper = RobustChatModelWrapper(model, endpoint=endpoint)
|
||||
tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "inspect_screen",
|
||||
"description": "Inspect the current screen",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
await wrapper.bind_tools([tool]).ainvoke(
|
||||
[
|
||||
SystemMessage(content="Use the available tools."),
|
||||
HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": "Inspect this screen."},
|
||||
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}},
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
payload = json.loads(requests[-1].content)
|
||||
assert payload["model"] == model_name
|
||||
assert payload["reasoning"]["effort"] == reasoning_effort
|
||||
assert payload["input"][0]["role"] == "user"
|
||||
assert payload["input"][0]["content"][0]["text"] == "Use the available tools."
|
||||
assert payload["input"][0]["content"][2]["type"] == "input_image"
|
||||
assert payload["tools"][0]["name"] == "inspect_screen"
|
||||
assert all(item.get("role") != "system" for item in payload["input"])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["gpt-6-luna", "GPT-5.6-luna"])
|
||||
def test_gpt_message_adaptation_keeps_history_and_images(model_name):
|
||||
image = {"type": "image_url", "image_url": {"url": "data:image/png;base64,AA=="}}
|
||||
original = [
|
||||
SystemMessage(content="First instruction"),
|
||||
SystemMessage(content="Second instruction"),
|
||||
HumanMessage(content=[{"type": "text", "text": "Observation"}, image]),
|
||||
SystemMessage(content="Follow-up instruction"),
|
||||
]
|
||||
adapted = adapt_responses_messages(original, model_name)
|
||||
|
||||
assert len(adapted) == 2
|
||||
assert isinstance(adapted[0], HumanMessage)
|
||||
assert adapted[0].content[0]["text"] == "First instruction"
|
||||
assert adapted[0].content[1]["text"] == "Second instruction"
|
||||
assert adapted[0].content[3] == image
|
||||
assert isinstance(adapted[1], HumanMessage)
|
||||
assert adapted[1].content == [{"type": "text", "text": "Follow-up instruction"}]
|
||||
assert isinstance(original[0], SystemMessage)
|
||||
assert len(original[2].content) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model_name", ["mimo-v2.6-pro", "mimo-v2.6-flash"])
|
||||
def test_mimo_messages_use_same_user_role_format(model_name):
|
||||
messages = [SystemMessage(content="Instructions"), HumanMessage(content="Task")]
|
||||
adapted = adapt_responses_messages(messages, model_name)
|
||||
assert len(adapted) == 1
|
||||
assert isinstance(adapted[0], HumanMessage)
|
||||
assert adapted[0].content[0]["text"] == "Instructions"
|
||||
assert adapted[0].content[1]["text"] == "Task"
|
||||
assert isinstance(messages[0], SystemMessage)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_reasoning_is_sent_unchanged_to_responses(transport_model):
|
||||
model, requests, _ = transport_model
|
||||
|
||||
Reference in new issue
Block a user