Improve task resilience and LLM retry visibility

Persist and publish recoverable provider retries so temporary 503 and throttling events remain visible in live and historical task streams.

Aggregate repeated retries into an expandable action card, preserve terminal pause recovery, harden Windows worker isolation and output forwarding, and retain reliable Android hierarchy fallbacks with regression coverage.
This commit is contained in:
somew1nd committed 2026-08-20 00:37:53 -07:00
1 parent c1a4b49a21
commit 0f5fb1d32c
17 files changed
+946 -97

No files matched your search

@@ -177,11 +177,22 @@ class StepRepository:
return None return None
if trace_type == "llm_call": if trace_type == "llm_call":
return ( allowed_keys = (
{"error": self._clean_value(payload_obj.get("error"))} "error",
if "error" in payload_obj "delay",
else None "attempt",
"max_retries",
"provider",
"source",
"recoverable",
"pause",
) )
cleaned = {
key: self._clean_value(payload_obj.get(key))
for key in allowed_keys
if key in payload_obj
}
return cleaned or None
args = payload_obj.get("args", payload_obj) args = payload_obj.get("args", payload_obj)
cleaned_args = ( cleaned_args = (
@@ -291,7 +302,7 @@ class StepRepository:
" t1.payload, t2.name as agent_name FROM traces t1 LEFT" " t1.payload, t2.name as agent_name FROM traces t1 LEFT"
" JOIN traces t2 ON t1.parent_trace_id = t2.trace_id WHERE" " JOIN traces t2 ON t1.parent_trace_id = t2.trace_id WHERE"
" t1.step_id = ? AND (t1.type = 'tool' OR t1.type = 'action' OR (t1.type =" " t1.step_id = ? AND (t1.type = 'tool' OR t1.type = 'action' OR (t1.type ="
" 'llm_call' AND t1.status = 'failed') OR (t1.type = 'log' AND" " 'llm_call' AND t1.status IN ('failed', 'retrying')) OR (t1.type = 'log' AND"
" t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS" " t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS"
" (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND" " (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND"
" t3.type = 'llm_call' AND t3.status = 'failed' AND" " t3.type = 'llm_call' AND t3.status = 'failed' AND"
@@ -363,7 +374,7 @@ class StepRepository:
" t2.name as agent_name FROM traces t1 LEFT JOIN traces t2 ON" " t2.name as agent_name FROM traces t1 LEFT JOIN traces t2 ON"
" t1.parent_trace_id = t2.trace_id WHERE t1.session_id = ? AND" " t1.parent_trace_id = t2.trace_id WHERE t1.session_id = ? AND"
" (t1.step_id IS NULL OR t1.step_id = '') AND (t1.type = 'tool' OR" " (t1.step_id IS NULL OR t1.step_id = '') AND (t1.type = 'tool' OR"
" (t1.type = 'llm_call' AND t1.status = 'failed') OR (t1.type = 'log'" " (t1.type = 'llm_call' AND t1.status IN ('failed', 'retrying')) OR (t1.type = 'log'"
" AND t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS" " AND t1.payload LIKE '%LLM Error:%Pausing execution%' AND NOT EXISTS"
" (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND" " (SELECT 1 FROM traces t3 WHERE t3.session_id = t1.session_id AND"
" t3.type = 'llm_call' AND t3.status = 'failed' AND" " t3.type = 'llm_call' AND t3.status = 'failed' AND"
@@ -13,6 +13,7 @@
# limitations under the License. # limitations under the License.
import asyncio import asyncio
import codecs
from datetime import datetime from datetime import datetime
import os import os
import shutil import shutil
@@ -78,17 +79,66 @@ class TaskQueueService:
] ]
@staticmethod @staticmethod
def _subprocess_creation_kwargs() -> dict[str, int]: def _subprocess_creation_kwargs() -> dict[str, Any]:
"""Keep task workers out of the UI server's Windows console group. """Isolate task workers from the UI server's Windows console.
Without a new process group, a console control event intended for the A new process group alone is insufficient on Windows: the worker still
uvicorn server is also delivered to a task that is still importing shares the parent's console, so a CTRL_C_EVENT generated anywhere in
Artemis, causing STATUS_CONTROL_C_EXIT (0xC000013A). that console can reach the UI server. CREATE_NO_WINDOW removes that
shared console boundary. Output is captured and forwarded explicitly
so detached workers remain visible in the server terminal.
""" """
if sys.platform == "win32": if sys.platform == "win32":
return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} return {
"creationflags": (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
),
"stdout": asyncio.subprocess.PIPE,
"stderr": asyncio.subprocess.STDOUT,
}
return {} return {}
@staticmethod
async def _forward_worker_output(stream: asyncio.StreamReader | None) -> None:
"""Forward a detached Windows worker's combined output without corrupting UTF-8."""
if stream is None:
return
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
while True:
chunk = await stream.read(4096)
if not chunk:
break
text = decoder.decode(chunk)
if text:
sys.stdout.write(text)
sys.stdout.flush()
tail = decoder.decode(b"", final=True)
if tail:
sys.stdout.write(tail)
sys.stdout.flush()
@staticmethod
async def _finish_output_forwarder(output_task: asyncio.Task[None] | None) -> None:
"""Drain final worker output without allowing inherited handles to stall the queue."""
if output_task is None:
return
try:
await asyncio.wait_for(asyncio.shield(output_task), timeout=2.0)
except asyncio.CancelledError:
if output_task.cancelled():
return
raise
except TimeoutError:
output_task.cancel()
try:
await output_task
except asyncio.CancelledError:
pass
except Exception as exc:
print(f"[QueueWorker] Failed to forward detached worker output: {exc}")
@staticmethod @staticmethod
async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None: async def _terminate_worker_process(proc: asyncio.subprocess.Process | None) -> None:
"""Stop a worker without changing the established POSIX behavior.""" """Stop a worker without changing the established POSIX behavior."""
@@ -125,6 +175,7 @@ class TaskQueueService:
sess_id = None sess_id = None
goal = None goal = None
profile = "flash" profile = "flash"
output_task: asyncio.Task[None] | None = None
try: try:
if state.is_running: if state.is_running:
await asyncio.sleep(0.2) await asyncio.sleep(0.2)
@@ -212,6 +263,8 @@ class TaskQueueService:
) )
state.current_process = proc state.current_process = proc
task_item["pid"] = proc.pid task_item["pid"] = proc.pid
if sys.platform == "win32" and isinstance(proc.stdout, asyncio.StreamReader):
output_task = asyncio.create_task(cls._forward_worker_output(proc.stdout))
if sess_id and ( if sess_id and (
str(sess_id) in getattr(state, "cancelled_session_ids", set()) str(sess_id) in getattr(state, "cancelled_session_ids", set())
@@ -255,6 +308,7 @@ class TaskQueueService:
print(f"[QueueWorker] Unexpected error executing task: {e}") print(f"[QueueWorker] Unexpected error executing task: {e}")
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
finally: finally:
await cls._finish_output_forwarder(output_task)
# 5. Clean up completed task from queue and reset execution state # 5. Clean up completed task from queue and reset execution state
if sess_id: if sess_id:
cls._remove_task(sess_id) cls._remove_task(sess_id)
@@ -285,18 +285,33 @@
<!-- Event Logs Section --> <!-- Event Logs Section -->
<main class="stream-logs-content" (scroll)="onStreamScroll($event)"> <main class="stream-logs-content" (scroll)="onStreamScroll($event)">
<!-- Keep recovery available even when a persisted failure trace is absent. -->
<div class="paused-task-banner" *ngIf="isViewingPausedTask()" role="status">
<div class="paused-task-message">
<span class="material-symbols-outlined paused-task-icon">pause_circle</span>
<div>
<strong>Task paused</strong>
<span>The AI request failed. Resume the task to try again.</span>
</div>
</div>
<button
type="button"
class="btn-resume-task"
(click)="resumePausedTask($event)"
title="Resume the paused task"
aria-label="Resume the paused task"
>
<span class="material-symbols-outlined">play_arrow</span>
Resume
</button>
</div>
<div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state"> <div *ngIf="consolidatedBlocks().length === 0 && !showPlanningLoader()" class="empty-state">
<div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div> <div class="empty-icon"><span class="material-symbols-outlined">sensors</span></div>
<h3>No Stream Activity Selected</h3> <h3>No Stream Activity Selected</h3>
<p>Please select a session from the Task List on the right to view its real-time log payloads.</p> <p>Please select a session from the Task List on the right to view its real-time log payloads.</p>
</div> </div>
<!-- Retrying Banner -->
<div class="retrying-banner" *ngIf="agentService.isRetrying()">
<span class="material-symbols-outlined spinning-icon">sync</span>
<span>{{ agentService.retryMessage() || 'Reconnecting to AI model...' }}</span>
</div>
<!-- The phase-based session logs list --> <!-- The phase-based session logs list -->
<div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()"> <div class="logs-list-container" *ngIf="phases().length > 0 || showPlanningLoader()">
<div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase"> <div class="phase-container" *ngFor="let phase of phases(); let first = first; trackBy: trackPhase">
@@ -513,6 +528,48 @@
<!-- Generic Tools & LLM Calls (when item is a tool) --> <!-- Generic Tools & LLM Calls (when item is a tool) -->
<ng-container *ngIf="item.type === 'tool'"> <ng-container *ngIf="item.type === 'tool'">
<!-- Recoverable provider/LLM retries remain visible after the task continues. -->
<div class="step-android-action-card tool-action-card is-retrying"
[class.is-expanded]="isActionExpanded('retry-' + block.id + '-' + itemIdx)"
*ngIf="isLLMRetry(item.data)">
<div class="action-card-header llm-retry-header"
(click)="toggleActionExpanded('retry-' + block.id + '-' + itemIdx, $event)">
<span class="material-symbols-outlined action-icon is-retrying">cloud_sync</span>
<span class="action-title">AI service retry</span>
<span class="action-status-badge is-retrying">
{{ getLLMRetryCount(item.data) }}× retried
</span>
<span class="action-expand-icon material-symbols-outlined">
{{ isActionExpanded('retry-' + block.id + '-' + itemIdx) ? 'expand_less' : 'expand_more' }}
</span>
</div>
<div class="action-card-body">
<div class="retry-summary-grid">
<div class="retry-summary-item">
<span class="retry-summary-label">Attempts</span>
<strong>{{ getLLMRetryCount(item.data) }}</strong>
</div>
<div class="retry-summary-item" *ngIf="getLLMRetryTotalDelay(item.data) as totalDelay">
<span class="retry-summary-label">Extra wait</span>
<strong>{{ totalDelay }}</strong>
</div>
<div class="retry-summary-item" *ngIf="getLLMRetryProvider(item.data) as retryProvider">
<span class="retry-summary-label">Provider</span>
<strong>{{ retryProvider }}</strong>
</div>
</div>
<div class="retry-attempt-list" *ngIf="isActionExpanded('retry-' + block.id + '-' + itemIdx)">
<div class="retry-attempt-item" *ngFor="let retry of getLLMRetryEntries(item.data); let retryIndex = index">
<div class="retry-attempt-heading">
<span>Retry {{ retryIndex + 1 }}</span>
<span class="retry-wait-pill" *ngIf="getLLMRetryEntryDelay(retry) as retryDelay">+{{ retryDelay }}</span>
</div>
<div class="retry-attempt-error">{{ getLLMRetryEntryError(retry) }}</div>
</div>
</div>
</div>
</div>
<!-- Failed LLM Calls (Permanently maintain Action Card format in red) --> <!-- Failed LLM Calls (Permanently maintain Action Card format in red) -->
<div class="step-android-action-card tool-action-card is-failed" *ngIf="item.data.type === 'llm_call' && item.data.status === 'failed'"> <div class="step-android-action-card tool-action-card is-failed" *ngIf="item.data.type === 'llm_call' && item.data.status === 'failed'">
<div class="action-card-header llm-failure-header"> <div class="action-card-header llm-failure-header">
@@ -530,14 +587,14 @@
</ng-template> </ng-template>
<button <button
type="button" type="button"
class="btn-retry-llm" class="btn-resume-llm"
*ngIf="isTerminalLLMFailure(item.data)" *ngIf="isTerminalLLMFailure(item.data)"
(click)="retryPausedTask($event)" (click)="resumePausedTask($event)"
title="Retry the paused AI request" title="Resume the paused task"
aria-label="Retry the paused AI request" aria-label="Resume the paused task"
> >
<span class="material-symbols-outlined">refresh</span> <span class="material-symbols-outlined">play_arrow</span>
Retry Resume
</button> </button>
</div> </div>
<div class="action-card-body"> <div class="action-card-body">
@@ -1053,24 +1053,96 @@ $uniform-gap: 8px;
} }
} }
.retrying-banner { .paused-task-banner {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; justify-content: space-between;
padding: 10px 16px; gap: 16px;
background-color: #fef9c3; padding: 12px 14px;
border: 1px solid #fde047;
border-radius: 8px;
color: #854d0e;
font-size: 13px;
font-weight: 500;
margin-bottom: 12px; margin-bottom: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); background: #fff7ed;
border: 1px solid #fdba74;
border-radius: 10px;
color: #9a3412;
box-shadow: 0 1px 3px rgba(154, 52, 18, 0.08);
.spinning-icon { .paused-task-message {
font-size: 18px; min-width: 0;
color: #d97706; display: flex;
animation: spin 1.5s linear infinite; align-items: center;
gap: 10px;
.paused-task-icon {
flex: 0 0 auto;
font-size: 21px;
color: #ea580c;
}
div {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
strong {
font-size: 12px;
line-height: 1.2;
}
span:not(.paused-task-icon) {
font-size: 11px;
line-height: 1.35;
color: #c2410c;
}
}
.btn-resume-task {
flex: 0 0 auto;
min-height: 32px;
padding: 6px 12px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
border: 1px solid #2563eb;
border-radius: 8px;
background: #2563eb;
color: #ffffff;
font-family: inherit;
font-size: 12px;
font-weight: 700;
line-height: 1;
cursor: pointer;
box-shadow: 0 2px 5px rgba(37, 99, 235, 0.2);
transition: background 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease;
.material-symbols-outlined {
font-size: 17px;
}
&:hover {
background: #1d4ed8;
box-shadow: 0 3px 8px rgba(37, 99, 235, 0.28);
}
&:active {
transform: scale(0.97);
}
&:focus-visible {
outline: 2px solid rgba(37, 99, 235, 0.35);
outline-offset: 2px;
}
}
@media (max-width: 560px) {
align-items: stretch;
flex-direction: column;
.btn-resume-task {
width: 100%;
}
} }
} }
@@ -2183,6 +2255,19 @@ $uniform-gap: 8px;
} }
} }
&.is-retrying {
background: #fffbeb;
border-color: #fcd34d;
&:hover {
border-color: #f59e0b;
}
.action-card-body .detail-value {
color: #78350f;
}
}
&.task-report-card { &.task-report-card {
background: #f0fdf4; background: #f0fdf4;
border: 1px solid #bbf7d0; border: 1px solid #bbf7d0;
@@ -2287,6 +2372,10 @@ $uniform-gap: 8px;
&.is-failed { &.is-failed {
color: #ef4444; color: #ef4444;
} }
&.is-retrying {
color: #d97706;
}
} }
.action-title { .action-title {
@@ -2352,12 +2441,34 @@ $uniform-gap: 8px;
color: #f97316; color: #f97316;
} }
} }
&.is-retrying {
background: #fef3c7;
color: #92400e;
border: 1px solid #fde68a;
.status-icon {
color: #d97706;
&.is-active {
animation: spin 1.5s linear infinite;
}
}
}
}
&.llm-retry-header {
border-bottom-color: #fde68a;
.action-title {
color: #78350f;
}
} }
&.llm-failure-header { &.llm-failure-header {
flex-wrap: wrap; flex-wrap: wrap;
.btn-retry-llm { .btn-resume-llm {
flex: 0 0 auto; flex: 0 0 auto;
min-height: 26px; min-height: 26px;
padding: 3px 9px; padding: 3px 9px;
@@ -2417,6 +2528,83 @@ $uniform-gap: 8px;
flex-direction: column; flex-direction: column;
gap: 6px; gap: 6px;
.retry-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.retry-summary-item {
min-width: 0;
padding: 7px 8px;
border: 1px solid rgba(245, 158, 11, 0.18);
border-radius: 7px;
background: rgba(255, 255, 255, 0.68);
display: flex;
flex-direction: column;
gap: 2px;
.retry-summary-label {
color: #a16207;
font-size: 9.5px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
}
strong {
color: #78350f;
font-size: 11.5px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.retry-attempt-list {
border-top: 1px solid rgba(245, 158, 11, 0.22);
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 2px;
padding-top: 8px;
animation: fadeInDetails 0.2s ease-out;
.retry-attempt-item {
padding: 8px 9px;
border: 1px solid rgba(245, 158, 11, 0.2);
border-radius: 7px;
background: rgba(255, 255, 255, 0.76);
}
.retry-attempt-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
color: #92400e;
font-size: 10px;
font-weight: 700;
}
.retry-wait-pill {
padding: 1px 5px;
border-radius: 999px;
background: #fef3c7;
color: #b45309;
}
.retry-attempt-error {
color: #78350f;
font-size: 10.5px;
line-height: 1.45;
overflow-wrap: anywhere;
user-select: text;
}
}
.action-detail-row { .action-detail-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
@@ -230,6 +230,14 @@ export class AgentStreamComponent implements AfterViewInit {
return checkPlanningLoader(this.filteredLogs(), isRunning, isCurrentRunningSession); return checkPlanningLoader(this.filteredLogs(), isRunning, isCurrentRunningSession);
}); });
public isViewingPausedTask = computed(() => {
if (!this.agentService.isPaused()) return false;
const currentSessionId = this.agentService.currentSessionId();
const pausedSessionId = this.agentService.runningSessionId();
return !currentSessionId || currentSessionId === pausedSessionId;
});
public sessionDuration = computed(() => { public sessionDuration = computed(() => {
const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId()); const currentSession = this.agentService.sessions().find(s => s.session_id === this.agentService.currentSessionId());
return computeSessionDuration(currentSession?.start_time || 0, this.filteredLogs()); return computeSessionDuration(currentSession?.start_time || 0, this.filteredLogs());
@@ -819,7 +827,7 @@ export class AgentStreamComponent implements AfterViewInit {
if (!block) return false; if (!block) return false;
const hasNative = Boolean(this.getNativeThinking(block)); const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block)); const hasRaw = Boolean(this.getRawThinking(block));
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) => this.shouldShowTool(t, block.data) || (t.type === 'llm_call' && t.status === 'failed') || this.isReportStatusAction(t)); const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) => this.shouldShowTool(t, block.data) || (t.type === 'llm_call' && (t.status === 'failed' || t.status === 'retrying')) || this.isReportStatusAction(t));
const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken)); const hasAndroidActions = Boolean(block.data?.action_taken) && (this.isAndroidAction(block.data.action_taken) || this.isReportStatusAction(block.data.action_taken));
return hasNative || hasRaw || hasVisibleTools || hasAndroidActions; return hasNative || hasRaw || hasVisibleTools || hasAndroidActions;
} }
@@ -844,7 +852,7 @@ export class AgentStreamComponent implements AfterViewInit {
return !!tool && this.retryablePauseTrace() === tool; return !!tool && this.retryablePauseTrace() === tool;
} }
public retryPausedTask(event: Event): void { public resumePausedTask(event: Event): void {
event.stopPropagation(); event.stopPropagation();
this.agentService.resumeTask(); this.agentService.resumeTask();
} }
@@ -856,6 +864,52 @@ export class AgentStreamComponent implements AfterViewInit {
return this.cleanErrorMessage(rawError); return this.cleanErrorMessage(rawError);
} }
public isLLMRetry(tool: any): boolean {
return tool?.type === 'llm_call' && tool?.status === 'retrying';
}
public getLLMRetryEntries(tool: any): any[] {
const retries = tool?.payload?.retries;
return Array.isArray(retries) && retries.length > 0 ? retries : [tool?.payload || tool];
}
public getLLMRetryCount(tool: any): number {
const count = Number(tool?.payload?.retry_count);
return Number.isFinite(count) && count > 0 ? count : this.getLLMRetryEntries(tool).length;
}
public getLLMRetryTotalDelay(tool: any): string | null {
const configuredTotal = Number(tool?.payload?.total_delay);
const total = Number.isFinite(configuredTotal)
? configuredTotal
: this.getLLMRetryEntries(tool).reduce(
(sum: number, retry: any) => sum + (Number(retry?.delay) || 0),
0
);
if (total <= 0) return null;
return `${total.toFixed(2).replace(/\.00$/, '')}s`;
}
public getLLMRetryEntryError(entry: any): string {
return this.cleanErrorMessage(entry?.error || 'Unknown error');
}
public getLLMRetryEntryDelay(entry: any): string | null {
const delay = Number(entry?.delay);
if (!Number.isFinite(delay) || delay <= 0) return null;
return `${delay.toFixed(2).replace(/\.00$/, '')}s`;
}
public getLLMRetryProvider(tool: any): string | null {
const providers = Array.isArray(tool?.payload?.providers)
? tool.payload.providers
: [tool?.payload?.provider];
const labels = providers
.filter((provider: any) => typeof provider === 'string' && provider.trim())
.map((provider: string) => provider.trim().replace(/^./, (value: string) => value.toUpperCase()));
return labels.length > 0 ? Array.from(new Set(labels)).join(', ') : null;
}
public cleanErrorMessage(rawError: any): string { public cleanErrorMessage(rawError: any): string {
return cleanErrorMessage(rawError); return cleanErrorMessage(rawError);
} }
@@ -518,9 +518,12 @@ export class AgentService {
if (eventType === 'llm_retrying') { if (eventType === 'llm_retrying') {
this.isRetrying.set(true); this.isRetrying.set(true);
const attempt = parsedData.attempt || 1; const attempt = Number(parsedData.attempt || 0);
const max = parsedData.max_retries || 3; const max = Number(parsedData.max_retries || 0);
this.retryMessage.set(`Reconnecting to AI model (Attempt ${attempt}/${max})...`); const delay = Number(parsedData.delay || 0);
const attemptText = attempt && max ? ` (Attempt ${attempt}/${max})` : '';
const delayText = delay > 0 ? `; retrying in ${delay.toFixed(2).replace(/\.00$/, '')}s` : '';
this.retryMessage.set(`AI service is temporarily busy${attemptText}${delayText}...`);
return; return;
} }
@@ -162,8 +162,9 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
} else if (log.type === 'trace_recorded') { } else if (log.type === 'trace_recorded') {
const isAction = log.data.type === 'action'; const isAction = log.data.type === 'action';
const isTool = log.data.type === 'tool' || isAction; const isTool = log.data.type === 'tool' || isAction;
const isFailedLLM = log.data.type === 'llm_call' && log.data.status === 'failed'; const isVisibleLLMEvent = log.data.type === 'llm_call'
if (!isTool && !isFailedLLM) return; && (log.data.status === 'failed' || log.data.status === 'retrying');
if (!isTool && !isVisibleLLMEvent) return;
let stepId = log.data.step_id; let stepId = log.data.step_id;
let existingIndex = -1; let existingIndex = -1;
@@ -0,0 +1,57 @@
import { getUniqueGenericTools } from './tool-formatter.util';
describe('getUniqueGenericTools retry aggregation', () => {
it('groups recoverable LLM retries while preserving every attempt', () => {
const tools = [
{
trace_id: 'retry-1',
type: 'llm_call',
name: 'llm_retry',
status: 'retrying',
timestamp: 100,
payload: { error: '503 first', delay: 1.18, provider: 'google' }
},
{
trace_id: 'retry-2',
type: 'llm_call',
name: 'llm_retry',
status: 'retrying',
timestamp: 101,
payload: { error: '503 second', delay: 2.96, provider: 'google' }
}
];
const result = getUniqueGenericTools(tools);
expect(result.length).toBe(1);
expect(result[0].name).toBe('llm_retry_group');
expect(result[0].payload.retry_count).toBe(2);
expect(result[0].payload.total_delay).toBeCloseTo(4.14);
expect(result[0].payload.providers).toEqual(['google']);
expect(result[0].payload.retries.map((retry: any) => retry.error)).toEqual([
'503 first',
'503 second'
]);
});
it('keeps a terminal LLM failure separate from its preceding retries', () => {
const result = getUniqueGenericTools([
{
trace_id: 'retry-1',
type: 'llm_call',
name: 'llm_retry',
status: 'retrying',
payload: { error: '503', delay: 1 }
},
{
trace_id: 'failure-1',
type: 'llm_call',
name: 'llm_pause',
status: 'failed',
payload: { error: '503 exhausted', pause: true }
}
]);
expect(result.map(tool => tool.name)).toEqual(['llm_retry_group', 'llm_pause']);
});
});
@@ -185,6 +185,42 @@ export function getToolAgentName(tool: any): string | null {
export function getUniqueGenericTools(tools: any[] | undefined): any[] { export function getUniqueGenericTools(tools: any[] | undefined): any[] {
if (!tools) return []; if (!tools) return [];
const retryTools = tools.filter(tool =>
tool?.type === 'llm_call'
&& tool?.status === 'retrying'
&& (tool?.name === 'llm_retry' || tool?.name === 'llm_retry_group')
);
const firstRetry = retryTools[0];
const latestRetry = retryTools[retryTools.length - 1];
const retryAggregate = firstRetry ? {
...firstRetry,
trace_id: `llm-retry-group-${firstRetry.trace_id || firstRetry.timestamp || 'unknown'}`,
name: 'llm_retry_group',
payload: {
...(latestRetry?.payload || {}),
retry_count: retryTools.length,
total_delay: retryTools.reduce(
(total, retry) => total + (Number(retry?.payload?.delay) || 0),
0
),
providers: Array.from(new Set(
retryTools
.map(retry => retry?.payload?.provider)
.filter((provider): provider is string => typeof provider === 'string' && !!provider)
)),
retries: retryTools.map(retry => ({
trace_id: retry.trace_id,
timestamp: retry.timestamp,
error: retry?.payload?.error || retry.error,
delay: Number(retry?.payload?.delay) || 0,
provider: retry?.payload?.provider,
source: retry?.payload?.source,
attempt: retry?.payload?.attempt,
max_retries: retry?.payload?.max_retries
}))
}
} : null;
const toolMap = new Map<string, any>(); const toolMap = new Map<string, any>();
for (const t of tools) { for (const t of tools) {
if (t && t.trace_id) { if (t && t.trace_id) {
@@ -196,11 +232,20 @@ export function getUniqueGenericTools(tools: any[] | undefined): any[] {
const result: any[] = []; const result: any[] = [];
const seenTraces = new Set<string>(); const seenTraces = new Set<string>();
let retryAggregateAdded = false;
for (const tool of tools) { for (const tool of tools) {
if (!tool || !tool.name) continue; if (!tool || !tool.name) continue;
if (tool.type === 'agent') continue; if (tool.type === 'agent') continue;
if (tool.type === 'llm_call' && tool.status === 'retrying') {
if (!retryAggregateAdded && retryAggregate) {
result.push(retryAggregate);
retryAggregateAdded = true;
}
continue;
}
if (tool.type === 'llm_call' && tool.status === 'failed') { if (tool.type === 'llm_call' && tool.status === 'failed') {
result.push(tool); result.push(tool);
continue; continue;
+29 -5
View File
@@ -21,10 +21,14 @@ from pathlib import Path
from typing import Any, Literal from typing import Any, Literal
from adbutils import AdbClient, AdbDevice from adbutils import AdbClient, AdbDevice
from artemis.clients.ui_automator_client import UIAutomatorClient from artemis.clients.ui_automator_client import (
UIAutomatorClient,
_parse_hierarchy_xml_to_elements,
)
from artemis.config.paths import get_temp_dir from artemis.config.paths import get_temp_dir
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.toolchain import find_ffmpeg, find_scrcpy from artemis.toolchain import find_ffmpeg, find_scrcpy
from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -119,6 +123,8 @@ class AndroidAdbDriver(BaseDeviceDriver):
# 1. Capture raw screenshot # 1. Capture raw screenshot
screenshot_bytes: bytes = b"" screenshot_bytes: bytes = b""
screenshot_base64: str = "" screenshot_base64: str = ""
ui_hierarchy_xml: str | None = None
ui_elements: list[dict[str, Any]] = []
# Check if ui_adb_client has direct screen data support # Check if ui_adb_client has direct screen data support
if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"): if self._ui_adb_client and hasattr(self._ui_adb_client, "get_screen_data"):
@@ -135,6 +141,16 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._width = raw_ui_data.width self._width = raw_ui_data.width
if hasattr(raw_ui_data, "height") and isinstance(raw_ui_data.height, int): if hasattr(raw_ui_data, "height") and isinstance(raw_ui_data.height, int):
self._height = raw_ui_data.height self._height = raw_ui_data.height
if hasattr(raw_ui_data, "hierarchy_xml") and isinstance(
raw_ui_data.hierarchy_xml, str
):
ui_hierarchy_xml = raw_ui_data.hierarchy_xml
if hasattr(raw_ui_data, "elements"):
raw_elements = raw_ui_data.elements
if isinstance(raw_elements, dict):
ui_elements = [raw_elements]
elif isinstance(raw_elements, list):
ui_elements = raw_elements
except Exception as e: except Exception as e:
logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}") logger.debug(f"Direct ui_adb_client.get_screen_data error: {e}")
@@ -156,13 +172,16 @@ class AndroidAdbDriver(BaseDeviceDriver):
screenshot_bytes = base64.b64decode(screenshot_base64) screenshot_bytes = base64.b64decode(screenshot_base64)
# 2. Capture UI Hierarchy via UIAutomator # 2. Capture UI Hierarchy via UIAutomator
ui_hierarchy_xml: str | None = None if self._ui_adb_client and not ui_elements:
ui_elements: list[dict[str, Any]] = []
if self._ui_adb_client:
try: try:
if hasattr(self._ui_adb_client, "get_hierarchy"): if hasattr(self._ui_adb_client, "get_hierarchy"):
res = self._ui_adb_client.get_hierarchy() res = self._ui_adb_client.get_hierarchy()
ui_elements = await res if asyncio.iscoroutine(res) else res hierarchy = await res if asyncio.iscoroutine(res) else res
if isinstance(hierarchy, str):
ui_hierarchy_xml = hierarchy
ui_elements = _parse_hierarchy_xml_to_elements(hierarchy)
else:
ui_elements = hierarchy
elif hasattr(self._ui_adb_client, "get_ui_elements"): elif hasattr(self._ui_adb_client, "get_ui_elements"):
res = self._ui_adb_client.get_ui_elements() res = self._ui_adb_client.get_ui_elements()
ui_elements = await res if asyncio.iscoroutine(res) else res ui_elements = await res if asyncio.iscoroutine(res) else res
@@ -178,6 +197,11 @@ class AndroidAdbDriver(BaseDeviceDriver):
w = self._width if isinstance(self._width, int) else 1080 w = self._width if isinstance(self._width, int) else 1080
h = self._height if isinstance(self._height, int) else 2400 h = self._height if isinstance(self._height, int) else 2400
clean_ui_elements = filter_ui_hierarchy(
clean_ui_elements,
screen_width=w,
screen_height=h,
)
clean_b64 = ( clean_b64 = (
screenshot_base64 screenshot_base64
if isinstance(screenshot_base64, str) and screenshot_base64 if isinstance(screenshot_base64, str) and screenshot_base64
+152 -29
View File
@@ -25,6 +25,8 @@ import functools
import logging import logging
from pathlib import Path from pathlib import Path
import random import random
import re
import sys
import time import time
from typing import Any, Literal, TypeVar, overload from typing import Any, Literal, TypeVar, overload
from uuid import uuid4 from uuid import uuid4
@@ -41,7 +43,6 @@ from artemis.config import (
settings, settings,
) )
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.data_engine.engine import _CURRENT_DATA_ENGINE
from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler from artemis.data_engine.trace import CURRENT_TRACE_ID, DataEngineCallbackHandler
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
from artemis.utils.logger import get_logger from artemis.utils.logger import get_logger
@@ -53,6 +54,131 @@ user_messages_logger = get_logger(__name__)
T = TypeVar("T") T = TypeVar("T")
# Retained as an override seam for unit tests. At runtime the active engine must
# be resolved dynamically because DataEngine.start_session() updates the module
# global after this module has already been imported.
_CURRENT_DATA_ENGINE = None
def _get_current_data_engine():
if _CURRENT_DATA_ENGINE is not None:
return _CURRENT_DATA_ENGINE
engine_module = sys.modules.get("artemis.data_engine.engine")
return getattr(engine_module, "_CURRENT_DATA_ENGINE", None) if engine_module else None
def _record_llm_retry(
error: str,
delay: float,
*,
attempt: int | None = None,
max_retries: int | None = None,
provider: str | None = None,
source: str = "artemis",
) -> None:
"""Persist and publish one recoverable LLM retry for UI transparency."""
engine = _get_current_data_engine()
if not engine or not getattr(engine, "current_session_id", None):
return
error_text = str(error)
if len(error_text) > 1000:
error_text = error_text[:1000] + "... [Truncated by LLM Retry Telemetry]"
payload = {
"error": error_text,
"delay": float(delay),
"attempt": attempt,
"max_retries": max_retries,
"provider": provider,
"source": source,
"recoverable": True,
}
payload = {key: value for key, value in payload.items() if value is not None}
step_id = getattr(engine, "current_step_id", None)
parent_trace_id = CURRENT_TRACE_ID.get()
timestamp = time.time()
try:
trace_id = engine.record_trace(
type="llm_call",
name="llm_retry",
payload=payload,
step_id=step_id,
parent_trace_id=parent_trace_id,
status="retrying",
)
except Exception as trace_error:
llm_logger.warning("Failed to persist LLM retry trace: %s", trace_error)
trace_id = None
try:
engine._publish(
"llm_retrying",
{
**payload,
"step_id": str(step_id) if step_id else None,
"trace_id": str(trace_id) if trace_id else None,
"timestamp": timestamp,
},
)
except Exception as publish_error:
# Retry visibility is best-effort and must never break the LLM retry itself.
llm_logger.warning("Failed to publish LLM retry event: %s", publish_error)
class _ProviderRetryTelemetryHandler(logging.Handler):
"""Turn provider SDK retry log records into structured Artemis telemetry."""
_DELAY_PATTERN = re.compile(r"\bin\s+(?P<delay>\d+(?:\.\d+)?)\s+seconds?\b", re.IGNORECASE)
_ERROR_PATTERN = re.compile(r"\bas it raised\s+(?P<error>.+)$", re.IGNORECASE)
_RETRYABLE_MARKERS = (
"503",
"429",
"unavailable",
"resource_exhausted",
"high demand",
"quota",
"throttled",
"overloaded",
)
def emit(self, record: logging.LogRecord) -> None:
try:
message = record.getMessage()
lowered = message.lower()
if "retrying" not in lowered or not any(
marker in lowered for marker in self._RETRYABLE_MARKERS
):
return
delay_match = self._DELAY_PATTERN.search(message)
error_match = self._ERROR_PATTERN.search(message)
delay = float(delay_match.group("delay")) if delay_match else 0.0
error = error_match.group("error").strip() if error_match else message
_record_llm_retry(
error,
delay,
provider="google",
source="provider_sdk",
)
except Exception:
# Telemetry must never interfere with the provider's own retry.
self.handleError(record)
def _install_provider_retry_telemetry() -> None:
provider_logger = logging.getLogger("google_genai._api_client")
if any(
isinstance(handler, _ProviderRetryTelemetryHandler)
for handler in provider_logger.handlers
):
return
provider_logger.addHandler(_ProviderRetryTelemetryHandler(level=logging.INFO))
_install_provider_retry_telemetry()
def _handle_llm_pause_and_resume(last_error: Exception) -> Path: def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
"""Handles pausing task execution upon persistent failure until resumed.""" """Handles pausing task execution upon persistent failure until resumed."""
@@ -67,13 +193,14 @@ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
except Exception: except Exception:
pass pass
if _CURRENT_DATA_ENGINE: current_engine = _get_current_data_engine()
step_id = getattr(_CURRENT_DATA_ENGINE, "current_step_id", None) if current_engine:
step_id = getattr(current_engine, "current_step_id", None)
timestamp = time.time() timestamp = time.time()
try: try:
# Persist exhausted LLM retries as a failed call so the same error # Persist exhausted LLM retries as a failed call so the same error
# card is available both in the live stream and after a refresh. # card is available both in the live stream and after a refresh.
_CURRENT_DATA_ENGINE.record_trace( current_engine.record_trace(
type="llm_call", type="llm_call",
name="llm_pause", name="llm_pause",
payload={"error": err_msg, "pause": True}, payload={"error": err_msg, "pause": True},
@@ -83,7 +210,7 @@ def _handle_llm_pause_and_resume(last_error: Exception) -> Path:
except Exception as trace_error: except Exception as trace_error:
llm_logger.warning("Failed to persist paused LLM error trace: %s", trace_error) llm_logger.warning("Failed to persist paused LLM error trace: %s", trace_error)
_CURRENT_DATA_ENGINE._publish( current_engine._publish(
"task_paused", "task_paused",
{ {
"error": err_msg, "error": err_msg,
@@ -129,16 +256,13 @@ def robust_retry_async(func: Callable) -> Callable:
if retry_delay > 0: if retry_delay > 0:
llm_logger.warning(f"LLM call failed, retrying in {retry_delay:.2f}s...") llm_logger.warning(f"LLM call failed, retrying in {retry_delay:.2f}s...")
if _CURRENT_DATA_ENGINE: _record_llm_retry(
_CURRENT_DATA_ENGINE._publish( str(e),
"llm_retrying", retry_delay,
{ attempt=attempt + 1,
"attempt": attempt + 1, max_retries=max_retries,
"max_retries": max_retries, source="artemis_wrapper",
"delay": retry_delay, )
"error": str(e),
},
)
await asyncio.sleep(retry_delay) await asyncio.sleep(retry_delay)
pause_file = _handle_llm_pause_and_resume(last_error) pause_file = _handle_llm_pause_and_resume(last_error)
@@ -147,8 +271,9 @@ def robust_retry_async(func: Callable) -> Callable:
llm_logger.info("Resume signal received, retrying LLM call...") llm_logger.info("Resume signal received, retrying LLM call...")
if _CURRENT_DATA_ENGINE: current_engine = _get_current_data_engine()
_CURRENT_DATA_ENGINE._publish("task_resumed", {}) if current_engine:
current_engine._publish("task_resumed", {})
return wrapper return wrapper
@@ -194,16 +319,13 @@ def robust_retry_astream(func: Callable) -> Callable:
f"LLM stream handshake throttled, retrying in {retry_delay:.2f}s..." f"LLM stream handshake throttled, retrying in {retry_delay:.2f}s..."
) )
if _CURRENT_DATA_ENGINE: _record_llm_retry(
_CURRENT_DATA_ENGINE._publish( str(e),
"llm_retrying", retry_delay,
{ attempt=attempt + 1,
"attempt": attempt + 1, max_retries=max_retries,
"max_retries": max_retries, source="artemis_wrapper",
"delay": retry_delay, )
"error": str(e),
},
)
await asyncio.sleep(retry_delay) await asyncio.sleep(retry_delay)
pause_file = _handle_llm_pause_and_resume(last_error) pause_file = _handle_llm_pause_and_resume(last_error)
@@ -212,8 +334,9 @@ def robust_retry_astream(func: Callable) -> Callable:
llm_logger.info("Resume signal received, retrying LLM stream...") llm_logger.info("Resume signal received, retrying LLM stream...")
if _CURRENT_DATA_ENGINE: current_engine = _get_current_data_engine()
_CURRENT_DATA_ENGINE._publish("task_resumed", {}) if current_engine:
current_engine._publish("task_resumed", {})
return wrapper return wrapper
+19 -13
View File
@@ -26,7 +26,7 @@ from artemis.utils.ocr_xml_fusion import (
_crop_image_remove_status_bar, _crop_image_remove_status_bar,
_map_coordinates_back, _map_coordinates_back,
) )
from artemis.utils.ocr_api import perform_ocr from artemis.utils.ocr_api import is_ocr_configured, perform_ocr
from artemis.utils.visualization import format_minimal_list_with_elements from artemis.utils.visualization import format_minimal_list_with_elements
@@ -77,19 +77,25 @@ async def mobile_get_device_state(view_type: str) -> str:
return f"file://{screenshot_path}" return f"file://{screenshot_path}"
elif view_type == "hierarchy": elif view_type == "hierarchy":
try: ocr_results = []
screen_height = device_data.height if is_ocr_configured():
status_bar_height = _detect_status_bar_height(xml_hierarchy, screen_height) try:
if status_bar_height > 0: screen_height = device_data.height
cropped_b64, _, _ = _crop_image_remove_status_bar( status_bar_height = _detect_status_bar_height(
latest_screenshot_b64, status_bar_height xml_hierarchy, screen_height
) )
raw_ocr_results = await perform_ocr(cropped_b64) if status_bar_height > 0:
ocr_results = _map_coordinates_back(raw_ocr_results, status_bar_height) cropped_b64, _, _ = _crop_image_remove_status_bar(
else: latest_screenshot_b64, status_bar_height
ocr_results = await perform_ocr(latest_screenshot_b64) )
except Exception: raw_ocr_results = await perform_ocr(cropped_b64)
ocr_results = [] ocr_results = _map_coordinates_back(
raw_ocr_results, status_bar_height
)
else:
ocr_results = await perform_ocr(latest_screenshot_b64)
except Exception:
ocr_results = []
fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results) fused_xml = fuse_ocr_with_xml(xml_hierarchy, ocr_results)
hierarchy_text, elements, labels = format_minimal_list_with_elements( hierarchy_text, elements, labels = format_minimal_list_with_elements(
@@ -43,3 +43,33 @@ def test_unrelated_log_is_not_presented_as_llm_failure():
assert normalized["type"] == "log" assert normalized["type"] == "log"
assert normalized["status"] == "success" assert normalized["status"] == "success"
def test_retrying_llm_payload_keeps_user_visible_retry_details():
repository = StepRepository()
trace = {
"trace_id": "retry-1",
"type": "llm_call",
"name": "llm_retry",
"status": "retrying",
"payload": json.dumps(
{
"error": "503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
"internal_noise": "do not expose",
}
),
}
normalized = repository._normalize_display_trace(trace)
assert normalized["payload"] == {
"error": "503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
}
@@ -14,6 +14,9 @@
import asyncio import asyncio
import importlib import importlib
import json
import os
import subprocess
import sys import sys
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
@@ -203,9 +206,11 @@ async def test_queue_worker_cmd_construction():
assert "--app-path" in cmd assert "--app-path" in cmd
assert "/path/to/app.apk" in cmd assert "/path/to/app.apk" in cmd
if sys.platform == "win32": if sys.platform == "win32":
import subprocess assert executed_kwargs[0]["creationflags"] == (
subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW
assert executed_kwargs[0]["creationflags"] == subprocess.CREATE_NEW_PROCESS_GROUP )
assert executed_kwargs[0]["stdout"] == asyncio.subprocess.PIPE
assert executed_kwargs[0]["stderr"] == asyncio.subprocess.STDOUT
else: else:
assert "creationflags" not in executed_kwargs[0] assert "creationflags" not in executed_kwargs[0]
@@ -218,6 +223,40 @@ async def test_queue_worker_cmd_construction():
pass pass
@pytest.mark.asyncio
async def test_forward_worker_output_preserves_split_utf8(capsys):
stream = asyncio.StreamReader()
encoded = "worker 输出正常\n".encode()
stream.feed_data(encoded[:9])
stream.feed_data(encoded[9:])
stream.feed_eof()
await TaskQueueService._forward_worker_output(stream)
assert capsys.readouterr().out == "worker 输出正常\n"
@pytest.mark.skipif(sys.platform != "win32", reason="Windows console isolation only")
def test_windows_worker_has_no_inherited_console():
probe = r"""
import ctypes
import json
buffer = (ctypes.c_uint32 * 32)()
count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, len(buffer))
print(json.dumps(list(buffer[:min(count, len(buffer))])))
"""
result = subprocess.run(
[sys.executable, "-c", probe],
check=True,
text=True,
**TaskQueueService._subprocess_creation_kwargs(),
)
attached_processes = json.loads(result.stdout)
assert os.getpid() not in attached_processes
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_stop_tasks_dead_process_resets_state(): async def test_stop_tasks_dead_process_resets_state():
state.queue_items = [ state.queue_items = [
+37
View File
@@ -17,7 +17,10 @@
import inspect import inspect
import shutil import shutil
import tempfile import tempfile
from types import SimpleNamespace
import uuid import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from mcp_server.tools import ( from mcp_server.tools import (
@@ -106,6 +109,40 @@ def test_mobile_manage_task_inject_instruction(temp_trace_env):
assert "Successfully injected" in ok_res["message"] assert "Successfully injected" in ok_res["message"]
@pytest.mark.asyncio
async def test_mobile_get_device_state_hierarchy_without_ocr():
"""XML hierarchy remains available when OCR is not configured."""
controller = MagicMock()
controller.ctx.device.device_width = 1080
controller.ctx.device.device_height = 2400
controller.get_screen_data = AsyncMock(
return_value=SimpleNamespace(
base64="ZHVtbXk=",
elements=[
{
"class": "android.widget.Button",
"text": "Start",
"bounds": "[100,500][400,600]",
}
],
width=1080,
height=2400,
)
)
with (
patch("mcp_server.tools.device_state._get_controller", return_value=controller),
patch("mcp_server.tools.device_state.is_ocr_configured", return_value=False),
patch(
"mcp_server.tools.device_state.perform_ocr", new_callable=AsyncMock
) as perform_ocr_mock,
):
result = await mobile_get_device_state(view_type="hierarchy")
assert "Start" in result
perform_ocr_mock.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mobile_inspect_trace_invalid_action(): async def test_mobile_inspect_trace_invalid_action():
res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123") res = await mobile_inspect_trace(action="invalid_action", trace_id="trace-123")
+78 -1
View File
@@ -14,14 +14,24 @@
"""Unit tests for Unified Base Device Driver and implementations.""" """Unit tests for Unified Base Device Driver and implementations."""
import pytest import base64
from types import SimpleNamespace
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
from artemis.drivers.mock.mock_driver import MockDeviceDriver from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.drivers.android.adb_driver import AndroidAdbDriver from artemis.drivers.android.adb_driver import AndroidAdbDriver
_ONE_PIXEL_PNG = base64.b64encode(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
)
).decode("ascii")
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_mock_driver_actions(): async def test_mock_driver_actions():
"""Verify MockDeviceDriver records and performs all core actions.""" """Verify MockDeviceDriver records and performs all core actions."""
@@ -89,3 +99,70 @@ async def test_android_driver_with_mock_adb():
# Test press key # Test press key
await driver.press_key(KeyCode.BACK) await driver.press_key(KeyCode.BACK)
mock_adb_device.shell.assert_called_with("input keyevent 4") mock_adb_device.shell.assert_called_with("input keyevent 4")
@pytest.mark.asyncio
async def test_android_driver_preserves_elements_from_combined_screen_data():
"""The combined uiautomator2 response is the authoritative live snapshot."""
mock_adb_client = MagicMock()
mock_ui_client = MagicMock()
expected_elements = [
{
"text": "",
"class": "android.widget.FrameLayout",
"bounds": "[0,0][1080,2400]",
"clickable": "false",
"focusable": "false",
},
{
"text": "Start",
"class": "android.widget.Button",
"bounds": "[10,20][100,80]",
}
]
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG,
hierarchy_xml="<hierarchy />",
elements=expected_elements,
width=1080,
height=2400,
)
driver = AndroidAdbDriver(
device_id="emulator-5554",
adb_client=mock_adb_client,
ui_adb_client=mock_ui_client,
)
screen_data = await driver.get_screen_data(skip_settling=True)
assert screen_data.ui_elements == [expected_elements[1]]
assert screen_data.ui_hierarchy_xml == "<hierarchy />"
mock_ui_client.get_hierarchy.assert_not_called()
@pytest.mark.asyncio
async def test_android_driver_parses_xml_hierarchy_fallback():
"""XML-only clients remain supported without discarding their hierarchy."""
mock_adb_client = MagicMock()
mock_ui_client = MagicMock()
mock_ui_client.get_screen_data.return_value = SimpleNamespace(
base64=_ONE_PIXEL_PNG,
width=1080,
height=2400,
)
mock_ui_client.get_hierarchy.return_value = (
'<hierarchy><node text="Settings" class="android.widget.TextView" '
'bounds="[0,0][100,100]" /></hierarchy>'
)
driver = AndroidAdbDriver(
device_id="emulator-5554",
adb_client=mock_adb_client,
ui_adb_client=mock_ui_client,
)
screen_data = await driver.get_screen_data(skip_settling=True)
assert screen_data.ui_hierarchy_xml is not None
assert any(element.get("text") == "Settings" for element in screen_data.ui_elements)
+43
View File
@@ -1,8 +1,11 @@
import logging
from artemis.services import llm from artemis.services import llm
class _RecordingEngine: class _RecordingEngine:
current_step_id = "step-7" current_step_id = "step-7"
current_session_id = "session-1"
def __init__(self): def __init__(self):
self.trace_kwargs = None self.trace_kwargs = None
@@ -10,6 +13,7 @@ class _RecordingEngine:
def record_trace(self, **kwargs): def record_trace(self, **kwargs):
self.trace_kwargs = kwargs self.trace_kwargs = kwargs
return "trace-1"
def _publish(self, event_type, payload): def _publish(self, event_type, payload):
self.published = (event_type, payload) self.published = (event_type, payload)
@@ -35,3 +39,42 @@ def test_pause_error_is_persisted_and_published(monkeypatch, tmp_path):
assert payload["error"] == "503 high demand" assert payload["error"] == "503 high demand"
assert payload["step_id"] == "step-7" assert payload["step_id"] == "step-7"
assert isinstance(payload["timestamp"], float) assert isinstance(payload["timestamp"], float)
def test_provider_sdk_retry_is_persisted_and_published(monkeypatch):
engine = _RecordingEngine()
monkeypatch.setattr(llm, "_CURRENT_DATA_ENGINE", engine)
record = logging.LogRecord(
name="google_genai._api_client",
level=logging.INFO,
pathname=__file__,
lineno=1,
msg=(
"Retrying google.genai request in 1.18 seconds as it raised "
"ServerError: 503 UNAVAILABLE: high demand"
),
args=(),
exc_info=None,
)
llm._ProviderRetryTelemetryHandler().emit(record)
assert engine.trace_kwargs == {
"type": "llm_call",
"name": "llm_retry",
"payload": {
"error": "ServerError: 503 UNAVAILABLE: high demand",
"delay": 1.18,
"provider": "google",
"source": "provider_sdk",
"recoverable": True,
},
"step_id": "step-7",
"parent_trace_id": None,
"status": "retrying",
}
event_type, payload = engine.published
assert event_type == "llm_retrying"
assert payload["trace_id"] == "trace-1"
assert payload["delay"] == 1.18
assert payload["error"].startswith("ServerError: 503")