Bind workflow judge inputs to trusted step attempts

Why: Compiled workflow critics could resend a prior prompt output after document duty or publication changed across separate model guards.

Evidence: RED cross-guard revocation and retry tests; go test ./... -count=1 and targeted go test -race passed locally before commit.

Impact: Bind materialized prompt bytes to the engine step attempt and responded model receipt; recheck current project sources and epochs before judging, failing closed for unproven or ambiguous output.

Refs: RDM two-trees stage5 W6; stage6 X1 public extension request; W6 independent review P1.
This commit is contained in:
weichen
2026-10-14 04:55:14 +00:00
parent 5f62b3d226
commit 7a00b20cd4
17 changed files with 852 additions and 25 deletions
+2 -2
View File
@@ -287,7 +287,7 @@ var copyOrder = []string{
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings",
"memory_entries", "audit_events", "context_manifests", "pending_confirms", "llm_usage_daily",
"memory_entries", "audit_events", "context_manifests", "workflow_step_sources", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
@@ -784,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error {
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit", "work_task_bindings", "context_manifests"}
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit", "work_task_bindings", "context_manifests", "workflow_step_sources"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
+16
View File
@@ -0,0 +1,16 @@
package projectcontext
import "context"
type workflowStepAttemptKey struct{}
// WithWorkflowStepAttempt is used by the trusted squad executor adapter. The
// value comes from the engine's persisted step attempt, never from model text.
func WithWorkflowStepAttempt(ctx context.Context, attemptID string) context.Context {
return context.WithValue(ctx, workflowStepAttemptKey{}, attemptID)
}
func WorkflowStepAttempt(ctx context.Context) string {
value, _ := ctx.Value(workflowStepAttemptKey{}).(string)
return value
}
+107
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"strings"
"time"
agentllm "agent-native/runtime/llm"
@@ -28,6 +29,30 @@ func (c *runCtx) ProjectContextEnabled() bool {
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil
}
// ValidateProjectCrossGuard links separate model streams in one persisted run.
// A new guard cannot treat a workflow/user prompt as fresh when earlier model
// work under the same run used another authorization or publication revision.
func (c *runCtx) ValidateProjectCrossGuard(ctx context.Context, current extension.ProjectContextBundle) error {
if !c.ProjectContextEnabled() {
return nil
}
if err := ctx.Err(); err != nil {
return err
}
prior, err := c.mgr.st.ListContextManifests(string(c.run.Owner), string(c.run.ID))
if err != nil {
return err
}
for _, m := range prior {
if m.ProjectID != current.ProjectID || m.AuthorizationEpoch == "" || m.PublicationEpoch == "" ||
current.AuthorizationEpoch == "" || current.PublicationEpoch == "" ||
m.AuthorizationEpoch != current.AuthorizationEpoch || m.PublicationEpoch != current.PublicationEpoch || m.Watermark != current.Watermark {
return errors.New("prior project model work used a different authorization or publication revision")
}
}
return nil
}
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
c.projectContextMu.Lock()
defer c.projectContextMu.Unlock()
@@ -224,3 +249,85 @@ func (c *runCtx) SaveContextManifest(ctx context.Context, m extension.ProjectCon
}
return c.mgr.st.AppendContextManifest(string(run.Owner), string(run.ID), m)
}
// InvalidateProjectStepOutput prevents a retry from inheriting the prior
// attempt's binding, even when the new attempt produces identical bytes.
func (c *runCtx) InvalidateProjectStepOutput(ctx context.Context, stepID string) error {
if err := ctx.Err(); err != nil {
return err
}
return c.mgr.st.UnbindWorkflowStepSource(string(c.run.Owner), string(c.run.ID), stepID)
}
// BindProjectStepOutput stores exact bytes and their responded model attempt
// after a prompt step has materialized. The step start timestamp fences out
// receipts from earlier steps; the workflow sink rejects concurrent ambiguity.
func (c *runCtx) BindProjectStepOutput(ctx context.Context, stepID, stepAttemptID, kind string, startedAt time.Time, output []byte) error {
if kind != "prompt" || !c.ProjectContextEnabled() {
return errors.New("project prompt step provenance unavailable")
}
bundle, active, err := c.ProjectContext(ctx, "", 12000)
if err != nil || !active {
return errors.New("project step current scope unavailable")
}
bound, err := c.mgr.st.BindWorkflowStepSource(string(c.run.Owner), string(c.run.ID), stepID, stepAttemptID, kind, startedAt, output)
if err != nil {
return err
}
if bound.ProjectID != string(bundle.ProjectID) || bound.AuthorizationEpoch != bundle.AuthorizationEpoch ||
bound.PublicationEpoch != bundle.PublicationEpoch || bound.Watermark != bundle.Watermark {
return errors.New("project step source changed before binding")
}
return nil
}
// VerifyProjectStepOutput is the last gate before a workflow critic receives
// output.md. It checks the persisted run+step association, current file bytes,
// the trusted responded request, and live domain authorization epochs.
func (c *runCtx) VerifyProjectStepOutput(ctx context.Context, stepID, stepAttemptID, kind string, output []byte) error {
if kind != "prompt" || !c.ProjectContextEnabled() {
return errors.New("project workflow output has no verifiable prompt source")
}
bound, err := c.mgr.st.WorkflowStepSource(string(c.run.Owner), string(c.run.ID), stepID)
if err != nil {
return errors.New("project workflow output has no trusted step binding")
}
if bound.ProjectID != string(c.run.ProjectID) || bound.StepAttemptID != stepAttemptID || bound.OutputSHA256 != sha256.Sum256(output) {
return errors.New("project workflow output bytes or project scope changed")
}
receipt, err := c.mgr.st.RespondedContextManifest(string(c.run.Owner), string(c.run.ID), bound.AttemptID)
if err != nil || receipt.ProjectID != c.run.ProjectID || receipt.WorkflowStepAttemptID != stepAttemptID || receipt.AuthorizationEpoch != bound.AuthorizationEpoch ||
receipt.PublicationEpoch != bound.PublicationEpoch || receipt.Watermark != bound.Watermark || receipt.Version != bound.Version {
return errors.New("project workflow output lost its trusted model source")
}
current, active, err := c.ProjectContext(ctx, "", 12000)
if err != nil || !active || current.ProjectID != receipt.ProjectID ||
current.AuthorizationEpoch != receipt.AuthorizationEpoch || current.PublicationEpoch != receipt.PublicationEpoch || current.Watermark != receipt.Watermark {
return errors.New("project workflow output authorization or publication changed")
}
if err := projectcontext.ValidateCurrent(receipt, current); err != nil {
return fmt.Errorf("project workflow output source changed: %w", err)
}
for _, used := range receipt.ReadEvidence {
if c.mgr.projectEvidenceReader == nil {
return errors.New("project workflow evidence reader unavailable")
}
matched := false
for _, ref := range current.Evidence {
if ref.ID != used.ReferenceID || ref.SourceID != used.SourceID || ref.Revision != used.Revision || ref.Hash != used.SourceHash {
continue
}
scope := c.contextRequest()
scope.BudgetChars = 8000
now, err := c.mgr.projectEvidenceReader(ctx, extension.ProjectEvidenceRequest{Scope: scope, Reference: ref})
if err == nil && now.Reference == ref && fmt.Sprintf("%x", sha256.Sum256([]byte(now.Text))) == used.InputSHA256 {
matched = true
}
break
}
if !matched {
return errors.New("project workflow evidence permission or content changed")
}
}
return nil
}
+88
View File
@@ -4,6 +4,7 @@ import (
"context"
"sync"
"testing"
"time"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/projectcontext"
@@ -70,6 +71,93 @@ func TestProjectContextResumeReloadsCurrentVersionAndAuthorization(t *testing.T)
}
}
func TestProjectJudgeChecksPersistedSourceEpochAcrossGuards(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
conv := recoveryConversation(t, st)
run := recoveryRun(t, st, conv, core.RunPending)
m.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return true, nil })
epoch := "duty-1"
publication := "pub-1"
items := []extension.ProjectContextItem{{SourceID: "restricted-doc", Revision: "r1", Hash: "h1", Text: "OLD_RESTRICTED_BODY", Required: true}}
m.SetProjectContextResolver(func(_ context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: req.ProjectID, AuthorizationEpoch: epoch, PublicationEpoch: publication, Watermark: "commit-1", Items: items}, nil
})
rc := &runCtx{mgr: m, run: run, ctx: context.Background()}
started := time.Now().Add(-time.Second)
bundle, _, err := rc.ProjectContext(context.Background(), "write output", 900)
if err != nil {
t.Fatal(err)
}
_, manifest, err := projectcontext.Assemble(bundle, 900)
if err != nil {
t.Fatal(err)
}
manifest.InputSHA256, manifest.AttemptID, manifest.Delivery = "input-hash", "step-attempt", "attempted"
manifest.WorkflowStepAttemptID = "engine-run/draft/1"
if err := rc.SaveContextManifest(context.Background(), manifest); err != nil {
t.Fatal(err)
}
manifest.Delivery = "responded"
if err := rc.SaveContextManifest(context.Background(), manifest); err != nil {
t.Fatal(err)
}
if err := rc.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", started, []byte("OLD_RESTRICTED_BODY")); err != nil {
t.Fatal(err)
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err != nil {
t.Fatalf("same epoch output rejected: %v", err)
}
if err := rc.ValidateProjectCrossGuard(context.Background(), bundle); err != nil {
t.Fatalf("same epoch cross-guard request rejected: %v", err)
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("replaced bytes")); err == nil {
t.Fatal("replaced output accepted")
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/2", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("second execution reused first attempt's identical output")
}
if err := rc.VerifyProjectStepOutput(context.Background(), "other", "engine-run/other/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("another step reused output binding")
}
items = nil // provider withdraws the document while retaining project membership and epoch
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("withdrawn document accepted with unchanged authorization epoch")
}
items = []extension.ProjectContextItem{{SourceID: "restricted-doc", Revision: "r1", Hash: "h1", Text: "OLD_RESTRICTED_BODY", Required: true}}
epoch = "duty-2"
current, _, err := rc.ProjectContext(context.Background(), "judge", 900)
if err != nil {
t.Fatal(err)
}
if err := rc.ValidateProjectCrossGuard(context.Background(), current); err == nil {
t.Fatal("cross-guard request ignored persisted epoch change")
}
if err := rc.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("revoked source epoch accepted for workflow judge")
}
reloaded, err := st.GetRunByID(string(run.ID))
if err != nil {
t.Fatal(err)
}
resumed := &runCtx{mgr: m, run: reloaded, ctx: context.Background()}
if err := resumed.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("resumed judge ignored revocation")
}
epoch, publication = "duty-1", "pub-2"
if err := resumed.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("OLD_RESTRICTED_BODY")); err == nil {
t.Fatal("resumed judge ignored publication epoch change")
}
other := recoveryRun(t, st, conv, core.RunPending)
noReceipt := &runCtx{mgr: m, run: other, ctx: context.Background()}
if err := noReceipt.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", started, []byte("tool body")); err == nil {
t.Fatal("tool-only output without model provenance accepted")
}
if err := noReceipt.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("tool body")); err == nil {
t.Fatal("another run reused output binding")
}
}
func TestContextManifestPersistsByOwnerAndRun(t *testing.T) {
st := openRecoveryStore(t)
m := newRecoveryManager(t, st)
+8
View File
@@ -67,6 +67,13 @@ func GuardProjectStream(rc any, goal string, stream agentllm.StreamFunc) agentll
if !active {
return stream(ctx, model, messages, tools, opts)
}
if checker, ok := rc.(interface {
ValidateProjectCrossGuard(context.Context, extension.ProjectContextBundle) error
}); ok {
if err := checker.ValidateProjectCrossGuard(ctx, bundle); err != nil {
return fail(err)
}
}
body, manifest, err := projectcontext.Assemble(bundle, budget)
if err != nil {
return fail(err)
@@ -134,6 +141,7 @@ func GuardProjectStream(rc any, goal string, stream agentllm.StreamFunc) agentll
return fail(err)
}
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
manifest.WorkflowStepAttemptID = projectcontext.WorkflowStepAttempt(ctx)
var save func(context.Context, extension.ProjectContextManifest) error
if sink, ok := rc.(interface {
SaveContextManifest(context.Context, extension.ProjectContextManifest) error
+32
View File
@@ -64,6 +64,8 @@ func (w CompiledWorkflowRunner) runLaunch(rc RunCtx, launch workflow.Launch, opt
}
if deps.JudgeStream == nil {
deps.JudgeStream = judgeStreamFor(rc)
} else {
deps.JudgeStream = GuardProjectStream(rc, "", deps.JudgeStream)
}
return wfcompile.RunCompiled(workflowHost{rc: rc}, deps, launch, workflow.Vars{
Input: currentUserText(rc),
@@ -169,3 +171,33 @@ func (h workflowHost) ConversationSandbox() string {
}
return ""
}
func (h workflowHost) InvalidateProjectStepOutput(ctx context.Context, stepID string) error {
invalidate, ok := h.rc.(interface {
InvalidateProjectStepOutput(context.Context, string) error
})
if !ok {
return fmt.Errorf("project workflow step invalidation unavailable")
}
return invalidate.InvalidateProjectStepOutput(ctx, stepID)
}
func (h workflowHost) BindProjectStepOutput(ctx context.Context, stepID, stepAttemptID, kind string, startedAt time.Time, output []byte) error {
binder, ok := h.rc.(interface {
BindProjectStepOutput(context.Context, string, string, string, time.Time, []byte) error
})
if !ok {
return fmt.Errorf("project workflow step binding unavailable")
}
return binder.BindProjectStepOutput(ctx, stepID, stepAttemptID, kind, startedAt, output)
}
func (h workflowHost) VerifyProjectStepOutput(ctx context.Context, stepID, stepAttemptID, kind string, output []byte) error {
verifier, ok := h.rc.(interface {
VerifyProjectStepOutput(context.Context, string, string, string, []byte) error
})
if !ok {
return fmt.Errorf("project workflow step verification unavailable")
}
return verifier.VerifyProjectStepOutput(ctx, stepID, stepAttemptID, kind, output)
}
+64 -1
View File
@@ -11,6 +11,7 @@ import (
"testing"
agentllm "agent-native/runtime/llm"
"agent-system-backend/internal/projectcontext"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
core "agent-system-core"
@@ -296,18 +297,31 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
type projectJudgeCtx struct {
*wfFake
manifests []extension.ProjectContextManifest
authEpoch string
}
func (c *projectJudgeCtx) Run() core.Run {
return core.Run{ID: "run-judge", Owner: "alice", ProjectID: "project-1"}
}
func (c *projectJudgeCtx) ProjectContext(context.Context, string, int) (extension.ProjectContextBundle, bool, error) {
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "project-1", AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Items: []extension.ProjectContextItem{{SourceID: "judge-source", Text: "current project rule", Required: true}}}, true, nil
epoch := c.authEpoch
if epoch == "" {
epoch = "duty-1"
}
return extension.ProjectContextBundle{Status: extension.ProjectContextReady, ProjectID: "project-1", AuthorizationEpoch: epoch, PublicationEpoch: "pub-1", Items: []extension.ProjectContextItem{{SourceID: "judge-source", Text: "current project rule", Required: true}}}, true, nil
}
func (c *projectJudgeCtx) SaveContextManifest(_ context.Context, m extension.ProjectContextManifest) error {
c.manifests = append(c.manifests, m)
return nil
}
func (c *projectJudgeCtx) ValidateProjectCrossGuard(_ context.Context, current extension.ProjectContextBundle) error {
for _, m := range c.manifests {
if m.AuthorizationEpoch != current.AuthorizationEpoch || m.PublicationEpoch != current.PublicationEpoch || m.Watermark != current.Watermark {
return fmt.Errorf("prior workflow model source was revoked")
}
}
return nil
}
func TestWorkflowJudgeRequestUsesProjectContext(t *testing.T) {
var sent []string
@@ -346,4 +360,53 @@ func TestWorkflowJudgeRequestUsesProjectContext(t *testing.T) {
}
}
func TestWorkflowModelReceiptCarriesEngineStepAttempt(t *testing.T) {
c := &projectJudgeCtx{wfFake: &wfFake{}, authEpoch: "duty-1"}
ctx := projectcontext.WithWorkflowStepAttempt(context.Background(), "engine-run/draft/2")
for ev := range GuardProjectStream(c, "draft", scriptStream(textTurns("done"), nil))(ctx, "model", []agentllm.Message{{Role: agentllm.RoleUser, Content: "write"}}, nil, agentllm.StreamOptions{}) {
if ev.Kind == agentllm.KindError {
t.Fatalf("step model failed: %+v", ev)
}
}
if len(c.manifests) != 2 || c.manifests[0].WorkflowStepAttemptID != "engine-run/draft/2" || c.manifests[1].WorkflowStepAttemptID != "engine-run/draft/2" || c.manifests[1].Delivery != "responded" {
t.Fatalf("engine attempt missing from trusted model receipts: %+v", c.manifests)
}
}
func TestWorkflowJudgeBlocksOutputFromEarlierAuthorizationEpoch(t *testing.T) {
const old = "OLD_RESTRICTED_BODY"
seen := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body struct {
Messages []struct {
Content string `json:"content"`
} `json:"messages"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
for _, m := range body.Messages {
if strings.Contains(m.Content, old) {
seen = true
}
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}))
defer srv.Close()
c := &projectJudgeCtx{wfFake: &wfFake{settings: core.EffectiveSettings{"models": map[string]any{"chat": map[string]any{"baseUrl": srv.URL, "apiKey": "test-key", "model": "test-model"}}}}, authEpoch: "duty-1"}
step := GuardProjectStream(c, "write", scriptStream(textTurns("step done"), nil))
for range step(context.Background(), "test-model", []agentllm.Message{{Role: agentllm.RoleUser, Content: "write report"}}, nil, agentllm.StreamOptions{}) {
}
if len(c.manifests) != 2 || c.manifests[1].Delivery != "responded" {
t.Fatalf("step source receipt missing: %+v", c.manifests)
}
c.authEpoch = "duty-2" // member remains; document duty and publication changed before critic
blocked := false
for ev := range judgeStreamFor(c)(context.Background(), "test-model", []agentllm.Message{{Role: agentllm.RoleUser, Content: "Judge output.md:\n" + old}}, nil, agentllm.StreamOptions{}) {
blocked = blocked || ev.Kind == agentllm.KindError
}
if seen || !blocked {
t.Fatalf("old workflow output reached a separate judge guard: seen=%v blocked=%v", seen, blocked)
}
}
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }
+7
View File
@@ -862,6 +862,13 @@ func init() {
},
down: map[string][]string{"sqlite": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}, "postgres": {`DROP INDEX IF EXISTS uniq_context_manifest_terminal`, `DROP INDEX IF EXISTS idx_context_manifests_owner_target`, `DROP TABLE IF EXISTS context_manifests`}},
},
migration{version: 33, name: "workflow_step_sources",
up: map[string][]string{
"sqlite": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE workflow_step_sources(run_id TEXT NOT NULL,step_id TEXT NOT NULL,owner TEXT NOT NULL,project_id TEXT NOT NULL,step_attempt_id TEXT NOT NULL,output_sha256 TEXT NOT NULL,attempt_id TEXT NOT NULL,authorization_epoch TEXT NOT NULL,publication_epoch TEXT NOT NULL,watermark TEXT NOT NULL,version TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(run_id,step_id),FOREIGN KEY(run_id) REFERENCES runs(id) ON DELETE CASCADE)`},
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS workflow_step_sources`}, "postgres": {`DROP TABLE IF EXISTS workflow_step_sources`}},
},
)
}
+1
View File
@@ -37,6 +37,7 @@ var migrationBreaksOldReaders = map[int]bool{
30: false, // project_event_publication_proofs:新表,旧读者不受影响
31: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
32: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
33: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+128
View File
@@ -0,0 +1,128 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
"agent-system-backend/pkg/extension"
)
type WorkflowStepSource struct {
RunID, StepID, Owner, ProjectID, StepAttemptID string
OutputSHA256 [32]byte
AttemptID, AuthorizationEpoch, PublicationEpoch, Watermark, Version string
}
// UnbindWorkflowStepSource removes an earlier attempt before the engine begins
// another execution of the same step. A failed/unknown retry cannot inherit a
// previous output's proof, even if the output bytes happen to be identical.
func (s *Store) UnbindWorkflowStepSource(owner, runID, stepID string) error {
if owner == "" || runID == "" || stepID == "" {
return errors.New("workflow step source scope is incomplete")
}
_, err := s.db.Exec(s.q(`DELETE FROM workflow_step_sources WHERE owner=? AND run_id=? AND step_id=?`), owner, runID, stepID)
return err
}
// BindWorkflowStepSource associates a materialized prompt step with only the
// responded model receipts created during that step. The caller must have
// excluded parallel prompt execution, whose receipt attribution is ambiguous.
func (s *Store) BindWorkflowStepSource(owner, runID, stepID, stepAttemptID, kind string, startedAt time.Time, output []byte) (WorkflowStepSource, error) {
if kind != "prompt" || owner == "" || runID == "" || stepID == "" || stepAttemptID == "" || startedAt.IsZero() {
return WorkflowStepSource{}, errors.New("workflow step source requires a started prompt step")
}
run, err := s.GetRunByID(runID)
if err != nil || run.Owner != owner || run.ProjectID == "" {
return WorkflowStepSource{}, ErrNotFound
}
rows, err := s.db.Query(s.q(`SELECT created_at,manifest_json FROM context_manifests WHERE owner=? AND target_id=? AND delivery='responded'`), owner, runID)
if err != nil {
return WorkflowStepSource{}, err
}
defer rows.Close()
var latest extension.ProjectContextManifest
var latestAt time.Time
for rows.Next() {
var atRaw, raw string
if err := rows.Scan(&atRaw, &raw); err != nil {
return WorkflowStepSource{}, err
}
at, err := time.Parse(time.RFC3339Nano, atRaw)
if err != nil {
return WorkflowStepSource{}, err
}
if at.Before(startedAt) {
continue
}
var m extension.ProjectContextManifest
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return WorkflowStepSource{}, err
}
if m.WorkflowStepAttemptID != stepAttemptID {
continue
}
if m.ProjectID != run.ProjectID || m.AuthorizationEpoch == "" || m.PublicationEpoch == "" || m.AttemptID == "" {
return WorkflowStepSource{}, errors.New("workflow step model receipt lacks trusted project epochs")
}
if !latestAt.IsZero() && (m.ProjectID != latest.ProjectID || m.AuthorizationEpoch != latest.AuthorizationEpoch || m.PublicationEpoch != latest.PublicationEpoch || m.Watermark != latest.Watermark) {
return WorkflowStepSource{}, errors.New("workflow step model receipts span project authorization revisions")
}
if latestAt.IsZero() || at.After(latestAt) {
latest, latestAt = m, at
}
}
if err := rows.Err(); err != nil {
return WorkflowStepSource{}, err
}
if latestAt.IsZero() {
return WorkflowStepSource{}, errors.New("workflow prompt output has no responded model receipt")
}
b := WorkflowStepSource{RunID: runID, StepID: stepID, Owner: owner, ProjectID: string(run.ProjectID), StepAttemptID: stepAttemptID, OutputSHA256: sha256.Sum256(output),
AttemptID: latest.AttemptID, AuthorizationEpoch: latest.AuthorizationEpoch, PublicationEpoch: latest.PublicationEpoch, Watermark: latest.Watermark, Version: latest.Version}
_, err = s.db.Exec(s.q(`INSERT INTO workflow_step_sources(run_id,step_id,owner,project_id,step_attempt_id,output_sha256,attempt_id,authorization_epoch,publication_epoch,watermark,version,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(run_id,step_id) DO UPDATE SET owner=excluded.owner,project_id=excluded.project_id,step_attempt_id=excluded.step_attempt_id,output_sha256=excluded.output_sha256,attempt_id=excluded.attempt_id,authorization_epoch=excluded.authorization_epoch,publication_epoch=excluded.publication_epoch,watermark=excluded.watermark,version=excluded.version,created_at=excluded.created_at`),
runID, stepID, owner, b.ProjectID, b.StepAttemptID, hex.EncodeToString(b.OutputSHA256[:]), b.AttemptID, b.AuthorizationEpoch, b.PublicationEpoch, b.Watermark, b.Version, nowISO())
if err != nil {
return WorkflowStepSource{}, fmt.Errorf("bind workflow step output: %w", err)
}
return b, nil
}
func (s *Store) WorkflowStepSource(owner, runID, stepID string) (WorkflowStepSource, error) {
var b WorkflowStepSource
var hash string
err := s.db.QueryRow(s.q(`SELECT run_id,step_id,owner,project_id,step_attempt_id,output_sha256,attempt_id,authorization_epoch,publication_epoch,watermark,version FROM workflow_step_sources WHERE owner=? AND run_id=? AND step_id=?`),
owner, runID, stepID).Scan(&b.RunID, &b.StepID, &b.Owner, &b.ProjectID, &b.StepAttemptID, &hash, &b.AttemptID, &b.AuthorizationEpoch, &b.PublicationEpoch, &b.Watermark, &b.Version)
if errors.Is(err, sql.ErrNoRows) {
return WorkflowStepSource{}, ErrNotFound
}
if err != nil {
return WorkflowStepSource{}, err
}
decoded, err := hex.DecodeString(hash)
if err != nil || len(decoded) != len(b.OutputSHA256) {
return WorkflowStepSource{}, errors.New("workflow step source has invalid output hash")
}
copy(b.OutputSHA256[:], decoded)
return b, nil
}
func (s *Store) RespondedContextManifest(owner, runID, attemptID string) (extension.ProjectContextManifest, error) {
var raw string
err := s.db.QueryRow(s.q(`SELECT manifest_json FROM context_manifests WHERE owner=? AND target_id=? AND attempt_id=? AND delivery='responded'`), owner, runID, attemptID).Scan(&raw)
if errors.Is(err, sql.ErrNoRows) {
return extension.ProjectContextManifest{}, ErrNotFound
}
if err != nil {
return extension.ProjectContextManifest{}, err
}
var m extension.ProjectContextManifest
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return extension.ProjectContextManifest{}, err
}
return m, nil
}
@@ -0,0 +1,75 @@
package store
import (
"crypto/sha256"
"path/filepath"
"testing"
"time"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func TestWorkflowStepSourceRequiresRespondedManifestAndExactRunStep(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "step.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "P")
if err != nil {
t.Fatal(err)
}
c, err := st.CreateConversation("alice", string(p.ID), "C", "")
if err != nil {
t.Fatal(err)
}
r := core.Run{Owner: "alice", ConversationID: c.ID, ProjectID: p.ID, Kind: core.RunConversation, Status: core.RunPending}
if err := st.CreateRun(&r); err != nil {
t.Fatal(err)
}
started := time.Now().Add(-time.Second)
m := extension.ProjectContextManifest{ProjectID: p.ID, AttemptID: "attempt-1", WorkflowStepAttemptID: "engine-run/draft/1", Delivery: "attempted", InputSHA256: "input", AuthorizationEpoch: "duty-1", PublicationEpoch: "pub-1", Watermark: "w1"}
if err := st.AppendContextManifest("alice", string(r.ID), m); err != nil {
t.Fatal(err)
}
if _, err := st.BindWorkflowStepSource("alice", string(r.ID), "draft", "engine-run/draft/1", "prompt", started, []byte("old body")); err == nil {
t.Fatal("unanswered model attempt bound output")
}
unrelated := m
unrelated.AttemptID = "other-model-attempt"
unrelated.WorkflowStepAttemptID = "engine-run/other/1"
if err := st.AppendContextManifest("alice", string(r.ID), unrelated); err != nil {
t.Fatal(err)
}
unrelated.Delivery = "responded"
if err := st.AppendContextManifest("alice", string(r.ID), unrelated); err != nil {
t.Fatal(err)
}
if _, err := st.BindWorkflowStepSource("alice", string(r.ID), "draft", "engine-run/draft/1", "prompt", started, []byte("old body")); err == nil {
t.Fatal("another step's responded attempt bound draft output")
}
m.Delivery = "responded"
if err := st.AppendContextManifest("alice", string(r.ID), m); err != nil {
t.Fatal(err)
}
bound, err := st.BindWorkflowStepSource("alice", string(r.ID), "draft", "engine-run/draft/1", "prompt", started, []byte("old body"))
if err != nil || bound.AttemptID != "attempt-1" || bound.StepAttemptID != "engine-run/draft/1" || bound.OutputSHA256 != sha256.Sum256([]byte("old body")) {
t.Fatalf("source binding missed actual output: %+v err=%v", bound, err)
}
if _, err := st.WorkflowStepSource("alice", string(r.ID), "other"); err == nil {
t.Fatal("another step reused output binding")
}
if _, err := st.WorkflowStepSource("bob", string(r.ID), "draft"); err == nil {
t.Fatal("another owner read output binding")
}
if _, err := st.BindWorkflowStepSource("alice", string(r.ID), "tool", "engine-run/tool/1", "tool", started, []byte("tool body")); err == nil {
t.Fatal("tool output bound as model-sourced material")
}
if err := st.UnbindWorkflowStepSource("alice", string(r.ID), "draft"); err != nil {
t.Fatal(err)
}
if _, err := st.WorkflowStepSource("alice", string(r.ID), "draft"); err == nil {
t.Fatal("retry retained an earlier source binding")
}
}
+28 -4
View File
@@ -36,6 +36,15 @@ type Host interface {
Tools() []core.ToolDesc
}
// ProjectStepProvenance is implemented by the trusted Backend run host. Prompt
// output is bound after materialization; a critic must verify the exact bytes
// and current project authorization before it includes output in a model call.
type ProjectStepProvenance interface {
InvalidateProjectStepOutput(context.Context, string) error
BindProjectStepOutput(context.Context, string, string, string, time.Time, []byte) error
VerifyProjectStepOutput(context.Context, string, string, string, []byte) error
}
// Spawner 可选子代理派发能力(脚本回调 agent.spawn 用)。
type Spawner interface {
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
@@ -74,10 +83,11 @@ const (
// hostRunner 宿主 intent 步执行器(squad.IntentRunner 实现)。
type hostRunner struct {
rc Host
deps Deps
scripts map[string]string
subOf map[string]string
rc Host
deps Deps
scripts map[string]string
subOf map[string]string
stepKinds map[string]string
mu sync.Mutex
verdicts map[string]string // verdict 字段 → 最近一次判定
@@ -421,6 +431,20 @@ func (r *hostRunner) doCritic(ctx context.Context, step squad.IntentStep) (strin
target := step.Slots["target_step"]
field := step.Slots["verdict_field"]
output := r.readStepOutput(step.WorkDir, target)
if r.rc.Run().ProjectID != "" {
guard, ok := r.rc.(ProjectStepProvenance)
if !ok {
return "", nil, fmt.Errorf("项目工作流产物缺少可信来源校验")
}
wsDir := filepath.Dir(filepath.Dir(step.WorkDir))
stepAttemptID, err := completedStepAttempt(wsDir, target)
if err != nil {
return "", nil, fmt.Errorf("项目工作流步骤 %s 状态不可核验: %w", target, err)
}
if err := guard.VerifyProjectStepOutput(ctx, target, stepAttemptID, r.stepKinds[target], []byte(output)); err != nil {
return "", nil, fmt.Errorf("项目工作流步骤 %s 产物依据不可用: %w", target, err)
}
}
verdict := r.judge(ctx, step.Slots["goal"], step.Slots["criteria"], output)
fields := map[string]string{}
if verdict.Passed {
+37
View File
@@ -0,0 +1,37 @@
package wfcompile
import (
"context"
"agent-squad/squad"
"agent-system-backend/internal/projectcontext"
)
// projectStepBackends carries the engine-generated execution ID through the
// executor's request context into each model manifest. This associates a
// responded model request with exactly one workflow step attempt.
func projectStepBackends(base *squad.BackendResolver) *squad.BackendResolver {
wrapped := squad.NewBackendResolver()
for _, name := range base.ListBackends() {
backendName := name
wrapped.Register(backendName, func(role squad.RoleSpec) (squad.Executor, error) {
backend, _, err := base.Resolve(role, backendName)
if err != nil {
return nil, err
}
return &projectStepExecutor{next: backend}, nil
})
}
return wrapped
}
type projectStepExecutor struct{ next squad.Executor }
func (e *projectStepExecutor) Name() string { return e.next.Name() }
func (e *projectStepExecutor) Execute(ctx context.Context, req squad.ExecRequest) (*squad.ExecResult, error) {
if req.ExecutionID == "" {
return &squad.ExecResult{Success: false, ErrorReason: "project workflow step attempt unavailable"}, nil
}
return e.next.Execute(projectcontext.WithWorkflowStepAttempt(ctx, req.ExecutionID), req)
}
+107
View File
@@ -0,0 +1,107 @@
package wfcompile
import (
"context"
"errors"
"os"
"path/filepath"
"sync"
"time"
"agent-squad/squad"
)
// completedStepAttempt comes from the squad engine's persisted state, not a
// model response or plugin argument. It identifies the exact execution that
// produced output.md, including after a workflow resumes.
func completedStepAttempt(wsDir, stepID string) (string, error) {
state, err := (squad.FileStore{}).LoadRun(wsDir)
if err != nil {
return "", err
}
for _, step := range state.Plan.Workflow.Steps {
if step.ID == stepID && step.Status == "completed" && step.AttemptID != "" {
return step.AttemptID, nil
}
}
return "", errors.New("completed step attempt unavailable")
}
// projectOutputSink binds only a completed prompt step's materialized bytes.
// Parallel prompt executions have no unambiguous request-to-step attribution
// in the current backend contract, so they receive no binding and the critic
// fails closed. A recovered completion without its start event likewise needs
// an existing persisted binding to pass its critic.
type projectOutputSink struct {
rc ProjectStepProvenance
ctx context.Context
root string
subOf map[string]string
kinds map[string]string
mu sync.Mutex
started map[string]time.Time
ambiguous map[string]bool
}
func newProjectOutputSink(rc Host, root string, subOf, kinds map[string]string) squad.ProgressSink {
binder := rc.(ProjectStepProvenance)
return &projectOutputSink{rc: binder, ctx: rc.Context(), root: root, subOf: subOf, kinds: kinds,
started: map[string]time.Time{}, ambiguous: map[string]bool{}}
}
func (s *projectOutputSink) Emit(ev squad.Event) {
if ev.StepID == "" {
return
}
s.mu.Lock()
switch ev.Type {
case squad.EvStepStarted:
if s.kinds[ev.StepID] == "prompt" {
if err := s.rc.InvalidateProjectStepOutput(s.ctx, ev.StepID); err != nil {
s.ambiguous[ev.StepID] = true
}
}
if len(s.started) != 0 {
if s.kinds[ev.StepID] == "prompt" {
s.ambiguous[ev.StepID] = true
}
for id := range s.started {
if s.kinds[id] == "prompt" {
s.ambiguous[id] = true
}
}
}
s.started[ev.StepID] = ev.At
s.mu.Unlock()
return
case squad.EvStepCompleted:
startedAt, started := s.started[ev.StepID]
ambiguous := s.ambiguous[ev.StepID]
delete(s.started, ev.StepID)
delete(s.ambiguous, ev.StepID)
s.mu.Unlock()
if s.kinds[ev.StepID] != "prompt" || !started || startedAt.IsZero() || ambiguous {
return
}
sub := s.subOf[ev.StepID]
if sub == "" {
return
}
data, err := os.ReadFile(filepath.Join(s.root, "runs", ev.RunID, filepath.FromSlash(sub), "output.md"))
if err == nil {
wsDir := filepath.Join(s.root, "runs", ev.RunID)
attemptID, attemptErr := completedStepAttempt(wsDir, ev.StepID)
if attemptErr == nil {
_ = s.rc.BindProjectStepOutput(s.ctx, ev.StepID, attemptID, "prompt", startedAt, data)
}
}
return
case squad.EvStepFailed:
delete(s.started, ev.StepID)
delete(s.ambiguous, ev.StepID)
s.mu.Unlock()
return
default:
s.mu.Unlock()
}
}
+19 -2
View File
@@ -76,6 +76,15 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars,
if err != nil {
return Outcome{}, err
}
stepKinds := make(map[string]string, len(def.Steps))
for _, step := range def.Steps {
stepKinds[step.ID] = step.Kind
}
if rc.Run().ProjectID != "" {
if _, ok := rc.(ProjectStepProvenance); !ok {
return Outcome{}, fmt.Errorf("项目工作流缺少可信步骤产物绑定能力")
}
}
registry := squad.NewRoleRegistry()
for _, role := range compiled.Plan.Squad.Agents {
@@ -87,9 +96,17 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars,
if backends == nil {
backends = squad.NewBackendResolver()
}
if rc.Run().ProjectID != "" {
backends = projectStepBackends(backends)
}
runner := &hostRunner{
rc: rc, deps: deps, scripts: launch.Scripts, subOf: compiled.SubOf,
verdicts: map[string]string{}, lastWhy: map[string]string{}, fails: map[string]int{},
stepKinds: stepKinds,
verdicts: map[string]string{}, lastWhy: map[string]string{}, fails: map[string]int{},
}
var progress squad.ProgressSink = squad.NopSink{}
if rc.Run().ProjectID != "" {
progress = newProjectOutputSink(rc, filepath.Join(deps.DataDir, "wf", string(rc.Run().ID)), compiled.SubOf, stepKinds)
}
engine, err := squad.NewEngine(squad.Options{
DataDir: filepath.Join(deps.DataDir, "wf", string(rc.Run().ID)),
@@ -102,7 +119,7 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars,
Checker: passChecker{},
IntentCatalog: compiled.Catalog,
IntentRunner: runner,
Sink: squad.NopSink{},
Sink: progress,
})
if err != nil {
return Outcome{}, fmt.Errorf("workflow: 引擎装配: %w", err)
+116
View File
@@ -2,15 +2,19 @@ package wfcompile
import (
"context"
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
agentllm "agent-native/runtime/llm"
"agent-squad/squad"
"agent-system-backend/internal/projectcontext"
"agent-system-backend/internal/workflow"
core "agent-system-core"
)
@@ -452,6 +456,118 @@ func TestRunCompiledDemoBrief(t *testing.T) {
}
}
type projectSourceHost struct {
*fakeHost
boundID string
boundHash [32]byte
verified int
invalidated int
}
type stepContextProbe struct{ got string }
func (p *stepContextProbe) Name() string { return "probe" }
func (p *stepContextProbe) Execute(ctx context.Context, _ squad.ExecRequest) (*squad.ExecResult, error) {
p.got = projectcontext.WorkflowStepAttempt(ctx)
return &squad.ExecResult{Success: true}, nil
}
func TestProjectStepBackendCarriesEngineAttempt(t *testing.T) {
probe := &stepContextProbe{}
base := squad.NewBackendResolver()
base.RegisterInstance("probe", probe)
backend, _, err := projectStepBackends(base).Resolve(squad.RoleSpec{Role: "writer", Backend: "probe"}, "")
if err != nil {
t.Fatal(err)
}
if _, err := backend.Execute(context.Background(), squad.ExecRequest{ExecutionID: "engine-run/draft/2"}); err != nil {
t.Fatal(err)
}
if probe.got != "engine-run/draft/2" {
t.Fatalf("model backend lost trusted step attempt: %q", probe.got)
}
}
func (h *projectSourceHost) Run() core.Run {
return core.Run{ID: "run_project_wf", ProjectID: "p1", Owner: "alice"}
}
func (h *projectSourceHost) BindProjectStepOutput(_ context.Context, stepID, stepAttemptID, kind string, startedAt time.Time, output []byte) error {
if kind != "prompt" || stepAttemptID == "" || startedAt.IsZero() {
return fmt.Errorf("untrusted step binding: %s %s %s", stepID, kind, startedAt)
}
h.boundID, h.boundHash = stepID, sha256.Sum256(output)
return nil
}
func (h *projectSourceHost) VerifyProjectStepOutput(_ context.Context, stepID, stepAttemptID, kind string, output []byte) error {
h.verified++
if stepID != h.boundID || stepAttemptID == "" || kind != "prompt" || sha256.Sum256(output) != h.boundHash {
return fmt.Errorf("step output provenance mismatch: bound=%q/%x got=%q/%x", h.boundID, h.boundHash, stepID, sha256.Sum256(output))
}
return nil
}
func (h *projectSourceHost) InvalidateProjectStepOutput(_ context.Context, stepID string) error {
if stepID == h.boundID {
h.boundID = ""
h.boundHash = [32]byte{}
}
h.invalidated++
return nil
}
func TestProjectOutputSinkRejectsConcurrentAndRetriedAttribution(t *testing.T) {
h := &projectSourceHost{fakeHost: &fakeHost{}}
root := t.TempDir()
sub := "tasks/01_draft"
path := filepath.Join(root, "runs", "engine-run", sub, "output.md")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("model body"), 0o600); err != nil {
t.Fatal(err)
}
sink := newProjectOutputSink(h, root, map[string]string{"draft": sub}, map[string]string{"draft": "prompt", "other": "tool"})
started := time.Now().Add(-time.Second)
if err := h.BindProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", started, []byte("old attempt")); err != nil {
t.Fatal(err)
}
sink.Emit(squad.Event{Type: squad.EvStepStarted, RunID: "engine-run", StepID: "draft", At: started})
if err := h.VerifyProjectStepOutput(context.Background(), "draft", "engine-run/draft/1", "prompt", []byte("old attempt")); err == nil {
t.Fatal("retry retained previous attempt's binding")
}
sink.Emit(squad.Event{Type: squad.EvStepStarted, RunID: "engine-run", StepID: "other", At: started.Add(time.Millisecond)})
sink.Emit(squad.Event{Type: squad.EvStepCompleted, RunID: "engine-run", StepID: "draft", At: time.Now()})
if h.boundID != "" || h.invalidated == 0 {
t.Fatalf("ambiguous retry rebound output: id=%q invalidated=%d", h.boundID, h.invalidated)
}
}
func TestProjectPromptCriticBindsActualStepOutputBeforeJudge(t *testing.T) {
f := &projectSourceHost{fakeHost: &fakeHost{hitl: []core.HITLResponse{{ChoiceID: "short"}, {ChoiceID: "yes"}}}}
be := &seqBackend{texts: []string{"draft text\n核对标记:P4"}}
_, err := RunCompiled(f, Deps{DataDir: t.TempDir(), Backends: seqResolver(t, be), DefaultBackend: "seq", JudgeStream: verdictStream(verdictPass)},
workflow.Launch{Def: parseDef(t, demoBriefJSON)}, workflow.Vars{Input: "write", Slots: map[string]string{"topic": "topic"}}, RunOptions{})
if err != nil {
t.Fatal(err)
}
if f.boundID != "draft" || f.boundHash != sha256.Sum256([]byte("draft text\n核对标记:P4\n")) || f.verified == 0 {
t.Fatalf("project workflow output lacked trusted step binding: id=%q verified=%d", f.boundID, f.verified)
}
}
func TestProjectToolCriticRejectsUnprovenOutputBeforeModel(t *testing.T) {
f := &projectSourceHost{fakeHost: &fakeHost{toolNames: []string{"time_now"}}}
def := core.WorkflowDef{ID: "tool-critic", Title: "Tool review", Steps: []core.WorkflowStep{{ID: "get", Title: "Read", Kind: "tool", Tool: "time_now", Critic: &core.StepCritic{Criteria: "check"}}}}
called := false
judge := func(ctx context.Context, model string, messages []agentllm.Message, tools []agentllm.ToolDecl, opts agentllm.StreamOptions) <-chan agentllm.Event {
called = true
return verdictStream(verdictPass)(ctx, model, messages, tools, opts)
}
_, err := RunCompiled(f, Deps{DataDir: t.TempDir(), JudgeStream: judge}, workflow.Launch{Def: def}, workflow.Vars{Input: "read"}, RunOptions{})
if err == nil || called || !strings.Contains(err.Error(), "产物依据不可用") {
t.Fatalf("unproven tool output reached judge: err=%v called=%v", err, called)
}
}
// critic 预算耗尽仍不通过 → 工作流失败(P4 fail-closed)。
func TestRunCompiledCriticExhaustedFails(t *testing.T) {
def := parseDef(t, demoBriefJSON)
+17 -16
View File
@@ -112,22 +112,23 @@ type ProjectContextManifestEntry struct {
// ProjectContextManifest describes the text actually passed to the completion
// client. Candidate references in a bundle are never listed as included input.
type ProjectContextManifest struct {
ProjectID core.ID
TaskID string
Version string
Watermark string
AuthorizationEpoch string
PublicationEpoch string
AttemptID string
Delivery string // attempted | responded | unknown; never implies user-visible delivery
Model string
BudgetChars int
InputSHA256 string
Included []ProjectContextManifestEntry
Dropped []ProjectContextManifestEntry
OfferedReferences []ProjectEvidenceRef
ReadEvidence []ProjectContextManifestEntry
Missing []string
ProjectID core.ID
TaskID string
Version string
Watermark string
AuthorizationEpoch string
PublicationEpoch string
AttemptID string
WorkflowStepAttemptID string
Delivery string // attempted | responded | unknown; never implies user-visible delivery
Model string
BudgetChars int
InputSHA256 string
Included []ProjectContextManifestEntry
Dropped []ProjectContextManifestEntry
OfferedReferences []ProjectEvidenceRef
ReadEvidence []ProjectContextManifestEntry
Missing []string
}
type WorkCompletionResult struct {