Fix truncation issues in piplan, SQLite storage, and history compression; add PIPlanMaxChars configuration
This commit is contained in:
@@ -22,19 +22,23 @@ import (
|
||||
type StreamEventType string
|
||||
|
||||
const (
|
||||
StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程
|
||||
StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求
|
||||
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
|
||||
StreamEventTypeFinal StreamEventType = "final" // 最终答案
|
||||
StreamEventTypeError StreamEventType = "error" // 错误信息
|
||||
StreamEventTypeThought StreamEventType = "thought" // LLM 思考过程
|
||||
StreamEventTypeToolCall StreamEventType = "tool_call" // 工具调用请求
|
||||
StreamEventTypeToolResult StreamEventType = "tool_result" // 工具执行结果
|
||||
StreamEventTypeFinal StreamEventType = "final" // 最终答案
|
||||
StreamEventTypeError StreamEventType = "error" // 错误信息
|
||||
StreamEventTypeWorkspaceStart StreamEventType = "workspace_start" // 工具渲染开始
|
||||
StreamEventTypeWorkspaceDelta StreamEventType = "workspace_delta" // 工具渲染增量内容
|
||||
StreamEventTypeWorkspaceEnd StreamEventType = "workspace_end" // 工具渲染结束
|
||||
)
|
||||
|
||||
// StreamEvent 代表流式输出中的一个事件
|
||||
type StreamEvent struct {
|
||||
Type StreamEventType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Step int `json:"step,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
Type StreamEventType `json:"type"`
|
||||
Content string `json:"content"`
|
||||
Step int `json:"step,omitempty"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
WorkspaceTitle string `json:"workspace_title,omitempty"`
|
||||
}
|
||||
|
||||
// StreamEventCallback 是流式事件回调函数类型,用于推送事件到客户端
|
||||
@@ -42,23 +46,26 @@ type StreamEventCallback func(event StreamEvent) error
|
||||
|
||||
// Orchestrator 负责协调和组合业务逻辑,包含 LLM 计算、上下文管理、技能匹配计算和工具调用。
|
||||
type Orchestrator struct {
|
||||
llm llm.Client
|
||||
routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配
|
||||
store *memory.SQLiteStore
|
||||
tools *tools.Registry
|
||||
soul string
|
||||
skills []knowledge.Skill
|
||||
skillSummaries []knowledge.SkillSummary
|
||||
skillsDir string
|
||||
autoSkillDir string
|
||||
gapDraftTriggerCount int
|
||||
gapLookbackDuration time.Duration
|
||||
reactMaxStep int
|
||||
enableCapabilityGap bool
|
||||
log *logger.Logger
|
||||
skillsMu sync.RWMutex
|
||||
pendingFilesMu sync.Mutex
|
||||
pendingFiles map[string][]pendingFileRef
|
||||
llm llm.Client
|
||||
routerLLM llm.Client // 可选:轻量路由模型,用于技能意图路由;为 nil 则仅用关键词匹配
|
||||
store *memory.SQLiteStore
|
||||
tools *tools.Registry
|
||||
soul string
|
||||
skills []knowledge.Skill
|
||||
skillSummaries []knowledge.SkillSummary
|
||||
skillsDir string
|
||||
autoSkillDir string
|
||||
gapDraftTriggerCount int
|
||||
gapLookbackDuration time.Duration
|
||||
reactMaxStep int
|
||||
enableCapabilityGap bool
|
||||
autoSkillDraftEnabled bool
|
||||
log *logger.Logger
|
||||
skillsMu sync.RWMutex
|
||||
pendingFilesMu sync.Mutex
|
||||
pendingFiles map[string][]pendingFileRef
|
||||
planningSessionsMu sync.Mutex
|
||||
planningSessions map[string]planningSessionState
|
||||
}
|
||||
|
||||
type pendingFileRef struct {
|
||||
@@ -67,6 +74,15 @@ type pendingFileRef struct {
|
||||
MimeType string
|
||||
}
|
||||
|
||||
type planningSessionState struct {
|
||||
Active bool
|
||||
LastArtifact string
|
||||
AwaitingConfirm bool
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
const planningSessionTTL = 2 * time.Hour
|
||||
|
||||
// NewOrchestrator 创建一个新的编排器对象,初始化关键路径和超时控制等。
|
||||
func NewOrchestrator(
|
||||
llmClient llm.Client,
|
||||
@@ -97,21 +113,23 @@ func NewOrchestrator(
|
||||
autoSkillDir = skillsDir
|
||||
}
|
||||
return &Orchestrator{
|
||||
llm: llmClient,
|
||||
routerLLM: routerLLM,
|
||||
store: store,
|
||||
tools: registry,
|
||||
soul: soul,
|
||||
skills: skills,
|
||||
skillSummaries: copySkillSummaries(skillSummaries),
|
||||
skillsDir: skillsDir,
|
||||
autoSkillDir: autoSkillDir,
|
||||
gapDraftTriggerCount: gapDraftTriggerCount,
|
||||
gapLookbackDuration: gapLookbackDuration,
|
||||
reactMaxStep: reactMaxStep,
|
||||
enableCapabilityGap: enableCapabilityGap,
|
||||
log: log,
|
||||
pendingFiles: make(map[string][]pendingFileRef),
|
||||
llm: llmClient,
|
||||
routerLLM: routerLLM,
|
||||
store: store,
|
||||
tools: registry,
|
||||
soul: soul,
|
||||
skills: skills,
|
||||
skillSummaries: copySkillSummaries(skillSummaries),
|
||||
skillsDir: skillsDir,
|
||||
autoSkillDir: autoSkillDir,
|
||||
gapDraftTriggerCount: gapDraftTriggerCount,
|
||||
gapLookbackDuration: gapLookbackDuration,
|
||||
reactMaxStep: reactMaxStep,
|
||||
enableCapabilityGap: enableCapabilityGap,
|
||||
autoSkillDraftEnabled: false,
|
||||
log: log,
|
||||
pendingFiles: make(map[string][]pendingFileRef),
|
||||
planningSessions: make(map[string]planningSessionState),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,6 +184,11 @@ func (o *Orchestrator) HandleMessageStreamWithFiles(ctx context.Context, chatID,
|
||||
return o.handleMessageStreamInternal(ctx, chatID, userID, text, callback)
|
||||
}
|
||||
|
||||
// GetHistory 获取指定会话的历史记录。
|
||||
func (o *Orchestrator) GetHistory(chatID string, limit int) ([]memory.Message, error) {
|
||||
return o.store.LoadRecent(chatID, limit)
|
||||
}
|
||||
|
||||
// UploadAndCacheFiles 上传文件到 LLM 并缓存 file_id,供后续同会话文本问答复用。
|
||||
func (o *Orchestrator) UploadAndCacheFiles(ctx context.Context, chatID, userID string, files []llm.InputFile) ([]string, error) {
|
||||
if len(files) == 0 {
|
||||
@@ -342,7 +365,7 @@ func (o *Orchestrator) handleMessageStreamInternal(ctx context.Context, chatID,
|
||||
// buildUnifiedSystemPrompt 构建统一 ReAct 循环的 system prompt。
|
||||
// 工具定义通过 API 的 tools 字段传递;此处只需包含人格、技能、运行环境和思考指引。
|
||||
// routedSkills 为 LLM 路由预选的技能列表;如果为 nil,则回退到关键词匹配。
|
||||
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills []knowledge.Skill) string {
|
||||
func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, recentMessages []memory.Message, routedSkills []knowledge.Skill, planningMode bool) string {
|
||||
skillMetaDoc := o.formatSkillSummariesForPrompt()
|
||||
var relevantSkillsDoc string
|
||||
if routedSkills != nil {
|
||||
@@ -352,6 +375,18 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
|
||||
}
|
||||
runtimeDoc := formatRuntimeContextForPrompt()
|
||||
|
||||
// 提取最近的 Artifact
|
||||
artifactDoc := extractLastArtifact(recentMessages)
|
||||
planningModeDoc := "当前未处于 PI 规划编辑模式。"
|
||||
if planningMode {
|
||||
planningModeDoc = strings.Join([]string{
|
||||
"当前处于 PI 规划编辑模式。",
|
||||
"- 用户的修订意见必须基于现有 Artifact 继续迭代。",
|
||||
"- 需要继续调用 safe_pi_planning / publish_pi_plan 相关流程生成更新版本。",
|
||||
"- 不要仅给普通文本答复替代蓝图更新。",
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
return strings.Join([]string{
|
||||
"你是一个个人自动化助手,必须遵循如下人格设定并保持一致:",
|
||||
o.soul,
|
||||
@@ -373,6 +408,12 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
|
||||
"===== 运行环境 =====",
|
||||
runtimeDoc,
|
||||
"",
|
||||
"===== 已有结果 (Artifact) =====",
|
||||
artifactDoc,
|
||||
"",
|
||||
"===== PI 规划模式 =====",
|
||||
planningModeDoc,
|
||||
"",
|
||||
"===== 可用技能概览 =====",
|
||||
skillMetaDoc,
|
||||
"",
|
||||
@@ -383,6 +424,21 @@ func (o *Orchestrator) buildUnifiedSystemPrompt(userInput string, routedSkills [
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
// extractLastArtifact 从消息历史中提取最后一个 Artifact
|
||||
func extractLastArtifact(messages []memory.Message) string {
|
||||
for i := len(messages) - 1; i >= 0; i-- {
|
||||
msg := messages[i]
|
||||
if strings.Contains(msg.Content, "<artifact") {
|
||||
start := strings.Index(msg.Content, "<artifact")
|
||||
end := strings.LastIndex(msg.Content, "</artifact>")
|
||||
if end > start {
|
||||
return "\n当前正在处理的 Artifact 内容如下:\n" + msg.Content[start:end+11] + "\n如果你需要更新此 Artifact,请输出完整的更新后版本并确保包裹在 <artifact> 标签中。"
|
||||
}
|
||||
}
|
||||
}
|
||||
return "当前没有正在处理的 Artifact。"
|
||||
}
|
||||
|
||||
// routeSkillsWithLLM 使用轻量 LLM 模型对用户输入进行语义路由,判断是否需要加载技能以及选择哪些技能。
|
||||
// 返回匹配到的技能列表(可能为空切片表示不需要技能,nil 表示调用失败应回退)。
|
||||
func (o *Orchestrator) routeSkillsWithLLM(ctx context.Context, userInput string) ([]knowledge.Skill, error) {
|
||||
@@ -497,9 +553,26 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
||||
traceID := logger.TraceIDFromContext(ctx)
|
||||
traceLogPrefix := "trace_id=" + traceID
|
||||
|
||||
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 =====
|
||||
planningMode := false
|
||||
if session, ok := o.getPlanningSession(chatID, userID); ok && session.Active {
|
||||
if shouldExitPlanningMode(userInput) {
|
||||
o.clearPlanningSession(chatID, userID)
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s planning mode exited chat_id=%s", traceLogPrefix, chatID)
|
||||
}
|
||||
} else {
|
||||
planningMode = true
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能(规划模式下使用粘性技能) =====
|
||||
var routedSkills []knowledge.Skill
|
||||
if o.routerLLM != nil {
|
||||
if planningMode {
|
||||
routedSkills = o.getSafePIPlanningSkills()
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s planning mode sticky skill activated chat_id=%s matched=%d", traceLogPrefix, chatID, len(routedSkills))
|
||||
}
|
||||
} else if o.routerLLM != nil {
|
||||
routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
|
||||
if routeErr != nil {
|
||||
if o.log != nil {
|
||||
@@ -517,8 +590,12 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
||||
}
|
||||
}
|
||||
}
|
||||
if containsSafePIPlanningSkill(routedSkills) {
|
||||
planningMode = true
|
||||
o.activatePlanningSession(chatID, userID, "", false)
|
||||
}
|
||||
|
||||
systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills)
|
||||
systemPrompt := o.buildUnifiedSystemPrompt(userInput, nil, routedSkills, planningMode)
|
||||
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s unified react start", traceLogPrefix)
|
||||
@@ -544,6 +621,18 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
||||
// 加入当前用户消息
|
||||
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
|
||||
|
||||
// 获取最近消息以提取 Artifact
|
||||
recent, _ := o.store.LoadRecent(chatID, 16)
|
||||
systemPrompt = o.buildUnifiedSystemPrompt(userInput, recent, routedSkills, planningMode)
|
||||
|
||||
// 更新 system message
|
||||
messages[0].Content = systemPrompt
|
||||
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s unified react start", traceLogPrefix)
|
||||
o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt))
|
||||
}
|
||||
|
||||
// 构建工具定义列表(通过 API tools 字段传递)
|
||||
toolDefs := o.buildToolDefinitions()
|
||||
if o.log != nil {
|
||||
@@ -598,6 +687,16 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
||||
for _, tc := range completion.ToolCalls {
|
||||
toolName := strings.ToLower(strings.TrimSpace(tc.Function.Name))
|
||||
toolInput := extractToolInput(tc.Function.Arguments)
|
||||
if planningMode && toolName == "create_gitea_ticket" && !isPlanningConfirmation(userInput) {
|
||||
obs := formatToolErrorObservation("WAIT_USER_CONFIRM", toolName, "需用户确认后才能创建工单,请先征求用户确认")
|
||||
messages = append(messages, llm.PromptMessage{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: obs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
tool, ok := o.tools.Get(toolName)
|
||||
if !ok {
|
||||
@@ -624,6 +723,14 @@ func (o *Orchestrator) runUnifiedReAct(ctx context.Context, chatID, userID, comp
|
||||
if obs == "" {
|
||||
obs = "(empty output)"
|
||||
}
|
||||
if isPIPlanningToolName(toolName) && toolErr == nil && strings.TrimSpace(obs) != "" {
|
||||
o.activatePlanningSession(chatID, userID, obs, true)
|
||||
if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(obs)); err != nil {
|
||||
if o.log != nil {
|
||||
o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if toolErr != nil {
|
||||
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
|
||||
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName)
|
||||
@@ -657,9 +764,26 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
traceID := logger.TraceIDFromContext(ctx)
|
||||
traceLogPrefix := "trace_id=" + traceID
|
||||
|
||||
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能 =====
|
||||
planningMode := false
|
||||
if session, ok := o.getPlanningSession(chatID, userID); ok && session.Active {
|
||||
if shouldExitPlanningMode(userInput) {
|
||||
o.clearPlanningSession(chatID, userID)
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s planning mode exited chat_id=%s", traceLogPrefix, chatID)
|
||||
}
|
||||
} else {
|
||||
planningMode = true
|
||||
}
|
||||
}
|
||||
|
||||
// ===== LLM 意图路由:使用轻量模型判断是否需要加载技能(规划模式下使用粘性技能) =====
|
||||
var routedSkills []knowledge.Skill
|
||||
if o.routerLLM != nil {
|
||||
if planningMode {
|
||||
routedSkills = o.getSafePIPlanningSkills()
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s planning mode sticky skill activated chat_id=%s matched=%d", traceLogPrefix, chatID, len(routedSkills))
|
||||
}
|
||||
} else if o.routerLLM != nil {
|
||||
routed, routeErr := o.routeSkillsWithLLM(ctx, userInput)
|
||||
if routeErr != nil {
|
||||
if o.log != nil {
|
||||
@@ -676,8 +800,20 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
}
|
||||
}
|
||||
}
|
||||
if containsSafePIPlanningSkill(routedSkills) {
|
||||
planningMode = true
|
||||
o.activatePlanningSession(chatID, userID, "", false)
|
||||
}
|
||||
routedToSafePIPlanning := false
|
||||
for _, sk := range routedSkills {
|
||||
name := strings.ToLower(strings.TrimSpace(sk.Name))
|
||||
if strings.Contains(name, "safe") || strings.Contains(name, "pi 规划") || strings.Contains(name, "pi planning") {
|
||||
routedToSafePIPlanning = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
systemPrompt := o.buildUnifiedSystemPrompt(userInput, routedSkills)
|
||||
systemPrompt := o.buildUnifiedSystemPrompt(userInput, nil, routedSkills, planningMode)
|
||||
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s unified react stream start", traceLogPrefix)
|
||||
@@ -699,6 +835,18 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
messages = append(messages, parseCompressedHistoryMessages(compressedContext)...)
|
||||
messages = append(messages, llm.PromptMessage{Role: "user", Content: userInput})
|
||||
|
||||
// 获取最近消息以提取 Artifact
|
||||
recent, _ := o.store.LoadRecent(chatID, 16)
|
||||
systemPrompt = o.buildUnifiedSystemPrompt(userInput, recent, routedSkills, planningMode)
|
||||
|
||||
// 更新 system message
|
||||
messages[0].Content = systemPrompt
|
||||
|
||||
if o.log != nil {
|
||||
o.log.Infof("%s unified react stream start", traceLogPrefix)
|
||||
o.log.Debugf("%s system_prompt_len=%d", traceLogPrefix, len(systemPrompt))
|
||||
}
|
||||
|
||||
// 构建工具定义列表
|
||||
toolDefs := o.buildToolDefinitions()
|
||||
if o.log != nil {
|
||||
@@ -709,6 +857,7 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
o.log.Debugf("%s tool_defs_count=%d names=%v", traceLogPrefix, len(toolDefs), toolNames)
|
||||
}
|
||||
|
||||
workspaceSentThisTurn := false
|
||||
const maxSteps = 20
|
||||
for step := 1; step <= maxSteps; step++ {
|
||||
if o.log != nil {
|
||||
@@ -729,17 +878,23 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
}
|
||||
}
|
||||
|
||||
// 推送思考过程事件
|
||||
// 推送思考过程事件(剥离 artifact 标签,避免前端重复渲染工作区)
|
||||
if completion.Content != "" {
|
||||
// 分割内容为逐步推送的片段
|
||||
segments := splitContentIntoSegments(completion.Content, 50) // 每段50字符
|
||||
for _, segment := range segments {
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeThought,
|
||||
Content: segment,
|
||||
Step: step,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
displayContent := completion.Content
|
||||
if planningMode {
|
||||
displayContent = stripArtifactTags(displayContent)
|
||||
}
|
||||
if displayContent != "" {
|
||||
// 分割内容为逐步推送的片段
|
||||
segments := splitContentIntoSegments(displayContent, 50) // 每段50字符
|
||||
for _, segment := range segments {
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeThought,
|
||||
Content: segment,
|
||||
Step: step,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -750,6 +905,45 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
if finalText == "" {
|
||||
finalText = "已完成处理。"
|
||||
}
|
||||
if planningMode && !workspaceSentThisTurn {
|
||||
// 提取 artifact 标签内的文档内容作为工作区内容
|
||||
workspaceContent := extractArtifactContent(finalText)
|
||||
if workspaceContent == "" {
|
||||
workspaceContent = stripArtifactTags(finalText)
|
||||
}
|
||||
if workspaceContent == "" {
|
||||
workspaceContent = finalText
|
||||
}
|
||||
o.activatePlanningSession(chatID, userID, workspaceContent, true)
|
||||
if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(workspaceContent)); err != nil {
|
||||
if o.log != nil {
|
||||
o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||
}
|
||||
}
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeWorkspaceStart,
|
||||
WorkspaceTitle: "PI Planning Document",
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeWorkspaceDelta,
|
||||
Content: workspaceContent,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
if err := callback(StreamEvent{Type: StreamEventTypeWorkspaceEnd}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
workspaceSentThisTurn = true
|
||||
finalText = "我已根据你的意见更新 PI 规划,请查看右侧工作区;如确认无误,请回复“确认”进入工单创建。"
|
||||
} else if planningMode {
|
||||
// 工作区已在本轮通过工具调用发送,剥离 final 内容中的 artifact 标签
|
||||
finalText = stripArtifactTags(finalText)
|
||||
if finalText == "" {
|
||||
finalText = "已完成处理。"
|
||||
}
|
||||
}
|
||||
if o.log != nil {
|
||||
o.log.Debugf("%s react stream final at step=%d answer_len=%d", traceLogPrefix, step, len(finalText))
|
||||
}
|
||||
@@ -817,11 +1011,66 @@ func (o *Orchestrator) runUnifiedReActStream(ctx context.Context, chatID, userID
|
||||
o.log.Debugf("%s react stream step=%d tool=%s input=%q", traceLogPrefix, step, toolName, toolInput)
|
||||
}
|
||||
|
||||
if planningMode && toolName == "create_gitea_ticket" && !isPlanningConfirmation(userInput) {
|
||||
obs := formatToolErrorObservation("WAIT_USER_CONFIRM", toolName, "需用户确认后才能创建工单,请先征求用户确认")
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeToolResult,
|
||||
Content: obs,
|
||||
Step: step,
|
||||
ToolName: toolName,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
messages = append(messages, llm.PromptMessage{
|
||||
Role: "tool",
|
||||
ToolCallID: tc.ID,
|
||||
Name: tc.Function.Name,
|
||||
Content: obs,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// 当路由命中 SAFe PI 规划技能或调用 publish_pi_plan 工具时,触发 workspace 事件。
|
||||
isArtifactTool := strings.Contains(toolName, "safe_pi_planning") || toolName == "publish_pi_plan" || (routedToSafePIPlanning && strings.Contains(toolName, "publish_pi_plan"))
|
||||
if isArtifactTool {
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeWorkspaceStart,
|
||||
WorkspaceTitle: "PI Planning Document",
|
||||
ToolName: toolName,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
workspaceSentThisTurn = true
|
||||
}
|
||||
|
||||
toolOut, toolErr := tool.Call(ctx, toolInput)
|
||||
obs := strings.TrimSpace(toolOut)
|
||||
if obs == "" {
|
||||
obs = "(empty output)"
|
||||
}
|
||||
|
||||
if isArtifactTool && toolErr == nil {
|
||||
o.activatePlanningSession(chatID, userID, obs, true)
|
||||
if err := o.store.SaveMessage(chatID, userID, "assistant", wrapPIArtifact(obs)); err != nil {
|
||||
if o.log != nil {
|
||||
o.log.Warnf("%s save planning artifact failed chat_id=%s err=%v", traceLogPrefix, chatID, err)
|
||||
}
|
||||
}
|
||||
// 将工具输出通过 workspace_delta 发送给前端
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeWorkspaceDelta,
|
||||
Content: obs,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
if err := callback(StreamEvent{
|
||||
Type: StreamEventTypeWorkspaceEnd,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("callback error: %w", err)
|
||||
}
|
||||
workspaceSentThisTurn = true
|
||||
}
|
||||
|
||||
if toolErr != nil {
|
||||
obs = formatToolErrorObservation("TOOL_EXEC_ERROR", toolName, toolErr.Error()) + "\nOUTPUT:\n" + obs
|
||||
o.emitCapabilityGap(chatID, userID, userInput, "tool_call_failed:"+toolName)
|
||||
@@ -1171,6 +1420,118 @@ func defaultIfEmpty(v, fallback string) string {
|
||||
return v
|
||||
}
|
||||
|
||||
func shouldExitPlanningMode(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
markers := []string{"退出规划", "结束规划", "关闭分屏", "结束分屏", "停止规划", "cancel planning", "exit planning"}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isPlanningConfirmation(userInput string) bool {
|
||||
text := strings.ToLower(strings.TrimSpace(userInput))
|
||||
if text == "" {
|
||||
return false
|
||||
}
|
||||
markers := []string{
|
||||
"确认", "同意", "可以创建", "开始创建", "继续创建", "执行下一步", "没问题,继续",
|
||||
"confirm", "approved", "go ahead", "proceed",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsSafePIPlanningSkill(skills []knowledge.Skill) bool {
|
||||
for _, sk := range skills {
|
||||
if isSafePIPlanningSkill(sk) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isSafePIPlanningSkill(sk knowledge.Skill) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(sk.Name))
|
||||
source := strings.ToLower(strings.TrimSpace(sk.Source))
|
||||
if strings.Contains(name, "safe") || strings.Contains(name, "pi 规划") || strings.Contains(name, "pi planning") {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(source, "safe_pi_planning") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (o *Orchestrator) getSafePIPlanningSkills() []knowledge.Skill {
|
||||
all := o.getSkillsSnapshot()
|
||||
out := make([]knowledge.Skill, 0, 1)
|
||||
for _, sk := range all {
|
||||
if isSafePIPlanningSkill(sk) {
|
||||
out = append(out, sk)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isPIPlanningToolName(toolName string) bool {
|
||||
t := strings.ToLower(strings.TrimSpace(toolName))
|
||||
return strings.Contains(t, "safe_pi_planning") || t == "publish_pi_plan"
|
||||
}
|
||||
|
||||
func wrapPIArtifact(markdown string) string {
|
||||
content := strings.TrimSpace(markdown)
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
return "<artifact type=\"safe_pi_planning\" title=\"PI Planning Document\">\n" + content + "\n</artifact>"
|
||||
}
|
||||
|
||||
func (o *Orchestrator) activatePlanningSession(chatID, userID, artifact string, awaitingConfirm bool) {
|
||||
key := pendingFileKey(chatID, userID)
|
||||
o.planningSessionsMu.Lock()
|
||||
defer o.planningSessionsMu.Unlock()
|
||||
state := o.planningSessions[key]
|
||||
state.Active = true
|
||||
if strings.TrimSpace(artifact) != "" {
|
||||
state.LastArtifact = strings.TrimSpace(artifact)
|
||||
}
|
||||
state.AwaitingConfirm = awaitingConfirm
|
||||
state.UpdatedAt = time.Now().UTC()
|
||||
o.planningSessions[key] = state
|
||||
}
|
||||
|
||||
func (o *Orchestrator) clearPlanningSession(chatID, userID string) {
|
||||
key := pendingFileKey(chatID, userID)
|
||||
o.planningSessionsMu.Lock()
|
||||
defer o.planningSessionsMu.Unlock()
|
||||
delete(o.planningSessions, key)
|
||||
}
|
||||
|
||||
func (o *Orchestrator) getPlanningSession(chatID, userID string) (planningSessionState, bool) {
|
||||
key := pendingFileKey(chatID, userID)
|
||||
o.planningSessionsMu.Lock()
|
||||
defer o.planningSessionsMu.Unlock()
|
||||
state, ok := o.planningSessions[key]
|
||||
if !ok {
|
||||
return planningSessionState{}, false
|
||||
}
|
||||
if time.Since(state.UpdatedAt) > planningSessionTTL {
|
||||
delete(o.planningSessions, key)
|
||||
return planningSessionState{}, false
|
||||
}
|
||||
return state, true
|
||||
}
|
||||
|
||||
// formatRelevantSkillsForPrompt 返回与当前用户问题最相关的技能内容。
|
||||
func (o *Orchestrator) formatSelectedSkillsForPrompt(userInput string, selected []knowledge.Skill) string {
|
||||
skills := selected
|
||||
@@ -1331,6 +1692,14 @@ func (o *Orchestrator) emitCapabilityGap(chatID, userID, intent, reason string)
|
||||
return
|
||||
}
|
||||
|
||||
// 暂时关闭基于历史能力缺口的自动技能草稿生成。
|
||||
if !o.autoSkillDraftEnabled {
|
||||
if o.log != nil {
|
||||
o.log.Debugf("auto skill draft generation disabled temporarily")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 提取出高频率缺口并在超出阈值后进行 draft 生成
|
||||
clusters, err := o.store.TopCapabilityGapClusters(20, time.Now().UTC().Add(-o.gapLookbackDuration))
|
||||
if err != nil {
|
||||
@@ -1573,6 +1942,50 @@ func sanitizeUserFacingAnswer(raw string) string {
|
||||
return strings.TrimSpace(strings.Join(cleaned, "\n"))
|
||||
}
|
||||
|
||||
// stripArtifactTags removes <artifact ...>...</artifact> blocks from text,
|
||||
// returning only the surrounding non-artifact content.
|
||||
func stripArtifactTags(s string) string {
|
||||
for {
|
||||
start := strings.Index(s, "<artifact")
|
||||
if start == -1 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(s[start:], "</artifact>")
|
||||
if end == -1 {
|
||||
s = strings.TrimSpace(s[:start])
|
||||
break
|
||||
}
|
||||
end += start + len("</artifact>")
|
||||
before := strings.TrimSpace(s[:start])
|
||||
after := strings.TrimSpace(s[end:])
|
||||
if before != "" && after != "" {
|
||||
s = before + "\n\n" + after
|
||||
} else {
|
||||
s = before + after
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// extractArtifactContent returns the text inside the first <artifact ...>...</artifact> block.
|
||||
// Returns empty string if no artifact tags are found.
|
||||
func extractArtifactContent(s string) string {
|
||||
start := strings.Index(s, "<artifact")
|
||||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
tagEnd := strings.Index(s[start:], ">")
|
||||
if tagEnd == -1 {
|
||||
return ""
|
||||
}
|
||||
contentStart := start + tagEnd + 1
|
||||
end := strings.Index(s, "</artifact>")
|
||||
if end == -1 {
|
||||
return strings.TrimSpace(s[contentStart:])
|
||||
}
|
||||
return strings.TrimSpace(s[contentStart:end])
|
||||
}
|
||||
|
||||
// splitContentIntoSegments splits a string into smaller segments of the specified size (by rune count).
|
||||
func splitContentIntoSegments(content string, segmentSize int) []string {
|
||||
runes := []rune(content)
|
||||
|
||||
Reference in New Issue
Block a user