Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
6 changes: 4 additions & 2 deletions artemis/agents/hopper/hopper.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,14 @@ async def hopper(
HumanMessage(content=f"{request}\nHere is the data you must dig:\n{data}"),
]

llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(HopperOutput)
llm = get_llm(ctx=ctx, name="hopper", is_utils=True).with_structured_output(
HopperOutput, method="function_calling", tool_choice="auto"
)
try:
if use_fallback:
llm_fallback = get_llm(
ctx=ctx, name="hopper", is_utils=True, use_fallback=True
).with_structured_output(HopperOutput)
).with_structured_output(HopperOutput, method="function_calling", tool_choice="auto")
response: HopperOutput = await with_fallback(
main_call=lambda: invoke_llm_with_timeout_message(llm.ainvoke(messages)),
fallback_call=lambda: invoke_llm_with_timeout_message(
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
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/artemis-accessibility-helper/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ android {
applicationId = "com.artemis.helper"
minSdk = 24
targetSdk = 35
versionCode = 6
versionName = "1.2.0"
versionCode = 8
versionName = "1.2.2"
}

buildTypes {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,11 @@ private static AccessibilityNodeInfo childOf(AccessibilityNodeInfo node, int ind
* Tier 3: Focused input / accessibility node backtracking (walks up parent chain to top root).
*/
private static List<RawRootEntry> getActiveRawRoots(AccessibilityService service) {
// A snapshot or input action must use current nodes. On some OEM builds,
// cached focus/text can survive several actions in the same window.
if (Build.VERSION.SDK_INT >= 33) {
service.clearCache();
}
List<RawRootEntry> roots = new ArrayList<>();
Set<Integer> seenHashes = new HashSet<>();
boolean hasAppWindow = false;
Expand Down Expand Up @@ -788,10 +793,14 @@ public static AccessibilityNodeInfo findInputNode(AccessibilityService service)
for (RawRootEntry entry : roots) {
try {
AccessibilityNodeInfo focused = entry.root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT);
if (focused != null) {
if (focused != null && focused.isEditable() && focused.isEnabled()
&& focused.isVisibleToUser()) {
found = focused;
return found;
}
if (focused != entry.root) {
safeRecycle(focused);
}
} catch (Throwable ignored) {}
}
for (RawRootEntry entry : roots) {
Expand Down
4 changes: 2 additions & 2 deletions packages/artemis-accessibility-helper/build_apk.sh
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,8 @@ if [ -z "$SDK_ROOT" ] || [ ! -d "$SDK_ROOT" ]; then
exit 1
fi

BUILD_TOOLS="$(find "$SDK_ROOT/build-tools" -maxdepth 1 -mindepth 1 | sort -V | tail -n 1)"
PLATFORM="$(find "$SDK_ROOT/platforms" -maxdepth 1 -mindepth 1 | sort -V | tail -n 1)"
BUILD_TOOLS="$(find "$SDK_ROOT/build-tools" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -n 1)"
PLATFORM="$(find "$SDK_ROOT/platforms" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -n 1)"

if [ -z "$BUILD_TOOLS" ] || [ ! -d "$BUILD_TOOLS" ]; then
echo "Error: No build-tools found under $SDK_ROOT/build-tools" >&2
Expand Down
8 changes: 4 additions & 4 deletions packages/artemis-accessibility-helper/helper_manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"package": "com.artemis.helper",
"version_code": 6,
"version_name": "1.2.0",
"sha256": "133e373fe03615c251cd74b03a38539c844d09a074954bca2d2dde6684561969",
"built_at": "2026-09-10T23:13:11+00:00"
"version_code": 8,
"version_name": "1.2.2",
"sha256": "009183e099dccaa8c3fd5c9db0a786e445f55f98d9511d0472185e5a31656e7c",
"built_at": "2026-09-28T05:51:55+00:00"
}
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
Loading
Loading