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:
@@ -287,7 +287,7 @@ var copyOrder = []string{
|
|||||||
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
|
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
|
||||||
"sessions", "api_keys",
|
"sessions", "api_keys",
|
||||||
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings",
|
"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",
|
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
|
||||||
"user_commands", "pending_notices", "recovery_holds",
|
"user_commands", "pending_notices", "recovery_holds",
|
||||||
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
|
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
|
||||||
@@ -784,7 +784,7 @@ func advanceSequences(db *sql.DB, table string) error {
|
|||||||
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
|
||||||
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
|
||||||
func checkOwners(db *sql.DB, src *sql.DB) error {
|
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 {
|
for _, t := range ownerTables {
|
||||||
if !sqliteHasTable(src, t) {
|
if !sqliteHasTable(src, t) {
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
agentllm "agent-native/runtime/llm"
|
agentllm "agent-native/runtime/llm"
|
||||||
|
|
||||||
@@ -28,6 +29,30 @@ func (c *runCtx) ProjectContextEnabled() bool {
|
|||||||
return c.run.ProjectID != "" && c.mgr.projectContextResolver != nil
|
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 {
|
func (c *runCtx) contextRequest() extension.ProjectContextRequest {
|
||||||
c.projectContextMu.Lock()
|
c.projectContextMu.Lock()
|
||||||
defer c.projectContextMu.Unlock()
|
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)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
agentllm "agent-native/runtime/llm"
|
agentllm "agent-native/runtime/llm"
|
||||||
"agent-system-backend/internal/projectcontext"
|
"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) {
|
func TestContextManifestPersistsByOwnerAndRun(t *testing.T) {
|
||||||
st := openRecoveryStore(t)
|
st := openRecoveryStore(t)
|
||||||
m := newRecoveryManager(t, st)
|
m := newRecoveryManager(t, st)
|
||||||
|
|||||||
@@ -67,6 +67,13 @@ func GuardProjectStream(rc any, goal string, stream agentllm.StreamFunc) agentll
|
|||||||
if !active {
|
if !active {
|
||||||
return stream(ctx, model, messages, tools, opts)
|
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)
|
body, manifest, err := projectcontext.Assemble(bundle, budget)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fail(err)
|
return fail(err)
|
||||||
@@ -134,6 +141,7 @@ func GuardProjectStream(rc any, goal string, stream agentllm.StreamFunc) agentll
|
|||||||
return fail(err)
|
return fail(err)
|
||||||
}
|
}
|
||||||
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
|
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
|
||||||
|
manifest.WorkflowStepAttemptID = projectcontext.WorkflowStepAttempt(ctx)
|
||||||
var save func(context.Context, extension.ProjectContextManifest) error
|
var save func(context.Context, extension.ProjectContextManifest) error
|
||||||
if sink, ok := rc.(interface {
|
if sink, ok := rc.(interface {
|
||||||
SaveContextManifest(context.Context, extension.ProjectContextManifest) error
|
SaveContextManifest(context.Context, extension.ProjectContextManifest) error
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ func (w CompiledWorkflowRunner) runLaunch(rc RunCtx, launch workflow.Launch, opt
|
|||||||
}
|
}
|
||||||
if deps.JudgeStream == nil {
|
if deps.JudgeStream == nil {
|
||||||
deps.JudgeStream = judgeStreamFor(rc)
|
deps.JudgeStream = judgeStreamFor(rc)
|
||||||
|
} else {
|
||||||
|
deps.JudgeStream = GuardProjectStream(rc, "", deps.JudgeStream)
|
||||||
}
|
}
|
||||||
return wfcompile.RunCompiled(workflowHost{rc: rc}, deps, launch, workflow.Vars{
|
return wfcompile.RunCompiled(workflowHost{rc: rc}, deps, launch, workflow.Vars{
|
||||||
Input: currentUserText(rc),
|
Input: currentUserText(rc),
|
||||||
@@ -169,3 +171,33 @@ func (h workflowHost) ConversationSandbox() string {
|
|||||||
}
|
}
|
||||||
return ""
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
agentllm "agent-native/runtime/llm"
|
agentllm "agent-native/runtime/llm"
|
||||||
|
"agent-system-backend/internal/projectcontext"
|
||||||
"agent-system-backend/internal/workflow"
|
"agent-system-backend/internal/workflow"
|
||||||
"agent-system-backend/pkg/extension"
|
"agent-system-backend/pkg/extension"
|
||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
@@ -296,18 +297,31 @@ func TestJudgeStreamUsesConfiguredModel(t *testing.T) {
|
|||||||
type projectJudgeCtx struct {
|
type projectJudgeCtx struct {
|
||||||
*wfFake
|
*wfFake
|
||||||
manifests []extension.ProjectContextManifest
|
manifests []extension.ProjectContextManifest
|
||||||
|
authEpoch string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *projectJudgeCtx) Run() core.Run {
|
func (c *projectJudgeCtx) Run() core.Run {
|
||||||
return core.Run{ID: "run-judge", Owner: "alice", ProjectID: "project-1"}
|
return core.Run{ID: "run-judge", Owner: "alice", ProjectID: "project-1"}
|
||||||
}
|
}
|
||||||
func (c *projectJudgeCtx) ProjectContext(context.Context, string, int) (extension.ProjectContextBundle, bool, error) {
|
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 {
|
func (c *projectJudgeCtx) SaveContextManifest(_ context.Context, m extension.ProjectContextManifest) error {
|
||||||
c.manifests = append(c.manifests, m)
|
c.manifests = append(c.manifests, m)
|
||||||
return nil
|
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) {
|
func TestWorkflowJudgeRequestUsesProjectContext(t *testing.T) {
|
||||||
var sent []string
|
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 }
|
func (*wfFake) Surfaces() []core.SurfaceSnapshot { return nil }
|
||||||
|
|||||||
@@ -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`}},
|
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`}},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ var migrationBreaksOldReaders = map[int]bool{
|
|||||||
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
30: false, // project_event_publication_proofs:新表,旧读者不受影响
|
||||||
31: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
|
31: false, // work_task_bindings:新增绑定表,旧后台任务读面不受影响
|
||||||
32: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
|
32: false, // trusted_context_manifests:独立可信回执表,旧读者不受影响
|
||||||
|
33: false, // workflow_step_sources:新增逐步骤来源绑定表,旧读者不受影响
|
||||||
}
|
}
|
||||||
|
|
||||||
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
// SchemaTarget 是本二进制会迁移到的 schema 版本。
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,15 @@ type Host interface {
|
|||||||
Tools() []core.ToolDesc
|
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 用)。
|
// Spawner 可选子代理派发能力(脚本回调 agent.spawn 用)。
|
||||||
type Spawner interface {
|
type Spawner interface {
|
||||||
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
SpawnAgent(agentID, name, task, hint string) (core.AgentResultPayload, error)
|
||||||
@@ -74,10 +83,11 @@ const (
|
|||||||
|
|
||||||
// hostRunner 宿主 intent 步执行器(squad.IntentRunner 实现)。
|
// hostRunner 宿主 intent 步执行器(squad.IntentRunner 实现)。
|
||||||
type hostRunner struct {
|
type hostRunner struct {
|
||||||
rc Host
|
rc Host
|
||||||
deps Deps
|
deps Deps
|
||||||
scripts map[string]string
|
scripts map[string]string
|
||||||
subOf map[string]string
|
subOf map[string]string
|
||||||
|
stepKinds map[string]string
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
verdicts map[string]string // verdict 字段 → 最近一次判定
|
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"]
|
target := step.Slots["target_step"]
|
||||||
field := step.Slots["verdict_field"]
|
field := step.Slots["verdict_field"]
|
||||||
output := r.readStepOutput(step.WorkDir, target)
|
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)
|
verdict := r.judge(ctx, step.Slots["goal"], step.Slots["criteria"], output)
|
||||||
fields := map[string]string{}
|
fields := map[string]string{}
|
||||||
if verdict.Passed {
|
if verdict.Passed {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,6 +76,15 @@ func RunCompiled(rc Host, deps Deps, launch workflow.Launch, vars workflow.Vars,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Outcome{}, err
|
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()
|
registry := squad.NewRoleRegistry()
|
||||||
for _, role := range compiled.Plan.Squad.Agents {
|
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 {
|
if backends == nil {
|
||||||
backends = squad.NewBackendResolver()
|
backends = squad.NewBackendResolver()
|
||||||
}
|
}
|
||||||
|
if rc.Run().ProjectID != "" {
|
||||||
|
backends = projectStepBackends(backends)
|
||||||
|
}
|
||||||
runner := &hostRunner{
|
runner := &hostRunner{
|
||||||
rc: rc, deps: deps, scripts: launch.Scripts, subOf: compiled.SubOf,
|
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{
|
engine, err := squad.NewEngine(squad.Options{
|
||||||
DataDir: filepath.Join(deps.DataDir, "wf", string(rc.Run().ID)),
|
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{},
|
Checker: passChecker{},
|
||||||
IntentCatalog: compiled.Catalog,
|
IntentCatalog: compiled.Catalog,
|
||||||
IntentRunner: runner,
|
IntentRunner: runner,
|
||||||
Sink: squad.NopSink{},
|
Sink: progress,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Outcome{}, fmt.Errorf("workflow: 引擎装配: %w", err)
|
return Outcome{}, fmt.Errorf("workflow: 引擎装配: %w", err)
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ package wfcompile
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
agentllm "agent-native/runtime/llm"
|
agentllm "agent-native/runtime/llm"
|
||||||
"agent-squad/squad"
|
"agent-squad/squad"
|
||||||
|
"agent-system-backend/internal/projectcontext"
|
||||||
"agent-system-backend/internal/workflow"
|
"agent-system-backend/internal/workflow"
|
||||||
core "agent-system-core"
|
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)。
|
// critic 预算耗尽仍不通过 → 工作流失败(P4 fail-closed)。
|
||||||
func TestRunCompiledCriticExhaustedFails(t *testing.T) {
|
func TestRunCompiledCriticExhaustedFails(t *testing.T) {
|
||||||
def := parseDef(t, demoBriefJSON)
|
def := parseDef(t, demoBriefJSON)
|
||||||
|
|||||||
@@ -112,22 +112,23 @@ type ProjectContextManifestEntry struct {
|
|||||||
// ProjectContextManifest describes the text actually passed to the completion
|
// ProjectContextManifest describes the text actually passed to the completion
|
||||||
// client. Candidate references in a bundle are never listed as included input.
|
// client. Candidate references in a bundle are never listed as included input.
|
||||||
type ProjectContextManifest struct {
|
type ProjectContextManifest struct {
|
||||||
ProjectID core.ID
|
ProjectID core.ID
|
||||||
TaskID string
|
TaskID string
|
||||||
Version string
|
Version string
|
||||||
Watermark string
|
Watermark string
|
||||||
AuthorizationEpoch string
|
AuthorizationEpoch string
|
||||||
PublicationEpoch string
|
PublicationEpoch string
|
||||||
AttemptID string
|
AttemptID string
|
||||||
Delivery string // attempted | responded | unknown; never implies user-visible delivery
|
WorkflowStepAttemptID string
|
||||||
Model string
|
Delivery string // attempted | responded | unknown; never implies user-visible delivery
|
||||||
BudgetChars int
|
Model string
|
||||||
InputSHA256 string
|
BudgetChars int
|
||||||
Included []ProjectContextManifestEntry
|
InputSHA256 string
|
||||||
Dropped []ProjectContextManifestEntry
|
Included []ProjectContextManifestEntry
|
||||||
OfferedReferences []ProjectEvidenceRef
|
Dropped []ProjectContextManifestEntry
|
||||||
ReadEvidence []ProjectContextManifestEntry
|
OfferedReferences []ProjectEvidenceRef
|
||||||
Missing []string
|
ReadEvidence []ProjectContextManifestEntry
|
||||||
|
Missing []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkCompletionResult struct {
|
type WorkCompletionResult struct {
|
||||||
|
|||||||
Reference in New Issue
Block a user