Nightly Build / build (push) Successful in 7m33s
The two memory lints run weekly, which is right for maintenance and wrong for the moment somebody has just reorganised their notes and wants to know what the lint makes of them. Each agent's tab now carries a button that starts one pass immediately, for the caller. It runs as the caller — their pool, their sessions, their hub — so the report lands with the person who asked. The shared lint is the interesting case: its scheduled pass runs as the admin because the shared store belongs to nobody, but a member pressing the button reads the same store and gets the report themselves, which is coherent with shared memory being readable by every member anyway. Two settings are treated differently on purpose. Due-ness is skipped, exactly as manual /compact skips the compactor's token threshold: the interval answers *when*, and a human asking is a good enough answer to that. The Enabled switch is honoured: it answers *whether*, and that one is the admin's. The conversation review gets no button (AgentScope::PerSubject): it is about somebody else and picks its own subjects, so "run it for me" has no meaning. The frontend reads that from the agent's scope, not from a list of ids. A second starter breaks an invariant the scheduler used to hold for free. system_agent_runs::start sweeps any leftover `running` row of the same agent to `failed` before inserting, which was safe only because one sequential loop was the only thing that ever started a pass; a manual run overlapping a scheduled one would have marked a healthy run as interrupted and duplicated its work. So the agent list moves out of the scheduler and onto Skald as SystemAgents, which holds the registry plus an in-flight guard both paths claim through — keyed on what the pass is *about*, so an instance-wide agent is one slot no matter who runs it, and a per-subject review is keyed on the subject rather than on the supervisor lending the runtime. has_work is answered synchronously, before anything is spawned: it leaves no run row, so without that the button would say "started" over a log that never gains a row. Everything after it is spawned — a pass is an LLM turn, and no HTTP request should be held open for one. The run row exists before the browser is answered, so the log itself is the progress surface; the page polls it quietly until the pass leaves `running`.
418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
import { html, nothing } from 'lit';
|
|
import { LightElement } from '../lib/base.js';
|
|
import { t } from '../lib/i18n.js';
|
|
import { ConfigFormController, maybeT, propKeyId } from './shared/config-form.js';
|
|
|
|
const PAGE_ID = 'system-agents';
|
|
const PER_PAGE = 20;
|
|
|
|
/** The overview tab: every agent's runs, interleaved. */
|
|
const ALL_TAB = '__all__';
|
|
|
|
/**
|
|
* How a manually started pass is followed: the run row appears immediately (the
|
|
* server opens it before the work), so the log itself is the progress bar and
|
|
* nothing else has to be invented. Refreshing quietly — without the table's
|
|
* loading state — is what keeps that from flickering every few seconds.
|
|
*/
|
|
const POLL_MS = 4000;
|
|
const POLL_MAX_MIN = 15;
|
|
|
|
function formatDate(iso) {
|
|
if (!iso) return '—';
|
|
return new Date(iso).toLocaleString(undefined, {
|
|
day: '2-digit', month: '2-digit', year: '2-digit',
|
|
hour: '2-digit', minute: '2-digit',
|
|
});
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
if (ms == null) return '—';
|
|
if (ms < 1000) return `${ms} ms`;
|
|
const s = ms / 1000;
|
|
if (s < 60) return `${s.toFixed(1)} s`;
|
|
const m = Math.floor(s / 60);
|
|
return `${m}m ${Math.round(s % 60)}s`;
|
|
}
|
|
|
|
const STATUS_ICON = {
|
|
running: 'bi-arrow-repeat',
|
|
completed: 'bi-check-circle',
|
|
failed: 'bi-exclamation-circle',
|
|
cancelled: 'bi-slash-circle',
|
|
};
|
|
|
|
/**
|
|
* The background agents the instance runs, one tab per agent.
|
|
*
|
|
* **The tab is the agent, not the kind of information.** A tab holds an agent's
|
|
* settings *and* its run history, because the question people actually arrive
|
|
* with — "why did this do nothing last night?" — is answered half by the
|
|
* schedule and half by the log. Splitting them into a "runs" tab and a
|
|
* "settings" tab would put the two halves of every answer on opposite sides of
|
|
* the page.
|
|
*
|
|
* **Two audiences on one page.** The run history is the caller's own and is
|
|
* shown to everyone; the settings are instance-wide and shown only to an admin
|
|
* (`can_configure`). Hiding the form is presentation only — the backend gates
|
|
* both the listing and `PUT /api/config/{key}`.
|
|
*/
|
|
export class SystemAgentsPage extends LightElement {
|
|
static properties = {
|
|
_open: { state: true },
|
|
_agents: { state: true },
|
|
_canCfg: { state: true },
|
|
_tab: { state: true },
|
|
_items: { state: true },
|
|
_total: { state: true },
|
|
_page: { state: true },
|
|
_loading: { state: true },
|
|
_error: { state: true },
|
|
_running: { state: true },
|
|
_runMsg: { state: true },
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this._open = false;
|
|
this._agents = [];
|
|
this._canCfg = false;
|
|
this._tab = ALL_TAB;
|
|
this._items = [];
|
|
this._total = 0;
|
|
this._page = 1;
|
|
this._loading = false;
|
|
this._error = null;
|
|
/** The agent whose manual run we are waiting on, if any. */
|
|
this._running = null;
|
|
/** `{ agent, text, error }` — the answer to the last press. */
|
|
this._runMsg = null;
|
|
this._poll = null;
|
|
this._form = new ConfigFormController(() => this.requestUpdate());
|
|
}
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.__onLocaleChanged = () => this.requestUpdate();
|
|
window.addEventListener('locale-changed', this.__onLocaleChanged);
|
|
window.addEventListener('llm-page-change', (e) => {
|
|
this._open = e.detail.page === PAGE_ID;
|
|
this.style.display = this._open ? 'flex' : 'none';
|
|
if (this._open) this._loadAll();
|
|
// Navigating away stops the polling; the pass keeps running server-side
|
|
// and its row is waiting on the next visit.
|
|
else this._stopPolling();
|
|
});
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
window.removeEventListener('locale-changed', this.__onLocaleChanged);
|
|
this._stopPolling();
|
|
super.disconnectedCallback();
|
|
}
|
|
|
|
async _loadAll() {
|
|
await this._fetchAgents();
|
|
await this._fetch(this._page);
|
|
}
|
|
|
|
async _fetchAgents() {
|
|
try {
|
|
const res = await fetch('/api/system-agents');
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
this._agents = data.items ?? [];
|
|
this._canCfg = !!data.can_configure;
|
|
// A member gets no `config` at all, so there is nothing to seed.
|
|
this._form.seedFromSets(this._agents.map(a => a.config).filter(Boolean));
|
|
} catch (e) {
|
|
// Non-fatal: without the agent list the page still shows the run log,
|
|
// which is the half everyone can see.
|
|
this._agents = [];
|
|
this._canCfg = false;
|
|
}
|
|
}
|
|
|
|
/** `quiet` skips the loading state, so a poll does not blank the table. */
|
|
async _fetch(page, quiet = false) {
|
|
if (!quiet) this._loading = true;
|
|
this._error = null;
|
|
try {
|
|
const params = new URLSearchParams({ page, per_page: PER_PAGE });
|
|
if (this._tab !== ALL_TAB) params.set('agent_id', this._tab);
|
|
const res = await fetch(`/api/system-agents/runs?${params}`);
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
this._items = data.items;
|
|
this._total = data.total;
|
|
this._page = data.page;
|
|
} catch (e) {
|
|
this._error = e.message;
|
|
} finally {
|
|
this._loading = false;
|
|
}
|
|
}
|
|
|
|
_selectTab(id) {
|
|
if (this._tab === id) return;
|
|
this._tab = id;
|
|
this._page = 1;
|
|
this._runMsg = null;
|
|
this._fetch(1);
|
|
}
|
|
|
|
/**
|
|
* Start one pass now, for me.
|
|
*
|
|
* The button asks and stops there: the server answers as soon as the pass is
|
|
* *scheduled*, because a pass is an LLM turn and nothing good comes of holding
|
|
* a request open for it. "Nothing to do" is a real answer and arrives straight
|
|
* away — it leaves no run row, so without it the table would simply never
|
|
* change and the press would look lost.
|
|
*/
|
|
async _runNow(agent) {
|
|
if (this._running) return;
|
|
this._running = agent.id;
|
|
this._runMsg = null;
|
|
try {
|
|
const res = await fetch(`/api/system-agents/${encodeURIComponent(agent.id)}/run`,
|
|
{ method: 'POST' });
|
|
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
|
const data = await res.json();
|
|
if (data.status === 'nothing_to_do') {
|
|
this._runMsg = { agent: agent.id, text: t('system_agents.run.nothing') };
|
|
} else {
|
|
this._runMsg = { agent: agent.id, text: t('system_agents.run.started') };
|
|
await this._fetch(1, true);
|
|
this._startPolling();
|
|
}
|
|
} catch (e) {
|
|
this._runMsg = { agent: agent.id, text: e.message, error: true };
|
|
} finally {
|
|
this._running = null;
|
|
}
|
|
}
|
|
|
|
/** Refresh the log until the pass leaves `running`, then stop. */
|
|
_startPolling() {
|
|
this._stopPolling();
|
|
const until = Date.now() + POLL_MAX_MIN * 60_000;
|
|
this._poll = setInterval(async () => {
|
|
await this._fetch(this._page, true);
|
|
const live = this._items.some(r => r.status === 'running');
|
|
if (!live || Date.now() > until) this._stopPolling();
|
|
}, POLL_MS);
|
|
}
|
|
|
|
_stopPolling() {
|
|
if (this._poll) { clearInterval(this._poll); this._poll = null; }
|
|
}
|
|
|
|
_openSession(id) {
|
|
if (id != null) window.location.hash = `session/${id}`;
|
|
}
|
|
|
|
get _totalPages() { return Math.max(1, Math.ceil(this._total / PER_PAGE)); }
|
|
|
|
get _currentAgent() {
|
|
return this._agents.find(a => a.id === this._tab) ?? null;
|
|
}
|
|
|
|
/** Server-supplied English unless the instance ships a translation. */
|
|
_agentLabel(agent) {
|
|
return {
|
|
name: maybeT(`system_agents.agent.${agent.id}.name`, agent.name),
|
|
description: maybeT(`system_agents.agent.${agent.id}.desc`, agent.description),
|
|
};
|
|
}
|
|
|
|
/// The agent's own counters. Rendered generically so a new system agent needs
|
|
/// no change here: unknown keys fall back to the raw key name.
|
|
_renderStats(stats) {
|
|
if (!stats || typeof stats !== 'object') return '—';
|
|
const parts = Object.entries(stats)
|
|
.filter(([, v]) => v != null)
|
|
.map(([k, v]) => {
|
|
const label = t(`system_agents.stat.${k}`);
|
|
return `${v} ${label.startsWith('system_agents.') ? k.replace(/_/g, ' ') : label}`;
|
|
});
|
|
return parts.length ? parts.join(' · ') : '—';
|
|
}
|
|
|
|
_renderTabs() {
|
|
if (this._agents.length === 0) return nothing;
|
|
const tab = (id, label, icon) => html`
|
|
<button class="sa-tab ${this._tab === id ? 'sa-tab--active' : ''}"
|
|
@click=${() => this._selectTab(id)}>
|
|
${icon ? html`<i class="bi ${icon}"></i>` : nothing}${label}
|
|
</button>`;
|
|
|
|
return html`
|
|
<div class="sa-tab-bar">
|
|
${tab(ALL_TAB, t('system_agents.tab.all'), 'bi-collection')}
|
|
${this._agents.map(a => tab(a.id, this._agentLabel(a).name, null))}
|
|
</div>`;
|
|
}
|
|
|
|
/**
|
|
* The selected agent's description and its **Run now** button, plus its
|
|
* settings when the caller is an admin.
|
|
*
|
|
* The button sits with the description rather than in the page header because
|
|
* it acts on *this* agent, not on the page: the header's Refresh reloads
|
|
* whatever is on screen, and a "Run" next to it would read as running all of
|
|
* them. Agents that work on somebody else (the conversation review) have no
|
|
* "for me" to run and say so with `can_run_now: false`.
|
|
*/
|
|
_renderAgentPanel() {
|
|
const agent = this._currentAgent;
|
|
if (!agent) return nothing;
|
|
const label = this._agentLabel(agent);
|
|
const busy = this._running === agent.id;
|
|
const msg = this._runMsg?.agent === agent.id ? this._runMsg : null;
|
|
|
|
return html`
|
|
<div class="sa-agent-panel">
|
|
<p class="sa-agent-desc">${label.description}</p>
|
|
${agent.can_run_now ? html`
|
|
<div class="sa-agent-actions">
|
|
<button class="btn btn-sm btn-outline-primary"
|
|
?disabled=${busy}
|
|
@click=${() => this._runNow(agent)}>
|
|
${busy
|
|
? html`<span class="spinner-border spinner-border-sm" role="status"></span>`
|
|
: html`<i class="bi bi-play-fill"></i>`}
|
|
${t('system_agents.run.now')}
|
|
</button>
|
|
<small class="sa-run-hint">${t('system_agents.run.hint')}</small>
|
|
${msg ? html`
|
|
<span class="sa-run-msg ${msg.error ? 'sa-run-msg--error' : ''}">
|
|
<i class="bi ${msg.error ? 'bi-exclamation-circle' : 'bi-info-circle'}"></i>
|
|
${msg.text}
|
|
</span>` : nothing}
|
|
</div>` : nothing}
|
|
${this._canCfg && agent.config ? html`
|
|
<div class="config-set sa-agent-config">
|
|
<div class="config-set-header">
|
|
<div class="config-set-name">${t('system_agents.settings')}</div>
|
|
</div>
|
|
${this._form.renderRows(agent.config.properties, p => {
|
|
const pk = propKeyId(p.key);
|
|
return {
|
|
name: maybeT(`config.prop.${pk}.name`, p.name),
|
|
description: maybeT(`config.prop.${pk}.desc`, p.description),
|
|
};
|
|
})}
|
|
</div>` : nothing}
|
|
</div>`;
|
|
}
|
|
|
|
_renderTable() {
|
|
if (this._loading) return html`
|
|
<div class="sa-state">
|
|
<div class="spinner-border spinner-border-sm text-secondary" role="status"></div>
|
|
<span>${t('system_agents.loading')}</span>
|
|
</div>
|
|
`;
|
|
if (this._error) return html`
|
|
<div class="sa-state sa-state--error">
|
|
<i class="bi bi-exclamation-circle"></i>
|
|
<span>${this._error}</span>
|
|
</div>
|
|
`;
|
|
if (this._items.length === 0) return html`
|
|
<div class="sa-state sa-state--empty">
|
|
<i class="bi bi-robot"></i>
|
|
<span>${t('system_agents.empty')}</span>
|
|
<small>${t('system_agents.empty_hint')}</small>
|
|
</div>
|
|
`;
|
|
|
|
// The agent column is redundant once a single agent's tab is selected.
|
|
const showAgent = this._tab === ALL_TAB;
|
|
|
|
return html`
|
|
<div class="sa-table-wrap">
|
|
<table class="table table-sm sa-table">
|
|
<thead>
|
|
<tr>
|
|
${showAgent ? html`<th>${t('system_agents.table.agent')}</th>` : nothing}
|
|
<th>${t('system_agents.table.started')}</th>
|
|
<th>${t('system_agents.table.status')}</th>
|
|
<th class="text-end">${t('system_agents.table.duration')}</th>
|
|
<th>${t('system_agents.table.result')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${this._items.map(r => html`
|
|
<tr class=${r.session_id != null ? 'sa-row--clickable' : ''}
|
|
@click=${() => this._openSession(r.session_id)}>
|
|
${showAgent ? html`<td><span class="sa-agent">${r.agent_id}</span></td>` : nothing}
|
|
<td class="sa-date">${formatDate(r.started_at)}</td>
|
|
<td>
|
|
<span class="sa-status sa-status--${r.status}">
|
|
<i class="bi ${STATUS_ICON[r.status] ?? 'bi-question-circle'}"></i>
|
|
${t(`system_agents.status.${r.status}`)}
|
|
</span>
|
|
</td>
|
|
<td class="text-end sa-num">${formatDuration(r.duration_ms)}</td>
|
|
<td class="sa-result">
|
|
${r.error
|
|
? html`<span class="sa-error" title=${r.error}>${r.error}</span>`
|
|
: this._renderStats(r.stats)}
|
|
</td>
|
|
</tr>
|
|
`)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
_renderPagination() {
|
|
if (this._totalPages <= 1) return nothing;
|
|
const pages = this._totalPages;
|
|
const cur = this._page;
|
|
return html`
|
|
<div class="sa-pagination">
|
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur <= 1}
|
|
@click=${() => this._fetch(cur - 1)}>
|
|
<i class="bi bi-chevron-left"></i>
|
|
</button>
|
|
<span class="sa-page-info">${t('system_agents.pagination', { cur, pages, total: this._total })}</span>
|
|
<button class="btn btn-sm btn-outline-secondary" ?disabled=${cur >= pages}
|
|
@click=${() => this._fetch(cur + 1)}>
|
|
<i class="bi bi-chevron-right"></i>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
render() {
|
|
return html`
|
|
<div class="sa-page">
|
|
<div class="page-header">
|
|
<div class="page-header-left">
|
|
<h2 class="page-header-title"><i class="bi bi-robot"></i> ${t('system_agents.title')}</h2>
|
|
</div>
|
|
<div class="page-header-actions">
|
|
<span class="page-header-count">${t('system_agents.total', { n: this._total })}</span>
|
|
<button class="btn btn-sm btn-outline-secondary"
|
|
?disabled=${this._loading}
|
|
@click=${() => this._loadAll()}>
|
|
<i class="bi bi-arrow-clockwise"></i> ${t('system_agents.refresh')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="sa-body">
|
|
<p class="sa-subtitle">${t('system_agents.subtitle')}</p>
|
|
${this._renderTabs()}
|
|
${this._renderAgentPanel()}
|
|
${this._renderTable()}
|
|
${this._renderPagination()}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|