Skip to content
21 changes: 15 additions & 6 deletions artemis/agents/checker/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -408,15 +408,17 @@ def _format_check_items(check_items: list) -> str:


async def _structured_report(llm, messages) -> CheckReport:
structured_llm = llm.with_structured_output(CheckReport)
structured_llm = llm.with_structured_output(
CheckReport, method="function_calling", tool_choice="auto"
)
# A conversation must end with a user turn: Gemini rejects requests whose
# last message is the model's own answer ("Requests ending with a model
# turn are not supported"), which is exactly the state after the loop's
# final tool-free reply. Nudge with an explicit verdict request instead.
if messages and isinstance(messages[-1], AIMessage):
messages = [
*messages,
HumanMessage(content="Now provide your structured verdict report."),
HumanMessage(content="Now call the CheckReport tool with your structured verdict report."),
]
result = await invoke_llm_with_timeout_message(structured_llm.ainvoke(messages))
if isinstance(result, CheckReport):
Expand Down Expand Up @@ -482,7 +484,7 @@ async def _run_check_loop(
messages.append(
HumanMessage(
content=(
"This is your final iteration; provide your structured verdict report now."
"This is your final iteration; call CheckReport with your verdict report now."
)
)
)
Expand All @@ -497,6 +499,7 @@ async def _run_check_loop(
break

messages.append(response)
deferred_images: list[HumanMessage] = []
for tc in response.tool_calls:
tool_name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
args = dict(tc["args"])
Expand All @@ -522,9 +525,15 @@ async def _run_check_loop(
# Screenshots (get_step_screenshot) enter the conversation in the
# carrier the model's provider accepts; text results stay a plain
# ToolMessage.
messages.extend(
tool_result_messages(tc["id"], result_obj, name=tool_name, status=status, llm=llm)
)
for message in tool_result_messages(
tc["id"], result_obj, name=tool_name, status=status, llm=llm
):
if isinstance(message, HumanMessage):
deferred_images.append(message)
else:
messages.append(message)
# Complete every tool_call before introducing user-carried screenshots.
messages.extend(deferred_images)

if report is None:
report = CheckReport(verdicts=[])
Expand Down
6 changes: 6 additions & 0 deletions artemis/agents/flash/flash_runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@ You are an autonomous and highly efficient Android Device Execution Agent. Your

**Objective: {{ goal }}**

The same original objective as an ASCII-escaped JSON string (an exact character reference):
```json
{{ goal_json }}
```
When entering user-provided text, copy the requested text exactly from the original objective. Preserve emoji, variation selectors, skin tones, joiners, punctuation and whitespace; do not substitute a visually similar character or reinterpret a symbol by its name. JSON Unicode escapes may be used in tool arguments; they decode to the actual characters, not literal backslash text. Before reporting completion, compare the field's contents with the original requested text, not just with your previous tool arguments.

---

# 1. COGNITIVE PROTOCOL
Expand Down
6 changes: 5 additions & 1 deletion artemis/agents/flash/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,11 @@ def _render_system_prompt(self, tools_declaration: list) -> str:
prompt_path = Path(__file__).parent / "flash_runner.md"
prompt_template = prompt_path.read_text(encoding="utf-8")
available_tools = frozenset(t.name for t in tools_declaration)
return Template(prompt_template).render(goal=self.goal, available_tools=available_tools)
return Template(prompt_template).render(
goal=self.goal,
goal_json=json.dumps(self.goal, ensure_ascii=True),
available_tools=available_tools,
)

# ------------------------------------------------------------------
# Per-turn helpers (observe / think)
Expand Down
2 changes: 1 addition & 1 deletion artemis/agents/flash/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ def __init__(
if model_name:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
else:
self._llm = get_llm(ctx, name="summarizer", is_utils=True)
self._llm = get_llm(ctx, name="summarizer")
except Exception:
self._llm = get_google_llm(model_name=target_model, temperature=0.0)
try:
Expand Down
22 changes: 16 additions & 6 deletions artemis/agents/outputter/outputter.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pathlib import Path

from jinja2 import Template
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage
from langchain_core.messages import BaseMessage, HumanMessage, SystemMessage, ToolMessage
from artemis.core.tool_failure import ToolFailure, is_tool_failure
from artemis.config import OutputConfig
from artemis.context import ArtemisContext
Expand Down Expand Up @@ -268,6 +268,8 @@ async def outputter(
raw_answer = response.content
break

tool_replies: list[ToolMessage] = []
image_messages: list[BaseMessage] = []
for tc in response.tool_calls:
tool_name = tc["name"].split(":")[-1] if ":" in tc["name"] else tc["name"]
args = tc["args"]
Expand Down Expand Up @@ -304,11 +306,19 @@ async def outputter(
result = f"Error running tool {tool_name}: {e}"
status = "error"

# Step screenshots enter the conversation in the carrier the
# model's provider accepts; text results stay a plain ToolMessage.
messages.extend(
tool_result_messages(tc["id"], result, name=tool_name, status=status, llm=llm)
)
# OpenAI-compatible providers carry screenshot images in a user
# message. All tool calls must receive their replies before that
# message is inserted into the conversation.
for message in tool_result_messages(
tc["id"], result, name=tool_name, status=status, llm=llm
):
if isinstance(message, ToolMessage):
tool_replies.append(message)
else:
image_messages.append(message)

messages.extend(tool_replies)
messages.extend(image_messages)

if raw_answer is None:
raw_answer = "Error: Outputter failed to resolve the query within maximum turns."
Expand Down
14 changes: 12 additions & 2 deletions artemis/clients/screen_client_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,18 @@ def get_screenshot_base64(self) -> str | None:
def set_clipboard(self, text: str) -> bool:
return self._call("set_clipboard", text)

def send_text(self, text: str) -> Any:
return self._call("send_text", text)
def send_text(self, text: str) -> bool:
result = self._call("send_text", text)
if result is False and self._active_backend == "helper":
# A healthy helper may observe a field but be unable to set its text.
# Try IME input without marking the whole helper service unavailable.
self._ensure_device_online()
try:
return self.uiautomator.send_text(text)
finally:
# UiAutomation can unbind the helper even when input fails.
self._set_active("uiautomator", "Helper rejected direct text input")
return result

def clear_text(self) -> bool:
return self._call("clear_text")
Expand Down
3 changes: 2 additions & 1 deletion artemis/clients/ui_automator_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,7 +324,7 @@ def press_key(self, key: str):
device = self._ensure_connected()
return device.press(key=key)

def send_text(self, text: str) -> None:
def send_text(self, text: str) -> bool:
"""Send text input to the device using FastInputIME.

This method supports special characters (e.g., 'ö') that ADB shell
Expand All @@ -343,6 +343,7 @@ def send_text(self, text: str) -> None:
# Give FastInputIME time to process the broadcast and commit text
# before switching it off and killing it.
time.sleep(0.5)
return True
finally:
device.set_fastinput_ime(False)

Expand Down
23 changes: 11 additions & 12 deletions artemis/drivers/android/adb_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -325,21 +325,15 @@ async def input_text(self, text: str, clear_existing: bool = True) -> bool:
# Normalize literal escaped newlines from LLM / tool call serialization
norm_text = text.replace(r"\r\n", "\n").replace(r"\n", "\n").replace(r"\r", "\n")

# 1. Tier 1: Try clipboard injection + KEYCODE_PASTE (Zero IME interference, preserves multiline, works for all charsets)
# Prefer direct input. A background clipboard write can be silently
# denied by Android even when the helper reports success.
if self._ui_adb_client:
try:
set_clip_ok = False
if hasattr(self._ui_adb_client, "set_clipboard"):
set_clip_ok = self._ui_adb_client.set_clipboard(norm_text)
elif hasattr(self._ui_adb_client, "_device") and self._ui_adb_client._device:
self._ui_adb_client._device.set_clipboard(norm_text)
set_clip_ok = True

if set_clip_ok:
await asyncio.to_thread(self.device.shell, "input keyevent 279")
result = self._ui_adb_client.send_text(norm_text)
if result is True:
return True
except Exception as e:
logger.debug(f"Clipboard paste fallback to ADB input: {e}")
logger.debug(f"Direct text input failed, trying ADBKeyboard: {e}")

# 2. Tier 2: Check if ADBKeyboard is currently active
try:
Expand All @@ -355,7 +349,12 @@ async def input_text(self, text: str, clear_existing: bool = True) -> bool:
# ADBKeyboard probe/broadcast failed; fall through to native input.
logger.debug(f"ADBKeyboard IME path failed, falling back to ADB input: {e}")

# 3. Tier 3: Universal Native ADB input text fallback
# Native adb input text cannot reliably enter Unicode characters.
if not norm_text.isascii():
logger.warning("Unicode input failed: no supported input channel succeeded")
return False

# 3. Tier 3: Native ADB input text fallback for ASCII
lines = norm_text.split("\n")
for i, line in enumerate(lines):
if i > 0:
Expand Down
18 changes: 15 additions & 3 deletions artemis/interfaces/cli/commands/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@

from adbutils import AdbClient
from langchain_core.callbacks.base import Callbacks
from artemis.config import checker_overrides_for_level, initialize_llm_config, settings
from artemis.config import (
checker_overrides_for_level,
initialize_llm_config,
load_agent_config,
settings,
)
from artemis.utils.startup_progress import publish_startup_progress
from artemis import Agent, Builders
from artemis.sdk.types.task import AgentProfile
Expand Down Expand Up @@ -116,9 +121,16 @@ async def execute_task(
config.with_flash_step_summarizer(enabled=enable_step_summarizer)

if enable_outputter is not None or force_output_synthesis is not None:
outputter_defaults = load_agent_config().outputter
config.with_outputter(
enabled=enable_outputter if enable_outputter is not None else True,
force_synthesis=bool(force_output_synthesis),
enabled=enable_outputter
if enable_outputter is not None
else outputter_defaults.enabled,
force_synthesis=(
force_output_synthesis
if force_output_synthesis is not None
else outputter_defaults.force_synthesis
),
)

if (
Expand Down
14 changes: 14 additions & 0 deletions artemis/sdk/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import asyncio
import inspect
import json
import os
import re

Expand Down Expand Up @@ -101,6 +102,7 @@
remove_steps_json_from_trace_folder,
)
from artemis.utils.startup_progress import publish_startup_progress
from artemis.utils.notes import save_note_content

logger = get_logger(__name__)

Expand Down Expand Up @@ -1267,6 +1269,18 @@ async def _extract_output(
output_config=output_config or OutputConfig(),
graph_output=state,
)
if ctx.data_engine and structured_output is not None:
report = (
structured_output
if isinstance(structured_output, str)
else "```json\n"
+ json.dumps(structured_output, ensure_ascii=False, indent=2)
+ "\n```"
)
try:
save_note_content(ctx.data_engine.base_dir, "output", report)
except OSError as exc:
logger.warning(f"[{task_name}] Failed to save Task Report: {exc}")
logger.info(f"[{task_name}] Structured output: {structured_output}")
record_events(
output_path=request.llm_output_path,
Expand Down
16 changes: 16 additions & 0 deletions tests/unit/agents/test_flash_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -556,3 +556,19 @@ async def test_final_report_persists_native_thinking(mock_context):
assert kwargs["operator_raw_thinking"] == "final text"
assert kwargs["operator_native_thinking"] == "native summary"
assert isinstance(messages[-1], ToolMessage)


@pytest.mark.parametrize('text', ['你好中文英😅', '👍🏽👩‍💻🇨🇳❤️', r'路径\n"原文"'])
def test_prompt_preserves_literal_unicode_goal_as_json(mock_context, text):
import json

goal = '在当前输入框输入:' + text
with patch('artemis.controllers.unified_controller.get_driver'):
runner = FlashRunner(mock_context, goal=goal)
prompt = runner._render_system_prompt(runner._get_tools())
# A second, ASCII-only representation makes the exact code points available
# even when the model misreads an emoji glyph (😅 was changed to ㊅ in a trace).
encoded = json.dumps(goal, ensure_ascii=True)
assert encoded in prompt
assert json.loads(encoded) == goal
assert 'original objective' in prompt
60 changes: 60 additions & 0 deletions tests/unit/agents/test_outputter.py
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,66 @@ def get_llm_side_effect(ctx, name, is_utils=False, use_fallback=False):
assert result == "Video played successfully."


@patch("artemis.agents.outputter.outputter.get_read_note_tool_pure")
@patch("artemis.agents.outputter.outputter.get_history_tools")
@patch("artemis.agents.outputter.outputter.get_llm")
@pytest.mark.asyncio
async def test_outputter_replies_to_all_tools_before_screenshot_images(
mock_get_llm, mock_get_history_tools, mock_get_read_note, mock_context, mock_state
):
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
from langchain_core.tools import StructuredTool

async def screenshot(step_number: int):
return [
{"type": "text", "text": f"Screenshot of step {step_number}"},
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,AA=="}},
]

async def read_note(key: str):
return f"Note {key}"

screenshot_tool = StructuredTool.from_function(
coroutine=screenshot, name="get_step_screenshot", description="Get screenshot"
)
read_note_tool = StructuredTool.from_function(
coroutine=read_note, name="read_note", description="Read note"
)
mock_get_history_tools.return_value = (screenshot_tool, screenshot_tool, screenshot_tool)
mock_get_read_note.return_value = read_note_tool

model = Mock()
model.endpoint.provider = "deepseek"
bound_model = Mock()
bound_model.ainvoke = AsyncMock(
side_effect=[
AIMessage(
content="",
tool_calls=[
{"name": "get_step_screenshot", "args": {"step_number": 10}, "id": "shot-10"},
{"name": "read_note", "args": {"key": "result"}, "id": "note"},
{"name": "get_step_screenshot", "args": {"step_number": 11}, "id": "shot-11"},
],
),
AIMessage(content="Verified.", tool_calls=[]),
]
)
model.bind_tools.return_value = bound_model
mock_get_llm.return_value = model

answer = await outputter(
ctx=mock_context,
output_config=OutputConfig(structured_output=None, output_description=None),
graph_output=mock_state,
)

sent = bound_model.ainvoke.call_args_list[1].args[0]
assert [message.tool_call_id for message in sent[3:6]] == ["shot-10", "note", "shot-11"]
assert all(isinstance(message, ToolMessage) for message in sent[3:6])
assert all(isinstance(message, HumanMessage) for message in sent[6:8])
assert answer == "Verified."


@patch("artemis.agents.outputter.outputter.get_llm")
@pytest.mark.asyncio
async def test_outputter_executes_save_note_tool(mock_get_llm, mock_context, mock_state):
Expand Down
Loading
Loading