[codex] integrate goal command workflow (#1025)
* feat: integrate goal command workflow * fix: keep goal done visible * fix: add goal done slash command * fix: promote queued message on run start
This commit is contained in:
@@ -110,6 +110,7 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
|
||||
command: string
|
||||
handled: boolean
|
||||
type?: string
|
||||
action?: string
|
||||
message?: string
|
||||
output?: string
|
||||
notice?: string
|
||||
@@ -120,6 +121,30 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
|
||||
retry?: boolean
|
||||
retry_input?: AgentBridgeMessage
|
||||
title?: string
|
||||
kickoff_prompt?: string
|
||||
clear_goal_continuations?: boolean
|
||||
max_turns?: number
|
||||
}
|
||||
|
||||
export interface AgentBridgeGoalEvaluation extends AgentBridgeResponse {
|
||||
session_id: string
|
||||
handled: boolean
|
||||
active?: boolean
|
||||
status?: string | null
|
||||
should_continue?: boolean
|
||||
continuation_prompt?: string | null
|
||||
verdict?: string
|
||||
reason?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export interface AgentBridgeGoalPause extends AgentBridgeResponse {
|
||||
session_id: string
|
||||
handled: boolean
|
||||
active?: boolean
|
||||
status?: string | null
|
||||
reason?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
export class AgentBridgeError extends Error {
|
||||
@@ -419,6 +444,15 @@ export class AgentBridgeClient {
|
||||
})
|
||||
}
|
||||
|
||||
goalEvaluate(sessionId: string, finalResponse: string, profile?: string): Promise<AgentBridgeGoalEvaluation> {
|
||||
return this.request<AgentBridgeGoalEvaluation>({
|
||||
action: 'goal_evaluate',
|
||||
session_id: sessionId,
|
||||
final_response: finalResponse,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
|
||||
return this.request<AgentBridgeOutput>({
|
||||
action: 'get_output',
|
||||
@@ -474,6 +508,15 @@ export class AgentBridgeClient {
|
||||
})
|
||||
}
|
||||
|
||||
goalPause(sessionId: string, reason: string, profile?: string): Promise<AgentBridgeGoalPause> {
|
||||
return this.request<AgentBridgeGoalPause>({
|
||||
action: 'goal_pause',
|
||||
session_id: sessionId,
|
||||
reason,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'steer',
|
||||
@@ -518,6 +561,14 @@ export class AgentBridgeClient {
|
||||
})
|
||||
}
|
||||
|
||||
status(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'status',
|
||||
session_id: sessionId,
|
||||
...(profile ? { profile } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
|
||||
return this.request({
|
||||
action: 'destroy',
|
||||
|
||||
@@ -1482,6 +1482,11 @@ class AgentPool:
|
||||
arg = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
with _profile_env(profile):
|
||||
if name == "goal":
|
||||
return self._dispatch_goal_command(session_id, arg)
|
||||
if name == "subgoal":
|
||||
return self._dispatch_subgoal_command(session_id, arg)
|
||||
|
||||
try:
|
||||
try:
|
||||
from agent.skill_bundles import (
|
||||
@@ -1544,6 +1549,222 @@ class AgentPool:
|
||||
"message": f"not a supported bridge command: /{name}",
|
||||
}
|
||||
|
||||
def _goal_max_turns_from_config(self) -> int:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
goals_cfg = (load_config() or {}).get("goals") or {}
|
||||
return int(goals_cfg.get("max_turns", 20) or 20)
|
||||
except Exception:
|
||||
return 20
|
||||
|
||||
def _goal_manager(self, session_id: str):
|
||||
from hermes_cli.goals import GoalManager
|
||||
|
||||
return GoalManager(
|
||||
session_id=session_id,
|
||||
default_max_turns=self._goal_max_turns_from_config(),
|
||||
)
|
||||
|
||||
def _dispatch_goal_command(self, session_id: str, arg: str) -> dict[str, Any]:
|
||||
mgr = self._goal_manager(session_id)
|
||||
clean_arg = str(arg or "").strip()
|
||||
lower = clean_arg.lower()
|
||||
|
||||
if not clean_arg or lower == "status":
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "goal_status",
|
||||
"message": mgr.status_line(),
|
||||
}
|
||||
|
||||
if lower == "pause":
|
||||
state = mgr.pause(reason="user-paused")
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "pause",
|
||||
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
|
||||
"clear_goal_continuations": True,
|
||||
}
|
||||
|
||||
if lower == "resume":
|
||||
state = mgr.resume()
|
||||
prompt = mgr.next_continuation_prompt() if state else None
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "resume",
|
||||
"message": f"▶ Goal resumed: {state.goal}" if state else "No goal to resume.",
|
||||
"kickoff_prompt": prompt,
|
||||
"max_turns": state.max_turns if state else None,
|
||||
}
|
||||
|
||||
if lower in {"clear", "stop", "done"}:
|
||||
had = mgr.has_goal()
|
||||
mgr.clear()
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "clear",
|
||||
"message": "✓ Goal cleared." if had else "No active goal.",
|
||||
"clear_goal_continuations": True,
|
||||
}
|
||||
|
||||
try:
|
||||
state = mgr.set(clean_arg)
|
||||
except ValueError as exc:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "set",
|
||||
"message": f"Invalid goal: {exc}",
|
||||
}
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "set",
|
||||
"message": (
|
||||
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
|
||||
"After each turn, a judge model will check if the goal is done. "
|
||||
"Hermes keeps working until it is, you pause/clear it, or the budget is exhausted."
|
||||
),
|
||||
"kickoff_prompt": state.goal,
|
||||
"max_turns": state.max_turns,
|
||||
}
|
||||
|
||||
def _dispatch_subgoal_command(self, session_id: str, arg: str) -> dict[str, Any]:
|
||||
mgr = self._goal_manager(session_id)
|
||||
clean_arg = str(arg or "").strip()
|
||||
if not mgr.has_goal():
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "subgoal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "subgoal",
|
||||
"message": "No active goal. Set one with /goal <text>.",
|
||||
}
|
||||
|
||||
if not clean_arg:
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "subgoal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "subgoal_status",
|
||||
"message": f"{mgr.status_line()}\n{mgr.render_subgoals()}",
|
||||
}
|
||||
|
||||
tokens = clean_arg.split(None, 1)
|
||||
verb = tokens[0].lower()
|
||||
rest = tokens[1].strip() if len(tokens) > 1 else ""
|
||||
|
||||
if verb == "remove":
|
||||
if not rest:
|
||||
message = "Usage: /subgoal remove <n>"
|
||||
else:
|
||||
try:
|
||||
idx = int(rest.split()[0])
|
||||
removed = mgr.remove_subgoal(idx)
|
||||
message = f"✓ Removed subgoal {idx}: {removed}"
|
||||
except ValueError:
|
||||
message = "/subgoal remove: <n> must be an integer (1-based index)."
|
||||
except (IndexError, RuntimeError) as exc:
|
||||
message = f"/subgoal remove: {exc}"
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "subgoal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "subgoal_remove",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if verb == "clear":
|
||||
try:
|
||||
prev = mgr.clear_subgoals()
|
||||
message = f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." if prev else "No subgoals to clear."
|
||||
except RuntimeError as exc:
|
||||
message = f"/subgoal clear: {exc}"
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "subgoal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "subgoal_clear",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
try:
|
||||
text = mgr.add_subgoal(clean_arg)
|
||||
idx = len(mgr.state.subgoals) if mgr.state else 0
|
||||
message = f"✓ Added subgoal {idx}: {text}"
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
message = f"/subgoal: {exc}"
|
||||
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "subgoal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "subgoal_add",
|
||||
"message": message,
|
||||
}
|
||||
|
||||
def evaluate_goal(self, session_id: str, final_response: str, profile: str | None = None) -> dict[str, Any]:
|
||||
with _profile_env(profile):
|
||||
mgr = self._goal_manager(session_id)
|
||||
if not mgr.is_active():
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"handled": True,
|
||||
"active": False,
|
||||
"should_continue": False,
|
||||
"continuation_prompt": None,
|
||||
"message": "",
|
||||
"verdict": "inactive",
|
||||
}
|
||||
decision = mgr.evaluate_after_turn(str(final_response or ""), user_initiated=True)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"handled": True,
|
||||
"active": mgr.is_active(),
|
||||
**decision,
|
||||
}
|
||||
|
||||
def pause_goal(self, session_id: str, reason: str, profile: str | None = None) -> dict[str, Any]:
|
||||
with _profile_env(profile):
|
||||
clean_reason = str(reason or "").strip() or "paused"
|
||||
mgr = self._goal_manager(session_id)
|
||||
state = mgr.pause(reason=clean_reason)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"command": "goal",
|
||||
"handled": True,
|
||||
"type": "goal",
|
||||
"action": "pause",
|
||||
"active": mgr.is_active(),
|
||||
"status": state.status if state else None,
|
||||
"reason": clean_reason,
|
||||
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
|
||||
"clear_goal_continuations": True,
|
||||
}
|
||||
|
||||
def get_result(self, run_id: str) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
record = self._runs.get(run_id)
|
||||
@@ -1785,6 +2006,29 @@ class BridgeServer:
|
||||
req.get("profile"),
|
||||
)
|
||||
|
||||
if action == "goal_evaluate":
|
||||
session_id = str(req.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
return self.pool.evaluate_goal(
|
||||
session_id,
|
||||
str(req.get("final_response") or ""),
|
||||
req.get("profile"),
|
||||
)
|
||||
|
||||
if action == "goal_pause":
|
||||
session_id = str(req.get("session_id") or "").strip()
|
||||
if not session_id:
|
||||
raise ValueError("session_id is required")
|
||||
return self.pool.pause_goal(
|
||||
session_id,
|
||||
str(req.get("reason") or ""),
|
||||
req.get("profile"),
|
||||
)
|
||||
|
||||
if action == "status":
|
||||
return self.pool.status(str(req.get("session_id") or ""))
|
||||
|
||||
if action == "destroy":
|
||||
return self.pool.destroy(str(req.get("session_id") or ""))
|
||||
|
||||
@@ -2359,7 +2603,7 @@ class BridgeBroker:
|
||||
profile = self._profile_for_run(str(req.get("run_id") or ""))
|
||||
return self._forward(profile, req)
|
||||
|
||||
if action in {"interrupt", "steer", "command", "get_history", "destroy"}:
|
||||
if action in {"interrupt", "steer", "command", "goal_evaluate", "goal_pause", "status", "get_history", "destroy"}:
|
||||
session_id = str(req.get("session_id") or "")
|
||||
profile = self._profile_for_session(session_id, req.get("profile"))
|
||||
resp = self._forward(profile, req)
|
||||
|
||||
@@ -64,6 +64,12 @@ export async function handleAbort(
|
||||
} catch (err) {
|
||||
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
|
||||
}
|
||||
try {
|
||||
await bridge.goalPause?.(sessionId, 'user-interrupted', state.profile)
|
||||
state.queue = state.queue.filter(item => !item.goalContinuation)
|
||||
} catch (err) {
|
||||
logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId)
|
||||
}
|
||||
} else if (state.abortController) {
|
||||
state.abortController.abort()
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
recordBridgeToolCompleted,
|
||||
} from './bridge-message'
|
||||
import { summarizeToolArguments } from './response-utils'
|
||||
import type { ContentBlock, SessionState } from './types'
|
||||
import type { ContentBlock, QueuedRun, SessionState } from './types'
|
||||
import type { ChatMessage } from '../../../lib/context-compressor'
|
||||
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
|
||||
import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } from './bridge-delta'
|
||||
@@ -349,6 +349,7 @@ export async function handleBridgeRun(
|
||||
dequeueNextQueuedRun,
|
||||
fullInstructions,
|
||||
{ model: resolvedModel, provider: resolvedProvider },
|
||||
data.model_groups,
|
||||
)
|
||||
if (chunk.done) break
|
||||
}
|
||||
@@ -485,6 +486,7 @@ async function applyBridgeChunkAsync(
|
||||
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
|
||||
instructions: string,
|
||||
modelContext: { model?: string | null; provider?: string | null },
|
||||
modelGroups?: RunModelGroup[],
|
||||
): Promise<void> {
|
||||
if (state.activeRunMarker !== runMarker) {
|
||||
bridgeLogger.info({
|
||||
@@ -737,11 +739,13 @@ async function applyBridgeChunkAsync(
|
||||
replaceState(sessionMap, sessionId, 'compression.completed', payload)
|
||||
emit('compression.completed', payload)
|
||||
} else if (evType === 'status') {
|
||||
emit('agent.event', {
|
||||
const payload = {
|
||||
...ev,
|
||||
event: 'agent.event',
|
||||
run_id: chunk.run_id,
|
||||
...ev,
|
||||
})
|
||||
}
|
||||
replaceState(sessionMap, sessionId, 'agent.event', payload)
|
||||
emit('agent.event', payload)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,19 +816,15 @@ async function applyBridgeChunkAsync(
|
||||
outputTokens: usage.outputTokens,
|
||||
profile: state.profile,
|
||||
})
|
||||
const nextQueuedRun = state.queue.length > 0 ? state.queue[0] : undefined
|
||||
state.isWorking = Boolean(nextQueuedRun)
|
||||
const terminalError = bridgeTerminalError(chunk)
|
||||
const hadQueuedRunBeforeGoalEvaluation = state.queue.length > 0
|
||||
state.isWorking = hadQueuedRunBeforeGoalEvaluation
|
||||
state.isAborting = false
|
||||
if (nextQueuedRun) {
|
||||
state.profile = nextQueuedRun.profile || profile
|
||||
state.source = nextQueuedRun.source
|
||||
} else {
|
||||
state.profile = undefined
|
||||
}
|
||||
state.profile = hadQueuedRunBeforeGoalEvaluation ? (state.queue[0]?.profile || profile) : undefined
|
||||
state.source = hadQueuedRunBeforeGoalEvaluation ? state.queue[0]?.source : state.source
|
||||
state.runId = undefined
|
||||
state.activeRunMarker = undefined
|
||||
state.events = []
|
||||
const terminalError = bridgeTerminalError(chunk)
|
||||
const eventName = terminalError ? 'run.failed' : 'run.completed'
|
||||
const payload = {
|
||||
event: eventName,
|
||||
@@ -838,8 +838,157 @@ async function applyBridgeChunkAsync(
|
||||
queue_remaining: state.queue.length,
|
||||
}
|
||||
emit(eventName, payload)
|
||||
if (state.queue.length > 0) {
|
||||
|
||||
if (!terminalError) {
|
||||
await maybeEnqueueGoalContinuation({
|
||||
nsp,
|
||||
socket,
|
||||
sessionId,
|
||||
state,
|
||||
bridge,
|
||||
profile,
|
||||
modelContext,
|
||||
modelGroups,
|
||||
instructions,
|
||||
finalResponse: bridgeFinalResponse(chunk, state),
|
||||
})
|
||||
}
|
||||
|
||||
if (state.queue.length > 0 && !state.activeRunMarker) {
|
||||
const nextQueuedRun = state.queue[0]
|
||||
state.isWorking = true
|
||||
state.profile = nextQueuedRun.profile || profile
|
||||
state.source = nextQueuedRun.source
|
||||
dequeueNextQueuedRun(socket, sessionId)
|
||||
} else if (!state.activeRunMarker) {
|
||||
state.isWorking = false
|
||||
state.profile = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function bridgeFinalResponse(chunk: AgentBridgeOutput, state: SessionState): string {
|
||||
const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result)
|
||||
? chunk.result as Record<string, unknown>
|
||||
: null
|
||||
const finalResponse = result && typeof result.final_response === 'string'
|
||||
? result.final_response
|
||||
: ''
|
||||
return finalResponse || chunk.output || state.bridgeOutput || ''
|
||||
}
|
||||
|
||||
function hasRealQueuedRun(state: SessionState): boolean {
|
||||
return state.queue.some(item => !item.goalContinuation)
|
||||
}
|
||||
|
||||
async function maybeEnqueueGoalContinuation(args: {
|
||||
nsp: ReturnType<Server['of']>
|
||||
socket: Socket
|
||||
sessionId: string
|
||||
state: SessionState
|
||||
bridge: AgentBridgeClient
|
||||
profile: string
|
||||
modelContext: { model?: string | null; provider?: string | null }
|
||||
modelGroups?: RunModelGroup[]
|
||||
instructions: string
|
||||
finalResponse: string
|
||||
}) {
|
||||
const finalResponse = args.finalResponse || ''
|
||||
if (!finalResponse.trim()) return
|
||||
if (hasRealQueuedRun(args.state)) return
|
||||
|
||||
let decision
|
||||
try {
|
||||
decision = await args.bridge.goalEvaluate(args.sessionId, finalResponse, args.profile)
|
||||
} catch (err) {
|
||||
logger.warn(err, '[chat-run-socket] /goal evaluation failed for session %s', args.sessionId)
|
||||
return
|
||||
}
|
||||
|
||||
if (isGoalJudgeUnavailable(decision.reason)) {
|
||||
emitGoalStatus(
|
||||
args.nsp,
|
||||
args.socket,
|
||||
args.sessionId,
|
||||
args.state,
|
||||
'judge_unavailable',
|
||||
'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const message = typeof decision.message === 'string' ? decision.message.trim() : ''
|
||||
if (message) emitGoalStatus(args.nsp, args.socket, args.sessionId, args.state, decision.verdict || 'goal', message)
|
||||
|
||||
if (!decision.should_continue) return
|
||||
if (hasRealQueuedRun(args.state)) return
|
||||
|
||||
const prompt = typeof decision.continuation_prompt === 'string'
|
||||
? decision.continuation_prompt.trim()
|
||||
: ''
|
||||
if (!prompt) return
|
||||
|
||||
const next: QueuedRun = {
|
||||
queue_id: `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
input: prompt,
|
||||
displayInput: null,
|
||||
storageMessage: prompt,
|
||||
model: args.modelContext.model || undefined,
|
||||
provider: args.modelContext.provider || undefined,
|
||||
model_groups: args.modelGroups,
|
||||
instructions: undefined,
|
||||
profile: args.profile,
|
||||
source: 'cli',
|
||||
goalContinuation: true,
|
||||
}
|
||||
args.state.queue.push(next)
|
||||
}
|
||||
|
||||
function isGoalJudgeUnavailable(reason?: string | null): boolean {
|
||||
const value = String(reason || '').toLowerCase()
|
||||
return value.includes('no auxiliary client configured') || value.includes('auxiliary client unavailable')
|
||||
}
|
||||
|
||||
function emitGoalStatus(
|
||||
nsp: ReturnType<Server['of']>,
|
||||
socket: Socket,
|
||||
sessionId: string,
|
||||
state: SessionState,
|
||||
action: string,
|
||||
message: string,
|
||||
) {
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const id = addMessage({
|
||||
session_id: sessionId,
|
||||
role: 'command',
|
||||
content: message,
|
||||
timestamp: now,
|
||||
})
|
||||
state.messages.push({
|
||||
id: id || `goal_${now}_${state.messages.length}`,
|
||||
session_id: sessionId,
|
||||
role: 'command',
|
||||
content: message,
|
||||
timestamp: now,
|
||||
})
|
||||
nsp.to(`session:${sessionId}`).emit('session.command', {
|
||||
event: 'session.command',
|
||||
session_id: sessionId,
|
||||
command: 'goal',
|
||||
ok: true,
|
||||
action,
|
||||
message,
|
||||
terminal: false,
|
||||
})
|
||||
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
|
||||
socket.emit('session.command', {
|
||||
event: 'session.command',
|
||||
session_id: sessionId,
|
||||
command: 'goal',
|
||||
ok: true,
|
||||
action,
|
||||
message,
|
||||
terminal: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ export class ChatRunSocket {
|
||||
bridge: this.bridge,
|
||||
profile: runProfile,
|
||||
model: data.model,
|
||||
provider: data.provider,
|
||||
model_groups: data.model_groups,
|
||||
instructions: data.instructions,
|
||||
queueId: data.queue_id,
|
||||
runQueuedItem: this.runQueuedItem.bind(this),
|
||||
@@ -393,12 +395,10 @@ export class ChatRunSocket {
|
||||
}
|
||||
|
||||
private serializeQueuedMessages(queue: QueuedRun[]) {
|
||||
return queue.map(item => ({
|
||||
return queue.filter(item => item.displayInput !== null).map(item => ({
|
||||
id: item.queue_id,
|
||||
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
|
||||
content: item.displayInput === null
|
||||
? (item.storageMessage || '')
|
||||
: contentBlocksToString(item.displayInput ?? item.input),
|
||||
content: contentBlocksToString(item.displayInput ?? item.input),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
queued: true,
|
||||
}))
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function resolveBridgeRunModelConfig(options: {
|
||||
const candidateProvider = sessionProvider || requestedProvider
|
||||
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
|
||||
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
|
||||
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
|
||||
const shouldUseDefault = !candidateModel || !candidateProvider || (hasGroups && !candidateAvailable)
|
||||
return shouldUseDefault
|
||||
? resolveDefaultModelConfig(options.profile)
|
||||
: { model: candidateModel, provider: candidateProvider }
|
||||
|
||||
@@ -15,6 +15,8 @@ type CommandName =
|
||||
| 'abort'
|
||||
| 'queue'
|
||||
| 'plan'
|
||||
| 'goal'
|
||||
| 'subgoal'
|
||||
| 'clear'
|
||||
| 'title'
|
||||
| 'compress'
|
||||
@@ -34,6 +36,8 @@ interface SessionCommandContext {
|
||||
bridge: AgentBridgeClient
|
||||
profile: string
|
||||
model?: string
|
||||
provider?: string
|
||||
model_groups?: Array<{ provider: string; models: string[] }>
|
||||
instructions?: string
|
||||
queueId?: string
|
||||
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
|
||||
@@ -45,6 +49,8 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
|
||||
abort: 'abort',
|
||||
queue: 'queue',
|
||||
plan: 'plan',
|
||||
goal: 'goal',
|
||||
subgoal: 'subgoal',
|
||||
clear: 'clear',
|
||||
title: 'title',
|
||||
compress: 'compress',
|
||||
@@ -120,24 +126,30 @@ export async function handleSessionCommand(
|
||||
|
||||
case 'status': {
|
||||
const row = getSession(sessionId)
|
||||
const bridgeStatus = await getBridgeSessionStatus(ctx, sessionId)
|
||||
const bridgeRunning = bridgeStatus?.running === true
|
||||
const isWorking = state.isWorking || bridgeRunning
|
||||
const runId = state.runId || state.activeRunMarker || bridgeStatus?.currentRunId || null
|
||||
emitCommand({
|
||||
action: 'status',
|
||||
terminal: !state.isWorking,
|
||||
terminal: !isWorking,
|
||||
message: [
|
||||
`Status: ${state.isWorking ? 'running' : 'idle'}`,
|
||||
`Status: ${isWorking ? 'running' : 'idle'}`,
|
||||
`source: ${state.source || row?.source || 'cli'}`,
|
||||
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
|
||||
`model: ${ctx.model || row?.model || '-'}`,
|
||||
`queue: ${state.queue.length}`,
|
||||
`run: ${state.runId || state.activeRunMarker || '-'}`,
|
||||
].join(', '),
|
||||
isWorking: state.isWorking,
|
||||
`run: ${runId || '-'}`,
|
||||
bridgeStatus ? `bridge: ${bridgeRunning ? 'running' : 'idle'}` : null,
|
||||
].filter(Boolean).join(', '),
|
||||
isWorking,
|
||||
isAborting: Boolean(state.isAborting),
|
||||
queueLength: state.queue.length,
|
||||
source: state.source || row?.source || 'cli',
|
||||
profile: state.profile || ctx.profile || row?.profile || 'default',
|
||||
model: ctx.model || row?.model || null,
|
||||
runId: state.runId || state.activeRunMarker || null,
|
||||
runId,
|
||||
bridgeStatus,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -161,6 +173,8 @@ export async function handleSessionCommand(
|
||||
queue_id: queueId,
|
||||
input: command.args,
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
model_groups: ctx.model_groups,
|
||||
instructions: ctx.instructions,
|
||||
profile: ctx.profile,
|
||||
source: 'cli',
|
||||
@@ -170,13 +184,7 @@ export async function handleSessionCommand(
|
||||
event: 'run.queued',
|
||||
session_id: sessionId,
|
||||
queue_length: state.queue.length,
|
||||
queued_messages: state.queue.map(item => ({
|
||||
id: item.queue_id,
|
||||
role: 'user',
|
||||
content: contentBlocksToString(item.input),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
queued: true,
|
||||
})),
|
||||
queued_messages: serializeVisibleQueuedMessages(state.queue),
|
||||
})
|
||||
emitCommand({
|
||||
action: 'queue',
|
||||
@@ -221,6 +229,8 @@ export async function handleSessionCommand(
|
||||
displayRole: 'command',
|
||||
storageMessage: displayCommand,
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
model_groups: ctx.model_groups,
|
||||
instructions: ctx.instructions,
|
||||
profile: ctx.profile,
|
||||
source: 'cli',
|
||||
@@ -233,15 +243,7 @@ export async function handleSessionCommand(
|
||||
event: 'run.queued',
|
||||
session_id: sessionId,
|
||||
queue_length: state.queue.length,
|
||||
queued_messages: state.queue.map(item => ({
|
||||
id: item.queue_id,
|
||||
role: typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user',
|
||||
content: item.displayInput === null
|
||||
? (item.storageMessage || '')
|
||||
: contentBlocksToString(item.displayInput ?? item.input),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
queued: true,
|
||||
})),
|
||||
queued_messages: serializeVisibleQueuedMessages(state.queue),
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -255,6 +257,88 @@ export async function handleSessionCommand(
|
||||
return
|
||||
}
|
||||
|
||||
case 'goal':
|
||||
case 'subgoal': {
|
||||
const isGoalSet = command.name === 'goal'
|
||||
&& Boolean(command.args)
|
||||
&& !['status', 'pause', 'resume', 'clear', 'stop', 'done'].includes(command.args.toLowerCase())
|
||||
if (state.isWorking && isGoalSet) {
|
||||
emitCommand({
|
||||
ok: false,
|
||||
action: 'goal',
|
||||
terminal: false,
|
||||
message: 'Agent is running. Use /goal status, /goal pause, or /goal clear mid-run, or /abort before setting a new goal.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const bridgeCommand = `${command.name}${command.args ? ` ${command.args}` : ''}`
|
||||
let result
|
||||
try {
|
||||
result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile)
|
||||
} catch (err) {
|
||||
emitCommand({
|
||||
ok: false,
|
||||
action: command.name,
|
||||
terminal: !state.isWorking,
|
||||
message: `Goal command failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result.clear_goal_continuations) {
|
||||
const removed = removeGoalContinuationRuns(state)
|
||||
if (removed > 0) emitQueuedState(ctx, sessionId, state)
|
||||
}
|
||||
|
||||
const kickoffPrompt = typeof result.kickoff_prompt === 'string' ? result.kickoff_prompt.trim() : ''
|
||||
|
||||
const bridgeStatus = result.action === 'goal_status' || result.action === 'status'
|
||||
? await getBridgeSessionStatus(ctx, sessionId)
|
||||
: null
|
||||
const message = formatGoalStatusMessage(String(result.message || ''), bridgeStatus)
|
||||
|
||||
const resultAction = String(result.action || command.name)
|
||||
const action = (command.name === 'goal' || command.name === 'subgoal') && resultAction === 'clear'
|
||||
? `${command.name}_clear`
|
||||
: resultAction
|
||||
|
||||
emitCommand({
|
||||
action,
|
||||
terminal: !state.isWorking && !kickoffPrompt,
|
||||
started: Boolean(kickoffPrompt),
|
||||
message,
|
||||
type: result.type || 'goal',
|
||||
maxTurns: result.max_turns,
|
||||
bridgeStatus,
|
||||
})
|
||||
|
||||
if (!kickoffPrompt) return
|
||||
|
||||
const next: QueuedRun = {
|
||||
queue_id: ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
|
||||
input: kickoffPrompt,
|
||||
displayInput: null,
|
||||
storageMessage: kickoffPrompt,
|
||||
model: ctx.model,
|
||||
provider: ctx.provider,
|
||||
model_groups: ctx.model_groups,
|
||||
instructions: ctx.instructions,
|
||||
profile: ctx.profile,
|
||||
source: 'cli',
|
||||
originSocketId: ctx.socket.id,
|
||||
}
|
||||
|
||||
if (state.isWorking) {
|
||||
state.queue.push(next)
|
||||
emitQueuedState(ctx, sessionId, state)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile)
|
||||
return
|
||||
}
|
||||
|
||||
case 'clear': {
|
||||
if (command.args === '--history') {
|
||||
if (state.isWorking) {
|
||||
@@ -462,6 +546,79 @@ function clearTransientRunState(state: SessionState) {
|
||||
state.isAborting = false
|
||||
}
|
||||
|
||||
function removeGoalContinuationRuns(state: SessionState): number {
|
||||
const before = state.queue.length
|
||||
state.queue = state.queue.filter(item => !item.goalContinuation)
|
||||
return before - state.queue.length
|
||||
}
|
||||
|
||||
function emitQueuedState(ctx: SessionCommandContext, sessionId: string, state: SessionState) {
|
||||
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
|
||||
event: 'run.queued',
|
||||
session_id: sessionId,
|
||||
queue_length: state.queue.length,
|
||||
queued_messages: serializeVisibleQueuedMessages(state.queue),
|
||||
})
|
||||
}
|
||||
|
||||
function serializeVisibleQueuedMessages(queue: QueuedRun[]) {
|
||||
return queue.filter(item => item.displayInput !== null).map(item => ({
|
||||
id: item.queue_id,
|
||||
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
|
||||
content: contentBlocksToString(item.displayInput ?? item.input),
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
queued: true,
|
||||
}))
|
||||
}
|
||||
|
||||
type BridgeSessionStatus = {
|
||||
exists: boolean
|
||||
running: boolean
|
||||
currentRunId: string | null
|
||||
messageCount: number
|
||||
}
|
||||
|
||||
async function getBridgeSessionStatus(ctx: SessionCommandContext, sessionId: string): Promise<BridgeSessionStatus | null> {
|
||||
try {
|
||||
const raw = await ctx.bridge.status(sessionId, ctx.profile) as Record<string, unknown>
|
||||
return {
|
||||
exists: raw.exists === true,
|
||||
running: raw.running === true,
|
||||
currentRunId: typeof raw.current_run_id === 'string' && raw.current_run_id.trim()
|
||||
? raw.current_run_id
|
||||
: null,
|
||||
messageCount: typeof raw.message_count === 'number' && Number.isFinite(raw.message_count)
|
||||
? raw.message_count
|
||||
: 0,
|
||||
}
|
||||
} catch (err) {
|
||||
logger.debug({ err, sessionId }, '[chat-run-socket] bridge status lookup failed')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatGoalStatusMessage(message: string, bridgeStatus: BridgeSessionStatus | null): string {
|
||||
if (!bridgeStatus) return message
|
||||
const lines = [message]
|
||||
if (bridgeStatus.running) {
|
||||
const progress = parseGoalTurnProgress(message)
|
||||
lines.push(progress
|
||||
? `Current turn: ${Math.min(progress.used + 1, progress.max)}/${progress.max} running (completed turns: ${progress.used}/${progress.max}; count updates after the judge).`
|
||||
: 'Current turn: running (turn count updates after the judge).')
|
||||
}
|
||||
lines.push(`Run: ${bridgeStatus.running ? 'running' : 'idle'}${bridgeStatus.currentRunId ? ` (${bridgeStatus.currentRunId})` : ''}`)
|
||||
return lines.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
function parseGoalTurnProgress(message: string): { used: number; max: number } | null {
|
||||
const match = message.match(/\b(\d+)\s*\/\s*(\d+)\s+turns\b/i)
|
||||
if (!match) return null
|
||||
const used = Number(match[1])
|
||||
const max = Number(match[2])
|
||||
if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return null
|
||||
return { used, max }
|
||||
}
|
||||
|
||||
function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) {
|
||||
if (getSession(sessionId)) return
|
||||
createSession({
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface QueuedRun {
|
||||
profile: string
|
||||
source?: ChatRunSource
|
||||
originSocketId?: string
|
||||
goalContinuation?: boolean
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
|
||||
Reference in New Issue
Block a user