mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
fix(agents): bind built-in tools, live tool-call UI, Markdown streaming (#255)
This commit is contained in:
@@ -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
@@ -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 */ }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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([]) == []
|
||||
|
||||
Reference in New Issue
Block a user