hermes: keep durable worker activity visible
This commit is contained in:
parent
4a750c9717
commit
9bc47f9ce3
@ -856,6 +856,83 @@ function replaceOnce(source, before, after, label) {
|
||||
"",
|
||||
"xterm WebGL import",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' const resumeParam = searchParams.get("resume");\n',
|
||||
' const resumeParam = searchParams.get("resume");\n' +
|
||||
' const lineageRoot = searchParams.get("lineage_root") || resumeParam;\n',
|
||||
"durable lineage root",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
String.raw` useEffect(() => {
|
||||
if (!resumeParam) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
api
|
||||
.getSessionLatestDescendant(resumeParam, scopedProfile)
|
||||
.then((res) => {
|
||||
if (cancelled || !res.session_id || res.session_id === resumeParam) {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set("resume", res.session_id);
|
||||
setSearchParams(next, { replace: true });
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort: old servers or missing sessions should not block chat.
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resumeParam, scopedProfile, searchParams, setSearchParams]);
|
||||
`,
|
||||
String.raw` useEffect(() => {
|
||||
if (!lineageRoot) return;
|
||||
|
||||
let cancelled = false;
|
||||
const followActiveLineage = async () => {
|
||||
try {
|
||||
const res = await api.getSessionLatestDescendant(
|
||||
lineageRoot,
|
||||
scopedProfile,
|
||||
);
|
||||
if (cancelled || !res.session_id) return;
|
||||
setSearchParams(
|
||||
(previous) => {
|
||||
const next = new URLSearchParams(previous);
|
||||
const current = next.get("resume");
|
||||
if (current === res.session_id) return previous;
|
||||
next.set("lineage_root", lineageRoot);
|
||||
next.set("resume", res.session_id);
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort: old servers or missing sessions should not block chat.
|
||||
}
|
||||
};
|
||||
|
||||
void followActiveLineage();
|
||||
const timer = window.setInterval(() => void followActiveLineage(), 2_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [lineageRoot, scopedProfile, setSearchParams]);
|
||||
`,
|
||||
"active lineage follower",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' next.delete("resume");\n',
|
||||
' next.delete("resume");\n next.delete("lineage_root");\n',
|
||||
"fresh chat lineage reset",
|
||||
);
|
||||
const webglStart = source.indexOf(" // WebGL draws from");
|
||||
const webglEnd = source.indexOf(
|
||||
"\n\n // Initial fit + resize observer",
|
||||
@ -993,6 +1070,40 @@ function replaceOnce(source, before, after, label) {
|
||||
),`,
|
||||
"session child query option",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
`export interface SessionMessagesResponse {
|
||||
session_id: string;
|
||||
messages: SessionMessage[];
|
||||
}`,
|
||||
`export interface SessionMessagesResponse {
|
||||
session_id: string;
|
||||
messages: SessionMessage[];
|
||||
total_messages?: number;
|
||||
}`,
|
||||
"session message total",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
` getSessionMessages: (id: string, profile = getManagementProfile()) =>
|
||||
fetchJSON<SessionMessagesResponse>(
|
||||
appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile),
|
||||
),`,
|
||||
` getSessionMessages: (
|
||||
id: string,
|
||||
profile = getManagementProfile(),
|
||||
limit?: number,
|
||||
) =>
|
||||
fetchJSON<SessionMessagesResponse>(
|
||||
appendProfileParam(
|
||||
\`/api/sessions/\${encodeURIComponent(id)}/messages\${
|
||||
limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : ""
|
||||
}\`,
|
||||
profile,
|
||||
),
|
||||
),`,
|
||||
"bounded session message query",
|
||||
);
|
||||
fs.writeFileSync(path, source);
|
||||
}
|
||||
|
||||
@ -1019,6 +1130,18 @@ function replaceOnce(source, before, after, label) {
|
||||
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)',
|
||||
"session-tree child query",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' next.set("resume", id);\n',
|
||||
' next.delete("lineage_root");\n next.set("resume", id);\n',
|
||||
"manual session lineage reset",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
' next.delete("resume");\n',
|
||||
' next.delete("resume");\n next.delete("lineage_root");\n',
|
||||
"new session lineage reset",
|
||||
);
|
||||
source = replaceOnce(
|
||||
source,
|
||||
" const [reloadNonce, setReloadNonce] = useState(0);\n",
|
||||
@ -1236,6 +1359,8 @@ RUN cd /opt/hermes/web \
|
||||
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
|
||||
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
|
||||
&& grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \
|
||||
&& grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \
|
||||
&& grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \
|
||||
&& grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \
|
||||
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
|
||||
&& grep -Fq 'redirectToProxyLogin' src/lib/api.ts \
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
|
||||
const POLL_MS = 2_000;
|
||||
const DEFAULT_VISIBLE_MESSAGES = 250;
|
||||
|
||||
interface SessionActivityPanelProps {
|
||||
sessionId: string;
|
||||
@ -29,7 +30,7 @@ function messageLabel(message: SessionMessage): string {
|
||||
}
|
||||
if (message.tool_calls?.length) {
|
||||
const names = message.tool_calls
|
||||
.map((call) => call.function.name)
|
||||
.map((call) => call?.function?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
return names ? `Tool calls: ${names}` : "Tool calls";
|
||||
@ -37,6 +38,43 @@ function messageLabel(message: SessionMessage): string {
|
||||
return message.role === "assistant" ? "Hermes" : message.role;
|
||||
}
|
||||
|
||||
function displayText(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
if (value == null) return "";
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function ExpandablePayload({
|
||||
payload,
|
||||
summary,
|
||||
openByDefault = false,
|
||||
}: {
|
||||
payload: string;
|
||||
summary: string;
|
||||
openByDefault?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(openByDefault);
|
||||
|
||||
return (
|
||||
<details
|
||||
className="my-1 rounded border border-white/10 px-2 py-1"
|
||||
open={open}
|
||||
onToggle={(event) => setOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="cursor-pointer select-none text-white/75">{summary}</summary>
|
||||
{open && (
|
||||
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
|
||||
{payload}
|
||||
</pre>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function latestTimestamp(messages: SessionMessage[]): number | null {
|
||||
let latest: number | null = null;
|
||||
for (const message of messages) {
|
||||
@ -53,8 +91,8 @@ function ActivityMessage({
|
||||
message: SessionMessage;
|
||||
index: number;
|
||||
}) {
|
||||
const toolCalls = message.tool_calls ?? [];
|
||||
const content = message.content ?? "";
|
||||
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
|
||||
const content = displayText(message.content);
|
||||
const isToolResult = message.role === "tool";
|
||||
|
||||
return (
|
||||
@ -71,26 +109,23 @@ function ActivityMessage({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{toolCalls.map((call) => (
|
||||
<details key={call.id} className="my-1 rounded border border-white/10 px-2 py-1">
|
||||
<summary className="cursor-pointer select-none text-white/75">
|
||||
{call.function.name || "tool"} arguments
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] text-white/70">
|
||||
{call.function.arguments}
|
||||
</pre>
|
||||
</details>
|
||||
))}
|
||||
{toolCalls.map((call, callIndex) => {
|
||||
const name = call?.function?.name || "tool";
|
||||
return (
|
||||
<ExpandablePayload
|
||||
key={call?.id || `${index}-${callIndex}`}
|
||||
payload={displayText(call?.function?.arguments)}
|
||||
summary={`${name} arguments`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{content && isToolResult ? (
|
||||
<details className="mt-1" open={content.length < 1_500}>
|
||||
<summary className="cursor-pointer select-none text-white/75">
|
||||
{content.length.toLocaleString()} characters of output
|
||||
</summary>
|
||||
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
|
||||
{content}
|
||||
</pre>
|
||||
</details>
|
||||
<ExpandablePayload
|
||||
payload={content}
|
||||
summary={`${content.length.toLocaleString()} characters of output`}
|
||||
openByDefault={content.length < 1_500}
|
||||
/>
|
||||
) : content ? (
|
||||
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
|
||||
{content}
|
||||
@ -114,7 +149,9 @@ export function SessionActivityPanel({
|
||||
}: SessionActivityPanelProps) {
|
||||
const [detail, setDetail] = useState<SessionInfo | null>(null);
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [totalMessages, setTotalMessages] = useState(0);
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pollTime, setPollTime] = useState(Date.now());
|
||||
const requestRunning = useRef(false);
|
||||
@ -127,10 +164,16 @@ export function SessionActivityPanel({
|
||||
try {
|
||||
const [nextDetail, response] = await Promise.all([
|
||||
api.getSessionDetail(sessionId, profile ?? ""),
|
||||
api.getSessionMessages(sessionId, profile ?? ""),
|
||||
api.getSessionMessages(sessionId, profile ?? "", visibleLimit),
|
||||
]);
|
||||
setDetail(nextDetail);
|
||||
setMessages(Array.isArray(response.messages) ? response.messages : []);
|
||||
const nextMessages = Array.isArray(response.messages) ? response.messages : [];
|
||||
setMessages(nextMessages);
|
||||
setTotalMessages(
|
||||
typeof response.total_messages === "number"
|
||||
? response.total_messages
|
||||
: nextMessages.length,
|
||||
);
|
||||
setError(null);
|
||||
setPollTime(Date.now());
|
||||
if (stickToBottom.current) {
|
||||
@ -144,6 +187,10 @@ export function SessionActivityPanel({
|
||||
} finally {
|
||||
requestRunning.current = false;
|
||||
}
|
||||
}, [profile, sessionId, visibleLimit]);
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleLimit(DEFAULT_VISIBLE_MESSAGES);
|
||||
}, [profile, sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -160,6 +207,14 @@ export function SessionActivityPanel({
|
||||
}, [load]);
|
||||
|
||||
const latest = useMemo(() => latestTimestamp(messages), [messages]);
|
||||
const visibleMessages = useMemo(
|
||||
() =>
|
||||
messages.length <= visibleLimit
|
||||
? messages
|
||||
: messages.slice(-visibleLimit),
|
||||
[messages, visibleLimit],
|
||||
);
|
||||
const hiddenMessageCount = Math.max(0, totalMessages - visibleMessages.length);
|
||||
const quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest);
|
||||
const running = detail?.ended_at == null;
|
||||
const status = error
|
||||
@ -199,7 +254,8 @@ export function SessionActivityPanel({
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{status} · {messages.length} recorded events
|
||||
{status} · {messages.length.toLocaleString()} of{" "}
|
||||
{totalMessages.toLocaleString()} recorded events loaded
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -245,6 +301,29 @@ export function SessionActivityPanel({
|
||||
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
|
||||
}}
|
||||
>
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="sticky top-0 z-10 flex justify-center pb-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
stickToBottom.current = false;
|
||||
setVisibleLimit((current) => current + DEFAULT_VISIBLE_MESSAGES);
|
||||
}}
|
||||
prefix={<ChevronUp className="h-3.5 w-3.5" />}
|
||||
aria-label={`Show up to ${Math.min(
|
||||
DEFAULT_VISIBLE_MESSAGES,
|
||||
hiddenMessageCount,
|
||||
)} earlier recorded events`}
|
||||
className="bg-black/95 text-white"
|
||||
>
|
||||
Show up to{" "}
|
||||
{Math.min(
|
||||
DEFAULT_VISIBLE_MESSAGES,
|
||||
hiddenMessageCount,
|
||||
).toLocaleString()}{" "}
|
||||
earlier events
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
|
||||
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
|
||||
@ -254,11 +333,11 @@ export function SessionActivityPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message, index) => (
|
||||
visibleMessages.map((message, index) => (
|
||||
<ActivityMessage
|
||||
key={`${message.timestamp ?? "untimed"}-${index}`}
|
||||
key={`${message.timestamp ?? "untimed"}-${hiddenMessageCount + index}`}
|
||||
message={message}
|
||||
index={index}
|
||||
index={hiddenMessageCount + index}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -279,12 +279,14 @@ data:
|
||||
|
||||
## Atlas engineering access
|
||||
|
||||
Atlas repositories are private and canonical at
|
||||
`https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git authentication is
|
||||
already supplied through `GIT_ASKPASS`. Verify the remote and cleanly
|
||||
separate pre-existing changes, create a task branch, run the repository's
|
||||
tests, use `git push --dry-run` when proving access, and push a real branch
|
||||
only when the requested implementation is review-ready. Never force-push.
|
||||
The Atlas organization has private visibility. Its repositories are access-
|
||||
controlled as either private or Gitea-internal, never public, and are
|
||||
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
|
||||
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
|
||||
and cleanly separate pre-existing changes, create a task branch, run the
|
||||
repository's tests, use `git push --dry-run` when proving access, and push a
|
||||
real branch only when the requested implementation is review-ready. Never
|
||||
force-push.
|
||||
|
||||
The terminal PATH contains the pinned operator tools. Start cluster work
|
||||
with `kubectl config current-context`, read-only status/events/logs, and the
|
||||
|
||||
@ -125,6 +125,7 @@ spec:
|
||||
- |
|
||||
set -eu
|
||||
env_file=/opt/data/.env
|
||||
profile_file=/opt/data/home/.profile
|
||||
mkdir -p \
|
||||
/opt/data/home/.claude \
|
||||
/opt/data/home/.codex \
|
||||
@ -184,6 +185,18 @@ spec:
|
||||
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
|
||||
upsert_env GIT_TERMINAL_PROMPT 0
|
||||
chmod 0600 "${env_file}"
|
||||
touch "${profile_file}"
|
||||
if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then
|
||||
printf '%s\n' \
|
||||
'' \
|
||||
'# Hermes managed operator PATH.' \
|
||||
'case ":${PATH}:" in' \
|
||||
' *":/opt/data/tools/bin:"*) ;;' \
|
||||
' *) PATH="/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/opt/hermes/bin:${PATH}" ;;' \
|
||||
'esac' \
|
||||
'export PATH' >> "${profile_file}"
|
||||
fi
|
||||
chmod 0644 "${profile_file}"
|
||||
# Existing owner data is already written as uid/gid 10000. A
|
||||
# recursive chown made every routine rollout walk the full 20Gi
|
||||
# workspace while the dashboard had no endpoint. Own only the
|
||||
@ -208,6 +221,7 @@ spec:
|
||||
/opt/data/SOUL.md \
|
||||
/opt/data/workspace/AGENTS.md \
|
||||
/opt/data/workspace/START-HERE.md \
|
||||
"${profile_file}" \
|
||||
"${env_file}"
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
|
||||
@ -4,7 +4,7 @@ kind: Kustomization
|
||||
namespace: hermes
|
||||
images:
|
||||
- name: registry.bstein.dev/bstein/hermes-agent
|
||||
digest: sha256:611c1642381d2a82ff03b55dda58bcab9a2e723c495dc298d1e9f938e94ed8d4
|
||||
digest: sha256:de9463fab178916ff9c880af2f8dae7ffc77585e3f7b73e15f5369454ce0f3dd
|
||||
resources:
|
||||
- namespace.yaml
|
||||
- vault-serviceaccount.yaml
|
||||
|
||||
@ -159,6 +159,7 @@ EVENT_CALLBACK_SIGNATURE_BEFORE = ''' def _make_run_event_callback(self, run_
|
||||
|
||||
EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
||||
_RUN_ACTIVITY_FILES = 256
|
||||
_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0
|
||||
|
||||
def _record_run_activity(
|
||||
self,
|
||||
@ -189,6 +190,34 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
||||
label = labels.get(event_type)
|
||||
if not session_id or not label:
|
||||
return
|
||||
now = time.time()
|
||||
if event_type in {
|
||||
"_thinking",
|
||||
"reasoning.available",
|
||||
"subagent.progress",
|
||||
"subagent_progress",
|
||||
"subagent.text",
|
||||
"subagent.thinking",
|
||||
}:
|
||||
# Streaming providers may emit hundreds of token-level progress
|
||||
# callbacks. They carry no displayable detail here; keep one live
|
||||
# heartbeat per session interval while retaining every persisted
|
||||
# assistant message, tool call, and tool result in the session DB.
|
||||
heartbeats = getattr(self, "_run_activity_heartbeats", None)
|
||||
if heartbeats is None:
|
||||
heartbeats = {}
|
||||
self._run_activity_heartbeats = heartbeats
|
||||
last_heartbeat = float(heartbeats.get(session_id, 0.0))
|
||||
if now - last_heartbeat < self._RUN_ACTIVITY_HEARTBEAT_SECONDS:
|
||||
return
|
||||
heartbeats[session_id] = now
|
||||
if len(heartbeats) > self._RUN_ACTIVITY_FILES * 2:
|
||||
cutoff = now - 3_600.0
|
||||
self._run_activity_heartbeats = {
|
||||
key: value
|
||||
for key, value in heartbeats.items()
|
||||
if value >= cutoff
|
||||
}
|
||||
# Tool names are bounded identifiers. All previews are arbitrary model
|
||||
# or tool text and can contain prompts, arguments, paths, or credentials,
|
||||
# so the activity projection must never persist them.
|
||||
@ -203,7 +232,7 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
|
||||
entry = {
|
||||
"role": "assistant",
|
||||
"content": f"Activity · {content}",
|
||||
"timestamp": time.time(),
|
||||
"timestamp": now,
|
||||
"activity_event": event_type,
|
||||
}
|
||||
root = Path(
|
||||
@ -304,6 +333,25 @@ EVENT_CALLBACK_CALL_AFTER = ''' event_cb = self._make_run_event_callback(
|
||||
self._record_run_activity(session_id, "run.started")
|
||||
'''
|
||||
|
||||
RUN_SWEEP_BEFORE = ''' self._run_streams.pop(run_id, None)
|
||||
self._run_streams_created.pop(run_id, None)
|
||||
self._active_run_agents.pop(run_id, None)
|
||||
self._active_run_tasks.pop(run_id, None)
|
||||
self._run_approval_sessions.pop(run_id, None)
|
||||
'''
|
||||
|
||||
RUN_SWEEP_AFTER = ''' self._run_streams.pop(run_id, None)
|
||||
self._run_streams_created.pop(run_id, None)
|
||||
# Stream retention and run lifetime are separate. A long run
|
||||
# can legitimately outlive its unconsumed SSE queue; keep the
|
||||
# control handles so /stop and approval resolution still work.
|
||||
terminal_status = self._run_statuses.get(run_id, {}).get("status")
|
||||
if terminal_status in {"completed", "failed", "cancelled"}:
|
||||
self._active_run_agents.pop(run_id, None)
|
||||
self._active_run_tasks.pop(run_id, None)
|
||||
self._run_approval_sessions.pop(run_id, None)
|
||||
'''
|
||||
|
||||
|
||||
def patch(source: Path, destination: Path) -> None:
|
||||
"""Apply the narrow session-lineage extension and fail on upstream drift."""
|
||||
@ -319,6 +367,7 @@ def patch(source: Path, destination: Path) -> None:
|
||||
(EVENT_CALLBACK_BODY_BEFORE, "event callback body"),
|
||||
(EVENT_CALLBACK_END_BEFORE, "event callback end"),
|
||||
(EVENT_CALLBACK_CALL_BEFORE, "event callback call"),
|
||||
(RUN_SWEEP_BEFORE, "run stream sweep"),
|
||||
):
|
||||
if marker not in content:
|
||||
raise RuntimeError(f"Hermes API {message} patch context changed")
|
||||
@ -333,6 +382,7 @@ def patch(source: Path, destination: Path) -> None:
|
||||
)
|
||||
content = content.replace(EVENT_CALLBACK_BODY_BEFORE, EVENT_CALLBACK_BODY_AFTER, 1)
|
||||
content = content.replace(EVENT_CALLBACK_END_BEFORE, EVENT_CALLBACK_END_AFTER, 1)
|
||||
content = content.replace(RUN_SWEEP_BEFORE, RUN_SWEEP_AFTER, 1)
|
||||
destination.write_text(
|
||||
content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1),
|
||||
encoding="utf-8",
|
||||
|
||||
@ -52,7 +52,11 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(session_id: str, profile: Optional[str] = None):
|
||||
async def get_session_messages(
|
||||
session_id: str,
|
||||
profile: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
):
|
||||
'''
|
||||
|
||||
MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
|
||||
@ -64,7 +68,126 @@ MESSAGES_AFTER = ''' messages = [
|
||||
*_run_activity_messages(sid),
|
||||
]
|
||||
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
|
||||
return {"session_id": sid, "messages": messages}
|
||||
total_messages = len(messages)
|
||||
if limit is not None:
|
||||
messages = messages[-max(1, min(int(limit), 10_000)) :]
|
||||
return {
|
||||
"session_id": sid,
|
||||
"messages": messages,
|
||||
"total_messages": total_messages,
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
LATEST_ROWS_BEFORE = ''' "SELECT id, parent_session_id, started_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
})
|
||||
'''
|
||||
|
||||
LATEST_ROWS_AFTER = ''' "SELECT id, parent_session_id, started_at, ended_at FROM sessions"
|
||||
).fetchall()
|
||||
for row in raw_rows:
|
||||
rows.append({
|
||||
"id": row_get(row, "id", 0),
|
||||
"parent_session_id": row_get(row, "parent_session_id", 1),
|
||||
"started_at": row_get(row, "started_at", 2),
|
||||
"ended_at": row_get(row, "ended_at", 3),
|
||||
})
|
||||
'''
|
||||
|
||||
LATEST_SELECTION_BEFORE = ''' children = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
current = sid
|
||||
path = [sid]
|
||||
seen = {sid}
|
||||
|
||||
while children.get(current):
|
||||
candidates = [r for r in children[current] if r.get("id") not in seen]
|
||||
if not candidates:
|
||||
break
|
||||
candidates.sort(key=started, reverse=True)
|
||||
current = candidates[0]["id"]
|
||||
path.append(current)
|
||||
seen.add(current)
|
||||
|
||||
return current, path
|
||||
'''
|
||||
|
||||
LATEST_SELECTION_AFTER = ''' children = {}
|
||||
rows_by_id = {}
|
||||
for row in rows:
|
||||
rid = row.get("id")
|
||||
parent = row.get("parent_session_id")
|
||||
if rid:
|
||||
rows_by_id[rid] = row
|
||||
if rid and parent:
|
||||
children.setdefault(parent, []).append(row)
|
||||
|
||||
def started(row):
|
||||
try:
|
||||
return float(row.get("started_at") or 0)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
# Old dashboard versions rewrote the URL to a child without retaining the
|
||||
# root. Recover the oldest available ancestor so those existing live links
|
||||
# can also follow a resumed parent or a newer delegated sibling.
|
||||
anchor = sid
|
||||
ancestor_seen = {anchor}
|
||||
while True:
|
||||
parent = (rows_by_id.get(anchor) or {}).get("parent_session_id")
|
||||
if not parent or parent in ancestor_seen:
|
||||
break
|
||||
anchor = parent
|
||||
ancestor_seen.add(anchor)
|
||||
sid = anchor
|
||||
|
||||
root = db.get_session(sid) or {"id": sid}
|
||||
descendants = [(root, [sid])]
|
||||
stack = [(sid, [sid])]
|
||||
seen = {sid}
|
||||
while stack:
|
||||
parent, parent_path = stack.pop()
|
||||
candidates = sorted(children.get(parent, []), key=started, reverse=True)
|
||||
for row in candidates:
|
||||
child = row.get("id")
|
||||
if not child or child in seen:
|
||||
continue
|
||||
seen.add(child)
|
||||
child_path = [*parent_path, child]
|
||||
descendants.append((row, child_path))
|
||||
stack.append((child, child_path))
|
||||
|
||||
# A durable parent can resume after a delegated reviewer exits. Prefer the
|
||||
# newest still-open member of the lineage instead of stranding the browser
|
||||
# on the most recently created (but already finished) child.
|
||||
active = [item for item in descendants if item[0].get("ended_at") is None]
|
||||
if active:
|
||||
row, path = max(active, key=lambda item: (started(item[0]), len(item[1])))
|
||||
return row.get("id") or sid, path
|
||||
|
||||
leaves = [
|
||||
item for item in descendants
|
||||
if not any(child.get("id") in seen for child in children.get(item[0].get("id"), []))
|
||||
]
|
||||
row, path = max(leaves or descendants, key=lambda item: started(item[0]))
|
||||
return row.get("id") or sid, path
|
||||
'''
|
||||
|
||||
|
||||
@ -75,7 +198,13 @@ def patch(source: Path, destination: Path) -> None:
|
||||
raise RuntimeError("Hermes dashboard activity helper context changed")
|
||||
if MESSAGES_BEFORE not in content:
|
||||
raise RuntimeError("Hermes dashboard messages context changed")
|
||||
if LATEST_ROWS_BEFORE not in content:
|
||||
raise RuntimeError("Hermes dashboard lineage row context changed")
|
||||
if LATEST_SELECTION_BEFORE not in content:
|
||||
raise RuntimeError("Hermes dashboard lineage selection context changed")
|
||||
content = content.replace(HELPER_MARKER, HELPER_REPLACEMENT, 1)
|
||||
content = content.replace(LATEST_ROWS_BEFORE, LATEST_ROWS_AFTER, 1)
|
||||
content = content.replace(LATEST_SELECTION_BEFORE, LATEST_SELECTION_AFTER, 1)
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(
|
||||
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),
|
||||
|
||||
@ -80,6 +80,9 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
|
||||
assert "Only the foreground durable worker owns its Kanban task lifecycle" in instructions
|
||||
assert "must never complete, block, unblock, reclaim" in instructions
|
||||
assert "task's final structured result itself" in instructions
|
||||
assert "Atlas organization has private visibility" in instructions
|
||||
assert "private or Gitea-internal, never public" in instructions
|
||||
assert "already supplied through `GIT_ASKPASS`" in instructions
|
||||
|
||||
|
||||
def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
|
||||
@ -174,6 +177,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
assert "SessionActivityPanel" in dockerfile
|
||||
assert "include_children=${includeChildren}" in dockerfile
|
||||
assert "_dashboard_session_is_active" in dockerfile
|
||||
assert 'searchParams.get("lineage_root") || resumeParam' in dockerfile
|
||||
assert "followActiveLineage" in dockerfile
|
||||
assert 'next.delete("lineage_root")' in dockerfile
|
||||
activity = (
|
||||
ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx"
|
||||
).read_text()
|
||||
@ -184,6 +190,15 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
|
||||
assert "aria-busy={running && messages.length === 0}" in activity
|
||||
assert 'detail.source !== "api_server"' not in activity
|
||||
assert "Poll-backed transcript; terminal rendering is not required." in activity
|
||||
assert "DEFAULT_VISIBLE_MESSAGES = 250" in activity
|
||||
assert "current + DEFAULT_VISIBLE_MESSAGES" in activity
|
||||
assert 'Show up to{" "}' in activity
|
||||
assert "Math.min(" in activity
|
||||
assert "visibleMessages.map" in activity
|
||||
assert "response.total_messages" in activity
|
||||
assert "visibleLimit)," in activity
|
||||
assert "function displayText(value: unknown)" in activity
|
||||
assert "open && (" in activity
|
||||
assert "Open Telegram setup" in dockerfile
|
||||
|
||||
|
||||
@ -1071,6 +1086,7 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
|
||||
+ "tool start body\n"
|
||||
+ module.EVENT_CALLBACK_END_BEFORE
|
||||
+ module.EVENT_CALLBACK_CALL_BEFORE
|
||||
+ module.RUN_SWEEP_BEFORE
|
||||
+ "suffix\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
@ -1099,6 +1115,14 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
|
||||
assert 'self._record_run_activity(session_id, "run.started")' in patched
|
||||
assert 'detail = tool_name if event_type in {' in patched
|
||||
assert 'if event_type == "subagent.tool"' in patched
|
||||
assert "_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0" in patched
|
||||
assert 'heartbeats.get(session_id, 0.0)' in patched
|
||||
assert '"subagent.thinking",' in patched
|
||||
assert "Stream retention and run lifetime are separate" in patched
|
||||
assert 'terminal_status in {"completed", "failed", "cancelled"}' in patched
|
||||
assert patched.index("terminal_status = self._run_statuses") < patched.index(
|
||||
"self._active_run_tasks.pop(run_id, None)"
|
||||
)
|
||||
|
||||
|
||||
def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
|
||||
@ -1112,6 +1136,8 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
|
||||
destination = tmp_path / "patched.py"
|
||||
source.write_text(
|
||||
"prefix\n"
|
||||
+ module.LATEST_ROWS_BEFORE
|
||||
+ module.LATEST_SELECTION_BEFORE
|
||||
+ module.HELPER_MARKER
|
||||
+ " db = object()\n"
|
||||
+ module.MESSAGES_BEFORE
|
||||
@ -1128,6 +1154,114 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
|
||||
assert "entries[-1_000:]" in patched
|
||||
assert "*_run_activity_messages(sid)" in patched
|
||||
assert "messages.sort(" in patched
|
||||
assert "limit: Optional[int] = None" in patched
|
||||
assert "total_messages = len(messages)" in patched
|
||||
assert "min(int(limit), 10_000)" in patched
|
||||
assert '"total_messages": total_messages' in patched
|
||||
assert "SELECT id, parent_session_id, started_at, ended_at" in patched
|
||||
assert "newest still-open member of the lineage" in patched
|
||||
assert "oldest available ancestor" in patched
|
||||
assert 'item[0].get("ended_at") is None' in patched
|
||||
|
||||
|
||||
def test_web_session_lineage_returns_to_resumed_parent():
|
||||
"""An ended reviewer must not strand the live view away from its parent."""
|
||||
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_web_lineage", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
namespace: dict[str, object] = {}
|
||||
exec(
|
||||
"def select_active(sid, db, rows):\n" + module.LATEST_SELECTION_AFTER,
|
||||
namespace,
|
||||
)
|
||||
|
||||
class FakeDB:
|
||||
root = {
|
||||
"id": "root",
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
}
|
||||
|
||||
def get_session(self, _session_id):
|
||||
return self.root
|
||||
|
||||
rows = [
|
||||
{
|
||||
"id": "root",
|
||||
"parent_session_id": None,
|
||||
"started_at": 1.0,
|
||||
"ended_at": None,
|
||||
},
|
||||
{
|
||||
"id": "review-1",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 2.0,
|
||||
"ended_at": 3.0,
|
||||
},
|
||||
{
|
||||
"id": "review-2",
|
||||
"parent_session_id": "root",
|
||||
"started_at": 4.0,
|
||||
"ended_at": None,
|
||||
},
|
||||
]
|
||||
select_active = namespace["select_active"]
|
||||
|
||||
assert select_active("root", FakeDB(), rows) == (
|
||||
"review-2",
|
||||
["root", "review-2"],
|
||||
)
|
||||
assert select_active("review-1", FakeDB(), rows) == (
|
||||
"review-2",
|
||||
["root", "review-2"],
|
||||
)
|
||||
rows[1]["ended_at"] = 5.0
|
||||
rows[2]["ended_at"] = 5.0
|
||||
assert select_active("root", FakeDB(), rows) == ("root", ["root"])
|
||||
|
||||
|
||||
def test_api_activity_patch_coalesces_streaming_heartbeats(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
"""Token callbacks stay bounded while every tool transition is retained."""
|
||||
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
|
||||
spec = importlib.util.spec_from_file_location("patch_api_activity", module_path)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
activity_body = module.EVENT_CALLBACK_SIGNATURE_AFTER.split(
|
||||
" def _make_run_event_callback(", 1
|
||||
)[0]
|
||||
namespace: dict[str, object] = {}
|
||||
exec(
|
||||
"import hashlib, json, logging, os, time\n"
|
||||
"from pathlib import Path\n"
|
||||
"logger = logging.getLogger(__name__)\n"
|
||||
"def redact_sensitive_text(value): return value\n"
|
||||
"class ActivityRecorder:\n"
|
||||
+ activity_body,
|
||||
namespace,
|
||||
)
|
||||
recorder = namespace["ActivityRecorder"]()
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
|
||||
recorder._record_run_activity("session", "_thinking")
|
||||
recorder._record_run_activity("session", "reasoning.available")
|
||||
recorder._record_run_activity("session", "subagent.thinking")
|
||||
recorder._record_run_activity("session", "tool.started", tool_name="terminal")
|
||||
recorder._record_run_activity("session", "tool.completed", tool_name="terminal")
|
||||
|
||||
journals = list((tmp_path / "run-activity").glob("*.jsonl"))
|
||||
assert len(journals) == 1
|
||||
entries = [json.loads(line) for line in journals[0].read_text().splitlines()]
|
||||
assert [entry["activity_event"] for entry in entries] == [
|
||||
"_thinking",
|
||||
"tool.started",
|
||||
"tool.completed",
|
||||
]
|
||||
|
||||
|
||||
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):
|
||||
|
||||
@ -1204,6 +1204,15 @@ def test_owner_agent_installs_the_pinned_operator_toolchain():
|
||||
)
|
||||
assert "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2]
|
||||
assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"])
|
||||
init_config = next(
|
||||
item
|
||||
for item in deployment["spec"]["template"]["spec"]["initContainers"]
|
||||
if item["name"] == "init-config"
|
||||
)
|
||||
init_command = init_config["command"][2]
|
||||
assert "# Hermes managed operator PATH." in init_command
|
||||
assert "/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin" in init_command
|
||||
assert 'chmod 0644 "${profile_file}"' in init_command
|
||||
|
||||
|
||||
def test_owner_agent_uses_only_the_canonical_hostname():
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user