Why: X3 must share one Backend surface with the X1 context and X2 audit candidates while current main now owns account migrations 32-34.
Evidence: go test -p 1 ./... and go build ./... passed on the merged tree; isolated PostgreSQL project audit, migration, and work-context tests passed against a temporary instance. Default parallel go test ./... had only the unrelated sandbox orphan timing test fail; its isolated ten-run replay passed.
Impact: Preserve account migrations 32-34, place X1/X2 migrations at 35-43, and retain account-effective write guards alongside project audit/context behavior. X3 observer remains a contract only.
Refs: TT-C01-TT-C04, TT-A01-TT-A04, TT-N03, TT-R01; combination probe d9b00c2
2852 lines
104 KiB
Go
2852 lines
104 KiB
Go
// Package runmgr 是 Run 生命周期枢纽(4.2/4.3):一切运行皆 Run、一切增量皆事件。
|
||
// 职责:run 状态机、事件持久化+广播(seq 游标)、HITL 挂起/恢复(三分支路由)、
|
||
// 子代理派生(任务树)、工具执行策略(画像三清单 + 确认卡片 + 截断转工件)、取消级联。
|
||
package runmgr
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
nruntime "agent-native/runtime"
|
||
"agent-native/runtime/filestate"
|
||
agentllm "agent-native/runtime/llm"
|
||
agentpolicy "agent-native/runtime/policy"
|
||
"agent-native/runtime/subagent"
|
||
|
||
"agent-system-backend/internal/bus"
|
||
"agent-system-backend/internal/cardsvc"
|
||
"agent-system-backend/internal/ingest"
|
||
"agent-system-backend/internal/intentgate"
|
||
"agent-system-backend/internal/profiles"
|
||
"agent-system-backend/internal/route"
|
||
"agent-system-backend/internal/runtime"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/tools"
|
||
"agent-system-backend/internal/workflow"
|
||
"agent-system-backend/pkg/extension"
|
||
core "agent-system-core"
|
||
)
|
||
|
||
type Manager struct {
|
||
st *store.Store
|
||
bus *bus.Bus
|
||
registry *tools.Registry
|
||
profiles *profiles.Service
|
||
settings *settings.Service
|
||
runner runtime.Runner // conversation runner(mock 或 LLM)
|
||
subRunner runtime.Runner // subagent runner(LLM 轻任务)
|
||
mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式)
|
||
mockSub runtime.Runner // 子代理脚本回放
|
||
squad runtime.Runner // runtime.entry=squad 时的对话入口
|
||
wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.CompiledWorkflowRunner
|
||
workflows *workflow.Registry // run_workflow 按 id 查找已注册定义
|
||
gate IntentGate
|
||
cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退)
|
||
// OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。
|
||
OnUsage func(owner string, tokens int)
|
||
events *eventBuffer // 事件批处理(写放大治理;seq 权威仍在 store)
|
||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||
accountEffectiveAuthorization func(context.Context, string) (bool, error)
|
||
accountAllowImplicitLocal bool
|
||
projectContextResolver func(context.Context, extension.ProjectContextRequest) (extension.ProjectContextBundle, error)
|
||
projectEvidenceReader func(context.Context, extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error)
|
||
projectSourceRechecker func(context.Context, extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error)
|
||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||
commands CommandSource
|
||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||
// workspaceRoot fs 预留能力(Hidden 工具族)的沙箱根;空=未配置,工具执行时报错。
|
||
workspaceRoot string
|
||
|
||
mu sync.Mutex
|
||
active map[string]*runHandle
|
||
|
||
// 会话内串行(设计并发策略):一个会话同时至多一个在途 conversation run。
|
||
convMu sync.Mutex
|
||
convActive map[string]bool // conversationID → 有在途 run
|
||
adm *admissionGate // 执行许可门(U3:预算默认关闭时零开销)
|
||
instanceTag string // 执行身份(U5:多实例互异;空=runmgr:addr)
|
||
addr string // 监听地址(身份退化用)
|
||
execMu sync.Mutex // 执行所有权(U5:DB 租约的本地代次登记)
|
||
execEpoch map[string]int64 // conversationID → 本实例当前 epoch
|
||
|
||
instanceID string
|
||
shuttingDown atomic.Bool
|
||
|
||
// 后台子代理的完成通知只留在内存里。重启后未投递的通知丢失。
|
||
noticeMu sync.Mutex
|
||
notices map[string][]bgNotice
|
||
bgInFlight map[string]int
|
||
delivering map[string]bool
|
||
|
||
// 材料整理内容键注册表(附件即保证):会话 → 活跃材料键。卡片 KV 是
|
||
// 重启后的持久兜底,这里只是热路径的快速去重。
|
||
materialMu sync.Mutex
|
||
materialActive map[string]map[string]bool
|
||
|
||
// 后台排队(I3):会话满 3 时的系统派发任务先建卡(排队中)入队,
|
||
// 槽位释放按 FIFO 启动。模型 delegate_task 仍硬拒绝,不入队。
|
||
bgQueue map[string][]bgQueueItem
|
||
|
||
allowMu sync.Mutex
|
||
sessionAllow map[string]map[string]bool
|
||
|
||
// genuiAuto:Generative UI 自动建议闸门(nil = 未接线;默认关闭行为)。
|
||
// 仅由任务阶段/材料工件语义事件调用;present/surface/action 不得接入。
|
||
genuiAuto GenUIAutoTrigger
|
||
}
|
||
|
||
// GenUIAutoTrigger 自动建议页面闸门(由 genui.Auto 实现)。
|
||
type GenUIAutoTrigger interface {
|
||
Try(owner, convID, reason string) (ok bool, skip string)
|
||
}
|
||
|
||
const maxBackgroundChildren = 3
|
||
|
||
// bgQueueItem 一个等待槽位的系统派发后台任务(建卡先行,子 run 启动延后)。
|
||
type bgQueueItem struct {
|
||
parent core.Run
|
||
opts bgSpawnOpts
|
||
card core.ActivityCard
|
||
}
|
||
|
||
// bgNotice 是一条尚未投递给父会话的后台子代理结果。
|
||
type bgNotice struct {
|
||
id string // 持久化队列主键(重启重投凭据)
|
||
owner string
|
||
name string
|
||
payload core.AgentResultPayload
|
||
material bool // 材料整理任务:通知回合要衔接交付物选择(I2 消费)
|
||
}
|
||
|
||
// ErrConversationBusy 表示会话已有在途 run(HTTP 层映射 409)。
|
||
var ErrConversationBusy = fmt.Errorf("runmgr: conversation already has an in-flight run")
|
||
|
||
type runHandle struct {
|
||
owner string
|
||
cancel context.CancelFunc
|
||
hitlCh chan hitlAnswer
|
||
assistant []core.Part
|
||
ctxObs *core.ContextObservation
|
||
submittedCards []core.ID // submit_card 产物卡(finishRun 回填锚点)
|
||
streamCardID core.ID // 活跃流式产物卡(card_begin/card_commit 三段式;D44/D45)
|
||
started time.Time
|
||
promptTokens int
|
||
completionTokens int
|
||
lastPromptTokens int
|
||
lastCompletionTokens int
|
||
cacheReadTokens int
|
||
cacheWriteTokens int
|
||
}
|
||
|
||
type hitlAnswer struct {
|
||
resp core.HITLResponse
|
||
err error
|
||
}
|
||
|
||
func New(st *store.Store, b *bus.Bus, reg *tools.Registry, p *profiles.Service, s *settings.Service, runner, subRunner, mockRunner, mockSubRunner runtime.Runner) *Manager {
|
||
return &Manager{
|
||
st: st, bus: b, registry: reg, profiles: p, settings: s,
|
||
runner: runner, subRunner: subRunner, mock: mockRunner, mockSub: mockSubRunner,
|
||
active: map[string]*runHandle{}, convActive: map[string]bool{}, execEpoch: map[string]int64{},
|
||
notices: map[string][]bgNotice{}, bgInFlight: map[string]int{}, delivering: map[string]bool{},
|
||
events: newEventBuffer(st, b),
|
||
adm: newAdmissionGate(nil, 0),
|
||
}
|
||
}
|
||
|
||
// Shutdown 停机清空事件缓冲(在 store.Close 之前调用)。
|
||
func (m *Manager) Shutdown() { m.events.stop() }
|
||
|
||
// SetInstanceID 记录本进程标识。启动对账在 PG 档只收口这个实例的 run。
|
||
func (m *Manager) SetInstanceID(id string) { m.instanceID = id }
|
||
|
||
// SetEventNotifier 注入落盘后回调(PG 档 pgbus.Notifier;SQLite 档不注入)。
|
||
func (m *Manager) SetEventNotifier(fn func(conversationID string, evs []core.StreamEvent)) {
|
||
m.events.onPersisted = fn
|
||
}
|
||
|
||
// IntentGate 是可选意图闸门端口(intentgate.Gate 实现;nil = 未启用,行为回退)。
|
||
type IntentGate interface {
|
||
Match(ctx context.Context, input string) (branch, topID string)
|
||
}
|
||
|
||
// EmitProjectSharedCardEvent is an internal producer-authorized structural
|
||
// publication entry, separate from the ordinary private card event emitter.
|
||
func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
|
||
return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload)
|
||
}
|
||
|
||
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
|
||
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
|
||
|
||
// SetCards 注入收件箱卡服务(nil = 不产卡;T0 退回基线仍成立)。
|
||
func (m *Manager) SetCards(s *cardsvc.Service) { m.cards = s }
|
||
|
||
// EmitCardEvent 供服务层(cardsvc 投影)复用会话事件管道(持久化 + seq + 扇出)。
|
||
func (m *Manager) EmitCardEvent(conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||
_, err := m.emit(conversationID, runID, typ, payload)
|
||
return err
|
||
}
|
||
|
||
func (m *Manager) EmitCardEventAccountChecked(actor, conversationID, runID string, typ core.StreamEventType, payload any) error {
|
||
return m.events.emitAccountChecked(actor, conversationID, runID, typ, payload, m.accountAllowImplicitLocal)
|
||
}
|
||
|
||
func (m *Manager) appendMessageForEffectiveAccount(actor string, msg *core.Message) error {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.st.AppendMessageWithRequestAccountChecked(actor, m.accountAllowImplicitLocal, msg, "")
|
||
}
|
||
return m.st.AppendMessage(msg)
|
||
}
|
||
|
||
func (m *Manager) saveHITLCardForEffectiveAccount(actor string, card *core.HITLCard) error {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.st.SaveHITLCardAccountChecked(actor, card, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.st.SaveHITLCard(actor, card)
|
||
}
|
||
|
||
func (m *Manager) resolveHITLCardForEffectiveAccount(actor, id string, resolution core.HITLResolvedPayload) error {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.st.ResolveHITLCardAccountChecked(actor, id, resolution, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.st.ResolveHITLCard(actor, id, resolution)
|
||
}
|
||
|
||
func (m *Manager) resolveProjectedCardForEffectiveAccount(actor, ref, actionID, answer string) error {
|
||
if m.cards == nil {
|
||
return nil
|
||
}
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.cards.ResolveByRefAccountChecked(actor, ref, actionID, answer, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.cards.ResolveByRef(actor, ref, actionID, answer)
|
||
}
|
||
|
||
func (m *Manager) createCardForEffectiveAccount(actor string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.cards.CreateAccountChecked(actor, card, ref, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.cards.Create(actor, card, ref)
|
||
}
|
||
|
||
func (m *Manager) setCardStatusForEffectiveAccount(actor, id string, status core.CardStatus, tone, note string) (core.ActivityCard, error) {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.cards.SetStatusAccountChecked(actor, id, status, tone, note, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.cards.SetStatus(actor, id, status, tone, note)
|
||
}
|
||
|
||
func (m *Manager) updateCardForEffectiveAccount(actor string, card core.ActivityCard) error {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
_, version, err := m.st.GetCardVersion(actor, card.ID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return m.cards.UpdateVersionedAccountChecked(actor, card, version, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.cards.Update(actor, card)
|
||
}
|
||
|
||
func (m *Manager) appendCardForEffectiveAccount(actor, id, text string) error {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.cards.AppendAccountChecked(actor, id, text, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.cards.Append(actor, id, text)
|
||
}
|
||
|
||
// SetRetriever 注入混合检索(rag 管线;nil = store 全文检索)。
|
||
func (m *Manager) SetRetriever(r core.Retriever) { m.retriever = r }
|
||
|
||
// SetSharedKnowledgeAllow 注入回合内共享检索谓词(EXT-13;组合根在装载扩展后
|
||
// 以同一 EXT-8 适配器槽接线)。nil = 现状。
|
||
func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.ID) bool) {
|
||
m.sharedKnowledgeAllow = fn
|
||
}
|
||
|
||
// SetKnowledgeResourceAllow makes resource-level authorization authoritative for
|
||
// model context, including owned rows. The callback receives stored identities.
|
||
func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) {
|
||
m.knowledgeResourceAllow = fn
|
||
}
|
||
|
||
// SetProjectWorkAuthorization is the live domain guard for starting runs and
|
||
// executing tools. Nil preserves the ordinary unregistered host behavior.
|
||
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
||
m.projectWorkAuthorization = fn
|
||
}
|
||
|
||
// SetAccountEffectiveAuthorization binds the current account-state guard. It is
|
||
// independent of the optional domain project provider.
|
||
func (m *Manager) SetAccountEffectiveAuthorization(fn func(context.Context, string) (bool, error), allowImplicitLocal ...bool) {
|
||
m.accountEffectiveAuthorization = fn
|
||
m.events.accountGuardEnabled = fn != nil
|
||
if len(allowImplicitLocal) > 0 {
|
||
m.accountAllowImplicitLocal = allowImplicitLocal[0]
|
||
m.events.allowImplicitLocal = allowImplicitLocal[0]
|
||
}
|
||
}
|
||
|
||
func (m *Manager) authorizeAccountEffective(ctx context.Context, owner string) error {
|
||
if m.accountEffectiveAuthorization == nil {
|
||
return nil
|
||
}
|
||
ok, err := m.accountEffectiveAuthorization(ctx, owner)
|
||
if err != nil {
|
||
m.cancelActiveOwnerRuns(owner)
|
||
return err
|
||
}
|
||
if !ok {
|
||
m.cancelActiveOwnerRuns(owner)
|
||
return store.ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m *Manager) cancelActiveOwnerRuns(owner string) {
|
||
m.mu.Lock()
|
||
var cancels []context.CancelFunc
|
||
for _, h := range m.active {
|
||
if h.owner == owner && h.cancel != nil {
|
||
cancels = append(cancels, h.cancel)
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
for _, cancel := range cancels {
|
||
cancel()
|
||
}
|
||
}
|
||
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
|
||
if m.projectWorkAuthorization == nil {
|
||
return nil
|
||
}
|
||
ok, err := m.projectWorkAuthorization(ctx, owner, id)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if !ok {
|
||
return store.ErrNotFound
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (m *Manager) authorizeRunWork(ctx context.Context, owner string, id core.ID) error {
|
||
if err := m.authorizeAccountEffective(ctx, owner); err != nil {
|
||
return err
|
||
}
|
||
return m.authorizeProjectWork(ctx, owner, id)
|
||
}
|
||
|
||
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
|
||
// canonical domain metadata without widening the current conversation scope.
|
||
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
|
||
m.knowledgeProjectScope = fn
|
||
}
|
||
|
||
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
|
||
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
|
||
|
||
// SetIndexer 注入检索索引入口(分块+全文+可选向量;nil = 整文档单块)。
|
||
func (m *Manager) SetIndexer(i core.DocumentIndexer) { m.indexer = i }
|
||
|
||
// SetExecutionBudgetFn 注入执行预算读取(U3,R8):fn 返回当前预算(全零=
|
||
// 不限,本地档默认);queueMax 为待执行队列上限(<=0 走默认 200)。
|
||
func (m *Manager) SetExecutionBudgetFn(fn func() store.ExecutionBudget, queueMax int) {
|
||
if m.adm == nil {
|
||
m.adm = newAdmissionGate(fn, queueMax) // 直接构造的 Manager(测试)
|
||
return
|
||
}
|
||
m.adm.mu.Lock()
|
||
m.adm.budgetFn = fn
|
||
if queueMax > 0 {
|
||
m.adm.queueMax = queueMax
|
||
}
|
||
m.adm.mu.Unlock()
|
||
}
|
||
|
||
// QueueFull 非阻塞探测待执行队列是否已满(API 边缘 429 拒绝,不落库)。
|
||
func (m *Manager) QueueFull() bool { return m.adm.QueueFull() }
|
||
|
||
// QueueDepth 当前排队数(诊断)。
|
||
func (m *Manager) QueueDepth() int { return m.adm.QueueDepth() }
|
||
|
||
// SetWorkspaceRoot 注入 fs 预留能力(Hidden 工具族)的沙箱根目录。
|
||
// 实际会话沙箱为 <root>/<owner>/<conversationID>/;空值=未配置(工具执行报错)。
|
||
func (m *Manager) SetWorkspaceRoot(dir string) { m.workspaceRoot = dir }
|
||
|
||
// CommandSource EXT-14:命令总线暴露给回合执行的两个面——工具投影清单与
|
||
// 经命令总的分发(同审计信封与 HITL 收口)。runmgr 只认本包视图类型,
|
||
// 组合根以 cmdbus.Bus 适配注入(app.busAdapter 负责转换),runmgr 不依赖总线包。
|
||
type CommandSource interface {
|
||
CommandTools() []CommandTool
|
||
// CommandToolsFor 按 owner 过滤工具投影(U3:禁用即不注入能力)。
|
||
CommandToolsFor(owner string) []CommandTool
|
||
DispatchCommand(ctx context.Context, req CommandRequest) CommandResponse
|
||
}
|
||
|
||
// CommandTool EXT-14 工具投影视图:组合根从命令总线的投影清单转换注入。
|
||
type CommandTool struct {
|
||
ID string
|
||
Description string
|
||
Params json.RawMessage
|
||
Risk string
|
||
}
|
||
|
||
// CommandRequest 一次命令工具分发请求(runmgr 固定以 Entrance=agent 发起,
|
||
// Actor/Owner 绑定会话 owner、带会话上下文)。
|
||
type CommandRequest struct {
|
||
ID string
|
||
Params map[string]any
|
||
Entrance string
|
||
Actor string
|
||
Owner string
|
||
ConversationID string
|
||
}
|
||
|
||
// CommandResponse 分发结果视图:回执状态/原因 + 负载 + 是否挂起确认卡。
|
||
type CommandResponse struct {
|
||
State string
|
||
Reason string
|
||
Payload any
|
||
Card bool
|
||
}
|
||
|
||
// sharedRetriever EXT-13:检索器具备共享通道(rag.Pipeline.RetrieveShared 同形)。
|
||
type sharedRetriever interface {
|
||
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
|
||
}
|
||
|
||
// ---- runCtx 实现(runtime.RunCtx)----
|
||
|
||
type runCtx struct {
|
||
mgr *Manager
|
||
run core.Run
|
||
projectContextReq extension.ProjectContextRequest
|
||
projectContextMu sync.Mutex
|
||
projectEvidenceMu sync.Mutex
|
||
projectEvidenceUsed []projectEvidenceRecord
|
||
prof core.AgentProfile
|
||
eff core.EffectiveSettings
|
||
ctx context.Context
|
||
h *runHandle
|
||
userMessage core.Message
|
||
attachments []core.ParsedDocument
|
||
inputPaths []string
|
||
subTask string
|
||
subHint string
|
||
subAgent string
|
||
subModel string // 系统派发覆盖("chat" = 子代理用对话同档;空 = 按 spec)
|
||
launch *workflow.Launch
|
||
files *filestate.Tracker
|
||
permissionMode string
|
||
// 材料整理(附件即保证):预启动说明注入第 0 回合;ensureMaterial 兜底幂等。
|
||
materialNote string
|
||
materialEnsured bool
|
||
}
|
||
|
||
// Surfaces 返回当前会话最近保存的生成页面(U3/R6:ctxasm 语义投影回灌;
|
||
// 最近 5 页、新→旧;读路径不受启禁影响——禁用挡写入,不丢历史)。
|
||
func (c *runCtx) Surfaces() []core.SurfaceSnapshot {
|
||
if c.mgr.st == nil || c.run.ConversationID == "" || c.run.Owner == "" {
|
||
return nil
|
||
}
|
||
metas, err := c.mgr.st.ListSurfaces(string(c.run.Owner), string(c.run.ConversationID))
|
||
if err != nil || len(metas) == 0 {
|
||
return nil
|
||
}
|
||
if len(metas) > 5 {
|
||
metas = metas[:5]
|
||
}
|
||
out := make([]core.SurfaceSnapshot, 0, len(metas))
|
||
for _, m := range metas {
|
||
if snap, err := c.mgr.st.GetSurfaceSnapshot(string(c.run.Owner), string(m.SurfaceID), 0); err == nil {
|
||
out = append(out, snap)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。
|
||
func (c *runCtx) Workflow() (workflow.Launch, bool) {
|
||
if c.launch == nil {
|
||
return workflow.Launch{}, false
|
||
}
|
||
return *c.launch, true
|
||
}
|
||
|
||
// WorkflowByID 按 id 取当前注册表里的定义。运行中的副本不受之后卸载的影响。
|
||
func (c *runCtx) WorkflowByID(id string) (core.WorkflowDef, bool) {
|
||
if c.mgr.workflows == nil {
|
||
return core.WorkflowDef{}, false
|
||
}
|
||
return c.mgr.workflows.Get(id)
|
||
}
|
||
|
||
// WorkflowScript 读取插件脚本的当前正文。启动工作流时会另外拷进 Launch。
|
||
func (c *runCtx) WorkflowScript(id, path string) (string, bool) {
|
||
if c.mgr.workflows == nil {
|
||
return "", false
|
||
}
|
||
return c.mgr.workflows.Script(id, path)
|
||
}
|
||
|
||
func (c *runCtx) Context() context.Context { return c.ctx }
|
||
func (c *runCtx) Run() core.Run { return c.run }
|
||
func (c *runCtx) Profile() core.AgentProfile { return c.prof }
|
||
func (c *runCtx) Settings() core.EffectiveSettings { return c.eff }
|
||
func (c *runCtx) History() []core.Message {
|
||
hist, err := c.mgr.st.ListMessages(c.run.ConversationID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
if sub, qerr := c.mgr.st.SubagentRunIDs(c.run.ConversationID); qerr == nil && len(sub) > 0 {
|
||
kept := hist[:0]
|
||
for _, m := range hist {
|
||
if m.RunID != "" && sub[m.RunID] {
|
||
continue
|
||
}
|
||
kept = append(kept, m)
|
||
}
|
||
hist = kept
|
||
}
|
||
if len(hist) > 200 {
|
||
hist = hist[len(hist)-200:]
|
||
}
|
||
return hist
|
||
}
|
||
func (c *runCtx) Attachments() []core.ParsedDocument { return c.attachments }
|
||
func (c *runCtx) WorkspaceInputs() []string { return c.inputPaths }
|
||
|
||
// ConversationSandbox 是会话工作区根。宿主工具把内核步骤里生成的文件复制到这里再登记。
|
||
func (c *runCtx) ConversationSandbox() string {
|
||
if c.mgr.workspaceRoot == "" || c.run.Owner == "" || c.run.ConversationID == "" {
|
||
return ""
|
||
}
|
||
return filepath.Join(c.mgr.workspaceRoot, c.run.Owner, c.run.ConversationID)
|
||
}
|
||
|
||
// AddUsage 累加本 run 的模型用量,finishRun 写入 run.Usage。
|
||
// 计费拆分(N3 WP6):Last* 覆盖(最近一次请求)、Cache* 累计。
|
||
func (c *runCtx) AddUsage(u core.Usage) {
|
||
c.mgr.mu.Lock()
|
||
c.h.promptTokens += u.PromptTokens
|
||
c.h.completionTokens += u.CompletionTokens
|
||
c.h.lastPromptTokens = u.LastPromptTokens
|
||
c.h.lastCompletionTokens = u.LastCompletionTokens
|
||
c.h.cacheReadTokens += u.CacheReadTokens
|
||
c.h.cacheWriteTokens += u.CacheWriteTokens
|
||
c.mgr.mu.Unlock()
|
||
}
|
||
func (c *runCtx) Subtask() string { return c.subTask }
|
||
|
||
// Tools 模型可见工具清单:Hidden 工具(fs 预留能力)在 runtime.fsTools 未开时过滤
|
||
// (与 /api/tools 列表同门禁;registry.Execute 另有执行侧 fail-closed)。
|
||
func conversationMode(m *Manager, owner, id string) string {
|
||
if m == nil || m.st == nil || id == "" {
|
||
return ""
|
||
}
|
||
conv, err := m.st.GetConversation(owner, id)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return conv.PermissionMode
|
||
}
|
||
|
||
func (c *runCtx) PermissionMode() string { return c.permissionMode }
|
||
|
||
func (c *runCtx) ExitPlanMode() error {
|
||
if c.mgr == nil || c.mgr.st == nil {
|
||
c.permissionMode = ""
|
||
return nil
|
||
}
|
||
var err error
|
||
if c.mgr.accountEffectiveAuthorization != nil {
|
||
err = c.mgr.st.SetPermissionModeAccountChecked(c.run.Owner, c.run.ConversationID, "default", c.mgr.accountAllowImplicitLocal)
|
||
} else {
|
||
err = c.mgr.st.SetPermissionMode(c.run.Owner, c.run.ConversationID, "default")
|
||
}
|
||
if err == nil {
|
||
c.permissionMode = ""
|
||
}
|
||
return err
|
||
}
|
||
|
||
func (m *Manager) rememberSessionAllow(conv, tool string) {
|
||
if m == nil || conv == "" || tool == "" {
|
||
return
|
||
}
|
||
m.allowMu.Lock()
|
||
defer m.allowMu.Unlock()
|
||
if m.sessionAllow == nil {
|
||
m.sessionAllow = map[string]map[string]bool{}
|
||
}
|
||
if m.sessionAllow[conv] == nil {
|
||
m.sessionAllow[conv] = map[string]bool{}
|
||
}
|
||
m.sessionAllow[conv][tool] = true
|
||
}
|
||
|
||
func (m *Manager) sessionAllows(conv, tool string) bool {
|
||
if m == nil {
|
||
return false
|
||
}
|
||
m.allowMu.Lock()
|
||
defer m.allowMu.Unlock()
|
||
return m.sessionAllow[conv][tool]
|
||
}
|
||
|
||
// convAllowStore 把 Manager 的会话放行表呈现为 agentpolicy.SessionAllows 端口。
|
||
// 键控保持既有口径:会话 × 工具名(同名调用不再询问),不区分参数。
|
||
type convAllowStore struct {
|
||
mgr *Manager
|
||
conv string
|
||
}
|
||
|
||
func (s convAllowStore) Remember(name, args string) {
|
||
s.mgr.rememberSessionAllow(s.conv, name)
|
||
}
|
||
|
||
func (s convAllowStore) Allowed(name, args string) bool {
|
||
return s.mgr.sessionAllows(s.conv, name)
|
||
}
|
||
|
||
// toolConfirmCard 是工具确认 HITL 卡的统一形态:ExecuteTool 的画像确认与
|
||
// 内核 PermissionAsk 端口共用,两条路径的选项与口径保持一致。
|
||
func toolConfirmCard(name, args string) core.HITLCard {
|
||
var argsPreview string
|
||
if len(args) > 0 {
|
||
argsPreview = truncate(args, 600)
|
||
}
|
||
return core.HITLCard{
|
||
Question: fmt.Sprintf("执行工具 %s?", name), Kind: core.HITLKindConfirm, Detail: argsPreview,
|
||
Choices: []core.HITLChoice{
|
||
{ID: "yes", Label: "执行", Description: "按上述参数调用", Impact: "该工具将访问外部资源并返回结果", Recommended: true},
|
||
{ID: "session", Label: "本会话始终允许", Description: "这次以及本会话后续的同名调用不再询问", Impact: "拒绝规则和计划模式仍然有效"},
|
||
{ID: "no", Label: "取消", Description: "跳过本次调用", Impact: "模型会收到调用被拒绝的说明"},
|
||
},
|
||
AllowFreeInput: false,
|
||
}
|
||
}
|
||
|
||
// PermissionAsk 把内核钩子的 ask 决策路由到 HITL 确认卡(N1 WP2 宿主侧)。
|
||
// 「本会话始终允许」沿用 ExecuteTool 的会话放行口径(会话 × 工具名)。
|
||
func (c *runCtx) PermissionAsk(ctx context.Context, call agentllm.ToolCall) nruntime.AskVerdict {
|
||
resp, err := c.AskHITL(toolConfirmCard(call.Name, call.Arguments))
|
||
if err != nil {
|
||
return nruntime.AskVerdict{Reason: "hitl aborted: " + err.Error()}
|
||
}
|
||
switch resp.ChoiceID {
|
||
case "session":
|
||
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
|
||
return nruntime.AskVerdict{Allow: true}
|
||
case "yes":
|
||
return nruntime.AskVerdict{Allow: true}
|
||
default:
|
||
return nruntime.AskVerdict{Reason: "用户取消了本次工具调用"}
|
||
}
|
||
}
|
||
|
||
func (c *runCtx) Tools() []core.ToolDesc {
|
||
list := tools.FilterExecDisabled(c.mgr.registry.List())
|
||
if c.mgr.commands != nil {
|
||
// EXT-14:exposeAsTool 命令投影为工具(去重:注册表同名工具优先)。
|
||
seen := make(map[string]bool, len(list))
|
||
for _, t := range list {
|
||
seen[t.Name] = true
|
||
}
|
||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||
if seen[ct.ID] {
|
||
continue
|
||
}
|
||
list = append(list, core.ToolDesc{Name: ct.ID, Description: ct.Description,
|
||
Parameters: ct.Params, Source: core.SourcePlugin, Risk: ct.Risk})
|
||
}
|
||
}
|
||
out := make([]core.ToolDesc, 0, len(list))
|
||
fs := c.eff.GetBool("runtime.fsTools", false)
|
||
code := c.eff.GetBool("runtime.codeOrchestration", false)
|
||
for _, t := range list {
|
||
if t.Hidden && !fs {
|
||
continue
|
||
}
|
||
if t.Name == "run_tool_script" && !code {
|
||
continue
|
||
}
|
||
readOnly := t.ReadOnly || agentpolicy.TraitsFor(t.Name).ReadOnly
|
||
if !agentpolicy.Visible(c.permissionMode, t.Name, readOnly) {
|
||
continue
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
func (c *runCtx) SetContextObservation(o core.ContextObservation) {
|
||
c.mgr.mu.Lock()
|
||
c.h.ctxObs = &o
|
||
c.mgr.mu.Unlock()
|
||
}
|
||
|
||
func (c *runCtx) ContextObservation() *core.ContextObservation {
|
||
c.mgr.mu.Lock()
|
||
defer c.mgr.mu.Unlock()
|
||
return c.h.ctxObs
|
||
}
|
||
|
||
func (c *runCtx) SessionNote() string {
|
||
if c.mgr == nil || c.mgr.st == nil {
|
||
return ""
|
||
}
|
||
conv, err := c.mgr.st.GetConversation(c.run.Owner, c.run.ConversationID)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return conv.SessionNote
|
||
}
|
||
|
||
func (c *runCtx) Injections() []runtime.ContextInjection {
|
||
if c.mgr == nil || c.mgr.st == nil {
|
||
return nil
|
||
}
|
||
list, err := c.mgr.st.ListContextEntries(c.run.Owner, c.run.ProjectID, c.run.ConversationID)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
out := make([]runtime.ContextInjection, 0, len(list))
|
||
for _, e := range list {
|
||
if !e.Enabled {
|
||
continue
|
||
}
|
||
out = append(out, runtime.ContextInjection{
|
||
Keys: e.Keys, Secondary: e.Secondary, Logic: e.Logic, Content: e.Content,
|
||
Constant: e.Constant, ScanDepth: e.ScanDepth, BudgetChars: e.BudgetChars,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (c *runCtx) Memories() []core.MemoryEntry {
|
||
if c.mgr == nil || c.mgr.st == nil {
|
||
return nil
|
||
}
|
||
list, err := c.mgr.st.ListMemory(c.run.Owner, "", "")
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
now := time.Now()
|
||
var live []core.MemoryEntry
|
||
for _, e := range list {
|
||
if e.ExpiresAt != nil && !e.ExpiresAt.After(now) {
|
||
continue
|
||
}
|
||
live = append(live, e)
|
||
}
|
||
return live
|
||
}
|
||
|
||
func (c *runCtx) Emit(typ core.StreamEventType, payload any) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, typ, payload)
|
||
return err
|
||
}
|
||
|
||
func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
c.mgr.mu.Lock()
|
||
c.h.assistant = parts
|
||
c.mgr.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
card.ID = store.NewID("hitl")
|
||
card.RunID = c.run.ID
|
||
card.ConversationID = c.run.ConversationID
|
||
if card.CreatedAt == "" {
|
||
card.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
if err := c.mgr.saveHITLCardForEffectiveAccount(c.run.Owner, &card); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
if err := c.mgr.setRunStatus(&c.run, core.RunWaitingHITL, c.run.Phase); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvHITLRequest, card); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
// 收件箱投影卡(双现):待确认进收件箱不沉没;ref 联动使任一侧响应即双侧销项。
|
||
if c.mgr.cards != nil {
|
||
proj := core.ActivityCard{
|
||
ProjectID: c.run.ProjectID, ConversationID: c.run.ConversationID, SourceRunID: c.run.ID,
|
||
Kind: core.CardKindConfirm, Title: card.Question, Status: core.CardActionRequired,
|
||
Tone: map[bool]string{true: "danger", false: "warn"}[card.Blocked],
|
||
}
|
||
if card.Blocked {
|
||
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "⚠ 阻断:" + card.BlockReason})
|
||
}
|
||
for _, ch := range card.Choices {
|
||
proj.Actions = append(proj.Actions, core.UIAction{ID: ch.ID, Label: ch.Label, Style: map[bool]string{true: "primary", false: "default"}[ch.Recommended]})
|
||
}
|
||
if card.AllowFreeInput {
|
||
proj.Body = append(proj.Body, core.UINode{Type: core.UIMarkdown, Markdown: "_可在输入框自由输入回应。_"})
|
||
}
|
||
if _, err := c.mgr.createCardForEffectiveAccount(c.run.Owner, proj, "hitl:"+card.ID); err != nil {
|
||
return core.HITLResponse{}, err
|
||
}
|
||
}
|
||
var timeout time.Duration
|
||
if card.TimeoutSec != nil && *card.TimeoutSec > 0 {
|
||
timeout = time.Duration(*card.TimeoutSec) * time.Second
|
||
}
|
||
// HITL 等待让出执行许可(U3:等待审批不占执行额度;应答后重取——可能
|
||
// 排队,取消语境下重取失败由取消路径收口)。
|
||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||
defer func() {
|
||
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner, c.mgr.accountEffectiveAuthorization != nil, c.mgr.accountAllowImplicitLocal); acquireErr != nil {
|
||
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
|
||
if err == nil {
|
||
err = acquireErr
|
||
}
|
||
}
|
||
// Permission can change during both confirmation and admission waiting.
|
||
// Every HITL caller receives the live decision before it resumes work.
|
||
if err == nil {
|
||
err = c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID)
|
||
}
|
||
}()
|
||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||
}
|
||
|
||
func waitHITL(ctx context.Context, ch <-chan hitlAnswer, timeout time.Duration) (core.HITLResponse, error) {
|
||
if timeout <= 0 {
|
||
select {
|
||
case ans := <-ch:
|
||
return ans.resp, ans.err
|
||
case <-ctx.Done():
|
||
return core.HITLResponse{}, ctx.Err()
|
||
}
|
||
}
|
||
timer := time.NewTimer(timeout)
|
||
defer timer.Stop()
|
||
select {
|
||
case ans := <-ch:
|
||
return ans.resp, ans.err
|
||
case <-timer.C:
|
||
return core.HITLResponse{}, fmt.Errorf("runmgr: hitl timed out after %s", timeout)
|
||
case <-ctx.Done():
|
||
return core.HITLResponse{}, ctx.Err()
|
||
}
|
||
}
|
||
|
||
func (c *runCtx) SpawnSubagent(name, task, hint string) (core.AgentResultPayload, error) {
|
||
return c.SpawnAgent("agent.sub.general", name, task, hint)
|
||
}
|
||
|
||
func (c *runCtx) SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||
return c.mgr.spawnSubagent(c.ctx, c.run, agentID, name, task, hint)
|
||
}
|
||
|
||
func (c *runCtx) SpawnBackground(agentID, name, task, hint string) (core.ID, error) {
|
||
return c.mgr.spawnBackground(c.run, agentID, name, task, hint)
|
||
}
|
||
|
||
func (c *runCtx) SubagentID() string { return c.subAgent }
|
||
|
||
func (c *runCtx) Subhint() string { return c.subHint }
|
||
|
||
// Submodel 系统派发的模型档覆盖(材料整理用对话同档;空 = 子代理 spec 默认)。
|
||
func (c *runCtx) Submodel() string { return c.subModel }
|
||
|
||
func (c *runCtx) UpdatePlan(p core.PlanSnapshot) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
prev, _ := c.mgr.st.GetPlan(c.run.ConversationID)
|
||
p.PlanID = c.run.ConversationID
|
||
var saveErr error
|
||
if c.mgr.accountEffectiveAuthorization != nil {
|
||
saveErr = c.mgr.st.SavePlanAccountChecked(c.run.Owner, c.run.ConversationID, p, c.mgr.accountAllowImplicitLocal)
|
||
} else {
|
||
saveErr = c.mgr.st.SavePlan(c.run.ConversationID, p)
|
||
}
|
||
if err := saveErr; err != nil {
|
||
return err
|
||
}
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvPlanUpdate, core.PlanUpdatePayload{Plan: p})
|
||
if err == nil && planStageChanged(prev, p) {
|
||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "plan_stage")
|
||
}
|
||
return err
|
||
}
|
||
|
||
// Todos 读取本会话已有待办。没有记录时返回 false,供终轮提醒当作可选接口。
|
||
func (c *runCtx) Todos() (core.TodoList, bool) {
|
||
list, err := c.mgr.st.GetTodos(c.run.ConversationID)
|
||
if err != nil {
|
||
return core.TodoList{}, false
|
||
}
|
||
return list, true
|
||
}
|
||
|
||
// Plan 读取本会话已有计划。没有记录时返回 false。
|
||
func (c *runCtx) Plan() (core.PlanSnapshot, bool) {
|
||
p, err := c.mgr.st.GetPlan(c.run.ConversationID)
|
||
if err != nil {
|
||
return core.PlanSnapshot{}, false
|
||
}
|
||
return p, true
|
||
}
|
||
|
||
func (c *runCtx) UpdateTodos(l core.TodoList) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
prev, _ := c.mgr.st.GetTodos(c.run.ConversationID)
|
||
l.ConversationID = c.run.ConversationID
|
||
var saveErr error
|
||
if c.mgr.accountEffectiveAuthorization != nil {
|
||
saveErr = c.mgr.st.SaveTodosAccountChecked(c.run.Owner, c.run.ConversationID, l, c.mgr.accountAllowImplicitLocal)
|
||
} else {
|
||
saveErr = c.mgr.st.SaveTodos(c.run.ConversationID, l)
|
||
}
|
||
if err := saveErr; err != nil {
|
||
return err
|
||
}
|
||
_, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvTodoUpdate, core.TodoUpdatePayload{List: l})
|
||
if err == nil && todoStageChanged(prev, l) {
|
||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "todo_stage")
|
||
}
|
||
return err
|
||
}
|
||
|
||
// CreateCard / SetCardStatus:run 内产卡入口(摄取/编排/工作流步骤;mock 演示)。
|
||
// 收件箱关闭(cards==nil)时为 no-op——T0 退回基线不受影响。
|
||
// 归属判定(D48):调用方留空 ConversationID 且预置 ProjectID = 项目级卡
|
||
// (scope=project 工具参数),补全 ProjectID、ConversationID 保持空;其余照旧
|
||
// 归属当前会话。
|
||
func (c *runCtx) CreateCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ActivityCard{}, err
|
||
}
|
||
if c.mgr.cards == nil {
|
||
return card, nil
|
||
}
|
||
if card.ConversationID == "" && card.ProjectID == "" {
|
||
card.ConversationID = c.run.ConversationID
|
||
}
|
||
card.ProjectID = c.run.ProjectID
|
||
card.SourceRunID = c.run.ID
|
||
return c.mgr.createCardForEffectiveAccount(c.run.Owner, card, "")
|
||
}
|
||
|
||
func (c *runCtx) SetCardStatus(id string, status core.CardStatus, tone, note string) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
if c.mgr.cards == nil {
|
||
return nil
|
||
}
|
||
_, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, id, status, tone, note)
|
||
return err
|
||
}
|
||
|
||
// SubmitCard:submit_card 工具入口——建卡并登记 ID,终态消息落库后回填锚点。
|
||
func (c *runCtx) SubmitCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||
created, err := c.CreateCard(card)
|
||
if err != nil {
|
||
return created, err
|
||
}
|
||
c.mgr.mu.Lock()
|
||
c.h.submittedCards = append(c.h.submittedCards, created.ID)
|
||
c.mgr.mu.Unlock()
|
||
return created, nil
|
||
}
|
||
|
||
// BeginStreamCard:card_begin 工具入口——建 active 产物卡并设为活跃流式卡。
|
||
// 多次 begin 以最后一卡为活跃(提案 §4);未收尾的旧卡由 finishRun 兜底置态。
|
||
// 收件箱关闭(cards==nil 或返回空 ID)时报错,工具层回退为对话内直写。
|
||
func (c *runCtx) BeginStreamCard(card core.ActivityCard) (core.ActivityCard, error) {
|
||
card.Status = core.CardActive
|
||
created, err := c.CreateCard(card)
|
||
if err != nil {
|
||
return created, err
|
||
}
|
||
if created.ID == "" {
|
||
return created, errors.New("card: inbox disabled")
|
||
}
|
||
c.mgr.mu.Lock()
|
||
c.h.streamCardID = created.ID
|
||
c.mgr.mu.Unlock()
|
||
return created, nil
|
||
}
|
||
|
||
// AppendStreamCard:card_append 工具入口——向活跃流式卡追加正文(事件节流)。
|
||
func (c *runCtx) AppendStreamCard(text string) error {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return err
|
||
}
|
||
c.mgr.mu.Lock()
|
||
id := c.h.streamCardID
|
||
c.mgr.mu.Unlock()
|
||
if id == "" {
|
||
return errors.New("card: no active stream card (card_begin first)")
|
||
}
|
||
return c.mgr.appendCardForEffectiveAccount(c.run.Owner, string(id), text)
|
||
}
|
||
|
||
// CommitStreamCard:card_commit 工具入口——活跃流式卡置 done、登记锚点回填
|
||
// 并清空活跃指针。
|
||
func (c *runCtx) CommitStreamCard() (core.ActivityCard, error) {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ActivityCard{}, err
|
||
}
|
||
c.mgr.mu.Lock()
|
||
id := c.h.streamCardID
|
||
c.h.streamCardID = ""
|
||
c.mgr.mu.Unlock()
|
||
if id == "" {
|
||
return core.ActivityCard{}, errors.New("card: no active stream card (card_begin first)")
|
||
}
|
||
card, err := c.mgr.setCardStatusForEffectiveAccount(c.run.Owner, string(id), core.CardDone, "ok", "")
|
||
if err != nil {
|
||
return card, err
|
||
}
|
||
c.mgr.mu.Lock()
|
||
c.h.submittedCards = append(c.h.submittedCards, card.ID)
|
||
c.mgr.mu.Unlock()
|
||
return card, nil
|
||
}
|
||
|
||
// Card:按 ID 取卡(ctxasm 回填;无此卡或收件箱关闭返回 nil)。
|
||
func (c *runCtx) Card(id string) *core.ActivityCard {
|
||
if c.mgr.cards == nil {
|
||
return nil
|
||
}
|
||
card, err := c.mgr.cards.Get(c.run.Owner, id)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return &card
|
||
}
|
||
|
||
// Artifact:按 ID 取工件与内容(ctxasm 回填 artifact_ref 正文用;跨租户/不存在
|
||
// 一律 false,不泄露存在性)。
|
||
func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
|
||
art, err := c.mgr.st.GetArtifact(c.run.Owner, string(id))
|
||
if err != nil {
|
||
return core.Artifact{}, nil, false
|
||
}
|
||
content, err := c.mgr.st.ArtifactContent(c.run.Owner, string(id))
|
||
if err != nil {
|
||
return art, nil, false
|
||
}
|
||
return art, content, true
|
||
}
|
||
|
||
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.Artifact{}, err
|
||
}
|
||
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
|
||
var writeErr error
|
||
if c.mgr.accountEffectiveAuthorization != nil {
|
||
writeErr = c.mgr.st.CreateArtifactAccountChecked(c.run.Owner, &art, content, c.mgr.accountAllowImplicitLocal)
|
||
} else {
|
||
writeErr = c.mgr.st.CreateArtifact(&art, content)
|
||
}
|
||
if err := writeErr; err != nil {
|
||
return art, err
|
||
}
|
||
if _, err := c.mgr.emit(c.run.ConversationID, c.run.ID, core.EvArtifactCreated, core.ArtifactPayload{Artifact: art}); err != nil {
|
||
return art, err
|
||
}
|
||
if kind == "document" {
|
||
c.mgr.maybeGenUIAuto(c.run.Owner, c.run.ConversationID, "artifact_document")
|
||
}
|
||
// 工件文本进检索索引。二进制 Office/PDF 先经 ingest 抽出纯文本,抽到才入库;
|
||
// 抽不到则跳过,避免把 ZIP 字节当文本索引。
|
||
if kind == "document" && len(content) < 200000 {
|
||
if text := indexTextOf(name, mime, content); text != "" {
|
||
if c.mgr.accountEffectiveAuthorization != nil {
|
||
if c.mgr.indexer != nil {
|
||
checked, ok := c.mgr.indexer.(interface {
|
||
IndexDocumentAccountChecked(context.Context, core.ID, core.ID, string, string, string, string, bool) error
|
||
})
|
||
if !ok {
|
||
return art, fmt.Errorf("document indexer does not support account fencing")
|
||
}
|
||
if err := checked.IndexDocumentAccountChecked(c.ctx, c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text, c.mgr.accountAllowImplicitLocal); err != nil {
|
||
return art, err
|
||
}
|
||
} else if err := c.mgr.st.AddKnowledgeAccountChecked(string(c.run.Owner), c.mgr.accountAllowImplicitLocal, core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text}); err != nil {
|
||
return art, err
|
||
}
|
||
} else if c.mgr.indexer != nil {
|
||
_ = c.mgr.indexer.IndexDocument(c.run.Owner, c.run.ProjectID, "artifact", name, art.ID, text)
|
||
} else {
|
||
_ = c.mgr.st.AddKnowledge(core.KnowledgeDoc{Owner: c.run.Owner, ProjectID: c.run.ProjectID, Source: "artifact", Title: name, Ref: art.ID, Chunk: text})
|
||
}
|
||
}
|
||
}
|
||
return art, nil
|
||
}
|
||
|
||
// binaryDocMime 判定二进制文档 mime:此类工件字节不能按 UTF-8 文本进索引。
|
||
func binaryDocMime(mime string) bool {
|
||
switch mime {
|
||
case "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||
"application/vnd.ms-excel",
|
||
"application/vnd.ms-powerpoint",
|
||
"application/msword",
|
||
"application/pdf",
|
||
"application/zip":
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// indexTextOf 决定工件进检索索引的文本。普通文本原样返回;二进制文档 mime
|
||
// 先走 ingest 抽文本,解析失败或为空则返回空串(跳过索引)。
|
||
func indexTextOf(name, mime string, content []byte) string {
|
||
if !binaryDocMime(mime) {
|
||
return string(content)
|
||
}
|
||
doc := ingest.Parse(name, mime, content)
|
||
if len(doc.Warnings) > 0 || len(doc.Blocks) == 0 {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(doc.PlainText())
|
||
}
|
||
|
||
func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||
if q.TopK <= 0 {
|
||
q.TopK = int(c.eff.GetNumber("retrieval.topK", 5))
|
||
}
|
||
if q.ProjectID == "" {
|
||
q.ProjectID = c.run.ProjectID
|
||
}
|
||
if c.mgr.knowledgeProjectScope != nil {
|
||
id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
q.ProjectID = id
|
||
}
|
||
if c.mgr.knowledgeResourceAllow != nil {
|
||
allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) }
|
||
if rs, ok := c.mgr.retriever.(interface {
|
||
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
|
||
}); ok {
|
||
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
|
||
}
|
||
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := []core.RetrievalHit{}
|
||
for _, h := range hits {
|
||
if allow(h.Doc) {
|
||
out = append(out, h)
|
||
}
|
||
}
|
||
shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out = append(out, shared...)
|
||
if len(out) > q.TopK {
|
||
out = out[:q.TopK]
|
||
}
|
||
return out, nil
|
||
}
|
||
if c.mgr.retriever != nil {
|
||
if c.mgr.sharedKnowledgeAllow != nil {
|
||
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
|
||
return rs.RetrieveShared(c.ctx, c.run.Owner, q, func(docOwner, projectID core.ID) bool {
|
||
return c.mgr.sharedKnowledgeAllow(string(c.run.Owner), projectID)
|
||
})
|
||
}
|
||
}
|
||
return c.mgr.retriever.Retrieve(c.ctx, c.run.Owner, q)
|
||
}
|
||
return c.mgr.st.SearchKnowledge(c.run.Owner, q)
|
||
}
|
||
|
||
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
|
||
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
policy := c.prof.Tools
|
||
if !policy.ToolAllowed(call.Name) {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
|
||
}
|
||
desc, _, _ := c.mgr.registry.Get(call.Name)
|
||
// N1 WP3:判定走 Policy 对象,会话放行经 SessionAllows 端口(键控口径不变:会话 × 工具名)。
|
||
pol := agentpolicy.Policy{
|
||
Mode: agentpolicy.Normalize(c.permissionMode),
|
||
SessionAllows: convAllowStore{mgr: c.mgr, conv: c.run.ConversationID},
|
||
}
|
||
effect := pol.Decide(call.Name, string(call.Arguments),
|
||
agentpolicy.Traits{ReadOnly: desc.ReadOnly, Edits: desc.Edits, Risk: desc.Risk})
|
||
if effect == agentpolicy.EffectDeny {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("计划模式拒绝了工具 %s", call.Name)}
|
||
}
|
||
if effect != agentpolicy.EffectAllow && (effect == agentpolicy.EffectAsk || policy.NeedsConfirm(call.Name)) {
|
||
resp, err := c.AskHITL(toolConfirmCard(call.Name, string(call.Arguments)))
|
||
if err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
|
||
}
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
if resp.ChoiceID == "session" {
|
||
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
|
||
} else if resp.ChoiceID != "yes" {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
|
||
}
|
||
}
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
|
||
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
|
||
if c.mgr.commands != nil {
|
||
for _, ct := range c.mgr.commands.CommandToolsFor(string(c.run.Owner)) {
|
||
if ct.ID != call.Name {
|
||
continue
|
||
}
|
||
var params map[string]any
|
||
if len(call.Arguments) > 0 {
|
||
_ = json.Unmarshal(call.Arguments, ¶ms)
|
||
}
|
||
resp := c.mgr.commands.DispatchCommand(c.ctx, CommandRequest{
|
||
ID: call.Name, Params: params, Entrance: "agent",
|
||
Actor: c.run.Owner, Owner: c.run.Owner, ConversationID: c.run.ConversationID,
|
||
})
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
out, _ := json.Marshal(resp.Payload)
|
||
if resp.Card && resp.State == "accepted" {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "ok",
|
||
Output: "已创建确认卡,等待用户确认后执行:" + resp.Reason}
|
||
}
|
||
if resp.State != "committed" && resp.State != "accepted" {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error",
|
||
Output: "命令 " + call.Name + " " + resp.State + ": " + resp.Reason}
|
||
}
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "ok", Output: string(out)}
|
||
}
|
||
}
|
||
tc := &toolCtx{rc: c, callID: call.CallID}
|
||
res := c.mgr.registry.Execute(tc, call)
|
||
if err := c.mgr.authorizeRunWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
|
||
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
|
||
}
|
||
// 工具产物回填(交付物卡片根因修复):注册表工具执行期经 rc.WriteArtifact
|
||
// 写入的工件(write_artifact / fs_write_file / register_artifact / 未来执行
|
||
// 原语产物)挂到结果上——llm.go 依 res.Artifact 生成对话内 artifact_ref 卡片。
|
||
// 此前只有「结果超长截断转工件」会设置它,正常产物的卡片从未出现。
|
||
if res.Artifact == nil && len(tc.arts) > 0 {
|
||
last := tc.arts[len(tc.arts)-1]
|
||
res.Artifact = &last
|
||
}
|
||
// 超长截断并转工件引用(4.10)。
|
||
limit := c.prof.Context.MaxToolResultChars
|
||
if limit <= 0 {
|
||
limit = int(c.eff.GetNumber("context.maxToolResultChars", 20000))
|
||
}
|
||
if res.Status == "ok" && utf8.RuneCountInString(res.Output) > limit {
|
||
if art, err := c.WriteArtifact("tool-"+call.Name+"-"+call.CallID+".txt", "data", "text/plain", []byte(res.Output)); err == nil {
|
||
res.Artifact = &art
|
||
res.Output = cutRunes(res.Output, limit) + "\n…(超长,完整结果已存为工件 " + art.Name + ")"
|
||
} else {
|
||
res.Output = cutRunes(res.Output, limit) + "…(超长截断,工件写入失败)"
|
||
}
|
||
res.Truncated = true
|
||
}
|
||
return res
|
||
}
|
||
|
||
// toolCtx 包装 runCtx 供工具执行;arts 记录本调用期间写入的工件,
|
||
// ExecuteTool 据此回填 res.Artifact(对话内交付物卡片的来源)。
|
||
type toolCtx struct {
|
||
rc *runCtx
|
||
arts []core.Artifact
|
||
callID string
|
||
}
|
||
|
||
func (t *toolCtx) ProjectID() string { return t.rc.run.ProjectID }
|
||
func (t *toolCtx) ConversationID() string { return t.rc.run.ConversationID }
|
||
func (t *toolCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
|
||
return t.rc.Retrieve(q)
|
||
}
|
||
func (t *toolCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
|
||
art, err := t.rc.WriteArtifact(name, kind, mime, content)
|
||
if err == nil {
|
||
t.arts = append(t.arts, art)
|
||
}
|
||
return art, err
|
||
}
|
||
func (t *toolCtx) Context() context.Context { return t.rc.Context() }
|
||
func (t *toolCtx) CheckExternalEffect() error {
|
||
return t.rc.mgr.authorizeRunWork(t.rc.ctx, t.rc.run.Owner, t.rc.run.ProjectID)
|
||
}
|
||
func (t *toolCtx) OwnerID() string { return t.rc.run.Owner }
|
||
func (t *toolCtx) WorkspaceRoot() string { return t.rc.mgr.workspaceRoot }
|
||
func (t *toolCtx) SettingBool(key string, def bool) bool { return t.rc.eff.GetBool(key, def) }
|
||
func (t *toolCtx) AskUser(card core.HITLCard) (core.HITLResponse, error) {
|
||
return t.rc.AskHITL(card)
|
||
}
|
||
func (t *toolCtx) ExitPlanMode() error { return t.rc.ExitPlanMode() }
|
||
|
||
// EmitToolOutput 把解释器执行中的输出增量发到会话事件流。
|
||
func (t *toolCtx) EmitToolOutput(stream, text string) {
|
||
if t.callID == "" || text == "" {
|
||
return
|
||
}
|
||
_ = t.rc.Emit(core.EvToolOutput, core.ToolOutputPayload{CallID: t.callID, Stream: stream, Text: text})
|
||
}
|
||
|
||
// ---- 跨会话读取门禁(提案 2026-09-30-cross-session-reading;fail-closed)----
|
||
//
|
||
// canRead(A→B) = crossSession.read(项目闸门) AND A.allowCrossRead(出站,仅读他人时)
|
||
// AND !B.crossPrivate(入站,仅被他人读时) AND 同项目 AND 同租户。
|
||
// 出站/入站检查对自读(A==B)豁免:私有会话仍可读自身更早历史。
|
||
|
||
var (
|
||
// ErrCrossReadDisabled 项目闸门关或本会话关闭出站读取。
|
||
ErrCrossReadDisabled = errors.New("跨会话读取未开启(项目设置 crossSession.read 或会话 allowCrossRead)")
|
||
// ErrCrossReadForbidden 目标不可读:私有(被他人读)或跨项目。
|
||
ErrCrossReadForbidden = errors.New("目标会话不可读(私有或不在本项目内)")
|
||
)
|
||
|
||
// crossReadSelf 加载当前会话并校验项目闸门;出站检查由调用方按目标是否为自身决定。
|
||
func (t *toolCtx) crossReadSelf() (core.Conversation, error) {
|
||
if !t.rc.eff.GetBool("crossSession.read", false) {
|
||
return core.Conversation{}, ErrCrossReadDisabled
|
||
}
|
||
self, err := t.rc.mgr.st.GetConversation(t.rc.run.Owner, t.rc.run.ConversationID)
|
||
if err != nil {
|
||
return core.Conversation{}, err
|
||
}
|
||
return self, nil
|
||
}
|
||
|
||
func (t *toolCtx) PeerConversations() ([]core.Conversation, error) {
|
||
self, err := t.crossReadSelf()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if !self.AllowCrossRead {
|
||
return nil, ErrCrossReadDisabled
|
||
}
|
||
convs, err := t.rc.mgr.st.ListConversations(t.rc.run.Owner, t.rc.run.ProjectID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]core.Conversation, 0, len(convs))
|
||
for _, c := range convs {
|
||
// 只读他人:排除自身;私有会话不出现(存在性本身是信息)。
|
||
if c.ID == self.ID || c.CrossPrivate {
|
||
continue
|
||
}
|
||
out = append(out, c)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([]core.Message, error) {
|
||
if limit <= 0 {
|
||
limit = 50
|
||
}
|
||
if limit > 200 {
|
||
limit = 200
|
||
}
|
||
self, err := t.crossReadSelf()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 租户校验先行(GetConversation 按 owner 过滤;跨租户 = NotFound,不泄露存在性)。
|
||
target, err := t.rc.mgr.st.GetConversation(t.rc.run.Owner, conversationID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
isSelf := target.ID == self.ID
|
||
if !isSelf && !self.AllowCrossRead {
|
||
return nil, ErrCrossReadDisabled
|
||
}
|
||
if !isSelf && (target.CrossPrivate || target.ProjectID != t.rc.run.ProjectID) {
|
||
return nil, ErrCrossReadForbidden
|
||
}
|
||
return t.rc.mgr.st.LastMessages(conversationID, limit)
|
||
}
|
||
|
||
// ---- run 生命周期 ----
|
||
|
||
// SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。
|
||
func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r }
|
||
|
||
// SetWorkflowRunner 注入工作流执行器。测试用来替换默认实现。
|
||
func (m *Manager) SetWorkflowRunner(r runtime.Runner) { m.wfRunner = r }
|
||
|
||
// SetWorkflows 注入已装载工作流,供 run_workflow 按 id 查找。
|
||
func (m *Manager) SetWorkflows(r *workflow.Registry) { m.workflows = r }
|
||
|
||
// selectConversationRunner 按设置选择入口。带工作流定义时走工作流执行器;
|
||
// 否则 mock 优先,squad 仅在注入了入口时生效。
|
||
func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *workflow.Launch) runtime.Runner {
|
||
if launch != nil {
|
||
if m.wfRunner != nil {
|
||
return m.wfRunner
|
||
}
|
||
return runtime.CompiledWorkflowRunner{}
|
||
}
|
||
if eff.GetBool("runtime.mock", false) {
|
||
return m.mock
|
||
}
|
||
if eff.GetString("runtime.entry", "direct") == "squad" && m.squad != nil {
|
||
return m.squad
|
||
}
|
||
return m.runner
|
||
}
|
||
|
||
// StartConversationRun 为一条已落库的用户消息启动 conversation run。
|
||
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
|
||
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
|
||
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||
return core.Run{}, err
|
||
}
|
||
if err := m.claimConversation(conv.ID); err != nil {
|
||
return core.Run{}, err
|
||
}
|
||
return m.startClaimedRun(conv, userMessage, attachments, inputs, nil)
|
||
}
|
||
|
||
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
|
||
// 之后插件卸载不会改到已经在跑的这份。
|
||
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
|
||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||
return core.Run{}, err
|
||
}
|
||
if err := m.claimConversation(conv.ID); err != nil {
|
||
return core.Run{}, err
|
||
}
|
||
copiedSlots := map[string]string{}
|
||
for k, v := range slots {
|
||
copiedSlots[k] = v
|
||
}
|
||
launch := &workflow.Launch{
|
||
Def: workflow.Clone(def),
|
||
Slots: copiedSlots,
|
||
Missing: append([]workflow.SlotQuestion(nil), missing...),
|
||
Scripts: workflowScriptBodies(m.workflows, def),
|
||
}
|
||
return m.startClaimedRun(conv, userMessage, nil, nil, launch)
|
||
}
|
||
|
||
func workflowScriptBodies(reg *workflow.Registry, def core.WorkflowDef) map[string]string {
|
||
out := map[string]string{}
|
||
if reg == nil {
|
||
return out
|
||
}
|
||
for _, step := range def.Steps {
|
||
if step.Kind != "script" || strings.TrimSpace(step.Script) == "" {
|
||
continue
|
||
}
|
||
if body, ok := reg.Script(def.ID, step.Script); ok {
|
||
out[step.Script] = body
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ClaimConversation 抢占会话槽位。忙则返回 ErrConversationBusy,此时调用方不应写消息。
|
||
func (m *Manager) ClaimConversation(conversationID string, actor ...string) error {
|
||
if err := m.claimConversation(conversationID); err != nil {
|
||
return err
|
||
}
|
||
// 执行所有权(U5/KTD4):PG 档在内存槽位之上叠加 DB 租约——跨实例的
|
||
// 同会话争抢由库裁决;接管递增 epoch 供结算围栏。失败时回退内存槽位。
|
||
if m.st.Dialect() == "postgres" {
|
||
var epoch int64
|
||
var err error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
if len(actor) == 0 || actor[0] == "" {
|
||
m.releaseConversation(conversationID)
|
||
return store.ErrAccountForbidden
|
||
}
|
||
epoch, err = m.st.TryClaimExecutionAccountChecked(actor[0], conversationID, m.InstanceID(), "", 15*60, m.accountAllowImplicitLocal)
|
||
} else {
|
||
epoch, err = m.st.TryClaimExecution(conversationID, m.InstanceID(), "", 15*60)
|
||
}
|
||
if err != nil {
|
||
m.releaseConversation(conversationID)
|
||
if errors.Is(err, store.ErrExecutionBusy) {
|
||
return ErrConversationBusy
|
||
}
|
||
return err
|
||
}
|
||
m.execMu.Lock()
|
||
m.execEpoch[conversationID] = epoch
|
||
m.execMu.Unlock()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// InstanceID 本进程执行身份(组合根可经 SetInstanceTag 覆盖;多实例必须互异)。
|
||
func (m *Manager) InstanceID() string {
|
||
if m.instanceTag != "" {
|
||
return m.instanceTag
|
||
}
|
||
return "runmgr:" + m.addr
|
||
}
|
||
|
||
// SetInstanceTag 注入稳定实例身份与监听地址(组合根)。
|
||
func (m *Manager) SetInstanceTag(tag, addr string) {
|
||
m.instanceTag = tag
|
||
m.addr = addr
|
||
}
|
||
|
||
// ReleaseConversation 释放已抢到的槽位。启动失败时调用;重复释放无效果。
|
||
func (m *Manager) ReleaseConversation(conversationID string) {
|
||
m.releaseConversation(conversationID)
|
||
}
|
||
|
||
// StartClaimedConversationRun 在调用方已经抢到槽位之后启动回合。
|
||
// CreateRun 失败时释放槽位;成功后由 execute 在终态释放。
|
||
func (m *Manager) StartClaimedConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
|
||
return m.startClaimedRun(conv, userMessage, attachments, inputs, nil)
|
||
}
|
||
|
||
// StartClaimedWorkflowRun 在调用方已经抢到槽位之后启动工作流回合。
|
||
func (m *Manager) StartClaimedWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
|
||
copiedSlots := map[string]string{}
|
||
for k, v := range slots {
|
||
copiedSlots[k] = v
|
||
}
|
||
launch := &workflow.Launch{
|
||
Def: workflow.Clone(def),
|
||
Slots: copiedSlots,
|
||
Missing: append([]workflow.SlotQuestion(nil), missing...),
|
||
Scripts: workflowScriptBodies(m.workflows, def),
|
||
}
|
||
return m.startClaimedRun(conv, userMessage, nil, nil, launch)
|
||
}
|
||
|
||
func (m *Manager) claimConversation(conversationID string) error {
|
||
m.convMu.Lock()
|
||
defer m.convMu.Unlock()
|
||
if m.convActive == nil {
|
||
m.convActive = map[string]bool{}
|
||
}
|
||
if m.convActive[conversationID] {
|
||
return ErrConversationBusy
|
||
}
|
||
m.convActive[conversationID] = true
|
||
return nil
|
||
}
|
||
|
||
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
|
||
if err := m.authorizeRunWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
|
||
m.releaseConversation(conv.ID)
|
||
return core.Run{}, err
|
||
}
|
||
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
|
||
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
|
||
run := core.Run{
|
||
ID: store.NewID("run"), ConversationID: conv.ID, ProjectID: conv.ProjectID, Owner: conv.Owner,
|
||
Kind: core.RunConversation, ProfileID: prof.ID, Status: core.RunPending, Phase: core.PhaseThinking,
|
||
}
|
||
var createErr error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
createErr = m.st.CreateRunAccountChecked(&run, m.accountAllowImplicitLocal)
|
||
} else {
|
||
createErr = m.st.CreateRun(&run)
|
||
}
|
||
if err := createErr; err != nil {
|
||
m.releaseConversation(conv.ID)
|
||
return run, err
|
||
}
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
h := &runHandle{owner: run.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||
m.mu.Lock()
|
||
m.active[run.ID] = h
|
||
m.mu.Unlock()
|
||
|
||
runner := selectConversationRunner(m, eff, launch)
|
||
var checkInputEffect func(context.Context) error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
checkInputEffect = func(checkCtx context.Context) error {
|
||
return m.authorizeAccountEffective(checkCtx, conv.Owner)
|
||
}
|
||
}
|
||
inputPaths, inputErr := m.stageInputsChecked(ctx, conv, eff, inputs, checkInputEffect)
|
||
if inputErr == nil {
|
||
inputErr = m.persistInputTextsChecked(ctx, conv.Owner, conv.ID, attachments, checkInputEffect)
|
||
}
|
||
if inputErr != nil {
|
||
cancel()
|
||
m.mu.Lock()
|
||
delete(m.active, run.ID)
|
||
m.mu.Unlock()
|
||
m.releaseConversation(conv.ID)
|
||
m.setRunStatus(&run, core.RunFailed, core.PhaseDone)
|
||
return run, inputErr
|
||
}
|
||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||
// 材料整理(附件即保证):无文字/大材料发送瞬间预启动,说明注入第 0 回合;
|
||
// 其余带附件回合由 runtime stop 钩子兜底(模型已自行委派时尊重模型判断)。
|
||
if launch == nil {
|
||
rc.materialNote = m.materialIntake(rc)
|
||
}
|
||
go func() {
|
||
// 执行许可(U3,R8):排队有界;获取失败(队列满/取消/存储故障)以
|
||
// failed 收口——不执行任何模型调用,副作用无从产生。
|
||
if err := m.adm.acquire(rc.ctx, m.st, run.ID, run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||
log.Printf("[runmgr] admission denied run %s: %v", run.ID, err)
|
||
m.setRunStatus(&run, core.RunFailed, core.PhaseThinking)
|
||
if uErr, ok := err.(error); ok {
|
||
m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: uErr.Error()}})
|
||
}
|
||
m.emit(run.ConversationID, run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "执行预算排队已满或已取消"})
|
||
m.releaseConversation(run.ConversationID)
|
||
return
|
||
}
|
||
defer m.adm.release(m.st, run.ID)
|
||
m.execute(rc, runner)
|
||
}()
|
||
return run, nil
|
||
}
|
||
|
||
// releaseConversation 释放会话在途槽位(run 终态/启动失败时)。
|
||
func (m *Manager) releaseConversation(conversationID string) {
|
||
m.convMu.Lock()
|
||
delete(m.convActive, conversationID)
|
||
m.convMu.Unlock()
|
||
if m.st != nil && m.st.Dialect() == "postgres" {
|
||
m.execMu.Lock()
|
||
epoch, ok := m.execEpoch[conversationID]
|
||
if ok {
|
||
delete(m.execEpoch, conversationID)
|
||
}
|
||
m.execMu.Unlock()
|
||
if ok {
|
||
m.st.ReleaseExecution(conversationID, m.InstanceID(), epoch)
|
||
}
|
||
}
|
||
}
|
||
|
||
// execute 运行至终态并收敛:状态、usage、助手消息、收尾事件。
|
||
func (m *Manager) execute(rc *runCtx, runner runtime.Runner) {
|
||
defer func() {
|
||
m.releaseConversation(rc.run.ConversationID)
|
||
m.mu.Lock()
|
||
delete(m.active, rc.run.ID)
|
||
m.mu.Unlock()
|
||
m.deliverNotices(rc.run.ConversationID)
|
||
}()
|
||
if err := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID); err != nil {
|
||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||
return
|
||
}
|
||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseThinking); err != nil {
|
||
m.finishRun(rc, core.RunFailed, core.Usage{}, &core.ErrorPart{Code: "run.account_inactive", Message: err.Error()})
|
||
return
|
||
}
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunPhase, core.RunPhasePayload{Phase: core.PhaseThinking})
|
||
|
||
execErr := func() (err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("runner panic: %v", r)
|
||
}
|
||
}()
|
||
return runner.Execute(rc)
|
||
}()
|
||
|
||
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
|
||
if execErr != nil {
|
||
if rc.ctx.Err() != nil {
|
||
m.finishRun(rc, core.RunCancelled, usage, &core.ErrorPart{Code: "run.cancelled", Message: "已取消"})
|
||
return
|
||
}
|
||
m.finishRun(rc, core.RunFailed, usage, &core.ErrorPart{Code: "run.failed", Message: execErr.Error()})
|
||
return
|
||
}
|
||
m.finishRun(rc, core.RunCompleted, usage, nil)
|
||
}
|
||
|
||
func (m *Manager) finishRun(rc *runCtx, status core.RunStatus, usage core.Usage, errPart *core.ErrorPart) {
|
||
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
|
||
if accountDenied != nil {
|
||
status = core.RunFailed
|
||
errPart = &core.ErrorPart{Code: "run.account_inactive", Message: accountDenied.Error()}
|
||
if rc.h.cancel != nil {
|
||
rc.h.cancel()
|
||
}
|
||
}
|
||
m.mu.Lock()
|
||
usage.PromptTokens = rc.h.promptTokens
|
||
usage.CompletionTokens = rc.h.completionTokens
|
||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||
usage.LastPromptTokens = rc.h.lastPromptTokens
|
||
usage.LastCompletionTokens = rc.h.lastCompletionTokens
|
||
usage.CacheReadTokens = rc.h.cacheReadTokens
|
||
usage.CacheWriteTokens = rc.h.cacheWriteTokens
|
||
m.mu.Unlock()
|
||
if m.shuttingDown.Load() && status == core.RunCancelled {
|
||
status = core.RunInterrupted
|
||
errPart = &core.ErrorPart{Code: "run.interrupted", Message: "进程停机,运行已中断"}
|
||
}
|
||
now := time.Now().UTC()
|
||
rc.run.EndedAt = &now
|
||
rc.run.Status = status
|
||
rc.run.Phase = core.PhaseDone
|
||
rc.run.Usage = &usage
|
||
rc.run.Error = errPart
|
||
|
||
// 终态权威消息树与 run 状态同一次提交。
|
||
m.mu.Lock()
|
||
parts := append([]core.Part{}, rc.h.assistant...)
|
||
m.mu.Unlock()
|
||
if accountDenied != nil {
|
||
parts = nil
|
||
}
|
||
if errPart != nil {
|
||
parts = append(parts, core.Part{Type: core.PartError, Err: errPart})
|
||
}
|
||
var msg *core.Message
|
||
if len(parts) > 0 {
|
||
msg = &core.Message{ConversationID: rc.run.ConversationID, RunID: rc.run.ID, Role: core.RoleAssistant, Parts: parts}
|
||
if group, index, ok := m.st.PeekRegenPending(rc.run.ConversationID); ok {
|
||
msg.VariantGroup = group
|
||
msg.VariantIndex = index
|
||
}
|
||
}
|
||
updated, fenced, err := m.settleRunWithFence(rc, msg)
|
||
if err != nil {
|
||
log.Printf("[runmgr] settle run %s: %v", rc.run.ID, err)
|
||
m.failSettle(rc, err)
|
||
return
|
||
}
|
||
if fenced {
|
||
// 旧执行代次(U5/KTD4):租约已被他实例接管——本地不写终态;
|
||
// run 归属已移交,发围栏事件后静默退出(迟到副作用由此可见)。
|
||
log.Printf("[runmgr] settle run %s fenced: execution taken over (conv %s)", rc.run.ID, rc.run.ConversationID)
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.fenced", Message: "执行权已被其他实例接管"})
|
||
return
|
||
}
|
||
if m.OnUsage != nil && usage.TotalTokens > 0 {
|
||
m.OnUsage(rc.run.Owner, usage.TotalTokens)
|
||
}
|
||
if msg != nil {
|
||
// 消息树权威已落库;hitl 摘要部件的卡片状态由前端对账收敛。
|
||
// submit_card 产物卡回填锚点:卡→消息互定位(D32 方向补全)。
|
||
m.mu.Lock()
|
||
cardIDs := append([]core.ID{}, rc.h.submittedCards...)
|
||
m.mu.Unlock()
|
||
if m.cards != nil {
|
||
for _, id := range cardIDs {
|
||
if card, err := m.cards.Get(rc.run.Owner, string(id)); err == nil {
|
||
card.AnchorMessageID = msg.ID
|
||
_ = m.updateCardForEffectiveAccount(rc.run.Owner, card)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 流式卡兜底(D45):run 终态时仍有未 commit 的流式卡——按 run 结果置
|
||
// done/failed,tone 标注「未正常收尾」;SetStatus 恒发布,收件箱可见终态。
|
||
m.mu.Lock()
|
||
streamID := rc.h.streamCardID
|
||
rc.h.streamCardID = ""
|
||
m.mu.Unlock()
|
||
if streamID != "" && m.cards != nil {
|
||
st := core.CardDone
|
||
if status != core.RunCompleted {
|
||
st = core.CardFailed
|
||
}
|
||
if card, err := m.setCardStatusForEffectiveAccount(rc.run.Owner, string(streamID), st, "warn", "未正常收尾"); err == nil {
|
||
m.mu.Lock()
|
||
rc.h.submittedCards = append(rc.h.submittedCards, card.ID)
|
||
m.mu.Unlock()
|
||
}
|
||
}
|
||
|
||
// 停机对账已经发出 run.failed 时,这里只补消息,不再发第二次。
|
||
if !updated {
|
||
return
|
||
}
|
||
switch status {
|
||
case core.RunCompleted:
|
||
usagePayload := core.RunCompletedPayload{Usage: usage}
|
||
// 上下文组装观测随终态下发(4.10/4.17:构成与裁剪可解释)。
|
||
m.mu.Lock()
|
||
if rc.h.ctxObs != nil {
|
||
usagePayload.Context = rc.h.ctxObs
|
||
}
|
||
m.mu.Unlock()
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunCompleted, usagePayload)
|
||
case core.RunFailed:
|
||
code, message := "run.failed", ""
|
||
if errPart != nil {
|
||
code, message = errPart.Code, errPart.Message
|
||
}
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
|
||
case core.RunInterrupted:
|
||
code, message := "run.interrupted", "进程停机,运行已中断"
|
||
if errPart != nil && errPart.Code != "" {
|
||
code, message = errPart.Code, errPart.Message
|
||
}
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
|
||
}
|
||
}
|
||
|
||
// failSettle 在终态提交失败后另起一次写入,把 run 标成 failed 并发出 run.failed。
|
||
func (m *Manager) failSettle(rc *runCtx, cause error) {
|
||
now := time.Now().UTC()
|
||
rc.run.EndedAt = &now
|
||
rc.run.Status = core.RunFailed
|
||
rc.run.Phase = core.PhaseDone
|
||
rc.run.Error = &core.ErrorPart{Code: "run.settle_failed", Message: cause.Error()}
|
||
if err := m.st.UpdateRun(&rc.run); err != nil {
|
||
log.Printf("[runmgr] mark settle failure %s: %v", rc.run.ID, err)
|
||
return
|
||
}
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.settle_failed", Message: cause.Error()})
|
||
}
|
||
|
||
// setRunStatus 更新并广播 phase(状态不单独出事件,phase 事件携带)。
|
||
func (m *Manager) setRunStatus(r *core.Run, status core.RunStatus, phase core.RunPhase) error {
|
||
r.Status = status
|
||
r.Phase = phase
|
||
if m.accountEffectiveAuthorization != nil && status != core.RunFailed && status != core.RunCancelled && status != core.RunInterrupted {
|
||
return m.st.UpdateRunAccountChecked(string(r.Owner), r, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.st.UpdateRun(r)
|
||
}
|
||
|
||
// emit 经批处理器持久化事件并广播;返回 seq(缓冲中的增量返回 0)。
|
||
func (m *Manager) emit(conversationID, runID string, typ core.StreamEventType, payload any) (int64, error) {
|
||
return m.events.emit(conversationID, runID, typ, payload)
|
||
}
|
||
|
||
// settleRunWithFence(U5):PG 档以本实例登记的 epoch 做围栏结算。
|
||
func (m *Manager) settleRunWithFence(rc *runCtx, msg *core.Message) (updated, fenced bool, err error) {
|
||
if m.st.Dialect() != "postgres" {
|
||
if m.accountEffectiveAuthorization != nil {
|
||
updated, err = m.st.SettleRunAccountChecked(&rc.run, msg, m.accountAllowImplicitLocal)
|
||
} else {
|
||
updated, err = m.st.SettleRun(&rc.run, msg)
|
||
}
|
||
return updated, false, err
|
||
}
|
||
m.execMu.Lock()
|
||
epoch, ok := m.execEpoch[string(rc.run.ConversationID)]
|
||
m.execMu.Unlock()
|
||
if !ok {
|
||
// 未登记(旧路径起跑):按库内当前代次校验(同实例放行)。
|
||
if es, gerr := m.st.GetExecution(string(rc.run.ConversationID)); gerr == nil {
|
||
epoch = es.Epoch
|
||
}
|
||
}
|
||
if m.accountEffectiveAuthorization != nil {
|
||
return m.st.SettleRunFencedAccountChecked(&rc.run, msg, m.InstanceID(), epoch, m.accountAllowImplicitLocal)
|
||
}
|
||
return m.st.SettleRunFenced(&rc.run, msg, m.InstanceID(), epoch)
|
||
}
|
||
|
||
// RunActiveLocally run 是否在本实例活跃执行(U5:跨实例命令路由判定)。
|
||
func (m *Manager) RunActiveLocally(runID string) bool {
|
||
root := runID
|
||
if r, err := m.st.GetRunByID(runID); err == nil && r.ParentRunID != "" {
|
||
root = r.ParentRunID
|
||
}
|
||
m.mu.Lock()
|
||
_, ok := m.active[root]
|
||
if !ok {
|
||
_, ok = m.active[runID]
|
||
}
|
||
m.mu.Unlock()
|
||
return ok
|
||
}
|
||
|
||
// CancelRun 取消 run 及其子 run(ctx 级联)。
|
||
func (m *Manager) CancelRun(runID string) error {
|
||
r, err := m.st.GetRunByID(runID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if r.Status == core.RunCompleted || r.Status == core.RunFailed || r.Status == core.RunCancelled {
|
||
return fmt.Errorf("runmgr: run %s already terminal (%s)", runID, r.Status)
|
||
}
|
||
// 取消祖先句柄(子 run 派生自父 ctx,天然级联)。
|
||
rootID := r.ParentRunID
|
||
if rootID == "" {
|
||
rootID = r.ID
|
||
}
|
||
m.mu.Lock()
|
||
if h, ok := m.active[rootID]; ok {
|
||
h.cancel()
|
||
// 唤醒可能在等 HITL 的执行。
|
||
select {
|
||
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
|
||
default:
|
||
}
|
||
}
|
||
if h, ok := m.active[r.ID]; ok && r.ID != rootID {
|
||
h.cancel()
|
||
select {
|
||
case h.hitlCh <- hitlAnswer{err: context.Canceled}:
|
||
default:
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
// ---- HITL 解析(三分支路由,4.5)----
|
||
|
||
// ResolveHITL 处理用户对卡片的响应:
|
||
// 1. 选项命中 → 回填挂起 run;
|
||
// 2. 自由输入以 "/" 开头(命令)→ 取消挂起 run,返回转轨标志由 server 层发送新消息;
|
||
// 3. 其他自由输入 → 作为澄清回答回填。
|
||
func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (pivotText string, pivoted bool, err error) {
|
||
card, err := m.st.GetHITLCard(owner, cardID)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
if card.Resolved {
|
||
return "", false, fmt.Errorf("runmgr: card %s already resolved", cardID)
|
||
}
|
||
run, err := m.st.GetRunByID(card.RunID)
|
||
if err != nil {
|
||
return "", false, err
|
||
}
|
||
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
|
||
return "", false, store.ErrNotFound
|
||
}
|
||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||
return "", false, err
|
||
}
|
||
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
|
||
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
|
||
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
|
||
// run 只能等 HITL 超时。此处提前返回未决错误,让命令总线转交执行实例。
|
||
m.mu.Lock()
|
||
_, local := m.active[run.ID]
|
||
m.mu.Unlock()
|
||
if !local {
|
||
return "", false, fmt.Errorf("runmgr: run %s not executing locally (cross-instance resolve goes through command bus)", run.ID)
|
||
}
|
||
|
||
kind := "choice"
|
||
answer := ""
|
||
if resp.ChoiceID != "" {
|
||
for _, c := range card.Choices {
|
||
if c.ID == resp.ChoiceID {
|
||
answer = c.Label
|
||
break
|
||
}
|
||
}
|
||
} else if resp.FreeText != "" {
|
||
kind = "free"
|
||
answer = resp.FreeText
|
||
trimmed := strings.TrimSpace(resp.FreeText)
|
||
// 转轨判定(两源):/ 命令,或意图闸门命中其他已注册意图。
|
||
// 命中与否经唯一 Router(internal/route)判定,与 chat 路由同一裁决(I1 WP3)。
|
||
pivot := false
|
||
pivotReason := ""
|
||
if strings.HasPrefix(trimmed, "/") {
|
||
pivot, pivotReason = true, "command"
|
||
} else if m.gate != nil {
|
||
if rec, ok := m.gate.(interface {
|
||
Recognize(ctx context.Context, input string) intentgate.MatchResult
|
||
}); ok {
|
||
res := rec.Recognize(context.Background(), trimmed)
|
||
dec := route.Decide(res, route.Env{})
|
||
if dec.StartFound {
|
||
pivot, pivotReason = true, "intent:"+res.ID
|
||
}
|
||
} else if branch, topID := m.gate.Match(context.Background(), trimmed); branch == "matched" {
|
||
pivot, pivotReason = true, "intent:"+topID
|
||
}
|
||
}
|
||
if pivot {
|
||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||
return "", false, err
|
||
}
|
||
_ = m.CancelRun(run.ID)
|
||
m.resolveHITLCardForEffectiveAccount(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
|
||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, "", answer)
|
||
_ = pivotReason
|
||
return trimmed, true, nil
|
||
}
|
||
}
|
||
if err := m.authorizeRunWork(context.Background(), run.Owner, run.ProjectID); err != nil {
|
||
return "", false, err
|
||
}
|
||
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
|
||
if err := m.resolveHITLCardForEffectiveAccount(owner, cardID, resolution); err != nil {
|
||
return "", false, err
|
||
}
|
||
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, resolution)
|
||
// 投影卡同步销项(双现;幂等)。
|
||
_ = m.resolveProjectedCardForEffectiveAccount(owner, "hitl:"+cardID, resp.ChoiceID, answer)
|
||
|
||
m.mu.Lock()
|
||
h, ok := m.active[run.ID]
|
||
m.mu.Unlock()
|
||
if !ok {
|
||
return "", false, fmt.Errorf("runmgr: run %s not active (may have ended)", run.ID)
|
||
}
|
||
select {
|
||
case h.hitlCh <- hitlAnswer{resp: resp}:
|
||
default:
|
||
return "", false, fmt.Errorf("runmgr: hitl channel busy")
|
||
}
|
||
// 恢复运行状态。
|
||
if run.Status == core.RunWaitingHITL {
|
||
run.Status = core.RunRunning
|
||
if m.accountEffectiveAuthorization != nil {
|
||
if err := m.st.UpdateRunAccountChecked(string(run.Owner), &run, m.accountAllowImplicitLocal); err != nil {
|
||
return "", false, err
|
||
}
|
||
} else if err := m.st.UpdateRun(&run); err != nil {
|
||
return "", false, err
|
||
}
|
||
}
|
||
return "", false, nil
|
||
}
|
||
|
||
// ---- 子代理(任务树)----
|
||
|
||
func (m *Manager) spawnSubagent(parentCtx context.Context, parent core.Run, agentID, name, task, hint string) (core.AgentResultPayload, error) {
|
||
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
|
||
return core.AgentResultPayload{}, err
|
||
}
|
||
rc, runner, err := m.startChild(parentCtx, parent, agentID, name, task, hint, false)
|
||
if err != nil {
|
||
return core.AgentResultPayload{}, err
|
||
}
|
||
// 父子互等规避(U3,R8):同步子代理阻塞父 run——父先让出执行许可,子
|
||
// 获取自己的许可(与父同池计入全局预算),子完成后父重取(可能排队;
|
||
// 取消语境下重取失败仅记录,run 由取消路径收口)。
|
||
m.adm.release(m.st, string(parent.ID))
|
||
defer func() {
|
||
if err := m.adm.acquire(parentCtx, m.st, string(parent.ID), parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||
log.Printf("[runmgr] admission reacquire after child %s: %v", parent.ID, err)
|
||
}
|
||
}()
|
||
if err := m.adm.acquire(parentCtx, m.st, rc.run.ID, rc.run.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "子代理执行许可排队已满或已取消"})
|
||
return core.AgentResultPayload{}, err
|
||
}
|
||
defer m.adm.release(m.st, rc.run.ID)
|
||
return m.finishChild(rc, runner, true)
|
||
}
|
||
|
||
// startChild 做深度检查、建 run、发 spawn 事件。detach 时子 run 使用独立 ctx,
|
||
// 父 run 结束不会取消它;cancel 仍登记在 active 里,CancelRun 和停机中断照常生效。
|
||
func (m *Manager) startChild(parentCtx context.Context, parent core.Run, agentID, name, task, hint string, detach bool) (*runCtx, runtime.Runner, error) {
|
||
return m.startChildOpts(parentCtx, parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint}, detach)
|
||
}
|
||
|
||
// startChildOpts 在 startChild 之上支持系统派发选项(模型档等)。
|
||
func (m *Manager) startChildOpts(parentCtx context.Context, parent core.Run, o bgSpawnOpts, detach bool) (*runCtx, runtime.Runner, error) {
|
||
if err := m.authorizeRunWork(parentCtx, parent.Owner, parent.ProjectID); err != nil {
|
||
return nil, nil, err
|
||
}
|
||
agentID, name, task, hint := o.agentID, o.name, o.task, o.hint
|
||
if parent.Kind == core.RunSubagent {
|
||
return nil, nil, fmt.Errorf("子代理不能再派发子代理")
|
||
}
|
||
if strings.TrimSpace(agentID) == "" {
|
||
agentID = "agent.sub.general"
|
||
}
|
||
child := core.Run{
|
||
ID: store.NewID("run"), ParentRunID: parent.ID, ConversationID: parent.ConversationID,
|
||
ProjectID: parent.ProjectID, Owner: parent.Owner, Kind: core.RunSubagent, ProfileID: parent.ProfileID,
|
||
Status: core.RunPending, Phase: core.PhaseThinking, AgentName: name,
|
||
}
|
||
var createErr error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
createErr = m.st.CreateRunAccountChecked(&child, m.accountAllowImplicitLocal)
|
||
} else {
|
||
createErr = m.st.CreateRun(&child)
|
||
}
|
||
if err := createErr; err != nil {
|
||
return nil, nil, err
|
||
}
|
||
// 独立转录(N2 WP5):派生请求落盘,续跑的重建凭据。
|
||
var checkChildEffect func(context.Context) error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
checkChildEffect = func(checkCtx context.Context) error {
|
||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||
}
|
||
}
|
||
if err := m.writeSubagentRequestChecked(parentCtx, child, agentID, name, task, hint, detach, checkChildEffect); err != nil {
|
||
m.setRunStatus(&child, core.RunFailed, core.PhaseDone)
|
||
return nil, nil, err
|
||
}
|
||
m.emit(parent.ConversationID, parent.ID, core.EvAgentSpawn, core.AgentSpawnPayload{AgentRun: child.ID, Name: name, Task: task})
|
||
|
||
base := parentCtx
|
||
if detach {
|
||
base = context.Background()
|
||
}
|
||
ctx, cancel := context.WithCancel(base)
|
||
h := &runHandle{owner: child.Owner, cancel: cancel, hitlCh: make(chan hitlAnswer, 1), started: time.Now()}
|
||
m.mu.Lock()
|
||
m.active[child.ID] = h
|
||
m.mu.Unlock()
|
||
|
||
prof, _ := m.profiles.Get(parent.Owner, parent.ProfileID)
|
||
eff := m.settings.Effective(parent.Owner, parent.ProjectID)
|
||
rc := &runCtx{mgr: m, run: child, prof: prof, eff: eff, ctx: ctx, h: h, subTask: task, subHint: hint, subAgent: agentID, subModel: o.model, files: filestate.New(), permissionMode: conversationMode(m, child.Owner, child.ConversationID)}
|
||
if err := m.setRunStatus(&rc.run, core.RunRunning, core.PhaseActing); err != nil {
|
||
cancel()
|
||
m.mu.Lock()
|
||
delete(m.active, child.ID)
|
||
m.mu.Unlock()
|
||
return nil, nil, err
|
||
}
|
||
|
||
runner := m.subRunner
|
||
if eff.GetBool("runtime.mock", false) || runner == nil {
|
||
runner = m.mockSub
|
||
}
|
||
return rc, runner, nil
|
||
}
|
||
|
||
// finishChild 执行子 run、落库并汇总。emitResult 为假时不发 agent.result,
|
||
// 留给后台投递成功之后再发。
|
||
func (m *Manager) finishChild(rc *runCtx, runner runtime.Runner, emitResult bool) (core.AgentResultPayload, error) {
|
||
execErr := m.authorizeRunWork(rc.ctx, rc.run.Owner, rc.run.ProjectID)
|
||
if execErr == nil {
|
||
execErr = func() (err error) {
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
err = fmt.Errorf("subagent panic: %v", r)
|
||
}
|
||
}()
|
||
return runner.Execute(rc)
|
||
}()
|
||
}
|
||
accountDenied := m.authorizeAccountEffective(rc.ctx, rc.run.Owner)
|
||
if accountDenied != nil {
|
||
execErr = accountDenied
|
||
if rc.h.cancel != nil {
|
||
rc.h.cancel()
|
||
}
|
||
}
|
||
|
||
// 子代理记账(N3 WP6):token 用量随子 run 入账(此前只有 DurationMs)。
|
||
usage := core.Usage{DurationMs: float64(time.Since(rc.h.started).Milliseconds())}
|
||
m.mu.Lock()
|
||
usage.PromptTokens = rc.h.promptTokens
|
||
usage.CompletionTokens = rc.h.completionTokens
|
||
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
|
||
usage.LastPromptTokens = rc.h.lastPromptTokens
|
||
usage.LastCompletionTokens = rc.h.lastCompletionTokens
|
||
usage.CacheReadTokens = rc.h.cacheReadTokens
|
||
usage.CacheWriteTokens = rc.h.cacheWriteTokens
|
||
m.mu.Unlock()
|
||
status := core.RunCompleted
|
||
summary := "完成"
|
||
payload := core.AgentResultPayload{AgentRun: rc.run.ID, Status: status, Summary: summary}
|
||
if execErr != nil {
|
||
if accountDenied != nil {
|
||
status = core.RunFailed
|
||
payload.Status = status
|
||
payload.Summary = accountDenied.Error()
|
||
} else if rc.ctx.Err() != nil {
|
||
status = core.RunCancelled
|
||
payload.Status = status
|
||
payload.Summary = "已取消"
|
||
} else {
|
||
status = core.RunFailed
|
||
payload.Status = status
|
||
payload.Summary = execErr.Error()
|
||
}
|
||
}
|
||
now := time.Now().UTC()
|
||
child := rc.run
|
||
child.EndedAt = &now
|
||
child.Status = status
|
||
child.Phase = core.PhaseDone
|
||
child.Usage = &usage
|
||
if m.accountEffectiveAuthorization != nil && status == core.RunCompleted {
|
||
if err := m.st.UpdateRunAccountChecked(string(child.Owner), &child, m.accountAllowImplicitLocal); err != nil {
|
||
accountDenied = err
|
||
status = core.RunFailed
|
||
child.Status = status
|
||
payload.Status = status
|
||
payload.Summary = err.Error()
|
||
_ = m.st.UpdateRun(&child)
|
||
}
|
||
} else {
|
||
_ = m.st.UpdateRun(&child)
|
||
}
|
||
if emitResult && accountDenied == nil {
|
||
m.emit(child.ConversationID, child.ID, core.EvAgentResult, payload)
|
||
}
|
||
|
||
m.mu.Lock()
|
||
subParts := append([]core.Part{}, rc.h.assistant...)
|
||
m.mu.Unlock()
|
||
if accountDenied != nil {
|
||
subParts = nil
|
||
}
|
||
// 独立转录落定(含终态 done 标记,供续跑幂等判定)。
|
||
transcriptCheck := func(checkCtx context.Context) error {
|
||
return m.authorizeAccountEffective(checkCtx, child.Owner)
|
||
}
|
||
if m.accountEffectiveAuthorization == nil {
|
||
transcriptCheck = nil
|
||
}
|
||
if err := m.writeSubagentTranscriptChecked(rc.ctx, child, subParts, string(status), transcriptCheck); err != nil {
|
||
log.Printf("[runmgr] subagent transcript %s: %v", child.ID, err)
|
||
}
|
||
fullText := partsText(subParts)
|
||
if len(subParts) > 0 {
|
||
_ = m.appendMessageForEffectiveAccount(child.Owner, &core.Message{ConversationID: child.ConversationID, RunID: child.ID, Role: core.RoleAssistant, Parts: subParts})
|
||
}
|
||
if fullText != "" {
|
||
payload.Text = capSubagentResult(rc, fullText)
|
||
if payload.Summary == "" || payload.Summary == "完成" {
|
||
payload.Summary = truncate(fullText, 160)
|
||
}
|
||
}
|
||
if line := artifactSummary(subParts); line != "" && status == core.RunCompleted {
|
||
payload.Summary = strings.TrimSpace(payload.Summary + "\n" + line)
|
||
}
|
||
|
||
m.mu.Lock()
|
||
delete(m.active, child.ID)
|
||
m.mu.Unlock()
|
||
if status != core.RunCompleted {
|
||
return payload, fmt.Errorf("subagent %s: %s", child.AgentName, payload.Summary)
|
||
}
|
||
return payload, nil
|
||
}
|
||
|
||
// bgSpawnOpts 后台派发选项:模型 delegate_task 走零值;系统确定性派发
|
||
// (材料整理)携带模型档、卡片 KV、内容键、材料标记与排队许可。
|
||
type bgSpawnOpts struct {
|
||
agentID, name, task, hint string
|
||
model string // "chat" = 子代理用对话同档模型(材料整理)
|
||
kv [][2]string
|
||
taskKey string // 内容键(非空 = 派发前去重、完成后保留键于卡片)
|
||
material bool // 材料整理任务(通知回合衔接交付物选择)
|
||
queue bool // 满容量时可排队(系统任务;模型任务仍硬拒绝)
|
||
}
|
||
|
||
func (m *Manager) spawnBackground(parent core.Run, agentID, name, task, hint string) (core.ID, error) {
|
||
return m.spawnBackgroundOpts(parent, bgSpawnOpts{agentID: agentID, name: name, task: task, hint: hint})
|
||
}
|
||
|
||
func (m *Manager) spawnBackgroundOpts(parent core.Run, o bgSpawnOpts) (core.ID, error) {
|
||
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
|
||
return "", err
|
||
}
|
||
if parent.Kind == core.RunSubagent {
|
||
return "", fmt.Errorf("子代理不能再派发子代理")
|
||
}
|
||
if o.taskKey != "" {
|
||
convID := string(parent.ConversationID)
|
||
m.materialMu.Lock()
|
||
active := m.materialActive != nil && m.materialActive[convID][o.taskKey]
|
||
m.materialMu.Unlock()
|
||
if active {
|
||
return "", fmt.Errorf("同键后台任务已在运行(材料键 %s),见收件箱进度卡", truncate(o.taskKey, 12))
|
||
}
|
||
}
|
||
conv := parent.ConversationID
|
||
m.noticeMu.Lock()
|
||
if m.bgInFlight == nil {
|
||
m.bgInFlight = map[string]int{}
|
||
}
|
||
if m.bgInFlight[conv] >= maxBackgroundChildren {
|
||
if !o.queue {
|
||
m.noticeMu.Unlock()
|
||
return "", fmt.Errorf("本会话已有 %d 个后台子代理在运行", maxBackgroundChildren)
|
||
}
|
||
if m.bgQueue == nil {
|
||
m.bgQueue = map[string][]bgQueueItem{}
|
||
}
|
||
m.bgQueue[conv] = append(m.bgQueue[conv], bgQueueItem{parent: parent, opts: o})
|
||
m.noticeMu.Unlock()
|
||
// 排队卡先行(用户立即可见排队态);SourceRunID 与执行态在槽位
|
||
// 释放启动时回填。入队与建卡之间释放槽位的窗口由 pump 兜住:
|
||
// 下一次 decBackground 会尝试启动它。
|
||
card := m.createBackgroundCard(parent, "", o.name, o.task, o.kv, fmt.Sprintf("排队中(本会话已有 %d 个后台任务,释放后自动开始)", maxBackgroundChildren))
|
||
m.noticeMu.Lock()
|
||
if q := m.bgQueue[conv]; len(q) > 0 {
|
||
q[len(q)-1].card = card
|
||
}
|
||
m.noticeMu.Unlock()
|
||
return "", nil
|
||
}
|
||
m.bgInFlight[conv]++
|
||
m.noticeMu.Unlock()
|
||
return m.startBackgroundSlot(parent, o, core.ActivityCard{})
|
||
}
|
||
|
||
// startBackgroundSlot 持槽启动后台任务(调用方已占 bgInFlight)。
|
||
// preCard 非空时复用排队卡(回填 SourceRunID 并转执行态),否则新建执行卡。
|
||
func (m *Manager) startBackgroundSlot(parent core.Run, o bgSpawnOpts, preCard core.ActivityCard) (core.ID, error) {
|
||
if err := m.authorizeRunWork(context.Background(), parent.Owner, parent.ProjectID); err != nil {
|
||
m.decBackground(parent.ConversationID)
|
||
return "", err
|
||
}
|
||
rc, runner, err := m.startChildOpts(context.Background(), parent, o, true)
|
||
if err != nil {
|
||
m.decBackground(parent.ConversationID)
|
||
return "", err
|
||
}
|
||
if o.taskKey != "" {
|
||
m.materialKeyRegister(string(parent.ConversationID), o.taskKey)
|
||
}
|
||
// 慢通道可观测(任务快慢双速,SR-B10):后台子代理进收件箱进度卡,
|
||
// 完成后把结果回投该卡;通知回合路径保持不变,卡片是用户侧回收入口。
|
||
bgCard := preCard
|
||
if bgCard.ID == "" {
|
||
bgCard = m.createBackgroundCard(parent, rc.run.ID, o.name, o.task, o.kv, "")
|
||
} else {
|
||
m.promoteQueuedCard(parent.Owner, preCard, rc.run.ID)
|
||
}
|
||
kv := o.kv
|
||
go func() {
|
||
defer m.decBackground(parent.ConversationID)
|
||
if o.taskKey != "" {
|
||
defer m.materialKeyRelease(string(parent.ConversationID), o.taskKey)
|
||
}
|
||
// 后台子代理同样计入执行预算(U3:子代理预算计入全局)。
|
||
if err := m.adm.acquire(rc.ctx, m.st, rc.run.ID, parent.Owner, m.accountEffectiveAuthorization != nil, m.accountAllowImplicitLocal); err != nil {
|
||
log.Printf("[runmgr] admission denied background %s: %v", rc.run.ID, err)
|
||
m.setRunStatus(&rc.run, core.RunFailed, rc.run.Phase)
|
||
m.st.UpdateRun(&core.Run{ID: rc.run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.admission_denied", Message: err.Error()}})
|
||
m.emit(rc.run.ConversationID, rc.run.ID, core.EvRunFailed, core.RunFailedPayload{Code: "run.admission_denied", Message: "后台子代理执行许可排队已满或已取消"})
|
||
m.finishBackgroundCard(parent.Owner, bgCard, core.AgentResultPayload{}, err, kv)
|
||
return
|
||
}
|
||
defer m.adm.release(m.st, rc.run.ID)
|
||
payload, err := m.finishChild(rc, runner, false)
|
||
m.finishBackgroundCard(parent.Owner, bgCard, payload, err, kv)
|
||
m.enqueueNotice(parent.ConversationID, bgNotice{owner: parent.Owner, name: o.name, payload: payload, material: o.material})
|
||
m.deliverNotices(parent.ConversationID)
|
||
}()
|
||
return rc.run.ID, nil
|
||
}
|
||
|
||
// pumpBackgroundQueue 槽位释放后按 FIFO 启动排队任务(decBackground 尾调)。
|
||
func (m *Manager) pumpBackgroundQueue(conv string) {
|
||
m.noticeMu.Lock()
|
||
if len(m.bgQueue[conv]) == 0 || m.bgInFlight[conv] >= maxBackgroundChildren {
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
item := m.bgQueue[conv][0]
|
||
m.bgQueue[conv] = m.bgQueue[conv][1:]
|
||
m.bgInFlight[conv]++
|
||
m.noticeMu.Unlock()
|
||
if _, err := m.startBackgroundSlot(item.parent, item.opts, item.card); err != nil {
|
||
log.Printf("[runmgr] background queue start: %v", err)
|
||
// 启动失败也要给排队卡一个终态,不能永久停在排队中。
|
||
m.finishBackgroundCard(item.parent.Owner, item.card, core.AgentResultPayload{}, err, item.opts.kv)
|
||
}
|
||
}
|
||
|
||
// createBackgroundCard 在收件箱登记慢通道进度卡。收件箱未启用(cards==nil)
|
||
// 或建卡失败时返回零值卡,完成回投随之跳过,不影响通知投递。
|
||
// kv 为系统派发任务的附加键值(材料键等),随完成回投保留;status 非空时
|
||
// 覆盖默认状态行(排队态文案)。
|
||
func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, task string, kv [][2]string, status string) core.ActivityCard {
|
||
if m.cards == nil {
|
||
return core.ActivityCard{}
|
||
}
|
||
title := strings.TrimSpace(name)
|
||
if title == "" {
|
||
title = "后台任务"
|
||
}
|
||
_ = task // 派发说明保留入参兼容;用户可见正文用 L1 骨架,不回填原始指令。
|
||
statusLabel := core.TaskSummaryStatusRunning
|
||
if status != "" {
|
||
statusLabel = status
|
||
}
|
||
focus := "正在执行:" + title
|
||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
|
||
card, err := m.createCardForEffectiveAccount(parent.Owner, core.ActivityCard{
|
||
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
|
||
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
|
||
Body: body,
|
||
}, "")
|
||
if err != nil {
|
||
log.Printf("[runmgr] background card create: %v", err)
|
||
return core.ActivityCard{}
|
||
}
|
||
return card
|
||
}
|
||
|
||
// promoteQueuedCard 排队任务获得槽位启动:回填 SourceRunID 并把状态行转为执行中。
|
||
func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childID core.ID) {
|
||
if m.cards == nil || card.ID == "" {
|
||
return
|
||
}
|
||
card.SourceRunID = childID
|
||
for i, node := range card.Body {
|
||
if node.Type != core.UIKV {
|
||
continue
|
||
}
|
||
for j, pair := range node.KV {
|
||
if pair[0] == core.TaskSummaryKVStatus || pair[0] == "状态" {
|
||
card.Body[i].KV[j][1] = core.TaskSummaryStatusRunning
|
||
}
|
||
}
|
||
}
|
||
if err := m.updateCardForEffectiveAccount(owner, card); err != nil {
|
||
log.Printf("[runmgr] queued card promote: %v", err)
|
||
}
|
||
}
|
||
|
||
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
|
||
// 失败=错误说明并置 CardFailed(U1:失败不得显示完成灯)。kv 原样保留。
|
||
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
|
||
if m.cards == nil || card.ID == "" {
|
||
return
|
||
}
|
||
summary := strings.TrimSpace(payload.Summary)
|
||
tone := "ok"
|
||
status := core.CardDone
|
||
statusLabel := core.TaskSummaryStatusDone
|
||
if err != nil {
|
||
tone = "danger"
|
||
status = core.CardFailed
|
||
statusLabel = core.TaskSummaryStatusFailed
|
||
if summary == "" {
|
||
summary = err.Error()
|
||
}
|
||
summary = "执行失败:" + summary
|
||
}
|
||
if summary == "" {
|
||
summary = "(无结果摘要)"
|
||
}
|
||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
|
||
if uerr := m.updateCardForEffectiveAccount(owner, card); uerr != nil {
|
||
log.Printf("[runmgr] background card update: %v", uerr)
|
||
}
|
||
if _, serr := m.setCardStatusForEffectiveAccount(owner, card.ID, status, tone, ""); serr != nil {
|
||
log.Printf("[runmgr] background card status: %v", serr)
|
||
}
|
||
}
|
||
|
||
func (m *Manager) decBackground(convID string) {
|
||
m.noticeMu.Lock()
|
||
if m.bgInFlight == nil {
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
if m.bgInFlight[convID] > 0 {
|
||
m.bgInFlight[convID]--
|
||
}
|
||
m.noticeMu.Unlock()
|
||
// 槽位释放:按 FIFO 启动排队的系统任务(I3)。
|
||
m.pumpBackgroundQueue(convID)
|
||
}
|
||
|
||
func (m *Manager) enqueueNotice(convID string, n bgNotice) {
|
||
// 先落库(重启重投凭据),再进内存热队列;账号拒绝时不投递迟到结果。
|
||
if n.id == "" {
|
||
n.id = store.NewID("ntc")
|
||
}
|
||
notice := store.Notice{
|
||
ID: n.id, ConversationID: convID, Owner: n.owner, Name: n.name, Payload: n.payload,
|
||
}
|
||
var persistErr error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
persistErr = m.st.AppendNoticeAccountChecked(notice, m.accountAllowImplicitLocal)
|
||
} else {
|
||
persistErr = m.st.AppendNotice(notice)
|
||
}
|
||
if persistErr != nil {
|
||
log.Printf("[runmgr] notice persist %s: %v", convID, persistErr)
|
||
return
|
||
}
|
||
m.noticeMu.Lock()
|
||
defer m.noticeMu.Unlock()
|
||
if m.notices == nil {
|
||
m.notices = map[string][]bgNotice{}
|
||
}
|
||
m.notices[convID] = append(m.notices[convID], n)
|
||
}
|
||
|
||
// LoadPendingNotices 启动时把未投递的通知装回内存队列并尝试投递
|
||
// (N2 WP5:重启重投)。投递遵循既有空闲语义(忙则等下一轮触发)。
|
||
func (m *Manager) LoadPendingNotices() {
|
||
items, err := m.st.ListNotices()
|
||
if err != nil {
|
||
log.Printf("[runmgr] notice load: %v", err)
|
||
return
|
||
}
|
||
byConv := map[string]bool{}
|
||
m.noticeMu.Lock()
|
||
if m.notices == nil {
|
||
m.notices = map[string][]bgNotice{}
|
||
}
|
||
for _, it := range items {
|
||
m.notices[it.ConversationID] = append(m.notices[it.ConversationID], bgNotice{
|
||
id: it.ID, owner: it.Owner, name: it.Name, payload: it.Payload,
|
||
})
|
||
byConv[it.ConversationID] = true
|
||
}
|
||
m.noticeMu.Unlock()
|
||
for convID := range byConv {
|
||
m.deliverNotices(convID)
|
||
}
|
||
}
|
||
|
||
// deliverNotices 在会话空闲时把全部待投递结果合并成一条 system 消息,并启动新的父回合。
|
||
// 会话忙、或启动时发现忙,就把结果放回队列。
|
||
func (m *Manager) deliverNotices(convID string) {
|
||
m.convMu.Lock()
|
||
busy := m.convActive != nil && m.convActive[convID]
|
||
m.convMu.Unlock()
|
||
if busy {
|
||
return
|
||
}
|
||
|
||
m.noticeMu.Lock()
|
||
if m.delivering == nil {
|
||
m.delivering = map[string]bool{}
|
||
}
|
||
if m.delivering[convID] {
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
items := append([]bgNotice{}, m.notices[convID]...)
|
||
if len(items) == 0 {
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
delete(m.notices, convID)
|
||
m.delivering[convID] = true
|
||
m.noticeMu.Unlock()
|
||
|
||
defer func() {
|
||
m.noticeMu.Lock()
|
||
delete(m.delivering, convID)
|
||
m.noticeMu.Unlock()
|
||
}()
|
||
|
||
// 迭代而非递归(U3 真库门禁实测):原实现的 defer 在投递失败重新入队后
|
||
// 同步再入 deliverNotices——持续失败(如会话忙/查库错误)时无限自递归,
|
||
// test-pg 慢速时序下栈溢出(job 2352)。失败本轮即返回:队列保留,
|
||
// 由下一次 enqueueNotice 或重启重投驱动;投递期间新到的通知由循环
|
||
// 尾部再取,不再经 defer 递归。
|
||
for {
|
||
if err := m.startNoticeRun(convID, items); err != nil {
|
||
m.noticeMu.Lock()
|
||
if m.notices == nil {
|
||
m.notices = map[string][]bgNotice{}
|
||
}
|
||
m.notices[convID] = append(items, m.notices[convID]...)
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
// 投递成功才删持久化行(失败保留下次重投)。
|
||
ids := make([]string, 0, len(items))
|
||
for _, n := range items {
|
||
if n.id != "" {
|
||
ids = append(ids, n.id)
|
||
}
|
||
}
|
||
if err := m.st.DeleteNotices(ids); err != nil {
|
||
log.Printf("[runmgr] notice delete: %v", err)
|
||
}
|
||
m.noticeMu.Lock()
|
||
items = append([]bgNotice{}, m.notices[convID]...)
|
||
if len(items) == 0 {
|
||
m.noticeMu.Unlock()
|
||
return
|
||
}
|
||
delete(m.notices, convID)
|
||
m.noticeMu.Unlock()
|
||
}
|
||
}
|
||
func (m *Manager) startNoticeRun(convID string, items []bgNotice) error {
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
if err := m.claimConversation(convID); err != nil {
|
||
return err
|
||
}
|
||
owner := items[0].owner
|
||
conv, err := m.st.GetConversation(owner, convID)
|
||
if err != nil {
|
||
m.releaseConversation(convID)
|
||
return err
|
||
}
|
||
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil {
|
||
m.releaseConversation(convID)
|
||
return err
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString("<system-reminder>\n")
|
||
for _, n := range items {
|
||
fmt.Fprintf(&b, "后台子代理「%s」已结束。\n状态:%s\n%s\n", n.name, n.payload.Status, strings.TrimSpace(n.payload.Summary))
|
||
}
|
||
// 材料整理完成 → 交付物选择职责(I2):汇报要点后立即发起待确认选择卡。
|
||
// runtime 侧对不发 confirm_choice 的弱模型另有 stop 兜底(一次性)。
|
||
materialSeen := false
|
||
for _, n := range items {
|
||
if n.material {
|
||
materialSeen = true
|
||
break
|
||
}
|
||
}
|
||
if materialSeen {
|
||
b.WriteString("\n这批材料的系统整理已完成。处理方式:\n1) 先用不超过 5 行汇报整理结果要点(来自上面的摘要)。\n2) 然后立即调用 confirm_choice 工具发起交付物选择卡:问题如「基于整理结果,接下来生成什么?」;选项取整理结果中的「建议后续处理」(3-4 项,description 写一句材料依据,最贴合的一项设 recommended),允许自由输入。\n3) 用户答复后按工具结果指引,用 delegate_task 的 background=true 启动对应后台任务。\n不要重复派发材料整理任务。\n")
|
||
if m.genuiAuto != nil {
|
||
if ok, _ := m.genuiAuto.Try(owner, convID, "material_done"); ok {
|
||
b.WriteString("\n")
|
||
b.WriteString(genUIAutoReminderText())
|
||
b.WriteString("\n")
|
||
}
|
||
}
|
||
}
|
||
b.WriteString("</system-reminder>")
|
||
msg := core.Message{
|
||
ConversationID: convID, Role: core.RoleSystem,
|
||
Parts: []core.Part{{Type: core.PartText, Text: b.String()}},
|
||
}
|
||
if err := m.authorizeRunWork(context.Background(), owner, conv.ProjectID); err != nil {
|
||
m.releaseConversation(convID)
|
||
return err
|
||
}
|
||
if err := m.appendMessageForEffectiveAccount(owner, &msg); err != nil {
|
||
m.releaseConversation(convID)
|
||
return err
|
||
}
|
||
if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil {
|
||
m.releaseConversation(convID)
|
||
return err
|
||
}
|
||
for _, n := range items {
|
||
m.emit(convID, n.payload.AgentRun, core.EvAgentResult, n.payload)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// InterruptRunning 服务停机前把在途 run 收成 interrupted(4.2 恢复策略)。
|
||
// 与启动对账共用 sealRunInterrupted,终态形状一致。
|
||
func (m *Manager) InterruptRunning() {
|
||
m.shuttingDown.Store(true)
|
||
m.mu.Lock()
|
||
ids := make([]string, 0, len(m.active))
|
||
for id := range m.active {
|
||
ids = append(ids, id)
|
||
}
|
||
handles := make([]*runHandle, 0, len(ids))
|
||
for _, id := range ids {
|
||
if h, ok := m.active[id]; ok {
|
||
handles = append(handles, h)
|
||
}
|
||
}
|
||
m.mu.Unlock()
|
||
for _, h := range handles {
|
||
h.cancel()
|
||
}
|
||
for _, id := range ids {
|
||
// 子代理 run 先把已收集的部分转录落定(中断可续跑的现场)。
|
||
if run, err := m.st.GetRunByID(id); err == nil && run.Kind == core.RunSubagent {
|
||
m.mu.Lock()
|
||
var parts []core.Part
|
||
if h, ok := m.active[id]; ok {
|
||
parts = append(parts, h.assistant...)
|
||
}
|
||
m.mu.Unlock()
|
||
var check func(context.Context) error
|
||
if m.accountEffectiveAuthorization != nil {
|
||
check = func(ctx context.Context) error { return m.authorizeAccountEffective(ctx, run.Owner) }
|
||
}
|
||
if err := m.writeSubagentTranscriptChecked(context.Background(), run, parts, "interrupted", check); err != nil {
|
||
log.Printf("[runmgr] interrupted transcript %s: %v", run.ID, err)
|
||
}
|
||
}
|
||
m.sealRunInterrupted(id)
|
||
}
|
||
}
|
||
|
||
// ReconcileInterrupted 在开始监听之前调用。
|
||
// SQLite 档扫全部非终态 run。PG 档只扫本实例,以及升级前 instance_id 为空的行。
|
||
func (m *Manager) ReconcileInterrupted() error {
|
||
all := m.st.Dialect() != "postgres"
|
||
runs, err := m.st.ListRecoverableRuns(m.instanceID, all)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, r := range runs {
|
||
m.sealRunInterrupted(r.ID)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sealRunInterrupted 条件更新成功才关 HITL 并发事件。并发调用只有一个赢家。
|
||
func (m *Manager) sealRunInterrupted(runID string) {
|
||
ok, err := m.st.SealInterrupted(runID)
|
||
if err != nil || !ok {
|
||
return
|
||
}
|
||
r, err := m.st.GetRunByID(runID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
m.closePendingHITL(r)
|
||
code, message := "run.interrupted", "进程重启或停机,运行已中断"
|
||
if r.Error != nil && r.Error.Code != "" {
|
||
code, message = r.Error.Code, r.Error.Message
|
||
}
|
||
m.emit(r.ConversationID, r.ID, core.EvRunFailed, core.RunFailedPayload{Code: code, Message: message})
|
||
}
|
||
|
||
func (m *Manager) closePendingHITL(r core.Run) {
|
||
cards, err := m.st.ActiveHITLCardsByRun(r.ID)
|
||
if err != nil {
|
||
return
|
||
}
|
||
for _, c := range cards {
|
||
res := core.HITLResolvedPayload{CardID: c.ID, Kind: "cancelled"}
|
||
if err := m.resolveHITLCardForEffectiveAccount(r.Owner, string(c.ID), res); err != nil {
|
||
continue
|
||
}
|
||
m.emit(r.ConversationID, r.ID, core.EvHITLResolved, res)
|
||
}
|
||
}
|
||
|
||
func partsText(parts []core.Part) string {
|
||
var sb strings.Builder
|
||
for _, p := range parts {
|
||
if p.Type == core.PartText {
|
||
text := p.Text
|
||
// MiniMax 等模型把推理写成行内 <think> 块:子代理摘要/回投卡正文
|
||
// 只取 think 之外的正文(实测 <think> 前缀泄漏进收件箱卡片与通知)。
|
||
if body := stripInlineThink(text); strings.TrimSpace(body) != "" || !strings.Contains(text, "</think>") {
|
||
text = body
|
||
}
|
||
sb.WriteString(text)
|
||
sb.WriteString("\n")
|
||
}
|
||
}
|
||
return strings.TrimSpace(sb.String())
|
||
}
|
||
|
||
// stripInlineThink 去掉文本中的 <think>…</think> 段(完整闭合才剥,半截保守保留)。
|
||
func stripInlineThink(text string) string {
|
||
for {
|
||
start := strings.Index(text, "<think>")
|
||
if start < 0 {
|
||
return text
|
||
}
|
||
end := strings.Index(text[start:], "</think>")
|
||
if end < 0 {
|
||
return text
|
||
}
|
||
text = text[:start] + text[start+end+len("</think>"):]
|
||
}
|
||
}
|
||
|
||
func artifactSummary(parts []core.Part) string {
|
||
var bits []string
|
||
seen := map[string]bool{}
|
||
for _, p := range parts {
|
||
if p.Type != core.PartArtifactRef || p.Artifact == nil || p.Artifact.ArtifactID == "" || seen[p.Artifact.ArtifactID] {
|
||
continue
|
||
}
|
||
seen[p.Artifact.ArtifactID] = true
|
||
name := p.Artifact.Name
|
||
if name == "" {
|
||
name = p.Artifact.ArtifactID
|
||
}
|
||
bits = append(bits, "工件:"+p.Artifact.ArtifactID+" "+name)
|
||
}
|
||
return strings.Join(bits, ";")
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
cut := cutRunes(s, n)
|
||
if cut == s {
|
||
return s
|
||
}
|
||
return cut + "…"
|
||
}
|
||
|
||
func cutRunes(s string, n int) string {
|
||
if n < 0 {
|
||
n = 0
|
||
}
|
||
r := []rune(s)
|
||
if len(r) <= n {
|
||
return s
|
||
}
|
||
return string(r[:n])
|
||
}
|
||
|
||
func capSubagentResult(rc *runCtx, text string) string {
|
||
shown, full := subagent.CapParentText(text)
|
||
if full == nil {
|
||
return shown
|
||
}
|
||
if art, err := rc.WriteArtifact("subagent-result.txt", "data", "text/plain", full); err == nil {
|
||
return shown + "\n完整结果见工件 " + art.Name
|
||
}
|
||
return shown + "\n完整结果超出 32000 字,已截断"
|
||
}
|
||
|
||
func (t *toolCtx) NoteFileRead(path string) error {
|
||
if t.rc == nil || t.rc.files == nil {
|
||
return nil
|
||
}
|
||
return t.rc.files.NoteRead(path)
|
||
}
|
||
|
||
func (t *toolCtx) CheckFileWrite(path string) error {
|
||
if t.rc == nil || t.rc.files == nil {
|
||
return nil
|
||
}
|
||
return t.rc.files.CheckWrite(path)
|
||
}
|