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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -811,11 +811,17 @@ type Display struct {
Compact bool `yaml:"compact" json:"compact"`
ToolProgress bool `yaml:"tool_progress" json:"tool_progress"`
ShowReasoning bool `yaml:"show_reasoning" json:"show_reasoning"`
Theme string `yaml:"theme" json:"theme"`
Skin string `yaml:"skin" json:"skin"`
Language string `yaml:"language" json:"language"`
BellOnComplete bool `yaml:"bell_on_complete" json:"bell_on_complete"`
InterimAssistant bool `yaml:"interim_assistant_messages" json:"interim_assistant_messages"`
// MaxLiveReasoningChars caps how much of a streaming reasoning trace the
// dashboard keeps in React state (trailing window). High-effort models can
// emit hundreds of KB per turn; unbounded string growth freezes the tab.
// 0 means unlimited. The full text is still persisted server-side and is
// restored on hydrate after the turn completes.
MaxLiveReasoningChars int `yaml:"max_live_reasoning_chars" json:"max_live_reasoning_chars"`
Theme string `yaml:"theme" json:"theme"`
Skin string `yaml:"skin" json:"skin"`
Language string `yaml:"language" json:"language"`
BellOnComplete bool `yaml:"bell_on_complete" json:"bell_on_complete"`
InterimAssistant bool `yaml:"interim_assistant_messages" json:"interim_assistant_messages"`
}

// Logging controls log level and sinks.
Expand Down
5 changes: 3 additions & 2 deletions internal/config/defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,9 @@ func Default() *Config {
SessionReset: SessionReset{Mode: "never", IdleMinutes: 180, AtHour: 4},
Streaming: Streaming{Enabled: true},
Display: Display{
ToolProgress: true, ShowReasoning: true, Theme: "system",
Skin: "antares", Language: "auto", InterimAssistant: true,
ToolProgress: true, ShowReasoning: true,
MaxLiveReasoningChars: 48_000,
Theme: "system", Skin: "antares", Language: "auto", InterimAssistant: true,
},
Logging: Logging{Level: "info", File: filepath.Join(Home(), "logs", "antares.log")},
MCP: MCP{Enabled: true, Servers: map[string]MCPServer{}},
Expand Down
4 changes: 4 additions & 0 deletions internal/config/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,10 @@ func normalize(c *Config) {
if c.Agent.MaxTurns <= 0 {
c.Agent.MaxTurns = 200
}
// Negative is meaningless; treat as default cap. 0 stays unlimited.
if c.Display.MaxLiveReasoningChars < 0 {
c.Display.MaxLiveReasoningChars = 48_000
}
}

// ResolveProvider returns the provider entry used for a model call, falling back
Expand Down
8 changes: 6 additions & 2 deletions internal/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,9 @@ var common = map[string]bool{
"compression.enabled": true,
"streaming.enabled": true,
"delegation.enabled": true,
"display.show_reasoning": true,
"display.tool_progress": true,
"display.show_reasoning": true,
"display.tool_progress": true,
"display.max_live_reasoning_chars": true,
"logging.level": true,
"server.host": true,
}
Expand Down Expand Up @@ -138,6 +139,9 @@ var help = map[string]string{
"memory.memory_enabled": "Lets the agent store durable facts between sessions.",
"skills.auto_create": "Allows the agent to write new skills on its own.",
"osint.google_cookie": "Optional. A logged-in Google Cookie header enables osint_google to resolve an email to its public profile. ToS-sensitive; uses your own session. Leave empty to disable.",
"display.show_reasoning": "Stream and show model reasoning/thinking in the dashboard (and TUI). Off skips emitting reasoning events so long thinking traces never hit the UI.",
"display.tool_progress": "Show live tool progress lines while a tool runs.",
"display.max_live_reasoning_chars": "Max characters of reasoning kept in the browser while a turn streams (trailing window). Prevents tab freezes on long thinking. Default 48000. 0 = unlimited. Full text is still saved server-side and restored after the turn.",
}

func secretKey(path string) bool {
Expand Down
64 changes: 55 additions & 9 deletions internal/mcp/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,13 +333,26 @@ func (c *Client) Close() error { return c.transport.Close() }

// stdioTransport runs the server as a child process and exchanges
// newline-delimited JSON-RPC frames over its pipes.
//
// Concurrency model:
// - sendMu serialises request/response pairs (stdio MCP is half-duplex).
// - mu protects closed/stdin so Close can mark the transport dead and kill the
// child without waiting for a hung RPC to finish writing.
//
// Previously send held one mutex for the entire RPC duration. If the child hung
// (e.g. IDA Pro MCP waiting on a dead IDA RPC port), Close/Refresh blocked
// forever on that lock — the dashboard MCP page and any reload path appeared to
// hang. Close must be able to kill the process so the blocked read returns EOF.
type stdioTransport struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader

mu sync.Mutex
sendMu sync.Mutex // serialises send/notify request cycles
mu sync.Mutex // protects closed + stdin write against Close
closed bool
waitOnce sync.Once
waitErr error
}

func newStdioTransport(cfg ServerConfig) (transport, error) {
Expand Down Expand Up @@ -375,20 +388,41 @@ func newStdioTransport(cfg ServerConfig) (transport, error) {
}
}()

return &stdioTransport{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}, nil
t := &stdioTransport{cmd: cmd, stdin: stdin, stdout: bufio.NewReaderSize(stdout, 1<<20)}
// Reap the child if it exits on its own so it never sits as a zombie until
// the next Close/Refresh. Wait is idempotent via waitOnce.
go func() { _ = t.reap() }()
return t, nil
}

func (t *stdioTransport) reap() error {
t.waitOnce.Do(func() {
if t.cmd != nil {
t.waitErr = t.cmd.Wait()
}
})
return t.waitErr
}

func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse, error) {
// One in-flight request at a time — required for line-delimited stdio.
t.sendMu.Lock()
defer t.sendMu.Unlock()

t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
t.mu.Unlock()
return nil, fmt.Errorf("mcp connection closed")
}
if err := t.writeFrame(req); err != nil {
err := t.writeFrame(req)
t.mu.Unlock()
if err != nil {
return nil, err
}

// Skip any notification the server interleaves before our response.
// Do NOT hold mu while waiting: Close must be able to kill the child so a
// hung tools/call (dead IDA backend, wedged proxy, …) unblocks.
type result struct {
resp *rpcResponse
err error
Expand Down Expand Up @@ -419,15 +453,23 @@ func (t *stdioTransport) send(ctx context.Context, req rpcRequest) (*rpcResponse

select {
case <-ctx.Done():
// Tear down the child so the reader goroutine unblocks on EOF and the
// next send cannot talk to a half-dead process with a stolen reply.
_ = t.Close()
return nil, ctx.Err()
case r := <-ch:
return r.resp, r.err
}
}

func (t *stdioTransport) notify(_ context.Context, req rpcRequest) error {
t.sendMu.Lock()
defer t.sendMu.Unlock()
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return fmt.Errorf("mcp connection closed")
}
return t.writeFrame(req)
}

Expand All @@ -444,16 +486,20 @@ func (t *stdioTransport) writeFrame(req rpcRequest) error {

func (t *stdioTransport) Close() error {
t.mu.Lock()
defer t.mu.Unlock()
if t.closed {
return nil
t.mu.Unlock()
return t.reap()
}
t.closed = true
_ = t.stdin.Close()
if t.cmd.Process != nil {
_ = t.cmd.Process.Kill()
proc := t.cmd.Process
t.mu.Unlock()

if proc != nil {
_ = proc.Kill()
}
return nil
// Always Wait so the child does not linger as a zombie under antares.
return t.reap()
}

// ---- http transport ----------------------------------------------------------
Expand Down
70 changes: 63 additions & 7 deletions web/src/components/chat/SubAgentPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react'
import { ArrowLeft, CircleNotch, UsersThree } from '@phosphor-icons/react'
import { streamGet, type StreamEvent } from '@/lib/api'
import { get, streamGet, type StreamEvent } from '@/lib/api'
import { useI18n } from '@/lib/i18n'
import {
DEFAULT_MAX_LIVE_REASONING_CHARS,
MessageBubble,
appendSeg,
pushToolSeg,
Expand All @@ -28,8 +29,30 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: (
const { t } = useI18n()
const [messages, setMessages] = useState<ChatMessage[]>([])
const [done, setDone] = useState(false)
const [showReasoning, setShowReasoning] = useState(true)
const showReasoningRef = useRef(true)
const maxLiveReasoningRef = useRef(DEFAULT_MAX_LIVE_REASONING_CHARS)
const bottomRef = useRef<HTMLDivElement>(null)

useEffect(() => {
showReasoningRef.current = showReasoning
}, [showReasoning])

useEffect(() => {
get<{
values?: { display?: { show_reasoning?: boolean; max_live_reasoning_chars?: number } }
}>('/config')
.then((d) => {
const disp = d.values?.display
if (disp && typeof disp.show_reasoning === 'boolean') setShowReasoning(disp.show_reasoning)
const n = Number(disp?.max_live_reasoning_chars)
if (Number.isFinite(n)) {
maxLiveReasoningRef.current = n < 0 ? DEFAULT_MAX_LIVE_REASONING_CHARS : n
}
})
.catch(() => {})
}, [])

useEffect(() => {
// Reset when switching between sub-agents.
setMessages([])
Expand All @@ -39,18 +62,43 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: (
// main transcript renders one streaming turn.
setMessages([{ id, role: 'assistant', content: '' }])

const patch = (fn: (m: ChatMessage) => ChatMessage) =>
setMessages((prev) => prev.map((m) => (m.id === id ? fn(m) : m)))
// Batch stream patches once per frame — sub-agents stream the same dense
// reasoning token firehose as the main chat and used to setState per token.
let pending: ((m: ChatMessage) => ChatMessage) | null = null
let raf: number | null = null
const flush = () => {
raf = null
const fn = pending
pending = null
if (!fn) return
setMessages((prev) => {
const idx = prev.findIndex((m) => m.id === id)
if (idx < 0) return prev
const next = prev.slice()
next[idx] = fn(prev[idx])
return next
})
}
const patch = (fn: (m: ChatMessage) => ChatMessage) => {
const prev = pending
pending = prev ? (m) => fn(prev(m)) : fn
if (raf == null) raf = requestAnimationFrame(flush)
}

const close = streamGet(
`/subagent/${encodeURIComponent(agent.id)}/attach`,
(event: StreamEvent) => {
switch (event.type) {
case 'text':
patch((m) => appendSeg(m, 'text', String(event.delta ?? '')))
patch((m) =>
appendSeg(m, 'text', String(event.delta ?? ''), maxLiveReasoningRef.current),
)
break
case 'reasoning':
patch((m) => appendSeg(m, 'reasoning', String(event.delta ?? '')))
if (!showReasoningRef.current) break
patch((m) =>
appendSeg(m, 'reasoning', String(event.delta ?? ''), maxLiveReasoningRef.current),
)
break
case 'tool_call':
patch((m) =>
Expand Down Expand Up @@ -88,13 +136,21 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: (
patch((m) => ({ ...m, error: String(event.error ?? '') }))
break
case 'done':
if (raf != null) {
cancelAnimationFrame(raf)
raf = null
}
if (pending) flush()
setDone(true)
break
}
},
() => setDone(true),
)
return close
return () => {
if (raf != null) cancelAnimationFrame(raf)
close()
}
}, [agent.id])

// Keep the newest output in view as it streams.
Expand Down Expand Up @@ -138,7 +194,7 @@ export function SubAgentPanel({ agent, onBack }: { agent: ActiveAgent; onBack: (
</div>
) : null}
{messages.map((m) => (
<MessageBubble key={m.id} message={m} />
<MessageBubble key={m.id} message={m} showReasoning={showReasoning} />
))}
<div ref={bottomRef} />
</div>
Expand Down
Loading
Loading