mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-25 17:36:43 +08:00
fix(core): stop the relay timeout path returning the target agent's tool output
HandleRelay accumulated every EventToolResult into textParts, and the timeout path hands textParts back to the caller verbatim. A relay whose target agent ran past the deadline therefore returned its whole tool log as if it were the answer: measured at 11932 chars in a single timed-out relay, against 165 to 2034 for turns that finished normally. That blob then sat in the caller's context and was re-billed on every later turn of its session — the token cost two /bind-ed bots were paying for nothing. Tool output is intermediate state, not an answer, so it no longer reaches textParts. The normal path is unaffected either way: both agent/claudecode and agent/cursor populate EventResult.Content, so resp is the final answer and textParts gets discarded. The timeout path also stops returning a bare truncated fragment as a success. relayPartialResponseOrError now trims to 2000 runes and wraps the fragment in an explicit notice (MsgRelayPartial, all five languages), because a silent fragment is indistinguishable from a real answer — the caller had no way to tell it had been cut off. It becomes a method on *Engine to reach i18n. TestHandleRelay_TimeoutExcludesToolResults is the regression test: a tool result carrying a sentinel is not relayed, assistant text still is. TestHandleRelay_ReturnsPartialOnTimeout keeps asserting the partial text survives, now through the notice.
This commit is contained in:
1 parent
9bef91f733
commit
5ae7b24fe3
3 files changed
+88
-20
No files matched your search
+24
-18
@@ -16427,17 +16427,13 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey,
|
||||
saveRelaySessionID(event.SessionID, false)
|
||||
}
|
||||
case EventToolResult:
|
||||
out := strings.TrimSpace(event.Content)
|
||||
if out == "" {
|
||||
out = strings.TrimSpace(event.ToolResult)
|
||||
}
|
||||
if out != "" {
|
||||
tn := strings.TrimSpace(event.ToolName)
|
||||
if tn == "" {
|
||||
tn = "tool"
|
||||
}
|
||||
textParts = append(textParts, fmt.Sprintf(e.i18n.T(MsgToolResult), tn, out)+"\n\n")
|
||||
}
|
||||
// Tool output is intermediate state, not an answer. It is
|
||||
// deliberately NOT accumulated into textParts: on the timeout
|
||||
// path textParts is handed back to the caller verbatim, so a
|
||||
// tool dump would be billed into the caller's context — and
|
||||
// re-billed on every later turn of that session.
|
||||
slog.Debug("relay: tool result (not relayed)",
|
||||
"from", fromProject, "to", e.name, "tool", event.ToolName)
|
||||
case EventResult:
|
||||
// Use agentSession.CurrentSessionID() for the same reason as above.
|
||||
if currentID := agentSession.CurrentSessionID(); currentID != "" {
|
||||
@@ -16471,7 +16467,7 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey,
|
||||
// background so the session state is saved cleanly and the
|
||||
// session remains resumable for the next relay call.
|
||||
go e.drainRelaySession(agentSession, session, sessions, agent.Name(), relaySessionKey)
|
||||
return relayPartialResponseOrError(ctx.Err(), textParts, fromProject, e.name)
|
||||
return e.relayPartialResponseOrError(ctx.Err(), textParts, fromProject, e.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16479,7 +16475,7 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey,
|
||||
agentSession.Close()
|
||||
|
||||
if ctx.Err() != nil {
|
||||
return relayPartialResponseOrError(ctx.Err(), textParts, fromProject, e.name)
|
||||
return e.relayPartialResponseOrError(ctx.Err(), textParts, fromProject, e.name)
|
||||
}
|
||||
|
||||
if len(textParts) > 0 {
|
||||
@@ -16488,19 +16484,29 @@ func (e *Engine) HandleRelay(ctx context.Context, fromProject, sourceSessionKey,
|
||||
return "", fmt.Errorf("relay: agent process exited without response")
|
||||
}
|
||||
|
||||
func relayPartialResponseOrError(ctxErr error, textParts []string, fromProject, toProject string) (string, error) {
|
||||
if len(textParts) == 0 {
|
||||
// relayPartialNoticeMaxRunes bounds the partial text handed back to the caller
|
||||
// when a relay times out. Tool output never reaches textParts (see
|
||||
// HandleRelay), so this only ever caps assistant prose.
|
||||
const relayPartialNoticeMaxRunes = 2000
|
||||
|
||||
// relayPartialResponseOrError reports a relay that hit its deadline before the
|
||||
// target agent produced a final EventResult. The partial text is wrapped in an
|
||||
// explicit timeout notice: without one the caller cannot tell a truncated
|
||||
// fragment from a real answer, and silently treats it as the answer.
|
||||
func (e *Engine) relayPartialResponseOrError(ctxErr error, textParts []string, fromProject, toProject string) (string, error) {
|
||||
partial := strings.TrimSpace(strings.Join(textParts, ""))
|
||||
if partial == "" {
|
||||
return "", ctxErr
|
||||
}
|
||||
partial = truncateRelay(partial, relayPartialNoticeMaxRunes)
|
||||
|
||||
resp := strings.Join(textParts, "")
|
||||
slog.Warn("relay: context done before final result; returning partial response",
|
||||
"from", fromProject,
|
||||
"to", toProject,
|
||||
"error", ctxErr,
|
||||
"response_len", len(resp),
|
||||
"response_len", len(partial),
|
||||
)
|
||||
return resp, nil
|
||||
return fmt.Sprintf(e.i18n.T(MsgRelayPartial), partial), nil
|
||||
}
|
||||
|
||||
// drainRelaySession runs in a goroutine after a relay timeout. It lets the
|
||||
|
||||
@@ -574,6 +574,7 @@ const (
|
||||
MsgRelaySetupOK MsgKey = "relay_setup_ok"
|
||||
MsgRelaySetupExists MsgKey = "relay_setup_exists"
|
||||
MsgRelaySetupNoMemory MsgKey = "relay_setup_no_memory"
|
||||
MsgRelayPartial MsgKey = "relay_partial_response"
|
||||
MsgSetupNative MsgKey = "setup_native"
|
||||
MsgCronSetupOK MsgKey = "cron_setup_ok"
|
||||
|
||||
@@ -3476,6 +3477,13 @@ var messages = map[MsgKey]map[Language]string{
|
||||
LangEnglish: "Current relay binding: %s",
|
||||
LangChinese: "当前中继绑定: %s",
|
||||
},
|
||||
MsgRelayPartial: {
|
||||
LangEnglish: "⏳ The other bot is still working — this relay timed out before it produced a final answer.\n\nPartial output so far:\n---\n%s",
|
||||
LangChinese: "⏳ 对方仍在运行 —— 本次中继在它给出最终结果前超时了。\n\n目前的片段输出:\n---\n%s",
|
||||
LangTraditionalChinese: "⏳ 對方仍在執行 —— 本次中繼在它給出最終結果前逾時了。\n\n目前的片段輸出:\n---\n%s",
|
||||
LangJapanese: "⏳ 相手はまだ実行中です —— 最終結果が出る前に中継がタイムアウトしました。\n\n現時点の部分的な出力:\n---\n%s",
|
||||
LangSpanish: "⏳ El otro bot sigue trabajando: este relay agotó el tiempo antes de dar una respuesta final.\n\nSalida parcial hasta ahora:\n---\n%s",
|
||||
},
|
||||
MsgRelayUsage: {
|
||||
LangEnglish: "Usage:\n /bind <project> — bind with another bot in this group\n /bind remove — remove binding\n /bind — show current binding\n\n<project> is the project name from config.toml [[projects]].",
|
||||
LangChinese: "用法:\n /bind <项目名> — 绑定群聊中的另一个机器人\n /bind remove — 解除绑定\n /bind — 查看当前绑定\n\n<项目名> 是 config.toml 中 [[projects]] 的 name 字段。",
|
||||
|
||||
+56
-2
@@ -385,8 +385,13 @@ func TestHandleRelay_ReturnsPartialOnTimeout(t *testing.T) {
|
||||
if got.err != nil {
|
||||
t.Fatalf("HandleRelay() error = %v, want nil", got.err)
|
||||
}
|
||||
if got.resp != "partial response" {
|
||||
t.Fatalf("HandleRelay() response = %q, want %q", got.resp, "partial response")
|
||||
// The partial text is wrapped in an explicit timeout notice, so the caller
|
||||
// can tell a truncated fragment from a real answer.
|
||||
if !strings.Contains(got.resp, "partial response") {
|
||||
t.Fatalf("HandleRelay() response = %q, want it to carry the partial text", got.resp)
|
||||
}
|
||||
if !strings.Contains(got.resp, "---") {
|
||||
t.Fatalf("HandleRelay() response = %q, want the timeout notice around the partial text", got.resp)
|
||||
}
|
||||
|
||||
// Wait for the background drain goroutine to close the session.
|
||||
@@ -397,6 +402,55 @@ func TestHandleRelay_ReturnsPartialOnTimeout(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleRelay_TimeoutExcludesToolResults is the regression test for the
|
||||
// relay timeout path leaking the target agent's tool output back to the caller.
|
||||
// Every EventToolResult used to be appended to textParts, and on timeout
|
||||
// textParts is handed back verbatim — so a turn that ran past the deadline
|
||||
// returned the whole tool log as if it were the answer. That blob then sat in
|
||||
// the caller's context and was re-billed on every later turn of its session.
|
||||
func TestHandleRelay_TimeoutExcludesToolResults(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
session := newControllableSession("relay-session")
|
||||
e.agent = &controllableAgent{nextSession: session}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
type relayResult struct {
|
||||
resp string
|
||||
err error
|
||||
}
|
||||
done := make(chan relayResult, 1)
|
||||
go func() {
|
||||
resp, err := e.HandleRelay(ctx, "source", "test:chat-1:user", "hello")
|
||||
done <- relayResult{resp: resp, err: err}
|
||||
}()
|
||||
|
||||
const toolDump = "TOOL-OUTPUT-MUST-NOT-BE-RELAYED"
|
||||
session.events <- Event{Type: EventToolResult, ToolName: "Bash", Content: toolDump, SessionID: "relay-session"}
|
||||
session.events <- Event{Type: EventText, Content: "working on it", SessionID: "relay-session"}
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
session.events <- Event{Type: EventThinking, Content: "still working"}
|
||||
session.events <- Event{Type: EventResult, Content: "done", Done: true}
|
||||
|
||||
got := <-done
|
||||
if got.err != nil {
|
||||
t.Fatalf("HandleRelay() error = %v, want nil", got.err)
|
||||
}
|
||||
if strings.Contains(got.resp, toolDump) {
|
||||
t.Fatalf("HandleRelay() leaked tool output to the caller: %q", got.resp)
|
||||
}
|
||||
if !strings.Contains(got.resp, "working on it") {
|
||||
t.Fatalf("HandleRelay() response = %q, want the assistant text preserved", got.resp)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-session.closed:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("background drain goroutine did not close the session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleRelay_TimeoutWithoutTextReturnsContextError(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
session := newControllableSession("relay-session")
|
||||
|
||||
Reference in new issue
Block a user