Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cec9898e4c | ||
|
|
60ac066859 | ||
|
|
9d6833f092 |
No files matched your search
@@ -16,6 +16,8 @@ OPENAI_REASONING_EFFORT=high
|
||||
ARTEMIS_VIDEO_ENABLED=false
|
||||
ARTEMIS_OUTPUTTER_ENABLED=true
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
|
||||
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
|
||||
|
||||
# 视频分段、重试与单次分析超时(秒)
|
||||
ARTEMIS_VIDEO_CHUNK_SECONDS=60
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.5",
|
||||
"@angular/common": "^22.1.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "artemis-showcase-ui",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve --proxy-config proxy.conf.json",
|
||||
|
||||
@@ -124,7 +124,7 @@ limitations under the License.
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="item-footer">
|
||||
<span class="item-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<app-task-id-copy [taskId]="session.session_id" />
|
||||
<span class="item-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
@@ -236,7 +237,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote, StreamRe
|
||||
@Component({
|
||||
selector: 'app-agent-stream',
|
||||
standalone: true,
|
||||
imports: [UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, OverlayModule, TaskHistoryComponent, AutomationPanelComponent],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -122,7 +122,7 @@ limitations under the License.
|
||||
{{ session.initial_goal || '未命名任务' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<app-task-id-copy [taskId]="session.session_id" />
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { AutomationPanelComponent } from '../automation-panel/automation-panel.component';
|
||||
import { TaskHistoryComponent } from '../task-history/task-history.component';
|
||||
import { UiLabelPipe } from '../../utils/ui-label.pipe';
|
||||
@@ -30,7 +31,7 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
@Component({
|
||||
selector: 'app-chat-interface',
|
||||
standalone: true,
|
||||
imports: [UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
||||
imports: [TaskIdCopyComponent, UiLabelPipe, CommonModule, FormsModule, AutomationPanelComponent, TaskHistoryComponent],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { signal } from '@angular/core';
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
@@ -14,6 +15,25 @@ describe('历史任务检索', () => {
|
||||
});
|
||||
afterEach(() => http.verify());
|
||||
|
||||
it('历史 ID 独立于打开任务按钮,点击复制不会打开任务', async () => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
http.expectOne(r => r.url === '/api/automation/history').flush({total: 1, items: [
|
||||
{session_id:'completed-task', name:'已完成任务', status:'completed', start_time:Date.now()/1000}
|
||||
]});
|
||||
const copy = spyOn(TestBed.inject(Clipboard), 'copy').and.returnValue(true);
|
||||
fixture.detectChanges();
|
||||
const id: HTMLElement = fixture.nativeElement.querySelector('app-task-id-copy [role="button"]');
|
||||
expect(id.closest('button.row')).toBeNull();
|
||||
id.click();
|
||||
await Promise.resolve();
|
||||
expect(copy).toHaveBeenCalledOnceWith('completed-task');
|
||||
expect(TestBed.inject(AgentService).selectSession).not.toHaveBeenCalled();
|
||||
fixture.nativeElement.querySelector('button.row').click();
|
||||
expect(TestBed.inject(AgentService).selectSession).toHaveBeenCalledWith('completed-task', true);
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('迟到的旧查询不会覆盖最新搜索结果', () => {
|
||||
const fixture = TestBed.createComponent(TaskHistoryComponent);
|
||||
const first = http.expectOne(r => r.url === '/api/automation/history');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TaskIdCopyComponent } from '../task-id-copy/task-id-copy.component';
|
||||
import { Component, DestroyRef, inject, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -5,19 +6,19 @@ import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
|
||||
@Component({
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule],
|
||||
selector:'app-task-history', standalone:true, imports:[CommonModule,FormsModule,TaskIdCopyComponent],
|
||||
template:`<section aria-label="历史任务"><h3>历史任务 <small>{{total()}} 条</small></h3>
|
||||
<input aria-label="搜索历史任务" placeholder="搜索名称、目标、任务编号或标签" [(ngModel)]="query" (ngModelChange)="search()">
|
||||
<div class="filters"><label>起始日期<input type="date" [(ngModel)]="start" (ngModelChange)="search()"></label><label>结束日期<input type="date" [(ngModel)]="end" (ngModelChange)="search()"></label><label>状态<select [(ngModel)]="status" (ngModelChange)="search()"><option value="">全部状态</option><option value="completed">执行完成</option><option value="failed">失败</option><option value="cancelled">已取消</option><option value="interrupted">中断</option><option value="skipped">跳过</option></select></label></div>
|
||||
@if(error()){<p role="alert">{{error()}}</p>}
|
||||
@if(loading()){<small>正在查询…</small>}
|
||||
@for(group of groups(); track group.key){<details [open]="!collapsed.has(group.key)" (toggle)="toggle(group.key,$event)"><summary>{{group.title}} <small>本页 {{group.rows.length}} 条</small></summary>
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span><small>{{row.session_id}}</small></button><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||
@for(row of group.rows; track row.session_id){<div class="history-item"><button class="row" [class.selected]="agent.currentSessionId() === row.session_id" (click)="agent.selectSession(row.session_id,true)"><strong>{{row.name || row.initial_goal}}</strong><span>{{label(row.status)}} · {{row.start_time*1000 | date:'HH:mm:ss'}} · {{row.tags}}</span></button><app-task-id-copy [taskId]="row.session_id" /><button class="delete-history" (click)="remove(row.session_id)" aria-label="删除此历史任务"><span class="material-symbols-outlined">delete</span></button></div>}
|
||||
</details>}
|
||||
@if(!loading() && !total()){<p>没有符合条件的历史任务。</p>}
|
||||
<div class="pager"><button [disabled]="page === 1 || loading()" (click)="page=page-1;load()">上一页</button><span>{{page}} / {{pages()}}</span><button [disabled]="page >= pages() || loading()" (click)="page=page+1;load()">下一页</button></div>
|
||||
</section>`,
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
styles:[`:host{display:block;font-size:12px;color:inherit}h3{font-size:14px;margin:14px 0 8px}small{opacity:.6}input,select,button{font:inherit;color:inherit;border:1px solid #64748b55;border-radius:6px;padding:8px;background:transparent;box-sizing:border-box}input{width:100%}select option{background:white;color:#1e293b}.filters{display:flex;gap:6px;flex-wrap:wrap;margin:8px 0}.filters label{flex:1;min-width:110px}.filters input,.filters select{display:block;width:100%}summary{padding:10px 0;cursor:pointer}.row{display:flex;flex-direction:column;gap:4px;width:100%;text-align:left;cursor:pointer;margin:5px 0;overflow-wrap:anywhere}.row strong{font-weight:500}.row span{opacity:.75}.row.selected{border-color:#60a5fa;background:#3b82f615}.history-item{position:relative;border:1px solid #64748b55;border-radius:6px;margin:5px 0;padding:8px}.history-item:has(.row.selected){border-color:#60a5fa;background:#3b82f615}.history-item .row{border:0;padding:0;margin:0 0 4px}.history-item .row.selected{background:transparent}.history-item .row{padding-right:35px}.delete-history{position:absolute;right:5px;top:8px;border:0;padding:3px;color:#64748b;cursor:pointer;font-size:12px}.delete-history .material-symbols-outlined{font-size:16px}.delete-history:hover{color:#b91c1c}.pager{display:flex;align-items:center;justify-content:space-between;gap:8px;margin:12px 0}button:disabled{opacity:.4}p{opacity:.7}`]
|
||||
})
|
||||
export class TaskHistoryComponent {
|
||||
agent=inject(AgentService); private http=inject(HttpClient); query='';start='';end='';status='';page=1;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TaskIdCopyComponent } from './task-id-copy.component';
|
||||
|
||||
describe('任务 ID 复制', () => {
|
||||
let clipboard: jasmine.SpyObj<Clipboard>;
|
||||
beforeEach(() => {
|
||||
spyOn(navigator.clipboard, 'writeText').and.rejectWith(new Error('blocked'));
|
||||
clipboard = jasmine.createSpyObj('Clipboard', ['copy']);
|
||||
TestBed.configureTestingModule({ providers: [{ provide: Clipboard, useValue: clipboard }] });
|
||||
});
|
||||
afterEach(() => window.getSelection()?.removeAllRanges());
|
||||
|
||||
function setup() {
|
||||
const fixture = TestBed.createComponent(TaskIdCopyComponent);
|
||||
fixture.componentRef.setInput('taskId', 'task-123');
|
||||
fixture.detectChanges();
|
||||
const target: HTMLElement = fixture.nativeElement.querySelector('[role="button"]');
|
||||
return { fixture, target };
|
||||
}
|
||||
|
||||
it('点击复制完整 ID,并提示成功,不触发父卡片', async () => {
|
||||
clipboard.copy.and.returnValue(true);
|
||||
const { fixture, target } = setup();
|
||||
const parentClick = jasmine.createSpy('parentClick');
|
||||
fixture.nativeElement.addEventListener('click', parentClick);
|
||||
target.click();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||
expect(fixture.nativeElement.querySelector('[role="status"]').textContent).toContain('已复制');
|
||||
expect(parentClick).not.toHaveBeenCalled();
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('优先使用浏览器剪贴板,并在写入完成后提示成功', async () => {
|
||||
const write = navigator.clipboard.writeText as jasmine.Spy;
|
||||
let complete!: () => void;
|
||||
write.and.returnValue(new Promise<void>(resolve => complete = resolve));
|
||||
const { fixture, target } = setup();
|
||||
target.click();
|
||||
expect(fixture.componentInstance.feedback()).toBe('');
|
||||
complete();
|
||||
await Promise.resolve();
|
||||
expect(write).toHaveBeenCalledOnceWith('task-123');
|
||||
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||
expect(fixture.componentInstance.feedback()).toBe('任务 ID 已复制');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('复制失败不会提示成功,ID 仍可手动选择', async () => {
|
||||
clipboard.copy.and.returnValue(false);
|
||||
const { fixture, target } = setup();
|
||||
target.click();
|
||||
await Promise.resolve();
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.textContent).toContain('复制失败,请选中任务 ID 手动复制');
|
||||
expect(getComputedStyle(target.querySelector('.value')!).userSelect).toBe('text');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('拖选 ID 后的点击不会覆盖剪贴板或清空选区', () => {
|
||||
const { fixture, target } = setup();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(target.querySelector('.value')!);
|
||||
const selection = window.getSelection()!;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
target.click();
|
||||
expect(clipboard.copy).not.toHaveBeenCalled();
|
||||
expect(selection.toString()).toContain('task-123');
|
||||
fixture.destroy();
|
||||
});
|
||||
|
||||
it('支持键盘 Enter 复制', async () => {
|
||||
clipboard.copy.and.returnValue(true);
|
||||
const { fixture, target } = setup();
|
||||
target.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await Promise.resolve();
|
||||
expect(clipboard.copy).toHaveBeenCalledOnceWith('task-123');
|
||||
fixture.destroy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard';
|
||||
import { Component, DestroyRef, Input, inject, signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-task-id-copy',
|
||||
standalone: true,
|
||||
template: `
|
||||
<span class="copy-target" role="button" tabindex="0"
|
||||
[attr.aria-label]="'复制任务 ID:' + taskId" title="点击复制任务 ID,也可选中文字复制"
|
||||
(click)="copy($event)" (keydown.enter)="copy($event)" (keydown.space)="copy($event)">
|
||||
<span class="value">ID: {{ taskId }}</span>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">content_copy</span>
|
||||
</span>
|
||||
<span class="feedback" role="status" aria-live="polite">{{ feedback() }}</span>
|
||||
`,
|
||||
styles: [`
|
||||
:host { display: block; min-width: 0; font-size: 11px; color: inherit; user-select: text; }
|
||||
.copy-target { display: inline-flex; align-items: center; gap: 5px; max-width: 100%;
|
||||
padding: 3px 0; cursor: pointer; user-select: text; border-radius: 3px; }
|
||||
.value { min-width: 0; overflow-wrap: anywhere; user-select: text; }
|
||||
.material-symbols-outlined { flex-shrink: 0; font-size: 14px; user-select: none; }
|
||||
.copy-target:hover { color: #2563eb; }
|
||||
.copy-target:focus-visible { outline: 2px solid #60a5fa; outline-offset: 2px; }
|
||||
.feedback { display: block; line-height: 1.6; user-select: text; }
|
||||
.feedback:empty { display: none; }
|
||||
`]
|
||||
})
|
||||
export class TaskIdCopyComponent {
|
||||
@Input({ required: true }) taskId = '';
|
||||
readonly feedback = signal('');
|
||||
private readonly clipboard = inject(Clipboard);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
constructor() {
|
||||
this.destroyRef.onDestroy(() => clearTimeout(this.timer));
|
||||
}
|
||||
|
||||
async copy(event: Event): Promise<void> {
|
||||
event.stopPropagation();
|
||||
// A drag selection must remain available for the browser's normal copy action.
|
||||
const selection = window.getSelection();
|
||||
if (event.type === 'click' && selection && !selection.isCollapsed
|
||||
&& (event.currentTarget as HTMLElement).contains(selection.anchorNode)) return;
|
||||
event.preventDefault();
|
||||
let copied = false;
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(this.taskId);
|
||||
copied = true;
|
||||
} else {
|
||||
copied = this.clipboard.copy(this.taskId);
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
copied = this.clipboard.copy(this.taskId);
|
||||
} catch {
|
||||
// Keep manual selection available when the browser blocks both clipboard APIs.
|
||||
}
|
||||
}
|
||||
if (this.destroyRef.destroyed) return;
|
||||
this.feedback.set(copied ? '任务 ID 已复制' : '复制失败,请选中任务 ID 手动复制');
|
||||
clearTimeout(this.timer);
|
||||
this.timer = setTimeout(() => this.feedback.set(''), 3000);
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,10 @@ from uuid import uuid4
|
||||
from langchain_core.messages import SystemMessage, ToolMessage
|
||||
from langchain_core.tools import BaseTool
|
||||
|
||||
from artemis.core.tool_failure import is_tool_failure
|
||||
from artemis.agents.validator.tool_declarations import normalize_coordinate_target
|
||||
from artemis.config.settings import settings
|
||||
from artemis.context import ArtemisContext
|
||||
from artemis.core.tool_failure import is_tool_failure
|
||||
from artemis.data_engine.trace import (
|
||||
TraceSpan,
|
||||
trace,
|
||||
@@ -57,6 +59,11 @@ from artemis.utils.visualization import format_minimal_list_with_elements
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class OperatorNoProgressError(RuntimeError):
|
||||
"""The model repeatedly returned no usable tool call; stop without device actions."""
|
||||
|
||||
|
||||
DEFERRING_TOOLS = {
|
||||
"ask_diagnoser",
|
||||
"video_analyzer",
|
||||
@@ -485,6 +492,8 @@ class OperatorNode:
|
||||
native_thoughts = []
|
||||
tool_limit_exceeded = False
|
||||
plan_gate_bounced = False
|
||||
no_progress_attempts = 0
|
||||
no_progress_limit = settings.ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS
|
||||
|
||||
for iteration in range(max_iterations):
|
||||
if iteration == max_iterations - 1:
|
||||
@@ -568,8 +577,27 @@ class OperatorNode:
|
||||
logger.error(f"Failed to parse function_call fallback: {e}")
|
||||
|
||||
if not response.tool_calls:
|
||||
logger.warning("LLM stopped without calling any tool. Encouraging action.")
|
||||
break
|
||||
no_progress_attempts += 1
|
||||
if no_progress_attempts >= no_progress_limit:
|
||||
raise OperatorNoProgressError(
|
||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||
"已停止任务以避免空转;未执行新的设备动作。"
|
||||
)
|
||||
logger.warning("LLM returned no tool call; requesting a concrete tool call.")
|
||||
current_messages.append(response)
|
||||
current_messages.append(
|
||||
SystemMessage(
|
||||
content=(
|
||||
"No tool call was returned, so no device action occurred. "
|
||||
"Use the available tool interface now, not a prose promise or JSON "
|
||||
"inside text. Use an index from the current element list or "
|
||||
"normalized coordinates with target_description. If blocked, "
|
||||
"use an observation/diagnostic tool; do not invent a click or "
|
||||
"claim success. This recovery has a limited retry budget."
|
||||
)
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
current_messages.append(response)
|
||||
|
||||
@@ -840,9 +868,20 @@ class OperatorNode:
|
||||
break
|
||||
|
||||
if validation_errors:
|
||||
no_progress_attempts += 1
|
||||
if no_progress_attempts >= no_progress_limit:
|
||||
raise OperatorNoProgressError(
|
||||
f"模型连续 {no_progress_attempts} 次未提供可执行的工具调用,"
|
||||
"工具参数校验仍失败;已停止任务,未执行被拒绝的设备动作。"
|
||||
)
|
||||
logger.warning("Some action calls failed validation. Feeding back to LLM.")
|
||||
continue
|
||||
no_progress_attempts = 0
|
||||
else:
|
||||
if no_progress_attempts:
|
||||
raise OperatorNoProgressError(
|
||||
"模型在本轮工具调用预算内未恢复有效调用,已停止任务以避免空转。"
|
||||
)
|
||||
tool_limit_exceeded = True
|
||||
|
||||
raw_thinking = "\n".join(raw_thoughts) if raw_thoughts else None
|
||||
@@ -1122,6 +1161,9 @@ class OperatorNode:
|
||||
straight from the indexed list); a coordinate pair carries only the
|
||||
model's own ``target_description``. The two never mix.
|
||||
"""
|
||||
target = normalize_coordinate_target(target)
|
||||
if isinstance(target, bool):
|
||||
return None, "Error: Boolean values are not valid target indices."
|
||||
if isinstance(target, (int, float)):
|
||||
try:
|
||||
target_int = int(target)
|
||||
|
||||
@@ -111,6 +111,8 @@ class Settings(BaseSettings):
|
||||
ARTEMIS_VIDEO_ENABLED: bool = False
|
||||
ARTEMIS_OUTPUTTER_ENABLED: bool = True
|
||||
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
|
||||
# Consecutive Pro responses without a valid tool call before failing the task.
|
||||
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
|
||||
|
||||
# Android ADB Connectivity
|
||||
ADB_HOST: str | None = Field(default=DEFAULT_ADB_HOST)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# ARTEMIS-Eapil 1.0.1
|
||||
|
||||
## 修复
|
||||
|
||||
- Pro 模式兼容模型返回的字符串元素编号和字符串坐标,修复点击、长按及文本输入被错误拒绝的问题;保留越界检查和坐标目标描述校验。
|
||||
- 模型未调用工具时提供纠正提示;连续无有效调用或参数校验失败默认最多 3 次,耗尽后明确失败,避免持续空转。可通过 `ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS` 调整。
|
||||
- 历史任务和当前队列的任务 ID 支持选中文字、点击复制及键盘复制;成功或失败均有提示,复制不再触发打开任务。
|
||||
|
||||
## 验证
|
||||
|
||||
- 后端默认测试范围共 2146 项通过、2 项跳过;路径测试在普通环境单独执行,其余测试使用隔离运行目录。
|
||||
- 141 项前端测试通过,生产构建通过。
|
||||
- Ruff、质量基线、核心类型检查通过;内置浏览器验证任务 ID 拖选、复制提示和实际剪贴板内容。
|
||||
|
||||
## 安装与升级
|
||||
|
||||
Windows 解压源码包后运行 `start.bat`,国内网络使用 `start-cn.bat`。首次运行会安装依赖并构建网页。
|
||||
|
||||
Git 安装用户可通过启动脚本检查更新。升级后刷新网页;需要更新 AI 客户端的 MCP 时重新加载 MCP。已启动的任务不会热更新代码,新任务加载新版实现。
|
||||
|
||||
源码包不包含 `.env`、虚拟环境、依赖目录、编译产物或任务数据。`SHA256SUMS.txt` 提供附件校验值。
|
||||
|
||||
## 已知问题
|
||||
|
||||
- 部分 Responses 网关返回的最终校验 JSON 未被结构化结果接收环节采纳,可能出现模型文本报告通过、系统记录为 `inconclusive` 的不一致。本版尚未修复,执行完成不等于验证通过。
|
||||
- Pro 模式在计划全部完成后,仍可能反复输出完成说明、调用笔记或观察工具,导致额外收尾耗时。本版的连续无有效调用保护不能消除所有此类循环。
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
|
||||
[project]
|
||||
name = "artemis"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
description = "Autonomous AI Assistant for Mobile Automation"
|
||||
|
||||
authors = [
|
||||
|
||||
@@ -410,7 +410,7 @@ async def test_operator_node_multiple_actions():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_node_no_record_step():
|
||||
from artemis.agents.operator.operator import OperatorNode
|
||||
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
|
||||
from artemis.context import ArtemisContext
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
@@ -437,13 +437,11 @@ async def test_operator_node_no_record_step():
|
||||
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(mock_ctx, transcript_config=LEGACY_TRANSCRIPT)
|
||||
node_update = await node(mock_state)
|
||||
with pytest.raises(OperatorNoProgressError):
|
||||
await node(mock_state)
|
||||
|
||||
mock_ctx.data_engine.record_step.assert_not_called()
|
||||
|
||||
update_dict = node_update
|
||||
assert "structured_decisions" in update_dict
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_operator_dynamic_prompt():
|
||||
@@ -472,7 +470,13 @@ async def test_operator_dynamic_prompt():
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.tool_calls = []
|
||||
mock_response.tool_calls = [
|
||||
{
|
||||
"name": "click",
|
||||
"args": {"target": [50, 50], "target_description": "button"},
|
||||
"id": "call_click",
|
||||
}
|
||||
]
|
||||
|
||||
async def mock_ainvoke(*args, **kwargs):
|
||||
messages = args[0]
|
||||
@@ -574,7 +578,13 @@ async def test_operator_history_compression():
|
||||
|
||||
mock_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.tool_calls = []
|
||||
mock_response.tool_calls = [
|
||||
{
|
||||
"name": "click",
|
||||
"args": {"target": [50, 50], "target_description": "button"},
|
||||
"id": "call_click",
|
||||
}
|
||||
]
|
||||
|
||||
# Smarter file reading mock
|
||||
def mock_read_text(path_self, encoding=None):
|
||||
|
||||
@@ -69,8 +69,14 @@ def _state():
|
||||
def _llm(captured: list, usage_metadata=None):
|
||||
mock_llm = MagicMock()
|
||||
response = MagicMock()
|
||||
response.tool_calls = []
|
||||
response.content = "no action this turn"
|
||||
response.tool_calls = [
|
||||
{
|
||||
"name": "click",
|
||||
"args": {"target": [50, 50], "target_description": "button"},
|
||||
"id": "call_click",
|
||||
}
|
||||
]
|
||||
response.content = "Click the button."
|
||||
# usage_from_message only trusts a non-empty dict; a MagicMock attribute
|
||||
# (the default) reads as "no usage reported".
|
||||
if usage_metadata is not None:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Regression coverage for quoted click targets and prose-only recovery loops."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from langchain_core.messages import AIMessage, SystemMessage, ToolMessage
|
||||
import pytest
|
||||
|
||||
from artemis.agents.operator.operator import OperatorNode, OperatorNoProgressError
|
||||
from artemis.config.settings import settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def operator(monkeypatch):
|
||||
node = object.__new__(OperatorNode)
|
||||
node.ctx = SimpleNamespace(transcript_ledger=None)
|
||||
node._available_device_actions = lambda: {"click", "long_press", "input_text"}
|
||||
node._plan_changed_this_turn = lambda: True
|
||||
node._max_burst_actions = lambda: 4
|
||||
state = SimpleNamespace(
|
||||
operator_raw_data={"width": 1280, "height": 2856},
|
||||
indexed_elements=[{"center": [310, 2610], "text": "model menu", "resource_id": "model"}]
|
||||
* 13,
|
||||
open_incident=None,
|
||||
)
|
||||
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", 3)
|
||||
return node, state
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["click", "long_press", "input_text"])
|
||||
@pytest.mark.parametrize("target", ["13", "[240, 914]", "240, 914"])
|
||||
def test_quoted_targets_keep_same_action_and_grounding(operator, name, target):
|
||||
node, state = operator
|
||||
expected_target = 13 if target == "13" else [240, 914]
|
||||
args = {"target": target, "target_description": "model menu", "text": "test"}
|
||||
actions, error = node._translate_and_validate_tool({"name": name, "args": args}, state)
|
||||
expected, _ = node._translate_and_validate_tool(
|
||||
{"name": name, "args": {**args, "target": expected_target}}, state
|
||||
)
|
||||
assert error is None
|
||||
assert actions == expected
|
||||
if target == "13":
|
||||
assert actions[0]["target_resource_id"] == "model"
|
||||
else:
|
||||
assert actions[0]["target_description"] == "model menu"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("target", ["0", "14", "model menu", True, "true"])
|
||||
def test_invalid_index_is_not_guessed_or_dispatched(operator, target):
|
||||
node, state = operator
|
||||
actions, error = node._translate_and_validate_tool(
|
||||
{"name": "click", "args": {"target": target}}, state
|
||||
)
|
||||
assert not actions
|
||||
assert error
|
||||
|
||||
|
||||
def test_quoted_coordinates_still_need_description(operator):
|
||||
node, state = operator
|
||||
actions, error = node._translate_and_validate_tool(
|
||||
{"name": "click", "args": {"target": "[240, 914]"}}, state
|
||||
)
|
||||
assert not actions
|
||||
assert "target_description" in error
|
||||
|
||||
|
||||
def click(target="13"):
|
||||
return AIMessage(
|
||||
content="Open the model menu.",
|
||||
tool_calls=[{"name": "click", "args": {"target": target}, "id": "call_click"}],
|
||||
)
|
||||
|
||||
|
||||
async def run_loop(operator, monkeypatch, responses):
|
||||
node, state = operator
|
||||
invoke = AsyncMock(side_effect=responses)
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
messages = []
|
||||
result = await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||
return result, messages, invoke
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prose_retry_keeps_context_and_recovers_once(operator, monkeypatch):
|
||||
prose = AIMessage(content="I will click the model menu.")
|
||||
result, messages, invoke = await run_loop(operator, monkeypatch, [prose, click()])
|
||||
assert invoke.await_count == 2
|
||||
assert len(result[0]) == 1
|
||||
assert result[0][0]["coordinates"] == [310, 2610]
|
||||
assert prose in messages
|
||||
assert any(isinstance(m, SystemMessage) and "No tool call" in m.content for m in messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_target_can_be_corrected_before_limit(operator, monkeypatch):
|
||||
result, messages, invoke = await run_loop(operator, monkeypatch, [click("14"), click("13")])
|
||||
assert invoke.await_count == 2
|
||||
assert len(result[0]) == 1
|
||||
assert result[0][0]["coordinates"] == [310, 2610]
|
||||
outputs = [m for m in messages if isinstance(m, ToolMessage)]
|
||||
assert [m.status for m in outputs] == ["error", "success"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("limit", [1, 3])
|
||||
async def test_empty_responses_fail_at_configured_limit(operator, monkeypatch, limit):
|
||||
monkeypatch.setattr(settings, "ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS", limit)
|
||||
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
with pytest.raises(OperatorNoProgressError, match="未提供可执行的工具调用"):
|
||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert invoke.await_count == limit
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_calls_and_prose_share_bounded_recovery(operator, monkeypatch):
|
||||
invoke = AsyncMock(side_effect=[click("missing"), AIMessage(content="Retry."), click("14")])
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
messages = []
|
||||
with pytest.raises(OperatorNoProgressError, match="校验仍失败"):
|
||||
await node._invoke_llm_loop(Mock(), messages, [], [], state)
|
||||
assert invoke.await_count == 3
|
||||
assert any(isinstance(m, ToolMessage) and m.status == "error" for m in messages)
|
||||
assert not any(isinstance(m, ToolMessage) and m.content == "Action Recorded" for m in messages)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_iteration_cap_cannot_restart_no_progress_next_turn(operator, monkeypatch):
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.OPERATOR_MAX_TOOL_ITERATIONS", 2)
|
||||
invoke = AsyncMock(return_value=AIMessage(content="I will click now."))
|
||||
monkeypatch.setattr("artemis.agents.operator.operator.acomplete", invoke)
|
||||
node, state = operator
|
||||
with pytest.raises(OperatorNoProgressError, match="预算内未恢复"):
|
||||
await node._invoke_llm_loop(Mock(), [], [], [], state)
|
||||
assert invoke.await_count == 2
|
||||
@@ -141,11 +141,17 @@ def _transcript_state(**overrides):
|
||||
return state
|
||||
|
||||
|
||||
def _no_action_llm(captured: list):
|
||||
def _action_llm(captured: list):
|
||||
mock_llm = MagicMock()
|
||||
mock_response = MagicMock()
|
||||
mock_response.tool_calls = []
|
||||
mock_response.content = "no action this turn"
|
||||
mock_response.tool_calls = [
|
||||
{
|
||||
"name": "click",
|
||||
"args": {"target": [50, 50], "target_description": "button"},
|
||||
"id": "call_click",
|
||||
}
|
||||
]
|
||||
mock_response.content = "Click the button."
|
||||
|
||||
async def mock_ainvoke(*args, **kwargs):
|
||||
captured.append(list(args[0]))
|
||||
@@ -166,7 +172,7 @@ async def test_transcript_mode_two_turns_build_four_regions(tmp_path):
|
||||
ctx.data_engine.global_base_dir = str(tmp_path)
|
||||
ctx.data_engine.get_agent_friendly_steps.return_value = []
|
||||
captured: list = []
|
||||
mock_llm = _no_action_llm(captured)
|
||||
mock_llm = _action_llm(captured)
|
||||
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||
@@ -237,7 +243,7 @@ async def test_transcript_tail_marks_per_turn_notices_ephemeral():
|
||||
|
||||
ctx = _transcript_ctx()
|
||||
captured: list = []
|
||||
mock_llm = _no_action_llm(captured)
|
||||
mock_llm = _action_llm(captured)
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||
await node(
|
||||
@@ -278,7 +284,7 @@ async def test_transcript_tail_marks_per_turn_notices_ephemeral():
|
||||
async def test_transcript_actionless_turn_commits_without_validator_message():
|
||||
ctx = _transcript_ctx()
|
||||
captured: list = []
|
||||
mock_llm = _no_action_llm(captured)
|
||||
mock_llm = _action_llm(captured)
|
||||
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||
@@ -315,7 +321,7 @@ async def test_transcript_cold_start_builds_restored_history_block(tmp_path):
|
||||
]
|
||||
|
||||
captured: list = []
|
||||
mock_llm = _no_action_llm(captured)
|
||||
mock_llm = _action_llm(captured)
|
||||
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=True))
|
||||
@@ -338,7 +344,7 @@ async def test_transcript_cold_start_builds_restored_history_block(tmp_path):
|
||||
async def test_flag_off_keeps_legacy_two_message_build_and_no_ledger():
|
||||
ctx = _transcript_ctx()
|
||||
captured: list = []
|
||||
mock_llm = _no_action_llm(captured)
|
||||
mock_llm = _action_llm(captured)
|
||||
|
||||
with patch("artemis.agents.operator.operator.get_llm", return_value=mock_llm):
|
||||
node = OperatorNode(ctx, transcript_config=MemoryTranscriptConfig(enabled=False))
|
||||
|
||||
Reference in new issue
Block a user