mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-12 23:27:30 +08:00
833 lines
21 KiB
Go
833 lines
21 KiB
Go
package agent
|
||
|
||
import (
|
||
"bufio"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"math"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/YspCoder/clawgo/pkg/bus"
|
||
"github.com/YspCoder/clawgo/pkg/ekg"
|
||
"github.com/YspCoder/clawgo/pkg/providers"
|
||
"github.com/YspCoder/clawgo/pkg/scheduling"
|
||
)
|
||
|
||
type plannedTask struct {
|
||
Index int
|
||
Total int
|
||
Content string
|
||
ResourceKeys []string
|
||
}
|
||
|
||
type plannedTaskResult struct {
|
||
Index int
|
||
Task plannedTask
|
||
Output string
|
||
Err error
|
||
ErrText string
|
||
}
|
||
|
||
var reLeadingNumber = regexp.MustCompile(`^\d+[\.)、]\s*`)
|
||
|
||
func (al *AgentLoop) processPlannedMessage(ctx context.Context, msg bus.InboundMessage) (string, error) {
|
||
tasks := al.planSessionTasks(ctx, msg)
|
||
if len(tasks) <= 1 {
|
||
return al.processMessage(ctx, msg)
|
||
}
|
||
return al.runPlannedTasks(ctx, msg, tasks)
|
||
}
|
||
|
||
func (al *AgentLoop) planSessionTasks(ctx context.Context, msg bus.InboundMessage) []plannedTask {
|
||
base := strings.TrimSpace(msg.Content)
|
||
if base == "" {
|
||
return nil
|
||
}
|
||
if msg.Metadata != nil {
|
||
if planningDisabled(msg.Metadata["disable_planning"]) {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
}
|
||
if msg.Channel == "system" || msg.Channel == "internal" {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
if msg.Metadata != nil {
|
||
if strings.TrimSpace(msg.Metadata["trigger"]) != "" {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
}
|
||
if strings.HasPrefix(base, "/") {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
|
||
segments := splitPlannedSegments(base)
|
||
if len(segments) <= 1 {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
if refined, ok := al.inferPlannedSegments(ctx, base, segments); ok {
|
||
segments = refined
|
||
}
|
||
|
||
out := make([]plannedTask, 0, len(segments))
|
||
for i, seg := range segments {
|
||
content := strings.TrimSpace(seg)
|
||
if content == "" {
|
||
continue
|
||
}
|
||
out = append(out, plannedTask{
|
||
Index: i + 1,
|
||
Total: 0,
|
||
Content: content,
|
||
ResourceKeys: scheduling.DeriveResourceKeys(content),
|
||
})
|
||
}
|
||
if len(out) == 0 {
|
||
return []plannedTask{{Index: 1, Content: base, ResourceKeys: scheduling.DeriveResourceKeys(base)}}
|
||
}
|
||
if len(out) == 1 {
|
||
out[0].Content = base
|
||
out[0].ResourceKeys = scheduling.DeriveResourceKeys(base)
|
||
}
|
||
for i := range out {
|
||
out[i].Total = len(out)
|
||
}
|
||
return out
|
||
}
|
||
|
||
type plannerDecision struct {
|
||
ShouldSplit bool `json:"should_split"`
|
||
Tasks []string `json:"tasks"`
|
||
}
|
||
|
||
func (al *AgentLoop) inferPlannedSegments(ctx context.Context, content string, candidates []string) ([]string, bool) {
|
||
if al == nil || al.provider == nil {
|
||
return nil, false
|
||
}
|
||
content = strings.TrimSpace(content)
|
||
if content == "" || len(candidates) <= 1 {
|
||
return nil, false
|
||
}
|
||
plannerCtx := ctx
|
||
if plannerCtx == nil {
|
||
plannerCtx = context.Background()
|
||
}
|
||
var cancel context.CancelFunc
|
||
plannerCtx, cancel = context.WithTimeout(plannerCtx, 8*time.Second)
|
||
defer cancel()
|
||
|
||
previewCandidates := candidates
|
||
if len(previewCandidates) > 12 {
|
||
previewCandidates = previewCandidates[:12]
|
||
}
|
||
var candidateList strings.Builder
|
||
for i, item := range previewCandidates {
|
||
candidateList.WriteString(fmt.Sprintf("%d. %s\n", i+1, strings.TrimSpace(item)))
|
||
}
|
||
|
||
resp, err := al.provider.Chat(plannerCtx, []providers.Message{
|
||
{
|
||
Role: "system",
|
||
Content: "Decide whether the request should stay as one task or be split into a small number of high-level task groups. " +
|
||
"Default to no split. Only split when the request contains multiple independent deliverables, roles, or workstreams. " +
|
||
"Never split into fine-grained execution steps. Merge related steps. Return strict JSON only: " +
|
||
`{"should_split":true|false,"tasks":["..."]}` +
|
||
" If should_split is false, tasks must be empty. If true, tasks must contain 2 to 8 concise high-level task groups.",
|
||
},
|
||
{
|
||
Role: "user",
|
||
Content: fmt.Sprintf("Original request:\n%s\n\nRule-based candidate segments (%d shown of %d):\n%s",
|
||
content,
|
||
len(previewCandidates),
|
||
len(candidates),
|
||
strings.TrimSpace(candidateList.String()),
|
||
),
|
||
},
|
||
}, nil, al.provider.GetDefaultModel(), map[string]interface{}{
|
||
"max_tokens": 256,
|
||
})
|
||
if err != nil || resp == nil {
|
||
return nil, false
|
||
}
|
||
decision, ok := parsePlannerDecision(resp.Content)
|
||
if !ok {
|
||
return nil, false
|
||
}
|
||
if !decision.ShouldSplit {
|
||
return []string{content}, true
|
||
}
|
||
tasks := sanitizePlannerTasks(decision.Tasks)
|
||
if len(tasks) < 2 || len(tasks) > 8 {
|
||
return []string{content}, true
|
||
}
|
||
return tasks, true
|
||
}
|
||
|
||
func parsePlannerDecision(raw string) (plannerDecision, bool) {
|
||
raw = strings.TrimSpace(raw)
|
||
if raw == "" {
|
||
return plannerDecision{}, false
|
||
}
|
||
if fenced := extractJSONObject(raw); fenced != "" {
|
||
raw = fenced
|
||
}
|
||
var out plannerDecision
|
||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||
return plannerDecision{}, false
|
||
}
|
||
return out, true
|
||
}
|
||
|
||
func extractJSONObject(raw string) string {
|
||
start := strings.Index(raw, "{")
|
||
end := strings.LastIndex(raw, "}")
|
||
if start < 0 || end <= start {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(raw[start : end+1])
|
||
}
|
||
|
||
func sanitizePlannerTasks(items []string) []string {
|
||
out := make([]string, 0, len(items))
|
||
seen := map[string]struct{}{}
|
||
for _, item := range items {
|
||
t := strings.TrimSpace(reLeadingNumber.ReplaceAllString(strings.TrimSpace(item), ""))
|
||
if t == "" {
|
||
continue
|
||
}
|
||
key := strings.ToLower(t)
|
||
if _, ok := seen[key]; ok {
|
||
continue
|
||
}
|
||
seen[key] = struct{}{}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func planningDisabled(v string) bool {
|
||
switch strings.ToLower(strings.TrimSpace(v)) {
|
||
case "1", "true", "yes", "on":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func splitPlannedSegments(content string) []string {
|
||
lines := strings.Split(content, "\n")
|
||
bullet := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
t := strings.TrimSpace(line)
|
||
if t == "" {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(t, "- ") || strings.HasPrefix(t, "* ") {
|
||
bullet = append(bullet, strings.TrimSpace(t[2:]))
|
||
continue
|
||
}
|
||
if reLeadingNumber.MatchString(t) {
|
||
bullet = append(bullet, strings.TrimSpace(reLeadingNumber.ReplaceAllString(t, "")))
|
||
}
|
||
}
|
||
if len(bullet) >= 2 {
|
||
return bullet
|
||
}
|
||
|
||
// Only split implicit plans on strong separators. Plain newlines are often
|
||
// just formatting inside a single request, and "然后/and then" frequently
|
||
// describes execution order inside one task rather than separate tasks.
|
||
replaced := strings.NewReplacer(";", ";")
|
||
norm := replaced.Replace(content)
|
||
parts := strings.Split(norm, ";")
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
t := strings.TrimSpace(p)
|
||
if t == "" {
|
||
continue
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (al *AgentLoop) runPlannedTasks(ctx context.Context, msg bus.InboundMessage, tasks []plannedTask) (string, error) {
|
||
results := make([]plannedTaskResult, len(tasks))
|
||
enrichedContent := al.enrichPlannedTaskContents(ctx, tasks)
|
||
var wg sync.WaitGroup
|
||
var progressMu sync.Mutex
|
||
completed := 0
|
||
failed := 0
|
||
milestones := plannedProgressMilestones(len(tasks))
|
||
notified := make(map[int]struct{}, len(milestones))
|
||
for i, task := range tasks {
|
||
wg.Add(1)
|
||
go func(index int, t plannedTask) {
|
||
defer wg.Done()
|
||
subMsg := msg
|
||
enriched := enrichedContent[index]
|
||
subMsg.Content = enriched.content
|
||
subMsg.Metadata = cloneMetadata(msg.Metadata)
|
||
if subMsg.Metadata == nil {
|
||
subMsg.Metadata = map[string]string{}
|
||
}
|
||
subMsg.Metadata["resource_keys"] = strings.Join(t.ResourceKeys, ",")
|
||
subMsg.Metadata["planned_task_index"] = fmt.Sprintf("%d", t.Index)
|
||
subMsg.Metadata["planned_task_total"] = fmt.Sprintf("%d", len(tasks))
|
||
if enriched.extraChars > 0 {
|
||
subMsg.Metadata["context_extra_chars"] = fmt.Sprintf("%d", enriched.extraChars)
|
||
}
|
||
if enriched.ekgChars > 0 {
|
||
subMsg.Metadata["context_ekg_chars"] = fmt.Sprintf("%d", enriched.ekgChars)
|
||
}
|
||
if enriched.memoryChars > 0 {
|
||
subMsg.Metadata["context_memory_chars"] = fmt.Sprintf("%d", enriched.memoryChars)
|
||
}
|
||
out, err := al.processMessage(ctx, subMsg)
|
||
res := plannedTaskResult{Index: index, Task: t, Output: strings.TrimSpace(out), Err: err}
|
||
if err != nil {
|
||
res.ErrText = err.Error()
|
||
}
|
||
results[index] = res
|
||
progressMu.Lock()
|
||
completed++
|
||
if res.ErrText != "" && !isPlannedTaskCancellation(ctx, res) {
|
||
failed++
|
||
}
|
||
snapshotCompleted := completed
|
||
snapshotFailed := failed
|
||
shouldNotify := shouldPublishPlannedTaskProgress(ctx, len(tasks), snapshotCompleted, res, milestones, notified)
|
||
if shouldNotify && res.ErrText == "" {
|
||
notified[snapshotCompleted] = struct{}{}
|
||
}
|
||
progressMu.Unlock()
|
||
|
||
if shouldNotify {
|
||
al.publishPlannedTaskProgress(msg, len(tasks), snapshotCompleted, snapshotFailed, res)
|
||
}
|
||
}(i, task)
|
||
}
|
||
wg.Wait()
|
||
if err := ctx.Err(); err != nil {
|
||
return "", err
|
||
}
|
||
var b strings.Builder
|
||
b.WriteString(fmt.Sprintf("已自动拆解为 %d 个任务并执行:\n\n", len(results)))
|
||
for _, r := range results {
|
||
b.WriteString(fmt.Sprintf("[%d] %s\n", r.Task.Index, r.Task.Content))
|
||
if r.ErrText != "" {
|
||
b.WriteString("执行失败:" + r.ErrText + "\n\n")
|
||
continue
|
||
}
|
||
if r.Output == "" {
|
||
b.WriteString("(无输出)\n\n")
|
||
continue
|
||
}
|
||
b.WriteString(r.Output + "\n\n")
|
||
}
|
||
return strings.TrimSpace(b.String()), nil
|
||
}
|
||
|
||
func plannedProgressMilestones(total int) []int {
|
||
if total <= 3 {
|
||
return nil
|
||
}
|
||
points := []float64{0.33, 0.66}
|
||
out := make([]int, 0, len(points))
|
||
seen := map[int]struct{}{}
|
||
for _, p := range points {
|
||
step := int(math.Round(float64(total) * p))
|
||
if step <= 0 || step >= total {
|
||
continue
|
||
}
|
||
if _, ok := seen[step]; ok {
|
||
continue
|
||
}
|
||
seen[step] = struct{}{}
|
||
out = append(out, step)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func shouldPublishPlannedTaskProgress(ctx context.Context, total, completed int, res plannedTaskResult, milestones []int, notified map[int]struct{}) bool {
|
||
if total <= 1 {
|
||
return false
|
||
}
|
||
if isPlannedTaskCancellation(ctx, res) {
|
||
return false
|
||
}
|
||
if strings.TrimSpace(res.ErrText) != "" {
|
||
return true
|
||
}
|
||
if completed >= total {
|
||
return false
|
||
}
|
||
for _, step := range milestones {
|
||
if completed != step {
|
||
continue
|
||
}
|
||
if _, ok := notified[step]; ok {
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isPlannedTaskCancellation(ctx context.Context, res plannedTaskResult) bool {
|
||
if res.Err != nil && errors.Is(res.Err, context.Canceled) {
|
||
return true
|
||
}
|
||
if strings.EqualFold(strings.TrimSpace(res.ErrText), context.Canceled.Error()) {
|
||
return true
|
||
}
|
||
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
||
}
|
||
|
||
func (al *AgentLoop) publishPlannedTaskProgress(msg bus.InboundMessage, total, completed, failed int, res plannedTaskResult) {
|
||
if al == nil || al.bus == nil || total <= 1 {
|
||
return
|
||
}
|
||
if msg.Channel == "system" || msg.Channel == "internal" {
|
||
return
|
||
}
|
||
idx := res.Task.Index
|
||
if idx <= 0 {
|
||
idx = res.Index + 1
|
||
}
|
||
status := "完成"
|
||
body := strings.TrimSpace(res.Output)
|
||
if res.ErrText != "" {
|
||
status = "失败"
|
||
body = strings.TrimSpace(res.ErrText)
|
||
}
|
||
if body == "" {
|
||
body = "(无输出)"
|
||
}
|
||
body = summarizePlannedTaskProgressBody(body, 6, 320)
|
||
content := fmt.Sprintf("阶段进度 %d/%d(失败 %d)\n最近任务:%d 已%s\n%s", completed, total, failed, idx, status, body)
|
||
al.bus.PublishOutbound(bus.OutboundMessage{
|
||
Channel: msg.Channel,
|
||
ChatID: msg.ChatID,
|
||
Content: content,
|
||
})
|
||
}
|
||
|
||
func summarizePlannedTaskProgressBody(body string, maxLines, maxChars int) string {
|
||
body = strings.ReplaceAll(body, "\r\n", "\n")
|
||
body = strings.TrimSpace(body)
|
||
if body == "" {
|
||
return "(无输出)"
|
||
}
|
||
lines := strings.Split(body, "\n")
|
||
out := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
out = append(out, line)
|
||
if maxLines > 0 && len(out) >= maxLines {
|
||
break
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return "(无输出)"
|
||
}
|
||
joined := strings.Join(out, "\n")
|
||
if maxChars > 0 && len(joined) > maxChars {
|
||
joined = truncate(joined, maxChars)
|
||
}
|
||
if len(lines) > len(out) && !strings.HasSuffix(joined, "...") {
|
||
joined += "\n..."
|
||
}
|
||
return joined
|
||
}
|
||
|
||
type taskPromptHints struct {
|
||
ekg string
|
||
memory string
|
||
}
|
||
|
||
type plannedTaskPrompt struct {
|
||
content string
|
||
extraChars int
|
||
ekgChars int
|
||
memoryChars int
|
||
}
|
||
|
||
func (al *AgentLoop) enrichPlannedTaskContents(ctx context.Context, tasks []plannedTask) []plannedTaskPrompt {
|
||
out := make([]plannedTaskPrompt, len(tasks))
|
||
seen := make(map[string]struct{}, len(tasks)*2)
|
||
remainingBudget := plannedTaskContextBudget(len(tasks))
|
||
for i, task := range tasks {
|
||
hints := al.collectTaskPromptHints(ctx, task)
|
||
if len(tasks) > 1 {
|
||
dedupeTaskPromptHints(&hints, seen)
|
||
applyPromptBudget(&hints, &remainingBudget)
|
||
}
|
||
prompt := buildPlannedTaskPrompt(task.Content, hints)
|
||
if len(tasks) > 1 && prompt.extraChars > 0 {
|
||
remainingBudget -= prompt.extraChars
|
||
if remainingBudget < 0 {
|
||
remainingBudget = 0
|
||
}
|
||
}
|
||
out[i] = prompt
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (al *AgentLoop) enrichTaskContentWithMemoryAndEKG(ctx context.Context, task plannedTask) string {
|
||
return buildPlannedTaskPrompt(task.Content, al.collectTaskPromptHints(ctx, task)).content
|
||
}
|
||
|
||
func (al *AgentLoop) collectTaskPromptHints(ctx context.Context, task plannedTask) taskPromptHints {
|
||
hints := taskPromptHints{}
|
||
if risk := al.ekgHintForTask(task); risk != "" {
|
||
hints.ekg = risk
|
||
hints.memory = al.memoryHintForTask(ctx, task, true)
|
||
return hints
|
||
}
|
||
hints.memory = al.memoryHintForTask(ctx, task, false)
|
||
return hints
|
||
}
|
||
|
||
func (al *AgentLoop) memoryHintForTask(ctx context.Context, task plannedTask, hasEKG bool) string {
|
||
if al == nil || al.tools == nil {
|
||
return ""
|
||
}
|
||
maxResults := 1
|
||
maxChars := 360
|
||
if task.Total > 1 {
|
||
maxChars = 220
|
||
}
|
||
if hasEKG {
|
||
maxChars = 160
|
||
}
|
||
args := map[string]interface{}{
|
||
"query": task.Content,
|
||
"maxResults": maxResults,
|
||
}
|
||
if ns := memoryNamespaceFromContext(ctx); ns != "main" {
|
||
args["namespace"] = ns
|
||
}
|
||
res, err := al.tools.Execute(ctx, "memory_search", args)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
txt := strings.TrimSpace(res)
|
||
if txt == "" || strings.HasPrefix(strings.ToLower(txt), "no memory found") {
|
||
return ""
|
||
}
|
||
return compactMemoryHint(txt, maxChars)
|
||
}
|
||
|
||
func (al *AgentLoop) ekgHintForTask(task plannedTask) string {
|
||
if al == nil || al.ekg == nil || strings.TrimSpace(al.workspace) == "" {
|
||
return ""
|
||
}
|
||
evt, ok := al.findRecentRelatedErrorEvent(task.Content)
|
||
if !ok {
|
||
return ""
|
||
}
|
||
errSig := ekg.NormalizeErrorSignature(evt.Log)
|
||
if errSig == "" {
|
||
return ""
|
||
}
|
||
advice := al.ekg.GetAdvice(ekg.SignalContext{
|
||
TaskID: evt.TaskID,
|
||
ErrSig: errSig,
|
||
Source: evt.Source,
|
||
Channel: evt.Channel,
|
||
})
|
||
if !advice.ShouldEscalate {
|
||
return ""
|
||
}
|
||
parts := []string{
|
||
fmt.Sprintf("repeat_errsig=%s", truncate(errSig, 72)),
|
||
fmt.Sprintf("backoff=%ds", advice.RetryBackoffSec),
|
||
}
|
||
if evt.Preview != "" {
|
||
parts = append(parts, "related_task="+truncate(strings.TrimSpace(evt.Preview), 96))
|
||
}
|
||
if len(advice.Reason) > 0 {
|
||
parts = append(parts, "reason="+truncate(strings.Join(advice.Reason, "+"), 64))
|
||
}
|
||
return strings.Join(parts, "; ")
|
||
}
|
||
|
||
type taskAuditErrorEvent struct {
|
||
TaskID string
|
||
Source string
|
||
Channel string
|
||
Log string
|
||
Preview string
|
||
MatchScore int
|
||
MatchRatio float64
|
||
}
|
||
|
||
func (al *AgentLoop) findRecentRelatedErrorEvent(taskContent string) (taskAuditErrorEvent, bool) {
|
||
path := filepath.Join(strings.TrimSpace(al.workspace), "memory", "task-audit.jsonl")
|
||
f, err := os.Open(path)
|
||
if err != nil {
|
||
return taskAuditErrorEvent{}, false
|
||
}
|
||
defer f.Close()
|
||
|
||
kw := tokenizeTaskText(taskContent)
|
||
if len(kw) == 0 {
|
||
return taskAuditErrorEvent{}, false
|
||
}
|
||
var best taskAuditErrorEvent
|
||
bestScore := 0
|
||
bestRatio := 0.0
|
||
|
||
s := bufio.NewScanner(f)
|
||
for s.Scan() {
|
||
line := strings.TrimSpace(s.Text())
|
||
if line == "" {
|
||
continue
|
||
}
|
||
var row map[string]interface{}
|
||
if json.Unmarshal([]byte(line), &row) != nil {
|
||
continue
|
||
}
|
||
if strings.ToLower(strings.TrimSpace(fmt.Sprintf("%v", row["status"]))) != "error" {
|
||
continue
|
||
}
|
||
logText := strings.TrimSpace(fmt.Sprintf("%v", row["log"]))
|
||
if logText == "" {
|
||
continue
|
||
}
|
||
preview := strings.TrimSpace(fmt.Sprintf("%v", row["input_preview"]))
|
||
previewKW := tokenizeTaskText(preview)
|
||
score := overlapScore(kw, previewKW)
|
||
ratio := overlapRatio(kw, previewKW, score)
|
||
if !isStrongTaskMatch(score, ratio) {
|
||
continue
|
||
}
|
||
if score < bestScore || (score == bestScore && ratio < bestRatio) {
|
||
continue
|
||
}
|
||
bestScore = score
|
||
bestRatio = ratio
|
||
best = taskAuditErrorEvent{
|
||
TaskID: strings.TrimSpace(fmt.Sprintf("%v", row["task_id"])),
|
||
Source: strings.TrimSpace(fmt.Sprintf("%v", row["source"])),
|
||
Channel: strings.TrimSpace(fmt.Sprintf("%v", row["channel"])),
|
||
Log: logText,
|
||
Preview: preview,
|
||
MatchScore: score,
|
||
MatchRatio: ratio,
|
||
}
|
||
}
|
||
if bestScore == 0 || strings.TrimSpace(best.TaskID) == "" {
|
||
return taskAuditErrorEvent{}, false
|
||
}
|
||
return best, true
|
||
}
|
||
|
||
func tokenizeTaskText(s string) []string {
|
||
normalized := strings.NewReplacer("\n", " ", "\t", " ", ",", " ", ",", " ", ".", " ", "。", " ", ":", " ", ":", " ", ";", " ", ";", " ").Replace(strings.ToLower(strings.TrimSpace(s)))
|
||
parts := strings.Fields(normalized)
|
||
out := make([]string, 0, len(parts))
|
||
for _, p := range parts {
|
||
if len(p) < 3 {
|
||
continue
|
||
}
|
||
out = append(out, p)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func overlapScore(a, b []string) int {
|
||
if len(a) == 0 || len(b) == 0 {
|
||
return 0
|
||
}
|
||
set := make(map[string]struct{}, len(a))
|
||
for _, k := range a {
|
||
set[k] = struct{}{}
|
||
}
|
||
score := 0
|
||
for _, k := range b {
|
||
if _, ok := set[k]; ok {
|
||
score++
|
||
}
|
||
}
|
||
return score
|
||
}
|
||
|
||
func overlapRatio(a, b []string, score int) float64 {
|
||
if score <= 0 || len(a) == 0 || len(b) == 0 {
|
||
return 0
|
||
}
|
||
shorter := len(a)
|
||
if len(b) < shorter {
|
||
shorter = len(b)
|
||
}
|
||
if shorter <= 0 {
|
||
return 0
|
||
}
|
||
return float64(score) / float64(shorter)
|
||
}
|
||
|
||
func isStrongTaskMatch(score int, ratio float64) bool {
|
||
if score >= 4 {
|
||
return true
|
||
}
|
||
if score < 2 {
|
||
return false
|
||
}
|
||
return ratio >= 0.35
|
||
}
|
||
|
||
func compactMemoryHint(raw string, maxChars int) string {
|
||
raw = strings.ReplaceAll(raw, "\r\n", "\n")
|
||
lines := strings.Split(raw, "\n")
|
||
parts := make([]string, 0, len(lines))
|
||
for _, line := range lines {
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
lower := strings.ToLower(line)
|
||
if strings.HasPrefix(lower, "found ") {
|
||
continue
|
||
}
|
||
if strings.HasPrefix(lower, "source: ") {
|
||
line = strings.TrimSpace(strings.TrimPrefix(line, "Source: "))
|
||
if line != "" {
|
||
parts = append(parts, "src="+line)
|
||
}
|
||
continue
|
||
}
|
||
parts = append(parts, line)
|
||
if len(parts) >= 2 {
|
||
break
|
||
}
|
||
}
|
||
if len(parts) == 0 {
|
||
return ""
|
||
}
|
||
return truncate(strings.Join(parts, " | "), maxChars)
|
||
}
|
||
|
||
func renderTaskPromptWithHints(taskContent string, hints taskPromptHints) string {
|
||
return buildPlannedTaskPrompt(taskContent, hints).content
|
||
}
|
||
|
||
func buildPlannedTaskPrompt(taskContent string, hints taskPromptHints) plannedTaskPrompt {
|
||
base := strings.TrimSpace(taskContent)
|
||
if base == "" {
|
||
return plannedTaskPrompt{}
|
||
}
|
||
if hints.ekg == "" && hints.memory == "" {
|
||
return plannedTaskPrompt{content: base}
|
||
}
|
||
lines := make([]string, 0, 4)
|
||
lines = append(lines, "Task Context:")
|
||
if hints.ekg != "" {
|
||
lines = append(lines, "EKG: "+hints.ekg)
|
||
}
|
||
if hints.memory != "" {
|
||
lines = append(lines, "Memory: "+hints.memory)
|
||
}
|
||
lines = append(lines, "Task:", base)
|
||
content := strings.Join(lines, "\n")
|
||
return plannedTaskPrompt{
|
||
content: content,
|
||
extraChars: maxInt(len(content)-len(base), 0),
|
||
ekgChars: len(hints.ekg),
|
||
memoryChars: len(hints.memory),
|
||
}
|
||
}
|
||
|
||
func plannedTaskContextBudget(taskCount int) int {
|
||
if taskCount <= 1 {
|
||
return 1 << 30
|
||
}
|
||
return 320
|
||
}
|
||
|
||
func applyPromptBudget(hints *taskPromptHints, remaining *int) {
|
||
if hints == nil || remaining == nil {
|
||
return
|
||
}
|
||
if *remaining <= 0 {
|
||
hints.ekg = ""
|
||
hints.memory = ""
|
||
return
|
||
}
|
||
needed := estimateHintChars(*hints)
|
||
if needed <= *remaining {
|
||
return
|
||
}
|
||
hints.memory = ""
|
||
needed = estimateHintChars(*hints)
|
||
if needed <= *remaining {
|
||
return
|
||
}
|
||
hints.ekg = ""
|
||
}
|
||
|
||
func estimateHintChars(hints taskPromptHints) int {
|
||
total := 0
|
||
if hints.ekg != "" {
|
||
total += len("Task Context:\nEKG: \nTask:\n") + len(hints.ekg)
|
||
}
|
||
if hints.memory != "" {
|
||
total += len("Memory: \n") + len(hints.memory)
|
||
if hints.ekg == "" {
|
||
total += len("Task Context:\nTask:\n")
|
||
}
|
||
}
|
||
return total
|
||
}
|
||
|
||
func dedupeTaskPromptHints(hints *taskPromptHints, seen map[string]struct{}) {
|
||
if hints == nil || seen == nil {
|
||
return
|
||
}
|
||
if hints.ekg != "" {
|
||
key := "ekg:" + hints.ekg
|
||
if _, ok := seen[key]; ok {
|
||
hints.ekg = ""
|
||
} else {
|
||
seen[key] = struct{}{}
|
||
}
|
||
}
|
||
if hints.memory != "" {
|
||
key := "memory:" + hints.memory
|
||
if _, ok := seen[key]; ok {
|
||
hints.memory = ""
|
||
} else {
|
||
seen[key] = struct{}{}
|
||
}
|
||
}
|
||
}
|
||
|
||
func maxInt(a, b int) int {
|
||
if a > b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
func cloneMetadata(m map[string]string) map[string]string {
|
||
if len(m) == 0 {
|
||
return nil
|
||
}
|
||
out := make(map[string]string, len(m))
|
||
for k, v := range m {
|
||
out[k] = v
|
||
}
|
||
return out
|
||
}
|