mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-06 14:22:47 +02:00
Apply per-card decisions in HITL bundle resume.
This commit is contained in:
parent
0af2c28a8d
commit
6a9433c8ff
3 changed files with 70 additions and 60 deletions
|
|
@ -136,20 +136,20 @@ def build_task_tool_with_parent_config(
|
||||||
|
|
||||||
if pending_value is not None:
|
if pending_value is not None:
|
||||||
resume_value = consume_surfsense_resume(runtime)
|
resume_value = consume_surfsense_resume(runtime)
|
||||||
if resume_value is not None:
|
if resume_value is None:
|
||||||
expected = hitlrequest_action_count(pending_value)
|
# Bridge invariant: a queued resume must accompany any pending
|
||||||
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
# subagent interrupt. Fall-through replay would silently re-prompt
|
||||||
result = subagent.invoke(
|
# the user; raise so the streaming layer surfaces a clear error.
|
||||||
build_resume_command(resume_value, pending_id),
|
raise RuntimeError(
|
||||||
config=sub_config,
|
f"Subagent {subagent_type!r} has a pending interrupt but no "
|
||||||
|
"surfsense_resume_value on config; resume bridge is broken."
|
||||||
)
|
)
|
||||||
else:
|
expected = hitlrequest_action_count(pending_value)
|
||||||
logger.warning(
|
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
||||||
"Subagent %r has pending interrupt but no surfsense_resume_value "
|
result = subagent.invoke(
|
||||||
"on config — replaying with fresh state (interrupt will re-fire).",
|
build_resume_command(resume_value, pending_id),
|
||||||
subagent_type,
|
config=sub_config,
|
||||||
)
|
)
|
||||||
result = subagent.invoke(subagent_state, config=sub_config)
|
|
||||||
else:
|
else:
|
||||||
result = subagent.invoke(subagent_state, config=sub_config)
|
result = subagent.invoke(subagent_state, config=sub_config)
|
||||||
maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
|
maybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
|
||||||
|
|
@ -201,20 +201,17 @@ def build_task_tool_with_parent_config(
|
||||||
|
|
||||||
if pending_value is not None:
|
if pending_value is not None:
|
||||||
resume_value = consume_surfsense_resume(runtime)
|
resume_value = consume_surfsense_resume(runtime)
|
||||||
if resume_value is not None:
|
if resume_value is None:
|
||||||
expected = hitlrequest_action_count(pending_value)
|
raise RuntimeError(
|
||||||
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
f"Subagent {subagent_type!r} has a pending interrupt but no "
|
||||||
result = await subagent.ainvoke(
|
"surfsense_resume_value on config; resume bridge is broken."
|
||||||
build_resume_command(resume_value, pending_id),
|
|
||||||
config=sub_config,
|
|
||||||
)
|
)
|
||||||
else:
|
expected = hitlrequest_action_count(pending_value)
|
||||||
logger.warning(
|
resume_value = fan_out_decisions_to_match(resume_value, expected)
|
||||||
"Subagent %r has pending interrupt but no surfsense_resume_value "
|
result = await subagent.ainvoke(
|
||||||
"on config — replaying with fresh state (interrupt will re-fire).",
|
build_resume_command(resume_value, pending_id),
|
||||||
subagent_type,
|
config=sub_config,
|
||||||
)
|
)
|
||||||
result = await subagent.ainvoke(subagent_state, config=sub_config)
|
|
||||||
else:
|
else:
|
||||||
result = await subagent.ainvoke(subagent_state, config=sub_config)
|
result = await subagent.ainvoke(subagent_state, config=sub_config)
|
||||||
await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
|
await amaybe_propagate_subagent_interrupt(subagent, sub_config, subagent_type)
|
||||||
|
|
|
||||||
|
|
@ -1283,36 +1283,36 @@ export default function NewChatPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge edited args if present to fix race condition
|
// Apply each decision to its own card by toolCallId so mixed
|
||||||
if (decisions.length > 0 && decisions[0].type === "edit" && decisions[0].edited_action) {
|
// bundles (approve/edit/reject) and multi-edit bundles do not
|
||||||
const editedAction = decisions[0].edited_action;
|
// collapse onto ``decisions[0]``. Cards outside the bundle are
|
||||||
|
// untouched. Mirrors the host ``hitl-decision`` handler.
|
||||||
|
const decisionByTcId = new Map<string, (typeof decisions)[number]>();
|
||||||
|
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||||
|
if (decisions.length === tcIds.length) {
|
||||||
|
for (let i = 0; i < tcIds.length; i++) decisionByTcId.set(tcIds[i], decisions[i]);
|
||||||
|
}
|
||||||
|
if (decisionByTcId.size > 0) {
|
||||||
for (const part of contentParts) {
|
for (const part of contentParts) {
|
||||||
if (part.type === "tool-call" && part.toolName === editedAction.name) {
|
if (part.type !== "tool-call") continue;
|
||||||
const mergedArgs = { ...part.args, ...editedAction.args };
|
const tcId = part.toolCallId as string | undefined;
|
||||||
|
const d = tcId ? decisionByTcId.get(tcId) : undefined;
|
||||||
|
if (!d) continue;
|
||||||
|
if (typeof part.result !== "object" || part.result === null) continue;
|
||||||
|
if (!("__interrupt__" in (part.result as Record<string, unknown>))) continue;
|
||||||
|
const decided = d.type as "approve" | "reject" | "edit";
|
||||||
|
if (decided === "edit" && d.edited_action) {
|
||||||
|
const mergedArgs = { ...part.args, ...d.edited_action.args };
|
||||||
part.args = mergedArgs;
|
part.args = mergedArgs;
|
||||||
// Sync argsText so the rendered card shows the
|
// Sync argsText so the rendered card shows the
|
||||||
// edited inputs — assistant-ui prefers caller-
|
// edited inputs (assistant-ui prefers it over
|
||||||
// supplied argsText over JSON.stringify(args).
|
// JSON.stringify(args)).
|
||||||
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
part.argsText = JSON.stringify(mergedArgs, null, 2);
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const decisionType = decisions[0]?.type as "approve" | "reject" | undefined;
|
|
||||||
if (decisionType) {
|
|
||||||
for (const part of contentParts) {
|
|
||||||
if (
|
|
||||||
part.type === "tool-call" &&
|
|
||||||
typeof part.result === "object" &&
|
|
||||||
part.result !== null &&
|
|
||||||
"__interrupt__" in (part.result as Record<string, unknown>)
|
|
||||||
) {
|
|
||||||
part.result = {
|
|
||||||
...(part.result as Record<string, unknown>),
|
|
||||||
__decided__: decisionType,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
part.result = {
|
||||||
|
...(part.result as Record<string, unknown>),
|
||||||
|
__decided__: decided,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1579,16 +1579,19 @@ export default function NewChatPage() {
|
||||||
const tcIds = pendingInterrupt.bundleToolCallIds;
|
const tcIds = pendingInterrupt.bundleToolCallIds;
|
||||||
const N = tcIds.length;
|
const N = tcIds.length;
|
||||||
|
|
||||||
// Build a per-card decision map. Bundle path: one decision per
|
// Bundles must submit exactly one decision per action_request.
|
||||||
// action_request in order. Legacy single-click on a multi-card
|
// Refuse rather than silently broadcast a single decision across
|
||||||
// interrupt: replay the last decision across the bundle.
|
// the bundle (would mis-apply rejects/edits and diverge from
|
||||||
const byTcId = new Map<string, (typeof incoming)[number]>();
|
// what handleResume sends to /resume).
|
||||||
if (incoming.length === N) {
|
if (N > 1 && incoming.length !== N) {
|
||||||
for (let i = 0; i < N; i++) byTcId.set(tcIds[i], incoming[i]);
|
toast.error(
|
||||||
} else {
|
`Cannot resume: ${incoming.length} decision(s) submitted for ${N} pending actions.`
|
||||||
const fallback = incoming[incoming.length - 1];
|
);
|
||||||
for (const tcId of tcIds) byTcId.set(tcId, fallback);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const byTcId = new Map<string, (typeof incoming)[number]>();
|
||||||
|
for (let i = 0; i < tcIds.length; i++) byTcId.set(tcIds[i], incoming[i]);
|
||||||
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
const submittedDecisions = tcIds.map((id) => byTcId.get(id)!);
|
||||||
|
|
||||||
setMessages((prev) =>
|
setMessages((prev) =>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,16 @@ export function useHitlDecision() {
|
||||||
const dispatch = useCallback(
|
const dispatch = useCallback(
|
||||||
(decisions: HitlDecision[]) => {
|
(decisions: HitlDecision[]) => {
|
||||||
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
|
if (bundle && toolCallId && bundle.isInBundle(toolCallId) && decisions.length > 0) {
|
||||||
|
if (decisions.length > 1 && process.env.NODE_ENV !== "production") {
|
||||||
|
// Tool-ui cards stage one decision per call; a multi-decision
|
||||||
|
// dispatch into an active bundle would silently drop tail entries.
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.warn(
|
||||||
|
"[hitl] dispatch received %d decisions inside an active bundle; only [0] will be staged for %s",
|
||||||
|
decisions.length,
|
||||||
|
toolCallId
|
||||||
|
);
|
||||||
|
}
|
||||||
bundle.stage(toolCallId, decisions[0]);
|
bundle.stage(toolCallId, decisions[0]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue