Files
vllm-omni/benchmarks/edge_harness/android_agent/agent.py
T
tzhouamandClaude Opus 5 b36a3431bd Package this fork as vllm-embedding: README, docs, roadmap, harness
Turns a branch of accumulated edge/CPU work into something someone else can
read, reproduce and continue.

README.md states what the fork is for (multimodal models on edge CPUs rather
than servers), the headline memory and speed results with their costs, and why
the numbers are worth reading -- five retracted conclusions, all measurement
errors rather than coding errors. Upstream's README is preserved at
docs/README.vllm-omni.md and NOTICE records the Apache-2.0 derivation and the
files this fork changes.

docs/edge/:
  methodology.md  the measurement discipline, which is the most transferable
                  thing here: what each of the five retractions was, and the
                  rule it left behind. Includes the two traps specific to this
                  stack -- vLLM's AOT compile cache keying on config so two
                  arms sharing an artifact load the wrong graph, and allocated
                  not being resident until mincore(2) says so.
  results.md      every measured figure with its spread and baseline, and an
                  explicit note that the four memory levers overlap and must
                  not be added.
  cpu-4bit.md     the two 4-bit paths, the packed layout as measured rather
                  than assumed, why the path choice is a config field, and the
                  GPTQ output-head trap that invalidated a whole round of W4A8
                  numbers.
  harness.md      how to run a comparison, and the two arguments that matter
                  more than they look (--context-len, --prompt-len), each of
                  which has produced a wrong conclusion by being left at its
                  default.

ROADMAP.md orders by whether the verification method is correct rather than by
expected size, lists what is done with results, what is next with expected
gains and how each would be verified, and -- at equal length -- what was
considered and rejected, including the CPU megakernel with the measurements
that rule it out for now.

benchmarks/edge_harness/ brings the tooling into the repo so the numbers are
reproducible: the grid runner, the single-run benchmarks, the memory ledger,
the fidelity metric, the Android agent suite, the quantizers, and the tests
that pin the harness's judgement. 82 tests pass from the new location.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L1ax5wYLA6XbqEL1i8L9gZ
2026-09-14 13:25:32 +08:00

272 lines
11 KiB
Python

"""The observe-decide-act loop.
Three things in here are not generic agent scaffolding; they are direct
responses to how this model failed when it was measured one step at a time
(analysis/spark_edge_support.md section 5):
* **A step budget, not a bigger token cap.** On the one genuinely ambiguous
screen the model reasoned past 1200 tokens without closing `<think>`, and
vLLM's reasoning parser only emits content once the block closes -- so the
reply came back empty, with no tool call at all. Raising the cap buys a
longer silence. The loop instead retries that step with thinking disabled,
which measured 0.61 s against 8.9 s, and takes the weaker-but-present answer.
* **Detecting the no-op.** The clearest wrong action was tapping a Wi-Fi
switch that was already on -- undoing the task it had just completed. An
action that leaves the screen signature unchanged is reported back as such,
because "nothing happened" is information the model cannot otherwise have.
* **Repeat suppression.** Two identical actions on an identical screen means
stuck, and a third will not help. The loop says so in the transcript and
stops, rather than burning the budget.
"""
from __future__ import annotations
import json
import time
from dataclasses import dataclass, field
from urllib import error as urlerror
from urllib import request as urlrequest
from action_space import SYSTEM_PROMPT, TOOLS
from device import Device
from executor import ActionResult, execute
from tool_parse import extract_tool_call
from ui_tree import Element, parse_ui_dump, render_tree, screen_signature
@dataclass
class AgentConfig:
base_url: str = "http://127.0.0.1:8077/v1"
model: str = "XHToken/Spark-X2.5-1.7B"
max_steps: int = 12
think: bool = True
think_fallback: bool = True
"""On an empty reply, retry the step without thinking rather than fail."""
max_tokens: int = 1024
temperature: float = 0.0
timeout: float = 900.0
history: int = 6
retries: int = 3
"""Transient HTTP failures retried before a step is abandoned."""
retry_backoff_s: float = 2.0
"""Recent actions shown back to the model."""
@dataclass
class Step:
n: int
screen: str
tool: str | None
args: dict
result: ActionResult | None
latency_s: float
thinking: bool
note: str = ""
parsed_via: str = "tool_calls"
"""Which parser recovered the call; anything but tool_calls is worth seeing."""
@dataclass
class Episode:
task: str
steps: list[Step] = field(default_factory=list)
done: bool = False
summary: str = ""
stop_reason: str = ""
@property
def n_steps(self) -> int:
return len(self.steps)
def transcript(self) -> str:
lines = [f"task: {self.task}"]
for s in self.steps:
call = f"{s.tool}({json.dumps(s.args, ensure_ascii=False)})" if s.tool else "(no tool call)"
outcome = s.result.message if s.result else s.note
if s.parsed_via not in ("tool_calls", "none"):
outcome += f" [recovered via {s.parsed_via}]"
lines.append(f" {s.n:2d}. {call:48s} -> {outcome} [{s.latency_s:.2f}s]")
lines.append(f"stopped: {self.stop_reason}")
return "\n".join(lines)
class ChatError(RuntimeError):
pass
def chat(cfg: AgentConfig, messages: list[dict], *, think: bool) -> tuple[dict, float]:
payload = {
"model": cfg.model,
"messages": messages,
"tools": TOOLS,
"tool_choice": "auto",
"temperature": cfg.temperature,
"max_tokens": cfg.max_tokens,
}
if not think:
# Spark's chat template opens <think> by default; closing it up front
# is what makes the model answer directly.
payload["chat_template_kwargs"] = {"enable_thinking": False}
req = urlrequest.Request(
f"{cfg.base_url}/chat/completions",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
)
t0 = time.perf_counter()
last: Exception | None = None
for attempt in range(max(1, cfg.retries)):
try:
with urlrequest.urlopen(req, timeout=cfg.timeout) as r:
return json.loads(r.read()), time.perf_counter() - t0
except (urlerror.URLError, TimeoutError, json.JSONDecodeError) as exc:
# A phone task is a dozen round trips; one dropped connection
# should cost a retry, not the episode.
last = exc
if attempt + 1 < max(1, cfg.retries):
time.sleep(cfg.retry_backoff_s * (attempt + 1))
raise ChatError(
f"{cfg.base_url} failed after {cfg.retries} attempts: {last}") from last
def wait_for_server(cfg: AgentConfig, timeout_s: float = 300.0) -> bool:
"""Block until the endpoint serves a model list, or give up.
Starting an episode against a server that is still loading produces a
confusing transcript full of connection errors; this turns that into one
clear wait.
"""
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
try:
with urlrequest.urlopen(f"{cfg.base_url}/models", timeout=5) as r:
if b'"id"' in r.read():
return True
except Exception:
time.sleep(2.0)
return False
def parse_response(response: dict):
"""The one call to act on, from whichever shape the model used.
Spark does not always stay in GLM-4.5 tool format -- see tool_parse -- and
on a phone a dropped step is a stalled task, so the fallbacks matter.
"""
try:
message = response["choices"][0]["message"]
except (KeyError, IndexError):
message = {}
return extract_tool_call(message)
def first_tool_call(response: dict) -> tuple[str | None, dict]:
"""Name and arguments only, for callers that do not care how it parsed."""
parsed = parse_response(response)
return parsed.name, parsed.args
def _user_message(task: str, screen: str, log: list[str], history: int) -> str:
parts = [f"Task: {task}", "", "Current screen:", screen]
if log:
parts += ["", "Actions so far:"] + [f" {line}" for line in log[-history:]]
parts += ["", "Choose the next action. Call exactly one tool."]
return "\n".join(parts)
def observe(device: Device) -> tuple[list[Element], str, str]:
elements = parse_ui_dump(device.dump())
return elements, render_tree(elements), screen_signature(elements)
def run_episode(device: Device, task: str, cfg: AgentConfig | None = None) -> Episode:
cfg = cfg or AgentConfig()
episode = Episode(task=task)
log: list[str] = []
recent: list[tuple[str, str]] = [] # (action, screen signature before it)
signature_before_last: str | None = None
last_action: str | None = None
oscillations = 0
for n in range(1, cfg.max_steps + 1):
elements, screen, signature = observe(device)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user",
"content": _user_message(task, screen, log, cfg.history)},
]
thinking = cfg.think
response, latency = chat(cfg, messages, think=thinking)
parsed = parse_response(response)
tool, args = parsed.name, parsed.args
if tool is None and cfg.think_fallback and thinking:
# The measured failure: reasoning never closed, so the parser
# emitted nothing. A second, non-thinking pass is worth ~0.6 s.
thinking = False
retry, retry_latency = chat(cfg, messages, think=False)
latency += retry_latency
parsed = parse_response(retry)
tool, args = parsed.name, parsed.args
if tool is None:
episode.steps.append(Step(n, screen, None, {}, None, latency, thinking,
note="model returned no tool call",
parsed_via=parsed.source))
episode.stop_reason = "no tool call, even without thinking"
return episode
if (tool_key := f"{tool}:{json.dumps(args, sort_keys=True)}") and \
recent.count((tool_key, signature)) >= 2:
episode.steps.append(Step(n, screen, tool, args, None, latency, thinking,
note="repeated a no-op action",
parsed_via=parsed.source))
episode.stop_reason = (
f"stuck: {tool} repeated on an unchanged screen"
)
return episode
recent.append((tool_key, signature))
result = execute(tool, args, elements, device)
if result.ok and not result.done:
_, _, after = observe(device)
if after == signature:
# Say so out loud: the model has no other way to learn that
# its action changed nothing.
result = ActionResult(True, result.message + " -- the screen did not change",
action=result.action)
elif tool_key == last_action and after == signature_before_last:
# Oscillation: the *same* action, undoing what it just did --
# the measured failure, tapping a switch off and on forever.
# It hides from the no-change check because each individual tap
# really does change the screen. Deliberately narrow: revisiting
# a screen by other means (open a submenu, press back) is normal
# navigation and must not trip this.
result = ActionResult(
True,
result.message + " -- that undid your previous action; the "
"setting is now back where it started",
action=result.action,
)
oscillations += 1
if oscillations >= 2:
episode.steps.append(Step(n, screen, tool, args, result, latency,
thinking, parsed_via=parsed.source))
episode.stop_reason = "stuck: repeatedly undoing its own action"
return episode
signature_before_last, last_action = signature, tool_key
episode.steps.append(Step(n, screen, tool, args, result, latency, thinking,
parsed_via=parsed.source))
log.append(f"{tool} -> {result.message}")
if result.done:
episode.done = True
episode.summary = result.summary
episode.stop_reason = "task_complete"
return episode
episode.stop_reason = f"step budget exhausted ({cfg.max_steps})"
return episode