mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-27 11:22:03 +00:00
Merge pull request #52756 from NousResearch/bb/delegate-bg-resume-ux
feat(delegation): calm "will resume" affordance for background delegate_task
This commit is contained in:
commit
41f4dce828
13 changed files with 301 additions and 2 deletions
|
|
@ -96,6 +96,7 @@ import { extractPreviewTargets } from '@/lib/preview-targets'
|
|||
import { useEnterAnimation } from '@/lib/use-enter-animation'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { $backgroundResume } from '@/store/background-delegation'
|
||||
import { $compactionActive } from '@/store/compaction'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
|
|
@ -243,7 +244,7 @@ export const Thread: FC<{
|
|||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : null}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : <BackgroundResumeNotice />}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
|
|
@ -422,6 +423,36 @@ const ResponseLoadingIndicator: FC = () => {
|
|||
)
|
||||
}
|
||||
|
||||
// Parked-background affordance: a top-level delegate_task runs in the
|
||||
// background, so the parent turn ends and the app goes idle while the subagent
|
||||
// keeps working and its result re-enters as a fresh turn later. Instead of a
|
||||
// spinner (reads as "stuck"), reuse the same compact, centered system-note
|
||||
// chrome as the steer / slash-status lines (SystemMessage above) so it sits in
|
||||
// the thread like every other meta line. Idle-only (gated upstream). Null when
|
||||
// nothing is parked.
|
||||
const BackgroundResumeNotice: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const resume = useStore($backgroundResume)
|
||||
|
||||
if (!resume) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = resume.activity ?? t.assistant.thread.resumeWhenBackgroundDone(resume.count)
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex max-w-[min(86%,44rem)] items-center gap-1.5 self-center px-2 py-0.5 text-[0.6875rem] leading-5 text-muted-foreground/55"
|
||||
data-slot="aui_background-resume"
|
||||
role="status"
|
||||
>
|
||||
<Codicon className="text-muted-foreground/55" name="sync" size="0.75rem" />
|
||||
<span className="shimmer min-w-0 truncate">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Seconds of no visible output (text or part count) before a still-running turn
|
||||
// is treated as stalled and the thinking indicator returns at the tail.
|
||||
const STREAM_STALL_S = 2
|
||||
|
|
|
|||
|
|
@ -1965,6 +1965,10 @@ export const en: Translations = {
|
|||
loadingSession: 'Loading session',
|
||||
showEarlier: 'Show earlier messages',
|
||||
loadingResponse: 'Hermes is loading a response',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1
|
||||
? 'Will resume when the background task finishes'
|
||||
: `Will resume when ${count} background tasks finish`,
|
||||
thinking: 'Thinking',
|
||||
today: time => `Today, ${time}`,
|
||||
yesterday: time => `Yesterday, ${time}`,
|
||||
|
|
|
|||
|
|
@ -2090,6 +2090,10 @@ export const ja = defineLocale({
|
|||
loadingSession: 'セッションを読み込み中',
|
||||
showEarlier: '以前のメッセージを表示',
|
||||
loadingResponse: 'Hermes が応答を読み込み中',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1
|
||||
? 'バックグラウンドタスクの完了後に再開します'
|
||||
: `${count} 件のバックグラウンドタスクの完了後に再開します`,
|
||||
thinking: '考え中',
|
||||
today: time => `今日 ${time}`,
|
||||
yesterday: time => `昨日 ${time}`,
|
||||
|
|
|
|||
|
|
@ -1622,6 +1622,7 @@ export interface Translations {
|
|||
loadingSession: string
|
||||
showEarlier: string
|
||||
loadingResponse: string
|
||||
resumeWhenBackgroundDone: (count: number) => string
|
||||
thinking: string
|
||||
today: (time: string) => string
|
||||
yesterday: (time: string) => string
|
||||
|
|
|
|||
|
|
@ -2027,6 +2027,8 @@ export const zhHant = defineLocale({
|
|||
loadingSession: '正在載入工作階段',
|
||||
showEarlier: '顯示較早的訊息',
|
||||
loadingResponse: 'Hermes 正在載入回覆',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '背景工作完成後將自動繼續' : `${count} 個背景工作完成後將自動繼續`,
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
yesterday: time => `昨天,${time}`,
|
||||
|
|
|
|||
|
|
@ -2139,6 +2139,8 @@ export const zh: Translations = {
|
|||
loadingSession: '正在加载会话',
|
||||
showEarlier: '显示更早的消息',
|
||||
loadingResponse: 'Hermes 正在加载回复',
|
||||
resumeWhenBackgroundDone: count =>
|
||||
count === 1 ? '后台任务完成后将自动继续' : `${count} 个后台任务完成后将自动继续`,
|
||||
thinking: '思考中',
|
||||
today: time => `今天,${time}`,
|
||||
yesterday: time => `昨天,${time}`,
|
||||
|
|
|
|||
65
apps/desktop/src/store/background-delegation.test.ts
Normal file
65
apps/desktop/src/store/background-delegation.test.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $backgroundResume } from './background-delegation'
|
||||
import { $activeSessionId, $busy } from './session'
|
||||
import { $subagentsBySession, type SubagentProgress, type SubagentStreamEntry } from './subagents'
|
||||
|
||||
const sub = (over: Partial<SubagentProgress> = {}): SubagentProgress => ({
|
||||
id: over.id ?? 'deleg:1',
|
||||
parentId: null,
|
||||
goal: 'do the thing',
|
||||
status: 'running',
|
||||
taskCount: 1,
|
||||
taskIndex: 0,
|
||||
startedAt: 0,
|
||||
updatedAt: 0,
|
||||
filesRead: [],
|
||||
filesWritten: [],
|
||||
stream: [],
|
||||
...over
|
||||
})
|
||||
|
||||
const stream = (text: string): SubagentStreamEntry => ({ at: 0, kind: 'progress', text })
|
||||
|
||||
describe('$backgroundResume', () => {
|
||||
beforeEach(() => {
|
||||
$busy.set(false)
|
||||
$activeSessionId.set('s1')
|
||||
$subagentsBySession.set({})
|
||||
})
|
||||
|
||||
it('counts running/queued children for the active session while idle', () => {
|
||||
$subagentsBySession.set({ s1: [sub({ id: 'a' }), sub({ id: 'b', status: 'queued' })] })
|
||||
expect($backgroundResume.get()?.count).toBe(2)
|
||||
})
|
||||
|
||||
it('surfaces the primary child latest stream line as live activity', () => {
|
||||
$subagentsBySession.set({ s1: [sub({ id: 'a', stream: [stream('Searching the web…')] })] })
|
||||
expect($backgroundResume.get()?.activity).toBe('Searching the web…')
|
||||
})
|
||||
|
||||
it('activity is null when no stream line has arrived (UI uses generic copy)', () => {
|
||||
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
|
||||
expect($backgroundResume.get()?.activity).toBeNull()
|
||||
})
|
||||
|
||||
it('is null while a turn is busy (the turn owns the main loader)', () => {
|
||||
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
|
||||
$busy.set(true)
|
||||
expect($backgroundResume.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('is null when only terminal children or other sessions have work', () => {
|
||||
$subagentsBySession.set({
|
||||
s1: [sub({ id: 'a', status: 'completed' }), sub({ id: 'b', status: 'failed' })],
|
||||
s2: [sub({ id: 'c' })]
|
||||
})
|
||||
expect($backgroundResume.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('is null when there is no active session', () => {
|
||||
$subagentsBySession.set({ s1: [sub({ id: 'a' })] })
|
||||
$activeSessionId.set(null)
|
||||
expect($backgroundResume.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
48
apps/desktop/src/store/background-delegation.ts
Normal file
48
apps/desktop/src/store/background-delegation.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { computed } from 'nanostores'
|
||||
|
||||
import { $activeSessionId, $busy } from './session'
|
||||
import { $subagentsBySession, type SubagentProgress } from './subagents'
|
||||
|
||||
export interface BackgroundResume {
|
||||
/** Latest live activity from the primary child (its newest stream line), or
|
||||
* null when nothing readable has arrived yet — the UI then falls back to the
|
||||
* generic "will resume" copy. */
|
||||
activity: string | null
|
||||
/** Running/queued background children for the active session. */
|
||||
count: number
|
||||
}
|
||||
|
||||
const RUNNING = (s: SubagentProgress) => s.status === 'running' || s.status === 'queued'
|
||||
|
||||
/**
|
||||
* "Parked" background-delegation signal for the active session.
|
||||
*
|
||||
* A top-level `delegate_task` always runs in the background: the parent turn
|
||||
* ends (`$busy` -> false) while the subagent keeps running, and its result
|
||||
* re-enters the conversation as a fresh turn when it finishes. During that
|
||||
* window the app is genuinely idle but work is still happening elsewhere, so we
|
||||
* surface a calm, shimmering status line (its latest activity, or a generic
|
||||
* "will resume" fallback) instead of a spinner that reads as "stuck."
|
||||
*
|
||||
* Null while `$busy`: an active turn already owns the main loader, and subagents
|
||||
* spawned inside a running turn (synchronous orchestrator children) are part of
|
||||
* that turn, not parked background work the user is waiting on.
|
||||
*/
|
||||
export const $backgroundResume = computed(
|
||||
[$subagentsBySession, $activeSessionId, $busy],
|
||||
(bySession, sid, busy): BackgroundResume | null => {
|
||||
if (busy || !sid) {
|
||||
return null
|
||||
}
|
||||
|
||||
const running = (bySession[sid] ?? []).filter(RUNNING)
|
||||
|
||||
if (running.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const activity = (running[0]!.stream.at(-1)?.text ?? '').trim() || null
|
||||
|
||||
return { activity, count: running.length }
|
||||
}
|
||||
)
|
||||
|
|
@ -334,7 +334,7 @@
|
|||
/* Paragraph spacing — vertical gap between prose paragraphs, both inside a
|
||||
markdown block and between consecutive prose parts. Single knob; tweak
|
||||
freely. */
|
||||
--paragraph-gap: 0.45rem;
|
||||
--paragraph-gap: 0.7rem;
|
||||
--sticky-human-top: 0.23rem;
|
||||
--file-tree-row-height: 1.375rem;
|
||||
|
||||
|
|
@ -890,6 +890,14 @@ code {
|
|||
margin-block: var(--paragraph-gap) 0;
|
||||
}
|
||||
|
||||
/* Headings are section breaks, so they own a larger top gap than prose — a
|
||||
leading `# title` (common in re-entered/delegated turns) no longer butts
|
||||
against the block above. Top-owned + bottom snug; the first-child reset
|
||||
below still flushes a leading heading. */
|
||||
[data-slot='aui_assistant-message-content'] .aui-md :where(h1, h2, h3, h4) {
|
||||
margin-block: 1rem 0.25rem;
|
||||
}
|
||||
|
||||
/* First rendered element of a prose block is flush — the block-level gap above
|
||||
(tool / paragraph) already provides the separation. Reach one level deep too:
|
||||
Streamdown wraps blocks in a `div.space-y-*`, so the real first line is the
|
||||
|
|
|
|||
15
cli.py
15
cli.py
|
|
@ -10499,6 +10499,21 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
|
||||
def _on_tool_complete(self, tool_call_id: str, function_name: str, function_args: dict, function_result: str):
|
||||
"""Render file edits with inline diff after write-capable tools complete."""
|
||||
# A top-level delegate_task dispatches in the background and re-enters as
|
||||
# a fresh turn when done. Say so once — no spinner, nothing to poll — so
|
||||
# the idle prompt doesn't read as "nothing happened" (⛓ tracks the work).
|
||||
if function_name == "delegate_task":
|
||||
try:
|
||||
parsed = json.loads(function_result) if isinstance(function_result, str) else (function_result or {})
|
||||
except Exception:
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict) and parsed.get("status") == "dispatched" and parsed.get("mode") == "background":
|
||||
n = parsed.get("count") or 1
|
||||
noun, tail = ("task", "it finishes") if n == 1 else (f"{n} tasks", "they finish")
|
||||
try:
|
||||
_cprint(f"\033[2m\u21a9 Background {noun} running — I'll resume when {tail}. Keep chatting.\033[0m")
|
||||
except Exception:
|
||||
pass
|
||||
snapshot = self._pending_edit_snapshots.pop(tool_call_id, None)
|
||||
try:
|
||||
from agent.display import render_edit_diff_with_delta
|
||||
|
|
|
|||
69
tests/cli/test_cli_delegate_background_notice.py
Normal file
69
tests/cli/test_cli_delegate_background_notice.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""The CLI spells out auto-resume when a delegate_task goes to the background.
|
||||
|
||||
A top-level ``delegate_task`` returns a handle immediately and runs the subagent
|
||||
in the background; the result re-enters the conversation as a fresh turn when it
|
||||
finishes. ``_on_tool_complete`` prints a one-line, no-spinner reassurance at
|
||||
dispatch so the idle prompt doesn't read as "nothing happened".
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import cli
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
def _make_cli():
|
||||
cli_obj = HermesCLI.__new__(HermesCLI)
|
||||
cli_obj._pending_edit_snapshots = {}
|
||||
return cli_obj
|
||||
|
||||
|
||||
def _capture(monkeypatch):
|
||||
printed: list[str] = []
|
||||
monkeypatch.setattr(cli, "_cprint", lambda text: printed.append(text))
|
||||
return printed
|
||||
|
||||
|
||||
def test_background_dispatch_prints_resume_notice(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"status": "dispatched", "mode": "background", "count": 1})
|
||||
cli_obj._on_tool_complete("tc1", "delegate_task", {"goal": "x"}, result)
|
||||
|
||||
joined = "\n".join(printed)
|
||||
assert "resume" in joined.lower()
|
||||
assert "it finishes" in joined
|
||||
|
||||
|
||||
def test_background_batch_dispatch_pluralizes(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"status": "dispatched", "mode": "background", "count": 3})
|
||||
cli_obj._on_tool_complete("tc2", "delegate_task", {"tasks": []}, result)
|
||||
|
||||
joined = "\n".join(printed)
|
||||
assert "3 tasks" in joined
|
||||
assert "they finish" in joined
|
||||
|
||||
|
||||
def test_synchronous_delegate_result_prints_no_notice(monkeypatch):
|
||||
"""A non-background result (e.g. the stateless sync fallback) must not claim
|
||||
a background dispatch."""
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
result = json.dumps({"results": [{"status": "completed", "summary": "done"}]})
|
||||
cli_obj._on_tool_complete("tc3", "delegate_task", {"goal": "x"}, result)
|
||||
|
||||
assert not any("resume" in p.lower() for p in printed)
|
||||
|
||||
|
||||
def test_non_delegate_tool_prints_no_notice(monkeypatch):
|
||||
cli_obj = _make_cli()
|
||||
printed = _capture(monkeypatch)
|
||||
|
||||
cli_obj._on_tool_complete("tc4", "read_file", {"path": "a"}, '{"ok": true}')
|
||||
|
||||
assert not any("resume" in p.lower() for p in printed)
|
||||
|
|
@ -129,6 +129,41 @@ describe('StatusRule background-subagent indicator', () => {
|
|||
expect(textContent(element)).not.toContain('⛓')
|
||||
})
|
||||
|
||||
it('spells out the auto-resume hint when idle with subagents in flight', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 1 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('resumes when subagent finishes')
|
||||
})
|
||||
|
||||
it('pluralizes the resume hint for multiple in-flight subagents', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
usage: { ...baseProps.usage, active_subagents: 3 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).toContain('resumes when 3 subagents finish')
|
||||
})
|
||||
|
||||
it('hides the resume hint mid-turn (a busy turn owns the indicator)', () => {
|
||||
const element = StatusRule({
|
||||
...baseProps,
|
||||
busy: true,
|
||||
turnStartedAt: Date.now(),
|
||||
usage: { ...baseProps.usage, active_subagents: 2 }
|
||||
})
|
||||
|
||||
expect(textContent(element)).not.toContain('resumes when')
|
||||
})
|
||||
|
||||
it('omits the resume hint when no subagents are running', () => {
|
||||
const element = StatusRule({ ...baseProps })
|
||||
|
||||
expect(textContent(element)).not.toContain('resumes when')
|
||||
})
|
||||
|
||||
it('drops the subagent segment before the bg segment on a narrow terminal', () => {
|
||||
// cols=44 is below the subagents breakpoint (92) but the bg breakpoint
|
||||
// (88) too — both gone. Assert the lower-priority subagent indicator is
|
||||
|
|
|
|||
|
|
@ -512,6 +512,15 @@ export function StatusRule({
|
|||
const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`))
|
||||
const subagentCount = typeof usage.active_subagents === 'number' ? usage.active_subagents : 0
|
||||
const showSubagents = segs.subagents && subagentCount > 0 && fits(SEP + stringWidth(`⛓ ${subagentCount}`))
|
||||
// Parked-background reassurance: a top-level delegate_task runs in the
|
||||
// background, so the turn ends (idle) while the subagent keeps working and its
|
||||
// result re-enters as a fresh turn later. When idle with work still in flight,
|
||||
// spell out that the agent resumes on its own — no spinner, nothing to poll.
|
||||
// Width-budgeted like every tail segment, so it drops first on a tight
|
||||
// terminal where ⛓ already carries the signal.
|
||||
const resumeHintText =
|
||||
subagentCount === 1 ? '↩ resumes when subagent finishes' : `↩ resumes when ${subagentCount} subagents finish`
|
||||
const showResumeHint = !busy && subagentCount > 0 && fits(SEP + stringWidth(resumeHintText))
|
||||
// Dev-gated readout (HERMES_DEV_CREDITS), lowest priority,
|
||||
// so it consumes tail budget LAST and drops first on a narrow terminal.
|
||||
const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText))
|
||||
|
|
@ -624,6 +633,12 @@ export function StatusRule({
|
|||
⛓ {subagentCount}
|
||||
</Text>
|
||||
) : null}
|
||||
{showResumeHint ? (
|
||||
<Text color={t.color.muted} dim wrap="truncate-end">
|
||||
{' │ '}
|
||||
{resumeHintText}
|
||||
</Text>
|
||||
) : null}
|
||||
{showDevCredits ? (
|
||||
<Text color={t.color.accent} wrap="truncate-end">
|
||||
{' │ '}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue