fix(agents): bind built-in tools, live tool-call UI, Markdown streaming (#255)

This commit is contained in:
Andrew Park
2026-04-17 11:01:50 -07:00
committed by GitHub
parent 48c23d87df
commit 28e913f706
8 changed files with 1286 additions and 145 deletions
+113 -31
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight, Wrench, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import { ChevronDown, ChevronRight, Loader2, CheckCircle2, XCircle } from 'lucide-react';
import type { ToolCallInfo } from '../../types';
interface Props {
@@ -7,44 +7,83 @@ interface Props {
}
const statusConfig = {
running: { icon: Loader2, label: 'Running', color: 'var(--color-accent)' },
success: { icon: CheckCircle2, label: 'Done', color: 'var(--color-success)' },
error: { icon: XCircle, label: 'Failed', color: 'var(--color-error)' },
running: { icon: Loader2, color: 'var(--color-accent)' },
success: { icon: CheckCircle2, color: 'var(--color-success)' },
error: { icon: XCircle, color: 'var(--color-error)' },
};
function previewArgs(raw: string): string {
if (!raw) return '';
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === 'object') {
const entries = Object.entries(parsed);
if (entries.length === 0) return '';
const [k, v] = entries[0];
const valStr =
typeof v === 'string' ? v : JSON.stringify(v);
const trimmed = valStr.length > 40 ? `${valStr.slice(0, 40)}` : valStr;
return entries.length === 1 ? `${k}: ${trimmed}` : `${k}: ${trimmed}, …`;
}
} catch {
/* fall through */
}
return raw.length > 60 ? `${raw.slice(0, 60)}` : raw;
}
export function ToolCallCard({ toolCall }: Props) {
const [expanded, setExpanded] = useState(false);
const config = statusConfig[toolCall.status];
const StatusIcon = config.icon;
const preview = previewArgs(toolCall.arguments);
return (
<div
className="rounded-lg text-sm overflow-hidden"
style={{ border: '1px solid var(--color-border)', background: 'var(--color-bg-secondary)' }}
className="rounded-md text-xs overflow-hidden"
style={{
border: '1px solid var(--color-border-subtle, var(--color-border))',
background: 'var(--color-bg-tertiary, var(--color-bg-secondary))',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
}}
>
<button
onClick={() => setExpanded(!expanded)}
className="flex items-center gap-2 w-full px-3 py-2 transition-colors cursor-pointer"
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--color-bg-tertiary)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
className="flex items-center gap-2 w-full px-2.5 py-1.5 cursor-pointer text-left"
style={{ background: 'transparent' }}
>
{expanded ? (
<ChevronDown size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<ChevronDown size={11} style={{ color: 'var(--color-text-tertiary)', flexShrink: 0 }} />
) : (
<ChevronRight size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<ChevronRight size={11} style={{ color: 'var(--color-text-tertiary)', flexShrink: 0 }} />
)}
<Wrench size={14} style={{ color: 'var(--color-text-tertiary)' }} />
<span style={{ color: 'var(--color-text)' }} className="font-medium">
{toolCall.tool}
</span>
<div className="flex-1" />
<StatusIcon
size={14}
style={{ color: config.color }}
size={11}
style={{ color: config.color, flexShrink: 0 }}
className={toolCall.status === 'running' ? 'animate-spin' : ''}
/>
<span
style={{ color: 'var(--color-text)', fontWeight: 500, flexShrink: 0 }}
>
{toolCall.tool}
</span>
{preview && !expanded && (
<span
className="truncate"
style={{ color: 'var(--color-text-tertiary)', fontSize: 10.5 }}
>
{preview}
</span>
)}
<div className="flex-1" />
{toolCall.latency != null && (
<span className="text-[11px] font-mono" style={{ color: 'var(--color-text-tertiary)' }}>
<span
style={{
color: 'var(--color-text-tertiary)',
fontSize: 10,
flexShrink: 0,
}}
>
{toolCall.latency < 1000
? `${Math.round(toolCall.latency)}ms`
: `${(toolCall.latency / 1000).toFixed(1)}s`}
@@ -52,28 +91,63 @@ export function ToolCallCard({ toolCall }: Props) {
)}
</button>
{expanded && (
<div className="px-3 pb-3" style={{ borderTop: '1px solid var(--color-border)' }}>
<div
className="px-2.5 pb-2 pt-0.5"
style={{ borderTop: '1px solid var(--color-border-subtle, var(--color-border))' }}
>
{toolCall.arguments && (
<div className="mt-2">
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Arguments
<div className="mt-1.5">
<div
style={{
color: 'var(--color-text-tertiary)',
fontSize: 9.5,
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 3,
}}
>
args
</div>
<pre
className="text-xs p-2 rounded overflow-x-auto font-mono"
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
className="p-1.5 rounded overflow-auto"
style={{
background: 'var(--color-code-bg, rgba(0,0,0,0.2))',
color: 'var(--color-text-secondary)',
fontSize: 11,
lineHeight: 1.4,
maxHeight: 120,
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
}}
>
{toolCall.arguments}
{formatJson(toolCall.arguments)}
</pre>
</div>
)}
{toolCall.result && (
<div className="mt-2">
<div className="text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-tertiary)' }}>
Result
<div className="mt-1.5">
<div
style={{
color: 'var(--color-text-tertiary)',
fontSize: 9.5,
textTransform: 'uppercase',
letterSpacing: '0.05em',
marginBottom: 3,
}}
>
result
</div>
<pre
className="text-xs p-2 rounded overflow-x-auto font-mono max-h-48"
style={{ background: 'var(--color-code-bg)', color: 'var(--color-text-secondary)' }}
className="p-1.5 rounded overflow-auto"
style={{
background: 'var(--color-code-bg, rgba(0,0,0,0.2))',
color: 'var(--color-text-secondary)',
fontSize: 11,
lineHeight: 1.4,
maxHeight: 180,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{toolCall.result}
</pre>
@@ -84,3 +158,11 @@ export function ToolCallCard({ toolCall }: Props) {
</div>
);
}
function formatJson(raw: string): string {
try {
return JSON.stringify(JSON.parse(raw), null, 2);
} catch {
return raw;
}
}
+72 -5
View File
@@ -344,6 +344,14 @@ export interface AgentTemplate {
[key: string]: unknown;
}
export interface PersistedToolCall {
tool: string;
arguments: string;
result?: string;
success?: boolean;
latency?: number;
}
export interface AgentMessage {
id: string;
agent_id: string;
@@ -352,6 +360,7 @@ export interface AgentMessage {
mode: 'immediate' | 'queued';
status: 'pending' | 'delivered' | 'responded';
created_at: number;
tool_calls?: PersistedToolCall[] | null;
}
export async function fetchManagedAgents(): Promise<ManagedAgent[]> {
@@ -570,6 +579,18 @@ export async function fetchAgentState(agentId: string): Promise<{
return res.json();
}
export interface AgentToolCallStart {
tool: string;
arguments: string;
}
export interface AgentToolCallEnd {
tool: string;
success: boolean;
latency: number;
result?: string;
}
export async function sendAgentMessage(
agentId: string,
content: string,
@@ -577,6 +598,8 @@ export async function sendAgentMessage(
callbacks?: {
onProgress?: (label: string) => void;
onContentDelta?: (delta: string, fullContent: string) => void;
onToolCallStart?: (info: AgentToolCallStart) => void;
onToolCallEnd?: (info: AgentToolCallEnd) => void;
onDone?: (fullContent: string, usage?: Record<string, number>, telemetry?: Record<string, unknown>) => void;
},
): Promise<AgentMessage> {
@@ -596,6 +619,7 @@ export async function sendAgentMessage(
let buffer = '';
let lastUsage: Record<string, number> | undefined;
let lastTelemetry: Record<string, unknown> | undefined;
let currentEvent: string | undefined;
try {
while (true) {
const { done, value } = await reader.read();
@@ -604,10 +628,52 @@ export async function sendAgentMessage(
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue;
if (line.startsWith('event: ')) {
currentEvent = line.slice(7).trim();
continue;
}
if (!line.startsWith('data: ')) {
if (line.trim() === '') currentEvent = undefined;
continue;
}
const data = line.slice(6);
if (data === '[DONE]') {
currentEvent = undefined;
continue;
}
const evName = currentEvent;
currentEvent = undefined;
if (evName === 'tool_call_start') {
try {
const parsed = JSON.parse(data);
callbacks?.onToolCallStart?.({
tool: parsed.tool,
arguments: parsed.arguments ?? '',
});
} catch {
/* skip */
}
continue;
}
if (evName === 'tool_call_end') {
try {
const parsed = JSON.parse(data);
callbacks?.onToolCallEnd?.({
tool: parsed.tool,
success: !!parsed.success,
latency: typeof parsed.latency === 'number' ? parsed.latency : 0,
result: parsed.result,
});
} catch {
/* skip */
}
continue;
}
try {
const chunk = JSON.parse(line.slice(6));
// Check for tool progress events
const chunk = JSON.parse(data);
// Deep-research branch still uses tool_progress in a data chunk
const toolProgress = chunk.choices?.[0]?.tool_progress;
if (toolProgress) {
callbacks?.onProgress?.(toolProgress);
@@ -617,10 +683,11 @@ export async function sendAgentMessage(
fullContent += delta;
callbacks?.onContentDelta?.(delta, fullContent);
}
// Capture usage + telemetry from final chunk
if (chunk.usage) lastUsage = chunk.usage;
if (chunk.telemetry) lastTelemetry = chunk.telemetry;
} catch { /* skip malformed chunks */ }
} catch {
/* skip malformed chunks */
}
}
}
} catch { /* stream ended */ }
+503 -40
View File
@@ -1,5 +1,6 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { toast } from 'sonner';
import { useAppStore } from '../lib/store';
import {
@@ -63,6 +64,8 @@ import {
import { SOURCE_CATALOG } from '../types/connectors';
import type { ConnectRequest } from '../types/connectors';
import { listConnectors, connectSource } from '../lib/connectors-api';
import type { ToolCallInfo } from '../types';
import { ToolCallCard } from '../components/Chat/ToolCallCard';
// ---------------------------------------------------------------------------
// Status helpers
@@ -284,6 +287,352 @@ function Tooltip({ text }: { text: string }) {
return <span className="inline-block ml-1 cursor-help" style={{ color: 'var(--color-text-tertiary)', fontSize: 10 }} title={text}>(?)</span>;
}
// ---------------------------------------------------------------------------
// ToolsPicker — dev-inventory style tool selector used by the launch wizard
// ---------------------------------------------------------------------------
const TOOL_CATEGORY_ORDER = [
'filesystem',
'system',
'code',
'vcs',
'storage',
'memory',
'knowledge',
'knowledge_graph',
'search',
'network',
'browser',
'database',
'data',
'math',
'reasoning',
'inference',
'media',
'audio',
'skill',
'channel',
'communication',
'other',
];
const TOOL_CATEGORY_LABELS: Record<string, string> = {
filesystem: 'filesystem',
system: 'shell & exec',
code: 'code & repl',
vcs: 'git',
storage: 'memory · storage',
memory: 'memory',
knowledge: 'knowledge',
knowledge_graph: 'knowledge graph',
search: 'search',
network: 'network',
browser: 'browser',
database: 'database',
data: 'data',
math: 'math',
reasoning: 'reasoning',
inference: 'inference',
media: 'media',
audio: 'audio',
skill: 'skills',
channel: 'channel primitives',
communication: 'channels',
other: 'other',
};
function ToolsPicker({
tools,
selected,
onChange,
}: {
tools: ToolInfo[];
selected: string[];
onChange: (next: string[]) => void;
}) {
const [hovered, setHovered] = useState<ToolInfo | null>(null);
const [pulseKey, setPulseKey] = useState(0);
// Channels (source === 'channel') live in ChannelRegistry and aren't
// directly callable by the LLM — the agent talks to them through the
// `channel_send` tool. Showing them in the tools picker is misleading,
// so filter them out; channel bindings are configured separately.
const tollableTools = tools.filter((t) => t.source !== 'channel');
// Group by category, respecting the preferred order then alphabetical.
const grouped = (() => {
const buckets: Record<string, ToolInfo[]> = {};
for (const t of tollableTools) {
const cat = TOOL_CATEGORY_ORDER.includes(t.category) ? t.category : 'other';
(buckets[cat] ||= []).push(t);
}
for (const cat of Object.keys(buckets)) {
buckets[cat].sort((a, b) => a.name.localeCompare(b.name));
}
return TOOL_CATEGORY_ORDER
.filter((cat) => buckets[cat]?.length)
.map((cat) => ({ category: cat, items: buckets[cat] }));
})();
const configurable = tollableTools.filter((t) => t.configured).map((t) => t.name);
const allSelected =
configurable.length > 0 && configurable.every((n) => selected.includes(n));
const toggle = (name: string) => {
const next = selected.includes(name)
? selected.filter((t) => t !== name)
: [...selected, name];
onChange(next);
setPulseKey((k) => k + 1);
};
const hint = hovered
? hovered.configured
? hovered.description || hovered.name
: `Needs ${hovered.credential_keys.join(', ') || 'credentials'}`
: 'hover a tool for details';
return (
<div>
<div className="flex items-baseline justify-between mb-1">
<label
className="block text-[13px] font-medium"
style={{ color: 'var(--color-text-secondary)' }}
>
Tools
</label>
<div className="flex items-center gap-2">
<span
key={pulseKey}
className="tools-count"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 10.5,
color: 'var(--color-text-tertiary)',
}}
>
<span style={{ color: 'var(--color-accent)' }}>
{selected.length}
</span>
<span style={{ opacity: 0.5 }}> / {tollableTools.length}</span>
</span>
<span style={{ color: 'var(--color-text-tertiary)', opacity: 0.3 }}>·</span>
<button
type="button"
onClick={() => onChange(allSelected ? [] : configurable)}
disabled={tools.length === 0}
className="transition-colors"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 10,
color: 'var(--color-text-tertiary)',
background: 'none',
border: 'none',
padding: 0,
cursor: tools.length === 0 ? 'default' : 'pointer',
textDecoration: 'underline',
textUnderlineOffset: 2,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = 'var(--color-text)')
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = 'var(--color-text-tertiary)')
}
>
{allSelected ? 'none' : 'all'}
</button>
</div>
</div>
<p
className="text-[10.5px] mb-2"
style={{ color: 'var(--color-text-tertiary)' }}
>
What the agent is allowed to call. An empty selection makes a
chat-only agent.
</p>
{tools.length === 0 ? (
<div
className="px-3 py-2 rounded-lg text-xs"
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
color: 'var(--color-text-tertiary)',
}}
>
Loading available tools
</div>
) : (
<div
className="rounded-lg overflow-hidden"
style={{
background: 'var(--color-bg-secondary)',
border: '1px solid var(--color-border)',
}}
onMouseLeave={() => setHovered(null)}
>
<div
className="px-2.5 py-2 overflow-y-auto"
style={{ maxHeight: 200 }}
>
{grouped.map(({ category, items }, idx) => (
<div key={category} style={{ marginTop: idx === 0 ? 0 : 10 }}>
<div
className="flex items-center gap-1.5 mb-1.5"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 9.5,
color: 'var(--color-text-tertiary)',
textTransform: 'uppercase',
letterSpacing: '0.1em',
}}
>
<span style={{ opacity: 0.5 }}></span>
<span>{TOOL_CATEGORY_LABELS[category] || category}</span>
<span
className="flex-1"
style={{
borderBottom: '1px dashed var(--color-border)',
marginBottom: 3,
opacity: 0.5,
}}
/>
</div>
<div className="flex flex-wrap gap-1">
{items.map((tool) => {
const isSelected = selected.includes(tool.name);
const disabled = !tool.configured;
return (
<button
key={tool.name}
type="button"
disabled={disabled}
onClick={() => toggle(tool.name)}
onMouseEnter={() => setHovered(tool)}
onFocus={() => setHovered(tool)}
className="tool-chip"
style={{
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 11,
lineHeight: 1.2,
padding: '3px 7px 3px 5px',
borderRadius: 4,
display: 'inline-flex',
alignItems: 'center',
gap: 5,
background: isSelected
? 'color-mix(in srgb, var(--color-accent) 14%, transparent)'
: 'var(--color-bg)',
color: disabled
? 'var(--color-text-tertiary)'
: isSelected
? 'var(--color-accent)'
: 'var(--color-text-secondary)',
border: disabled
? '1px dashed var(--color-border)'
: `1px solid ${isSelected ? 'var(--color-accent)' : 'var(--color-border)'}`,
boxShadow: isSelected
? 'inset 0 0 0 1px color-mix(in srgb, var(--color-accent) 30%, transparent)'
: 'none',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.55 : 1,
transition:
'background 120ms, color 120ms, border-color 120ms, transform 80ms',
}}
onMouseDown={(e) =>
!disabled && (e.currentTarget.style.transform = 'scale(0.97)')
}
onMouseUp={(e) =>
(e.currentTarget.style.transform = 'scale(1)')
}
>
<span
style={{
opacity: isSelected ? 1 : 0.5,
color: disabled
? 'var(--color-text-tertiary)'
: isSelected
? 'var(--color-accent)'
: 'var(--color-text-tertiary)',
fontSize: 10.5,
}}
>
{disabled ? '' : isSelected ? '▣' : '□'}
</span>
<span>{tool.name}</span>
</button>
);
})}
</div>
</div>
))}
</div>
{/* Live description strip */}
<div
className="flex items-center gap-2 px-2.5 py-1.5"
style={{
borderTop: '1px solid var(--color-border)',
background: 'var(--color-bg)',
fontFamily:
'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace',
fontSize: 10.5,
color: 'var(--color-text-tertiary)',
minHeight: 26,
}}
>
<span
style={{
color: hovered
? hovered.configured
? 'var(--color-accent)'
: '#f59e0b'
: 'var(--color-text-tertiary)',
opacity: hovered ? 1 : 0.5,
}}
>
{hovered ? (hovered.configured ? '▸' : '!') : '·'}
</span>
{hovered && (
<span
style={{
color: 'var(--color-text)',
fontWeight: 500,
}}
>
{hovered.name}
</span>
)}
<span
className="truncate"
style={{
flex: 1,
color: 'var(--color-text-tertiary)',
}}
>
{hovered ? `${hint}` : hint}
</span>
</div>
</div>
)}
<style>{`
@keyframes tools-count-pulse {
0% { transform: scale(1); }
40% { transform: scale(1.18); }
100% { transform: scale(1); }
}
.tools-count {
display: inline-block;
animation: tools-count-pulse 220ms ease-out;
}
`}</style>
</div>
);
}
function LaunchWizard({
templates,
onClose,
@@ -318,6 +667,7 @@ function LaunchWizard({
});
const [launching, setLaunching] = useState(false);
const [recommendedModel, setRecommendedModel] = useState('');
const [availableTools, setAvailableTools] = useState<ToolInfo[]>([]);
const models = useAppStore((s) => s.models);
useEffect(() => {
@@ -327,6 +677,9 @@ function LaunchWizard({
setWizard((w) => ({ ...w, model: r.model }));
}
}).catch(() => {});
fetchAvailableTools().then((tools) => {
setAvailableTools(tools);
}).catch(() => {});
}, []);
function selectTemplate(tpl: AgentTemplate | null) {
@@ -524,6 +877,15 @@ function LaunchWizard({
)}
</div>
{/* Tools picker */}
<ToolsPicker
tools={availableTools}
selected={wizard.selectedTools}
onChange={(next) =>
setWizard((w) => ({ ...w, selectedTools: next }))
}
/>
{/* Model + Schedule row */}
<div className="grid grid-cols-2 gap-3">
<div>
@@ -1190,6 +1552,7 @@ type InteractMessage = AgentMessage & {
_toolCalls?: number;
_usage?: Record<string, number>;
_telemetry?: Record<string, unknown>;
_toolCallDetails?: ToolCallInfo[];
};
function AgentResponseFooter({
@@ -1203,7 +1566,8 @@ function AgentResponseFooter({
const u = msg._usage;
const t = msg._telemetry as Record<string, unknown> | undefined;
const elapsed = msg._elapsed;
const toolCalls = msg._toolCalls || 0;
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[] = [];
@@ -1225,7 +1589,15 @@ function AgentResponseFooter({
if (u.prompt_tokens) tokenParts.push(`${u.prompt_tokens} prompt`);
if (tokenParts.length) rows.push({ label: 'Tokens', value: tokenParts.join(' · ') });
}
if (toolCalls > 0) rows.push({ label: 'Tool calls', value: `${toolCalls}` });
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` });
@@ -1297,12 +1669,19 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
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 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);
// Keep a ref of local metadata so polling doesn't overwrite it
const localMetaRef = useRef<Map<string, {
@@ -1310,6 +1689,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
_toolCalls?: number;
_usage?: Record<string, number>;
_telemetry?: Record<string, unknown>;
_toolCallDetails?: ToolCallInfo[];
}>>(new Map());
const loadData = useCallback(async () => {
@@ -1318,10 +1698,24 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
fetchAgentMessages(agentId),
fetchManagedAgent(agentId),
]);
// Merge server messages with locally-stored metadata
// 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) || '');
return meta ? { ...m, ...meta } : m;
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;
}
return base;
});
setMessages(merged);
setLiveStatus(agent.status);
@@ -1360,18 +1754,30 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
};
}, []);
// Scroll to bottom only on initial load, not on every poll update.
// 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: 'smooth' });
bottomRef.current?.scrollIntoView({ behavior: 'auto' });
hasScrolled.current = true;
isNearBottomRef.current = true;
}
}, [messages]);
// Scroll to bottom when streaming content updates
// 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) {
if (streamingContent && isNearBottomRef.current) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [streamingContent]);
@@ -1397,6 +1803,13 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
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();
@@ -1408,6 +1821,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
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) => {
@@ -1415,6 +1829,30 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
toolCount++;
},
onContentDelta: (_delta, full) => setStreamingContent(full),
onToolCallStart: ({ tool, arguments: args }) => {
toolCount++;
const tc: ToolCallInfo = {
id: `tc-${Date.now()}-${collectedToolCalls.length}`,
tool,
arguments: 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;
}
setStreamingToolCalls([...collectedToolCalls]);
setProgressLabel('');
},
onDone: (_content, usage, telemetry) => {
setStreamingContent('');
responseUsage = usage;
@@ -1429,6 +1867,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
_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);
@@ -1449,6 +1888,7 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
} finally {
setWaitingForResponse(false);
setStreamingContent('');
setStreamingToolCalls([]);
setProgressLabel('');
if (timerRef.current) {
clearInterval(timerRef.current);
@@ -1466,45 +1906,58 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
return (
<div className="flex flex-col" style={{ minHeight: 320 }}>
<div className="flex-1 overflow-y-auto space-y-3 pb-4" style={{ maxHeight: 'calc(100vh - 400px)' }}>
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto space-y-3 pb-4"
style={{ maxHeight: 'calc(100vh - 400px)' }}
>
{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.
</div>
)}
{displayMessages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.direction === 'user_to_agent' ? 'justify-end' : 'justify-start'}`}
>
<div
className="max-w-[75%] 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' ? '#fff' : 'var(--color-text)',
border: msg.direction === 'agent_to_user' ? '1px solid var(--color-border)' : 'none',
}}
>
{msg.direction === 'agent_to_user' ? (
<div className="prose prose-sm prose-invert max-w-none"><ReactMarkdown>{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 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} />
))}
</div>
)}
{/* Message bubble */}
<div className={`flex ${msg.direction === 'user_to_agent' ? 'justify-end' : 'justify-start'}`}>
<div
className="max-w-[75%] 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' ? '#fff' : 'var(--color-text)',
border: msg.direction === 'agent_to_user' ? '1px solid var(--color-border)' : 'none',
}}
>
{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 yet */}
{(waitingForResponse || sending) && !streamingContent && (
{/* 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"
@@ -1523,7 +1976,15 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
</div>
</div>
)}
{/* Streaming content bubble — real-time response as it arrives */}
{/* 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
@@ -1540,7 +2001,9 @@ function InteractTab({ agentId, agentStatus }: { agentId: string; agentStatus: s
{progressLabel}
</div>
)}
<p className="whitespace-pre-wrap">{streamingContent}</p>
<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>
+32 -5
View File
@@ -114,6 +114,8 @@ class AgentManager:
"ALTER TABLE managed_agents ADD COLUMN current_activity TEXT DEFAULT ''",
"ALTER TABLE managed_agents ADD COLUMN input_tokens INTEGER DEFAULT 0",
"ALTER TABLE managed_agents ADD COLUMN output_tokens INTEGER DEFAULT 0",
# JSON-encoded array of {tool, arguments, result, success, latency}
"ALTER TABLE agent_messages ADD COLUMN tool_calls TEXT",
]
for migration in _MIGRATIONS:
try:
@@ -521,15 +523,28 @@ class AgentManager:
"created_at": now,
}
def store_agent_response(self, agent_id: str, content: str) -> dict:
"""Store an agent-to-user response message."""
def store_agent_response(
self,
agent_id: str,
content: str,
tool_calls: Optional[list] = None,
) -> dict:
"""Store an agent-to-user response message.
``tool_calls`` is an optional list of ``{tool, arguments, result,
success, latency}`` dicts captured during the turn. They are stored
as JSON alongside the message so the UI can replay them after a
page reload.
"""
msg_id = uuid4().hex[:16]
now = time.time()
tool_calls_json = json.dumps(tool_calls) if tool_calls else None
self._conn.execute(
"INSERT INTO agent_messages"
" (id, agent_id, direction, content, mode, status, created_at)"
" VALUES (?, ?, 'agent_to_user', ?, 'immediate', 'delivered', ?)",
(msg_id, agent_id, content, now),
" (id, agent_id, direction, content, mode, status, created_at,"
" tool_calls)"
" VALUES (?, ?, 'agent_to_user', ?, 'immediate', 'delivered', ?, ?)",
(msg_id, agent_id, content, now, tool_calls_json),
)
self._conn.commit()
return {
@@ -540,6 +555,7 @@ class AgentManager:
"mode": "immediate",
"status": "delivered",
"created_at": now,
"tool_calls": tool_calls or None,
}
def list_messages(self, agent_id: str, limit: int = 50) -> list[dict]:
@@ -588,6 +604,16 @@ class AgentManager:
@staticmethod
def _row_to_message(row: sqlite3.Row) -> dict:
tool_calls = None
try:
raw = row["tool_calls"]
except (IndexError, KeyError):
raw = None
if raw:
try:
tool_calls = json.loads(raw)
except (json.JSONDecodeError, TypeError):
tool_calls = None
return {
"id": row["id"],
"agent_id": row["agent_id"],
@@ -596,6 +622,7 @@ class AgentManager:
"mode": row["mode"],
"status": row["status"],
"created_at": row["created_at"],
"tool_calls": tool_calls,
}
# ── Learning log ──────────────────────────────────────────
+143
View File
@@ -18,6 +18,7 @@ from openjarvis.engine._base import (
estimate_prompt_tokens,
messages_to_dicts,
)
from openjarvis.engine._stubs import StreamChunk
logger = logging.getLogger(__name__)
@@ -224,6 +225,148 @@ class OllamaEngine(InferenceEngine):
f"Ollama not reachable at {self._host}"
) from exc
async def stream_full(
self,
messages: Sequence[Message],
*,
model: str,
temperature: float = 0.7,
max_tokens: int = 1024,
**kwargs: Any,
) -> AsyncIterator[StreamChunk]:
"""Yield ``StreamChunk``s including tool_calls.
Unlike the default ``stream_full`` in the base class (which wraps
``stream()`` and drops tools), this posts to ``/api/chat`` with
``tools`` from kwargs and parses tool_calls out of the streamed
response. Falls back to a tools-less retry on 400 (mirrors
``generate()``'s behaviour for models that don't support tools).
"""
msg_dicts = messages_to_dicts(messages)
for md in msg_dicts:
for tc in md.get("tool_calls", []):
fn = tc.get("function", {})
args = fn.get("arguments")
if isinstance(args, str):
try:
fn["arguments"] = json.loads(args)
except (json.JSONDecodeError, TypeError):
pass
payload: Dict[str, Any] = {
"model": model,
"messages": msg_dicts,
"stream": True,
"options": {
"temperature": temperature,
"num_predict": max_tokens,
"num_ctx": kwargs.get("num_ctx", 8192),
},
}
if "think" not in kwargs:
payload["think"] = False
elif kwargs["think"] is not None:
payload["think"] = kwargs["think"]
tools = kwargs.get("tools")
if tools:
payload["tools"] = tools
async for chunk in self._run_stream(
payload, messages, retry_without_tools=bool(tools)
):
yield chunk
async def _run_stream(
self,
payload: Dict[str, Any],
messages: Sequence[Message],
*,
retry_without_tools: bool,
) -> AsyncIterator[StreamChunk]:
"""Execute the streaming request and yield parsed StreamChunks."""
try:
with self._client.stream("POST", "/api/chat", json=payload) as resp:
if resp.status_code == 400 and retry_without_tools:
# Model doesn't support tools — retry without them.
payload.pop("tools", None)
async for c in self._run_stream(
payload, messages, retry_without_tools=False
):
yield c
return
resp.raise_for_status()
finish_reason: str | None = None
for line in resp.iter_lines():
if not line.strip():
continue
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
message = chunk.get("message", {}) or {}
content = message.get("content", "")
raw_tool_calls = message.get("tool_calls") or []
if content:
yield StreamChunk(content=content)
if raw_tool_calls:
# Ollama emits fully-formed tool_calls in a single
# chunk (not fragmented). Convert to the
# OpenAI-delta fragment shape that agent_manager_routes
# expects in _merge_tool_call_fragments.
fragments: List[Dict[str, Any]] = []
for i, tc in enumerate(raw_tool_calls):
fn = tc.get("function", {}) or {}
raw_args = fn.get("arguments", "{}")
args_str = (
json.dumps(raw_args)
if isinstance(raw_args, dict)
else str(raw_args)
)
fragments.append(
{
"index": i,
"id": tc.get("id", f"call_{i}"),
"type": "function",
"function": {
"name": fn.get("name", ""),
"arguments": args_str,
},
}
)
yield StreamChunk(tool_calls=fragments)
finish_reason = "tool_calls"
if chunk.get("done", False):
reported_prompt = chunk.get("prompt_eval_count", 0)
est_prompt = estimate_prompt_tokens(messages)
full_prompt = max(reported_prompt, est_prompt)
evaluated = (
reported_prompt if reported_prompt > 0 else full_prompt
)
comp = chunk.get("eval_count", 0)
self._last_stream_usage = {
"prompt_tokens": full_prompt,
"prompt_tokens_evaluated": evaluated,
"completion_tokens": comp,
"total_tokens": full_prompt + comp,
}
if finish_reason is None:
finish_reason = chunk.get("done_reason") or "stop"
yield StreamChunk(
finish_reason=finish_reason,
usage=dict(self._last_stream_usage),
)
break
except (httpx.ConnectError, httpx.TimeoutException) as exc:
raise EngineConnectionError(
f"Ollama not reachable at {self._host}"
) from exc
def list_models(self) -> List[str]:
try:
resp = self._client.get("/api/tags")
+320 -64
View File
@@ -234,34 +234,35 @@ def build_tools_list() -> List[Dict[str, Any]]:
items: List[Dict[str, Any]] = []
try:
for name, tool_cls in ToolRegistry.items():
if name in _BROWSER_SUB_TOOLS:
continue
spec = getattr(tool_cls, "spec", None)
if callable(spec):
try:
spec = spec(tool_cls)
except Exception:
spec = None
cred_keys = TOOL_CREDENTIALS.get(name, [])
items.append(
{
"name": name,
"description": spec.description if spec else "",
"category": spec.category if spec else "",
"source": "tool",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys
else True
),
}
)
except Exception:
pass
for name, tool_cls in ToolRegistry.items():
if name in _BROWSER_SUB_TOOLS:
continue
# `spec` is an instance @property on BaseTool subclasses, so
# we have to instantiate the tool to read it. The earlier
# implementation used getattr(tool_cls, 'spec') which returns
# the property descriptor and crashed on spec.description,
# silently dropping every real tool from the picker.
try:
spec = tool_cls().spec
except Exception as exc:
logger.debug("Could not instantiate tool %s: %s", name, exc)
spec = None
cred_keys = TOOL_CREDENTIALS.get(name, [])
items.append(
{
"name": name,
"description": spec.description if spec else "",
"category": spec.category if spec else "",
"source": "tool",
"requires_credentials": len(cred_keys) > 0,
"credential_keys": cred_keys,
"configured": (
all(bool(os.environ.get(k)) for k in cred_keys)
if cred_keys
else True
),
}
)
try:
if any(ToolRegistry.contains(n) for n in _BROWSER_SUB_TOOLS):
@@ -308,6 +309,95 @@ def build_tools_list() -> List[Dict[str, Any]]:
return items
def _resolve_tool_specs(
tool_config: Any,
) -> List[Dict[str, Any]]:
"""Convert a template's ``tools`` config into OpenAI-format function specs.
The template TOML stores tools as a list of string names (e.g.
``["file_read", "shell_exec"]``). Engines expect OpenAI-shaped dicts:
``{"type": "function", "function": {"name, description, parameters"}}``.
Special handling:
* Dict entries pass through as-is (allows advanced configs to
supply fully-formed specs).
* ``browser`` is a synthetic display-only meta-tool that expands
to the 6 real browser sub-tools (browser_navigate, click, ).
* Channel names (``slack``, ``gmail``, ) come from the
``ChannelRegistry`` and are not directly callable by the LLM
they're destinations for ``channel_send``. Silently skip them.
* Unknown tool names are dropped with a warning.
"""
if not tool_config:
return []
from openjarvis.core.registry import ChannelRegistry, ToolRegistry
_ensure_registries_populated()
def _spec_dict_for(name: str) -> Optional[Dict[str, Any]]:
try:
spec = ToolRegistry.get(name)().spec
except Exception as exc:
logger.warning(
"Could not build spec for tool '%s' (%s) — dropping",
name,
exc,
)
return None
return {
"type": "function",
"function": {
"name": spec.name,
"description": spec.description,
"parameters": spec.parameters,
},
}
resolved: List[Dict[str, Any]] = []
seen: set = set()
for entry in tool_config:
if isinstance(entry, dict):
resolved.append(entry)
continue
if not isinstance(entry, str):
continue
# Expand the synthetic "browser" meta-tool into its sub-tools.
if entry == "browser":
for sub in _BROWSER_SUB_TOOLS:
if sub in seen or not ToolRegistry.contains(sub):
continue
spec_dict = _spec_dict_for(sub)
if spec_dict:
resolved.append(spec_dict)
seen.add(sub)
continue
# Channels (slack, gmail, …) live in ChannelRegistry and aren't
# callable by the LLM. Skip silently — the agent talks to them
# through the `channel_send` tool with a `channel` argument.
if ChannelRegistry.contains(entry):
continue
if not ToolRegistry.contains(entry):
logger.warning(
"Tool '%s' referenced in agent config but not in ToolRegistry",
entry,
)
continue
if entry in seen:
continue
spec_dict = _spec_dict_for(entry)
if spec_dict:
resolved.append(spec_dict)
seen.add(entry)
return resolved
def _build_deep_research_tools(
engine: Any,
model: str,
@@ -623,6 +713,8 @@ async def _stream_managed_agent(
tools=dr_tools,
max_turns=int(config.get("max_turns", 8)),
temperature=float(config.get("temperature", 0.3)),
interactive=True,
confirm_callback=lambda _prompt: True,
)
# Wrap the executor to capture tool calls
@@ -630,14 +722,15 @@ async def _stream_managed_agent(
def _tracked_execute(tc):
tool_name = tc.name
args_str = tc.arguments[:80] if tc.arguments else ""
full_args = tc.arguments or ""
args_str = full_args[:80]
# Log tool call start
try:
manager.add_learning_log(
agent_id,
"tool_call",
f"Calling {tool_name}: {args_str}",
{"tool": tool_name, "arguments": tc.arguments or ""},
{"tool": tool_name, "arguments": full_args},
)
except Exception as _tc_exc:
logger.warning("Log tool_call failed: %s", _tc_exc)
@@ -647,9 +740,12 @@ async def _stream_managed_agent(
"type": "tool_start",
"tool": tool_name,
"args": args_str,
"full_args": full_args,
}
)
_tool_start = _dr_time.monotonic()
result = original_execute(tc)
_tool_latency_ms = (_dr_time.monotonic() - _tool_start) * 1000
# Log tool result
try:
@@ -672,7 +768,10 @@ async def _stream_managed_agent(
{
"type": "tool_end",
"tool": tool_name,
"arguments": full_args,
"success": result.success,
"latency": _tool_latency_ms,
"result": result.content or "",
}
)
return result
@@ -721,6 +820,12 @@ async def _stream_managed_agent(
thread = threading.Thread(target=_run_agent, daemon=True)
thread.start()
# Collect tool calls from deep-research so we can persist them
# alongside the final response (and the UI can re-render them
# after a page reload).
dr_tool_calls: List[Dict[str, Any]] = []
_pending_dr_starts: Dict[str, str] = {}
# Stream progress events and final content
while True:
try:
@@ -733,6 +838,16 @@ async def _stream_managed_agent(
if event["type"] == "tool_start":
tool = event["tool"]
args = event.get("args", "")
full_args = event.get("full_args", "")
_pending_dr_starts[tool] = full_args
# Structured event so the UI can render a tool_call
# message card (same shape as the non-DR path).
_start_payload = json.dumps(
{"tool": tool, "arguments": full_args}
)
yield f"event: tool_call_start\ndata: {_start_payload}\n\n"
# Keep the human-readable progress label for the
# thinking-bubble fallback.
label = _tool_progress_label(tool, args)
progress_data = {
"id": chunk_id,
@@ -750,7 +865,28 @@ async def _stream_managed_agent(
yield f"data: {json.dumps(progress_data)}\n\n"
elif event["type"] == "tool_end":
pass # Could emit completion signal
tool = event["tool"]
dr_tool_calls.append(
{
"tool": tool,
"arguments": event.get(
"arguments", _pending_dr_starts.get(tool, "")
),
"result": event.get("result", ""),
"success": bool(event.get("success", False)),
"latency": float(event.get("latency", 0.0)),
}
)
_pending_dr_starts.pop(tool, None)
_end_payload = json.dumps(
{
"tool": tool,
"success": bool(event.get("success", False)),
"latency": float(event.get("latency", 0.0)),
"result": event.get("result", ""),
}
)
yield f"event: tool_call_end\ndata: {_end_payload}\n\n"
elif event["type"] in ("done", "error"):
content = event["content"]
@@ -798,10 +934,12 @@ async def _stream_managed_agent(
yield f"data: {json.dumps(finish_data)}\n\n"
yield "data: [DONE]\n\n"
# Persist
# Persist (with the tool calls captured during
# the deep-research turn so they survive reload).
manager.store_agent_response(
agent_id,
content,
tool_calls=dr_tool_calls or None,
)
break
@@ -814,10 +952,13 @@ async def _stream_managed_agent(
},
)
# Build extra kwargs for stream_full (e.g. tools from config)
# Build extra kwargs for stream_full (e.g. tools from config).
# Template stores tool names as strings; convert to OpenAI function specs
# so the engine can actually bind them to the model.
stream_kwargs: Dict[str, Any] = {}
if config.get("tools"):
stream_kwargs["tools"] = config["tools"]
resolved_tools = _resolve_tool_specs(config.get("tools"))
if resolved_tools:
stream_kwargs["tools"] = resolved_tools
# Discover MCP tools and merge into stream_kwargs
mcp_adapters: Dict[str, Any] = {}
@@ -836,13 +977,69 @@ async def _stream_managed_agent(
"Failed to get MCP tools for streaming: %s", exc, exc_info=True
)
# Shared state between the generator and the BackgroundTask that
# runs after the SSE response completes (or the client disconnects
# mid-stream). Starlette guarantees the BackgroundTask runs in both
# cases, so we use it as the single, reliable persistence point.
persist_state: Dict[str, Any] = {
"content": "",
"tool_calls": [],
"persisted": False,
}
def _persist_final() -> None:
if persist_state["persisted"]:
return
persist_state["persisted"] = True
if persist_state["content"]:
try:
manager.store_agent_response(
agent_id,
persist_state["content"],
tool_calls=persist_state["tool_calls"] or None,
)
except Exception as store_exc:
logger.error(
"Failed to store agent response: %s",
store_exc,
exc_info=True,
)
try:
content = persist_state["content"] or ""
manager.add_learning_log(
agent_id,
"query_complete",
f"Response: {len(content)} chars, "
f"{len(persist_state['tool_calls'])} tool calls",
{
"response_length": len(content),
"tool_calls": len(persist_state["tool_calls"]),
},
)
except Exception as _qc_exc:
logger.warning("Log query_complete failed: %s", _qc_exc)
async def generate():
"""Async generator yielding SSE-formatted chunks with real token streaming."""
collected_content = ""
collected_tool_calls: List[Dict[str, Any]] = []
messages_for_llm = list(llm_messages)
turns = 0
import time as _lgtime
_query_start_ts = _lgtime.time()
try:
manager.add_learning_log(
agent_id,
"query_start",
f"Query: {user_content[:100]}",
{"full_query": user_content},
)
except Exception as _qs_exc:
logger.warning("Log query_start failed: %s", _qs_exc)
while turns < max_turns:
turns += 1
turn_content = ""
@@ -860,6 +1057,9 @@ async def _stream_managed_agent(
# Stream content tokens immediately to the client
if chunk.content:
turn_content += chunk.content
# Mirror partial content so a disconnect during
# generation still saves what we've produced.
persist_state["content"] = collected_content + turn_content
chunk_data = {
"id": chunk_id,
"object": "chat.completion.chunk",
@@ -909,19 +1109,6 @@ async def _stream_managed_agent(
tool_call_fragments[i] for i in sorted(tool_call_fragments.keys())
]
# Emit tool_calls metadata as SSE event
tool_meta = []
for tc in sorted_tcs:
tool_meta.append(
{
"tool_name": tc["function"]["name"],
"arguments": tc["function"]["arguments"],
}
)
yield (
f"event: tool_calls\ndata: {json.dumps({'calls': tool_meta})}\n\n"
)
# Add assistant message with tool_calls to conversation
from openjarvis.core.types import ToolCall as MsgToolCall
@@ -939,11 +1126,32 @@ async def _stream_managed_agent(
)
messages_for_llm.append(assistant_msg)
# Execute each tool call and append results
# Execute each tool call and append results. Emit
# tool_call_start/tool_call_end around each call so the UI
# can render them live (same event names as the main chat
# in stream_bridge.py).
import time as _time
for tc in sorted_tcs:
tool_name = tc["function"]["name"]
tool_args = tc["function"]["arguments"]
tool_result_content = f"Tool '{tool_name}' not available"
tool_succeeded = False
_start_payload = json.dumps(
{"tool": tool_name, "arguments": tool_args}
)
yield f"event: tool_call_start\ndata: {_start_payload}\n\n"
try:
manager.add_learning_log(
agent_id,
"tool_call",
f"Calling {tool_name}: {tool_args[:80]}",
{"tool": tool_name, "arguments": tool_args or ""},
)
except Exception as _tc_exc:
logger.warning("Log tool_call failed: %s", _tc_exc)
tool_start_ms = _time.monotonic() * 1000
try:
# Try MCP adapter first (external tools)
@@ -968,7 +1176,20 @@ async def _stream_managed_agent(
tool_cls = ToolRegistry.get(tool_name)
if tool_cls is not None:
tool_instance = tool_cls()
executor = ToolExecutor(tools=[tool_instance], bus=bus)
# Tools the user explicitly added to this
# agent's toolkit are considered pre-approved —
# selecting them in the wizard is the
# confirmation. Without this, tools that have
# `requires_confirmation=True` (shell_exec,
# apply_patch) would fail with "requires
# confirmation but no callback available" on
# every call.
executor = ToolExecutor(
tools=[tool_instance],
bus=bus,
interactive=True,
confirm_callback=lambda _prompt: True,
)
result = executor.execute(
StubToolCall(
id=tc["id"],
@@ -982,6 +1203,7 @@ async def _stream_managed_agent(
"Tool '%s' not found in registry or MCP adapters",
tool_name,
)
tool_succeeded = True
except Exception as tool_exc:
logger.error(
"Tool execution error for %s: %s",
@@ -991,11 +1213,43 @@ async def _stream_managed_agent(
)
tool_result_content = f"Error executing {tool_name}: {tool_exc}"
# Emit tool result as SSE event
tool_event_data = json.dumps(
{"tool_name": tool_name, "output": tool_result_content}
tool_latency_ms = (_time.monotonic() * 1000) - tool_start_ms
collected_tool_calls.append(
{
"tool": tool_name,
"arguments": tool_args,
"result": tool_result_content,
"success": tool_succeeded,
"latency": tool_latency_ms,
}
)
yield (f"event: tool_result\ndata: {tool_event_data}\n\n")
# Update the shared persist state so mid-stream
# disconnects still capture already-executed tools.
persist_state["tool_calls"] = list(collected_tool_calls)
try:
_ok = "succeeded" if tool_succeeded else "failed"
_clen = len(tool_result_content) if tool_result_content else 0
manager.add_learning_log(
agent_id,
"tool_result",
f"{tool_name} {_ok} ({_clen} chars)",
{
"tool": tool_name,
"success": tool_succeeded,
"output_length": _clen,
},
)
except Exception as _tr_exc:
logger.warning("Log tool_result failed: %s", _tr_exc)
_end_payload = json.dumps(
{
"tool": tool_name,
"success": tool_succeeded,
"latency": tool_latency_ms,
"result": tool_result_content,
}
)
yield f"event: tool_call_end\ndata: {_end_payload}\n\n"
# Add tool result message to conversation
messages_for_llm.append(
@@ -1009,10 +1263,16 @@ async def _stream_managed_agent(
# Continue to next turn (loop back to stream_full)
collected_content += turn_content
# Mirror to shared state so BackgroundTask can persist
# even if the client disconnects mid-stream.
persist_state["content"] = collected_content
persist_state["tool_calls"] = list(collected_tool_calls)
continue
# No tool calls — this is the final response
collected_content += turn_content
persist_state["content"] = collected_content
persist_state["tool_calls"] = list(collected_tool_calls)
break
# Final chunk with finish_reason
@@ -1031,21 +1291,13 @@ async def _stream_managed_agent(
yield f"data: {json.dumps(final_data)}\n\n"
yield "data: [DONE]\n\n"
# Persist agent response in DB after streaming completes
if collected_content:
try:
manager.store_agent_response(agent_id, collected_content)
except Exception as store_exc:
logger.error(
"Failed to store agent response: %s",
store_exc,
exc_info=True,
)
from starlette.background import BackgroundTask
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "Connection": "keep-alive"},
background=BackgroundTask(_persist_final),
)
@@ -1285,6 +1537,8 @@ def create_agent_manager_router(
engine=engine,
model=getattr(engine, "_model", ""),
tools=tools,
interactive=True,
confirm_callback=lambda _prompt: True,
)
def handler(text: str) -> str:
@@ -1360,6 +1614,8 @@ def create_agent_manager_router(
engine=engine,
model=model_name,
tools=tools,
interactive=True,
confirm_callback=lambda _prompt: True,
)
bus = getattr(request.app.state, "bus", None)
if bus is None:
+34
View File
@@ -243,6 +243,40 @@ class TestMessageQueue:
resp = manager.add_agent_response(agent["id"], "Found 3 papers")
assert resp["direction"] == "agent_to_user"
def test_store_agent_response_with_tool_calls(self, manager):
"""Tool calls captured during a turn must survive a list_messages
round-trip so the UI can re-render them after a page reload."""
agent = manager.create_agent(name="test", agent_type="simple")
tool_calls = [
{
"tool": "file_read",
"arguments": '{"path": "~/notes.md"}',
"result": "hello world",
"success": True,
"latency": 12.3,
},
{
"tool": "shell_exec",
"arguments": '{"command": "ls"}',
"result": "a.md b.md",
"success": True,
"latency": 4.5,
},
]
manager.store_agent_response(
agent["id"], "Here is what I found", tool_calls=tool_calls
)
messages = manager.list_messages(agent["id"])
assert len(messages) == 1
assert messages[0]["content"] == "Here is what I found"
assert messages[0]["tool_calls"] == tool_calls
def test_store_agent_response_without_tool_calls(self, manager):
agent = manager.create_agent(name="test", agent_type="simple")
manager.store_agent_response(agent["id"], "plain reply")
messages = manager.list_messages(agent["id"])
assert messages[0]["tool_calls"] is None
def test_update_agent_budget_fields(tmp_path):
"""update_agent() accepts budget and stall kwargs."""
+69
View File
@@ -425,3 +425,72 @@ class TestAgentManagerStreaming:
assert resp.status_code == 200
assert "Error:" in resp.text or "error" in resp.text.lower()
assert "data: [DONE]" in resp.text
@pytest.mark.skipif(not HAS_FASTAPI, reason="fastapi not installed")
class TestResolveToolSpecs:
"""Unit tests for _resolve_tool_specs — converts template string
tool names into OpenAI-format function specs so the engine can
actually bind them to the model.
"""
@pytest.fixture
def _registered_tools(self):
"""Re-register tools after the autouse conftest fixture clears them."""
import importlib
import sys
from openjarvis.core.registry import ToolRegistry
for mod_name in list(sys.modules):
if (
mod_name.startswith("openjarvis.tools.")
and not mod_name.endswith("_stubs")
and not mod_name.endswith("agent_tools")
):
try:
importlib.reload(sys.modules[mod_name])
except Exception:
pass
yield ToolRegistry
def test_string_names_resolve_to_openai_specs(self, _registered_tools):
from openjarvis.server.agent_manager_routes import _resolve_tool_specs
specs = _resolve_tool_specs(["file_read", "think"])
assert len(specs) == 2
names = [s["function"]["name"] for s in specs]
assert "file_read" in names
assert "think" in names
for s in specs:
assert s["type"] == "function"
assert "description" in s["function"]
assert "parameters" in s["function"]
def test_unknown_names_dropped(self, _registered_tools):
from openjarvis.server.agent_manager_routes import _resolve_tool_specs
specs = _resolve_tool_specs(["file_read", "nonexistent_tool_xyz"])
assert len(specs) == 1
assert specs[0]["function"]["name"] == "file_read"
def test_dict_entries_passed_through(self, _registered_tools):
from openjarvis.server.agent_manager_routes import _resolve_tool_specs
full_spec = {
"type": "function",
"function": {
"name": "custom",
"description": "x",
"parameters": {"type": "object"},
},
}
specs = _resolve_tool_specs([full_spec, "file_read"])
assert len(specs) == 2
assert specs[0] is full_spec
def test_empty_and_none_return_empty_list(self):
from openjarvis.server.agent_manager_routes import _resolve_tool_specs
assert _resolve_tool_specs(None) == []
assert _resolve_tool_specs([]) == []