Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -252,4 +252,6 @@ uv.lock
.pypirc

.deployment/
.playwright-cli/
.playwright-cli/
/
but/
15 changes: 15 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ All notable changes to this project will be documented in this file.

## Unreleased

### v0.10.29 (dev)

- **Dialog HITL** — New HITL type for interactive content review via chat interface. When a `CaseNodeUpdate` payload contains `supervaizer_dialog`, the workbench renders a conversation UI instead of a fixed form. Supports iterative refinement through LLM-powered feedback loops. Fields: `content` (JSON string), `content_type` (email/text/code), `objective`, `instructions`, `messages` (conversation history), `confirm_label`.
- Template: `dialog_renderer.html` with `render_hitl_dialog` and `render_dialog_confirmed` macros
- JS: `submitDialogMessage(caseId, message)` and `confirmDialog(caseId)` in `workbench-form.js`
- Route: `workbench_routes.py` detects `supervaizer_dialog` in AWAITING case payloads

- **Local mode URL fixes** — `supervaizer start --local --port N` now correctly shows localhost URLs everywhere: CLI output, admin interface logs, storage, and uvicorn. Fixed by resolving `Server.__init__` defaults from env vars at call time (not class definition time) and ensuring CLI-provided values take precedence over `.env` file values.

- **Local mode event skipping** — `account_service.send_event()` returns a no-op `ApiSuccess` when `SUPERVAIZER_LOCAL_MODE=true`, preventing HTTP errors against the SaaS API during local development.

- **Agent parameter env pre-fill** — In local mode, the workbench auto-loads `.env` values into agent parameter fields with green `.env` badge indicators. Secret fields show a masked placeholder; non-secret fields display the value. Backend falls back to env values for empty fields on job submission.

- **HTMX polling guard** — Monitor template suppresses `hx-trigger` polling when a HITL dialog or form is active, preventing DOM overwrites while users interact with forms.

### v0.10.27

- **Agent Workbench** — Full-featured testing interface for agents directly from the admin panel. Four-zone layout with agent parameters, job control, execution monitor, and live console log. Supports starting/stopping jobs, real-time case and step tracking via HTMX polling, and Human-in-the-Loop (HITL) form rendering and submission. Job history panel lists all past executions with status badges.
Expand Down
9 changes: 8 additions & 1 deletion src/supervaizer/account_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import httpx

from supervaizer.common import ApiError, ApiResult, ApiSuccess, log
from supervaizer.common import ApiError, ApiResult, ApiSuccess, is_local_mode, log

logger = logging.getLogger("httpx")
# Enable httpx debug logging (optional - uncomment for transport-level debugging)
Expand Down Expand Up @@ -57,6 +57,13 @@ def send_event(
Tested in tests/test_account_service.py
"""

# In local mode, skip sending events to the SaaS API entirely.
if is_local_mode():
log.debug(f"[Send event] Local mode — skipping {event.type.name}")
return ApiSuccess(
message=f"Event {event.type.name} skipped (local mode)", detail=None
)

headers = account.api_headers
payload = event.payload
url_event = (
Expand Down
7 changes: 6 additions & 1 deletion src/supervaizer/admin/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1223,12 +1223,17 @@ async def execute_console_command(
return {"status": "error", "message": str(e)}

# Include workbench sub-router
from supervaizer.admin.workbench_routes import create_workbench_routes
from supervaizer.admin.workbench_routes import (
create_workbench_routes,
create_workbench_ws_routes,
)

router.include_router(
create_workbench_routes(),
dependencies=[Depends(verify_admin_access)],
)
# WebSocket routes are mounted separately — WS can't use APIKeyHeader auth
router.include_router(create_workbench_ws_routes())

return router

Expand Down
252 changes: 199 additions & 53 deletions src/supervaizer/admin/static/js/workbench-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ class WorkbenchForm {
this._getFields = config.getFields || null;
if (config.onJobStarted) this.onJobStarted = config.onJobStarted;
if (config.onError) this.onError = config.onError;
this._pollInterval = null;
this._ws = null;
}

getApiKey() {
Expand Down Expand Up @@ -122,6 +122,35 @@ class WorkbenchForm {
}
}

async pollJob() {
if (!this.activeJobId) return;
const btn = document.getElementById('btn-poll');
if (btn) {
btn.disabled = true;
btn.classList.add('opacity-50');
}
try {
const response = await fetch(`${this.basePath}/jobs/${this.activeJobId}/poll`, {
method: 'POST',
headers: { 'X-API-Key': this.getApiKey() },
});
const result = await response.json();
if (!response.ok) {
this.onError(result.detail || 'Poll failed');
} else {
this.onError('');
this.refreshMonitor(true);
}
} catch (e) {
this.onError(`Network error: ${e.message}`);
} finally {
if (btn) {
btn.disabled = false;
btn.classList.remove('opacity-50');
}
}
}

async stopJob(jobId) {
const targetJobId = jobId || this.activeJobId;
if (!targetJobId) return;
Expand Down Expand Up @@ -157,20 +186,22 @@ class WorkbenchForm {
}
}

async submitHitlAnswer(caseId, formElement) {
const answer = {};
new FormData(formElement).forEach((value, key) => {
answer[key] = value;
});
// Handle checkboxes that aren't in FormData when unchecked
formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (!(cb.name in answer)) {
answer[cb.name] = false;
} else {
answer[cb.name] = true;
}
/** Disable/enable all HITL buttons in the monitor to prevent double-clicks. */
_setHitlButtonsDisabled(disabled) {
const container = document.getElementById(this.monitorContainerId);
if (!container) return;
container.querySelectorAll('button').forEach(btn => {
btn.disabled = disabled;
if (disabled) btn.classList.add('opacity-50', 'cursor-not-allowed');
else btn.classList.remove('opacity-50', 'cursor-not-allowed');
});
}

/** Shared POST to /cases/{caseId}/answer endpoint. */
async _postAnswer(caseId, answerPayload) {
if (!this.activeJobId || this._submitting) return null;
this._submitting = true;
this._setHitlButtonsDisabled(true);
try {
const response = await fetch(
`${this.basePath}/jobs/${this.activeJobId}/cases/${caseId}/answer`,
Expand All @@ -180,90 +211,205 @@ class WorkbenchForm {
'Content-Type': 'application/json',
'X-API-Key': this.getApiKey(),
},
body: JSON.stringify({ answer }),
body: JSON.stringify({ answer: answerPayload }),
},
);
const result = await response.json();
if (!response.ok) {
this.onError(result.detail || result.message || 'Failed to submit answer');
} else {
// Remove the form so polling resumes, then refresh immediately
formElement.remove();
this.refreshMonitor();
this._setHitlButtonsDisabled(false);
return null;
}
this.onError('');
this.refreshMonitor(true);
return result;
} catch (e) {
this.onError(`Network error: ${e.message}`);
this._setHitlButtonsDisabled(false);
return null;
} finally {
this._submitting = false;
}
}

/** Show/hide Stop and Status buttons based on active job. */
async submitHitlAnswer(caseId, formElement) {
const answer = {};
new FormData(formElement).forEach((value, key) => {
answer[key] = value;
});
// Handle checkboxes that aren't in FormData when unchecked
formElement.querySelectorAll('input[type="checkbox"]').forEach(cb => {
if (!(cb.name in answer)) {
answer[cb.name] = false;
} else {
answer[cb.name] = true;
}
});

const result = await this._postAnswer(caseId, answer);
if (result) {
formElement.remove();
}
}

async submitDialogMessage(caseId, message) {
if (!message) return;
await this._postAnswer(caseId, { action: 'message', text: message });
}

async confirmDialog(caseId) {
await this._postAnswer(caseId, { action: 'confirm' });
}

/** Show/hide Stop, Poll, and Status buttons based on active job. */
_updateButtons(terminal) {
const stop = document.getElementById('btn-stop');
const status = document.getElementById('btn-status');
const poll = document.getElementById('btn-poll');
if (this.activeJobId && !terminal) {
if (stop) stop.classList.remove('hidden');
if (status) status.classList.remove('hidden');
if (poll) poll.classList.remove('hidden');
} else {
if (stop) stop.classList.add('hidden');
if (poll) poll.classList.add('hidden');
if (status) status.classList.remove('hidden');
}
}

/** Fetch and render the monitor partial for the active job. */
refreshMonitor() {
/** True when a HITL dialog or form is visible in the monitor. */
_hasActiveHitl() {
const container = document.getElementById(this.monitorContainerId);
if (!container) return false;
return !!(
container.querySelector('form[id^="hitl-form-"]') ||
container.querySelector('[id^="hitl-dialog-"]')
);
}

/** Fetch and render the monitor partial for the active job.
* @param {boolean} force - bypass HITL guard (used after dialog submit/confirm)
*/
refreshMonitor(force) {
if (!this.activeJobId) return;
const container = document.getElementById(this.monitorContainerId);
if (!container) return;
// Don't overwrite while user interacts with HITL (unless forced)
if (!force && this._hasActiveHitl()) return;
const url = `${this.basePath}/jobs/${this.activeJobId}`;
const apiKey = this.getApiKey();
const headers = apiKey ? { 'X-API-Key': apiKey } : {};
const self = this;
fetch(url, { headers })
.then(r => r.text())
.then(html => {
container.innerHTML = html;
if (typeof Alpine !== 'undefined') {
Alpine.initTree(container);
}
// Disconnect WebSocket when job reaches terminal state
const partial = container.querySelector('#workbench-monitor-partial');
if (partial && partial.dataset.jobTerminal === 'true') {
self.disconnectWebSocket();
self._updateButtons(true);
}
})
.catch(() => {});
}

/** Load monitor partial and start polling. Stops when job reaches terminal state. */
/** Check if the monitor shows a terminal job state. */
_isJobTerminal() {
var container = document.getElementById(this.monitorContainerId);
if (!container) return false;
var partial = container.querySelector('#workbench-monitor-partial');
return partial && partial.dataset.jobTerminal === 'true';
}

/** Connect a WebSocket that pushes typed signals when state changes. */
connectWebSocket(jobId) {
// Don't connect for terminal jobs
if (this._isJobTerminal()) return;
this.disconnectWebSocket();
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${location.host}${this.basePath}/jobs/${jobId}/ws`;
this._ws = new WebSocket(wsUrl);
const self = this;
this._ws.onmessage = (event) => {
const msg = event.data;
if (msg === 'terminal') {
self.refreshMonitor(true);
self._refreshConsole();
self._refreshJobsList();
self.disconnectWebSocket();
self._updateButtons(true);
} else if (msg === 'monitor' || msg === 'refresh') {
self.refreshMonitor();
} else if (msg === 'console') {
self._refreshConsole();
} else if (msg === 'jobs') {
self._refreshJobsList();
} else if (msg === 'ping') {
self._ws.send('pong');
}
};
this._ws.onclose = () => {
// Only reconnect if job is still active and we didn't intentionally disconnect
if (self.activeJobId && self._ws && !self._isJobTerminal()) {
self._ws = null;
setTimeout(() => {
if (self.activeJobId && !self._isJobTerminal()) {
self.connectWebSocket(jobId);
}
}, 3000);
}
};
this._ws.onerror = () => {};
}

/** Fetch and swap console log entries. */
_refreshConsole() {
var el = document.getElementById('console-log-container');
if (!el) return;
var url = el.dataset.url;
if (!url) return;
fetch(url).then(r => r.text()).then(html => {
el.innerHTML = html;
el.scrollTop = el.scrollHeight;
if (typeof applyConsoleFilter === 'function') applyConsoleFilter();
}).catch(() => {});
}

/** Fetch and swap job history list. */
_refreshJobsList() {
var el = document.getElementById('jobs-list-container');
if (!el) return;
var url = el.dataset.url;
if (!url) return;
fetch(url).then(r => r.text()).then(html => {
el.innerHTML = html;
}).catch(() => {});
}

/** Close the WebSocket connection. */
disconnectWebSocket() {
if (this._ws) {
const ws = this._ws;
this._ws = null;
ws.close();
}
}

/** Load monitor partial and connect WebSocket for live updates. */
onJobStarted(result) {
const container = document.getElementById(this.monitorContainerId);
if (!container) return;
const url = `${this.basePath}/jobs/${result.id}`;
const apiKey = this.getApiKey();
const self = this;
this._updateButtons(false);
const loadMonitor = () => {
// Don't overwrite the monitor while user is filling a HITL form
if (container.querySelector('form[id^="hitl-form-"]')) return;

const headers = apiKey ? { 'X-API-Key': apiKey } : {};
fetch(url, { headers })
.then(r => r.text())
.then(html => {
container.innerHTML = html;
// Re-initialize Alpine on swapped content
if (typeof Alpine !== 'undefined') {
Alpine.initTree(container);
}
// Stop polling once job is terminal
const partial = container.querySelector('#workbench-monitor-partial');
if (partial && partial.dataset.jobTerminal === 'true') {
if (self._pollInterval) {
clearInterval(self._pollInterval);
self._pollInterval = null;
}
self._updateButtons(true);
}
})
.catch(() => {});
};
loadMonitor();
if (this._pollInterval) clearInterval(this._pollInterval);
this._pollInterval = setInterval(loadMonitor, 2000);
// Initial load
this.refreshMonitor();
this._refreshConsole();
this._refreshJobsList();
// Connect WebSocket — pushes monitor, console, and jobs updates
this.connectWebSocket(result.id);
}

/** Show error in errors container. Override in template if needed. */
Expand Down
Loading
Loading