fix(agents): wire continuous-agent Interact tab end-to-end + CLI workflow polish (#425)

This commit is contained in:
Robby Manihani
2026-05-28 12:07:03 -07:00
committed by GitHub
parent c6448f7cf2
commit 26285e56fa
14 changed files with 649 additions and 495 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "openjarvis-chat",
"version": "0.1.0",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openjarvis-chat",
"version": "0.1.0",
"version": "1.0.1",
"dependencies": {
"@base-ui/react": "^1.3.0",
"@fontsource-variable/geist": "^5.2.8",
+19
View File
@@ -722,6 +722,25 @@ export async function sendAgentMessage(
return res.json();
}
/**
* Ask the agent a question by triggering an ad-hoc run.
*
* Posts the question as an `immediate`, non-streamed message — the backend
* stores it and spawns a real agent tick (`execute_tick`) that consumes it as
* the run's input (tools, trace, and all), rather than a raw one-shot chat.
* Returns immediately with the stored user message; progress is observed via
* the `/v1/agents/events` WebSocket and the resulting trace.
*/
export async function askAgent(agentId: string, content: string): Promise<AgentMessage> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, mode: 'immediate', stream: false }),
});
if (!res.ok) throw new Error(`Failed: ${res.status}`);
return res.json();
}
export async function fetchAgentMessages(agentId: string): Promise<AgentMessage[]> {
const res = await fetch(`${getBase()}/v1/managed-agents/${agentId}/messages`);
if (!res.ok) throw new Error(`Failed: ${res.status}`);
+389 -465
View File
@@ -9,7 +9,6 @@ import {
fetchAgentChannels,
bindAgentChannel,
unbindAgentChannel,
fetchAgentMessages,
fetchTemplates,
createManagedAgent,
pauseManagedAgent,
@@ -17,10 +16,11 @@ import {
deleteManagedAgent,
runManagedAgent,
recoverManagedAgent,
sendAgentMessage,
askAgent,
fetchLearningLog,
triggerLearning,
fetchAgentTraces,
fetchAgentTrace,
fetchManagedAgent,
fetchAvailableTools,
saveToolCredentials,
@@ -32,8 +32,9 @@ import {
sendblueTest,
sendblueHealth,
} from '../lib/api';
import type { AgentTask, ChannelBinding, AgentTemplate, AgentMessage, ManagedAgent, LearningLogEntry, AgentTrace, ToolInfo } from '../lib/api';
import type { AgentTask, ChannelBinding, AgentTemplate, ManagedAgent, LearningLogEntry, AgentTrace, AgentTraceDetail, ToolInfo } from '../lib/api';
import { useAgentEvents } from '../lib/useAgentEvents';
import type { AgentEvent } from '../lib/useAgentEvents';
import {
Plus,
Bot,
@@ -60,6 +61,7 @@ import {
Copy,
Check,
Pencil,
Loader2,
} from 'lucide-react';
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
@@ -1403,20 +1405,20 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
let cancelled = false;
async function checkModel() {
try {
const res = await fetch('http://localhost:11434/api/tags');
if (!res.ok) { setModelAvailable('unknown'); return; }
const data = await res.json();
const loadedNames: string[] = (data.models || []).map((m: { name: string }) => m.name);
if (!cancelled) {
setOllamaModels(loadedNames);
if (currentModel === '(default)') {
setModelAvailable(loadedNames.length > 0 ? 'available' : 'unknown');
} else {
const isLoaded = loadedNames.some(
(n) => n === currentModel || n.startsWith(currentModel + ':') || currentModel.startsWith(n.split(':')[0])
);
setModelAvailable(isLoaded ? 'available' : 'unavailable');
}
// Ask the backend which models are installed rather than hitting
// Ollama directly from the browser: the backend always knows where
// Ollama lives (incl. remote) and there's no cross-origin/CORS issue,
// which is what made the check spuriously report "Not available".
const installed = (await fetchModels()).map((m) => m.id);
if (cancelled) return;
setOllamaModels(installed);
if (currentModel === '(default)') {
setModelAvailable(installed.length > 0 ? 'available' : 'unknown');
} else {
const isInstalled = installed.some(
(n) => n === currentModel || n.startsWith(currentModel + ':') || currentModel.startsWith(n.split(':')[0])
);
setModelAvailable(isInstalled ? 'available' : 'unavailable');
}
} catch {
if (!cancelled) setModelAvailable('unknown');
@@ -1428,21 +1430,15 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
async function startEditingModel() {
try {
const fetched = await fetchModels();
setModels(fetched.map((m) => m.id));
} catch { /* ignore */ }
// Also refresh Ollama models for availability indication
try {
const res = await fetch('http://localhost:11434/api/tags');
if (res.ok) {
const data = await res.json();
setOllamaModels((data.models || []).map((m: { name: string }) => m.name));
}
const fetched = (await fetchModels()).map((m) => m.id);
setModels(fetched);
// Same backend list drives both the dropdown and the availability dots.
setOllamaModels(fetched);
} catch { /* ignore */ }
setEditingModel(true);
}
function isModelLoaded(modelId: string): boolean {
function isModelInstalled(modelId: string): boolean {
return ollamaModels.some(
(n) => n === modelId || n.startsWith(modelId + ':') || modelId.startsWith(n.split(':')[0])
);
@@ -1480,10 +1476,10 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
style={{ background: 'var(--color-bg)', border: '1px solid var(--color-border)', color: 'var(--color-text)' }}
>
{models.map((m) => {
const loaded = isModelLoaded(m);
const installed = isModelInstalled(m);
return (
<option key={m} value={m} style={!loaded ? { color: 'var(--color-text-tertiary)' } : undefined}>
{m}{!loaded ? ' (not loaded)' : ''}
<option key={m} value={m} style={!installed ? { color: 'var(--color-text-tertiary)' } : undefined}>
{m}{!installed ? ' (not installed)' : ''}
</option>
);
})}
@@ -1546,498 +1542,421 @@ function AgentConfigGrid({ agent, onAgentUpdated }: { agent: ManagedAgent; onAge
// Detail view — Interact tab
// ---------------------------------------------------------------------------
/** AgentMessage extended with optional response metadata for the footer. */
type InteractMessage = AgentMessage & {
_elapsed?: string;
_toolCalls?: number;
_usage?: Record<string, number>;
_telemetry?: Record<string, unknown>;
_toolCallDetails?: ToolCallInfo[];
};
/** One entry in the live activity feed assembled from agent events. */
type LiveItem =
| { kind: 'note'; id: string; label: string }
| { kind: 'tool'; id: string; tool: ToolCallInfo };
function AgentResponseFooter({
msg, copiedId, onCopy,
}: {
msg: InteractMessage;
copiedId: string | null;
onCopy: (id: string) => void;
}) {
const [expanded, setExpanded] = useState(false);
const u = msg._usage;
const t = msg._telemetry as Record<string, unknown> | undefined;
const elapsed = msg._elapsed;
const toolCallDetails = msg._toolCallDetails || [];
const toolCalls = msg._toolCalls ?? toolCallDetails.length;
// Build summary line like Chat: "ollama - qwen3.5:9b - 18.3s - 50 tokens"
const parts: string[] = [];
if (t?.engine) parts.push(String(t.engine));
if (t?.model_id) parts.push(String(t.model_id));
if (elapsed) parts.push(`${elapsed}s`);
if (u?.prompt_tokens) parts.push(`${u.prompt_tokens} input tokens`);
if (u?.completion_tokens) parts.push(`${u.completion_tokens} output tokens`);
if (toolCalls > 0) parts.push(`${toolCalls} tool ${toolCalls === 1 ? 'call' : 'calls'}`);
const summary = parts.length > 0 ? parts.join(' - ') : elapsed ? `${elapsed}s` : '';
// Build expanded rows
const rows: Array<{ label: string; value: string }> = [];
if (t?.engine) rows.push({ label: 'Engine', value: `${t.engine}${t.model_id ? ` (${t.model_id})` : ''}` });
if (u) {
const tokenParts = [];
if (u.completion_tokens) tokenParts.push(`${u.completion_tokens} generated`);
if (u.prompt_tokens) tokenParts.push(`${u.prompt_tokens} prompt`);
if (tokenParts.length) rows.push({ label: 'Tokens', value: tokenParts.join(' · ') });
}
if (toolCallDetails.length > 0) {
toolCallDetails.forEach((tc, i) => {
const prefix = toolCallDetails.length > 1 ? `Tool ${i + 1}` : 'Tool';
const args = tc.arguments ? ` ${tc.arguments}` : '';
rows.push({ label: prefix, value: `${tc.tool}(${args.trim()})` });
});
} else if (toolCalls > 0) {
rows.push({ label: 'Tool calls', value: `${toolCalls}` });
}
if (t?.tokens_per_sec) rows.push({ label: 'Speed', value: `${Math.round(Number(t.tokens_per_sec))} tok/s` });
if (t?.total_ms) rows.push({ label: 'Latency', value: `${(Number(t.total_ms) / 1000).toFixed(1)}s total` });
if (!summary) return null;
return (
<div style={{ borderTop: '1px solid var(--color-border-subtle)', marginTop: 6 }}>
<div style={{ display: 'flex', alignItems: 'center', paddingTop: 4 }}>
<button
onClick={() => rows.length > 0 && setExpanded(!expanded)}
style={{
flex: 1, display: 'flex', alignItems: 'center', gap: 6,
background: 'none', border: 'none', cursor: rows.length > 0 ? 'pointer' : 'default',
padding: 0, textAlign: 'left',
}}
>
<span style={{ width: 4, height: 4, borderRadius: '50%', background: 'var(--color-accent)', flexShrink: 0 }} />
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'system-ui' }}>
{summary}
</span>
{rows.length > 0 && (
<span style={{ fontSize: 10, color: 'var(--color-text-tertiary)' }}>
{expanded ? '▲' : '▼'}
</span>
)}
</button>
<button
onClick={() => onCopy(msg.id)}
style={{
background: 'none', border: 'none', cursor: 'pointer',
color: 'var(--color-text-tertiary)', padding: 2,
display: 'flex', alignItems: 'center',
}}
title="Copy response"
>
{copiedId === msg.id ? <Check size={12} /> : <Copy size={12} />}
</button>
</div>
{expanded && rows.length > 0 && (
<div style={{
borderRadius: 6, marginTop: 4, padding: '6px 10px',
background: 'rgba(0, 0, 0, 0.15)',
}}>
<div style={{
display: 'grid', gridTemplateColumns: 'auto 1fr',
columnGap: 12, rowGap: 2,
}}>
{rows.map((row) => (
<div key={row.label} style={{ display: 'contents' }}>
<span style={{ fontSize: 11, color: 'var(--color-text-tertiary)', fontFamily: 'monospace' }}>
{row.label}
</span>
<span style={{ fontSize: 11, color: 'var(--color-text-secondary)', fontFamily: 'monospace' }}>
{row.value}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
/** Convert a persisted trace step into a ToolCallInfo for ToolCallCard. */
function stepToToolCall(
step: AgentTraceDetail['steps'][number],
idx: number,
): ToolCallInfo {
const input = (step.input ?? {}) as { tool?: string; args?: unknown };
const out = step.output as unknown;
const result =
typeof out === 'string'
? out
: out && typeof out === 'object' && 'result' in out
? String((out as { result: unknown }).result ?? '')
: out != null
? JSON.stringify(out)
: '';
const args = input.args;
return {
id: `step-${idx}`,
tool: input.tool || step.step_type || 'step',
arguments:
typeof args === 'string' ? args : args != null ? JSON.stringify(args) : '',
status: 'success',
result,
latency: step.duration ? step.duration * 1000 : undefined,
};
}
function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: string }) {
const [messages, setMessages] = useState<InteractMessage[]>([]);
// ---------------------------------------------------------------------------
// Interact tab — trace viewer (top) + follow-up chat (bottom).
//
// The chat input doesn't open a side-channel chat; it triggers a real ad-hoc
// agent run (execute_tick) with the user's question as input. The trace area
// shows that run live (tick + tool calls over the events WebSocket) and, when
// idle, the last run's trace steps plus the agent's resulting findings — so
// users can interrogate the agent about its work ("tell me more about X").
// ---------------------------------------------------------------------------
function InteractTab({ agentId, agentStatus, onRunStateChange }: { agentId: string; agentStatus: string; onRunStateChange?: () => void }) {
const [agent, setAgent] = useState<ManagedAgent | null>(null);
const [activity, setActivity] = useState('');
const [running, setRunning] = useState(agentStatus === 'running');
const [liveItems, setLiveItems] = useState<LiveItem[]>([]);
const [lastTrace, setLastTrace] = useState<AgentTraceDetail | null>(null);
const [input, setInput] = useState('');
const [sending, setSending] = useState(false);
const [waitingForResponse, setWaitingForResponse] = useState(false);
const [progressLabel, setProgressLabel] = useState('');
const [streamingContent, setStreamingContent] = useState('');
const [streamingToolCalls, setStreamingToolCalls] = useState<ToolCallInfo[]>([]);
const [currentActivity, setCurrentActivity] = useState('');
const [liveStatus, setLiveStatus] = useState(agentStatus);
const [streamElapsedMs, setStreamElapsedMs] = useState(0);
const [copiedId, setCopiedId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const [errorMsg, setErrorMsg] = useState('');
const [question, setQuestion] = useState(''); // question driving the current/last run
const [elapsedMs, setElapsedMs] = useState(0);
const startRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Tail-mode flag: when the user is pinned to the bottom of the transcript
// (within NEAR_BOTTOM_THRESHOLD px) we keep auto-scrolling as new content
// streams in. If they manually scroll up, we stop following so the view
// doesn't get yanked back down.
const isNearBottomRef = useRef(true);
const runningRef = useRef(running);
runningRef.current = running;
const bottomRef = useRef<HTMLDivElement>(null);
// Keep a ref of local metadata so polling doesn't overwrite it
const localMetaRef = useRef<Map<string, {
_elapsed?: string;
_toolCalls?: number;
_usage?: Record<string, number>;
_telemetry?: Record<string, unknown>;
_toolCallDetails?: ToolCallInfo[];
}>>(new Map());
const clearTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const loadData = useCallback(async () => {
// Load idle snapshot: agent record (status + findings) and the latest trace.
const loadIdle = useCallback(async () => {
try {
const [msgs, agent] = await Promise.all([
fetchAgentMessages(agentId),
fetchManagedAgent(agentId),
]);
// Merge server messages with locally-stored metadata, and hydrate
// server-persisted tool_calls into _toolCallDetails so they survive
// page reloads.
const merged: InteractMessage[] = msgs.map((m) => {
const meta = localMetaRef.current.get(m.content?.slice(0, 100) || '');
const base = meta ? { ...m, ...meta } : { ...m };
if (!base._toolCallDetails && m.tool_calls && m.tool_calls.length > 0) {
base._toolCallDetails = m.tool_calls.map((tc, i) => ({
id: `${m.id}-tc-${i}`,
tool: tc.tool,
arguments: tc.arguments || '',
status: tc.success === false ? 'error' : 'success',
result: tc.result,
latency: tc.latency,
}));
if (base._toolCalls == null) base._toolCalls = m.tool_calls.length;
const a = await fetchManagedAgent(agentId);
setAgent(a);
setActivity(a.current_activity || '');
try {
const traces = await fetchAgentTraces(agentId, 1);
if (traces.length > 0) {
const detail = await fetchAgentTrace(agentId, traces[0].id);
setLastTrace(detail);
}
return base;
});
setMessages(merged);
setLiveStatus(agent.status);
setCurrentActivity(agent.current_activity || '');
} catch {
/* trace store may be empty */
}
} catch {
// ignore
/* ignore */
}
}, [agentId]);
useEffect(() => {
loadData();
// Fallback slow poll — WS is primary, this catches missed events / dropped sockets
const interval = setInterval(loadData, 30000);
return () => clearInterval(interval);
}, [loadData]);
loadIdle();
}, [loadIdle]);
// Event-driven refresh — fires when the server reports agent activity
useAgentEvents(agentId, loadData, [
'agent_tick_start',
'agent_tick_end',
'agent_tick_error',
'agent_message_received',
'tool_call_end',
'inference_end',
]);
useEffect(() => { setLiveStatus(agentStatus); }, [agentStatus]);
// Clean up elapsed-time timer on unmount
// Tick the elapsed timer while running.
useEffect(() => {
return () => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
};
}, []);
// Track whether the user is near the bottom. Called on every scroll
// event; only flips the ref, never triggers a re-render.
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
isNearBottomRef.current = distance < 80; // px threshold
}, []);
// Initial landing: jump to the bottom once the first batch of messages
// arrives. Subsequent poll updates honor the tail-mode ref.
const hasScrolled = useRef(false);
useEffect(() => {
if (!hasScrolled.current && messages.length > 0) {
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
hasScrolled.current = true;
isNearBottomRef.current = true;
if (!running) {
clearTimer();
return;
}
}, [messages]);
if (!startRef.current) startRef.current = Date.now();
timerRef.current = setInterval(
() => setElapsedMs(Date.now() - startRef.current),
100,
);
return clearTimer;
}, [running, clearTimer]);
// Stream auto-follow: only scroll while the user is pinned to the bottom.
// If they've scrolled up to re-read something, stay put.
useEffect(() => {
if (streamingContent && isNearBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [streamingContent]);
const finishRun = useCallback(() => {
setRunning(false);
startRef.current = 0;
clearTimer();
// Give the backend a beat to persist summary_memory + trace, then refresh
// both this tab and the parent (so the detail/list status badge flips back
// from "running" to "idle" without waiting for the slow background poll).
setTimeout(() => {
loadIdle();
onRunStateChange?.();
}, 500);
}, [clearTimer, loadIdle, onRunStateChange]);
async function handleSend(mode: 'immediate' | 'queued') {
if (!input.trim()) return;
const text = input.trim();
setInput('');
setSending(true);
// Show user message immediately as a local bubble
const localMsg: AgentMessage = {
id: `local-${Date.now()}`,
agent_id: agentId,
direction: 'user_to_agent',
content: text,
mode,
status: 'delivered',
created_at: Date.now() / 1000,
};
setMessages((prev) => [localMsg, ...prev]);
setSending(false);
setWaitingForResponse(true);
setProgressLabel('Initializing agent...');
setStreamingContent('');
setStreamingToolCalls([]);
// Sending is explicit user intent — always scroll and re-engage
// tail-mode so the subsequent stream follows along.
isNearBottomRef.current = true;
requestAnimationFrame(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
});
// Start elapsed-time timer
const startTime = Date.now();
setStreamElapsedMs(0);
timerRef.current = setInterval(() => {
setStreamElapsedMs(Date.now() - startTime);
}, 100);
let toolCount = 0;
let responseUsage: Record<string, number> | undefined;
let responseTelemetry: Record<string, unknown> | undefined;
const collectedToolCalls: ToolCallInfo[] = [];
try {
const response = await sendAgentMessage(agentId, text, mode, {
onProgress: (label) => {
setProgressLabel(label);
toolCount++;
},
onContentDelta: (_delta, full) => setStreamingContent(full),
onToolCallStart: ({ tool, arguments: args }) => {
toolCount++;
// Live trace: assemble events from the agent events WebSocket.
const onEvent = useCallback(
(ev: AgentEvent) => {
const data = ev.data || {};
switch (ev.type) {
case 'agent_tick_start': {
startRef.current = Date.now();
setElapsedMs(0);
setRunning(true);
setErrorMsg('');
setLiveItems([{ kind: 'note', id: `start-${ev.timestamp}`, label: 'Run started' }]);
break;
}
case 'tool_call_start': {
const id = `tc-${ev.timestamp}-${Math.random().toString(36).slice(2, 6)}`;
const args = data.arguments;
const tc: ToolCallInfo = {
id: `tc-${Date.now()}-${collectedToolCalls.length}`,
tool,
arguments: args,
id,
tool: String(data.tool || 'tool'),
arguments:
typeof args === 'string' ? args : args != null ? JSON.stringify(args) : '',
status: 'running',
};
collectedToolCalls.push(tc);
setStreamingToolCalls([...collectedToolCalls]);
setProgressLabel(`Calling ${tool}...`);
},
onToolCallEnd: ({ tool, success, latency, result }) => {
const match = [...collectedToolCalls]
.reverse()
.find((t) => t.tool === tool && t.status === 'running');
if (match) {
match.status = success ? 'success' : 'error';
match.latency = latency;
match.result = result;
setLiveItems((prev) => [...prev, { kind: 'tool', id, tool: tc }]);
break;
}
case 'tool_call_end': {
setLiveItems((prev) => {
const next = [...prev];
for (let i = next.length - 1; i >= 0; i--) {
const it = next[i];
if (
it.kind === 'tool' &&
it.tool.tool === String(data.tool) &&
it.tool.status === 'running'
) {
next[i] = {
...it,
tool: {
...it.tool,
status: data.success === false ? 'error' : 'success',
result:
typeof data.result === 'string' ? data.result : it.tool.result,
latency:
typeof data.latency === 'number'
? data.latency * 1000
: it.tool.latency,
},
};
break;
}
}
return next;
});
break;
}
case 'agent_tick_end':
case 'agent_tick_error': {
if (ev.type === 'agent_tick_error') {
setErrorMsg(String(data.error || 'The run failed.'));
}
setStreamingToolCalls([...collectedToolCalls]);
setProgressLabel('');
},
onDone: (_content, usage, telemetry) => {
setStreamingContent('');
responseUsage = usage;
responseTelemetry = telemetry;
},
});
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
// Add the agent's response as a local bubble immediately
if (response && response.content) {
const meta = {
_elapsed: elapsed,
_toolCalls: toolCount,
_usage: responseUsage,
_telemetry: responseTelemetry,
_toolCallDetails: collectedToolCalls.length > 0 ? [...collectedToolCalls] : undefined,
};
// Store metadata keyed by content prefix so polling preserves it
localMetaRef.current.set(response.content.slice(0, 100), meta);
setMessages((prev) => [
{
...response,
id: response.id || `response-${Date.now()}`,
direction: 'agent_to_user' as const,
...meta,
},
...prev,
]);
finishRun();
break;
}
}
// Also refresh from server to sync any persisted messages
await loadData();
},
[finishRun],
);
useAgentEvents(agentId, onEvent, [
'agent_tick_start',
'tool_call_start',
'tool_call_end',
'agent_tick_end',
'agent_tick_error',
]);
// Fallback poll — WS is primary, but this catches missed tick_end events and
// runs started elsewhere (e.g. the scheduler or the Overview "Run" button).
useEffect(() => {
const iv = setInterval(async () => {
try {
const a = await fetchManagedAgent(agentId);
setActivity(a.current_activity || '');
if (a.status === 'running' && !runningRef.current) {
setRunning(true);
} else if (a.status !== 'running' && runningRef.current) {
finishRun();
}
} catch {
/* ignore */
}
}, 3000);
return () => clearInterval(iv);
}, [agentId, finishRun]);
// Keep pinned to the newest live item.
useEffect(() => {
if (running) bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [liveItems, running]);
async function handleAsk() {
const q = input.trim();
if (!q || running || sending) return;
setInput('');
setQuestion(q);
setErrorMsg('');
setSending(true);
setLiveItems([{ kind: 'note', id: 'queued', label: 'Starting run…' }]);
startRef.current = Date.now();
setElapsedMs(0);
try {
// immediate, non-streamed → triggers a real agent run that consumes the
// question as input. tick_start over the WS confirms; poll is the backstop.
await askAgent(agentId, q);
setRunning(true);
onRunStateChange?.(); // flip the parent status badge to "running" now
} catch {
// ignore
setErrorMsg('Could not start the agent run.');
setLiveItems([]);
} finally {
setWaitingForResponse(false);
setStreamingContent('');
setStreamingToolCalls([]);
setProgressLabel('');
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setStreamElapsedMs(0);
setSending(false);
}
}
// Reverse so newest messages appear at the bottom (closest to input).
// Filter out agent responses with empty content.
const displayMessages = [...messages]
.filter((m) => m.direction === 'user_to_agent' || m.content.trim())
.reverse();
const isBusy = running || sending;
const findings = agent?.summary_memory?.trim() || '';
const traceSteps = lastTrace?.steps ?? [];
return (
<div className="flex flex-col" style={{ minHeight: 320 }}>
<div className="flex flex-col" style={{ minHeight: 360 }}>
{/* ── Trace area header ──────────────────────────────── */}
<div className="flex items-center justify-between mb-2">
<div
className="flex items-center gap-2 text-sm font-medium"
style={{ color: 'var(--color-text)' }}
>
<Activity size={14} style={{ color: 'var(--color-accent)' }} />
Activity trace
</div>
<div
className="flex items-center gap-2 text-xs"
style={{ color: 'var(--color-text-tertiary)' }}
>
{isBusy ? (
<>
<span
className="inline-block w-2 h-2 rounded-full animate-pulse"
style={{ background: 'var(--color-accent)' }}
/>
Running{elapsedMs > 0 ? ` · ${(elapsedMs / 1000).toFixed(1)}s` : ''}
</>
) : (
<>
{agent?.last_run_at
? `Last run ${new Date(agent.last_run_at * 1000).toLocaleString()}`
: 'Idle'}
{lastTrace && ` · ${lastTrace.outcome}`}
</>
)}
</div>
</div>
{/* ── Trace area body ────────────────────────────────── */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-3 pb-4"
style={{ maxHeight: 'calc(100vh - 400px)' }}
className="flex-1 overflow-y-auto rounded-lg p-3 space-y-3"
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
maxHeight: 'calc(100vh - 360px)',
minHeight: 200,
}}
>
{displayMessages.length === 0 && !waitingForResponse && (
<div className="text-sm text-center py-8" style={{ color: 'var(--color-text-tertiary)' }}>
No messages yet. Send a message to interact with this agent.
{question && (
<div className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
<span style={{ color: 'var(--color-text-secondary)' }}>Question:</span> {question}
</div>
)}
{displayMessages.map((msg) => (
<div key={msg.id} className="space-y-2">
{/* Tool calls rendered as their own full-width entries (like Claude Code) */}
{msg.direction === 'agent_to_user' && msg._toolCallDetails && msg._toolCallDetails.length > 0 && (
<div className="flex flex-col items-start gap-2 max-w-[75%]">
{msg._toolCallDetails.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
{errorMsg && (
<div
className="text-sm px-3 py-2 rounded-lg"
style={{
background: 'rgba(255,80,80,0.08)',
border: '1px solid var(--color-error)',
color: 'var(--color-error)',
}}
>
{errorMsg}
</div>
)}
{isBusy ? (
/* LIVE view — current tick */
<>
{liveItems.map((it) =>
it.kind === 'tool' ? (
<ToolCallCard key={it.id} toolCall={it.tool} />
) : (
<div
key={it.id}
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<span
className="inline-block w-2 h-2 rounded-full animate-pulse"
style={{ background: 'var(--color-accent)' }}
/>
{it.label}
</div>
),
)}
<div
className="flex items-center gap-2 text-sm"
style={{ color: 'var(--color-text-secondary)' }}
>
<Loader2 size={13} className="animate-spin" style={{ color: 'var(--color-accent)' }} />
{activity || 'Agent is working…'}
</div>
</>
) : (
/* IDLE view — last run's trace + findings */
<>
{traceSteps.length > 0 && (
<div className="space-y-2">
{traceSteps.map((s, i) => (
<ToolCallCard key={i} toolCall={stepToToolCall(s, i)} />
))}
</div>
)}
{/* Message bubble */}
<div className={`flex ${msg.direction === 'user_to_agent' ? 'justify-end' : 'justify-start'}`}>
{findings ? (
<div
className="max-w-[75%] px-3 py-2 rounded-lg text-sm"
className="px-3 py-2 rounded-lg text-sm"
style={{
background: msg.direction === 'user_to_agent' ? 'var(--color-accent)' : 'var(--color-bg-secondary)',
color: msg.direction === 'user_to_agent' ? 'var(--color-on-accent)' : 'var(--color-text)',
border: msg.direction === 'agent_to_user' ? '1px solid var(--color-border)' : 'none',
background: 'var(--color-bg)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
>
{msg.direction === 'agent_to_user' ? (
<div className="prose prose-sm prose-invert max-w-none"><ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown></div>
) : (
<p>{msg.content}</p>
)}
<p className="text-xs mt-1 opacity-70">
{msg.status === 'pending' ? 'sending...' : new Date(msg.created_at * 1000).toLocaleTimeString()}
</p>
{msg.direction === 'agent_to_user' && (
<AgentResponseFooter msg={msg} copiedId={copiedId} onCopy={(id) => {
navigator.clipboard.writeText(msg.content);
setCopiedId(id);
setTimeout(() => setCopiedId(null), 2000);
}} />
)}
</div>
</div>
</div>
))}
{/* Progress indicator — shown when waiting but no streamed content or tool calls yet */}
{(waitingForResponse || sending) && !streamingContent && streamingToolCalls.length === 0 && (
<div className="flex justify-start">
<div
className="px-3 py-2 rounded-lg text-sm"
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-secondary)',
}}
>
<div className="flex items-center gap-2">
<span className="inline-block w-2 h-2 rounded-full animate-pulse" style={{ background: 'var(--color-accent)' }} />
{sending
? 'Sending message...'
: progressLabel || 'Agent is thinking...'}
</div>
</div>
</div>
)}
{/* Live tool call cards rendered as their own entries in the flow */}
{waitingForResponse && streamingToolCalls.length > 0 && (
<div className="flex flex-col items-start gap-2 max-w-[75%]">
{streamingToolCalls.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</div>
)}
{/* Streaming content bubble — real-time response */}
{waitingForResponse && streamingContent && (
<div className="flex justify-start">
<div
className="max-w-[75%] px-3 py-2 rounded-lg text-sm"
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
}}
>
{progressLabel && (
<div className="flex items-center gap-2 mb-2 text-xs" style={{ color: 'var(--color-text-secondary)' }}>
<span className="inline-block w-2 h-2 rounded-full animate-pulse" style={{ background: 'var(--color-accent)' }} />
{progressLabel}
<div className="text-xs mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Result
</div>
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{findings}</ReactMarkdown>
</div>
)}
<div className="prose prose-sm prose-invert max-w-none">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{streamingContent}</ReactMarkdown>
</div>
<p className="text-xs mt-1 opacity-70">
{streamElapsedMs > 0 && `${(streamElapsedMs / 1000).toFixed(1)}s elapsed`}
</p>
</div>
</div>
) : (
traceSteps.length === 0 && (
<div
className="text-sm text-center py-8"
style={{ color: 'var(--color-text-tertiary)' }}
>
No runs yet. Ask a question below to run the agent.
</div>
)
)}
</>
)}
<div ref={bottomRef} />
</div>
{/* Input area */}
<div
className="mt-3 pt-3"
style={{ borderTop: '1px solid var(--color-border)' }}
>
{/* ── Follow-up chat input ───────────────────────────── */}
<div className="mt-3 pt-3" style={{ borderTop: '1px solid var(--color-border)' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend('immediate');
handleAsk();
}
}}
placeholder="Send a message to this agent..."
placeholder={isBusy ? 'Agent is running…' : "Ask a follow-up about this agent's work…"}
disabled={isBusy}
className="w-full px-3 py-2 rounded-lg text-sm bg-transparent outline-none resize-none"
style={{ border: '1px solid var(--color-border)', color: 'var(--color-text)', minHeight: 72 }}
style={{
border: '1px solid var(--color-border)',
color: 'var(--color-text)',
minHeight: 64,
opacity: isBusy ? 0.6 : 1,
}}
/>
<div className="flex gap-2 mt-2">
<div className="flex items-center justify-between mt-2">
<span className="text-xs" style={{ color: 'var(--color-text-tertiary)' }}>
Sends your question as an ad-hoc run results appear in the trace above.
</span>
<button
onClick={() => handleSend('immediate')}
disabled={sending || waitingForResponse || !input.trim()}
onClick={handleAsk}
disabled={isBusy || !input.trim()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm cursor-pointer font-medium"
style={{ background: 'var(--color-accent)', color: 'var(--color-on-accent)', opacity: sending || !input.trim() ? 0.5 : 1 }}
style={{
background: 'var(--color-accent)',
color: 'var(--color-on-accent)',
opacity: isBusy || !input.trim() ? 0.5 : 1,
}}
>
<Send size={13} /> Send
{isBusy ? <Loader2 size={13} className="animate-spin" /> : <Send size={13} />}
{isBusy ? 'Running' : 'Ask'}
</button>
</div>
</div>
@@ -3616,10 +3535,15 @@ export function AgentsPage() {
}
prevStatuses.current[agent.id] = agent.status;
}
// Keep the agent list — and the derived selectedAgent status badge —
// live. This poll previously fetched statuses only to fire error
// toasts and threw the result away, so a detail header could stay
// stuck on "running" after a tick finished on the backend.
setManagedAgents(agents);
} catch {}
}, 30000);
}, 5000);
return () => clearInterval(interval);
}, []);
}, [setManagedAgents]);
if (loading) {
return (
@@ -3903,7 +3827,7 @@ export function AgentsPage() {
)}
{/* Tab: Interact */}
{detailTab === 'interact' && <InteractTab agentId={selectedAgent.id} agentStatus={selectedAgent.status} />}
{detailTab === 'interact' && <InteractTab agentId={selectedAgent.id} agentStatus={selectedAgent.status} onRunStateChange={refresh} />}
{/* Tab: Channels */}
{detailTab === 'channels' && (
+1
View File
@@ -35,6 +35,7 @@ dependencies = [
"rich>=13",
"tomli>=2.0; python_version < '3.11'",
"tomlkit>=0.12",
"websockets>=15.0.1",
]
[project.optional-dependencies]
+6 -5
View File
@@ -660,11 +660,12 @@ class AgentExecutor:
budget_kwargs["total_cost_increment"] = cost
self._manager.update_agent(agent_id, **budget_kwargs)
self._manager.update_summary_memory(
agent_id,
result.content[:2000],
)
self._manager.store_agent_response(agent_id, result.content[:2000])
# Store the full response. update_summary_memory enforces the
# rolling-summary cap (_SUMMARY_MAX) itself, and the chat
# message keeps the complete report. The old [:2000] slices
# double-truncated and cut findings off mid-sentence.
self._manager.update_summary_memory(agent_id, result.content)
self._manager.store_agent_response(agent_id, result.content)
# Budget enforcement (post-tick check)
agent_data = self._manager.get_agent(agent_id)
+53 -10
View File
@@ -87,13 +87,24 @@ CREATE TABLE IF NOT EXISTS agent_learning_log (
);
"""
_SUMMARY_MAX = 2000
# Rolling summary fed back into the next tick's prompt, so it stays bounded —
# but 2000 chars clipped real research reports mid-sentence (the findings the
# UI/CLI show come from here). 16k (~4k tokens) holds a full report while
# keeping per-tick prompt growth in check.
_SUMMARY_MAX = 16000
class AgentManager:
"""Persistent agent lifecycle manager with SQLite backing."""
def __init__(self, db_path: str) -> None:
# A tick that hasn't touched its DB row in this many seconds is treated
# as a zombie (its worker died without running end_tick). start_tick()
# will overtake such a lock instead of refusing forever. The executor
# bumps updated_at at start and on every tool/inference event, so a live
# tick stays well under this window.
_STALE_TICK_SECONDS = 600
def __init__(self, db_path: str, *, clear_stale_running: bool = False) -> None:
self._db_path = str(db_path)
self._conn = sqlite3.connect(self._db_path, check_same_thread=False)
self._conn.row_factory = sqlite3.Row
@@ -126,7 +137,16 @@ class AgentManager:
except sqlite3.OperationalError:
pass # Column already exists
self._conn.commit()
self._clear_stale_running_state()
# Only the authoritative long-running process (the API server, which
# owns the scheduler) may sweep running→idle on boot. Short-lived CLI
# commands (`jarvis agents list/info/...`) and the SystemBuilder path
# used by `run`/`ask` MUST NOT: they share this DB with a server that
# may be mid-tick, and an unconditional sweep here flips an actively
# running agent back to "idle" — which is exactly why `list` reported
# "idle" while a tick was running elsewhere. Zombies left by a crashed
# worker are recovered lazily by start_tick()'s stale-lock overtake.
if clear_stale_running:
self._clear_stale_running_state()
def _clear_stale_running_state(self) -> None:
"""Reset any agent stuck in ``status='running'`` on startup.
@@ -137,10 +157,10 @@ class AgentManager:
in ``running`` forever. The :meth:`start_tick` guard then rejects
every subsequent run with "Agent is already running".
A freshly-started process holds zero tick locks by definition, so
any persisted ``running`` is a zombie. Sweep it back to ``idle``
and clear the activity string so the UI doesn't show a stale
"Preparing tick..." indicator.
The server boot holds zero tick locks by definition, so any persisted
``running`` is a zombie. Sweep it back to ``idle`` and clear the
activity string so the UI doesn't show a stale "Preparing tick..."
indicator. Call this only from a process that owns tick execution.
"""
cur = self._conn.execute(
"UPDATE managed_agents SET status = 'idle', current_activity = '',"
@@ -167,7 +187,17 @@ class AgentManager:
) -> Dict[str, Any]:
agent_id = uuid.uuid4().hex[:12]
now = time.time()
config_json = json.dumps(config or {})
# Pin the model the executor will actually use into the agent's config
# so the UI reflects what runs. Without this, config has no "model" and
# the executor silently falls back to _AGENT_TICK_DEFAULT_MODEL while
# the Overview shows a stale/default value. Lazy import avoids any
# import-order coupling with the executor module.
from openjarvis.agents.executor import _AGENT_TICK_DEFAULT_MODEL
config = dict(config or {})
if not config.get("model"):
config["model"] = _AGENT_TICK_DEFAULT_MODEL
config_json = json.dumps(config)
self._conn.execute(
"INSERT INTO managed_agents"
" (id, name, agent_type, config_json,"
@@ -258,10 +288,23 @@ class AgentManager:
# ── Tick concurrency guard ────────────────────────────────────
def start_tick(self, agent_id: str) -> None:
"""Mark agent as running. Raises ValueError if already running."""
"""Mark agent as running. Raises ValueError if already running.
A row that has been ``running`` longer than ``_STALE_TICK_SECONDS``
without any update is treated as a zombie left by a dead worker and
overtaken rather than refused — otherwise a crash with no server
around to sweep it would wedge the agent forever.
"""
agent = self.get_agent(agent_id)
if agent and agent["status"] == "running":
raise ValueError(f"Agent {agent_id} is already executing a tick")
age = time.time() - (agent.get("updated_at") or 0)
if age < self._STALE_TICK_SECONDS:
raise ValueError(f"Agent {agent_id} is already executing a tick")
logger.warning(
"Agent %s: overtaking stale tick lock (running, idle for %.0fs)",
agent_id,
age,
)
self._set_status(agent_id, "running")
def end_tick(self, agent_id: str) -> None:
+119 -7
View File
@@ -337,6 +337,67 @@ def _get_scheduler_and_executor(system=None):
return system.agent_scheduler, system.agent_executor, system
def _format_tool_args(args) -> str:
"""Compact one-line rendering of tool arguments for the live trace."""
if not isinstance(args, dict) or not args:
return ""
# Surface the single most meaningful field for common tools; fall back to
# the first couple of key=value pairs. Everything is truncated so a long
# query or URL can't blow out the line.
for key in ("query", "url", "path", "key", "thought", "content"):
val = args.get(key)
if val:
return str(val).replace("\n", " ")[:80]
return ", ".join(f"{k}={str(v)[:40]}" for k, v in list(args.items())[:2])
def _run_tick_with_live_trace(executor, agent_id: str, console: Console) -> None:
"""Run one tick synchronously, streaming progress to *console*.
Mirrors the Deep Research live trace: subscribe to the executor's
EventBus and print each inference / tool call as it happens. The bus
dispatches subscribers inline on the publishing thread, and we run the
tick on this same thread, so callbacks fire in real time. Subscriptions
are always torn down in the ``finally`` so a second invocation in the
same process doesn't double-print.
"""
from openjarvis.core.events import EventType
bus = getattr(executor, "_bus", None)
def on_tool_start(event) -> None:
if event.data.get("agent") != agent_id:
return
tool = event.data.get("tool", "?")
argstr = _format_tool_args(event.data.get("arguments", {}))
suffix = f" [dim italic]{argstr}[/dim italic]" if argstr else ""
console.print(f" [cyan]↳[/cyan] [dim]{tool}[/dim]{suffix}")
def on_tool_end(event) -> None:
if event.data.get("agent") != agent_id:
return
mark = "[green]✓[/green]" if event.data.get("success", True) else "[red]✗[/red]"
console.print(f" {mark} [dim]{event.data.get('latency', 0.0):.1f}s[/dim]")
def on_inference_start(_event) -> None:
console.print(" [dim]· thinking…[/dim]")
subs = [
(EventType.TOOL_CALL_START, on_tool_start),
(EventType.TOOL_CALL_END, on_tool_end),
(EventType.INFERENCE_START, on_inference_start),
]
if bus is not None:
for et, cb in subs:
bus.subscribe(et, cb)
try:
executor.execute_tick(agent_id)
finally:
if bus is not None:
for et, cb in subs:
bus.unsubscribe(et, cb)
@agent.command()
def launch():
"""Interactive agent launcher."""
@@ -354,6 +415,19 @@ def launch():
else:
template = None
name = click.prompt("Agent name")
# An agent with no instruction has nothing to work on — its ticks spin on
# an empty prompt. Require a topic/instruction up front and store it so the
# template's {instruction} placeholder is filled and `run` has real input.
instruction = click.prompt(
"What should this agent focus on? (research topic / instruction)"
).strip()
while not instruction:
click.echo("An instruction is required so the agent knows what to do.")
instruction = click.prompt(
"What should this agent focus on? (research topic / instruction)"
).strip()
schedule_type = click.prompt(
"Schedule type",
type=click.Choice(["cron", "interval", "manual"]),
@@ -372,9 +446,12 @@ def launch():
}
manager = _get_manager()
if template:
# Pass the instruction as an override so create_from_template can
# expand the template's {instruction} placeholder into system_prompt.
agent_data = manager.create_from_template(
template["id"],
name=name,
overrides={"instruction": instruction},
)
agent_config = agent_data.get("config", {})
agent_config.update(config)
@@ -382,6 +459,7 @@ def launch():
agent_data = manager.get_agent(agent_data["id"])
else:
agent_type = click.prompt("Agent type", default="monitor_operative")
config["instruction"] = instruction
agent_data = manager.create_agent(
name=name, agent_type=agent_type, config=config
)
@@ -434,19 +512,48 @@ def run_agent(agent_id):
if agent_data["status"] == "archived":
click.echo(f"Agent {agent_id} is archived and cannot be run", err=True)
raise SystemExit(1)
click.echo(f'Running tick for "{agent_data["name"]}"...')
# Refuse to run an agent with nothing to do: no standing instruction and
# no queued message means every tick would spin on an empty prompt.
instruction = (agent_data.get("config", {}).get("instruction") or "").strip()
pending = manager.get_pending_messages(agent_id)
if not instruction and not pending:
click.echo(
"Agent has no instruction set. Use "
f"'jarvis agents ask {agent_id} <message>' to set one.",
err=True,
)
raise SystemExit(1)
console = Console()
console.print(f'[bold]Running tick for "{agent_data["name"]}"…[/bold]')
_, executor, _ = _get_scheduler_and_executor()
if executor is None:
click.echo("Executor not available", err=True)
raise SystemExit(1)
try:
executor.execute_tick(agent_id)
_run_tick_with_live_trace(executor, agent_id, console)
except Exception as exc:
click.echo(f"Tick failed: {exc}", err=True)
raise SystemExit(1)
updated = manager.get_agent(agent_id)
runs = updated.get("total_runs", 0)
click.echo(f"Tick complete. Status: {updated['status']}, runs: {runs}")
console.print(
f"[green]✓[/green] Tick complete. "
f"Status: {updated['status']}, runs: {runs}"
)
# Print the agent's actual output. summary_memory holds the latest tick's
# findings (or an "ERROR: ..." detail on failure); render it as markdown so
# the headers/bullets the agent produced come through formatted.
findings = (updated.get("summary_memory") or "").strip()
if findings:
from rich.markdown import Markdown
console.print("\n[bold]--- Findings ---[/bold]\n")
console.print(Markdown(findings))
else:
console.print("[dim](No findings recorded for this tick.)[/dim]")
@agent.command("status")
@@ -715,7 +822,8 @@ def ask(agent_id, message, auto_approve):
manager = _get_manager()
agent_id = _resolve_agent_id(manager, agent_id)
manager.send_message(agent_id, message, mode="immediate")
click.echo("Asking agent...")
console = Console()
console.print("[bold]Asking agent…[/bold]")
_, executor, system = _get_scheduler_and_executor()
if executor is None:
click.echo("Executor not available", err=True)
@@ -730,13 +838,17 @@ def ask(agent_id, message, auto_approve):
executor._confirm_callback = (
lambda prompt: click.confirm(f"\n{prompt}", default=False)
)
executor.execute_tick(agent_id)
# Run the tick with a live trace rather than blocking in silence — the
# message we just queued is consumed as this tick's input, so the user
# sees the agent's searches/tool calls instead of an apparent hang.
_run_tick_with_live_trace(executor, agent_id, console)
msgs = manager.list_messages(agent_id)
# list_messages returns newest-first, so responses[0] is this tick's reply.
responses = [m for m in msgs if m["direction"] == "agent_to_user"]
if responses:
click.echo(f"\nAgent: {responses[0]['content']}")
console.print(f"\n[bold cyan]Agent:[/bold cyan] {responses[0]['content']}")
else:
click.echo("\n(No response from agent)")
console.print("\n[dim](No response from agent)[/dim]")
@agent.command("instruct")
+4 -1
View File
@@ -337,7 +337,10 @@ def serve(
am_db = config.agent_manager.db_path or str(
Path("~/.openjarvis/agents.db").expanduser()
)
agent_manager = AgentManager(db_path=am_db)
# The server owns the scheduler and is the authoritative tick
# runner — on boot it holds no locks, so it (and only it) sweeps
# any zombie running→idle left by a previous crash.
agent_manager = AgentManager(db_path=am_db, clear_stale_running=True)
except Exception as exc:
logger.debug("Agent manager init failed: %s", exc)
+2
View File
@@ -975,8 +975,10 @@ class ServerConfig:
default_factory=lambda: [
"http://localhost:3000",
"http://localhost:5173",
"http://localhost:5174",
"http://127.0.0.1:3000",
"http://127.0.0.1:5173",
"http://127.0.0.1:5174",
# Tauri 2 production webview origins.
# macOS / Linux / iOS use the custom scheme; Windows /
# Android use http(s)://tauri.localhost. All three must
+37 -2
View File
@@ -69,6 +69,27 @@ _BROWSER_SUB_TOOLS = {
}
def _resolve_memory_backend(config: Any) -> Any:
"""Instantiate the configured memory backend, or None if unavailable.
Mirrors serve.py's setup so an agent tick that runs through the server
can actually use its memory_* tools. Returns None (so the tools degrade
gracefully) when memory context is disabled or the backend can't load.
"""
if config is None or not getattr(config.agent, "context_from_memory", False):
return None
try:
import openjarvis.tools.storage # noqa: F401
from openjarvis.core.registry import MemoryRegistry
key = config.memory.default_backend
if MemoryRegistry.contains(key):
return MemoryRegistry.create(key, db_path=config.memory.db_path)
except Exception:
logger.debug("Lightweight system: memory backend init failed", exc_info=True)
return None
class _LightweightSystem:
"""Minimal system facade for the executor — avoids rebuilding the
full JarvisSystem (which picks a random model from Ollama)."""
@@ -77,7 +98,12 @@ class _LightweightSystem:
self.engine = engine
self.model = model
self.config = config
self.memory_backend = None
# Wire the configured memory backend so an agent's memory_store /
# memory_retrieve tools work when the tick runs through the server.
# The executor injects system.memory_backend into those tools; this
# facade previously left it None, so they reported "No memory backend
# configured" even though the backend was configured and active.
self.memory_backend = _resolve_memory_backend(config)
def _make_lightweight_system(
@@ -768,7 +794,16 @@ async def _stream_managed_agent(
agent_id = agent_record["id"]
config = agent_record.get("config", {})
model = config.get("model", getattr(engine, "_model", ""))
# Resolve the model: prefer the agent's own config, then the server's
# resolved model (app.state.model — what the engine was booted with),
# and only then the legacy engine._model attr. OllamaEngine takes the
# model per-call and exposes no _model attr, so without the app_state
# fallback this resolved to "" and Ollama 400'd on an empty model.
model = (
config.get("model")
or getattr(app_state, "model", None)
or getattr(engine, "_model", "")
)
system_prompt = config.get("system_prompt")
temperature = config.get("temperature", 0.7)
max_tokens = config.get("max_tokens", 1024)
+2
View File
@@ -189,6 +189,8 @@ def create_app(
else [
"http://localhost:5173",
"http://127.0.0.1:5173",
"http://localhost:5174",
"http://127.0.0.1:5174",
# Tauri 2 production webview origins:
# macOS / Linux / iOS -> tauri://localhost
# Windows / Android -> http://tauri.localhost (default),
+6 -1
View File
@@ -46,7 +46,12 @@ def create_ws_router(event_bus: EventBus) -> Any:
}
for ws, (queue, loop) in list(clients.items()):
agent_filter = getattr(ws, "_agent_filter", None)
event_agent = (event.data or {}).get("agent_id")
# Tick events carry "agent_id"; tool-call events carry "agent".
# Match either so a per-agent subscriber actually receives the
# tool calls that make up its live trace (without this, only
# tick_start/end pass the filter and the trace looks empty).
data = event.data or {}
event_agent = data.get("agent_id") or data.get("agent")
if agent_filter and event_agent != agent_filter:
continue
try:
+7 -2
View File
@@ -146,11 +146,16 @@ class TestSummaryMemory:
assert updated["summary_memory"] == "Key finding: X is Y"
def test_summary_max_length(self, manager):
# Import the cap from the module so this test follows the constant
# rather than hardcoding a number that drifts every time the cap is
# tuned (it was 2000, then 16000 after the truncation fix).
from openjarvis.agents.manager import _SUMMARY_MAX
agent = manager.create_agent(name="test", agent_type="simple")
long_text = "x" * 3000
long_text = "x" * (_SUMMARY_MAX + 1000)
manager.update_summary_memory(agent["id"], long_text)
updated = manager.get_agent(agent["id"])
assert len(updated["summary_memory"]) <= 2000
assert len(updated["summary_memory"]) == _SUMMARY_MAX
class TestConcurrency:
Generated
+2
View File
@@ -4987,6 +4987,7 @@ dependencies = [
{ name = "rich" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
{ name = "tomlkit" },
{ name = "websockets" },
]
[package.optional-dependencies]
@@ -5268,6 +5269,7 @@ requires-dist = [
{ name = "vllm", marker = "extra == 'inference-vllm'", specifier = ">=0.16.0" },
{ name = "wandb", marker = "extra == 'eval-wandb'", specifier = ">=0.17" },
{ name = "wasmtime", marker = "extra == 'sandbox-wasm'", specifier = ">=25" },
{ name = "websockets", specifier = ">=15.0.1" },
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-all'" },
{ name = "zeus-ml", extras = ["apple"], marker = "extra == 'energy-apple'" },
{ name = "zulip", marker = "extra == 'channel-zulip'", specifier = ">=0.9" },