feat: improve task execution, verification and packaging

Add execution incidents, action bursts and shared Flash history.

Expose verification and Explorer settings with streamed check results.

Support graceful cancellation, recording recovery and bundled UI resources.

Add CI quality checks and clean up comments and documentation.
This commit is contained in:
somew1nd committed 2026-09-02 21:45:51 -07:00
1 parent 5ccf06d677
commit 67248c887e
289 files changed
+15883 -6919

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@
<!-- Robot Silhouette Mask -->
<mask id="robotMeshMask">
<rect width="240" height="240" fill="black" />
<!-- Antennae -->
<path d="M 90 60 Q 75 35 65 28" stroke="white" stroke-width="10" stroke-linecap="round" />
<circle cx="62" cy="24" r="10" fill="white" />

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

@@ -277,7 +277,7 @@ limitations under the License.
}
</div>
}
<!-- Verification Box (Pulled out into a minimalist framed card) -->
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
@@ -505,7 +505,7 @@ limitations under the License.
class="phase-header"
[class.first-phase]="first && startupWorkItems().length === 0"
>
<span class="phase-worked-time">Worked for {{ phase.durationSeconds }}s</span>
<span class="phase-worked-time">{{ isCheckerPhase(phase) ? 'Checked' : 'Worked' }} for {{ phase.durationSeconds }}s</span>
@if (phase.tokens) {
<span class="phase-token-usage" [title]="getTokenTooltip(phase)">
<span class="token-dot">·</span>
@@ -524,12 +524,68 @@ limitations under the License.
>
<!-- Unified Step / Stream Body -->
<div class="step-body">
<!-- Check block (verifier lane): header, then the declared check items
and how each is judged; the chronological stream follows, and the
result rows close the block (see check-result below). -->
@if (isCheckerBlock(block)) {
<div class="check-lane" [ngClass]="'tone-' + getCheckerTone(block)">
<div class="check-header">
<span class="stream-status-dot" [class.streaming]="isBlockRunning(block)"></span>
<span class="check-title">{{ getCheckerPhaseLabel(block) }}</span>
@if (block.data.phase !== 'outcome' && block.data.subgoal_text) {
<span class="check-subject" [title]="block.data.subgoal_text">{{ block.data.subgoal_text }}</span>
}
<span class="check-status">{{ getCheckerStatusLabel(block) }}</span>
</div>
@if (!isCheckerMuted(block) && getCheckerItems(block).length > 0) {
<ol class="check-items">
@for (item of getCheckerItems(block); track $index) {
<li class="check-item">
<span class="check-index">{{ $index + 1 }}</span>
<span class="check-text">{{ item.text }}</span>
<span class="check-method">{{ getCheckMethodLabel(item, block) }}</span>
</li>
}
</ol>
}
</div>
}
<!-- Text, tool calls, and actions share one chronological timeline. -->
@if (getSortedStepEvents(block.data).length > 0) {
<div class="step-actions-container">
@for (item of getSortedStepEvents(block.data); track trackStepEvent(itemIdx, item); let itemIdx = $index) {
<!-- Check block: one Thought / Work entry per streamed turn, in time order with its tool calls -->
@if (isCheckerBlock(block) && item.type === 'thinking') {
<div
class="thinking-stream-details native-thinking-details"
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)"
[class.collapsed]="isCheckerSegmentCollapsed(block, itemIdx)">
<div class="details-summary" (click)="toggleCheckerSegment(block, itemIdx)">
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
Thought
</div>
<div class="details-content-wrapper">
<div class="details-content stream-box">
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
</div>
</div>
</div>
}
@if (isCheckerBlock(block) && item.type === 'text' && isCheckerTextVisible(item)) {
<div
class="step-raw-thinking-message"
[class.is-running]="isCheckerSegmentRunning(block, itemIdx)">
<div class="report-header">
<span class="stream-status-dot" [class.streaming]="isCheckerSegmentRunning(block, itemIdx)"></span>
Work
</div>
<div class="report-content stream-box">
<div class="stream-text" [innerHTML]="renderMarkdown(item.data.text, block.id + '-seg-' + itemIdx)"></div>
</div>
</div>
}
<!-- Native Thinking (Thought) -->
@if (item.type === 'thinking') {
@if (!isCheckerBlock(block) && item.type === 'thinking') {
<div
class="thinking-stream-details native-thinking-details"
[class.is-running]="isBlockRunning(block)"
@@ -546,7 +602,7 @@ limitations under the License.
</div>
}
<!-- Raw Thinking / Plan / Message (Work) -->
@if (item.type === 'text' && getRawThinking(block)) {
@if (!isCheckerBlock(block) && item.type === 'text' && getRawThinking(block)) {
<div
class="step-raw-thinking-message"
[class.is-running]="isBlockRunning(block)">
@@ -1093,6 +1149,26 @@ limitations under the License.
}
</div>
}
<!-- Check block result: one row per check item, keyed by its number above -->
@if (hasCheckerResult(block)) {
<div class="check-result" [ngClass]="'tone-' + getCheckerTone(block)">
@for (verdict of getCheckerVerdicts(block); track $index) {
<div class="check-verdict" [ngClass]="verdict.status">
<span class="check-index">{{ getVerdictItemNumber(block, verdict, $index) }}</span>
<span class="material-symbols-outlined verdict-icon">{{ getVerdictIcon(verdict.status) }}</span>
<span class="verdict-body">
<span class="verdict-evidence">{{ verdict.evidence || getVerdictStatusText(verdict.status) }}</span>
@if (verdict.suggestion) {
<span class="verdict-suggestion">{{ verdict.suggestion }}</span>
}
</span>
</div>
}
@for (note of getCheckerNotes(block); track note) {
<div class="check-note">{{ note }}</div>
}
</div>
}
</div>
</div>
}
@@ -3558,5 +3558,162 @@ $stream-row-height: 25px;
}
}
/* Checker blocks share the step timeline, marked by a violet left border. */
.agent-stream-container .stream-logs-content .phase-body .block-item.type-checker {
border-left: 2px solid #ddd6fe;
padding-left: 12px;
margin-left: -14px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', Arial, sans-serif;
.check-index {
flex: 0 0 14px;
text-align: right;
color: #a1a1aa;
font-variant-numeric: tabular-nums;
user-select: none;
}
.check-lane {
display: flex;
flex-direction: column;
gap: $uniform-gap;
margin-bottom: $uniform-gap;
.check-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 20px;
font-size: 12px;
line-height: 1.5;
letter-spacing: 0.2px;
color: #71717a;
user-select: none;
.stream-status-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
background: #d4d4d8;
&.streaming {
background: #7c3aed;
box-shadow: 0 0 0 0 rgba(124, 58, 237, 0.45);
animation: pulse-stream-dot 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
.check-title {
flex: 0 0 auto;
font-weight: 500;
color: #52525b;
}
.check-subject {
flex: 1 1 auto;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.check-status {
flex: 0 0 auto;
margin-left: auto;
font-weight: 500;
color: #52525b;
white-space: nowrap;
}
}
&.tone-passed .check-status { color: #15803d; }
&.tone-failed .check-status { color: #b91c1c; }
&.tone-inconclusive .check-status { color: #b45309; }
&.tone-running .check-status { color: #6d28d9; }
&.tone-muted { opacity: 0.6; }
.check-items {
list-style: none;
margin: 0;
padding: 0 0 0 16px;
display: flex;
flex-direction: column;
gap: 2px;
.check-item {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 12px;
line-height: 1.5;
color: #3f3f46;
.check-text {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.check-method {
flex: 0 0 auto;
color: #a1a1aa;
white-space: nowrap;
}
}
}
}
.check-result {
display: flex;
flex-direction: column;
gap: 4px;
margin-top: $uniform-gap;
padding-left: 16px;
.check-verdict {
display: flex;
align-items: flex-start;
gap: 8px;
font-size: 12px;
line-height: 1.5;
color: #52525b;
.check-index {
line-height: 18px;
}
.verdict-icon {
flex: 0 0 auto;
font-size: 15px;
line-height: 18px;
color: #a1a1aa;
}
&.passed .verdict-icon { color: #16a34a; }
&.failed .verdict-icon { color: #dc2626; }
&.inconclusive .verdict-icon { color: #d97706; }
&.superseded, &.unchecked { color: #a1a1aa; }
.verdict-body {
flex: 1 1 auto;
min-width: 0;
word-break: break-word;
}
.verdict-suggestion {
display: block;
color: #1d4ed8;
}
}
.check-note {
font-size: 12px;
line-height: 1.5;
color: #71717a;
padding-left: 22px;
word-break: break-word;
}
}
}
@@ -470,6 +470,8 @@ export class AgentStreamComponent implements AfterViewInit {
effect(() => {
const blocks = this.consolidatedBlocks();
blocks.forEach(block => {
// Check blocks render their per-turn segments directly (no typewriter).
if (block.type === 'checker') return;
const blockId = block.id;
const rawText = this.getRawThinking(block) || '';
const nativeText = this.getNativeThinking(block) || '';
@@ -1162,8 +1164,187 @@ export class AgentStreamComponent implements AfterViewInit {
return this.getThinkingTexts(block.data).raw;
}
// --- Check blocks (verifier lane) ----------------------------------------------
public isCheckerBlock(block: any): boolean {
return block?.type === 'checker';
}
public isCheckerPhase(phase: any): boolean {
const blocks: any[] = Array.isArray(phase?.blocks) ? phase.blocks : [];
return blocks.length > 0 && blocks.every((b) => this.isCheckerBlock(b));
}
public getCheckerPhaseLabel(block: any): string {
switch (block?.data?.phase) {
case 'final':
return 'Final check';
case 'outcome':
return 'Result';
default:
return 'Check';
}
}
public getCheckerVerdicts(block: any): any[] {
return Array.isArray(block?.data?.verdicts) ? block.data.verdicts : [];
}
/** Declared check items; persisted attempts only carry verdicts, so derive them. */
public getCheckerItems(block: any): any[] {
const d = block?.data || {};
if (Array.isArray(d.items) && d.items.length > 0) return d.items;
return this.getCheckerVerdicts(block).map((v) => ({ kind: v.kind, text: v.item_text, when: v.when }));
}
/** How the item is judged, in plain words (verify = must pass, assert = recorded test result). */
public getCheckMethodLabel(item: any, block: any): string {
const parts: string[] = [item?.kind === 'assert' ? 'Test assertion' : 'Must pass'];
if (item?.when === 'at_end' && block?.data?.phase !== 'final') parts.push('at the end');
return parts.join(' · ');
}
/** Superseded / unchecked attempts collapse to their header line. */
public isCheckerMuted(block: any): boolean {
const status = block?.data?.status;
return status === 'superseded' || status === 'unchecked';
}
/** Number of the check item a verdict belongs to (1-based, matches the list above the stream). */
public getVerdictItemNumber(block: any, verdict: any, fallbackIndex: number): number {
const idx = this.getCheckerItems(block).findIndex((i) => i.text === verdict?.item_text);
return (idx > -1 ? idx : fallbackIndex) + 1;
}
public getVerdictStatusText(status: string): string {
switch (status) {
case 'passed':
return 'Passed';
case 'failed':
return 'Failed';
case 'inconclusive':
return 'Could not be confirmed';
case 'superseded':
return 'Superseded by a newer check';
case 'unchecked':
return 'Not checked';
default:
return status || '';
}
}
public hasCheckerResult(block: any): boolean {
if (!this.isCheckerBlock(block) || this.isBlockRunning(block) || this.isCheckerMuted(block)) return false;
if (block.data?.phase === 'outcome') return this.getCheckerNotes(block).length > 0;
return this.getCheckerVerdicts(block).length > 0 || this.getCheckerNotes(block).length > 0;
}
/** Follow-ups not already visible in the verdict rows (the findings text repeats them). */
public getCheckerNotes(block: any): string[] {
const d = block?.data || {};
if (d.phase === 'outcome') return Array.isArray(d.last_findings) ? d.last_findings : [];
const notes: string[] = [];
if (Array.isArray(d.unmet_subgoals)) {
for (const text of d.unmet_subgoals) notes.push(`Not finished yet: ${text}`);
}
if (d.reverted) notes.push('The step was sent back to be redone.');
if (d.error) notes.push(String(d.error));
return notes;
}
// Streamed turns of a check: the last one is live while the block runs;
// finished Thought turns fold unless the reader opens them.
public openCheckSegments = signal<Set<string>>(new Set<string>());
public isCheckerSegmentRunning(block: any, itemIdx: number): boolean {
if (!this.isBlockRunning(block)) return false;
return itemIdx === this.getSortedStepEvents(block.data).length - 1;
}
public isCheckerSegmentCollapsed(block: any, itemIdx: number): boolean {
if (this.isCheckerSegmentRunning(block, itemIdx)) return false;
return !this.openCheckSegments().has(`${block.id}-${itemIdx}`);
}
public toggleCheckerSegment(block: any, itemIdx: number): void {
const key = `${block.id}-${itemIdx}`;
this.openCheckSegments.update((set) => {
const next = new Set(set);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}
public isCheckerTextVisible(item: any): boolean {
const text = item?.data?.text;
return typeof text === 'string' && text.trim().length > 0 && this.isHumanThinking(text);
}
public getCheckerTone(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
return d.task_status === 'completed' ? 'passed' : (d.task_status === 'blocked' ? 'failed' : 'inconclusive');
}
if (d.isCompleted === false) return 'running';
if (d.status === 'superseded' || d.status === 'unchecked') return 'muted';
if (d.status === 'error') return 'inconclusive';
const verdicts = this.getCheckerVerdicts(block);
if (verdicts.some((v) => v.status === 'failed')) return 'failed';
if (verdicts.some((v) => v.status === 'inconclusive')) return 'inconclusive';
return 'passed';
}
public getCheckerStatusLabel(block: any): string {
const d = block?.data || {};
if (d.phase === 'outcome') {
const t = d.tests || {};
const label = d.task_status === 'completed' ? 'Goal completed'
: (d.task_status === 'blocked' ? 'Blocked' : 'Partially completed');
const counts = [
[t.passed, 'passed'],
[t.failed, 'failed'],
[t.inconclusive, 'inconclusive'],
[t.unchecked, 'not checked']
]
.filter(([n]) => Number(n) > 0)
.map(([n, word]) => `${n} ${word}`);
return counts.length > 0 ? `${label} · ${counts.join(' · ')}` : label;
}
if (d.isCompleted === false) return 'Checking…';
switch (d.status) {
case 'superseded':
return 'Superseded';
case 'unchecked':
return 'Not checked';
case 'error':
return 'No verdict';
}
const verdicts = this.getCheckerVerdicts(block);
const failed = verdicts.filter((v) => v.status === 'failed').length;
if (failed > 0) return `${failed} failed`;
if (verdicts.some((v) => v.status === 'inconclusive')) return 'Inconclusive';
return verdicts.length > 0 ? 'Passed' : 'Done';
}
public getVerdictIcon(status: string): string {
switch (status) {
case 'passed':
return 'check_circle';
case 'failed':
return 'cancel';
case 'inconclusive':
return 'help';
case 'superseded':
return 'history';
case 'unchecked':
return 'radio_button_unchecked';
default:
return 'pending';
}
}
public hasVisibleContent(block: any): boolean {
if (!block) return false;
if (block.type === 'checker') return true;
const hasNative = Boolean(this.getNativeThinking(block));
const hasRaw = Boolean(this.getRawThinking(block));
const hasVisibleTools = Boolean(block.data?.generic_tools) && block.data.generic_tools.some((t: any) =>
@@ -1569,6 +1750,7 @@ export class AgentStreamComponent implements AfterViewInit {
public isBlockRunning(block: any): boolean {
if (!block || !block.data) return false;
if (!this.isCurrentSessionActive()) return false;
if (block.type === 'checker') return block.data.isCompleted === false;
if (this.isReportStatusAction(block.data.action_taken) || block.data?.status === 'completed' || block.data?.status === 'failed' || block.data?.status === 'cancelled') return false;
if (block.data.isCompleted === false) return true;
const blocks = this.consolidatedBlocks();
@@ -290,7 +290,7 @@ limitations under the License.
}
</div>
}
<!-- Verification Box (Pulled out into a minimalist framed card) -->
<!-- Verification result -->
@if (milestone.checks.length > 0) {
<div class="milestone-checks-container">
@for (check of milestone.checks; track trackMarkdownLine($index, check)) {
@@ -0,0 +1,47 @@
import {
DEFAULT_EXPLORER_MODE,
DEFAULT_VERIFICATION_LEVEL,
EXPLORER_MODES,
VERIFICATION_LEVELS,
levelIndex,
notchPercent
} from './pro-tuning.model';
describe('pro tuning ladders', () => {
it('keeps every notch id and label unique within its ladder', () => {
for (const ladder of [VERIFICATION_LEVELS, EXPLORER_MODES]) {
expect(new Set(ladder.map((l) => l.id)).size).toBe(ladder.length);
expect(new Set(ladder.map((l) => l.label)).size).toBe(ladder.length);
}
});
it('mirrors the backend wire values', () => {
expect(VERIFICATION_LEVELS.map((l) => l.id)).toEqual(['off', 'final', 'checkpoints', 'strict']);
expect(EXPLORER_MODES.map((l) => l.id)).toEqual(['flash', 'pro', 'ultra']);
expect(VERIFICATION_LEVELS.map((l) => l.id)).toContain(DEFAULT_VERIFICATION_LEVEL);
expect(EXPLORER_MODES.map((l) => l.id)).toContain(DEFAULT_EXPLORER_MODE);
});
it('every level explains itself', () => {
for (const level of [...VERIFICATION_LEVELS, ...EXPLORER_MODES]) {
expect(level.tagline.length).toBeGreaterThan(10);
expect(level.latency.length).toBeGreaterThan(0);
expect(level.runs.length).toBeGreaterThan(0);
expect(level.bestFor.length).toBeGreaterThan(10);
}
});
it('resolves ids case-insensitively and falls back to the default', () => {
expect(levelIndex(VERIFICATION_LEVELS, ' STRICT ', DEFAULT_VERIFICATION_LEVEL)).toBe(3);
expect(levelIndex(VERIFICATION_LEVELS, 'nope', DEFAULT_VERIFICATION_LEVEL)).toBe(1);
expect(levelIndex(EXPLORER_MODES, null, DEFAULT_EXPLORER_MODE)).toBe(0);
});
it('maps notches onto the track', () => {
expect(notchPercent(0, 4)).toBe(0);
expect(notchPercent(3, 4)).toBe(100);
expect(notchPercent(1, 3)).toBe(50);
expect(notchPercent(9, 3)).toBe(100);
expect(notchPercent(0, 1)).toBe(0);
});
});
@@ -0,0 +1,165 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Launcher options for `/api/run` (`verification_level`, `explorer_mode`).
* Keep ids in sync with `VERIFICATION_LEVEL_PRESETS` in `artemis/config/agent.py`
* and `EXPLORER_TIERS` in `artemis/agents/explorer/tiers.py`.
*/
export type VerificationLevelId = 'off' | 'final' | 'checkpoints' | 'strict';
export type ExplorerModeId = 'flash' | 'pro' | 'ultra';
/** One notch on a tuning slider. */
export interface TuningLevel<TId extends string = string> {
/** Wire value sent to the backend. */
id: TId;
/** Short name shown next to the slider title and as the hover card heading. */
label: string;
/** One-sentence summary shown in the hover card. */
tagline: string;
/** Plain-language time cost, e.g. "no extra time". */
latency: string;
/** Checks or searches performed at this level. */
runs: string[];
/** Checks or searches omitted at this level. */
skips?: string[];
/** When to pick this level. */
bestFor: string;
}
export const VERIFICATION_LEVELS: readonly TuningLevel<VerificationLevelId>[] = [
{
id: 'off',
label: 'Off',
tagline: 'No checking. The run ends as soon as the task looks done.',
latency: 'no extra time',
runs: [
'Each step is treated as finished the moment it is carried out.',
'You get the full action trace, but no pass / fail verdict.'
],
skips: ['Nothing is double-checked and nothing is retried.'],
bestFor: 'Quick tries and demos, when you only want to watch what happens.'
},
{
id: 'final',
label: 'At the end',
tagline: 'One check of the finished result against your goal. This is the default.',
latency: 'adds about 20–60 s at the end',
runs: [
'When the task finishes, the final screen, the step history and the device state are compared with what you asked for.',
'If the result does not match, the task goes back and tries to fix it, up to 3 times.'
],
skips: ['Nothing is checked while the task is still running.'],
bestFor: 'Everyday tasks: an honest pass / fail without slowing the run down.'
},
{
id: 'checkpoints',
label: 'Every step',
tagline: 'Each step is checked as soon as it is done, plus the final check.',
latency: 'a short check after each step, done in the background',
runs: [
'Every step is checked right after it completes, using the screenshots from that moment.',
'If a step went wrong, it gets fixed before moving on (up to 2 tries per step).',
'A failed test condition is written down and the task keeps going.',
'The final check still runs at the end.'
],
bestFor: 'Long tasks where one early mistake would spoil everything after it.'
},
{
id: 'strict',
label: 'Strict',
tagline: 'Every step is checked, with more retries. The first failed test stops the run.',
latency: 'slowest: more checks and more retries',
runs: [
'Each check takes longer and gets more attempts: 4 fixes per step and 5 at the end.',
'The first failed test condition stops the run immediately, with the evidence attached.'
],
bestFor: 'Release checks and regression runs, where a wrong pass is never acceptable.'
}
];
export const EXPLORER_MODES: readonly TuningLevel<ExplorerModeId>[] = [
{
id: 'flash',
label: 'Quick glance',
tagline: 'Finds buttons and text on the screen in a single look.',
latency: '1 look per search',
runs: [
'Something on screen is asked for by name, icon or colour and its position comes back straight away.',
'Several things can be looked up at once.'
],
skips: ['No zooming in and no second try.'],
bestFor: 'Ordinary apps with clearly labelled buttons, icons and text.'
},
{
id: 'pro',
label: 'Second look',
tagline: 'Takes up to 3 looks, thinking in between, before answering.',
latency: 'up to 3 looks per search',
runs: [
'The screen layout is read first, then the picture is searched.',
'If the first try misses, a different approach is tried within the 3 looks.'
],
skips: ['Still no zooming into small areas, to keep searches short.'],
bestFor: 'Things described by where they are ("the switch next to Wi-Fi") or with unclear labels.'
},
{
id: 'ultra',
label: 'Close-up',
tagline: 'Zooms into parts of the screen and takes up to 8 looks.',
latency: 'up to 8 looks per search (slowest)',
runs: [
'Parts of the screen can be cropped and magnified to read tiny text and crowded layouts piece by piece.',
'Later looks reuse the earlier ones, so they cost less time than they sound.'
],
bestFor: 'Crowded screens, tiny targets, charts and drawings, and checks where exact placement matters.'
}
];
/** Per-run tuning sent with `/api/run` for the Pro profile. */
export interface ProTuningOptions {
verificationLevel?: VerificationLevelId | string;
explorerMode?: ExplorerModeId | string;
}
/** Effective defaults reported by `GET /api/run/defaults`. */
export interface ProTuningDefaults {
verification_level?: string | null;
explorer_mode?: string | null;
}
export const DEFAULT_VERIFICATION_LEVEL: VerificationLevelId = 'final';
export const DEFAULT_EXPLORER_MODE: ExplorerModeId = 'flash';
/** Index of a level id within its ladder; falls back to the default when unknown. */
export function levelIndex<TId extends string>(
ladder: readonly TuningLevel<TId>[],
id: string | null | undefined,
fallback: TId
): number {
const wanted = String(id ?? '').trim().toLowerCase();
const idx = ladder.findIndex((l) => l.id === wanted);
if (idx >= 0) return idx;
return Math.max(0, ladder.findIndex((l) => l.id === fallback));
}
/** Slider fill percentage for a notch index on a ladder of `count` notches. */
export function notchPercent(index: number, count: number): number {
if (count <= 1) return 0;
const clamped = Math.min(Math.max(index, 0), count - 1);
return (clamped / (count - 1)) * 100;
}
@@ -79,7 +79,7 @@ export interface StepItemData {
export interface StepBlock {
id: string;
type: 'llm_stream' | 'step';
type: 'llm_stream' | 'step' | 'checker';
timestamp: string;
data: any;
}
@@ -93,6 +93,75 @@ export interface PhaseBlock {
blocks: StepBlock[];
}
/** One check item's verdict as booked by the Checker (ledger record shape). */
export interface CheckerVerdict {
item_text: string;
kind: 'verify' | 'assert' | string;
status: 'passed' | 'failed' | 'inconclusive' | 'superseded' | 'unchecked' | string;
evidence: string;
suggestion?: string;
}
export interface CheckerCheckItem {
kind: string;
text: string;
when?: string;
}
/**
* One streamed LLM turn of a multi-turn agent (the Checker's tool loop):
* the text of one execution id with the time its first chunk arrived, so the
* timeline can interleave it with the tool calls that followed.
*/
export interface StreamSegment {
execution_id: string;
stream_type: 'thinking' | 'text';
text: string;
timestamp: string;
isCompleted?: boolean;
}
/**
* Data of a `checker` timeline block: one Checker attempt (a midway checkpoint
* of a completed subgoal or the exit final review), or the run outcome
* (`phase: 'outcome'`). Streamed reasoning and tool traces land in the same
* `operator_*_thinking` / `generic_tools` fields as an Operator step so the
* timeline renders them with one code path.
*/
export interface CheckerBlockData {
event?: string;
attempt_id?: string;
checkpoint_id?: string;
phase: 'checkpoint' | 'final' | 'outcome' | string;
subgoal_text?: string;
status?: 'running' | 'done' | 'superseded' | 'unchecked' | 'error' | string;
trace_id?: string | null;
anchor_step_id?: string | null;
items?: CheckerCheckItem[];
verdicts?: CheckerVerdict[];
findings?: string[];
unmet_subgoals?: string[];
reverted?: boolean;
applicable?: boolean;
repairs_used?: number;
route?: string;
error?: string;
task_status?: 'completed' | 'partial' | 'blocked' | string;
tests?: { passed: number; failed: number; inconclusive: number; unchecked: number };
last_findings?: string[];
started_at?: number;
finished_at?: number;
duration?: number;
isCompleted?: boolean;
generic_tools?: any[];
/** Per-turn stream segments (live sessions only); the flat fields below hold the joined text. */
stream_segments?: StreamSegment[];
operator_native_thinking?: string;
operator_raw_thinking?: string;
[key: string]: any;
}
/** @deprecated legacy JSON-in-stream checker verdict (pre-ledger); kept for the parser. */
export interface CheckerResult {
success: boolean;
reason: string;
@@ -580,7 +580,7 @@
<span class="provider-dot"></span>
<span class="provider-name">{{ modelConfigEnv()?.default_model?.provider || 'google' }}</span>
<span class="model-divider">/</span>
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.7-flash' }}</span>
<span class="model-name">{{ modelConfigEnv()?.default_model?.model || 'gemini-3.8-flash' }}</span>
</div>
</div>
@@ -1761,14 +1761,14 @@
<p class="desc-line">
<span class="mode-name">Flash</span>
<span class="bullet">·</span>
<span>Token-efficient, rapid <strong>3–5s/step</strong> reactive decisions. Best for routine daily tasks within <strong>25 steps</strong>.</span>
<span class="limitation-tag">(No long-term monitoring or video analysis)</span>
<span>Token-efficient, rapid <strong>3–5s/step</strong> decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run.</span>
<span class="limitation-tag">(No plan, no result check, no ADB commands)</span>
</p>
} @else {
<p class="desc-line">
<span class="mode-name">Pro</span>
<span class="bullet">·</span>
<span>Our most powerful agent architecture. Handles <strong>100+ step</strong> workflows and direct phone state monitoring (<strong>20–40s</strong> / step).</span>
<span>Our most powerful mode. Plans first, checks its own results and recovers when a tap is blocked. Handles <strong>100+ step</strong> workflows and live phone monitoring (<strong>15–40s</strong> / step).</span>
</p>
}
</div>
@@ -1792,7 +1792,7 @@
class="model-toggle-btn btn-flash"
[class.active]="selectedProfile() === 'flash'"
(click)="setProfile('flash')"
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)"
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)"
>
<span class="material-symbols-outlined btn-icon">bolt</span>
<span class="btn-text">Flash</span>
@@ -1802,7 +1802,7 @@
class="model-toggle-btn btn-pro"
[class.active]="selectedProfile() === 'pro'"
(click)="setProfile('pro')"
title="Pro: Multi-agent graph architecture with planner & closed-loop verification for complex tasks"
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)"
>
<span class="material-symbols-outlined btn-icon">psychology</span>
<span class="btn-text">Pro</span>
@@ -1863,6 +1863,120 @@
}
</div>
</div>
<!-- Pro Mode Tuning Card: verification depth & explorer perception sliders -->
<div class="pro-tuning-card" [class.tip-open]="activeTuningTip() !== null">
<div class="tuning-card-header">
<span class="tuning-title">Pro settings</span>
@if (!isTuningDefault()) {
<button type="button" class="btn-tuning-reset" (click)="resetTuning()" title="Back to the defaults">Reset</button>
}
</div>
<div class="tuning-sliders">
@for (slider of tuningSliders(); track slider.kind) {
<div
class="tuning-slider"
[class.verify]="slider.kind === 'verify'"
[class.explore]="slider.kind === 'explore'"
[class.dragging]="activeTuningTip() === slider.kind"
[style.--pct]="slider.fraction"
>
<div class="slider-head">
<span class="slider-name">{{ slider.name }}</span>
<span class="slider-value">{{ slider.level.label }}</span>
</div>
<div class="slider-ends" aria-hidden="true">
<span>{{ slider.ends[0] }}</span>
<span>{{ slider.ends[1] }}</span>
</div>
<div
class="slider-track-wrap"
(pointerenter)="showTuningTip(slider.kind)"
(pointerleave)="hideTuningTip(slider.kind)"
>
<div class="slider-rail" aria-hidden="true">
<div class="slider-fill"></div>
@for (lvl of slider.ladder; track lvl.id; let i = $index) {
<span
class="slider-notch"
[class.reached]="i <= slider.index"
[style.--n]="notchFraction(i, slider.ladder.length)"
></span>
}
@if (slider.index === slider.ladder.length - 1) {
<!-- Re-created each time the thumb reaches the end, so the spread replays. -->
<div class="slider-solid"></div>
<svg class="slider-dither" width="100%" height="6" aria-hidden="true">
@for (px of ditherPixels; track $index) {
<rect
[attr.x]="px.x + '%'"
[attr.y]="px.y"
width="2"
height="2"
[style.opacity]="px.opacity"
[style.animation-delay.ms]="px.delay"
/>
}
</svg>
}
</div>
<div class="slider-thumb" aria-hidden="true"></div>
@if (slider.index === slider.ladder.length - 1) {
<span class="thumb-ripple" aria-hidden="true"></span>
}
<input
type="range"
class="slider-input"
min="0"
[max]="slider.ladder.length - 1"
step="1"
[value]="slider.index"
(input)="setTuningIndex(slider.kind, $any($event.target).value)"
(focus)="showTuningTip(slider.kind)"
(blur)="hideTuningTip(slider.kind)"
[attr.aria-label]="slider.name"
[attr.aria-valuetext]="slider.level.label + ': ' + slider.level.tagline"
/>
@if (activeTuningTip() === slider.kind) {
<div class="tuning-tip-arrow" aria-hidden="true"></div>
<div class="tuning-tip" role="tooltip">
<div class="tip-heading">
<span class="tip-title">{{ slider.level.label }}</span>
<span class="tip-latency">{{ slider.level.latency }}</span>
</div>
<p class="tip-tagline">{{ slider.level.tagline }}</p>
<div class="tip-section">
<span class="tip-section-title">What happens</span>
<ul>
@for (line of slider.level.runs; track $index) {
<li>{{ line }}</li>
}
</ul>
</div>
@if (slider.level.skips?.length) {
<div class="tip-section skips">
<span class="tip-section-title">Not included</span>
<ul>
@for (line of slider.level.skips; track $index) {
<li>{{ line }}</li>
}
</ul>
</div>
}
<div class="tip-best">
<span class="tip-section-title">Use it for</span>
<span>{{ slider.level.bestFor }}</span>
</div>
</div>
}
</div>
</div>
}
</div>
</div>
}
<!-- Recommended Tasks Hub (Clean & Direct) -->
@@ -4272,6 +4272,381 @@ $text-light: #94a3b8;
}
}
/* Pro tuning sliders and hover cards */
.pro-tuning-card {
--tip-w: 320px;
--thumb-w: 22px;
--thumb-h: 14px;
--col-gap: 28px;
--accent: #6366f1;
position: relative;
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.95);
border-radius: 16px;
padding: 12px 18px 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 8px 24px -6px rgba(15, 23, 42, 0.04);
display: flex;
flex-direction: column;
gap: 10px;
animation: slideUpFade 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&.tip-open {
z-index: 30;
}
.tuning-card-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-height: 16px;
.tuning-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
letter-spacing: -0.01em;
}
.btn-tuning-reset {
border: none;
background: transparent;
padding: 0;
font-family: inherit;
font-size: 12px;
font-weight: 500;
color: #94a3b8;
cursor: pointer;
transition: color 0.15s ease;
&:hover {
color: #475569;
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 3px;
border-radius: 3px;
}
}
}
.tuning-sliders {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px var(--col-gap);
}
.tuning-slider {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
/* Hover card clamp range, relative to this slider's track. The left
column may spill right across the gap; the right column may spill
left. Overridden below when the columns stack. */
--tip-min: 0px;
--tip-max: calc(100% - var(--tip-w));
&.verify {
--tip-max: calc(200% + var(--col-gap) - var(--tip-w));
}
&.explore {
--accent: #0ea5e9;
--tip-min: calc(-100% - var(--col-gap));
}
.slider-head {
display: flex;
align-items: baseline;
gap: 8px;
font-size: 13px;
line-height: 18px;
color: #0f172a;
.slider-name {
font-weight: 400;
color: #334155;
}
.slider-value {
font-weight: 600;
}
}
.slider-ends {
display: flex;
justify-content: space-between;
font-size: 11.5px;
line-height: 14px;
color: #94a3b8;
}
.slider-track-wrap {
position: relative;
height: 26px;
display: flex;
align-items: center;
}
.slider-rail {
position: relative;
width: 100%;
height: 6px;
border-radius: 3px;
background: #e2e8f0;
}
.slider-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
width: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
border-radius: 3px;
background: color-mix(in srgb, var(--accent) 24%, #e2e8f0);
transition: width 0.15s ease;
}
.slider-notch {
position: absolute;
top: 50%;
left: calc(var(--n) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: 4px;
height: 4px;
border-radius: 50%;
background: #94a3b8;
transform: translate(-50%, -50%);
transition: background 0.15s ease;
z-index: 1;
&.reached {
background: rgba(255, 255, 255, 0.95);
}
}
.slider-thumb {
position: absolute;
top: 50%;
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: var(--thumb-w);
height: var(--thumb-h);
border-radius: 5px;
background: #ffffff;
border: 1px solid rgba(15, 23, 42, 0.1);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.12), 0 2px 6px rgba(15, 23, 42, 0.08);
transform: translate(-50%, -50%);
pointer-events: none;
transition: left 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
/* End of track: a pixel front sweeps from the thumb to the left end,
a solid fill follows it until the whole rail has changed colour,
and the thumb emits one ripple. */
.slider-solid {
position: absolute;
inset: 0;
border-radius: 3px;
background: var(--accent);
transform-origin: right center;
transform: scaleX(1);
animation: solidSweep 1s cubic-bezier(0.4, 0, 0.2, 1) 0.2s both;
pointer-events: none;
}
.slider-dither {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: 3px;
pointer-events: none;
rect {
fill: var(--accent);
animation: ditherIn 0.3s ease-out both;
}
}
.thumb-ripple {
position: absolute;
top: 50%;
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: var(--thumb-w);
height: var(--thumb-w);
border-radius: 50%;
border: 1.5px solid var(--accent);
transform: translate(-50%, -50%) scale(0.6);
opacity: 0;
pointer-events: none;
animation: thumbRipple 0.6s ease-out forwards;
}
&.dragging .slider-thumb {
border-color: color-mix(in srgb, var(--accent) 45%, transparent);
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.12),
0 2px 8px rgba(15, 23, 42, 0.1),
0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* Native range input does drag, keyboard, and a11y; it is invisible
but its thumb geometry matches the drawn thumb so hits line up. */
.slider-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
-webkit-appearance: none;
appearance: none;
background: transparent;
&::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: var(--thumb-w);
height: 26px;
}
&::-moz-range-thumb {
width: var(--thumb-w);
height: 26px;
border: 0;
}
}
.slider-input:focus-visible ~ .slider-thumb,
&:has(.slider-input:focus-visible) .slider-thumb {
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.12),
0 0 0 2px #ffffff,
0 0 0 4px var(--accent);
}
.tuning-tip-arrow {
position: absolute;
bottom: calc(100% + 2px);
left: calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2);
width: 10px;
height: 10px;
background: #ffffff;
border-right: 1px solid rgba(226, 232, 240, 1);
border-bottom: 1px solid rgba(226, 232, 240, 1);
transform: translate(-50%, 50%) rotate(45deg);
z-index: 41;
pointer-events: none;
}
.tuning-tip {
position: absolute;
bottom: calc(100% + 7px);
left: clamp(
var(--tip-min),
calc(var(--pct) * (100% - var(--thumb-w)) + var(--thumb-w) / 2 - var(--tip-w) / 2),
var(--tip-max)
);
width: var(--tip-w);
max-width: calc(100vw - 32px);
padding: 12px 14px;
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 1);
border-radius: 12px;
box-shadow: 0 12px 32px -8px rgba(15, 23, 42, 0.18), 0 2px 6px rgba(15, 23, 42, 0.06);
z-index: 40;
pointer-events: none;
animation: tipPop 0.16s ease-out;
font-size: 12px;
line-height: 1.5;
color: #334155;
display: flex;
flex-direction: column;
gap: 8px;
.tip-heading {
display: flex;
flex-direction: column;
gap: 1px;
.tip-title {
font-size: 13.5px;
font-weight: 600;
color: #0f172a;
letter-spacing: -0.01em;
}
.tip-latency {
font-size: 11.5px;
color: #94a3b8;
}
}
.tip-tagline {
margin: 0;
color: #0f172a;
}
.tip-section {
display: flex;
flex-direction: column;
gap: 2px;
ul {
margin: 0;
padding-left: 16px;
display: flex;
flex-direction: column;
gap: 2px;
}
li::marker {
color: #cbd5e1;
}
&.skips li {
color: #64748b;
}
}
.tip-section-title {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #94a3b8;
}
.tip-best {
display: flex;
flex-direction: column;
gap: 2px;
color: #475569;
}
}
}
@media (max-width: 760px) {
.tuning-sliders {
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
.tuning-slider {
&.verify,
&.explore {
--tip-min: 0px;
--tip-max: calc(100% - var(--tip-w));
}
}
}
}
/* Smart Suggestions Hub (Device-Aware, Flash vs Pro Tailored) */
.smart-suggestions-container {
display: flex;
@@ -4733,7 +5108,47 @@ $text-light: #94a3b8;
@media (prefers-reduced-motion: reduce) {
.home-container .aurora-background .glow,
.sparkle-icon,
.status-dot {
.status-dot,
.slider-dither rect,
.slider-solid,
.thumb-ripple {
animation: none !important;
}
}
@keyframes ditherIn {
from {
opacity: 0;
}
}
@keyframes solidSweep {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes thumbRipple {
0% {
opacity: 0.7;
transform: translate(-50%, -50%) scale(0.6);
}
100% {
opacity: 0;
transform: translate(-50%, -50%) scale(2.6);
}
}
@keyframes tipPop {
from {
opacity: 0;
transform: translateY(6px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -15,7 +15,6 @@
*/
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AgentService } from '../../services/agent.service';
@@ -32,6 +31,69 @@ import {
SuggestionCategory
} from '../../core/data/smart-tasks.data';
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
import {
DEFAULT_EXPLORER_MODE,
DEFAULT_VERIFICATION_LEVEL,
EXPLORER_MODES,
ExplorerModeId,
TuningLevel,
VERIFICATION_LEVELS,
VerificationLevelId,
levelIndex,
notchPercent
} from '../../core/models/pro-tuning.model';
export type TuningKind = 'verify' | 'explore';
/** One 2x2 px square of the "maxed out" dither texture drawn over a slider rail. */
export interface DitherPixel {
/** Horizontal position as a percentage of the rail width. */
x: number;
/** Row offset in px (rail is 6 px tall, three 2 px rows). */
y: number;
/** Resting opacity; squares near the thumb are stronger. */
opacity: number;
/** Animation delay in ms so the texture spreads leftwards from the thumb. */
delay: number;
}
/**
* Seeded pixels keep the slider texture stable across renders.
* Delays increase with distance from the thumb to animate from right to left.
*/
function buildDitherPixels(count = 260, seed = 7): DitherPixel[] {
let state = seed >>> 0;
const rand = (): number => {
state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
return state / 0x100000000;
};
const pixels: DitherPixel[] = [];
for (let i = 0; i < count; i++) {
const x = rand() * 100;
const y = Math.floor(rand() * 3) * 2;
pixels.push({
x: Math.round(x * 10) / 10,
y,
opacity: Math.round((0.35 + 0.4 * rand()) * 100) / 100,
// 8 ms per percent: the front takes ~0.8 s to reach the left end.
delay: Math.round((100 - x) * 8)
});
}
return pixels;
}
/** Shared view model for the verification and screen-reading sliders. */
export interface TuningSliderVm {
kind: TuningKind;
name: string;
/** One-word meaning of each end of the track, e.g. ["Off", "Strict"]. */
ends: readonly [string, string];
ladder: readonly TuningLevel[];
index: number;
level: TuningLevel;
/** 0..1 position of the thumb along the track. */
fraction: number;
}
export type { AppReference, SmartSuggestion, SuggestionCategory };
@@ -139,6 +201,118 @@ export class HomeComponent implements OnInit, OnDestroy {
public enableOutputter = signal<boolean>(true);
public showOutputterDrawer = signal<boolean>(false);
// Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES.
public readonly verificationLevels = VERIFICATION_LEVELS;
public readonly explorerModes = EXPLORER_MODES;
public verificationIndex = signal<number>(
levelIndex(VERIFICATION_LEVELS, DEFAULT_VERIFICATION_LEVEL, DEFAULT_VERIFICATION_LEVEL)
);
public explorerIndex = signal<number>(
levelIndex(EXPLORER_MODES, DEFAULT_EXPLORER_MODE, DEFAULT_EXPLORER_MODE)
);
/** Effective defaults from the backend config (`GET /api/run/defaults`). */
private tuningDefaults = signal<{ verification: VerificationLevelId; explorer: ExplorerModeId }>({
verification: DEFAULT_VERIFICATION_LEVEL,
explorer: DEFAULT_EXPLORER_MODE
});
/** True once the user moved a slider; backend defaults then stop overriding it. */
private tuningTouched = signal<boolean>(false);
/** Which slider's hover card is open (while hovering, dragging, or focused). */
public activeTuningTip = signal<TuningKind | null>(null);
/** Pixel cloud drawn over a rail once its slider reaches the last notch. */
public readonly ditherPixels: readonly DitherPixel[] = buildDitherPixels();
public verificationLevel = computed<TuningLevel<VerificationLevelId>>(
() => VERIFICATION_LEVELS[this.verificationIndex()]
);
public explorerMode = computed<TuningLevel<ExplorerModeId>>(
() => EXPLORER_MODES[this.explorerIndex()]
);
public isTuningDefault = computed<boolean>(() => {
const d = this.tuningDefaults();
return this.verificationLevel().id === d.verification && this.explorerMode().id === d.explorer;
});
public tuningSliders = computed<TuningSliderVm[]>(() => {
const vi = this.verificationIndex();
const ei = this.explorerIndex();
return [
{
kind: 'verify',
name: 'Result check',
ends: ['Off', 'Strict'],
ladder: VERIFICATION_LEVELS,
index: vi,
level: VERIFICATION_LEVELS[vi],
fraction: notchPercent(vi, VERIFICATION_LEVELS.length) / 100
},
{
kind: 'explore',
name: 'Screen reading',
ends: ['Faster', 'Sharper'],
ladder: EXPLORER_MODES,
index: ei,
level: EXPLORER_MODES[ei],
fraction: notchPercent(ei, EXPLORER_MODES.length) / 100
}
];
});
public notchFraction(index: number, count: number): number {
return notchPercent(index, count) / 100;
}
public setTuningIndex(kind: TuningKind, raw: number | string): void {
const idx = Math.round(Number(raw));
if (!Number.isFinite(idx)) return;
const ladder = kind === 'verify' ? VERIFICATION_LEVELS : EXPLORER_MODES;
const clamped = Math.min(Math.max(idx, 0), ladder.length - 1);
this.tuningTouched.set(true);
if (kind === 'verify') {
this.verificationIndex.set(clamped);
} else {
this.explorerIndex.set(clamped);
}
// Keyboard nudges and drags should keep the explanation visible.
this.activeTuningTip.set(kind);
}
public showTuningTip(kind: TuningKind): void {
this.activeTuningTip.set(kind);
}
public hideTuningTip(kind: TuningKind): void {
if (this.activeTuningTip() === kind) {
this.activeTuningTip.set(null);
}
}
public resetTuning(): void {
const d = this.tuningDefaults();
this.verificationIndex.set(levelIndex(VERIFICATION_LEVELS, d.verification, DEFAULT_VERIFICATION_LEVEL));
this.explorerIndex.set(levelIndex(EXPLORER_MODES, d.explorer, DEFAULT_EXPLORER_MODE));
this.tuningTouched.set(false);
}
/** Pull the effective config defaults so the sliders start where artemis.jsonc is. */
private loadProTuningDefaults(): void {
this.agentService.getProTuningDefaults().subscribe({
next: (res) => {
const vIdx = levelIndex(VERIFICATION_LEVELS, res?.verification_level, DEFAULT_VERIFICATION_LEVEL);
const eIdx = levelIndex(EXPLORER_MODES, res?.explorer_mode, DEFAULT_EXPLORER_MODE);
this.tuningDefaults.set({
verification: VERIFICATION_LEVELS[vIdx].id,
explorer: EXPLORER_MODES[eIdx].id
});
if (!this.tuningTouched()) {
this.verificationIndex.set(vIdx);
this.explorerIndex.set(eIdx);
}
},
// Defaults are a convenience; the built-in ladder defaults already apply.
error: () => undefined
});
}
public toggleOutputterDrawer(): void {
this.showOutputterDrawer.update((v) => !v);
}
@@ -362,6 +536,8 @@ export class HomeComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.loadProTuningDefaults();
// Initial fetch of system readiness & model configuration
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
@@ -913,7 +1089,10 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
? this.expectedOutput().trim()
: undefined,
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined,
this.selectedProfile() === 'pro'
? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id }
: undefined
)
.subscribe({
next: () => {
@@ -105,8 +105,8 @@ limitations under the License.
rows="1"
[disabled]="isSubmitting()"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks'
: 'Flash: fast single-look loop for everyday app tasks'"
class="dock-textarea"
spellcheck="false"
></textarea>
@@ -155,13 +155,13 @@ limitations under the License.
<div class="model-profile-toggle" role="group" aria-label="Select Agent Architecture">
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
title="Flash: Reactive fast-loop architecture for standard app workflows (3–5s/step)">
title="Flash: fast single-look loop for everyday app tasks (3–5s/step)">
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
<span class="pill-label">Flash</span>
</button>
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
title="Pro: Multi-agent cognitive graph architecture with planner & closed-loop verification for complex tasks">
title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)">
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
<span class="pill-label">Pro</span>
</button>
@@ -19,6 +19,7 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { Session, ModelInfo, TaskQueueItem, AgentStatusResponse } from '../core/models/session.model';
import { ProTuningDefaults, ProTuningOptions } from '../core/models/pro-tuning.model';
import { StepItemData, StepReplayFrame } from '../core/models/stream.model';
import { extractStepReplayFrames } from '../utils/action-formatter.util';
export type { Session, ModelInfo, TaskQueueItem, AgentStatusResponse, StepItemData, StepReplayFrame };
@@ -347,7 +348,7 @@ export class AgentService {
// Streaming chunks are coalesced into one signal update per short window so a
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; streamType: string; chunk: string }>();
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; parentTraceId?: any; streamType: string; chunk: string }>();
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
private lastQueueSignature: string | null = null;
private lastActiveTasksSignature: string | null = null;
@@ -378,6 +379,14 @@ export class AgentService {
return this.sessions().some(s => s.status === 'running' || s.status === 'paused');
});
/**
* Effective Pro tuning defaults (verification level, explorer mode) from the
* backend agent config, so the launcher sliders start where the config is.
*/
public getProTuningDefaults(): Observable<ProTuningDefaults> {
return this.http.get<ProTuningDefaults>('/api/run/defaults');
}
/**
* Run a new task by submitting to backend queue
*/
@@ -385,7 +394,8 @@ export class AgentService {
goal: string,
profile: string = 'flash',
expectedOutput?: string,
enableOutputter?: boolean
enableOutputter?: boolean,
proTuning?: ProTuningOptions
): Observable<any> {
return new Observable((obs) => {
const submittedEvent: StartupProgressEvent = {
@@ -401,6 +411,12 @@ export class AgentService {
if (enableOutputter !== undefined) {
payload.enable_outputter = enableOutputter;
}
if (proTuning?.verificationLevel) {
payload.verification_level = proTuning.verificationLevel;
}
if (proTuning?.explorerMode) {
payload.explorer_mode = proTuning.explorerMode;
}
this.clearUserPinnedSession();
this.http.post<any>('/api/run', payload).subscribe({
next: (res) => {
@@ -702,6 +718,7 @@ export class AgentService {
this.isPaused.set(false);
this.pausedError.set(null);
this.fetchNotes(sessionId);
this.fetchChecks(sessionId);
this.updateModelForCurrentView();
// If video window is currently open, dynamically sync/refresh video for new session
@@ -709,7 +726,7 @@ export class AgentService {
this.openVideoPlayer(sessionId);
}
console.log(`Selecting session: ${sessionId}`);
console.debug(`Selecting session: ${sessionId}`);
// Start reading the persisted snapshot immediately
this.backfillSessionSteps(sessionId, loadGeneration);
this.ensureLiveStream();
@@ -724,7 +741,7 @@ export class AgentService {
return;
}
console.log('Establishing persistent unified live stream via /api/stream');
console.debug('Establishing persistent unified live stream via /api/stream');
// The stream is registered outside the Angular zone: high-frequency SSE
// callbacks must not schedule a change-detection pass each. Signal writes
// still notify the render scheduler, so the UI stays live.
@@ -756,7 +773,8 @@ export class AgentService {
'session_started',
'session_ended',
'recording_ready',
'recording_failed'
'recording_failed',
'checker_event'
];
eventTypes.forEach((eventType) => {
@@ -843,6 +861,11 @@ export class AgentService {
this.fetchStatus();
if (endedId) {
this.fetchNotes(endedId);
if (String(endedId) === String(this.currentSessionId() || '')) {
// The exit final review / run outcome may have landed while the
// stream was reconnecting: reconcile from the persisted ledger.
this.fetchChecks(endedId);
}
if (
this.isVideoWindowOpen()
&& this.activeVideoSessionId === endedId
@@ -1010,14 +1033,19 @@ export class AgentService {
private queueStreamChunk(parsedData: any): void {
const execId = parsedData.execution_id;
const stepId = parsedData.step_id;
// The emitting agent's trace: lets the aggregator route a concurrent
// agent's stream (e.g. the Checker) to its own block instead of the
// Operator step that happens to be current.
const parentTraceId = parsedData.parent_trace_id || null;
const streamType = parsedData.stream_type || 'text';
const key = `${execId}|${streamType}`;
const pending = this.pendingStreamChunks.get(key);
if (pending) {
pending.chunk += parsedData.chunk;
if (stepId) pending.stepId = stepId;
if (parentTraceId) pending.parentTraceId = parentTraceId;
} else {
this.pendingStreamChunks.set(key, { execId, stepId, streamType, chunk: parsedData.chunk });
this.pendingStreamChunks.set(key, { execId, stepId, parentTraceId, streamType, chunk: parsedData.chunk });
}
if (!this.streamFlushTimer) {
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
@@ -1058,14 +1086,18 @@ export class AgentService {
data: {
...existingLog.data,
text: existingLog.data.text + batch.chunk,
step_id: batch.stepId || existingLog.data.step_id
step_id: batch.stepId || existingLog.data.step_id,
parent_trace_id: batch.parentTraceId || existingLog.data.parent_trace_id || null
}
};
} else {
// A new execution closes the previous open stream of the same agent
// lane only; concurrent agents (Operator vs. Checker) keep theirs.
next = next.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === batch.streamType &&
!l.data.isCompleted) {
!l.data.isCompleted &&
(l.data.parent_trace_id || null) === (batch.parentTraceId || null)) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
@@ -1076,6 +1108,7 @@ export class AgentService {
data: {
execution_id: batch.execId,
step_id: batch.stepId,
parent_trace_id: batch.parentTraceId || null,
text: batch.chunk,
stream_type: batch.streamType,
isCompleted: false
@@ -1511,6 +1544,105 @@ export class AgentService {
/**
* Fetch notes for a specific session
*/
/**
* Backfill the Checker's attempts and run outcome from the persisted verdict
* ledger (`/api/sessions/{id}/checks`) as synthetic `checker_event` logs, so
* historical sessions show the same timeline blocks as live ones. Live
* events for attempts already known are merged by attempt id in the
* aggregator, so re-fetching is idempotent.
*/
public fetchChecks(sessionId: string): void {
if (!sessionId) return;
this.http.get<{ records?: any[]; run_outcome?: any }>(`/api/sessions/${sessionId}/checks`).subscribe({
next: (res) => {
if (this.currentSessionId() !== sessionId) return;
const snapshot = this.buildCheckerSnapshotLogs(sessionId, res?.records || [], res?.run_outcome || null);
this.sessionLogs.update((logs) => [
...logs.filter((log) => !log.checks_snapshot),
...snapshot
]);
},
error: (err) => {
console.error(`Failed to fetch checks for session ${sessionId}:`, err);
}
});
}
private buildCheckerSnapshotLogs(sessionId: string, records: any[], runOutcome: any): any[] {
const byAttempt = new Map<string, any>();
for (const rec of records) {
if (!rec || !rec.attempt_id) continue;
const attemptId = String(rec.attempt_id);
const checkpointId = String(rec.checkpoint_id || '');
let attempt = byAttempt.get(attemptId);
if (!attempt) {
attempt = {
event: 'attempt_finished',
phase: checkpointId === 'final' ? 'final' : 'checkpoint',
attempt_id: attemptId,
checkpoint_id: checkpointId,
subgoal_text: String(rec.subgoal_text || (checkpointId === 'final'
? "Final review against the user's original goal"
: `Subgoal ${checkpointId.slice(0, 8)}`)),
anchor_step_id: rec.anchor_step_id ?? null,
trace_id: rec.trace_id ?? null,
status: 'done',
verdicts: [],
findings: [],
ts: typeof rec.ts === 'number' ? rec.ts : undefined,
session_id: sessionId
};
byAttempt.set(attemptId, attempt);
}
if (typeof rec.ts === 'number') {
attempt.ts = attempt.ts === undefined ? rec.ts : Math.min(attempt.ts, rec.ts);
}
if (!attempt.trace_id && rec.trace_id) {
attempt.trace_id = rec.trace_id;
}
attempt.verdicts.push({
item_text: String(rec.item_text || ''),
kind: String(rec.kind || ''),
status: String(rec.status || ''),
evidence: String(rec.evidence || ''),
suggestion: rec.suggestion ? String(rec.suggestion) : '',
when: rec.when ? String(rec.when) : undefined
});
}
const logs: any[] = [];
for (const attempt of byAttempt.values()) {
const statuses = new Set(attempt.verdicts.map((v: any) => v.status));
if (statuses.size === 1) {
const only = [...statuses][0];
if (only === 'superseded' || only === 'unchecked') attempt.status = only;
}
const ts = attempt.ts ?? Date.now() / 1000;
attempt.timestamp = ts;
logs.push({
type: 'checker_event',
session_id: sessionId,
timestamp: new Date(ts * 1000).toISOString(),
checks_snapshot: true,
data: attempt
});
}
if (runOutcome && typeof runOutcome === 'object') {
const lastTs = logs.length > 0
? Math.max(...logs.map((l) => new Date(l.timestamp).getTime() / 1000))
: Date.now() / 1000;
const ts = lastTs + 0.001;
logs.push({
type: 'checker_event',
session_id: sessionId,
timestamp: new Date(ts * 1000).toISOString(),
checks_snapshot: true,
data: { event: 'run_outcome', phase: 'outcome', ...runOutcome, ts, timestamp: ts, session_id: sessionId }
});
}
return logs;
}
public fetchNotes(sessionId: string): void {
if (!sessionId) {
this.currentNotes.set({});
@@ -91,3 +91,261 @@ describe('stream aggregator timeline ordering', () => {
.toEqual(['text', 'tool']);
});
});
describe('stream aggregator checker lane', () => {
const started = {
type: 'checker_event',
timestamp: '2026-08-25T20:00:05.000Z',
data: {
event: 'attempt_started',
phase: 'checkpoint',
attempt_id: 'abc#1',
checkpoint_id: 'abc',
subgoal_text: 'Create the alarm',
trace_id: 'trace-checker-1',
items: [{ kind: 'verify', text: 'alarm exists' }],
ts: 1756152005
}
};
it('creates a running checker block from attempt_started and finishes it by attempt id', () => {
const blocks = consolidateLogsToBlocks([
started,
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
data: {
event: 'attempt_finished',
attempt_id: 'abc#1',
status: 'done',
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
findings: [],
ts: 1756152009
}
}
]);
expect(blocks.length).toBe(1);
expect(blocks[0].type).toBe('checker');
expect(blocks[0].id).toBe('checker-abc#1');
expect(blocks[0].data.isCompleted).toBeTrue();
expect(blocks[0].data.status).toBe('done');
expect(blocks[0].data.trace_id).toBe('trace-checker-1');
expect(blocks[0].data.verdicts.length).toBe(1);
expect(blocks[0].data.duration).toBe(4);
});
it("routes the checker's stream and tool traces by parent trace id, not by the current step", () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_recorded',
timestamp: '2026-08-25T20:00:01.000Z',
data: { step_id: 'step-1', step_number: 1, timestamp: 1756152001 }
},
started,
{
type: 'llm_stream',
timestamp: '2026-08-25T20:00:06.000Z',
data: {
execution_id: 'exec-checker',
step_id: 'step-1',
parent_trace_id: 'trace-checker-1',
stream_type: 'thinking',
text: 'Inspecting the alarm list',
isCompleted: false
}
},
{
type: 'trace_recorded',
timestamp: '2026-08-25T20:00:07.000Z',
data: {
trace_id: 'tool-probe',
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'probe_device',
step_id: 'step-1',
status: 'success'
}
}
]);
const step = blocks.find((b) => b.id === 'step-step-1')!;
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(step.data.operator_native_thinking).toBeUndefined();
expect(step.data.generic_tools || []).toEqual([]);
expect(checker.data.operator_native_thinking).toBe('Inspecting the alarm list');
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
expect(checker.data.step_id).toBeUndefined();
expect(blocks.map((b) => b.id)).toEqual(['step-step-1', 'checker-abc#1']);
});
it('keeps one stream segment per checker turn so text and tool calls interleave by time', () => {
const turn = (execId: string, timestamp: string, text: string) => ({
type: 'llm_stream',
timestamp,
data: {
execution_id: execId,
parent_trace_id: 'trace-checker-1',
stream_type: 'text',
text,
isCompleted: false
}
});
const blocks = consolidateLogsToBlocks([
started,
turn('exec-1', '2026-08-25T20:00:06.000Z', 'Let me look at step 2.'),
{
type: 'trace_recorded',
timestamp: '2026-08-25T20:00:07.000Z',
data: {
trace_id: 'tool-detail',
parent_trace_id: 'trace-checker-1',
agent_name: 'checker',
type: 'tool',
name: 'get_step_detail',
status: 'success',
timestamp: '2026-08-25T20:00:07.000Z'
}
},
turn('exec-2', '2026-08-25T20:00:08.000Z', 'The alarm is there.'),
turn('exec-1', '2026-08-25T20:00:08.500Z', 'Let me look at step 2. Done.')
]);
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(checker.data.stream_segments.map((s: any) => s.execution_id)).toEqual(['exec-1', 'exec-2']);
// A later chunk of an earlier turn keeps that turn's first-seen time.
expect(checker.data.stream_segments[0].text).toBe('Let me look at step 2. Done.');
expect(checker.data.stream_segments[0].timestamp).toBe('2026-08-25T20:00:06.000Z');
const events = getSortedStepEvents(checker.data);
expect(events.map((e) => e.type)).toEqual(['text', 'tool', 'text']);
expect(events[0].data.text).toBe('Let me look at step 2. Done.');
expect(events[2].data.text).toBe('The alarm is there.');
});
it('builds the run outcome block once and keeps it idempotent', () => {
const outcome = {
type: 'checker_event',
timestamp: '2026-08-25T20:00:20.000Z',
data: { event: 'run_outcome', task_status: 'completed', tests: { passed: 1, failed: 0, inconclusive: 0, unchecked: 0 } }
};
const blocks = consolidateLogsToBlocks([outcome, outcome]);
expect(blocks.length).toBe(1);
expect(blocks[0].id).toBe('checker-outcome');
expect(blocks[0].data.phase).toBe('outcome');
expect(blocks[0].data.task_status).toBe('completed');
});
});
describe('stream aggregator step ownership', () => {
const plannerStream = {
type: 'llm_stream',
timestamp: '2026-09-02T03:57:25.000Z',
data: { execution_id: 'exec-planner', stream_type: 'text', text: 'I have formulated the task plan.', isCompleted: true }
};
const tap = { trace_id: 'trace-tap-3', type: 'action', name: 'tap', timestamp: 1788374289.37, payload: { args: { action: 'tap' } } };
it('does not fold a step without streamed text into an earlier untagged stream block', () => {
const blocks = consolidateLogsToBlocks([
plannerStream,
{
type: 'step_recorded',
timestamp: '2026-09-02T03:57:47.000Z',
data: { step_id: 's1', step_number: 1, operator_raw_thinking: 'Launching Maps', generic_tools: [] }
},
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:09.000Z',
data: { step_id: 's3', step_number: 3, generic_tools: [tap] }
}
]);
expect(blocks.map((b) => b.id)).toEqual(['stream-exec-planner', 'step-s1', 'step-s3']);
expect(blocks[0].type).toBe('llm_stream');
expect(blocks[0].data.generic_tools).toEqual([]);
expect(blocks[0].data.operator_raw_thinking).toBe('I have formulated the task plan.');
expect(blocks[2].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-tap-3']);
});
it('creates the step block for a tagged trace that arrives before its step is recorded', () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:03.000Z',
data: { step_id: 's2', step_number: 2, operator_raw_thinking: 'Dismissing the dialog', generic_tools: [] }
},
{
type: 'trace_recorded',
timestamp: '2026-09-02T03:58:09.300Z',
data: { ...tap, step_id: 's3', status: 'running' }
},
{
type: 'step_recorded',
timestamp: '2026-09-02T03:58:09.400Z',
data: { step_id: 's3', step_number: 3, generic_tools: [{ ...tap, status: 'success' }] }
}
]);
expect(blocks.map((b) => b.id)).toEqual(['step-s2', 'step-s3']);
expect(blocks[0].data.generic_tools).toEqual([]);
expect(blocks[1].data.generic_tools.length).toBe(1);
expect(blocks[1].data.generic_tools[0].status).toBe('success');
});
it('still attaches untagged traces to the latest preceding block', () => {
const blocks = consolidateLogsToBlocks([
plannerStream,
{
type: 'trace_recorded',
timestamp: '2026-09-02T03:57:26.000Z',
data: { trace_id: 'trace-note', type: 'tool', name: 'save_note', payload: { args: {} } }
}
]);
expect(blocks.length).toBe(1);
expect(blocks[0].data.generic_tools.map((t: any) => t.trace_id)).toEqual(['trace-note']);
});
});
describe('stream aggregator checker history relocation', () => {
it('moves persisted checker tool traces from the operator step to the attempt block', () => {
const blocks = consolidateLogsToBlocks([
{
type: 'step_updated',
timestamp: '2026-08-25T20:00:01.000Z',
history_snapshot: true,
data: {
step_id: 'step-1',
step_number: 1,
timestamp: 1756152001,
generic_tools: [
{ trace_id: 'trace-checker-1', type: 'agent', name: 'checker', status: 'success' },
{ trace_id: 'tool-probe', parent_trace_id: 'trace-checker-1', type: 'tool', name: 'probe_device', status: 'success' },
{ trace_id: 'tool-note', parent_trace_id: 'operator-trace', type: 'tool', name: 'save_note', status: 'success' }
]
}
},
{
type: 'checker_event',
timestamp: '2026-08-25T20:00:09.000Z',
checks_snapshot: true,
data: {
event: 'attempt_finished',
phase: 'checkpoint',
attempt_id: 'abc#1',
checkpoint_id: 'abc',
subgoal_text: 'Create the alarm',
trace_id: 'trace-checker-1',
status: 'done',
verdicts: [{ item_text: 'alarm exists', kind: 'verify', status: 'passed', evidence: 'seen' }],
ts: 1756152009
}
}
]);
const step = blocks.find((b) => b.id === 'step-step-1')!;
const checker = blocks.find((b) => b.id === 'checker-abc#1')!;
expect(step.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-note']);
expect(checker.data.generic_tools.map((t: any) => t.trace_id)).toEqual(['tool-probe']);
});
});
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { StepBlock, PhaseBlock, StepEvent } from '../core/models/stream.model';
import { StepBlock, PhaseBlock, StepEvent, StreamSegment } from '../core/models/stream.model';
import { isAndroidAction, isReportStatusAction, getReportStatusExplanation, getReportStatusValue } from './action-formatter.util';
import { getUniqueGenericTools } from './tool-formatter.util';
@@ -32,6 +32,145 @@ export function getItemTimestamp(ts: any, fallback: number = 0): number {
return fallback;
}
/**
* Index of the `checker` block that owns a trace id (the Checker's own agent
* trace). The Checker runs concurrently with Operator steps, so its streamed
* reasoning and tool traces are routed by `parent_trace_id`, never by the
* step that happens to be current.
*/
function findCheckerBlockByTrace(blocks: StepBlock[], parentTraceId: any): number {
if (!parentTraceId) return -1;
const key = String(parentTraceId);
return blocks.findIndex(b => b.type === 'checker' && b.data?.trace_id && String(b.data.trace_id) === key);
}
/** Latest still-running checker block (fallback when only the agent name is known). */
function findRunningCheckerBlock(blocks: StepBlock[]): number {
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === 'checker' && blocks[i].data?.isCompleted === false) return i;
}
return -1;
}
/**
* Apply one `checker_event` (attempt_started / attempt_finished / run_outcome)
* to the block list. Attempts are keyed by attempt id so live events and the
* ledger backfill merge instead of duplicating.
*/
function applyCheckerLog(blocks: StepBlock[], log: any): void {
const d = log.data || {};
if (!d.event) return;
if (d.event === 'run_outcome') {
const id = 'checker-outcome';
const idx = blocks.findIndex(b => b.id === id);
const data = {
...(idx > -1 ? blocks[idx].data : {}),
...d,
phase: 'outcome',
status: 'done',
isCompleted: true,
generic_tools: []
};
const block: StepBlock = { id, type: 'checker', timestamp: log.timestamp, data };
if (idx > -1) blocks[idx] = block; else blocks.push(block);
return;
}
if (!d.attempt_id) return;
const id = `checker-${d.attempt_id}`;
const idx = blocks.findIndex(b => b.id === id);
const existing = idx > -1 ? blocks[idx] : null;
const ts = typeof d.ts === 'number' ? d.ts : (log.timestamp ? new Date(log.timestamp).getTime() / 1000 : undefined);
if (d.event === 'attempt_started') {
const data = {
...(existing?.data || {}),
...d,
// A late "started" must not resurrect a finished attempt.
status: existing?.data?.isCompleted ? existing.data.status : 'running',
isCompleted: existing?.data?.isCompleted ?? false,
started_at: existing?.data?.started_at ?? ts,
verdicts: existing?.data?.verdicts || [],
findings: existing?.data?.findings || [],
generic_tools: existing?.data?.generic_tools || []
};
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
if (existing) blocks[idx] = block; else blocks.push(block);
return;
}
if (d.event === 'attempt_finished') {
const startedAt = existing?.data?.started_at ?? ts;
const verdicts = Array.isArray(d.verdicts) ? d.verdicts : (existing?.data?.verdicts || []);
const items = existing?.data?.items?.length
? existing.data.items
: verdicts.map((v: any) => ({ kind: v.kind, text: v.item_text, when: v.when }));
const data = {
...(existing?.data || {}),
...d,
status: d.status || 'done',
verdicts,
items,
findings: Array.isArray(d.findings) ? d.findings : (existing?.data?.findings || []),
generic_tools: existing?.data?.generic_tools || [],
started_at: startedAt,
finished_at: ts,
duration: startedAt !== undefined && ts !== undefined ? Math.max(0, ts - startedAt) : undefined,
isCompleted: true
};
const block: StepBlock = { id, type: 'checker', timestamp: existing?.timestamp || log.timestamp, data };
if (existing) blocks[idx] = block; else blocks.push(block);
}
}
/**
* Persisted steps list the Checker's tool traces (and its agent trace) under
* the Operator step that was current while it ran, because traces are stored
* by step id. Once the checker blocks exist (ledger backfill carries the
* attempt's trace id), move those traces to the attempt they belong to and
* drop the agent trace itself.
*/
function relocateCheckerTools(blocks: StepBlock[]): void {
const checkerByTrace = new Map<string, number>();
blocks.forEach((b, i) => {
if (b.type === 'checker' && b.data?.trace_id) {
checkerByTrace.set(String(b.data.trace_id), i);
}
});
if (checkerByTrace.size === 0) return;
blocks.forEach((block, i) => {
if (block.type === 'checker') return;
const tools: any[] = block.data?.generic_tools || [];
if (tools.length === 0) return;
const keep: any[] = [];
let moved = false;
for (const tool of tools) {
const ownTrace = tool?.trace_id ? String(tool.trace_id) : '';
const parent = tool?.parent_trace_id ? String(tool.parent_trace_id) : '';
if (ownTrace && checkerByTrace.has(ownTrace) && tool?.type === 'agent') {
moved = true; // the Checker's own agent trace: not a tool of the step
continue;
}
const target = parent ? checkerByTrace.get(parent) : undefined;
if (target === undefined) {
keep.push(tool);
continue;
}
const checker = blocks[target];
const existing: any[] = checker.data.generic_tools || [];
if (!existing.some((t) => t.trace_id && t.trace_id === tool.trace_id)) {
blocks[target] = { ...checker, data: { ...checker.data, generic_tools: [...existing, tool] } };
}
moved = true;
}
if (moved) {
blocks[i] = { ...block, data: { ...block.data, generic_tools: keep } };
}
});
}
/**
* Consolidate raw SSE logs into deduplicated and ordered StepBlocks
*/
@@ -43,6 +182,11 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
rawLogs.forEach(log => {
if (!log) return;
if (log.type === 'checker_event') {
applyCheckerLog(blocks, log);
return;
}
if (log.type === 'llm_stream') {
const execId = log.data?.execution_id || log.id;
const stepId = log.data?.step_id;
@@ -50,6 +194,52 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
const text = log.data?.text || '';
const isCompleted = log.data?.isCompleted ?? false;
// The Checker's own reasoning streams under its agent trace: route it to
// the attempt block, never to the Operator step that is current.
const checkerIndex = findCheckerBlockByTrace(blocks, log.data?.parent_trace_id);
if (checkerIndex > -1) {
const checkerBlock = blocks[checkerIndex];
const checkerData: any = { ...checkerBlock.data };
// The Checker is a multi-turn tool loop: every LLM turn streams under
// its own execution id. Keep one segment per turn (with its first-seen
// time) so the timeline can interleave the text with the tool calls it
// triggered, instead of collapsing all turns into one early block.
const segments: StreamSegment[] = Array.isArray(checkerData.stream_segments)
? [...checkerData.stream_segments]
: [];
const segmentIndex = segments.findIndex(
(s) => s.execution_id === execId && s.stream_type === streamType
);
const segment: StreamSegment = {
execution_id: execId,
stream_type: streamType === 'thinking' ? 'thinking' : 'text',
text,
timestamp: segmentIndex > -1 ? segments[segmentIndex].timestamp : log.timestamp,
isCompleted
};
if (segmentIndex > -1) segments[segmentIndex] = segment; else segments.push(segment);
checkerData.stream_segments = segments;
// Flat fields stay as the joined text for consumers that only know steps.
const joined = (type: string) => segments
.filter((s) => s.stream_type === type && s.text.trim())
.map((s) => s.text)
.join('\n\n');
checkerData.operator_native_thinking = joined('thinking');
checkerData.operator_raw_thinking = joined('text');
if (streamType === 'thinking') {
checkerData.operator_native_thinking_timestamp =
checkerData.operator_native_thinking_timestamp || log.timestamp;
} else {
checkerData.operator_raw_thinking_timestamp =
checkerData.operator_raw_thinking_timestamp || log.timestamp;
}
if (execId && !checkerData.execution_id) {
checkerData.execution_id = execId;
}
blocks[checkerIndex] = { ...checkerBlock, data: checkerData };
return;
}
// Find existing block: match by stepId if available, or by execId, or active incomplete block
let existingIndex = -1;
if (stepId) {
@@ -111,15 +301,12 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
// Find if there is an existing block for this step or an unattached stream block from this turn
let existingIndex = blocks.findIndex(b => b.id === `step-${stepId}` || b.data?.step_id === stepId);
// If not found by step_id, check if there is an open stream block from the same execution turn
if (existingIndex === -1) {
for (let i = blocks.length - 1; i >= 0; i--) {
if (blocks[i].type === 'llm_stream' && (!blocks[i].data?.step_id || blocks[i].data?.step_id === stepId)) {
existingIndex = i;
break;
}
}
}
// No adoption of untagged stream blocks here: the Operator's streams
// always carry the step id (Perception / the Flash turn pre-allocate it
// before the LLM call), so a stream without one belongs to another agent
// (Planner, Checker...). A step whose Operator emitted only tool calls
// and no text used to steal the latest such block -- typically the
// Planner's -- and render its action inside it at the top of the timeline.
if (existingIndex > -1) {
const existingBlock = blocks[existingIndex];
@@ -185,8 +372,18 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
let stepId = log.data.step_id;
let existingIndex = -1;
// 0. Tools invoked by the Checker belong to its attempt block (routed by
// the parent trace, falling back to the running attempt by agent name).
let checkerIndex = findCheckerBlockByTrace(blocks, log.data.parent_trace_id);
if (checkerIndex === -1 && String(log.data.agent_name || '').toLowerCase() === 'checker') {
checkerIndex = findRunningCheckerBlock(blocks);
}
if (checkerIndex > -1) {
existingIndex = checkerIndex;
}
// 1. If this trace_id already exists in some block, update that block directly
if (log.data.trace_id) {
if (existingIndex === -1 && log.data.trace_id) {
existingIndex = blocks.findIndex(b =>
b.data?.generic_tools?.some((t: any) => t.trace_id === log.data.trace_id)
);
@@ -202,8 +399,13 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
existingIndex = blocks.findIndex(b => b.id === 'step-pre-planning' || b.data?.step_id === 'pre-planning');
}
// 4. Otherwise find by timestamp among existing step blocks
if (existingIndex === -1) {
// 4. Untagged traces only: attach to the latest block that precedes
// them. A trace that names a step whose block does not exist yet
// (tool call before any streamed text, or an action recorded before
// the step in Flash) creates that step's block below instead of being
// glued to an earlier step, where the later `step_recorded` (which
// carries the same trace) would leave a duplicate card.
if (existingIndex === -1 && !stepId) {
const logTime = log.timestamp ? new Date(log.timestamp).getTime() : 0;
let maxStepTime = -1;
for (let i = 0; i < blocks.length; i++) {
@@ -255,6 +457,8 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
}
});
relocateCheckerTools(blocks);
blocks.sort((a, b) => {
const timeA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
const timeB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
@@ -470,13 +674,17 @@ export function getSortedStepEvents(
const rawText = typeof stepData.operator_raw_thinking === 'string'
? stepData.operator_raw_thinking.trim()
: '';
const segments: StreamSegment[] | null = Array.isArray(stepData.stream_segments) && stepData.stream_segments.length > 0
? stepData.stream_segments
: null;
const signature = [
toolsLen,
actionTs ?? '',
stepData.operator_native_thinking_timestamp ?? '',
stepData.operator_raw_thinking_timestamp ?? '',
nativeText.length,
rawText.length
rawText.length,
segments ? segments.map((s) => `${s.execution_id}:${s.stream_type}:${s.text.length}`).join(',') : ''
].join('|');
if (cache) {
@@ -492,22 +700,36 @@ export function getSortedStepEvents(
// Text and tools share one timeline. Live streams carry their first-seen
// timestamps; persisted steps use the step timestamp as a stable fallback.
if (nativeText) {
events.push({
type: 'thinking',
data: { text: stepData.operator_native_thinking },
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
sequence: sequence++
if (segments) {
// Multi-turn agent (Checker): one event per streamed turn, each at the
// time its first chunk arrived, so turns sort between the tool calls.
segments.forEach((segment) => {
if (!segment.text || !segment.text.trim()) return;
events.push({
type: segment.stream_type === 'thinking' ? 'thinking' : 'text',
data: { text: segment.text, execution_id: segment.execution_id, segment: true },
timestamp: getItemTimestamp(segment.timestamp, fallbackTime),
sequence: sequence++
});
});
}
} else {
if (nativeText) {
events.push({
type: 'thinking',
data: { text: stepData.operator_native_thinking },
timestamp: getItemTimestamp(stepData.operator_native_thinking_timestamp, fallbackTime),
sequence: sequence++
});
}
if (rawText) {
events.push({
type: 'text',
data: { text: stepData.operator_raw_thinking },
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
sequence: sequence++
});
if (rawText) {
events.push({
type: 'text',
data: { text: stepData.operator_raw_thinking },
timestamp: getItemTimestamp(stepData.operator_raw_thinking_timestamp, fallbackTime),
sequence: sequence++
});
}
}
// Add action_taken if present and valid.
@@ -675,10 +675,20 @@ export function getToolDisplayLabel(tool: any, isFirstSaveNote: boolean = false)
const q = args.specific_query || args.query || '';
return q ? `Analyzing audio track: "${q}"` : 'Analyzing recording audio track';
}
case 'get_step_details':
return 'Retrieving step execution details';
case 'get_step_screenshot':
return 'Inspecting screenshot evidence';
case 'get_step_detail':
case 'get_step_details': {
const n = args.step_no ?? args.step_number ?? args.step;
return n !== undefined && n !== '' ? `Reviewing step ${n}` : 'Reviewing step details';
}
case 'get_step_screenshot': {
const n = args.step_no ?? args.step_number ?? args.step;
const which = String(args.which || '').toLowerCase() === 'post' ? 'after' : 'before';
return n !== undefined && n !== '' ? `Looking at the screen ${which} step ${n}` : 'Looking at a step screenshot';
}
case 'probe_device': {
const kind = args.kind ? String(args.kind).replace(/_/g, ' ') : '';
return kind ? `Reading ${kind} from the device` : 'Reading device state';
}
case 'search_history_for_text': {
const q = args.text || args.query || '';
return q ? `Searching execution trace for "${q}"` : 'Searching execution trace';
@@ -754,10 +764,13 @@ export function getToolIcon(tool: any): string {
return 'smart_toy';
case 'analyze_audio_only':
return 'graphic_eq';
case 'get_step_detail':
case 'get_step_details':
return 'manage_search';
case 'get_step_screenshot':
return 'image_search';
case 'probe_device':
return 'sensors';
case 'search_history_for_text':
return 'find_in_page';
case 'outputter':