feat(tasks): add templates findings workflows and batch controls
This commit is contained in:
1 parent
b7e0b93023
commit
d3183dbbd2
65 files changed
+11603
-2198
No files matched your search
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
@@ -23,6 +24,73 @@ func callReadJSON(t *testing.T, tool actool.CoreTool, input string) any {
|
||||
return out
|
||||
}
|
||||
|
||||
func TestGraphOverviewExpandsAssociatedCompanyScope(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
|
||||
companies := d.Companies()
|
||||
companyID, _, err := companies.UpsertCompany(fmt.Sprintf("overview-scope-%d", time.Now().UnixNano()), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = companies.DeleteCompany(companyID) })
|
||||
|
||||
domain := fmt.Sprintf("overview-scope-%d.invalid", companyID)
|
||||
ip := fmt.Sprintf("2001:db8:%x::42", companyID%0xffff)
|
||||
cidr := fmt.Sprintf("2001:db8:%x:1::/64", companyID%0xffff)
|
||||
icp := fmt.Sprintf("京 ICP 备 %d 号", companyID)
|
||||
keyword := fmt.Sprintf("Scope Company %d", companyID)
|
||||
inputs := []db.ScopeInput{
|
||||
{Kind: "domain", Value: domain},
|
||||
{Kind: "ip", Value: ip},
|
||||
{Kind: "cidr", Value: cidr},
|
||||
{Kind: "icp", Value: icp},
|
||||
{Kind: "keyword", Value: keyword},
|
||||
}
|
||||
added, skipped, invalid, scopeErrors := companies.AddScopeInputs(companyID, inputs, "task context test")
|
||||
if added != len(inputs) || skipped != 0 || invalid != 0 || len(scopeErrors) != 0 {
|
||||
t.Fatalf("add company scope: added=%d skipped=%d invalid=%d errors=%v", added, skipped, invalid, scopeErrors)
|
||||
}
|
||||
|
||||
task, err := d.CreateTaskWithOptions("company context", "read configured scope", db.TaskCreateOptions{
|
||||
CompanyIDs: []int64{companyID},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
|
||||
tools := NewToolSet(d.Exploration(task.ExplorationID), "planner")
|
||||
tools.SetTaskID(task.ID)
|
||||
tools.SetAssetStore(d.Assets(), companies)
|
||||
overview := tools.graphOverviewData()
|
||||
coverage, ok := overview["coverage"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("coverage missing: %#v", overview["coverage"])
|
||||
}
|
||||
scopeRows, ok := coverage["scope"].([]map[string]any)
|
||||
if !ok || len(scopeRows) != 1 {
|
||||
t.Fatalf("task scope missing: %#v", coverage["scope"])
|
||||
}
|
||||
companyScope, ok := scopeRows[0]["company_scope"].([]map[string]any)
|
||||
if !ok || len(companyScope) != len(inputs) {
|
||||
t.Fatalf("company scope not expanded: %#v", scopeRows[0])
|
||||
}
|
||||
kinds := make(map[string]string, len(companyScope))
|
||||
for _, rule := range companyScope {
|
||||
kinds[fmt.Sprint(rule["kind"])] = fmt.Sprint(rule["value"])
|
||||
}
|
||||
for _, input := range inputs {
|
||||
if kinds[input.Kind] != input.Value {
|
||||
t.Errorf("scope %s=%q want %q", input.Kind, kinds[input.Kind], input.Value)
|
||||
}
|
||||
}
|
||||
keywords, ok := scopeRows[0]["company_keywords"].([]string)
|
||||
if !ok || len(keywords) != 1 || keywords[0] != keyword {
|
||||
t.Fatalf("company keywords missing: %#v", scopeRows[0]["company_keywords"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlackboardToolsReadDirectSources(t *testing.T) {
|
||||
d := testDB(t)
|
||||
defer d.Close()
|
||||
@@ -125,7 +193,15 @@ func TestBlackboardToolsReadDirectSources(t *testing.T) {
|
||||
if related[0]["source_task_id"] != source.ID || related[0]["inherited"] != true {
|
||||
t.Fatalf("related source provenance: %#v", related[0])
|
||||
}
|
||||
if facts, ok := related[0]["recent_facts"].([]map[string]any); !ok || len(facts) == 0 || facts[0]["id"] != sourceFact {
|
||||
recentFacts, ok := related[0]["recent_facts"].([]map[string]any)
|
||||
foundSourceFact := false
|
||||
for _, fact := range recentFacts {
|
||||
if fact["id"] == sourceFact {
|
||||
foundSourceFact = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok || !foundSourceFact {
|
||||
t.Fatalf("related fact summary: %#v", related[0]["recent_facts"])
|
||||
}
|
||||
if findings, ok := related[0]["recent_findings"].([]map[string]any); !ok || len(findings) == 0 || findings[0]["id"] != sourceFinding {
|
||||
|
||||
+16
-2
@@ -66,6 +66,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
tbuf.WriteString(text)
|
||||
}
|
||||
lastTool := &runTrace{startedAt: time.Now()}
|
||||
var lastUsage *llm.Usage
|
||||
|
||||
var finalText string
|
||||
var rerr error
|
||||
@@ -74,10 +75,10 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
flush()
|
||||
if ctx.Err() != nil { // engine/user cancellation, not a provider failure
|
||||
sum, detail := terminalText(ctx, &harness.Terminal{Reason: reason, Err: ctx.Err()}, lastTool)
|
||||
rec(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail})
|
||||
rec(activityWithUsage(db.Activity{Kind: "result", Summary: firstLine(sum, 400), Detail: detail}, lastUsage))
|
||||
return finalText, reason, ctx.Err()
|
||||
}
|
||||
rec(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()})
|
||||
rec(activityWithUsage(db.Activity{Kind: "result", IsError: true, Summary: "执行出错: " + err.Error(), Detail: err.Error()}, lastUsage))
|
||||
return finalText, reason, err
|
||||
}
|
||||
switch ev.Kind {
|
||||
@@ -111,6 +112,7 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
// buffered final-answer text must stay for the KindResult de-dup.
|
||||
if ev.Usage != nil {
|
||||
u := *ev.Usage
|
||||
lastUsage = &u
|
||||
rec(db.Activity{Kind: "usage",
|
||||
InputTokens: &u.InputTokens, OutputTokens: &u.OutputTokens,
|
||||
CacheReadTokens: &u.CacheReadTokens, CacheWriteTokens: &u.CacheWriteTokens})
|
||||
@@ -145,6 +147,18 @@ func captureRunSession(ctx context.Context, s *agentcore.Session, input string,
|
||||
return finalText, reason, rerr
|
||||
}
|
||||
|
||||
func activityWithUsage(activity db.Activity, usage *llm.Usage) db.Activity {
|
||||
if usage == nil {
|
||||
return activity
|
||||
}
|
||||
u := *usage
|
||||
activity.InputTokens = &u.InputTokens
|
||||
activity.OutputTokens = &u.OutputTokens
|
||||
activity.CacheReadTokens = &u.CacheReadTokens
|
||||
activity.CacheWriteTokens = &u.CacheWriteTokens
|
||||
return activity
|
||||
}
|
||||
|
||||
// blocksText concatenates the text of a tool-result's content blocks.
|
||||
func blocksText(blocks []llm.ContentBlock) string {
|
||||
var b strings.Builder
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"iter"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/norma/agentcore"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
|
||||
type captureUsageProvider struct {
|
||||
stream func(context.Context, func(llm.StreamEvent, error) bool)
|
||||
}
|
||||
|
||||
func (p captureUsageProvider) Stream(ctx context.Context, _ llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
|
||||
return func(yield func(llm.StreamEvent, error) bool) { p.stream(ctx, yield) }
|
||||
}
|
||||
|
||||
func TestCaptureRunPersistsUsageOnProviderFailure(t *testing.T) {
|
||||
wantErr := errors.New("provider failed after reporting usage")
|
||||
provider := captureUsageProvider{stream: func(_ context.Context, yield func(llm.StreamEvent, error) bool) {
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 11, CacheReadTokens: 3}}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageDelta, Usage: llm.Usage{OutputTokens: 7, CacheWriteTokens: 2}}, nil)
|
||||
yield(llm.StreamEvent{}, wantErr)
|
||||
}}
|
||||
|
||||
var activities []db.Activity
|
||||
_, _, err := captureRun(context.Background(), agentcore.Options{Provider: provider, MaxTurns: 1}, "test", func(a db.Activity) {
|
||||
activities = append(activities, a)
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("captureRun error=%v, want %v", err, wantErr)
|
||||
}
|
||||
assertCapturedResultUsage(t, activities, 11, 7, 3, 2)
|
||||
}
|
||||
|
||||
func TestCaptureRunPersistsUsageOnCancellation(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
provider := captureUsageProvider{stream: func(ctx context.Context, yield func(llm.StreamEvent, error) bool) {
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 13, CacheReadTokens: 5}}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial"}, nil)
|
||||
yield(llm.StreamEvent{Type: llm.SEMessageDelta, Usage: llm.Usage{OutputTokens: 9, CacheWriteTokens: 4}}, nil)
|
||||
close(started)
|
||||
<-ctx.Done()
|
||||
yield(llm.StreamEvent{}, ctx.Err())
|
||||
}}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
var activities []db.Activity
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := captureRun(ctx, agentcore.Options{Provider: provider, MaxTurns: 1}, "test", func(a db.Activity) {
|
||||
activities = append(activities, a)
|
||||
})
|
||||
done <- err
|
||||
}()
|
||||
<-started
|
||||
cancel()
|
||||
if err := <-done; !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("captureRun error=%v, want context canceled", err)
|
||||
}
|
||||
assertCapturedResultUsage(t, activities, 13, 9, 5, 4)
|
||||
}
|
||||
|
||||
func assertCapturedResultUsage(t *testing.T, activities []db.Activity, input, output, read, write int) {
|
||||
t.Helper()
|
||||
results := 0
|
||||
for _, activity := range activities {
|
||||
if activity.Kind != "result" {
|
||||
continue
|
||||
}
|
||||
results++
|
||||
if activity.InputTokens == nil || *activity.InputTokens != input ||
|
||||
activity.OutputTokens == nil || *activity.OutputTokens != output ||
|
||||
activity.CacheReadTokens == nil || *activity.CacheReadTokens != read ||
|
||||
activity.CacheWriteTokens == nil || *activity.CacheWriteTokens != write {
|
||||
t.Fatalf("result usage=%+v, want input=%d output=%d read=%d write=%d", activity, input, output, read, write)
|
||||
}
|
||||
}
|
||||
if results != 1 {
|
||||
t.Fatalf("result activity count=%d, activities=%+v", results, activities)
|
||||
}
|
||||
}
|
||||
@@ -392,6 +392,42 @@ func (t *ToolSet) graphOverviewData() map[string]any {
|
||||
e["value"] = r.Net
|
||||
case r.CompanyID != nil:
|
||||
e["company_id"] = *r.CompanyID
|
||||
if t.cs != nil {
|
||||
if company, err := t.cs.GetCompany(*r.CompanyID); err == nil && company != nil {
|
||||
e["company_name"] = company.Name
|
||||
}
|
||||
if rules, err := t.cs.GetScope(*r.CompanyID); err == nil {
|
||||
keywords := make([]string, 0)
|
||||
companyScope := make([]map[string]any, 0, len(rules))
|
||||
for _, rule := range rules {
|
||||
value := rule.Raw
|
||||
if value == "" {
|
||||
switch rule.Kind {
|
||||
case "domain":
|
||||
value = rule.Domain
|
||||
case "ip", "cidr":
|
||||
value = rule.Net
|
||||
default:
|
||||
value = rule.Value
|
||||
}
|
||||
}
|
||||
entry := map[string]any{"kind": rule.Kind, "value": value}
|
||||
if rule.Reason != "" {
|
||||
entry["reason"] = rule.Reason
|
||||
}
|
||||
companyScope = append(companyScope, entry)
|
||||
if rule.Kind == "keyword" && rule.Raw != "" {
|
||||
keywords = append(keywords, rule.Raw)
|
||||
}
|
||||
}
|
||||
if len(companyScope) > 0 {
|
||||
e["company_scope"] = companyScope
|
||||
}
|
||||
if len(keywords) > 0 {
|
||||
e["company_keywords"] = keywords
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
scope = append(scope, e)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user