feat(ai): session harness contract — incomplete, run_id, confirm bind
Add docs/16 for session state machine, incomplete/resume, queue semantics, and agent-loop/harness/MCP gap review. Wire RunOutcome on max-rounds, run_id through audit, TrimMessages/Redact hooks, ConfirmBindHash on write confirm path; session file skeleton without HTTP yet. No push.
This commit is contained in:
1 parent
0aeee2b692
commit
5416e86379
17 files changed
+1041
-61
No files matched your search
+137
-40
@@ -19,13 +19,17 @@ const (
|
||||
|
||||
// AgentReq 侧栏 Agent 请求(与 Ask 的 /api/ai/chat 分路径)。
|
||||
type AgentReq struct {
|
||||
Prompt string `json:"prompt"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
Database string `json:"database,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxRounds int `json:"maxRounds,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
ConnectionID string `json:"connectionId,omitempty"`
|
||||
Driver string `json:"driver,omitempty"`
|
||||
Database string `json:"database,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
MaxRounds int `json:"maxRounds,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Continue bool `json:"continue,omitempty"` // 真·续跑:注入 Prior
|
||||
Prior []Message `json:"prior,omitempty"` // 含 tool 轨迹的历史(不含本轮 user)
|
||||
RunID string `json:"runId,omitempty"` // 可选;空则自动生成
|
||||
}
|
||||
|
||||
// AgentStep 挂在回复下的步骤时间线一项。
|
||||
@@ -38,16 +42,23 @@ type AgentStep struct {
|
||||
Ms int64 `json:"ms"`
|
||||
NeedConfirm bool `json:"needConfirm,omitempty"`
|
||||
ConfirmSQL string `json:"confirmSql,omitempty"`
|
||||
ConfirmBind string `json:"confirmBind,omitempty"`
|
||||
Pending bool `json:"pending,omitempty"`
|
||||
Result *AgentQueryResult `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// AgentRes Agent 最终回答 + 步骤。
|
||||
// AgentRes Agent 最终回答 + 步骤 + 运行结果契约。
|
||||
type AgentRes struct {
|
||||
Content string `json:"content"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
Steps []AgentStep `json:"steps"`
|
||||
Rounds int `json:"rounds"`
|
||||
Content string `json:"content"`
|
||||
SQL string `json:"sql,omitempty"`
|
||||
Steps []AgentStep `json:"steps"`
|
||||
Rounds int `json:"rounds"`
|
||||
RunID string `json:"runId,omitempty"`
|
||||
SessionID string `json:"sessionId,omitempty"`
|
||||
Outcome string `json:"runOutcome,omitempty"`
|
||||
IncompleteReason string `json:"incompleteReason,omitempty"`
|
||||
Resumable bool `json:"resumable,omitempty"`
|
||||
StepsBudget int `json:"stepsBudget,omitempty"`
|
||||
}
|
||||
|
||||
// RunAgent 多轮:LLM tool_call → aitools.Runtime.Call → 回灌。
|
||||
@@ -69,39 +80,74 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
return AgentRes{}, fmt.Errorf("tool chat LLM required")
|
||||
}
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
if prompt == "" && !(req.Continue && len(req.Prior) > 0) {
|
||||
return AgentRes{}, fmt.Errorf("prompt required")
|
||||
}
|
||||
maxRounds := req.MaxRounds
|
||||
if maxRounds <= 0 {
|
||||
maxRounds = defaultAgentMaxRounds
|
||||
}
|
||||
if maxRounds > 16 {
|
||||
maxRounds = 16
|
||||
}
|
||||
h := DefaultHarness()
|
||||
maxRounds := h.ClampRounds(req.MaxRounds)
|
||||
|
||||
runID := strings.TrimSpace(req.RunID)
|
||||
if runID == "" {
|
||||
runID = NewRunID()
|
||||
}
|
||||
rt.RunID = runID
|
||||
// Agent 路径禁止模型自行带确认令牌
|
||||
rt.RejectCallerConfirm = true
|
||||
|
||||
tools := aitools.OpenAITools()
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: buildAgentSystem(req)},
|
||||
{Role: "user", Content: prompt},
|
||||
var msgs []Message
|
||||
if req.Continue && len(req.Prior) > 0 {
|
||||
msgs = append([]Message{}, req.Prior...)
|
||||
hasSystem := false
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
hasSystem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasSystem {
|
||||
msgs = append([]Message{{Role: "system", Content: buildAgentSystem(req)}}, msgs...)
|
||||
}
|
||||
if prompt != "" {
|
||||
msgs = append(msgs, Message{Role: "user", Content: prompt})
|
||||
}
|
||||
msgs = TrimMessages(msgs, h)
|
||||
} else {
|
||||
msgs = []Message{
|
||||
{Role: "system", Content: buildAgentSystem(req)},
|
||||
{Role: "user", Content: prompt},
|
||||
}
|
||||
}
|
||||
|
||||
baseMeta := func(steps []AgentStep, rounds int) AgentRes {
|
||||
return AgentRes{
|
||||
Steps: steps,
|
||||
Rounds: rounds,
|
||||
RunID: runID,
|
||||
SessionID: req.SessionID,
|
||||
StepsBudget: maxRounds,
|
||||
}
|
||||
}
|
||||
|
||||
var steps []AgentStep
|
||||
var callHistory []string
|
||||
rounds := 0
|
||||
for round := 0; round < maxRounds; round++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
res := AgentRes{Steps: steps, Rounds: rounds, Content: "已取消"}
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: "cancelled", Steps: steps, Rounds: rounds})
|
||||
res := baseMeta(steps, rounds)
|
||||
res.Content = "已取消"
|
||||
res.Outcome = OutcomeCancelled
|
||||
res.Resumable = len(steps) > 0
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: "cancelled", Steps: steps, Rounds: rounds, RunID: runID, Outcome: OutcomeCancelled})
|
||||
return res, err
|
||||
}
|
||||
rounds = round + 1
|
||||
turn, err := llm.ChatWithTools(ctx, req.Model, msgs, tools)
|
||||
if err != nil {
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: err.Error(), Steps: steps, Rounds: rounds})
|
||||
return AgentRes{Steps: steps, Rounds: rounds}, err
|
||||
res := baseMeta(steps, rounds)
|
||||
res.Outcome = OutcomeFailed
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventError, Error: err.Error(), Steps: steps, Rounds: rounds, RunID: runID, Outcome: OutcomeFailed})
|
||||
return res, err
|
||||
}
|
||||
if len(turn.ToolCalls) == 0 {
|
||||
content := turn.Content
|
||||
@@ -111,13 +157,14 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
for _, part := range chunkDelta(content, 36) {
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||||
}
|
||||
res := AgentRes{
|
||||
Content: content,
|
||||
SQL: extractSQL(content),
|
||||
Steps: steps,
|
||||
Rounds: rounds,
|
||||
}
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds})
|
||||
res := baseMeta(steps, rounds)
|
||||
res.Content = content
|
||||
res.SQL = extractSQL(content)
|
||||
res.Outcome = OutcomeCompleted
|
||||
emitEvent(emit, AgentEvent{
|
||||
Type: AgentEventDone, Content: res.Content, SQL: res.SQL, Steps: steps, Rounds: rounds,
|
||||
RunID: runID, Outcome: OutcomeCompleted, StepsBudget: maxRounds,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -127,29 +174,76 @@ func runAgent(ctx context.Context, llm ToolChat, rt *aitools.Runtime, req AgentR
|
||||
ToolCalls: turn.ToolCalls,
|
||||
})
|
||||
|
||||
awaiting := false
|
||||
for _, tc := range turn.ToolCalls {
|
||||
name := strings.TrimSpace(tc.Function.Name)
|
||||
pending := AgentStep{Tool: name, Args: truncateRunes(strings.TrimSpace(tc.Function.Arguments), maxStepArgsChars), Pending: true}
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventStepStart, Step: &pending})
|
||||
argsRaw := strings.TrimSpace(tc.Function.Arguments)
|
||||
fp := ToolCallFingerprint(name, argsRaw)
|
||||
if DetectRepeatedToolCall(callHistory, fp, h.SameCallLimit) {
|
||||
step := AgentStep{
|
||||
Tool: name,
|
||||
Args: truncateRunes(argsRaw, maxStepArgsChars),
|
||||
OK: false,
|
||||
Summary: "repeated tool call rejected (same args)",
|
||||
Error: "repeated tool call rejected (same args)",
|
||||
}
|
||||
steps = append(steps, step)
|
||||
cp := step
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventStep, Step: &cp, RunID: runID})
|
||||
msgs = append(msgs, Message{
|
||||
Role: "tool", ToolCallID: tc.ID, Name: name,
|
||||
Content: step.Error,
|
||||
})
|
||||
callHistory = append(callHistory, fp)
|
||||
continue
|
||||
}
|
||||
callHistory = append(callHistory, fp)
|
||||
|
||||
pending := AgentStep{Tool: name, Args: truncateRunes(argsRaw, maxStepArgsChars), Pending: true}
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventStepStart, Step: &pending, RunID: runID})
|
||||
|
||||
step, toolMsg := execOneTool(ctx, rt, tc)
|
||||
steps = append(steps, step)
|
||||
cp := step
|
||||
ev := AgentEvent{Type: AgentEventStep, Step: &cp}
|
||||
ev := AgentEvent{Type: AgentEventStep, Step: &cp, RunID: runID}
|
||||
if step.Result != nil {
|
||||
ev.Result = step.Result
|
||||
}
|
||||
emitEvent(emit, ev)
|
||||
msgs = append(msgs, toolMsg)
|
||||
if step.NeedConfirm {
|
||||
awaiting = true
|
||||
}
|
||||
}
|
||||
if awaiting {
|
||||
content := "需要确认写操作后才能继续。"
|
||||
res := baseMeta(steps, rounds)
|
||||
res.Content = content
|
||||
res.Outcome = OutcomeAwaitingConfirm
|
||||
res.Resumable = true
|
||||
emitEvent(emit, AgentEvent{
|
||||
Type: AgentEventDone, Content: content, Steps: steps, Rounds: rounds,
|
||||
RunID: runID, Outcome: OutcomeAwaitingConfirm, Resumable: true, StepsBudget: maxRounds,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
msgs = TrimMessages(msgs, h)
|
||||
}
|
||||
|
||||
content := fmt.Sprintf("已达最大工具轮次(%d)。请根据已执行步骤自行判断,或缩小问题后重试。", maxRounds)
|
||||
content := fmt.Sprintf("已达最大工具轮次(%d)。可加预算继续,或缩小问题后重试。", maxRounds)
|
||||
for _, part := range chunkDelta(content, 36) {
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventDelta, Delta: part})
|
||||
}
|
||||
res := AgentRes{Content: content, Steps: steps, Rounds: rounds}
|
||||
emitEvent(emit, AgentEvent{Type: AgentEventDone, Content: content, Steps: steps, Rounds: rounds})
|
||||
res := baseMeta(steps, rounds)
|
||||
res.Content = content
|
||||
res.Outcome = OutcomeIncomplete
|
||||
res.IncompleteReason = ReasonMaxSteps
|
||||
res.Resumable = true
|
||||
emitEvent(emit, AgentEvent{
|
||||
Type: AgentEventIncomplete, Content: content, Steps: steps, Rounds: rounds,
|
||||
RunID: runID, Outcome: OutcomeIncomplete, IncompleteReason: ReasonMaxSteps,
|
||||
Resumable: true, StepsBudget: maxRounds,
|
||||
})
|
||||
return res, nil
|
||||
}
|
||||
|
||||
@@ -228,6 +322,9 @@ func execOneTool(ctx context.Context, rt *aitools.Runtime, tc ToolCall) (AgentSt
|
||||
} else if cmd, _ := m["command"].(string); cmd != "" {
|
||||
step.ConfirmSQL = cmd
|
||||
}
|
||||
if bind, _ := m["confirmBind"].(string); bind != "" {
|
||||
step.ConfirmBind = bind
|
||||
}
|
||||
msg, _ := m["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "requires user confirmation (confirm_danger)"
|
||||
|
||||
Reference in new issue
Block a user