#1 工具布尔标记生效(agent-106, 安全红线): loop toolSchemas/runToolCalls 加 allow-set 安全门 restrictSchemas/applyToolSecurityGate——群聊/陌生人只暴露允许工具(默认关), 被拒not_allowed软提示+AuditEvent, owner全工具不变; 身份靠 ActorEnv(非提示词) #2 checkpoint 恢复(agent-109): run.go LoadLatest + resumableStopReason门控(只恢复in-flight工具循环,不出答案不续跑), 幂等不重放副作用, 新会话不误恢复; resume_test 4用例 #3 tool_result(agent-108, 协议层): Anthropic 原生tool_result块(tool_use_id/is_error)非user纯文本 + OpenAI tool消息补tool_call_id; Content加ToolCallID/IsError等; (loop接线层2处留待,见备注) #6 capacity_awaken推送(agent-107): onCapacity从只Debug改推WS hub(BroadcastCapacityAwaken, sessionId定向+payload对齐前端AwakenPayload) 管理面板移动端(agent-110): AdminApp 汉堡菜单+抽屉侧栏+标题truncate防换行, RBAC守卫不受影响 全仓 build/vet/test绿 + 前端build绿; 前端无涉差异已对齐 备注: #3 loop接线层2处(assistant工具轮append历史 + Message.ToolCallID/IsError)留给后续; #4代审/#5子代理触发/#7拆文件后置(主人说后三不急)
117 lines
4.7 KiB
Go
117 lines
4.7 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
|
)
|
|
|
|
// checkpointStore is the minimal surface the loop needs to persist a durable
|
|
// snapshot and resume an interrupted run (design §1 durable execution).
|
|
// *persist.Store satisfies it in production; tests substitute a fake to assert
|
|
// the resume path without a real database.
|
|
type checkpointStore interface {
|
|
// Save upserts a checkpoint.
|
|
Save(ctx context.Context, cp *persist.Checkpoint) error
|
|
// LoadLatest returns the most recently updated checkpoint for agentID, or
|
|
// ErrNotFound when the agent has none.
|
|
LoadLatest(ctx context.Context, agentID string) (*persist.Checkpoint, error)
|
|
}
|
|
|
|
// checkpointState is the durable snapshot persisted at every step. Carrying the
|
|
// message window and the step index lets a long-running task be resumed from the
|
|
// latest checkpoint without losing context (design §1 durable execution).
|
|
type checkpointState struct {
|
|
Step int `json:"step"`
|
|
Messages []model.Message `json:"messages"`
|
|
StopReason string `json:"stop_reason,omitempty"`
|
|
}
|
|
|
|
// saveCheckpoint persists a snapshot at step. It never fails the run: a nil store
|
|
// is a no-op and a marshal/save error is logged.
|
|
func (l *Loop) saveCheckpoint(ctx context.Context, sessionID string, step int, messages []model.Message, stopReason string) {
|
|
if l.checkpoint == nil {
|
|
return
|
|
}
|
|
state := checkpointState{
|
|
Step: step,
|
|
Messages: messages,
|
|
StopReason: stopReason,
|
|
}
|
|
buf, err := json.Marshal(state)
|
|
if err != nil {
|
|
l.log.Warn("checkpoint marshal", "step", step, "error", err)
|
|
return
|
|
}
|
|
cp := &persist.Checkpoint{
|
|
ID: fmt.Sprintf("%s:%d", sessionID, step),
|
|
AgentID: sessionID,
|
|
Step: step,
|
|
State: buf,
|
|
}
|
|
if err := l.checkpoint.Save(ctx, cp); err != nil {
|
|
l.log.Warn("checkpoint save", "step", step, "error", err)
|
|
}
|
|
}
|
|
|
|
// loadCheckpoint resumes an interrupted run from the session's latest durable
|
|
// checkpoint (design §1 durable execution). It returns the restored message
|
|
// window, the next loop step to run (the checkpoint step + 1) and whether a
|
|
// resumable checkpoint was found. When no checkpoint exists, or the latest one
|
|
// reflects a completed/blocked run, it returns the caller's messages with step 1
|
|
// so the loop starts fresh.
|
|
//
|
|
// Restoration is idempotent: the checkpoint captures the message window exactly as
|
|
// it stood at the end of the last completed step (assistant tool calls plus their
|
|
// results are already fused in), so resuming at the next step rebuilds the model
|
|
// context from history and never re-executes a completed step's tool call nor
|
|
// regenerates a finished answer. Loading is read-only — a resume never deletes the
|
|
// checkpoint, so every step's snapshot stays queryable.
|
|
func (l *Loop) loadCheckpoint(ctx context.Context, sessionID string, messages []model.Message) ([]model.Message, int, bool) {
|
|
if l.checkpoint == nil {
|
|
return messages, 1, false
|
|
}
|
|
cp, err := l.checkpoint.LoadLatest(ctx, sessionID)
|
|
if err != nil {
|
|
if !errors.Is(err, persist.ErrNotFound) {
|
|
l.log.Warn("checkpoint load", "session", sessionID, "error", err)
|
|
}
|
|
return messages, 1, false
|
|
}
|
|
st := checkpointState{}
|
|
if err := json.Unmarshal(cp.State, &st); err != nil {
|
|
l.log.Warn("checkpoint unmarshal", "session", sessionID, "step", cp.Step, "error", err)
|
|
return messages, 1, false
|
|
}
|
|
if !resumableStopReason(st.StopReason) {
|
|
l.log.Debug("checkpoint not resumable", "session", sessionID, "step", cp.Step, "stop_reason", st.StopReason)
|
|
return messages, 1, false
|
|
}
|
|
l.log.Info("checkpoint resumed", "session", sessionID, "step", cp.Step, "next_step", cp.Step+1, "messages", len(st.Messages))
|
|
return st.Messages, cp.Step + 1, true
|
|
}
|
|
|
|
// resumableStopReason reports whether a checkpoint whose last step ended with the
|
|
// given stop reason represents an in-flight run worth resuming. Only a
|
|
// mid-tool-loop step is genuinely "interrupted": the loop folded the tool results
|
|
// into the window and was about to continue, so picking up at the next step
|
|
// rebuilds context without duplicating work. A checkpoint carrying a final answer,
|
|
// a budget exhaustion, a safety-net termination or a guardrail denial is a
|
|
// completed/blocked run — resuming it would regenerate an already-produced answer
|
|
// (a duplicate side effect) — so those fall through to a fresh start (a caller
|
|
// opening a genuinely new conversation uses a distinct sessionID, which misses
|
|
// LoadLatest anyway).
|
|
func resumableStopReason(stopReason string) bool {
|
|
switch stopReason {
|
|
case "", agent.StopReasonToolCalls:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|