"""Standalone MAIUI screenshot-to-action agent. This module intentionally has no dependency on mai-gym or OSWorld. It owns only model input assembly, history management, feature-flag prompt composition, an OpenAI-compatible model call, and response parsing. The caller remains responsible for executing returned actions and supplying the next screenshot and execution result. """ from __future__ import annotations import ast import asyncio import base64 import inspect import json import mimetypes import re import urllib.error import urllib.request from dataclasses import asdict, dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, Mapping, Sequence, Union IMAGE_COLLAPSED = "(screenshot omitted by the image window)" TOOL_RESULT_COMPACTED = "(old observation compacted)" _TOOL_CALL_RE = re.compile(r"]*>(.*?)", re.I | re.S) _ACTION_BLOCK_RE = re.compile( r"[^>]*)>(?P.*?)" r"|(?P.*?)", re.I | re.S, ) _INTENT_RE = re.compile(r"(.*?)", re.I | re.S) _THINK_RE = re.compile(r"<(?:think|thinking)>(.*?)", re.I | re.S) ModelResult = Union[Mapping[str, Any], str] ModelCallable = Callable[ [list[dict[str, Any]], dict[str, Any]], Union[ModelResult, Awaitable[ModelResult]], ] SummaryCallable = Callable[ [str, list[dict[str, Any]]], Union[str, Awaitable[str]] ] VerifierResult = Union[bool, Mapping[str, Any]] VerifierCallable = Callable[ [str, Sequence["Turn"], dict[str, Any]], Union[VerifierResult, Awaitable[VerifierResult]], ] ImageTransform = Callable[[Any, Sequence[dict[str, Any]]], Any] @dataclass class FeatureConfig: """All optional MAIUI behaviors. Every capability is opt-in.""" enable_bash: bool = False enable_batch_actions: bool = False enable_ask_user: bool = False enable_screenshot_action: bool = False enable_intent: bool = False micro_compaction: bool = False llm_compaction: bool = False enable_todo: bool = False todo_diff_snapshot: bool = False enable_step_hint: bool = False enable_action_overlay: bool = False enable_claude_thinking_pattern: bool = False enable_notice: bool = False enable_verifier: bool = False prompt_v10: bool = False img_keep_n: int = 0 img_interval: int = 0 micro_compaction_trigger_steps: int = 60 micro_keep_turns: int = 25 micro_interval: int = 35 compaction_trigger_steps: int = 120 compaction_trigger_tokens: int = 100_000 image_token_estimate: int = 1600 @classmethod def from_mapping(cls, values: Mapping[str, Any] | None) -> "FeatureConfig": values = values or {} known = cls.__dataclass_fields__ return cls(**{key: values[key] for key in known if key in values}) @classmethod def from_flags(cls, flags: str | Sequence[str]) -> "FeatureConfig": """Parse compact flags such as ``+intent +keep_n(n=5,interval=10)``.""" text = flags if isinstance(flags, str) else " ".join(flags) aliases = { "bash": "enable_bash", "batch": "enable_batch_actions", "ask_user": "enable_ask_user", "screenshot": "enable_screenshot_action", "intent": "enable_intent", "micro_compact": "micro_compaction", "micro compact": "micro_compaction", "micro compaction": "micro_compaction", "llm_compact": "llm_compaction", "llm compact": "llm_compaction", "llm compaction": "llm_compaction", "todo": "enable_todo", "step_hint": "enable_step_hint", "action_overlay": "enable_action_overlay", "claude_pattern": "enable_claude_thinking_pattern", "notice": "enable_notice", "verifier": "enable_verifier", "prompt_v10": "prompt_v10", } values: dict[str, Any] = {} normalized = re.sub(r"\s+", " ", text.strip().lower()) for alias, field_name in aliases.items(): if re.search(rf"(?:^|\s)\+{re.escape(alias)}(?:\s|$)", normalized): values[field_name] = True keep = re.search( r"\+keep_n\s*\(\s*n\s*=\s*(\d+)\s*,\s*interval\s*=\s*(\d+)\s*\)", normalized, ) if keep: values["img_keep_n"] = int(keep.group(1)) values["img_interval"] = int(keep.group(2)) if values.get("enable_todo"): values["todo_diff_snapshot"] = True return cls(**values) @dataclass class TodoItem: id: str content: str kind: str = "task" status: str = "pending" updated_step: int = 0 @dataclass class Turn: step: int observation: str image_url: str | None response: str reasoning: str action: dict[str, Any] execution_result: str = "" intent: str = "" @dataclass class AgentOutput: action: dict[str, Any] raw_response: str reasoning: str intent: str messages: list[dict[str, Any]] model_response: dict[str, Any] = field(default_factory=dict) verifier: dict[str, Any] | None = None def as_dict(self) -> dict[str, Any]: return asdict(self) def _display_number(value: float) -> str: return str(int(value)) if float(value).is_integer() else str(value) def _format_prompt(template: str, *, password: str, range_scale: float) -> str: return template.format( password=password, range_scale=_display_number(range_scale), range_half=_display_number(range_scale / 2), ) def image_to_data_url(image: Any, *, image_format: str = "jpeg", jpeg_quality: int = 85) -> str: """Normalize a path, bytes, data URL, or PIL-compatible image to a data URL.""" if image is None: raise ValueError("image must not be None") if isinstance(image, str): if image.startswith("data:image/"): return image path = Path(image).expanduser() if not path.is_file(): raise FileNotFoundError(path) payload = path.read_bytes() mime = mimetypes.guess_type(path.name)[0] or "image/png" return f"data:{mime};base64,{base64.b64encode(payload).decode('ascii')}" if isinstance(image, Path): return image_to_data_url(str(image), image_format=image_format, jpeg_quality=jpeg_quality) if isinstance(image, (bytes, bytearray, memoryview)): payload = bytes(image) return f"data:image/{image_format};base64,{base64.b64encode(payload).decode('ascii')}" if hasattr(image, "save"): import io buffer = io.BytesIO() fmt = "JPEG" if image_format.lower() in {"jpg", "jpeg"} else "PNG" prepared = image.convert("RGB") if fmt == "JPEG" and hasattr(image, "convert") else image save_kwargs = {"quality": jpeg_quality} if fmt == "JPEG" else {} prepared.save(buffer, format=fmt, **save_kwargs) return f"data:image/{fmt.lower()};base64,{base64.b64encode(buffer.getvalue()).decode('ascii')}" raise TypeError("image must be a path, bytes, data URL, or PIL-compatible image") def _claude_pattern_prompt() -> str: return ( "## Deliberate task discipline\n" "Derive a concrete task contract before acting. Distinguish observed facts from " "hypotheses, revise a failed approach instead of repeating it, and verify the final " "state with external evidence before reporting success." ) def _notice_prompt() -> str: return ( "## Notices\n" "Honor the application named by the user. When editing an existing artifact, preserve " "unrequested content. Record important values in reasoning before their screenshot leaves " "the visible history window." ) def build_simple_prompt( features: FeatureConfig, *, password: str = "password", range_scale: float = 1000, ) -> str: """Compose the tool-call prompt from feature switches.""" screenshot_mode = ( "A screenshot is attached only when requested in the previous response." if features.enable_screenshot_action else "A current screenshot is attached automatically at every step." ) sections = [ "You are a computer-use agent operating a desktop. Each step contains the immutable " "task instruction, the current observation, prior action results, and optional history. " f"{screenshot_mode} The computer password is {{password}}." ] if features.enable_claude_thinking_pattern: sections.append(_claude_pattern_prompt()) if features.enable_notice: sections.append(_notice_prompt()) sections.append( "## GUI actions\n" "Use pyautogui calls such as click, rightClick, doubleClick, moveTo, dragTo, " "typewrite, press, hotkey, and scroll. Coordinates are NORMALIZED integers in " "[0, {range_scale}] on both axes; ({range_half}, {range_half}) is screen center. " "Never copy raw pixel coordinates without converting them." ) if features.enable_bash: sections.append( "## Bash actions\n" "Put one complete raw script in a bare ... block. Never nest it " "inside . Its stdout and stderr arrive in the next observation." ) controls = [ '- computer.wait() waits for loading.', '- computer.terminate(status="success"|"failure", answer=None) ends the task.', ] if features.enable_screenshot_action: controls.insert(0, "- computer.screenshot() requests the image delivered next step.") if features.enable_ask_user: controls.append("- computer.ask_user(question=...) asks one essential concise question.") sections.append("## Flow control\n" + "\n".join(controls)) if features.enable_todo: snapshot = ( "The full ledger is shown only after changes; otherwise a compact status is shown." if features.todo_diff_snapshot else "The current ledger is shown with every observation." ) sections.append( "## TODO ledger\n" "Use todo.update(id=..., kind=..., content=..., status=...) and todo.list(). " "Statuses are pending, attempted, changed_unverified, claimed_verified, blocked, " f"or delete. {snapshot}" ) if features.enable_batch_actions: response = ( "Emit one or more action blocks in execution order, at most 20 executable actions. " "Use one ONE CALL per GUI/flow action" + (" and one bare RAW SCRIPT per Bash action." if features.enable_bash else ".") ) else: response = "Emit exactly one action in one ONE CALL block." if features.enable_bash: response += " A Bash step instead uses exactly one bare RAW SCRIPT block." if features.enable_intent: response = ( "After reasoning, emit one short ... naming the immediate purpose. " + response ) sections.append("## Response format\n" + response + " Do not add prose outside these blocks.") sections.append( "## Guidelines\n" "Read exact values rather than guessing. Observe before acting. Batch only independent " "actions, stop before an action that depends on unseen results, change approach after a " "failed retry, commit edits, and verify the requested final state before success." ) return _format_prompt("\n\n".join(sections), password=password, range_scale=range_scale) def _literal_call(code: str) -> tuple[str, list[Any], dict[str, Any]] | None: try: tree = ast.parse(code.strip(), mode="exec") except SyntaxError: return None if len(tree.body) != 1 or not isinstance(tree.body[0], ast.Expr): return None call = tree.body[0].value if not isinstance(call, ast.Call): return None if isinstance(call.func, ast.Attribute) and isinstance(call.func.value, ast.Name): name = f"{call.func.value.id}.{call.func.attr}" elif isinstance(call.func, ast.Name): name = call.func.id else: return None try: args = [ast.literal_eval(arg) for arg in call.args] kwargs = {kw.arg: ast.literal_eval(kw.value) for kw in call.keywords if kw.arg} except (ValueError, TypeError): args, kwargs = [], {} return name, args, kwargs def _normalize_status(value: Any) -> str: value = str(value or "success").strip().lower() if value in {"success", "done", "succeeded", "ok"}: return "success" if value in {"failure", "fail", "failed", "infeasible"}: return "failure" raise ValueError(f"invalid termination status: {value!r}") def parse_simple_response(content: str, features: FeatureConfig) -> tuple[dict[str, Any], str, str]: """Parse model text into an executor-neutral action dictionary.""" intent_match = _INTENT_RE.search(content) intent = intent_match.group(1).strip() if intent_match and features.enable_intent else "" think_parts = [part.strip() for part in _THINK_RE.findall(content) if part.strip()] actions: list[dict[str, Any]] = [] for match in _ACTION_BLOCK_RE.finditer(content): bash_body = match.group("bash") if bash_body is not None: if features.enable_bash: actions.append({"action_type": "execute_bash", "command": bash_body.strip("\r\n")}) continue body = (match.group("tool") or "").strip() parsed = _literal_call(body) if parsed is None: actions.append({"action_type": "pyautogui_code", "code": body}) continue name, args, kwargs = parsed if name == "computer.screenshot": if features.enable_screenshot_action: actions.append({"action_type": "screenshot"}) elif name == "computer.wait": actions.append({"action_type": "wait", "seconds": kwargs.get("seconds", args[0] if args else 20)}) elif name == "computer.ask_user": if features.enable_ask_user: question = kwargs.get("question", args[0] if args else "") actions.append({"action_type": "ask_user", "question": str(question)}) elif name == "computer.terminate": status = kwargs.get("status", args[0] if args else "success") answer = kwargs.get("answer", args[1] if len(args) > 1 else None) action = {"action_type": "finished", "status": _normalize_status(status)} if answer is not None: action["answer"] = str(answer) actions.append(action) elif name.startswith("todo."): actions.append({"action_type": "todo", "name": name, "args": args, "kwargs": kwargs}) else: actions.append({"action_type": "pyautogui_code", "code": body}) executable = [a for a in actions if a["action_type"] != "todo"] if not features.enable_batch_actions and len(executable) > 1: first = executable[0] actions = [a for a in actions if a["action_type"] == "todo"] + [first] if not actions: action: dict[str, Any] = {"action_type": "FAIL", "error": "no enabled action block found"} elif len(actions) == 1: action = actions[0] else: action = {"action_type": "action_sequence", "actions": actions[:20]} return action, "\n".join(think_parts), intent class MaiUIAgent: """Stateful standalone screenshot-to-tool-call agent.""" def __init__( self, model_name: str, *, llm_base_url: str | None = None, api_key: str | None = None, model: ModelCallable | None = None, features: FeatureConfig | Mapping[str, Any] | None = None, history_n: int = 3, max_steps: int = 100, temperature: float = 1.0, top_p: float = 0.95, max_tokens: int = 4096, thinking: bool = True, password: str = "password", range_scale: float = 1000, image_format: str = "jpeg", jpeg_quality: int = 85, max_retries: int = 3, retry_sleep: float = 2.0, request_timeout: float = 180.0, summary_model: SummaryCallable | None = None, verifier: VerifierCallable | None = None, image_transform: ImageTransform | None = None, **feature_overrides: Any, ) -> None: self.model_name = model_name self.llm_base_url = (llm_base_url or "").rstrip("/") self.api_key = api_key or "" self.model = model base_features = ( asdict(features) if isinstance(features, FeatureConfig) else dict(features or {}) ) unknown = set(feature_overrides) - set(FeatureConfig.__dataclass_fields__) if unknown: raise TypeError(f"unknown feature option(s): {', '.join(sorted(unknown))}") base_features.update(feature_overrides) self.features = FeatureConfig.from_mapping(base_features) self.history_n = max(0, int(history_n)) self.max_steps = max(1, int(max_steps)) self.temperature = float(temperature) self.top_p = float(top_p) self.max_tokens = max(1, int(max_tokens)) self.thinking = bool(thinking) self.password = password self.range_scale = float(range_scale) self.image_format = image_format self.jpeg_quality = int(jpeg_quality) self.max_retries = max(1, int(max_retries)) self.retry_sleep = max(0.0, float(retry_sleep)) self.request_timeout = float(request_timeout) self.summary_model = summary_model self.verifier = verifier self.image_transform = image_transform self.instruction = "" self.turns: list[Turn] = [] self.previous_summary = "" self.todos: dict[str, TodoItem] = {} self._todo_changed = False self._micro_cutoff = 0 self.system_prompt = self._build_system_prompt() @classmethod def from_config(cls, config: Mapping[str, Any] | Any) -> "MaiUIAgent": if isinstance(config, Mapping): agent = config.get("agent", config) runtime = dict(agent.get("runtime_conf") or config.get("runtime_conf") or {}) get_agent = agent.get get_root = config.get else: agent = config runtime = dict(getattr(agent, "runtime_conf", None) or {}) get_agent = lambda key, default=None: getattr(agent, key, default) get_root = get_agent features = FeatureConfig.from_mapping(runtime) return cls( model_name=str(get_agent("model_name") or get_root("model_name") or ""), llm_base_url=get_agent("llm_base_url") or get_root("llm_base_url"), api_key=get_agent("api_key") or get_root("api_key"), features=features, history_n=runtime.get("history_n", 3), max_steps=get_agent("max_steps", get_root("max_steps", 100)), temperature=runtime.get("temperature", 1.0), top_p=runtime.get("top_p", 0.95), max_tokens=runtime.get("max_tokens", 4096), thinking=runtime.get("thinking", True), password=runtime.get("password", "password"), range_scale=runtime.get("range_scale", runtime.get("coordinate_range", 1000)), image_format=runtime.get("image_format", "jpeg"), jpeg_quality=runtime.get("jpeg_quality", 85), max_retries=runtime.get("max_retries", 3), retry_sleep=runtime.get("retry_sleep", 2.0), ) from_agent_config = from_config def _build_system_prompt(self) -> str: return build_simple_prompt(self.features, password=self.password, range_scale=self.range_scale) def initialize(self, instruction: str) -> None: self.instruction = str(instruction).strip() if not self.instruction: raise ValueError("instruction must not be empty") self.turns.clear() self.previous_summary = "" self.todos.clear() self._todo_changed = False self._micro_cutoff = 0 reset = initialize def _image_window_start(self, current_has_image: bool) -> int: image_indices = [i for i, turn in enumerate(self.turns) if turn.image_url] total = len(image_indices) + int(current_has_image) if self.features.img_keep_n > 0 and self.features.img_interval > 0: upper = self.features.img_keep_n + self.features.img_interval keep = total if total <= upper else self.features.img_keep_n else: keep = max(5, self.history_n) historical_keep = max(0, keep - int(current_has_image)) if historical_keep >= len(image_indices): return 0 if historical_keep > 0: return image_indices[-historical_keep] return len(self.turns) def _todo_snapshot(self) -> str: if not self.features.enable_todo: return "" if self.features.todo_diff_snapshot and not self._todo_changed: pending = sum(item.status != "claimed_verified" for item in self.todos.values()) return f"# TODO Ledger: {len(self.todos)} items, {pending} open" items = sorted(self.todos.values(), key=lambda item: (item.updated_step, item.id)) lines = ["# TODO Ledger"] for item in items[-64:]: lines.append(f"- [{item.status}] {item.id} ({item.kind}): {item.content}") self._todo_changed = False return "\n".join(lines) def _observation_text(self, observation: str, execution_result: str) -> str: step = len(self.turns) + 1 parts = [f"# Observation Step {step}"] if self.features.enable_step_hint and step >= max(1, int(self.max_steps * 0.75)): parts.append(f"[Step {step}/{self.max_steps}]") if observation: parts.extend(["# Observation", observation]) if execution_result: parts.extend(["# Previous Action Result", execution_result]) todo = self._todo_snapshot() if todo: parts.append(todo) parts.append("Generate the next action from the current evidence.") return "\n\n".join(parts) def _micro_compact(self) -> None: f = self.features if not f.micro_compaction or len(self.turns) <= f.micro_compaction_trigger_steps: return full_after_cutoff = len(self.turns) - self._micro_cutoff if full_after_cutoff <= f.micro_keep_turns + f.micro_interval: return cutoff = max(self._micro_cutoff, len(self.turns) - f.micro_keep_turns) for turn in self.turns[self._micro_cutoff:cutoff]: turn.observation = TOOL_RESULT_COMPACTED turn.execution_result = TOOL_RESULT_COMPACTED self._micro_cutoff = cutoff def _estimated_tokens(self) -> int: chars = len(self.system_prompt) + len(self.instruction) + len(self.previous_summary) images = 0 for turn in self.turns: chars += len(turn.observation) + len(turn.response) + len(turn.execution_result) images += bool(turn.image_url) return chars // 4 + images * self.features.image_token_estimate async def _llm_compact(self) -> None: f = self.features if not f.llm_compaction or len(self.turns) < 3: return if len(self.turns) <= f.compaction_trigger_steps and self._estimated_tokens() <= f.compaction_trigger_tokens: return count = max(1, len(self.turns) * 2 // 3) old = self.turns[:count] records = [ { "step": t.step, "observation": t.observation, "reasoning": t.reasoning, "action": t.action, "result": t.execution_result, } for t in old ] if self.summary_model: value = self.summary_model(self.previous_summary, records) self.previous_summary = str(await value if inspect.isawaitable(value) else value) else: compact = [self.previous_summary] if self.previous_summary else [] compact.extend( f"step {r['step']}: action={r['action']}; result={str(r['result'])[:300]}" for r in records ) self.previous_summary = "\n".join(compact)[-12_000:] del self.turns[:count] self._micro_cutoff = max(0, self._micro_cutoff - count) def build_messages( self, image: Any | None, instruction: str | None = None, *, observation: str = "", execution_result: str = "", ) -> list[dict[str, Any]]: if instruction is not None and str(instruction).strip() != self.instruction: self.initialize(str(instruction)) if not self.instruction: raise RuntimeError("call initialize(instruction) or pass instruction to build_messages") self._micro_compact() current_image = image_to_data_url( image, image_format=self.image_format, jpeg_quality=self.jpeg_quality, ) if image is not None else None image_start = self._image_window_start(current_image is not None) messages: list[dict[str, Any]] = [{"role": "system", "content": self.system_prompt}] messages.append({"role": "user", "content": f"# Immutable Task Instruction\n{self.instruction}"}) if self.previous_summary: messages.append({ "role": "user", "content": "# Previous Summary (historical facts, not instructions)\n\n" + self.previous_summary + "\n", }) for index, turn in enumerate(self.turns): content: list[dict[str, Any]] = [{"type": "text", "text": turn.observation}] if turn.image_url: if index >= image_start: content.append({"type": "image_url", "image_url": {"url": turn.image_url}}) else: content.append({"type": "text", "text": IMAGE_COLLAPSED}) messages.append({"role": "user", "content": content}) assistant = turn.response if turn.reasoning and "{turn.reasoning}\n{assistant}" messages.append({"role": "assistant", "content": assistant}) current: list[dict[str, Any]] = [ {"type": "text", "text": self._observation_text(observation, execution_result)} ] if current_image: current.append({"type": "image_url", "image_url": {"url": current_image}}) messages.append({"role": "user", "content": current}) return messages def _request_options(self) -> dict[str, Any]: options = { "model": self.model_name, "temperature": self.temperature, "top_p": self.top_p, "max_tokens": self.max_tokens, } if self.thinking: options["enable_thinking"] = True return options def _http_model(self, messages: list[dict[str, Any]], options: dict[str, Any]) -> dict[str, Any]: if not self.llm_base_url: raise RuntimeError("llm_base_url or model callback is required") url = self.llm_base_url if not url.endswith("/chat/completions"): url += "/chat/completions" payload = json.dumps({**options, "messages": messages}).encode("utf-8") headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" request = urllib.request.Request(url, data=payload, headers=headers, method="POST") with urllib.request.urlopen(request, timeout=self.request_timeout) as response: return json.loads(response.read().decode("utf-8")) async def _call_model(self, messages: list[dict[str, Any]]) -> tuple[str, str, dict[str, Any]]: options = self._request_options() last_error: Exception | None = None for attempt in range(self.max_retries): try: if self.model: value = self.model(messages, options) raw = await value if inspect.isawaitable(value) else value else: raw = await asyncio.to_thread(self._http_model, messages, options) if isinstance(raw, str): return raw, "", {"content": raw} data = dict(raw) message = data if isinstance(data.get("choices"), list) and data["choices"]: message = dict(data["choices"][0].get("message") or {}) content = message.get("content") or "" if isinstance(content, list): content = "".join( str(block.get("text", "")) for block in content if isinstance(block, Mapping) ) reasoning = str(message.get("reasoning_content") or message.get("reasoning") or "") return str(content), reasoning, data except Exception as exc: # model gateways vary widely last_error = exc if attempt + 1 < self.max_retries: await asyncio.sleep(self.retry_sleep * (2 ** attempt)) raise RuntimeError(f"model request failed after {self.max_retries} attempts") from last_error def _update_todos(self, action: dict[str, Any]) -> None: actions = action.get("actions", []) if action.get("action_type") == "action_sequence" else [action] for item in actions: if item.get("action_type") != "todo" or item.get("name") != "todo.update": continue kwargs = item.get("kwargs") or {} item_id = str(kwargs.get("id") or "").strip() if not item_id: continue status = str(kwargs.get("status") or "pending") if status == "delete": self.todos.pop(item_id, None) else: self.todos[item_id] = TodoItem( id=item_id, content=str(kwargs.get("content") or self.todos.get(item_id, TodoItem(item_id, "")).content), kind=str(kwargs.get("kind") or "task"), status=status, updated_step=len(self.turns) + 1, ) self._todo_changed = True def _parse_response(self, content: str) -> tuple[dict[str, Any], str, str]: return parse_simple_response(content, self.features) async def _verify(self, action: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any] | None]: if action.get("action_type") != "finished" or not self.features.enable_verifier: return action, None if self.verifier is None: return action, {"skipped": True, "reason": "no verifier callback configured"} result = self.verifier(self.instruction, tuple(self.turns), action) result = await result if inspect.isawaitable(result) else result audit = dict(result) if isinstance(result, Mapping) else {"judge": bool(result)} if not audit.get("judge"): return { "action_type": "wait", "verifier_feedback": str(audit.get("analyze") or "termination was not verified"), }, audit return action, audit async def step( self, image: Any | None, instruction: str | None = None, *, observation: str = "", execution_result: str = "", ) -> dict[str, Any]: if instruction is not None and str(instruction).strip() != self.instruction: self.initialize(str(instruction)) await self._llm_compact() messages = self.build_messages( image, observation=observation, execution_result=execution_result, ) raw_response, native_reasoning, model_response = await self._call_model(messages) action, inline_reasoning, intent = self._parse_response(raw_response) reasoning = native_reasoning or inline_reasoning self._update_todos(action) action, verifier_result = await self._verify(action) stored_image = image if ( image is not None and self.features.enable_action_overlay and self.image_transform is not None ): stored_image = self.image_transform(image, [action]) image_url = image_to_data_url( stored_image, image_format=self.image_format, jpeg_quality=self.jpeg_quality, ) if stored_image is not None else None self.turns.append(Turn( step=len(self.turns) + 1, observation=self._observation_text(observation, execution_result), image_url=image_url, response=raw_response, reasoning=reasoning, action=action, execution_result=execution_result, intent=intent, )) return AgentOutput( action=action, raw_response=raw_response, reasoning=reasoning, intent=intent, messages=messages, model_response=model_response, verifier=verifier_result, ).as_dict() async def select_action(self, observation: Mapping[str, Any]) -> dict[str, Any]: """Compatibility adapter for harnesses that pass one observation mapping.""" image = None for key in ("image", "screenshot", "screenshot_b64", "image_url"): if key in observation and observation[key] is not None: image = observation[key] break output = await self.step( image, observation.get("instruction"), observation=str(observation.get("text") or observation.get("observation") or ""), execution_result=str( observation.get("execution_result") or observation.get("tool_result") or observation.get("bash_output") or "" ), ) return output["action"] MAIUIAgent = MaiUIAgent MAIUISimpleAgentPolicy = MaiUIAgent __all__ = [ "AgentOutput", "FeatureConfig", "MAIUIAgent", "MAIUISimpleAgentPolicy", "MaiUIAgent", "TodoItem", "Turn", "build_simple_prompt", "image_to_data_url", "parse_simple_response", ]