Compare commits
384
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
342a93caa4 | ||
|
|
0904a4ade7 | ||
|
|
bbd525eeb5 | ||
|
|
352703a91f | ||
|
|
5a439f7ba8 | ||
|
|
f7200b0704 | ||
|
|
14bf8261da | ||
|
|
feb87d5c4e | ||
|
|
24320f22a6 | ||
|
|
98cf218cc3 | ||
|
|
7abe73e7bd | ||
|
|
a05ddacff3 | ||
|
|
20b18de60a | ||
|
|
d5c98dd62f | ||
|
|
61d08fa05a | ||
|
|
1ea2e713d8 | ||
|
|
38dce57666 | ||
|
|
8f255b8bde | ||
|
|
6b97d6668b | ||
|
|
3fa1d70379 | ||
|
|
2dd05a7aa6 | ||
|
|
02227edc23 | ||
|
|
a2297a84ad | ||
|
|
05cbd8adc7 | ||
|
|
984731b111 | ||
|
|
0da03c18d1 | ||
|
|
302b8fc8cd | ||
|
|
59239635cd | ||
|
|
238105a7da | ||
|
|
d7ca5f7e68 | ||
|
|
827135a02d | ||
|
|
a3ddf53207 | ||
|
|
35ac9015ad | ||
|
|
1c196ef675 | ||
|
|
2ce3b5a9b3 | ||
|
|
7421fd058d | ||
|
|
368e9df551 | ||
|
|
e9629046cb | ||
|
|
b5ccc419f6 | ||
|
|
615723da2a | ||
|
|
d1bcd5beff | ||
|
|
519dc583a0 | ||
|
|
0bf883cf56 | ||
|
|
c8502c735c | ||
|
|
48af97fbdd | ||
|
|
13bcf87ea5 | ||
|
|
e271fbb1e1 | ||
|
|
5678686c57 | ||
|
|
9fc3fd1aa5 | ||
|
|
e3a9c18cb3 | ||
|
|
9534b041db | ||
|
|
9426fa40a9 | ||
|
|
51e2b23082 | ||
|
|
aece914fb8 | ||
|
|
762e312730 | ||
|
|
e9eb529d6e | ||
|
|
e39a2b7dda | ||
|
|
cdc6baa4c0 | ||
|
|
255ab8dbf3 | ||
|
|
a76dc42bd8 | ||
|
|
f0cbd71129 | ||
|
|
aa10c3e093 | ||
|
|
0d836121d0 | ||
|
|
6e8ecbf5e5 | ||
|
|
8088ff197e | ||
|
|
55ee967f2f | ||
|
|
a56bd614bd | ||
|
|
992e8470b2 | ||
|
|
fe2bd8493f | ||
|
|
e43820c5ce | ||
|
|
662e30cb93 | ||
|
|
a51675e58d | ||
|
|
820b1dc643 | ||
|
|
94f2ae81c3 | ||
|
|
9020100313 | ||
|
|
d097940692 | ||
|
|
28dfcc8aec | ||
|
|
7dd0c8e7a4 | ||
|
|
bed21ad5c0 | ||
|
|
2622140529 | ||
|
|
3f9dc7a270 | ||
|
|
b5633c7fab | ||
|
|
5d053fedf4 | ||
|
|
d924694602 | ||
|
|
cf8a48ae0d | ||
|
|
24e865aa07 | ||
|
|
5d0dbbbe92 | ||
|
|
f2277e35b4 | ||
|
|
e8a642c5e2 | ||
|
|
399bdea220 | ||
|
|
9a0205758b | ||
|
|
0507e1af9c | ||
|
|
f893744ed1 | ||
|
|
e455e89777 | ||
|
|
9b60da96b0 | ||
|
|
34c0e7a0f4 | ||
|
|
550ce99bcd | ||
|
|
e81411c4a4 | ||
|
|
688a7e49db | ||
|
|
2ce997ca73 | ||
|
|
075967c395 | ||
|
|
1efd8bd25d | ||
|
|
e2bfd83481 | ||
|
|
2d5f0c69d0 | ||
|
|
339159a194 | ||
|
|
ad905e0bc5 | ||
|
|
721817515a | ||
|
|
73de357c9f | ||
|
|
b1e635d00b | ||
|
|
e489b5511a | ||
|
|
79409fb5c8 | ||
|
|
e9bfe12293 | ||
|
|
0833a7a0ca | ||
|
|
dfecab7c34 | ||
|
|
2eb57675c8 | ||
|
|
98f1454b3c | ||
|
|
d274768bcc | ||
|
|
ba31326302 | ||
|
|
f4e674fa9b | ||
|
|
1c7447b7e3 | ||
|
|
2d24c011aa | ||
|
|
b9b082b802 | ||
|
|
74dd9922a5 | ||
|
|
4298b63d91 | ||
|
|
c48fba7d8d | ||
|
|
7b71216a05 | ||
|
|
35204177e9 | ||
|
|
fc39278c91 | ||
|
|
eefd54137c | ||
|
|
880cd055dd | ||
|
|
ce07163541 | ||
|
|
6bd9acac6e | ||
|
|
1be14da122 | ||
|
|
96a61a46d0 | ||
|
|
4f4e43c953 | ||
|
|
306a35e294 | ||
|
|
53a0bdb2e3 | ||
|
|
bf73dcd1ea | ||
|
|
6409a9d442 | ||
|
|
96f96bd130 | ||
|
|
a8ce97c807 | ||
|
|
0804a69b4f | ||
|
|
e816b70f46 | ||
|
|
8adcd86cad | ||
|
|
9d2db45dd5 | ||
|
|
111276fb22 | ||
|
|
cad4623232 | ||
|
|
ececb58025 | ||
|
|
4a9ba3b7d4 | ||
|
|
b029adfb43 | ||
|
|
d0651474ab | ||
|
|
ca9db836de | ||
|
|
44ea97b938 | ||
|
|
84f5ab6c47 | ||
|
|
153372c2de | ||
|
|
7384b8922b | ||
|
|
295523b470 | ||
|
|
0991a24739 | ||
|
|
420c52b10e | ||
|
|
1e3ed210d7 | ||
|
|
947a75b771 | ||
|
|
c84ddad051 | ||
|
|
71b63ac6f9 | ||
|
|
659eb4ecfc | ||
|
|
2ff2c7db09 | ||
|
|
475e216ee0 | ||
|
|
226056fb46 | ||
|
|
580d18a41c | ||
|
|
0abd6c2a11 | ||
|
|
def43ecfc3 | ||
|
|
c4bfcf032a | ||
|
|
4a3a7d4812 | ||
|
|
dbe3e023ec | ||
|
|
c63110ae7d | ||
|
|
101f8beadb | ||
|
|
b16925b386 | ||
|
|
be5a146cca | ||
|
|
094fdcc51e | ||
|
|
307ad4a587 | ||
|
|
e867ba3c93 | ||
|
|
c3027c5901 | ||
|
|
30bf2d6e67 | ||
|
|
7fcab3bf31 | ||
|
|
eca8475e29 | ||
|
|
b340d46cdd | ||
|
|
ac721216ea | ||
|
|
e314d02212 | ||
|
|
6394e45bf2 | ||
|
|
ba5cc60c15 | ||
|
|
a0fed9956c | ||
|
|
58e9c97d76 | ||
|
|
ba154e4ec2 | ||
|
|
c4d7795280 | ||
|
|
25323d4ac1 | ||
|
|
15133cd0fc | ||
|
|
813554dc80 | ||
|
|
bd8980bc3c | ||
|
|
62a3713e1e | ||
|
|
1fb03de7be | ||
|
|
fa92a10eb7 | ||
|
|
bf20f247eb | ||
|
|
560b51e2f6 | ||
|
|
d19fcceff3 | ||
|
|
8c6f23dc49 | ||
|
|
19c58b6d25 | ||
|
|
e9d5b1ca8a | ||
|
|
444d9d825a | ||
|
|
a2ff01050e | ||
|
|
443e0ceb2e | ||
|
|
3e91ae503c | ||
|
|
bf6f2365d4 | ||
|
|
9b7091bbe9 | ||
|
|
12725511c0 | ||
|
|
3c46a5f07f | ||
|
|
b0a8b3e5a7 | ||
|
|
f6f68ffb04 | ||
|
|
5c98bc3568 | ||
|
|
de1d2f5107 | ||
|
|
bcbd41f217 | ||
|
|
f5616fe3ea | ||
|
|
94baf98060 | ||
|
|
c499ae090e | ||
|
|
e132ec6ba9 | ||
|
|
344f8ad106 | ||
|
|
080bd44021 | ||
|
|
318a68c3dd | ||
|
|
73d7ffd012 | ||
|
|
51db6377a4 | ||
|
|
c3b4283101 | ||
|
|
ffd7ddcce5 | ||
|
|
dd83442ff9 | ||
|
|
f1c3abb3b7 | ||
|
|
a56957138d | ||
|
|
5058d5ac0e | ||
|
|
00d7f70ffb | ||
|
|
c09be4c9b5 | ||
|
|
976edccfd8 | ||
|
|
1eb3e39286 | ||
|
|
15088d1d7d | ||
|
|
4cdd1a5276 | ||
|
|
e93f810946 | ||
|
|
459d836468 | ||
|
|
52058459f6 | ||
|
|
9b91be0764 | ||
|
|
e4f8d6fa70 | ||
|
|
d7151f1d78 | ||
|
|
d357e7714a | ||
|
|
9160654a07 | ||
|
|
29dad403ec | ||
|
|
041c1ce849 | ||
|
|
ba162f6433 | ||
|
|
3ca7ffab16 | ||
|
|
615a3e4765 | ||
|
|
c459cd563c | ||
|
|
b2753a64c4 | ||
|
|
649dd602dd | ||
|
|
7a004e524a | ||
|
|
cb78c35f24 | ||
|
|
b1a79ede91 | ||
|
|
e012694108 | ||
|
|
c4f3f02933 | ||
|
|
102a57aaff | ||
|
|
381e4d52e8 | ||
|
|
c8dc9edbdc | ||
|
|
a0b0b32f0a | ||
|
|
1f2068512e | ||
|
|
b97c910434 | ||
|
|
b789878d01 | ||
|
|
705e4a8ed6 | ||
|
|
531dfa85ad | ||
|
|
6e215b5b57 | ||
|
|
d46c0cf719 | ||
|
|
3eecc710ed | ||
|
|
1b4284592b | ||
|
|
d2dd38b9ae | ||
|
|
fb6b5e9677 | ||
|
|
40735ce9c1 | ||
|
|
9f40c6a793 | ||
|
|
a9dbc3b624 | ||
|
|
d4b5416126 | ||
|
|
7c4981ea09 | ||
|
|
5e8286695f | ||
|
|
2b11baa170 | ||
|
|
8868258afd | ||
|
|
90cb2b1590 | ||
|
|
3d97b348f7 | ||
|
|
3eea9e58cd | ||
|
|
12ab124411 | ||
|
|
0886342031 | ||
|
|
3bfe76278b | ||
|
|
067facdfa4 | ||
|
|
f2e520e31e | ||
|
|
cf91807fab | ||
|
|
c925089fd2 | ||
|
|
a2d0a3bf19 | ||
|
|
c1cde67bcf | ||
|
|
aeafc6e0ac | ||
|
|
1f5569db15 | ||
|
|
2b8cea5aa8 | ||
|
|
7b09b3b2ec | ||
|
|
9cc5101a30 | ||
|
|
f90ed7af35 | ||
|
|
6f61601d77 | ||
|
|
259e7343e3 | ||
|
|
cdc72e17be | ||
|
|
931a0b0f0c | ||
|
|
e70be0103a | ||
|
|
2353f1f3bb | ||
|
|
355ba7db56 | ||
|
|
4839f139e8 | ||
|
|
ea90bf0fab | ||
|
|
7978b495d1 | ||
|
|
d73d4fc118 | ||
|
|
c81e2269e1 | ||
|
|
47327cc6d8 | ||
|
|
d0ede46352 | ||
|
|
e4ba1cb5fa | ||
|
|
90104d2553 | ||
|
|
ca0d002073 | ||
|
|
20db36f52e | ||
|
|
383d4c6e4f | ||
|
|
f3ddb45fe5 | ||
|
|
6061eb5c13 | ||
|
|
0e46a64702 | ||
|
|
0609561dfe | ||
|
|
1b88a35b82 | ||
|
|
be01b6b883 | ||
|
|
deaddbca12 | ||
|
|
b20611590a | ||
|
|
d4972afd11 | ||
|
|
60e8292c1d | ||
|
|
da251e1aa8 | ||
|
|
eac39a3fd4 | ||
|
|
1f8af98f31 | ||
|
|
ee16103e03 | ||
|
|
09d53adfd1 | ||
|
|
89694047b1 | ||
|
|
23e0525c08 | ||
|
|
45874f2186 | ||
|
|
ae5848d799 | ||
|
|
ec79f19a79 | ||
|
|
f63c4bb40c | ||
|
|
22f839df99 | ||
|
|
c41634a065 | ||
|
|
2609f31e59 | ||
|
|
7aa41b3be2 | ||
|
|
1f6ca363ee | ||
|
|
02471990ef | ||
|
|
40b56798e2 | ||
|
|
c53d7ded06 | ||
|
|
e080c34a77 | ||
|
|
306f53d484 | ||
|
|
c4411afaaf | ||
|
|
c508941a8a | ||
|
|
ee68e2207e | ||
|
|
3d9a5bccba | ||
|
|
d736500d98 | ||
|
|
33b51a4445 | ||
|
|
0aab10863c | ||
|
|
b17caa3ea9 | ||
|
|
66ed523248 | ||
|
|
c5b5055ff1 | ||
|
|
7476f43ffa | ||
|
|
be29ed5776 | ||
|
|
ab840878fe | ||
|
|
aebc932b97 | ||
|
|
05a246ea3f | ||
|
|
c847988630 | ||
|
|
62f0c529b3 | ||
|
|
54958d0902 | ||
|
|
8d3e7f7cac | ||
|
|
c9eb398857 | ||
|
|
284ea88553 | ||
|
|
f9b9765100 | ||
|
|
863a379048 | ||
|
|
a86d1994c3 | ||
|
|
699eac00eb | ||
|
|
dff6c91c8e | ||
|
|
7afe3ff48f | ||
|
|
6dec47c791 | ||
|
|
def0e4aff0 | ||
|
|
9a6ab6a9ca | ||
|
|
8f20326c07 | ||
|
|
a82592a130 |
@@ -0,0 +1,197 @@
|
||||
// Package developmentexecutor contains the local, read-only verification
|
||||
// adapter for governed development candidates. It is deliberately not a
|
||||
// source mutator, promotion authority, secret resolver, or deployment tool.
|
||||
package developmentexecutor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type CheckKind string
|
||||
|
||||
const (
|
||||
CheckGoTest CheckKind = "GO_TEST"
|
||||
CheckGoVet CheckKind = "GO_VET"
|
||||
CheckGitDiffCheck CheckKind = "GIT_DIFF_CHECK"
|
||||
)
|
||||
|
||||
type Check struct {
|
||||
Kind CheckKind
|
||||
Packages []string
|
||||
}
|
||||
|
||||
type Plan struct {
|
||||
Worktree string
|
||||
WorktreeID string
|
||||
BaseRepoRevision string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
Checks []Check
|
||||
}
|
||||
|
||||
type CheckResult struct {
|
||||
Kind CheckKind `json:"kind"`
|
||||
Passed bool `json:"passed"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
DurationMillis int64 `json:"duration_millis"`
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
BaseRepoRevision string `json:"base_repo_revision"`
|
||||
Passed bool `json:"passed"`
|
||||
Checks []CheckResult `json:"checks"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrInvalidPlan = errors.New("invalid governed verification plan")
|
||||
ErrUnsupportedCheck = errors.New("unsupported governed verification check")
|
||||
ErrProtectedWorktree = errors.New("protected worktree is not executable")
|
||||
)
|
||||
|
||||
type CommandRunner interface {
|
||||
Run(context.Context, string, string, []string) int
|
||||
}
|
||||
|
||||
type OSCommandRunner struct{}
|
||||
|
||||
func (OSCommandRunner) Run(ctx context.Context, dir, name string, args []string) int {
|
||||
command := exec.CommandContext(ctx, name, args...)
|
||||
command.Dir = dir
|
||||
command.Stdin = nil
|
||||
command.Stdout = io.Discard
|
||||
command.Stderr = io.Discard
|
||||
command.Env = safeEnvironment()
|
||||
if err := command.Run(); err != nil {
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return exitErr.ExitCode()
|
||||
}
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func ValidatePlan(plan Plan) error {
|
||||
if strings.TrimSpace(plan.Worktree) == "" || strings.TrimSpace(plan.WorktreeID) == "" || strings.TrimSpace(plan.BaseRepoRevision) == "" || len(plan.Checks) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
if !filepath.IsAbs(plan.Worktree) {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
if plan.ProtectedMainAccess != "DENIED" || plan.ProductionAccess != "DENIED" || plan.SecretAccess != "DENIED" {
|
||||
return ErrProtectedWorktree
|
||||
}
|
||||
switch strings.ToLower(filepath.Base(filepath.Clean(plan.WorktreeID))) {
|
||||
case "main", "protected-main", "production":
|
||||
return ErrProtectedWorktree
|
||||
}
|
||||
for _, check := range plan.Checks {
|
||||
if err := validateCheck(check); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCheck(check Check) error {
|
||||
switch check.Kind {
|
||||
case CheckGoTest, CheckGoVet:
|
||||
if len(check.Packages) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
for _, packagePath := range check.Packages {
|
||||
if strings.TrimSpace(packagePath) == "" || strings.ContainsAny(packagePath, ";&|`$()<>\r\n") || unsafePackagePath(packagePath) {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
}
|
||||
case CheckGitDiffCheck:
|
||||
if len(check.Packages) != 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
default:
|
||||
return ErrUnsupportedCheck
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsafePackagePath(value string) bool {
|
||||
value = strings.ReplaceAll(value, "\\", "/")
|
||||
for _, part := range strings.Split(value, "/") {
|
||||
if part == ".." {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.HasPrefix(value, "../") || strings.HasPrefix(value, "/") || (len(value) > 1 && value[1] == ':')
|
||||
}
|
||||
|
||||
func Execute(ctx context.Context, plan Plan, runner CommandRunner) (Result, error) {
|
||||
if err := ValidatePlan(plan); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if runner == nil {
|
||||
runner = OSCommandRunner{}
|
||||
}
|
||||
if _, err := os.Stat(plan.Worktree); err != nil {
|
||||
return Result{}, fmt.Errorf("worktree unavailable: %w", err)
|
||||
}
|
||||
result := Result{WorktreeID: plan.WorktreeID, BaseRepoRevision: plan.BaseRepoRevision, Passed: true, Checks: make([]CheckResult, 0, len(plan.Checks))}
|
||||
for _, check := range plan.Checks {
|
||||
started := time.Now()
|
||||
name, args := commandFor(check)
|
||||
exitCode := runner.Run(ctx, plan.Worktree, name, args)
|
||||
passed := exitCode == 0
|
||||
result.Checks = append(result.Checks, CheckResult{Kind: check.Kind, Passed: passed, ExitCode: exitCode, DurationMillis: time.Since(started).Milliseconds()})
|
||||
if !passed {
|
||||
result.Passed = false
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
result.Passed = false
|
||||
break
|
||||
}
|
||||
}
|
||||
result.EvidenceRef = evidenceRef(result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func commandFor(check Check) (string, []string) {
|
||||
switch check.Kind {
|
||||
case CheckGoTest:
|
||||
return "go", append([]string{"test"}, check.Packages...)
|
||||
case CheckGoVet:
|
||||
return "go", append([]string{"vet"}, check.Packages...)
|
||||
default:
|
||||
return "git", []string{"diff", "--check"}
|
||||
}
|
||||
}
|
||||
|
||||
func evidenceRef(result Result) string {
|
||||
hash := sha256.New()
|
||||
_, _ = fmt.Fprintf(hash, "%s|%s|%t|", result.WorktreeID, result.BaseRepoRevision, result.Passed)
|
||||
for _, check := range result.Checks {
|
||||
_, _ = fmt.Fprintf(hash, "%s|%t|%d|", check.Kind, check.Passed, check.ExitCode)
|
||||
}
|
||||
return "verification:" + hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func safeEnvironment() []string {
|
||||
allowed := []string{"PATH", "SystemRoot", "TEMP", "TMP", "GOROOT", "GOPATH", "GOMODCACHE"}
|
||||
result := make([]string, 0, len(allowed))
|
||||
for _, name := range allowed {
|
||||
if value, ok := os.LookupEnv(name); ok && value != "" {
|
||||
result = append(result, name+"="+value)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package developmentexecutor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeRunner struct {
|
||||
calls []string
|
||||
exits []int
|
||||
}
|
||||
|
||||
func (r *fakeRunner) Run(_ context.Context, _ string, name string, args []string) int {
|
||||
call := name
|
||||
for _, arg := range args {
|
||||
call += " " + arg
|
||||
}
|
||||
r.calls = append(r.calls, call)
|
||||
if len(r.exits) == 0 {
|
||||
return 0
|
||||
}
|
||||
exit := r.exits[0]
|
||||
r.exits = r.exits[1:]
|
||||
return exit
|
||||
}
|
||||
|
||||
func validPlan(t *testing.T) Plan {
|
||||
t.Helper()
|
||||
worktree, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return Plan{Worktree: worktree, WorktreeID: "sandbox-1", BaseRepoRevision: "revision-1", ProtectedMainAccess: "DENIED", ProductionAccess: "DENIED", SecretAccess: "DENIED", Checks: []Check{{Kind: CheckGoTest, Packages: []string{"./modules/work/..."}}, {Kind: CheckGoVet, Packages: []string{"./modules/work/..."}}, {Kind: CheckGitDiffCheck}}}
|
||||
}
|
||||
|
||||
func TestExecuteRunsOnlyTypedChecksAndReturnsEvidence(t *testing.T) {
|
||||
runner := &fakeRunner{}
|
||||
result, err := Execute(context.Background(), validPlan(t), runner)
|
||||
if err != nil || !result.Passed || len(result.Checks) != 3 || result.EvidenceRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
if len(runner.calls) != 3 || runner.calls[0] != "go test ./modules/work/..." || runner.calls[2] != "git diff --check" {
|
||||
t.Fatalf("calls=%v", runner.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteFailureCannotBecomeVerificationSuccess(t *testing.T) {
|
||||
runner := &fakeRunner{exits: []int{1, 0}}
|
||||
plan := validPlan(t)
|
||||
plan.Checks = []Check{{Kind: CheckGoTest, Packages: []string{"./..."}}, {Kind: CheckGitDiffCheck}}
|
||||
result, err := Execute(context.Background(), plan, runner)
|
||||
if err != nil || result.Passed || result.Checks[0].Passed || result.EvidenceRef == "" {
|
||||
t.Fatalf("failed result was overclaimed: %+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePlanRejectsProtectedAndShellLikeInputs(t *testing.T) {
|
||||
protected := validPlan(t)
|
||||
protected.WorktreeID = "production"
|
||||
if !errors.Is(ValidatePlan(protected), ErrProtectedWorktree) {
|
||||
t.Fatal("protected worktree accepted")
|
||||
}
|
||||
injection := validPlan(t)
|
||||
injection.Checks = []Check{{Kind: CheckGoTest, Packages: []string{"./...; echo leaked"}}}
|
||||
if !errors.Is(ValidatePlan(injection), ErrInvalidPlan) {
|
||||
t.Fatal("shell-like package accepted")
|
||||
}
|
||||
}
|
||||
@@ -122,10 +122,10 @@ type compatibleChatResponse struct {
|
||||
}
|
||||
|
||||
func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" || len(request.Context) != 0 {
|
||||
if err := ValidateAdvisoryRequest(request); err != nil || request.ProviderModelID == "" {
|
||||
return cognitive.AdvisoryResponse{}, ErrInvalidAdvisory
|
||||
}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Provide non-canonical advisory text only. Never claim that an action occurred."}, {Role: "user", Content: request.Message}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
|
||||
payload := compatibleChatRequest{Model: string(request.ProviderModelID), Messages: []compatibleMessage{{Role: "system", Content: "Ты отвечаешь от имени Домового — локального помощника пользователя. Не называй себя именем модели, поставщиком или внутренним runtime. Если спрашивают имя, ответь: «Меня зовут Домовой». Понимай смысл сообщения и помогай довести цель до полезного результата. По умолчанию отвечай кратко, примерно одним-тремя предложениями; подробности давай только по просьбе. Отвечай только некатегоричным текстом совета; не утверждай, что внешнее действие уже выполнено."}, {Role: "user", Content: localAdvisoryMessage(request)}}, Temperature: 0, Stream: false, MaxTokens: request.MaxOutputBytes / 4, ChatTemplateKwargs: compatibleTemplateKwargs{EnableThinking: false}}
|
||||
var result compatibleChatResponse
|
||||
status, err := a.requestJSON(ctx, http.MethodPost, "/chat/completions", payload, &result)
|
||||
if err != nil {
|
||||
@@ -151,6 +151,19 @@ func (a *OpenAICompatibleLocalAdapter) Advise(ctx context.Context, request cogni
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func localAdvisoryMessage(request cognitive.AdvisoryRequest) string {
|
||||
if len(request.Context) == 0 {
|
||||
return request.Message
|
||||
}
|
||||
var builder strings.Builder
|
||||
builder.WriteString(request.Message)
|
||||
builder.WriteString("\n\nRelevant local context:\n")
|
||||
for _, excerpt := range request.Context {
|
||||
fmt.Fprintf(&builder, "- [%s/%s] %s\n", excerpt.SourceKind, excerpt.SourceID, excerpt.Text)
|
||||
}
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
func (a *OpenAICompatibleLocalAdapter) requestJSON(ctx context.Context, method, path string, input, output any) (int, error) {
|
||||
var body io.Reader
|
||||
if input != nil {
|
||||
|
||||
@@ -92,3 +92,32 @@ func TestOpenAICompatibleLocalAdapterDisablesTemplateThinkingForBoundedAdvisory(
|
||||
t.Fatal("expected bounded advisory to disable template thinking")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAICompatibleLocalAdapterPassesLocalContextToAdvisory(t *testing.T) {
|
||||
transport := &captureRoundTripper{}
|
||||
adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", transport)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = adapter.Advise(context.Background(), cognitive.AdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: "request-context", ActorID: "actor", PrincipalID: "principal", ScopeID: "scope",
|
||||
Message: "Продолжи разговор", Locale: "ru-RU", MaxOutputBytes: 256, ProviderBindingID: "binding-1", ProviderBindingRevision: 1,
|
||||
ProviderInstanceID: "provider-1", ModelDescriptorID: "model-1", ProviderModelID: capability.ProviderModelID("local-model"),
|
||||
Context: []cognitive.ContextExcerpt{{ID: "focus", SourceKind: "situation", SourceID: "focus-1", Revision: 1, Sensitivity: cognitive.SensitivityPrivateAllowed, Text: "Активный проект: Домовой"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload struct {
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(transport.body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(payload.Messages) != 2 || !strings.Contains(payload.Messages[1].Content, "Активный проект: Домовой") {
|
||||
t.Fatalf("local context was not passed to advisory: %+v", payload.Messages)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +437,24 @@ func (r *CapabilityRepository) LoadCognitiveCandidatesForProfile(ctx context.Con
|
||||
return r.loadCognitiveCandidates(ctx, capabilityID, owner, profileID)
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) LoadCognitiveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var capabilityID capability.CapabilityID
|
||||
var owner identity.PrincipalID
|
||||
if err := r.pool.QueryRow(ctx, `SELECT capability_id,owner_principal_id FROM capability.cognitive_bindings WHERE binding_id=$1 AND revision=$2`, bindingID, revision).Scan(&capabilityID, &owner); err != nil {
|
||||
return capability.ResolvedCognitiveBinding{}, err
|
||||
}
|
||||
candidates, err := r.loadCognitiveCandidates(ctx, capabilityID, owner, "")
|
||||
if err != nil {
|
||||
return capability.ResolvedCognitiveBinding{}, err
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate.Binding.ID == bindingID && candidate.Binding.Revision == revision {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return capability.ResolvedCognitiveBinding{}, pgx.ErrNoRows
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) loadCognitiveCandidates(ctx context.Context, capabilityID capability.CapabilityID, owner identity.PrincipalID, profileID capability.TaskEvaluationProfileID) ([]capability.ResolvedCognitiveBinding, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT
|
||||
b.binding_id,b.revision,b.capability_id,b.owner_principal_id,b.provider_instance_id,b.provider_revision,b.model_descriptor_id,b.model_revision,b.data_policy,b.qualification,b.activation,b.health,b.limit_policy_ref,
|
||||
@@ -487,6 +505,14 @@ func (r *CapabilityRepository) loadCognitiveEvidence(ctx context.Context, value
|
||||
if err == nil {
|
||||
value.Performance = &performance
|
||||
}
|
||||
var qualification capability.ModelQualificationObservation
|
||||
err = r.pool.QueryRow(ctx, `SELECT observation_id,provider_instance_id,provider_model_id,status,finish_reason,latency_millis,output_observed,quality_assessed,category,observed_at_unix_millis FROM capability.cognitive_model_qualification_observations WHERE provider_instance_id=$1 AND provider_model_id=$2 ORDER BY observed_at_unix_millis DESC LIMIT 1`, value.Provider.ID, value.Model.ProviderModelID).Scan(&qualification.ID, &qualification.ProviderInstanceID, &qualification.ProviderModelID, &qualification.Status, &qualification.FinishReason, &qualification.LatencyMillis, &qualification.OutputObserved, &qualification.QualityAssessed, &qualification.Category, &qualification.ObservedAtUnixMillis)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
value.Qualification = &qualification
|
||||
}
|
||||
if profileID != "" {
|
||||
var taskEvaluation capability.TaskEvaluation
|
||||
err = r.pool.QueryRow(ctx, `SELECT evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis FROM capability.cognitive_task_evaluations WHERE task_profile_id=$1 AND binding_id=$2 AND binding_revision=$3 ORDER BY observed_at_unix_millis DESC LIMIT 1`, profileID, value.Binding.ID, value.Binding.Revision).Scan(&taskEvaluation.ID, &taskEvaluation.ProfileID, &taskEvaluation.BindingID, &taskEvaluation.BindingRevision, &taskEvaluation.CapabilityID, &taskEvaluation.Result, &taskEvaluation.QualityScoreMillis, &taskEvaluation.ReliabilityScoreMillis, &taskEvaluation.LatencyScoreMillis, &taskEvaluation.OverallScoreMillis, &taskEvaluation.CriticalFailureMillis, &taskEvaluation.BenchmarkVersion, &taskEvaluation.ArtifactID, &taskEvaluation.ObservedAtUnixMillis)
|
||||
@@ -615,3 +641,8 @@ func (r *CapabilityRepository) SaveTaskEvaluation(ctx context.Context, value cap
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_task_evaluations (evaluation_id,task_profile_id,binding_id,binding_revision,capability_id,result,quality_score_millis,reliability_score_millis,latency_score_millis,overall_score_millis,critical_failure_millis,benchmark_version,artifact_id,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) ON CONFLICT DO NOTHING`, value.ID, value.ProfileID, value.BindingID, value.BindingRevision, value.CapabilityID, value.Result, value.QualityScoreMillis, value.ReliabilityScoreMillis, value.LatencyScoreMillis, value.OverallScoreMillis, value.CriticalFailureMillis, value.BenchmarkVersion, value.ArtifactID, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *CapabilityRepository) SaveModelQualificationObservation(ctx context.Context, value capability.ModelQualificationObservation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO capability.cognitive_model_qualification_observations (observation_id,provider_instance_id,provider_model_id,status,finish_reason,latency_millis,output_observed,quality_assessed,category,observed_at_unix_millis) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING`, value.ID, value.ProviderInstanceID, value.ProviderModelID, value.Status, value.FinishReason, value.LatencyMillis, value.OutputObserved, value.QualityAssessed, value.Category, value.ObservedAtUnixMillis)
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -11,6 +11,14 @@ import (
|
||||
|
||||
type EpistemicsRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
type TruthRuntimeStatus struct {
|
||||
BundleCount int64 `json:"bundle_count"`
|
||||
ClaimsByVerification map[string]int64 `json:"claims_by_verification"`
|
||||
BeliefsByState map[string]int64 `json:"beliefs_by_state"`
|
||||
SourcesByFreshness map[string]int64 `json:"sources_by_freshness"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func OpenEpistemics(ctx context.Context, config ContinuityConfig) (*EpistemicsRepository, error) {
|
||||
pool, err := openPool(ctx, config)
|
||||
if err != nil {
|
||||
@@ -25,6 +33,49 @@ func (r *EpistemicsRepository) Ping(ctx context.Context) error {
|
||||
return r.pool.Ping(ctx)
|
||||
}
|
||||
|
||||
// TruthRuntimeStatus is a read-only projection of durable truth bundles.
|
||||
// It does not promote claims, revise beliefs, or mutate epistemic state.
|
||||
func (r *EpistemicsRepository) TruthRuntimeStatus(ctx context.Context) (TruthRuntimeStatus, error) {
|
||||
status := TruthRuntimeStatus{
|
||||
ClaimsByVerification: map[string]int64{},
|
||||
BeliefsByState: map[string]int64{},
|
||||
SourcesByFreshness: map[string]int64{},
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := r.pool.QueryRow(ctx, `SELECT count(*) FROM epistemics.truth_bundles`).Scan(&status.BundleCount); err != nil {
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
queries := []struct {
|
||||
query string
|
||||
destination map[string]int64
|
||||
}{
|
||||
{`SELECT claim_verification, count(*) FROM epistemics.truth_bundles GROUP BY claim_verification`, status.ClaimsByVerification},
|
||||
{`SELECT belief_state, count(*) FROM epistemics.truth_bundles GROUP BY belief_state`, status.BeliefsByState},
|
||||
{`SELECT snapshot_freshness, count(*) FROM epistemics.truth_bundles GROUP BY snapshot_freshness`, status.SourcesByFreshness},
|
||||
}
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return TruthRuntimeStatus{}, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *EpistemicsRepository) SaveSystemExpectation(ctx context.Context, value epistemics.SystemExpectation) error {
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO epistemics.system_expectations (
|
||||
expectation_id, original_intent_id, source_utterance_id, expected_outcome, expected_effect,
|
||||
@@ -99,7 +150,7 @@ func (r *EpistemicsRepository) ClaimReflectionCandidate(ctx context.Context, wor
|
||||
var value epistemics.ReflectionCandidate
|
||||
err = tx.QueryRow(ctx, `SELECT reflection_candidate_id,trigger,state,source_experience_id,original_intent_id,source_utterance_id,summary,evidence_refs,requires_cognition,recorded_at_unix_millis,revision,claimed_by,claim_expires_at_unix_millis
|
||||
FROM epistemics.reflection_candidates
|
||||
WHERE state='OPEN' AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
|
||||
WHERE state='OPEN' AND requires_cognition=false AND (claim_expires_at_unix_millis=0 OR claim_expires_at_unix_millis < $1)
|
||||
ORDER BY recorded_at_unix_millis,reflection_candidate_id
|
||||
FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&value.ID, &value.Trigger, &value.State, &value.SourceExperienceID, &value.OriginalIntentID, &value.SourceUtteranceID, &value.Summary, &value.EvidenceRefs, &value.RequiresCognition, &value.RecordedAtUnixMillis, &value.Revision, &value.ClaimedBy, &value.ClaimExpiresAtUnixMillis)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package postgres
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
@@ -98,3 +99,22 @@ func (r *InteractionRepository) Utterances(ctx context.Context, id interaction.C
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RecentUtterancesForPrincipal provides durable conversational context when a
|
||||
// client starts a fresh conversation instead of carrying the previous ID.
|
||||
func (r *InteractionRepository) RecentUtterancesForPrincipal(ctx context.Context, principalID identity.PrincipalID, limit int) ([]interaction.HumanUtterance, error) {
|
||||
rows, err := r.pool.Query(ctx, `SELECT utterance_id,conversation_id,actor_id,principal_id,channel,received_at_unix_millis,correlation_id,content,content_ref,speech_act,interpretation_state,corrects_utterance_id FROM interaction.utterances WHERE principal_id=$1 ORDER BY received_at_unix_millis DESC, utterance_id DESC LIMIT $2`, principalID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []interaction.HumanUtterance
|
||||
for rows.Next() {
|
||||
var u interaction.HumanUtterance
|
||||
if err := rows.Scan(&u.ID, &u.ConversationID, &u.ActorID, &u.PrincipalID, &u.Channel, &u.ReceivedAtUnixMillis, &u.CorrelationID, &u.Content, &u.ContentRef, &u.SpeechAct, &u.InterpretationState, &u.CorrectsUtteranceID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,14 @@ package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -14,10 +18,11 @@ import (
|
||||
type WorkRepository struct{ pool *pgxpool.Pool }
|
||||
|
||||
var (
|
||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
|
||||
ErrDispatchIntentConflict = errors.New("dispatch intent idempotency conflict")
|
||||
ErrAgentLeaseUnavailable = errors.New("agent lease unavailable")
|
||||
ErrAgentCallbackRejected = errors.New("agent callback rejected")
|
||||
ErrDevelopmentGateConflict = errors.New("development gate revision conflict")
|
||||
ErrDevelopmentCandidateConflict = errors.New("development candidate conflict")
|
||||
)
|
||||
|
||||
func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, error) {
|
||||
@@ -27,6 +32,54 @@ func OpenWork(ctx context.Context, config ContinuityConfig) (*WorkRepository, er
|
||||
}
|
||||
return &WorkRepository{pool}, nil
|
||||
}
|
||||
|
||||
// WorkGraphRuntimeStatus is a read-only operational projection owned by Work.
|
||||
// It is not canonical graph state and cannot advance any lifecycle.
|
||||
type WorkGraphRuntimeStatus struct {
|
||||
GraphsByState map[string]int64 `json:"graphs_by_state"`
|
||||
NodesByState map[string]int64 `json:"nodes_by_state"`
|
||||
QueueByState map[string]int64 `json:"queue_by_state"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RuntimeStatus(ctx context.Context) (WorkGraphRuntimeStatus, error) {
|
||||
status := WorkGraphRuntimeStatus{
|
||||
GraphsByState: map[string]int64{},
|
||||
NodesByState: map[string]int64{},
|
||||
QueueByState: map[string]int64{},
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
queries := []struct {
|
||||
query string
|
||||
destination map[string]int64
|
||||
}{
|
||||
{`SELECT state, count(*) FROM work.graphs GROUP BY state`, status.GraphsByState},
|
||||
{`SELECT state, count(*) FROM work.graph_nodes GROUP BY state`, status.NodesByState},
|
||||
{`SELECT state, count(*) FROM work.agent_dispatch_queue GROUP BY state`, status.QueueByState},
|
||||
}
|
||||
for _, item := range queries {
|
||||
rows, err := r.pool.Query(ctx, item.query)
|
||||
if err != nil {
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var state string
|
||||
var count int64
|
||||
if err := rows.Scan(&state, &count); err != nil {
|
||||
rows.Close()
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
item.destination[state] = count
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return WorkGraphRuntimeStatus{}, err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
return status, nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) Close() { r.pool.Close() }
|
||||
func (r *WorkRepository) Ping(ctx context.Context) error { return r.pool.Ping(ctx) }
|
||||
func (r *WorkRepository) Create(ctx context.Context, target work.TaskTarget) error {
|
||||
@@ -100,33 +153,85 @@ func (r *WorkRepository) SaveDevelopmentGate(ctx context.Context, gate work.Deve
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveExtensionProposal(ctx context.Context, value work.ExtensionProposal) error {
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO work.extension_proposals(proposal_id,requested_by_principal_id,stage,evidence_refs,deduplicated_signal_keys,desired_outcome,smallest_change_layer,affected_owners,affected_contracts,risk_tier,evaluation_plan,alternatives,rollback_plan,assumptions,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(proposal_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.EvidenceRefs, value.DeduplicatedSignalKeys, value.DesiredOutcome, value.SmallestChangeLayer, value.AffectedOwners, value.AffectedContracts, value.RiskTier, value.EvaluationPlan, value.Alternatives, value.RollbackPlan, value.Assumptions, value.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrDevelopmentCandidateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDevelopmentScaffold(ctx context.Context, value work.DevelopmentScaffold) error {
|
||||
result, err := r.pool.Exec(ctx, `INSERT INTO work.development_scaffolds(scaffold_id,requested_by_principal_id,stage,proposal_ref,base_repo_revision,worktree_ref,task_intent,modules,allowed_paths,forbidden_paths,required_tests,artifact_lineage_refs,protected_main_access,production_access,secret_access,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(scaffold_id) DO NOTHING`, value.ID, value.RequestedByPrincipalID, value.Stage, value.ProposalRef, value.BaseRepoRevision, value.WorktreeRef, value.TaskIntent, value.Modules, value.AllowedPaths, value.ForbiddenPaths, value.RequiredTests, value.ArtifactLineageRefs, value.ProtectedMainAccess, value.ProductionAccess, value.SecretAccess, value.CreatedAtUnixMillis)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() != 1 {
|
||||
return ErrDevelopmentCandidateConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveDispatchIntent(ctx context.Context, intent work.AgentDispatchIntent) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis)
|
||||
profileJSON, casesJSON, err := marshalTaskPayload(intent.TaskEvaluationProfile, intent.TaskEvaluationCases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
executionJSON, err := json.Marshal(intent.TaskEvaluationExecution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO work.agent_dispatch_intents(dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ON CONFLICT(idempotency_key) DO NOTHING`, intent.DispatchID, intent.IdempotencyKey, intent.DelegationID, intent.RunID, intent.ImplementationID, intent.ImplementationRevision, intent.SourceRevision, intent.WorktreeID, intent.VerificationPlanRef, intent.CreatedAtUnixMillis, intent.SpecialistPurpose, intent.TaskEvaluationProfileID, intent.TaskEvaluationBindingID, intent.TaskEvaluationCaseIDs, profileJSON, casesJSON, executionJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save dispatch intent: %w", err)
|
||||
}
|
||||
var existing work.AgentDispatchIntent
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis)
|
||||
var profileJSONExisting, casesJSONExisting, executionJSONExisting []byte
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE idempotency_key=$1`, intent.IdempotencyKey).Scan(&existing.DispatchID, &existing.IdempotencyKey, &existing.DelegationID, &existing.RunID, &existing.ImplementationID, &existing.ImplementationRevision, &existing.SourceRevision, &existing.WorktreeID, &existing.VerificationPlanRef, &existing.CreatedAtUnixMillis, &existing.SpecialistPurpose, &existing.TaskEvaluationProfileID, &existing.TaskEvaluationBindingID, &existing.TaskEvaluationCaseIDs, &profileJSONExisting, &casesJSONExisting, &executionJSONExisting)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read dispatch intent: %w", err)
|
||||
}
|
||||
if existing != intent {
|
||||
if err := unmarshalTaskPayload(profileJSONExisting, casesJSONExisting, &existing.TaskEvaluationProfile, &existing.TaskEvaluationCases); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(executionJSONExisting) > 0 && string(executionJSONExisting) != "null" {
|
||||
if err := json.Unmarshal(executionJSONExisting, &existing.TaskEvaluationExecution); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !sameDispatchIntent(existing, intent) {
|
||||
return ErrDispatchIntentConflict
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func sameDispatchIntent(left, right work.AgentDispatchIntent) bool {
|
||||
return left.DispatchID == right.DispatchID && left.IdempotencyKey == right.IdempotencyKey && left.DelegationID == right.DelegationID && left.RunID == right.RunID && left.ImplementationID == right.ImplementationID && left.ImplementationRevision == right.ImplementationRevision && left.SourceRevision == right.SourceRevision && left.WorktreeID == right.WorktreeID && left.VerificationPlanRef == right.VerificationPlanRef && left.CreatedAtUnixMillis == right.CreatedAtUnixMillis && left.SpecialistPurpose == right.SpecialistPurpose && left.TaskEvaluationProfileID == right.TaskEvaluationProfileID && left.TaskEvaluationBindingID == right.TaskEvaluationBindingID && slices.Equal(left.TaskEvaluationCaseIDs, right.TaskEvaluationCaseIDs) && reflect.DeepEqual(left.TaskEvaluationProfile, right.TaskEvaluationProfile) && slices.Equal(left.TaskEvaluationCases, right.TaskEvaluationCases) && reflect.DeepEqual(left.TaskEvaluationExecution, right.TaskEvaluationExecution)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) LoadDispatchIntent(ctx context.Context, dispatchID string) (work.AgentDispatchIntent, error) {
|
||||
var intent work.AgentDispatchIntent
|
||||
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis)
|
||||
var profileJSON, casesJSON, executionJSON []byte
|
||||
err := r.pool.QueryRow(ctx, `SELECT dispatch_id,idempotency_key,delegation_id,run_id,implementation_id,implementation_revision,source_revision,worktree_id,verification_plan_ref,created_at_unix_millis,specialist_purpose,task_evaluation_profile_id,task_evaluation_binding_id,task_evaluation_case_ids,task_evaluation_profile,task_evaluation_cases,task_evaluation_execution FROM work.agent_dispatch_intents WHERE dispatch_id=$1`, dispatchID).Scan(&intent.DispatchID, &intent.IdempotencyKey, &intent.DelegationID, &intent.RunID, &intent.ImplementationID, &intent.ImplementationRevision, &intent.SourceRevision, &intent.WorktreeID, &intent.VerificationPlanRef, &intent.CreatedAtUnixMillis, &intent.SpecialistPurpose, &intent.TaskEvaluationProfileID, &intent.TaskEvaluationBindingID, &intent.TaskEvaluationCaseIDs, &profileJSON, &casesJSON, &executionJSON)
|
||||
if err != nil {
|
||||
return work.AgentDispatchIntent{}, fmt.Errorf("load dispatch intent: %w", err)
|
||||
}
|
||||
if err := unmarshalTaskPayload(profileJSON, casesJSON, &intent.TaskEvaluationProfile, &intent.TaskEvaluationCases); err != nil {
|
||||
return work.AgentDispatchIntent{}, err
|
||||
}
|
||||
if len(executionJSON) > 0 && string(executionJSON) != "null" {
|
||||
if err := json.Unmarshal(executionJSON, &intent.TaskEvaluationExecution); err != nil {
|
||||
return work.AgentDispatchIntent{}, err
|
||||
}
|
||||
}
|
||||
return intent, nil
|
||||
}
|
||||
|
||||
@@ -142,7 +247,7 @@ func (r *WorkRepository) ClaimNextDispatch(ctx context.Context, workerID string,
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var item work.AgentDispatchQueueItem
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||
err = tx.QueryRow(ctx, `SELECT dispatch_id,state,available_at_unix_millis,attempts,claimed_by,updated_at_unix_millis FROM work.agent_dispatch_queue WHERE state='QUEUED' AND available_at_unix_millis <= $1 AND attempts < retry_limit ORDER BY available_at_unix_millis,dispatch_id FOR UPDATE SKIP LOCKED LIMIT 1`, nowUnixMillis).Scan(&item.DispatchID, &item.State, &item.AvailableAtUnixMillis, &item.Attempts, &item.ClaimedBy, &item.UpdatedAtUnixMillis)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return work.AgentDispatchQueueItem{}, nil
|
||||
@@ -185,17 +290,36 @@ func (r *WorkRepository) CompleteDispatch(ctx context.Context, dispatchID, worke
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RetryDispatch(ctx context.Context, dispatchID, workerID string, nowUnixMillis int64, delay time.Duration) error {
|
||||
if delay < 0 {
|
||||
return fmt.Errorf("invalid retry delay")
|
||||
}
|
||||
availableAt := nowUnixMillis + delay.Milliseconds()
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE dispatch_id=$3 AND state='CLAIMED' AND claimed_by=$4`, availableAt, nowUnixMillis, dispatchID, workerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() != 1 {
|
||||
return ErrAgentCallbackRejected
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts) VALUES($1,$2,$3,$4,$5) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts); err != nil {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graphs(graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16) ON CONFLICT(graph_id,revision) DO NOTHING`, graph.ID, graph.Revision, graph.State, graph.Budget.MaxParallel, graph.Budget.MaxAttempts, graph.ParentGraphID, graph.ParentGraphRevision, graph.TriggerEvidenceRef, graph.SearchInputBound, graph.SearchInput.GoalID, graph.SearchInput.GoalState, graph.SearchInput.SearchRound, graph.SearchInput.MaxRound, graph.SearchInput.CancelRequested, graph.SearchInput.VerificationReady, graph.SearchInput.Unresolved); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, node := range graph.Nodes {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts); err != nil {
|
||||
profileJSON, casesJSON, err := marshalTaskPayload(node.TaskProfile, node.TaskCases)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO work.graph_nodes(graph_id,revision,node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) ON CONFLICT(graph_id,revision,node_id) DO NOTHING`, graph.ID, graph.Revision, node.ID, node.DispatchID, node.IdempotencyKey, node.State, node.Attempts, node.Purpose, node.TaskProfileID, node.BindingID, node.CaseIDs, profileJSON, casesJSON); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -209,17 +333,22 @@ func (r *WorkRepository) SaveWorkGraph(ctx context.Context, graph work.WorkGraph
|
||||
|
||||
func (r *WorkRepository) LoadWorkGraph(ctx context.Context, graphID string, revision int64) (work.WorkGraph, error) {
|
||||
var graph work.WorkGraph
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts)
|
||||
err := r.pool.QueryRow(ctx, `SELECT graph_id,revision,state,max_parallel,max_attempts,parent_graph_id,parent_graph_revision,trigger_evidence_ref,orchestration_bound,orchestration_goal_id,orchestration_goal_state,orchestration_search_round,orchestration_max_round,orchestration_cancel_requested,orchestration_verification_ready,orchestration_unresolved FROM work.graphs WHERE graph_id=$1 AND revision=$2`, graphID, revision).Scan(&graph.ID, &graph.Revision, &graph.State, &graph.Budget.MaxParallel, &graph.Budget.MaxAttempts, &graph.ParentGraphID, &graph.ParentGraphRevision, &graph.TriggerEvidenceRef, &graph.SearchInputBound, &graph.SearchInput.GoalID, &graph.SearchInput.GoalState, &graph.SearchInput.SearchRound, &graph.SearchInput.MaxRound, &graph.SearchInput.CancelRequested, &graph.SearchInput.VerificationReady, &graph.SearchInput.Unresolved)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, fmt.Errorf("load graph: %w", err)
|
||||
}
|
||||
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
|
||||
rows, err := r.pool.Query(ctx, `SELECT node_id,dispatch_id,idempotency_key,state,attempts,purpose,task_profile_id,binding_id,case_ids,task_evaluation_profile,task_evaluation_cases FROM work.graph_nodes WHERE graph_id=$1 AND revision=$2 ORDER BY node_id`, graphID, revision)
|
||||
if err != nil {
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var node work.GraphNode
|
||||
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts); err != nil {
|
||||
var profileJSON, casesJSON []byte
|
||||
if err := rows.Scan(&node.ID, &node.DispatchID, &node.IdempotencyKey, &node.State, &node.Attempts, &node.Purpose, &node.TaskProfileID, &node.BindingID, &node.CaseIDs, &profileJSON, &casesJSON); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
if err := unmarshalTaskPayload(profileJSON, casesJSON, &node.TaskProfile, &node.TaskCases); err != nil {
|
||||
rows.Close()
|
||||
return work.WorkGraph{}, err
|
||||
}
|
||||
@@ -270,11 +399,30 @@ func (r *WorkRepository) SaveGraphSynthesis(ctx context.Context, conflict work.C
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *WorkRepository) SaveGraphResultCandidate(ctx context.Context, candidate work.ResultCandidate) error {
|
||||
if candidate.ID == "" || candidate.GraphID == "" || candidate.GraphRevision < 1 || candidate.NodeID == "" || candidate.AuthorRunID == "" || candidate.EvidenceSummary == "" || candidate.Summary == "" {
|
||||
return errors.New("invalid graph result candidate")
|
||||
}
|
||||
_, err := r.pool.Exec(ctx, `INSERT INTO work.graph_result_candidates(result_candidate_id,graph_id,graph_revision,node_id,author_run_id,artifact_refs,evidence_summary,summary) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(result_candidate_id) DO NOTHING`, candidate.ID, candidate.GraphID, candidate.GraphRevision, candidate.NodeID, candidate.AuthorRunID, candidate.ArtifactRefs, candidate.EvidenceSummary, candidate.Summary)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *WorkRepository) RecoverStaleClaims(ctx context.Context, nowUnixMillis, staleAfterMillis int64) (int64, error) {
|
||||
command, err := r.pool.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state='QUEUED',claimed_by='',updated_at_unix_millis=$1 WHERE state='CLAIMED' AND updated_at_unix_millis <= $2`, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, `UPDATE work.agent_leases AS l SET state='EXPIRED' FROM work.agent_dispatch_queue AS q JOIN work.agent_dispatch_intents AS i ON i.dispatch_id=q.dispatch_id WHERE q.state='CLAIMED' AND q.updated_at_unix_millis <= $1 AND l.run_id=i.run_id AND l.state='ACTIVE'`, nowUnixMillis-staleAfterMillis); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
command, err := tx.Exec(ctx, `UPDATE work.agent_dispatch_queue SET state=CASE WHEN attempts >= retry_limit THEN 'FAILED' ELSE 'QUEUED' END,available_at_unix_millis=$1,claimed_by='',updated_at_unix_millis=$2 WHERE state='CLAIMED' AND updated_at_unix_millis <= $3`, nowUnixMillis, nowUnixMillis, nowUnixMillis-staleAfterMillis)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return command.RowsAffected(), nil
|
||||
}
|
||||
|
||||
@@ -565,3 +713,32 @@ func latestRowErr(err error) error {
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func marshalTaskPayload(profile *capability.TaskEvaluationProfile, cases []capability.TaskEvaluationCase) ([]byte, []byte, error) {
|
||||
var profileJSON []byte
|
||||
var err error
|
||||
if profile != nil {
|
||||
profileJSON, err = json.Marshal(profile)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
casesJSON, err := json.Marshal(cases)
|
||||
return profileJSON, casesJSON, err
|
||||
}
|
||||
|
||||
func unmarshalTaskPayload(profileJSON, casesJSON []byte, profile **capability.TaskEvaluationProfile, cases *[]capability.TaskEvaluationCase) error {
|
||||
if len(profileJSON) > 0 && string(profileJSON) != "null" {
|
||||
var value capability.TaskEvaluationProfile
|
||||
if err := json.Unmarshal(profileJSON, &value); err != nil {
|
||||
return err
|
||||
}
|
||||
*profile = &value
|
||||
}
|
||||
if len(casesJSON) > 0 && string(casesJSON) != "null" {
|
||||
if err := json.Unmarshal(casesJSON, cases); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"html"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
var ErrSearchRejected = errors.New("web search request rejected")
|
||||
|
||||
var (
|
||||
searchLinkPattern = regexp.MustCompile(`(?is)(<a[^>]+class=["'][^"']*result__a[^"']*["'][^>]*>)(.*?)</a>`)
|
||||
searchSnippetPattern = regexp.MustCompile(`(?is)<a[^>]+class=["'][^"']*result__snippet[^"']*["'][^>]*>(.*?)</a>`)
|
||||
bingLinkPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<h2[^>]*>(<a[^>]*>)(.*?)</a>`)
|
||||
bingSnippetPattern = regexp.MustCompile(`(?is)<li[^>]+class=["'][^"']*b_algo[^"']*["'][^>]*>.*?<p[^>]*>(.*?)</p>`)
|
||||
tagPattern = regexp.MustCompile(`(?s)<[^>]*>`)
|
||||
termPattern = regexp.MustCompile(`(?i)[\p{L}\p{N}]{2,}`)
|
||||
)
|
||||
|
||||
var searchStopTerms = map[string]struct{}{
|
||||
"какая": {}, "какое": {}, "какие": {}, "какой": {}, "что": {}, "как": {}, "где": {}, "когда": {}, "почему": {}, "мне": {}, "пожалуйста": {},
|
||||
"what": {}, "which": {}, "where": {}, "when": {}, "why": {}, "how": {}, "please": {}, "the": {}, "this": {},
|
||||
}
|
||||
|
||||
// SearchConnector uses a configured HTTPS search endpoint. It does not know
|
||||
// cities, weather terms, entities or model names: the complete semantic query
|
||||
// comes from the interpretation layer.
|
||||
type SearchConnector struct {
|
||||
Client *http.Client
|
||||
EndpointTemplate string
|
||||
AllowedHosts map[string]struct{}
|
||||
FallbackEndpoints []SearchEndpoint
|
||||
MaxBytes int64
|
||||
MaxResults int
|
||||
}
|
||||
|
||||
// SearchEndpoint is an independently allowlisted web-search route. The
|
||||
// connector tries routes in order and stops at the first relevant evidence.
|
||||
// This is a source-recovery policy, not a question-type classifier.
|
||||
type SearchEndpoint struct {
|
||||
Template string
|
||||
AllowedHosts map[string]struct{}
|
||||
}
|
||||
|
||||
func (c SearchConnector) Collect(ctx context.Context, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
|
||||
if request.Plan.Validate() != nil || strings.TrimSpace(request.Query) == "" || nowUnixMillis < 1 || !strings.Contains(c.EndpointTemplate, "%s") {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
endpoints := make([]SearchEndpoint, 0, 1+len(c.FallbackEndpoints))
|
||||
endpoints = append(endpoints, SearchEndpoint{Template: c.EndpointTemplate, AllowedHosts: c.AllowedHosts})
|
||||
endpoints = append(endpoints, c.FallbackEndpoints...)
|
||||
var lastErr error
|
||||
for _, candidateEndpoint := range endpoints {
|
||||
results, err := c.collectEndpoint(ctx, candidateEndpoint, request, nowUnixMillis)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
if lastErr != nil {
|
||||
return nil, lastErr
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c SearchConnector) collectEndpoint(ctx context.Context, configured SearchEndpoint, request epistemics.WebSearchRequest, nowUnixMillis int64) ([]epistemics.ResearchCandidate, error) {
|
||||
if !strings.Contains(configured.Template, "%s") {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
searchQuery := enrichedSearchQuery(request.Query)
|
||||
endpoint, err := url.Parse(strings.Replace(configured.Template, "%s", url.QueryEscape(searchQuery), 1))
|
||||
if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || !allowedHost(configured.AllowedHosts, strings.ToLower(endpoint.Hostname())) {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
client := c.Client
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
maxBytes := c.MaxBytes
|
||||
if maxBytes <= 0 || maxBytes > 4<<20 {
|
||||
maxBytes = 512 << 10
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
|
||||
if err != nil {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
// Search providers commonly return a consent/challenge shell to the
|
||||
// default Go client. A neutral browser identification improves the same
|
||||
// generic evidence path for every query without selecting by topic.
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; DomovoyResearch/1.0)")
|
||||
response, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(response.Body, maxBytes+1))
|
||||
if err != nil || len(body) == 0 || int64(len(body)) > maxBytes {
|
||||
return nil, ErrSearchRejected
|
||||
}
|
||||
links := searchLinkPattern.FindAllSubmatch(body, c.limit())
|
||||
snippets := searchSnippetPattern.FindAllSubmatch(body, c.limit())
|
||||
if len(links) == 0 {
|
||||
links = bingLinkPattern.FindAllSubmatch(body, c.limit())
|
||||
snippets = bingSnippetPattern.FindAllSubmatch(body, c.limit())
|
||||
}
|
||||
if len(links) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
digest := sha256.Sum256(body)
|
||||
digestText := "sha256:" + hex.EncodeToString(digest[:])
|
||||
base := hex.EncodeToString(digest[:])
|
||||
sourceID := epistemics.SourceID("source:search:" + base)
|
||||
snapshotID := epistemics.SourceSnapshotID("snapshot:search:" + base)
|
||||
results := make([]epistemics.ResearchCandidate, 0, len(links))
|
||||
for index, link := range links {
|
||||
linkURL := resolveSearchResultLocator(extractHref(string(link[1])), endpoint)
|
||||
title := cleanHTML(string(link[2]))
|
||||
snippet := ""
|
||||
if index < len(snippets) {
|
||||
snippet = cleanHTML(string(snippets[index][1]))
|
||||
}
|
||||
statement := strings.TrimSpace(strings.Trim(title+" — "+snippet, " —"))
|
||||
if statement == "" || !searchStatementRelatesToQuery(request.Query, statement) {
|
||||
continue
|
||||
}
|
||||
ordinal := strconv.Itoa(index)
|
||||
excerptID := epistemics.SourceExcerptID("excerpt:search:" + base + ":" + ordinal)
|
||||
results = append(results, epistemics.ResearchCandidate{
|
||||
PropositionKey: request.PropositionKey + ":result:" + ordinal,
|
||||
Statement: statement,
|
||||
Source: epistemics.Source{ID: sourceID, Kind: "web-search", Locator: linkURL, Title: title, Publisher: sourcePublisher(linkURL, endpoint.Hostname())},
|
||||
Snapshot: epistemics.SourceSnapshot{ID: snapshotID, SourceID: sourceID, ContentDigest: digestText, CapturedAtUnixMillis: nowUnixMillis, Freshness: epistemics.FreshnessCurrent},
|
||||
Excerpt: epistemics.SourceExcerpt{ID: excerptID, SnapshotID: snapshotID, Text: statement, Start: 0, End: int64(len(statement))},
|
||||
Evidence: epistemics.EvidenceObserved, Coverage: epistemics.CoverageComplete, Conflict: epistemics.ConflictNone,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// searchStatementRelatesToQuery is a bounded relevance gate, not a truth
|
||||
// check. It prevents generic search pages from becoming apparent evidence
|
||||
// when their content has no substantive relation to the requested query.
|
||||
func searchStatementRelatesToQuery(query, statement string) bool {
|
||||
terms := termPattern.FindAllString(strings.ToLower(query), -1)
|
||||
content := strings.ToLower(statement)
|
||||
productCue := containsSearchTerm(terms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
|
||||
if productCue && hasNumericSearchTerm(terms) {
|
||||
contentTerms := termPattern.FindAllString(content, -1)
|
||||
return hasNumericSearchTerm(contentTerms) && containsSearchTerm(contentTerms, "видеокарт", "gpu", "процессор", "телефон", "ноутбук", "монитор")
|
||||
}
|
||||
usable := 0
|
||||
for _, term := range terms {
|
||||
if _, stop := searchStopTerms[term]; stop || len([]rune(term)) < 4 {
|
||||
continue
|
||||
}
|
||||
usable++
|
||||
runes := []rune(term)
|
||||
prefixLen := 5
|
||||
if len(runes) < prefixLen {
|
||||
prefixLen = len(runes)
|
||||
}
|
||||
if strings.Contains(content, string(runes[:prefixLen])) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return usable == 0
|
||||
}
|
||||
|
||||
func containsSearchTerm(values []string, wanted ...string) bool {
|
||||
for _, value := range values {
|
||||
for _, candidate := range wanted {
|
||||
if strings.Contains(value, candidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasNumericSearchTerm(values []string) bool {
|
||||
for _, value := range values {
|
||||
for _, runeValue := range value {
|
||||
if runeValue >= '0' && runeValue <= '9' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func enrichedSearchQuery(query string) string {
|
||||
trimmed := strings.TrimSpace(query)
|
||||
lower := strings.ToLower(trimmed)
|
||||
if (strings.Contains(lower, "цен") || strings.Contains(lower, "стоим")) && !strings.Contains(lower, "куп") {
|
||||
return trimmed + " цена купить сегодня"
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func allowedHost(hosts map[string]struct{}, host string) bool { _, ok := hosts[host]; return ok }
|
||||
func (c SearchConnector) limit() int {
|
||||
if c.MaxResults <= 0 || c.MaxResults > 8 {
|
||||
return 3
|
||||
}
|
||||
return c.MaxResults
|
||||
}
|
||||
func cleanHTML(value string) string {
|
||||
return strings.Join(strings.Fields(html.UnescapeString(tagPattern.ReplaceAllString(value, ""))), " ")
|
||||
}
|
||||
|
||||
func extractHref(tag string) string {
|
||||
match := regexp.MustCompile(`(?is)\bhref=["']([^"']+)["']`).FindStringSubmatch(tag)
|
||||
if len(match) == 2 {
|
||||
return match[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolveSearchResultLocator(raw string, searchEndpoint *url.URL) string {
|
||||
value := strings.TrimSpace(html.UnescapeString(raw))
|
||||
if value == "" {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
parsed, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
if target := parsed.Query().Get("uddg"); target != "" {
|
||||
value = target
|
||||
parsed, err = url.Parse(target)
|
||||
if err != nil {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
}
|
||||
if target := parsed.Query().Get("u"); strings.HasPrefix(target, "a1") {
|
||||
if decoded, decodeErr := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(target, "a1")); decodeErr == nil {
|
||||
if targetURL, parseErr := url.Parse(string(decoded)); parseErr == nil {
|
||||
parsed = targetURL
|
||||
}
|
||||
}
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
if parsed.Host == "" && strings.HasPrefix(value, "//") {
|
||||
parsed, err = url.Parse("https:" + value)
|
||||
}
|
||||
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
|
||||
return searchEndpoint.String()
|
||||
}
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
func sourcePublisher(locator, fallback string) string {
|
||||
if parsed, err := url.Parse(locator); err == nil && parsed.Hostname() != "" {
|
||||
return parsed.Hostname()
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package research
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
func TestSearchConnectorFallsBackToAnotherAllowedSource(t *testing.T) {
|
||||
primary := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<html><body>no usable results</body></html>`))
|
||||
}))
|
||||
defer primary.Close()
|
||||
fallback := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`<a class="result__a" href="https://example.test">Казань: текущая температура 18 °C</a><a class="result__snippet">Наблюдается переменная облачность.</a>`))
|
||||
}))
|
||||
defer fallback.Close()
|
||||
client := fallback.Client()
|
||||
client.Transport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} // test servers only
|
||||
request := epistemics.WebSearchRequest{Plan: epistemics.ResearchPlan{ID: "plan:test", Question: "какая погода в Казани", SourceClasses: []string{"web-search"}, FreshnessPolicy: string(epistemics.FreshnessCurrent)}, PropositionKey: "question:test", Query: "какая погода в Казани"}
|
||||
connector := SearchConnector{
|
||||
Client: client,
|
||||
EndpointTemplate: primary.URL + "?q=%s",
|
||||
AllowedHosts: map[string]struct{}{strings.TrimPrefix(primary.URL, "https://"): {}},
|
||||
FallbackEndpoints: []SearchEndpoint{{
|
||||
Template: fallback.URL + "?q=%s",
|
||||
AllowedHosts: map[string]struct{}{strings.TrimPrefix(fallback.URL, "https://"): {}},
|
||||
}},
|
||||
}
|
||||
// The allowlist is hostname-only; test servers include an ephemeral port.
|
||||
connector.AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(primary.URL, "https://"), ":")[0]: {}}
|
||||
connector.FallbackEndpoints[0].AllowedHosts = map[string]struct{}{strings.Split(strings.TrimPrefix(fallback.URL, "https://"), ":")[0]: {}}
|
||||
results, err := connector.Collect(context.Background(), request, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 1 || !strings.Contains(results[0].Statement, "18") {
|
||||
t.Fatalf("fallback evidence = %#v", results)
|
||||
}
|
||||
if results[0].Source.Locator != "https://example.test" || results[0].Source.Publisher != "example.test" {
|
||||
t.Fatalf("search result source = %#v", results[0].Source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnrichedSearchQueryAddsPriceSignalsOnlyToPriceRequests(t *testing.T) {
|
||||
if got := enrichedSearchQuery("проверь цену на видеокарту 4090"); got != "проверь цену на видеокарту 4090 цена купить сегодня" {
|
||||
t.Fatalf("price query = %q", got)
|
||||
}
|
||||
if got := enrichedSearchQuery("какая погода в Казани"); got != "какая погода в Казани" {
|
||||
t.Fatalf("non-price query = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ The system already has the correct outbound mutation foundation: Commit owns aut
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Gmail, MCP, Codex Bridge, browser automation, mobile app, voice, or self-extension loop.
|
||||
- No Gmail, MCP, Codex Bridge, mobile app, voice, or self-extension loop. Browser automation is allowed only under ADR-0019 as bounded read-only observation; it is not a human-surface command or mutation channel.
|
||||
- No new canonical owner.
|
||||
- No generic scheduler/event-bus/autonomous-agent loop.
|
||||
- No new third-party dependency.
|
||||
|
||||
@@ -30,7 +30,7 @@ Examples include: a reminder after notification, waiting work requiring a decisi
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No Codex Bridge, Gmail, MCP, voice, mobile, browser automation, self-extension loop, or generalized orchestration.
|
||||
- No Codex Bridge, Gmail, MCP, voice, mobile, self-extension loop, or generalized orchestration. Browser observation may be used only through the bounded read-only capability defined by ADR-0019 and cannot continue or mutate a response target.
|
||||
- No universal object ontology.
|
||||
- No reminder-specific parallel subsystem.
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR-0019 — Bounded Site-Browser Observation
|
||||
|
||||
Status: Accepted
|
||||
|
||||
Task: DEV-000293
|
||||
|
||||
## Context
|
||||
|
||||
Search snippets are insufficient for dynamic pages such as marketplaces: they may expose a category page while hiding the current product price behind client-side rendering. Domovoy needs a replaceable observation capability that can inspect an allowlisted rendered page without becoming an account agent, universal scraper, or external mutation path.
|
||||
|
||||
## Decision
|
||||
|
||||
1. Browser automation is allowed only as a server-owned, read-only observation capability behind the existing Capability and research adapter boundaries.
|
||||
2. The browser runtime may navigate only to an explicit HTTPS host allowlist and bounded navigation policy. Browser-supplied or model-supplied authority, credentials, proxy settings, cookies, headers, download paths, or arbitrary JavaScript are rejected.
|
||||
3. The capability may load public pages and wait for bounded client-side rendering. It may read visible text and explicitly declared structured-data selectors, then return an Epistemics Observation with source URL, observed time, coverage, and failure state.
|
||||
4. Login, cookies, account state, CAPTCHA solving, checkout, form submission, uploads, downloads, purchases, messages, mutations, and external side effects are out of scope.
|
||||
5. A blocked page, challenge, timeout, partial DOM, missing price, or conflicting value remains `UNKNOWN`, `PARTIAL`, or `NOT_OBSERVED`; it never becomes a verified fact by model interpretation alone.
|
||||
6. Browser observation is optional. Search-only routing remains the fallback when the capability is unavailable, degraded, or not allowlisted for the requested host.
|
||||
7. Capability owns availability, host policy, health and limits. Epistemics owns the resulting observation and provenance. No new canonical owner or database schema is introduced.
|
||||
8. The first implementation may be development-only. Production activation requires independent capability health, allowlist evidence, adversarial navigation tests, and a live read-only smoke test.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- Core/domain code does not import a browser or provider runtime.
|
||||
- The browser adapter performs only bounded GET/navigation observation and cannot reach Commit/Executor.
|
||||
- Secrets remain outside the browser runtime and its artifacts, logs, screenshots, page content and chat context.
|
||||
- An observation is evidence for answer synthesis, not an instruction to mutate a site or canonical state.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Dynamic public pages can provide stronger evidence than search snippets when the site permits it.
|
||||
- Site-specific selectors and host policy are replaceable adapter configuration, not semantic domain logic.
|
||||
- Browser unavailability is a capability gap, not a runtime-wide outage.
|
||||
- The old blanket prohibition on browser automation is replaced by this narrower, testable boundary.
|
||||
@@ -24,7 +24,8 @@ func (s *memoryStore) SaveDispatchIntent(context.Context, work.AgentDispatchInte
|
||||
s.writes++
|
||||
return nil
|
||||
}
|
||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||
func (s *memoryStore) SaveAgentLease(context.Context, work.AgentLease) error { s.writes++; return nil }
|
||||
func (s *memoryStore) RenewAgentLease(context.Context, work.AgentLease, time.Time) error { return nil }
|
||||
func (s *memoryStore) AcceptAgentCallback(context.Context, work.AgentCallback) error {
|
||||
s.writes++
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type bridge struct {
|
||||
cognitiveURL string
|
||||
callbackURL string
|
||||
verifierURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func main() {
|
||||
b := bridge{
|
||||
cognitiveURL: strings.TrimRight(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), "/"),
|
||||
callbackURL: strings.TrimRight(envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), "/"),
|
||||
verifierURL: strings.TrimRight(envOr("TASK_VERIFIER_URL", "http://task-verifier:8091"), "/"),
|
||||
client: &http.Client{Timeout: 10 * time.Minute},
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", healthHandler)
|
||||
mux.HandleFunc("/readyz", healthHandler)
|
||||
mux.HandleFunc("/v1/execute", b.executeHandler)
|
||||
addr := envOr("LISTEN_ADDR", ":8092")
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (b bridge) executeHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var request work.AgentExecutionRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid execution request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := b.execute(r.Context(), request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (b bridge) execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
now := time.Now().UnixMilli()
|
||||
intent := request.Intent
|
||||
if intent.TaskEvaluationExecution != nil && intent.TaskEvaluationExecution.GenericMessage != "" {
|
||||
return b.executeGenericCognitiveWork(ctx, request, *intent.TaskEvaluationExecution)
|
||||
}
|
||||
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "локальный bridge не поддерживает это намерение; работа сохранена и ожидает подходящего исполнителя", ObservedAtUnixMillis: now}, nil
|
||||
}
|
||||
execution := intent.TaskEvaluationExecution
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "контекст task-evaluation неполон; работа сохранена для повторной маршрутизации", ObservedAtUnixMillis: now}, nil
|
||||
}
|
||||
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
|
||||
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, b.client, b.cognitiveURL+"/v1/task-evaluations/execute", payload)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if len(response.Responses) == 0 || response.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
|
||||
}
|
||||
caseByID := make(map[string]capability.TaskEvaluationCase, len(intent.TaskEvaluationCases))
|
||||
for _, item := range intent.TaskEvaluationCases {
|
||||
caseByID[item.CaseID] = item
|
||||
}
|
||||
observations := response.Observations
|
||||
if !hasCriticalFailure(observations) {
|
||||
observations = make([]capability.TaskCaseObservation, 0, len(response.Responses))
|
||||
for _, item := range response.Responses {
|
||||
caseValue, ok := caseByID[item.CaseID]
|
||||
if !ok || caseValue.Verification.Mode == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "task-evaluation case has no bounded verifier; work remains waiting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
verified, verifyErr := postJSON[capability.TaskCaseVerificationResponse](ctx, b.client, b.verifierURL+"/v1/task-evaluations/verify", capability.TaskCaseVerificationRequest{CaseID: item.CaseID, ResponseText: item.ResponseText, LatencyMicros: item.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef, Verification: caseValue.Verification})
|
||||
if verifyErr != nil {
|
||||
return work.AgentExecutionResult{}, verifyErr
|
||||
}
|
||||
observations = append(observations, capability.TaskCaseObservation{CaseID: verified.CaseID, Result: verified.Result, QualityScoreMillis: verified.QualityScoreMillis, LatencyMicros: verified.LatencyMicros, CriticalFailure: verified.CriticalFailure, EvidenceArtifactRef: verified.EvidenceArtifactRef, VerificationEvidenceRef: verified.VerificationEvidenceRef, VerifierIdentity: verified.VerifierIdentity})
|
||||
}
|
||||
}
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, b.client, b.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "local bridge executed task evaluation and persisted verifier callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func (b bridge) executeGenericCognitiveWork(ctx context.Context, request work.AgentExecutionRequest, execution work.TaskEvaluationDispatchContext) (work.AgentExecutionResult, error) {
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "generic cognitive work context is incomplete; durable work remains waiting for rerouting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
maxOutputBytes := execution.GenericMaxOutputBytes
|
||||
if maxOutputBytes < 1 {
|
||||
maxOutputBytes = 4096
|
||||
}
|
||||
dataPolicy := capability.DataPolicy(execution.GenericDataPolicy)
|
||||
if dataPolicy == "" {
|
||||
dataPolicy = capability.DataPolicyLocalOnly
|
||||
}
|
||||
response, err := postJSON[cognitive.GatewayAdvisoryResponse](ctx, b.client, b.cognitiveURL+"/v1/advisories", cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID("agent-work:" + request.Intent.DispatchID), ActorID: execution.ActorID,
|
||||
PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Message: execution.GenericMessage, Locale: execution.GenericLocale,
|
||||
MaxOutputBytes: maxOutputBytes, DataPolicy: dataPolicy, Meaning: cognitive.TaskMeaning{SpeechAct: "DIRECTIVE", RequestedOutcome: execution.GenericPurpose},
|
||||
})
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "" {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("generic cognitive work did not complete: %s", response.FinishReason)
|
||||
}
|
||||
candidateID := "candidate:" + request.Intent.DispatchID
|
||||
artifactRef := work.EvidenceRef("agent-output:" + request.Intent.DispatchID)
|
||||
verification, err := postJSON[work.GenericCandidateVerificationResponse](ctx, b.client, b.verifierURL+"/v1/work-graphs/candidates/verify", work.GenericCandidateVerificationRequest{
|
||||
GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID, AuthorRunID: request.Intent.RunID, CandidateID: candidateID, Summary: response.AdvisoryText, EvidenceRefs: []work.EvidenceRef{artifactRef},
|
||||
})
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if !verification.Verified || verification.VerificationEvidenceRef == "" || verification.VerifierIdentity == "" {
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionWaiting, Summary: "generic candidate envelope was not independently verified; work remains waiting", ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
candidate := work.GraphResultCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, Candidate: work.ResultCandidate{
|
||||
ID: candidateID, GraphID: execution.GraphID, GraphRevision: execution.GraphRevision, NodeID: execution.NodeID,
|
||||
AuthorRunID: request.Intent.RunID, ArtifactRefs: []work.EvidenceRef{artifactRef, work.EvidenceRef(verification.VerificationEvidenceRef)}, EvidenceSummary: "candidate attribution and evidence envelope independently verified; semantic content remains unverified",
|
||||
Summary: response.AdvisoryText,
|
||||
}}
|
||||
if _, err := postJSON[json.RawMessage](ctx, b.client, b.callbackURL+"/v1/work-graphs/result-callback", candidate); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "local cognitive specialist produced a candidate for independent verification", OutcomeRef: "candidate:" + request.Intent.DispatchID, ArtifactRefs: []string{"agent-output:" + request.Intent.DispatchID}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func hasCriticalFailure(observations []capability.TaskCaseObservation) bool {
|
||||
for _, observation := range observations {
|
||||
if observation.CriticalFailure {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
|
||||
var result T
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return result, nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func healthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"agent-execution-bridge","status":"ok","capabilities":["task-evaluation","generic-cognitive-work"]}`))
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestBridgeWaitsForUnsupportedIntent(t *testing.T) {
|
||||
b := bridge{client: http.DefaultClient}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-unsupported"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.State != work.AgentExecutionWaiting || result.ObservedAtUnixMillis < 1 {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthHandlerExposesBoundedCapability(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
healthHandler(recorder, request)
|
||||
if recorder.Code != http.StatusOK || recorder.Body.String() == "" {
|
||||
t.Fatalf("status=%d body=%q", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRunsTaskEvaluationThroughVerifierAndCallback(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
verification := capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/task-evaluations/execute":
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", ResponseText: "answer", LatencyMicros: 2, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
case "/v1/task-evaluations/verify":
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskCaseVerificationResponse{CaseID: "case-1", Result: capability.EvaluationSupported, QualityScoreMillis: 900, LatencyMicros: 2, EvidenceArtifactRef: "artifact-1", VerificationEvidenceRef: "verify-1", VerifierIdentity: "verifier-1"})
|
||||
case "/v1/work-graphs/task-evaluations/callback":
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, verifierURL: server.URL, client: server.Client()}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1", Verification: verification}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRunsGenericCognitiveWorkAsUnverifiedGraphCandidate(t *testing.T) {
|
||||
var callback work.GraphResultCallback
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/advisories":
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.Message != "find the bounded solution" {
|
||||
t.Fatalf("unexpected advisory request: %+v err=%v", request, err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "candidate answer", FinishReason: cognitive.FinishCompleted})
|
||||
case "/v1/work-graphs/result-callback":
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case "/v1/work-graphs/candidates/verify":
|
||||
_ = json.NewEncoder(w).Encode(work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:generic-1", VerifierIdentity: "deterministic-generic-envelope-verifier-v1", Summary: "envelope verified"})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
b := bridge{cognitiveURL: server.URL, callbackURL: server.URL, verifierURL: server.URL, client: server.Client()}
|
||||
result, err := b.execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{
|
||||
DispatchID: "dispatch-generic", RunID: "run-generic",
|
||||
TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1", GenericMessage: "find the bounded solution", GenericPurpose: "investigate", GenericDataPolicy: string(capability.DataPolicyLocalOnly)},
|
||||
}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || callback.Candidate.Summary != "candidate answer" || callback.Candidate.AuthorRunID != "run-generic" || len(callback.Candidate.ArtifactRefs) != 2 {
|
||||
t.Fatalf("result=%+v callback=%+v err=%v", result, callback, err)
|
||||
}
|
||||
}
|
||||
@@ -7,26 +7,26 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
// localFallbackExecutor keeps the durable work path alive when no external
|
||||
// executor is reachable. It never claims that an external action happened.
|
||||
// The work owner records the WAITING callback and can retry after a bridge is
|
||||
// localExecutor provides the bounded local execution capability. It never
|
||||
// claims that an unsupported or external action happened. The work owner
|
||||
// records the WAITING callback and can retry after a suitable executor is
|
||||
// configured or a human chooses the next step.
|
||||
type localFallbackExecutor struct {
|
||||
type localExecutor struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func newLocalFallbackExecutor() localFallbackExecutor {
|
||||
return localFallbackExecutor{now: time.Now}
|
||||
func newLocalExecutor() localExecutor {
|
||||
return localExecutor{now: time.Now}
|
||||
}
|
||||
|
||||
func (e localFallbackExecutor) Execute(_ context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
func (e localExecutor) Execute(_ context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
now := time.Now()
|
||||
if e.now != nil {
|
||||
now = e.now()
|
||||
}
|
||||
return work.AgentExecutionResult{
|
||||
State: work.AgentExecutionWaiting,
|
||||
Summary: "Внешний Agent Execution Bridge недоступен; локальный fallback сохранил durable работу и ожидает исполнителя.",
|
||||
Summary: "Локальный исполнитель не поддерживает эту операцию; durable работа сохранена и ожидает подходящего исполнителя.",
|
||||
ObservedAtUnixMillis: now.UnixMilli(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestLocalFallbackNeverClaimsExternalExecution(t *testing.T) {
|
||||
func TestLocalExecutorNeverClaimsUnsupportedExecution(t *testing.T) {
|
||||
when := time.UnixMilli(1_700_000_000_000)
|
||||
executor := localFallbackExecutor{now: func() time.Time { return when }}
|
||||
executor := localExecutor{now: func() time.Time { return when }}
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -19,6 +19,6 @@ func TestLocalFallbackNeverClaimsExternalExecution(t *testing.T) {
|
||||
t.Fatalf("unexpected fallback result: %+v", result)
|
||||
}
|
||||
if result.OutcomeRef != "" || result.Summary == "" {
|
||||
t.Fatalf("fallback must not claim an outcome: %+v", result)
|
||||
t.Fatalf("local executor must not claim an outcome: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,24 +42,29 @@ func main() {
|
||||
defer repository.Close()
|
||||
var executor ports.AgentExecutor
|
||||
localFallback := false
|
||||
if bridgeURL, bridgeConfigured := loadProtectedBridgeURL(); bridgeConfigured {
|
||||
if bridgeURL, bridgeConfigured := loadConfiguredBridgeURL(); bridgeConfigured {
|
||||
executor, err = agentbridge.NewHTTPExecutor(bridgeURL, &http.Client{Timeout: *leaseTTL})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
executor = newLocalFallbackExecutor()
|
||||
// The local executor keeps supported task-qualification work alive, but
|
||||
// it is not an external Agent Execution Bridge and must never be reported
|
||||
// as one. Unsupported work remains durably WAITING.
|
||||
localFallback = true
|
||||
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; local fallback preserves durable work without external mutation")
|
||||
executor = newTaskEvaluationExecutor(envOr("COGNITIVE_WORKER_URL", "http://cognitive-worker:8083"), envOr("WORK_GRAPH_RUNTIME_URL", "http://work-graph-runtime:8088"), envOr("TASK_VERIFIER_URL", "http://task-verifier:8091"), &http.Client{Timeout: *leaseTTL})
|
||||
fmt.Fprintln(os.Stderr, "protected agent execution bridge URL is not configured; task evaluations use the local durable evaluator, other work remains in fallback")
|
||||
}
|
||||
runtimeStatus := newAgentWorkerRuntimeStatus()
|
||||
go func() {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback)); serveErr != nil {
|
||||
if serveErr := http.ListenAndServe(*statusAddr, workerStatusHandler(executor != nil && !localFallback, localFallback, runtimeStatus)); serveErr != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent worker status server:", serveErr)
|
||||
}
|
||||
}()
|
||||
ctx := context.Background()
|
||||
for {
|
||||
runtimeStatus.cycleStarted(time.Now())
|
||||
var result application.WorkerCycleResult
|
||||
var cycleErr error
|
||||
if executor == nil {
|
||||
@@ -68,11 +73,19 @@ func main() {
|
||||
result, cycleErr = application.ExecuteWorkerCycleWithExecutor(ctx, repository, executor, *workerID, work.AgentFencingToken(1), time.Now().UTC(), *staleAfter, *leaseTTL)
|
||||
}
|
||||
if cycleErr != nil {
|
||||
runtimeStatus.cycleFailed(time.Now(), cycleErr)
|
||||
fmt.Fprintln(os.Stderr, cycleErr)
|
||||
if *once {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
cycleResult := "idle"
|
||||
if result.Completed {
|
||||
cycleResult = "completed"
|
||||
} else if result.RecoveredClaims > 0 {
|
||||
cycleResult = "recovered"
|
||||
}
|
||||
runtimeStatus.cycleFinished(time.Now(), result.RecoveredClaims, result.DispatchID, cycleResult)
|
||||
if cycleErr == nil && (result.Completed || result.RecoveredClaims > 0) {
|
||||
_ = json.NewEncoder(os.Stdout).Encode(result)
|
||||
}
|
||||
@@ -86,6 +99,19 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfiguredBridgeURL() (string, bool) {
|
||||
if bridgeURL, configured := loadProtectedBridgeURL(); configured {
|
||||
return bridgeURL, true
|
||||
}
|
||||
// The URL is routing configuration, not a credential. A protected file
|
||||
// remains preferred when an independently operated bridge is supplied.
|
||||
value := strings.TrimSpace(os.Getenv("AGENT_EXECUTION_BRIDGE_URL"))
|
||||
if value == "" || !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") {
|
||||
return "", false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func loadProtectedBridgeURL() (string, bool) {
|
||||
return loadProtectedBridgeURLFrom("/run/secrets", "/run/secrets/agent_execution_bridge_url")
|
||||
}
|
||||
@@ -107,6 +133,13 @@ func loadProtectedBridgeURLFrom(root, path string) (string, bool) {
|
||||
return value, true
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := strings.TrimSpace(os.Getenv(name)); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func resolveDatabasePassword(name, refName string, consumer secretcontracts.RuntimeConsumer) (string, error) {
|
||||
if _, ok := os.LookupEnv(name); ok {
|
||||
return "", fmt.Errorf("%s direct environment secret is forbidden", name)
|
||||
|
||||
+98
-16
@@ -3,35 +3,106 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type workerStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
ExecutorConfigured bool `json:"executor_configured"`
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
LocalFallback bool `json:"local_fallback"`
|
||||
Runtime string `json:"runtime"`
|
||||
ExecutorConfigured bool `json:"executor_configured"`
|
||||
Mode string `json:"mode"`
|
||||
Ready bool `json:"ready"`
|
||||
Reason string `json:"reason"`
|
||||
LocalFallback bool `json:"local_fallback"`
|
||||
LocalExecutor bool `json:"local_executor"`
|
||||
Capabilities []string `json:"capabilities,omitempty"`
|
||||
StartedAt time.Time `json:"started_at,omitempty"`
|
||||
LastCycleAt time.Time `json:"last_cycle_at,omitempty"`
|
||||
LastCompletedAt time.Time `json:"last_completed_at,omitempty"`
|
||||
LastRecoveredClaims int64 `json:"last_recovered_claims"`
|
||||
TotalRecoveredClaims int64 `json:"total_recovered_claims"`
|
||||
LastDispatchID string `json:"last_dispatch_id,omitempty"`
|
||||
LastResult string `json:"last_result,omitempty"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}
|
||||
|
||||
func workerStatusHandler(executorConfigured bool, localFallback ...bool) http.Handler {
|
||||
local := len(localFallback) > 0 && localFallback[0]
|
||||
status := workerStatusResponse{
|
||||
type agentWorkerRuntimeStatus struct {
|
||||
mu sync.RWMutex
|
||||
startedAt time.Time
|
||||
lastCycleAt time.Time
|
||||
lastCompletedAt time.Time
|
||||
lastRecoveredClaims int64
|
||||
totalRecoveredClaims int64
|
||||
lastDispatchID string
|
||||
lastResult string
|
||||
lastError string
|
||||
}
|
||||
|
||||
func newAgentWorkerRuntimeStatus() *agentWorkerRuntimeStatus {
|
||||
return &agentWorkerRuntimeStatus{startedAt: time.Now().UTC()}
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleStarted(at time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCycleAt = at.UTC()
|
||||
s.lastResult = "running"
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleFinished(at time.Time, recoveredClaims int64, dispatchID string, result string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompletedAt = at.UTC()
|
||||
s.lastRecoveredClaims = recoveredClaims
|
||||
s.totalRecoveredClaims += recoveredClaims
|
||||
s.lastDispatchID = dispatchID
|
||||
s.lastResult = result
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *agentWorkerRuntimeStatus) cycleFailed(at time.Time, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompletedAt = at.UTC()
|
||||
s.lastResult = "failed"
|
||||
s.lastError = err.Error()
|
||||
}
|
||||
|
||||
func workerStatusHandler(executorConfigured bool, localFallback ...interface{}) http.Handler {
|
||||
local := false
|
||||
var runtime *agentWorkerRuntimeStatus
|
||||
for _, argument := range localFallback {
|
||||
switch value := argument.(type) {
|
||||
case bool:
|
||||
local = value
|
||||
case *agentWorkerRuntimeStatus:
|
||||
runtime = value
|
||||
}
|
||||
}
|
||||
if runtime == nil {
|
||||
runtime = newAgentWorkerRuntimeStatus()
|
||||
}
|
||||
baseStatus := workerStatusResponse{
|
||||
Runtime: "agent-worker",
|
||||
ExecutorConfigured: executorConfigured,
|
||||
Mode: "recovery-only",
|
||||
Ready: false,
|
||||
Reason: "protected agent execution bridge URL is not configured",
|
||||
LocalFallback: local,
|
||||
LocalExecutor: local,
|
||||
}
|
||||
if local {
|
||||
status.Mode = "local-fallback"
|
||||
status.Reason = "external bridge unavailable; durable work is retained without external mutation"
|
||||
baseStatus.Mode = "local-executor"
|
||||
baseStatus.Ready = true
|
||||
baseStatus.Reason = "bounded local task-evaluation capability is ready; unsupported work remains durably waiting"
|
||||
baseStatus.Capabilities = []string{"task-evaluation"}
|
||||
}
|
||||
if executorConfigured {
|
||||
status.Mode = "execution"
|
||||
status.Ready = true
|
||||
status.Reason = "typed agent execution bridge configured"
|
||||
baseStatus.Mode = "execution"
|
||||
baseStatus.Ready = true
|
||||
baseStatus.Reason = "typed agent execution bridge configured"
|
||||
baseStatus.Capabilities = []string{"task-evaluation", "generic-cognitive-work"}
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || (r.URL.Path != "/healthz" && r.URL.Path != "/readyz" && r.URL.Path != "/status") {
|
||||
@@ -39,9 +110,20 @@ func workerStatusHandler(executorConfigured bool, localFallback ...bool) http.Ha
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if r.URL.Path == "/readyz" && !status.Ready {
|
||||
if r.URL.Path == "/readyz" && !baseStatus.Ready {
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(status)
|
||||
response := baseStatus
|
||||
runtime.mu.RLock()
|
||||
response.StartedAt = runtime.startedAt
|
||||
response.LastCycleAt = runtime.lastCycleAt
|
||||
response.LastCompletedAt = runtime.lastCompletedAt
|
||||
response.LastRecoveredClaims = runtime.lastRecoveredClaims
|
||||
response.TotalRecoveredClaims = runtime.totalRecoveredClaims
|
||||
response.LastDispatchID = runtime.lastDispatchID
|
||||
response.LastResult = runtime.lastResult
|
||||
response.LastError = runtime.lastError
|
||||
runtime.mu.RUnlock()
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoadProtectedBridgeURL(t *testing.T) {
|
||||
@@ -38,27 +39,76 @@ func TestWorkerStatusReportsRecoveryOnlyWithoutExecutor(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsLocalFallback(t *testing.T) {
|
||||
func TestWorkerStatusReportsLocalExecutorCapabilityReady(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Code != http.StatusOK || value.Ready || value.Mode != "local-fallback" || !value.LocalFallback {
|
||||
if response.Code != http.StatusOK || !value.Ready || value.Mode != "local-executor" || !value.LocalFallback || !value.LocalExecutor {
|
||||
t.Fatalf("unexpected status: code=%d value=%+v", response.Code, value)
|
||||
}
|
||||
if len(value.Capabilities) != 1 || value.Capabilities[0] != "task-evaluation" {
|
||||
t.Fatalf("unexpected local capabilities: %+v", value.Capabilities)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerReadyzRequiresExecutor(t *testing.T) {
|
||||
func TestWorkerStatusDoesNotCallLocalFallbackAnExternalExecutor(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.ExecutorConfigured || !value.Ready || !value.LocalFallback || !value.LocalExecutor || value.Mode != "local-executor" {
|
||||
t.Fatalf("local fallback was presented as external execution: %+v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerReadyzRequiresAnAdvertisedCapability(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(false, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("local capability readyz status=%d", response.Code)
|
||||
}
|
||||
response = httptest.NewRecorder()
|
||||
workerStatusHandler(true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("configured readyz status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsRecoveryCycle(t *testing.T) {
|
||||
runtime := newAgentWorkerRuntimeStatus()
|
||||
runtime.cycleStarted(time.Date(2026, 8, 23, 10, 0, 0, 0, time.UTC))
|
||||
runtime.cycleFinished(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), 3, "dispatch-1", "recovered")
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, true, runtime).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.LastRecoveredClaims != 3 || value.TotalRecoveredClaims != 3 || value.LastDispatchID != "dispatch-1" || value.LastResult != "recovered" {
|
||||
t.Fatalf("recovery evidence missing: %+v", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerStatusReportsCycleError(t *testing.T) {
|
||||
runtime := newAgentWorkerRuntimeStatus()
|
||||
runtime.cycleFailed(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), os.ErrClosed)
|
||||
response := httptest.NewRecorder()
|
||||
workerStatusHandler(false, runtime).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
var value workerStatusResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value.LastResult != "failed" || value.LastError == "" {
|
||||
t.Fatalf("cycle error missing: %+v", value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/artifact"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
capabilitydomain "git.cin.su/m/Assistant/modules/capability/domain"
|
||||
)
|
||||
|
||||
type taskEvaluationExecutor struct {
|
||||
cognitiveURL string
|
||||
callbackURL string
|
||||
verifierURL string
|
||||
client *http.Client
|
||||
fallback localExecutor
|
||||
}
|
||||
|
||||
func newTaskEvaluationExecutor(cognitiveURL, callbackURL, verifierURL string, client *http.Client) taskEvaluationExecutor {
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return taskEvaluationExecutor{cognitiveURL: strings.TrimRight(cognitiveURL, "/"), callbackURL: strings.TrimRight(callbackURL, "/"), verifierURL: strings.TrimRight(verifierURL, "/"), client: client, fallback: newLocalExecutor()}
|
||||
}
|
||||
|
||||
func (e taskEvaluationExecutor) Execute(ctx context.Context, request work.AgentExecutionRequest) (work.AgentExecutionResult, error) {
|
||||
intent := request.Intent
|
||||
if intent.TaskEvaluationExecution == nil || intent.TaskEvaluationProfile == nil || len(intent.TaskEvaluationCases) == 0 {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
execution := intent.TaskEvaluationExecution
|
||||
if execution.GraphID == "" || execution.NodeID == "" || execution.BindingRevision < 1 || execution.ActorID == "" || execution.PrincipalID == "" || execution.ScopeID == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
if e.cognitiveURL == "" || e.callbackURL == "" || e.verifierURL == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
payload := capability.TaskEvaluationExecutionRequest{Profile: *intent.TaskEvaluationProfile, CapabilityID: intent.TaskEvaluationProfile.CapabilityID, BindingID: intent.TaskEvaluationBindingID, BindingRevision: execution.BindingRevision, ActorID: execution.ActorID, PrincipalID: execution.PrincipalID, ScopeID: execution.ScopeID, Cases: intent.TaskEvaluationCases, MaxOutputBytes: 4096}
|
||||
response, err := postJSON[capability.TaskEvaluationExecutionResponse](ctx, e.client, e.cognitiveURL+"/v1/task-evaluations/execute", payload)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
if len(response.Responses) == 0 || response.ObservedAtUnixMillis < 1 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation returned no observations")
|
||||
}
|
||||
if len(response.Observations) == len(intent.TaskEvaluationCases) && taskEvaluationHasCriticalFailure(response.Observations) {
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, response.Observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation recorded as UNKNOWN after bounded provider recovery", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
if len(intent.TaskEvaluationCases) == 0 {
|
||||
return work.AgentExecutionResult{}, fmt.Errorf("task evaluation cases are missing")
|
||||
}
|
||||
caseByID := make(map[string]capability.TaskEvaluationCase, len(intent.TaskEvaluationCases))
|
||||
for _, item := range intent.TaskEvaluationCases {
|
||||
caseByID[item.CaseID] = item
|
||||
}
|
||||
observations := make([]capability.TaskCaseObservation, 0, len(response.Responses))
|
||||
for _, item := range response.Responses {
|
||||
caseValue, ok := caseByID[item.CaseID]
|
||||
if !ok || caseValue.Verification.Mode == "" {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
verified, verifyErr := postJSON[capability.TaskCaseVerificationResponse](ctx, e.client, e.verifierURL+"/v1/task-evaluations/verify", capability.TaskCaseVerificationRequest{CaseID: item.CaseID, ResponseText: item.ResponseText, LatencyMicros: item.LatencyMicros, EvidenceArtifactRef: item.EvidenceArtifactRef, Verification: caseValue.Verification})
|
||||
if verifyErr != nil {
|
||||
return e.fallback.Execute(ctx, request)
|
||||
}
|
||||
observations = append(observations, capability.TaskCaseObservation{CaseID: verified.CaseID, Result: verified.Result, QualityScoreMillis: verified.QualityScoreMillis, LatencyMicros: verified.LatencyMicros, CriticalFailure: verified.CriticalFailure, EvidenceArtifactRef: verified.EvidenceArtifactRef, VerificationEvidenceRef: verified.VerificationEvidenceRef, VerifierIdentity: verified.VerifierIdentity})
|
||||
}
|
||||
evaluation, err := capabilitydomain.BuildTaskEvaluation(*intent.TaskEvaluationProfile, capability.TaskEvaluationID("evaluation:"+intent.DispatchID), response.BindingID, response.BindingRevision, intent.TaskEvaluationProfile.CapabilityID, observations, artifact.ArtifactID(intent.TaskEvaluationCases[0].EvidenceArtifactRef), response.ObservedAtUnixMillis)
|
||||
if err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
callback := work.TaskEvaluationEvidenceCallback{GraphID: execution.GraphID, Revision: execution.GraphRevision, NodeID: execution.NodeID, Evaluation: evaluation, Succeeded: true}
|
||||
if _, err := postJSON[json.RawMessage](ctx, e.client, e.callbackURL+"/v1/work-graphs/task-evaluations/callback", callback); err != nil {
|
||||
return work.AgentExecutionResult{}, err
|
||||
}
|
||||
return work.AgentExecutionResult{State: work.AgentExecutionCompleted, Summary: "task evaluation executed, aggregated and accepted by Work Graph callback", OutcomeRef: string(evaluation.ID), ArtifactRefs: []string{string(evaluation.ArtifactID)}, ObservedAtUnixMillis: time.Now().UnixMilli()}, nil
|
||||
}
|
||||
|
||||
func taskEvaluationHasCriticalFailure(observations []capability.TaskCaseObservation) bool {
|
||||
for _, observation := range observations {
|
||||
if observation.CriticalFailure {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func postJSON[T any](ctx context.Context, client *http.Client, url string, payload any) (T, error) {
|
||||
var result T
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("POST %s: status %s", url, resp.Status)
|
||||
}
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return result, nil
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return result, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestTaskEvaluationExecutorInvokesAggregatesAndCallbacks(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-1", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
verification := capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/task-evaluations/execute" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", ResponseText: "answer", LatencyMicros: 2, EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/task-evaluations/verify" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskCaseVerificationResponse{CaseID: "case-1", Result: capability.EvaluationSupported, QualityScoreMillis: 900, LatencyMicros: 2, EvidenceArtifactRef: "artifact-1", VerificationEvidenceRef: "verify-1", VerifierIdentity: "verifier-1"})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
|
||||
var callback work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.GraphID != "graph-1" || callback.NodeID != "node-1" || callback.Evaluation.Result != capability.EvaluationSupported {
|
||||
http.Error(w, "invalid callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.URL, server.Client())
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-1", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1", Verification: verification}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEvaluationExecutorPersistsUnknownAfterProviderFailure(t *testing.T) {
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-unknown", Revision: 1, CapabilityID: "capability-1", Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "unknown-v1", RequiredCaseIDs: []string{"case-1"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/v1/task-evaluations/execute" {
|
||||
_ = json.NewEncoder(w).Encode(capability.TaskEvaluationExecutionResponse{ProfileID: profile.ID, BindingID: "binding-1", BindingRevision: 3, Observations: []capability.TaskCaseObservation{{CaseID: "case-1", Result: capability.EvaluationUnknown, CriticalFailure: true, EvidenceArtifactRef: "artifact-1"}}, Responses: []capability.TaskCaseResponse{{CaseID: "case-1", EvidenceArtifactRef: "artifact-1"}}, ObservedAtUnixMillis: 10})
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/work-graphs/task-evaluations/callback" {
|
||||
var callback work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(r.Body).Decode(&callback); err != nil || callback.Evaluation.Result != capability.EvaluationUnknown || !callback.Succeeded {
|
||||
http.Error(w, "invalid unknown callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/v1/task-evaluations/verify" {
|
||||
t.Errorf("verifier must not be called for a provider failure")
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
executor := newTaskEvaluationExecutor(server.URL, server.URL, server.URL, server.Client())
|
||||
result, err := executor.Execute(context.Background(), work.AgentExecutionRequest{Intent: work.AgentDispatchIntent{DispatchID: "dispatch-unknown", TaskEvaluationBindingID: "binding-1", TaskEvaluationProfile: &profile, TaskEvaluationCases: []capability.TaskEvaluationCase{{CaseID: "case-1", Prompt: "answer", EvidenceArtifactRef: "artifact-1"}}, TaskEvaluationExecution: &work.TaskEvaluationDispatchContext{GraphID: "graph-1", GraphRevision: 1, NodeID: "node-1", BindingRevision: 3, ActorID: "actor-1", PrincipalID: "principal-1", ScopeID: "scope-1"}}})
|
||||
if err != nil || result.State != work.AgentExecutionCompleted || result.OutcomeRef == "" {
|
||||
t.Fatalf("result=%+v err=%v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -17,6 +18,10 @@ type discoveryService struct {
|
||||
}
|
||||
|
||||
func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/v1/provider-discovery/status" {
|
||||
s.statusHandler(w, r)
|
||||
return
|
||||
}
|
||||
registration, ok := s.providers[r.PathValue("provider")]
|
||||
if !ok || registration.Discovery == nil {
|
||||
http.Error(w, "provider discovery not found", http.StatusNotFound)
|
||||
@@ -35,3 +40,42 @@ func (s discoveryService) handler(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
type discoveryStatus struct {
|
||||
Provider string `json:"provider"`
|
||||
ProviderKind capability.ProviderKind `json:"provider_kind"`
|
||||
Health capability.HealthState `json:"health"`
|
||||
FailureClass capability.CognitiveFailureType `json:"failure_class,omitempty"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Models []discoveredModelStatus `json:"models"`
|
||||
}
|
||||
|
||||
type discoveredModelStatus struct {
|
||||
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
|
||||
Fingerprint capability.ModelFingerprint `json:"fingerprint,omitempty"`
|
||||
Capabilities []capability.ModelCapability `json:"capabilities,omitempty"`
|
||||
}
|
||||
|
||||
func (s discoveryService) statusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
providers := make([]string, 0, len(s.providers))
|
||||
for name := range s.providers {
|
||||
providers = append(providers, name)
|
||||
}
|
||||
sort.Strings(providers)
|
||||
result := make([]discoveryStatus, 0, len(providers))
|
||||
for _, name := range providers {
|
||||
registration := s.providers[name]
|
||||
status := discoveryStatus{Provider: name, ProviderKind: registration.Kind, Health: capability.HealthUnknown, ObservedAtUnixMillis: s.now().UnixMilli(), Models: []discoveredModelStatus{}}
|
||||
if registration.Discovery != nil {
|
||||
models, probe := registration.Discovery.Discover(r.Context())
|
||||
status.Health = probe.Health
|
||||
status.FailureClass = capability.CognitiveFailureType(probe.Failure)
|
||||
for _, model := range models {
|
||||
status.Models = append(status.Models, discoveredModelStatus{ProviderModelID: model.ProviderModelID, Fingerprint: model.Fingerprint, Capabilities: model.Capabilities})
|
||||
}
|
||||
}
|
||||
result = append(result, status)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type discoveryTestProvider struct{}
|
||||
|
||||
func (discoveryTestProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (discoveryTestProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "local-model", Fingerprint: "fingerprint-1", Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (discoveryTestProvider) Advise(context.Context, cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
return cognitive.AdvisoryResponse{}, nil
|
||||
}
|
||||
|
||||
func TestDiscoveryStatusIsReadOnlyAndDoesNotExposeCredentialMetadata(t *testing.T) {
|
||||
service := discoveryService{
|
||||
providers: map[string]providerRegistration{"cpu": {ProviderInstanceID: "provider-1", Kind: "openai-compatible-chat-v1", CredentialRef: "credential://must-not-be-returned", Discovery: discoveryTestProvider{}}},
|
||||
owner: identity.PrincipalID("owner-1"),
|
||||
now: func() time.Time { return time.UnixMilli(1234) },
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
service.handler(response, httptest.NewRequest(http.MethodGet, "/v1/provider-discovery/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
var result []discoveryStatus
|
||||
if err := json.NewDecoder(strings.NewReader(body)).Decode(&result); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result) != 1 || result[0].Provider != "cpu" || result[0].Health != capability.HealthAvailable || len(result[0].Models) != 1 || result[0].ObservedAtUnixMillis != 1234 {
|
||||
t.Fatalf("unexpected discovery status: %+v", result)
|
||||
}
|
||||
if strings.Contains(body, "must-not-be-returned") || strings.Contains(body, "credential://") {
|
||||
t.Fatalf("credential metadata leaked in discovery status: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -31,35 +32,16 @@ type fleetStatusResponse struct {
|
||||
func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier, endpoints []fleetEndpoint) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
members := make([]capability.FleetMember, 0, len(endpoints))
|
||||
for _, endpoint := range endpoints {
|
||||
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
members = append(members, member)
|
||||
continue
|
||||
}
|
||||
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint.URL, nil)
|
||||
if err == nil {
|
||||
for name, value := range endpoint.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
probeClient := endpoint.Client
|
||||
if probeClient == nil {
|
||||
probeClient = client
|
||||
}
|
||||
response, requestErr := probeClient.Do(request)
|
||||
if requestErr == nil {
|
||||
response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
member.Ready, member.Reason = true, "AVAILABLE"
|
||||
} else {
|
||||
member.Reason = fmt.Sprintf("status=%s", response.Status)
|
||||
}
|
||||
} else {
|
||||
member.Reason = requestErr.Error()
|
||||
}
|
||||
}
|
||||
members = append(members, member)
|
||||
members = make([]capability.FleetMember, len(endpoints))
|
||||
var waitGroup sync.WaitGroup
|
||||
for index, endpoint := range endpoints {
|
||||
waitGroup.Add(1)
|
||||
go func(index int, endpoint fleetEndpoint) {
|
||||
defer waitGroup.Done()
|
||||
members[index] = probeFleetEndpoint(r.Context(), client, endpoint)
|
||||
}(index, endpoint)
|
||||
}
|
||||
waitGroup.Wait()
|
||||
readiness, err := fleetdomain.BuildFleetReadiness(time.Now().UnixMilli(), members)
|
||||
response := fleetStatusResponse{Readiness: readiness, Preferred: preferred}
|
||||
if err == nil {
|
||||
@@ -76,4 +58,37 @@ func fleetReadinessHandler(client *http.Client, preferred capability.FleetTier,
|
||||
})
|
||||
}
|
||||
|
||||
func probeFleetEndpoint(parent context.Context, fallbackClient *http.Client, endpoint fleetEndpoint) capability.FleetMember {
|
||||
member := capability.FleetMember{ProviderInstanceID: capability.ProviderInstanceID(endpoint.ProviderID), ModelDescriptorID: capability.ModelDescriptorID(endpoint.ModelID), Tier: endpoint.Tier, Reason: "NOT_CONFIGURED"}
|
||||
if strings.TrimSpace(endpoint.URL) == "" {
|
||||
return member
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.URL, nil)
|
||||
if err != nil {
|
||||
member.Reason = err.Error()
|
||||
return member
|
||||
}
|
||||
for name, value := range endpoint.Headers {
|
||||
request.Header.Set(name, value)
|
||||
}
|
||||
probeClient := endpoint.Client
|
||||
if probeClient == nil {
|
||||
probeClient = fallbackClient
|
||||
}
|
||||
response, requestErr := probeClient.Do(request)
|
||||
if requestErr != nil {
|
||||
member.Reason = requestErr.Error()
|
||||
return member
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 200 && response.StatusCode < 300 {
|
||||
member.Ready, member.Reason = true, "AVAILABLE"
|
||||
} else {
|
||||
member.Reason = fmt.Sprintf("status=%s", response.Status)
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
func fleetContext(ctx context.Context) context.Context { return ctx }
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
@@ -26,6 +27,29 @@ func TestFleetReadinessSelectsBestAvailablePrimaryTierBeforeLifeboat(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessDoesNotWaitForSlowMembersSerially(t *testing.T) {
|
||||
slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer slow.Close()
|
||||
fast := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }))
|
||||
defer fast.Close()
|
||||
handler := fleetReadinessHandler(fast.Client(), capability.FleetTierFast, []fleetEndpoint{
|
||||
{ProviderID: "slow", ModelID: "slow-model", Tier: capability.FleetTierMedium, URL: slow.URL},
|
||||
{ProviderID: "lifeboat", ModelID: "lifeboat-model", Tier: capability.FleetTierLifeboat, URL: fast.URL},
|
||||
})
|
||||
started := time.Now()
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/v1/fleet/readiness", nil))
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"selected_tier":"LIFEBOAT"`) {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed >= 300*time.Millisecond {
|
||||
t.Fatalf("fleet readiness was serialized: elapsed=%s", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFleetReadinessSendsConfiguredProviderHeaders(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer fleet-key" {
|
||||
|
||||
@@ -26,6 +26,10 @@ type bindingResolver interface {
|
||||
RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error)
|
||||
}
|
||||
|
||||
type candidateBindingResolver interface {
|
||||
ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error)
|
||||
}
|
||||
|
||||
type admissionClient interface {
|
||||
Admit(context.Context, string, capability.AdmissionRequest) (capability.AdmissionDecision, capability.AdmissionLease, error)
|
||||
Release(context.Context, capability.AdmissionLease) error
|
||||
@@ -182,27 +186,61 @@ func selectionObjectiveForMeaning(meaning cognitive.TaskMeaning, message string)
|
||||
}
|
||||
|
||||
func builtinAdvisory(request cognitive.GatewayAdvisoryRequest) cognitive.GatewayAdvisoryResponse {
|
||||
text := "Я рядом и продолжаю работу во встроенном режиме Домового. Внешний когнитивный маршрут сейчас недоступен, поэтому опираюсь на уже установленный смысл сообщения и локальные возможности."
|
||||
if request.Meaning.SpeechAct == "RESPONSE_REVIEW" {
|
||||
const marker = "Черновик ответа:"
|
||||
if index := strings.LastIndex(request.Message, marker); index >= 0 {
|
||||
if draft := strings.TrimSpace(request.Message[index+len(marker):]); draft != "" {
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: draft, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
}
|
||||
}
|
||||
text := "Понял."
|
||||
identityText := request.Meaning.RequestedOutcome
|
||||
if identityText == "" {
|
||||
identityText = request.Message
|
||||
}
|
||||
if request.Meaning.SpeechAct == "QUESTION" && isIdentityInquiry(identityText) {
|
||||
text = "Я — Домовой, ваш помощник."
|
||||
}
|
||||
switch request.Meaning.InformationRequestKind {
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
text = "Запрос понят как проверка моделей. Встроенный режим Домового доступен; я могу показать подключённые модели по назначению, качеству, скорости и стоимости. Ключи и пароли в разговор не нужны."
|
||||
text = "Сейчас не смог проверить подключённые модели."
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
text = "Запрос понят как проверка возможностей. Я могу объяснить доступные capabilities и отличить то, что уже подключено, от того, что пока является только планом или capability gap."
|
||||
text = "Сейчас не смог проверить доступные возможности."
|
||||
case interaction.InformationRequestCognitiveChannelStatus:
|
||||
text = "Запрос понят как проверка когнитивного канала. Локальный режим остаётся доступен; я объясню наблюдаемое состояние и что можно сделать без ожидания внешней модели."
|
||||
text = "Локальный канал Домового доступен; подробности состояния дам по запросу."
|
||||
case interaction.InformationRequestCognitiveRecovery:
|
||||
text = "Запрос понят как восстановление ИИ. Начинаю с локального маршрута: фиксирую, что недоступно, проверяю безопасные варианты восстановления и сформулирую следующий обратимый шаг."
|
||||
text = "Встроенный канал сейчас недоступен."
|
||||
default:
|
||||
switch request.Meaning.SpeechAct {
|
||||
case "REQUEST_TO_INVESTIGATE":
|
||||
text = "Я принял это как исследование. Даже без внешней модели могу сформулировать гипотезы, отделить известное от неизвестного и предложить следующий проверяемый шаг."
|
||||
text = "Понял задачу, но пока не смог получить проверяемый результат."
|
||||
case "QUESTION":
|
||||
text = "Я отвечаю во встроенном режиме Домового. Если для полного ответа не хватает внешней модели, объясню это прямо и продолжу с тем, что можно проверить локально."
|
||||
if !isIdentityInquiry(identityText) {
|
||||
text = "Пока не смог получить проверяемый ответ."
|
||||
}
|
||||
case "CONVERSATIONAL":
|
||||
text = "Я — Домовой, ваш помощник."
|
||||
}
|
||||
}
|
||||
return cognitive.GatewayAdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: text, FinishReason: cognitive.FinishCompleted}
|
||||
}
|
||||
|
||||
func isIdentityInquiry(message string) bool {
|
||||
tokens := strings.Fields(strings.ToLower(strings.NewReplacer("?", " ", "!", " ", ",", " ").Replace(message)))
|
||||
hasIdentitySubject := false
|
||||
hasAssistantReference := false
|
||||
for _, token := range tokens {
|
||||
switch token {
|
||||
case "кто", "зовут", "имя":
|
||||
hasIdentitySubject = true
|
||||
case "ты", "тебя", "тебе", "вы", "вас":
|
||||
hasAssistantReference = true
|
||||
}
|
||||
}
|
||||
return hasIdentitySubject && hasAssistantReference
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) invokeBuiltinAdvisory(ctx context.Context, request cognitive.GatewayAdvisoryRequest, maxOutputBytes int) cognitive.GatewayAdvisoryResponse {
|
||||
if g.builtinLocal != nil && g.builtinLocal.Provider != nil {
|
||||
invocation := cognitive.AdvisoryRequest{Version: request.Version, RequestID: request.RequestID, ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: request.Message, Context: request.Context, Locale: request.Locale, MaxOutputBytes: maxOutputBytes, ProviderBindingID: "builtin-local-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: "builtin-local-lifeboat-v1", ModelDescriptorID: "builtin-local-model-v1", ProviderModelID: "domovoy-lifeboat"}
|
||||
@@ -294,12 +332,21 @@ func fallbackEligible(reason cognitive.FinishReason) bool {
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) provider(selected capability.ResolvedCognitiveBinding) advisoryProvider {
|
||||
var byStableID advisoryProvider
|
||||
for _, registration := range g.providers[selected.Provider.Kind] {
|
||||
if registration.EndpointRef == selected.Provider.EndpointRef && registration.CredentialRef == selected.Provider.CredentialRef {
|
||||
return registration.Provider
|
||||
}
|
||||
// Capability revisions can legitimately retain an older credential
|
||||
// reference while the protected runtime has rotated or reloaded the
|
||||
// current credential. ProviderInstanceID is the stable owner identity;
|
||||
// use it after exact metadata matching so stale non-secret metadata does
|
||||
// not force an unnecessary builtin fallback.
|
||||
if byStableID == nil && registration.ProviderInstanceID == selected.Provider.ID && registration.Kind == selected.Provider.Kind {
|
||||
byStableID = registration.Provider
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return byStableID
|
||||
}
|
||||
|
||||
func writeGatewayResponse(w http.ResponseWriter, response cognitive.GatewayAdvisoryResponse) {
|
||||
@@ -341,6 +388,26 @@ func (r *httpBindingResolver) Resolve(ctx context.Context, value capability.Cogn
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *httpBindingResolver) ResolveCandidate(ctx context.Context, bindingID capability.CapabilityBindingID, revision int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
var result capability.ResolvedCognitiveBinding
|
||||
target := *r.base
|
||||
target.Path = "/v1/cognitive-bindings/" + url.PathEscape(string(bindingID)) + "/revisions/" + fmt.Sprint(revision) + "/snapshot"
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
response, err := r.client.Do(request)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return result, fmt.Errorf("candidate status %d", response.StatusCode)
|
||||
}
|
||||
err = json.NewDecoder(io.LimitReader(response.Body, 64<<10)).Decode(&result)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (r *httpBindingResolver) Observe(ctx context.Context, value capability.CognitiveUsageObservation) error {
|
||||
return r.post(ctx, "/v1/cognitive-usage-observations", value, nil)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,19 @@ func TestActiveRegistryBindingReachesProviderAdapter(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderUsesStableInstanceWhenCredentialMetadataIsStale(t *testing.T) {
|
||||
provider := &stubAdvisoryProvider{}
|
||||
gateway := cognitiveGateway{providers: map[capability.ProviderKind][]providerRegistration{
|
||||
"provider-kind-1": {{ProviderInstanceID: "provider-1", Kind: "provider-kind-1", EndpointRef: "current-endpoint", CredentialRef: "current-credential", Provider: provider}},
|
||||
}}
|
||||
selected := gatewaySelection()
|
||||
selected.Provider.EndpointRef = "old-endpoint"
|
||||
selected.Provider.CredentialRef = ""
|
||||
if got := gateway.provider(selected); got != provider {
|
||||
t.Fatalf("stable provider identity was not used for stale metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanaryFlagIsForwardedToCapabilityResolver(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("stop after resolve")}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{}, now: time.Now, allowCanary: true}
|
||||
@@ -172,11 +185,34 @@ func TestNoActiveBindingUsesBuiltInProviderWithoutInvocation(t *testing.T) {
|
||||
func TestBuiltInAdvisoryUsesTrustedInformationIntentInsteadOfMessageKeywords(t *testing.T) {
|
||||
request := cognitive.GatewayAdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", Message: "arbitrary text", Meaning: cognitive.TaskMeaning{InformationRequestKind: interaction.InformationRequestCognitiveRecovery}}
|
||||
response := builtinAdvisory(request)
|
||||
if !strings.Contains(response.AdvisoryText, "восстановление ИИ") {
|
||||
if response.AdvisoryText != "Встроенный канал сейчас недоступен." {
|
||||
t.Fatalf("unexpected recovery response: %q", response.AdvisoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltInAdvisoryIdentityIsNaturalAndDoesNotMentionResponseLength(t *testing.T) {
|
||||
response := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-identity",
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION", RequestedOutcome: "ты кто?"},
|
||||
})
|
||||
if response.AdvisoryText != "Я — Домовой, ваш помощник." {
|
||||
t.Fatalf("unexpected identity response: %q", response.AdvisoryText)
|
||||
}
|
||||
if strings.Contains(response.AdvisoryText, "коротко") || strings.Contains(response.AdvisoryText, "подробност") {
|
||||
t.Fatalf("identity response contains style meta-text: %q", response.AdvisoryText)
|
||||
}
|
||||
messageResponse := builtinAdvisory(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1,
|
||||
RequestID: "request-identity-message",
|
||||
Message: "как тебя зовут?",
|
||||
Meaning: cognitive.TaskMeaning{SpeechAct: "QUESTION"},
|
||||
})
|
||||
if messageResponse.AdvisoryText != "Я — Домовой, ваш помощник." {
|
||||
t.Fatalf("unexpected message identity response: %q", messageResponse.AdvisoryText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoActiveBindingInvokesBuiltInLocalProvider(t *testing.T) {
|
||||
registry := ®istryStub{err: errors.New("no active binding")}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: "request-1", AdvisoryText: "локальный ответ", FinishReason: cognitive.FinishCompleted}}
|
||||
|
||||
@@ -163,8 +163,8 @@ func interpretationPrompt(request cognitive.GatewayInterpretationRequest) string
|
||||
Allowed speech acts: DIRECTIVE, ASSERTION, DECISION, PREFERENCE, QUESTION, HYPOTHESIS, CORRECTION, REQUEST_TO_INVESTIGATE, CONFIRMATION, REJECTION, CANCELLATION, CONVERSATIONAL, UNKNOWN.
|
||||
Allowed applicability: APPLICABLE, LOW_CONFIDENCE, MIXED_INTENT, NEEDS_BROADER_INTERPRETATION, CLARIFICATION_REQUIRED.
|
||||
Allowed clarification: NOT_REQUIRED or REQUIRED.
|
||||
Capability hints may only contain known capability kinds like COGNITIVE_REASONING, MODIFY_CODE, RUN_TESTS, SEND_EMAIL, SCHEDULE_REMINDER, VERIFY_FACT, INSPECT_PROJECT, READ_DATABASE, QUERY_LOGS.
|
||||
Interpret the human utterance semantically. Do not claim action execution. Do not treat punctuation alone as decisive. If uncertain, set applicability accordingly.
|
||||
Capability hints may only contain known capability kinds like COGNITIVE_REASONING, WEB_RESEARCH, MODIFY_CODE, RUN_TESTS, SEND_EMAIL, SCHEDULE_REMINDER, VERIFY_FACT, INSPECT_PROJECT, READ_DATABASE, QUERY_LOGS.
|
||||
Interpret the human utterance semantically. Do not claim action execution. A question asking for a current, external or otherwise source-dependent fact should receive WEB_RESEARCH; do not answer that fact from model memory. A request to discuss hypotheses or options is different and may receive COGNITIVE_REASONING. Do not treat punctuation alone as decisive. If uncertain, set applicability accordingly.
|
||||
Message: %q
|
||||
Deterministic primary: %s
|
||||
Deterministic secondary: %s
|
||||
|
||||
@@ -97,7 +97,8 @@ func main() {
|
||||
if gpuErr != nil {
|
||||
log.Printf("optional GPU provider disabled: %v", gpuErr)
|
||||
} else if gpuConfigured {
|
||||
gpuAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKey(gpuURL, gpuKey, nil)
|
||||
gpuTimeout := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second)
|
||||
gpuAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout(gpuURL, gpuKey, nil, gpuTimeout)
|
||||
if adapterErr != nil {
|
||||
log.Printf("optional GPU provider disabled: %v", adapterErr)
|
||||
} else {
|
||||
@@ -125,7 +126,9 @@ func main() {
|
||||
}
|
||||
cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models"
|
||||
cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders)
|
||||
cpuHealthProbe.Timeout = 10 * time.Second
|
||||
// Opportunistic providers must not hide a ready local lifeboat. Keep their
|
||||
// readiness budget short; their detailed fleet state is reported separately.
|
||||
cpuHealthProbe.Timeout = 2 * time.Second
|
||||
mediumEndpoint := ""
|
||||
mediumHeaders := map[string]string{}
|
||||
mediumHealthProbe := health.Probe{}
|
||||
@@ -135,7 +138,7 @@ func main() {
|
||||
mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey
|
||||
}
|
||||
mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders)
|
||||
mediumHealthProbe.Timeout = 10 * time.Second
|
||||
mediumHealthProbe.Timeout = 2 * time.Second
|
||||
}
|
||||
readinessProbes := []health.Probe{
|
||||
health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"),
|
||||
@@ -148,7 +151,7 @@ func main() {
|
||||
if gpuConfigured {
|
||||
gpuHeaders := map[string]string{"Authorization": "Bearer " + gpuKey}
|
||||
gpuProbe := health.OptionalHTTPProbeWithHeaders(readinessClient, strings.TrimRight(gpuURL, "/")+"/models", gpuHeaders)
|
||||
gpuProbe.Timeout = 10 * time.Second
|
||||
gpuProbe.Timeout = 2 * time.Second
|
||||
readinessProbes = append(readinessProbes, gpuProbe)
|
||||
}
|
||||
|
||||
@@ -166,8 +169,11 @@ func main() {
|
||||
mux.HandleFunc("POST /v1/advisory-policy", gateway.policyHandler)
|
||||
mux.HandleFunc("POST /v1/advisories", gateway.advisoryHandler)
|
||||
mux.HandleFunc("POST /v1/interpretations", gateway.interpretationHandler)
|
||||
mux.HandleFunc("POST /v1/task-evaluations/execute", gateway.taskEvaluationHandler)
|
||||
discovery := discoveryService{resolver: resolver, providers: discoveries, owner: identity.PrincipalID(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), now: time.Now}
|
||||
mux.HandleFunc("POST /v1/provider-discovery/{provider}", discovery.handler)
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(discoveries, string(envOr("COGNITIVE_OWNER_PRINCIPAL_ID", "domovoy-creator-principal")), httpQualificationObserver{resolver: resolver}))
|
||||
mux.HandleFunc("GET /v1/provider-discovery/status", discovery.statusHandler)
|
||||
refreshProviderDiscoveries(resolver, discovery)
|
||||
log.Printf("cognitive-worker listening on %s", addr)
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
|
||||
@@ -19,6 +20,21 @@ func TestLifeboatProviderIsOptionalAndLocalAdapterBacked(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGPUProviderTimeoutUsesBoundedConfigurableDefault(t *testing.T) {
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 60*time.Second {
|
||||
t.Fatalf("default GPU timeout=%s", got)
|
||||
}
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "31s")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 31*time.Second {
|
||||
t.Fatalf("configured GPU timeout=%s", got)
|
||||
}
|
||||
t.Setenv("LOCAL_GPU_PROVIDER_TIMEOUT", "invalid")
|
||||
if got := durationEnv("LOCAL_GPU_PROVIDER_TIMEOUT", 60*time.Second); got != 60*time.Second {
|
||||
t.Fatalf("invalid GPU timeout=%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadLocalCPUProviderAPIKeyUsesProtectedFileReference(t *testing.T) {
|
||||
t.Setenv("LOCAL_CPU_PROVIDER_API_KEY", "")
|
||||
_ = os.Unsetenv("LOCAL_CPU_PROVIDER_API_KEY")
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type qualificationObserver interface {
|
||||
ObserveQualification(context.Context, capability.ModelQualificationObservation) error
|
||||
}
|
||||
|
||||
type httpQualificationObserver struct{ resolver *httpBindingResolver }
|
||||
|
||||
func (o httpQualificationObserver) ObserveQualification(ctx context.Context, value capability.ModelQualificationObservation) error {
|
||||
if o.resolver == nil {
|
||||
return fmt.Errorf("qualification observer resolver is nil")
|
||||
}
|
||||
return o.resolver.post(ctx, "/v1/cognitive-registry/model-qualification-observations", value, nil)
|
||||
}
|
||||
|
||||
type modelQualificationRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
MaxOutputBytes int `json:"max_output_bytes"`
|
||||
ModelIDs []capability.ProviderModelID `json:"model_ids,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResult struct {
|
||||
ProviderModelID capability.ProviderModelID `json:"provider_model_id"`
|
||||
Status string `json:"status"`
|
||||
FinishReason cognitive.FinishReason `json:"finish_reason,omitempty"`
|
||||
LatencyMillis int64 `json:"latency_millis"`
|
||||
OutputObserved bool `json:"output_observed"`
|
||||
QualityAssessed bool `json:"quality_assessed"`
|
||||
Category capability.ModelRatingCategory `json:"category"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type modelQualificationResponse struct {
|
||||
Provider capability.ProviderInstanceID `json:"provider"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
Results []modelQualificationResult `json:"results"`
|
||||
}
|
||||
|
||||
func modelQualificationHandler(providers map[string]providerRegistration, owner string, observers ...qualificationObserver) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("provider")
|
||||
registration, ok := providers[name]
|
||||
if !ok || registration.Discovery == nil || registration.Provider == nil {
|
||||
http.Error(w, "provider qualification not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var request modelQualificationRequest
|
||||
if !decodeJSON(w, r, 16<<10, &request) || request.Prompt == "" {
|
||||
return
|
||||
}
|
||||
if request.MaxOutputBytes < 1 {
|
||||
request.MaxOutputBytes = 512
|
||||
}
|
||||
if request.MaxOutputBytes > 4096 {
|
||||
http.Error(w, "max_output_bytes exceeds qualification bound", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
modelsFound, probe := registration.Discovery.Discover(r.Context())
|
||||
if probe.Health != capability.HealthAvailable || len(modelsFound) == 0 {
|
||||
http.Error(w, "provider unavailable for qualification", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if len(request.ModelIDs) > 0 {
|
||||
modelsFound = slices.DeleteFunc(modelsFound, func(model models.DiscoveredModel) bool {
|
||||
return !slices.Contains(request.ModelIDs, model.ProviderModelID)
|
||||
})
|
||||
}
|
||||
if len(modelsFound) == 0 {
|
||||
http.Error(w, "requested model not discovered", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
if len(modelsFound) > 16 {
|
||||
modelsFound = modelsFound[:16]
|
||||
}
|
||||
results := qualifyModels(r.Context(), registration, owner, request.Prompt, request.MaxOutputBytes, modelsFound)
|
||||
if len(observers) > 0 && observers[0] != nil {
|
||||
if err := persistQualificationResults(r.Context(), observers[0], registration, results); err != nil {
|
||||
http.Error(w, "persist model qualification observation", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = jsonEncode(w, modelQualificationResponse{Provider: registration.ProviderInstanceID, ObservedAtUnixMillis: time.Now().UnixMilli(), Results: results})
|
||||
})
|
||||
}
|
||||
|
||||
func qualifyModels(ctx context.Context, registration providerRegistration, owner, prompt string, maxOutputBytes int, discovered []models.DiscoveredModel) []modelQualificationResult {
|
||||
results := make([]modelQualificationResult, len(discovered))
|
||||
var wg sync.WaitGroup
|
||||
for index, model := range discovered {
|
||||
wg.Add(1)
|
||||
go func(index int, model models.DiscoveredModel) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
request := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("qualification:%s:%d", model.ProviderModelID, started.UnixNano())), ActorID: identity.ActorID("qualification-runtime"), PrincipalID: identity.PrincipalID(owner), ScopeID: authority.ScopeID("qualification-scope"), Message: prompt, Locale: "en-US", MaxOutputBytes: maxOutputBytes, ProviderBindingID: "qualification-binding-v1", ProviderBindingRevision: 1, ProviderInstanceID: registration.ProviderInstanceID, ModelDescriptorID: capability.ModelDescriptorID("qualification-" + string(model.ProviderModelID)), ProviderModelID: model.ProviderModelID}
|
||||
response, err := registration.Provider.Advise(ctx, request)
|
||||
result := modelQualificationResult{ProviderModelID: model.ProviderModelID, LatencyMillis: time.Since(started).Milliseconds(), QualityAssessed: false, Category: capability.ModelRatingUnrated}
|
||||
if err != nil {
|
||||
result.Status = "UNAVAILABLE"
|
||||
result.FinishReason = cognitive.FinishProviderUnavailable
|
||||
result.Error = err.Error()
|
||||
results[index] = result
|
||||
return
|
||||
}
|
||||
result.FinishReason = response.FinishReason
|
||||
result.OutputObserved = response.AdvisoryText != ""
|
||||
if response.FinishReason == cognitive.FinishCompleted && result.OutputObserved {
|
||||
result.Status = "REACHABLE"
|
||||
} else {
|
||||
result.Status = "RESPONDED_WITHOUT_QUALIFICATION"
|
||||
}
|
||||
results[index] = result
|
||||
}(index, model)
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
func persistQualificationResults(ctx context.Context, observer qualificationObserver, registration providerRegistration, results []modelQualificationResult) error {
|
||||
for _, result := range results {
|
||||
observationID, err := ids.New()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
status := capability.ModelQualificationRespondedWithoutRating
|
||||
switch result.Status {
|
||||
case "REACHABLE":
|
||||
status = capability.ModelQualificationReachable
|
||||
case "UNAVAILABLE":
|
||||
status = capability.ModelQualificationUnavailable
|
||||
}
|
||||
observation := capability.ModelQualificationObservation{
|
||||
ID: capability.ModelQualificationObservationID(observationID), ProviderInstanceID: registration.ProviderInstanceID, ProviderModelID: result.ProviderModelID,
|
||||
Status: status, FinishReason: string(result.FinishReason), LatencyMillis: result.LatencyMillis,
|
||||
OutputObserved: result.OutputObserved, QualityAssessed: false, Category: capability.ModelRatingUnrated,
|
||||
ObservedAtUnixMillis: time.Now().UnixMilli(),
|
||||
}
|
||||
if err := observation.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := observer.ObserveQualification(ctx, observation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonEncode(w http.ResponseWriter, value any) error {
|
||||
return json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/models"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
type qualificationProvider struct{}
|
||||
|
||||
func (qualificationProvider) Probe(context.Context) models.ProviderProbe {
|
||||
return models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Discover(context.Context) ([]models.DiscoveredModel, models.ProviderProbe) {
|
||||
return []models.DiscoveredModel{{ProviderModelID: "model-a"}, {ProviderModelID: "model-b"}}, models.ProviderProbe{Health: capability.HealthAvailable}
|
||||
}
|
||||
func (qualificationProvider) Advise(_ context.Context, request cognitive.AdvisoryRequest) (cognitive.AdvisoryResponse, error) {
|
||||
if err := models.ValidateAdvisoryRequest(request); err != nil {
|
||||
return cognitive.AdvisoryResponse{}, err
|
||||
}
|
||||
return cognitive.AdvisoryResponse{Version: request.Version, RequestID: request.RequestID, AdvisoryText: "reachable", FinishReason: cognitive.FinishCompleted}, nil
|
||||
}
|
||||
|
||||
func TestModelQualificationUsesDiscoveredModelsAndLeavesQualityUnrated(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"gpu": {ProviderInstanceID: "gpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
server := httptest.NewServer(mux)
|
||||
defer server.Close()
|
||||
request := httptest.NewRequest(http.MethodPost, server.URL+"/v1/provider-discovery/gpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":128}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("qualification status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"status":"REACHABLE"`) || !strings.Contains(response.Body.String(), `"category":"UNRATED"`) || strings.Contains(response.Body.String(), `"quality_assessed":true`) {
|
||||
t.Fatalf("qualification overclaimed quality: %s", response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelQualificationRejectsUnboundedOutput(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"cpu": {ProviderInstanceID: "cpu", Provider: provider, Discovery: provider}}, "owner"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/provider-discovery/cpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":8192}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("unbounded qualification status=%d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type qualificationObserverStub struct {
|
||||
observations []capability.ModelQualificationObservation
|
||||
}
|
||||
|
||||
func (s *qualificationObserverStub) ObserveQualification(_ context.Context, value capability.ModelQualificationObservation) error {
|
||||
s.observations = append(s.observations, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestModelQualificationPersistsReachabilityWithoutQuality(t *testing.T) {
|
||||
provider := qualificationProvider{}
|
||||
observer := &qualificationObserverStub{}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("POST /v1/provider-discovery/{provider}/qualify", modelQualificationHandler(map[string]providerRegistration{"cpu": {ProviderInstanceID: "cpu", Provider: provider, Discovery: provider}}, "owner", observer))
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/provider-discovery/cpu/qualify", strings.NewReader(`{"prompt":"ready","max_output_bytes":128,"model_ids":["model-a"]}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response := httptest.NewRecorder()
|
||||
mux.ServeHTTP(response, request)
|
||||
if response.Code != http.StatusOK || len(observer.observations) != 1 {
|
||||
t.Fatalf("qualification persistence status=%d observations=%d body=%s", response.Code, len(observer.observations), response.Body.String())
|
||||
}
|
||||
if err := observer.observations[0].Validate(); err != nil || observer.observations[0].Status != capability.ModelQualificationReachable {
|
||||
t.Fatalf("invalid qualification observation: %+v err=%v", observer.observations[0], err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
)
|
||||
|
||||
func (g cognitiveGateway) taskEvaluationHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request capability.TaskEvaluationExecutionRequest
|
||||
if !decodeJSON(w, r, 256<<10, &request) {
|
||||
return
|
||||
}
|
||||
if err := request.Profile.Validate(); err != nil || request.CapabilityID == "" || request.CapabilityID != request.Profile.CapabilityID || request.BindingID == "" || request.BindingRevision < 1 || request.ActorID == "" || request.PrincipalID == "" || request.ScopeID == "" || request.MaxOutputBytes < 1 || len(request.Cases) != len(request.Profile.RequiredCaseIDs) {
|
||||
http.Error(w, "invalid task evaluation execution request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
resolver, ok := g.resolver.(candidateBindingResolver)
|
||||
if !ok {
|
||||
http.Error(w, "exact candidate resolver unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
selected, err := resolver.ResolveCandidate(r.Context(), request.BindingID, request.BindingRevision)
|
||||
if err != nil || selected.Binding.ID != request.BindingID || selected.Binding.Revision != request.BindingRevision || selected.Binding.CapabilityID != request.CapabilityID || selected.Binding.OwnerPrincipalID != request.PrincipalID {
|
||||
http.Error(w, "cognitive binding snapshot unavailable", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if selected.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged {
|
||||
http.Error(w, "external egress acknowledgement required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
caseSet := make(map[string]struct{}, len(request.Profile.RequiredCaseIDs))
|
||||
seenCases := make(map[string]struct{}, len(request.Cases))
|
||||
for _, id := range request.Profile.RequiredCaseIDs {
|
||||
caseSet[id] = struct{}{}
|
||||
}
|
||||
for _, item := range request.Cases {
|
||||
if item.CaseID == "" || item.Prompt == "" || item.EvidenceArtifactRef == "" {
|
||||
http.Error(w, "invalid task evaluation case", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, exists := caseSet[item.CaseID]; !exists {
|
||||
http.Error(w, "task evaluation case is not in profile", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if _, exists := seenCases[item.CaseID]; exists {
|
||||
http.Error(w, "duplicate task evaluation case", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
seenCases[item.CaseID] = struct{}{}
|
||||
}
|
||||
if len(seenCases) != len(caseSet) {
|
||||
http.Error(w, "task evaluation case set is incomplete", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// One bounded fan-out wave uses one binding snapshot. If that candidate is
|
||||
// unavailable, only this binding is excluded and the existing resolver
|
||||
// chooses the next eligible candidate. Provider output remains untrusted
|
||||
// until the independent verifier accepts it downstream.
|
||||
requirement := capability.CognitiveRequirement{CapabilityID: request.CapabilityID, OwnerPrincipalID: request.PrincipalID, DataPolicy: selected.Binding.DataPolicy, RequiredModalities: []capability.ModelModality{capability.ModalityText}, RequiredCapabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, AllowCanary: g.allowCanary, MaxOutputBytes: request.MaxOutputBytes}
|
||||
var observations []capability.TaskCaseObservation
|
||||
var responses []capability.TaskCaseResponse
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
provider := g.provider(selected)
|
||||
if provider == nil {
|
||||
observations, responses = taskEvaluationUnavailable(request.Cases)
|
||||
} else {
|
||||
observations, responses = g.executeTaskEvaluationCases(r.Context(), request, selected, provider)
|
||||
}
|
||||
if taskEvaluationResponsesUsable(responses) {
|
||||
break
|
||||
}
|
||||
requirement.ExcludedBindingIDs = append(requirement.ExcludedBindingIDs, selected.Binding.ID)
|
||||
fallback, resolveErr := g.resolver.Resolve(r.Context(), requirement)
|
||||
fallbackProvider := g.provider(fallback)
|
||||
if resolveErr != nil || fallbackProvider == nil || fallback.Binding.ID == selected.Binding.ID || (fallback.Binding.DataPolicy != capability.DataPolicyLocalOnly && !request.EgressAcknowledged) {
|
||||
break
|
||||
}
|
||||
selected = fallback
|
||||
}
|
||||
|
||||
writeTaskEvaluationJSON(w, capability.TaskEvaluationExecutionResponse{ProfileID: request.Profile.ID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, Observations: observations, Responses: responses, ObservedAtUnixMillis: time.Now().UnixMilli()})
|
||||
}
|
||||
|
||||
func (g cognitiveGateway) executeTaskEvaluationCases(ctx context.Context, request capability.TaskEvaluationExecutionRequest, selected capability.ResolvedCognitiveBinding, provider advisoryProvider) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
||||
observations := make([]capability.TaskCaseObservation, len(request.Cases))
|
||||
responses := make([]capability.TaskCaseResponse, len(request.Cases))
|
||||
var wg sync.WaitGroup
|
||||
for i, item := range request.Cases {
|
||||
wg.Add(1)
|
||||
go func(index int, item capability.TaskEvaluationCase) {
|
||||
defer wg.Done()
|
||||
started := time.Now()
|
||||
invocation := cognitive.AdvisoryRequest{Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(fmt.Sprintf("task-evaluation:%s:%s", request.Profile.ID, item.CaseID)), ActorID: request.ActorID, PrincipalID: request.PrincipalID, ScopeID: request.ScopeID, Message: item.Prompt, MaxOutputBytes: int(request.MaxOutputBytes), ProviderBindingID: selected.Binding.ID, ProviderBindingRevision: selected.Binding.Revision, ProviderInstanceID: selected.Provider.ID, ModelDescriptorID: selected.Model.ID, ProviderModelID: selected.Model.ProviderModelID}
|
||||
response, invokeErr := g.invokeAdmitted(ctx, string(invocation.RequestID), provider, invocation, int(request.MaxOutputBytes))
|
||||
latency := time.Since(started).Microseconds()
|
||||
observations[index] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: invokeErr != nil || response.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(response.AdvisoryText) == "", EvidenceArtifactRef: item.EvidenceArtifactRef, LatencyMicros: latency}
|
||||
responses[index] = capability.TaskCaseResponse{CaseID: item.CaseID, ResponseText: response.AdvisoryText, LatencyMicros: latency, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
}(i, item)
|
||||
}
|
||||
wg.Wait()
|
||||
return observations, responses
|
||||
}
|
||||
|
||||
func taskEvaluationUnavailable(cases []capability.TaskEvaluationCase) ([]capability.TaskCaseObservation, []capability.TaskCaseResponse) {
|
||||
observations := make([]capability.TaskCaseObservation, len(cases))
|
||||
responses := make([]capability.TaskCaseResponse, len(cases))
|
||||
for i, item := range cases {
|
||||
observations[i] = capability.TaskCaseObservation{CaseID: item.CaseID, Result: capability.EvaluationUnknown, CriticalFailure: true, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
responses[i] = capability.TaskCaseResponse{CaseID: item.CaseID, EvidenceArtifactRef: item.EvidenceArtifactRef}
|
||||
}
|
||||
return observations, responses
|
||||
}
|
||||
|
||||
func taskEvaluationResponsesUsable(responses []capability.TaskCaseResponse) bool {
|
||||
if len(responses) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, response := range responses {
|
||||
if strings.TrimSpace(response.ResponseText) == "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func writeTaskEvaluationJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type candidateRegistryStub struct {
|
||||
registryStub
|
||||
selected capability.ResolvedCognitiveBinding
|
||||
}
|
||||
|
||||
func (s *candidateRegistryStub) ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
return s.selected, nil
|
||||
}
|
||||
|
||||
func TestTaskEvaluationHandlerInvokesEveryBoundedCase(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
registry := &candidateRegistryStub{selected: selected}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "observed response"}}
|
||||
gateway := cognitiveGateway{
|
||||
resolver: registry,
|
||||
providers: map[capability.ProviderKind][]providerRegistration{
|
||||
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}},
|
||||
},
|
||||
now: func() time.Time { return time.UnixMilli(1000) },
|
||||
}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-general", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "general-v1", RequiredCaseIDs: []string{"case-a", "case-b"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: selected.Binding.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}, {CaseID: "case-b", Prompt: "two", EvidenceArtifactRef: "artifact:b"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Observations) != 2 || response.Observations[0].Result != capability.EvaluationUnknown || response.Observations[1].Result != capability.EvaluationUnknown {
|
||||
t.Fatalf("unexpected observations: %+v", response.Observations)
|
||||
}
|
||||
for _, observation := range response.Observations {
|
||||
if observation.QualityScoreMillis != 0 || observation.CriticalFailure {
|
||||
t.Fatalf("reachability-only evidence must not carry quality or failure claims: %+v", observation)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type taskEvaluationFailoverRegistry struct {
|
||||
primary, fallback capability.ResolvedCognitiveBinding
|
||||
resolveCalls int
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) ResolveCandidate(context.Context, capability.CapabilityBindingID, int64) (capability.ResolvedCognitiveBinding, error) {
|
||||
return s.primary, nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) Resolve(_ context.Context, requirement capability.CognitiveRequirement) (capability.ResolvedCognitiveBinding, error) {
|
||||
s.resolveCalls++
|
||||
if len(requirement.ExcludedBindingIDs) == 0 {
|
||||
return s.primary, nil
|
||||
}
|
||||
return s.fallback, nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) Observe(context.Context, capability.CognitiveUsageObservation) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *taskEvaluationFailoverRegistry) RegisterDiscovery(context.Context, capability.CognitiveDiscoverySnapshot) (capability.CognitiveDiscoveryRegistration, error) {
|
||||
return capability.CognitiveDiscoveryRegistration{}, nil
|
||||
}
|
||||
|
||||
func TestTaskEvaluationExcludesUnavailableCandidateAndUsesFallback(t *testing.T) {
|
||||
primary := gatewaySelection()
|
||||
fallback := primary
|
||||
fallback.Binding.ID = "binding-fallback"
|
||||
fallback.Binding.Revision = 7
|
||||
fallback.Binding.ProviderInstanceID = "provider-fallback"
|
||||
fallback.Provider.ID = "provider-fallback"
|
||||
fallback.Provider.Kind = "provider-fallback-kind"
|
||||
fallback.Provider.EndpointRef = "fallback-endpoint"
|
||||
fallback.Provider.CredentialRef = "fallback-credential"
|
||||
fallback.Model.ProviderInstanceID = "provider-fallback"
|
||||
registry := &taskEvaluationFailoverRegistry{primary: primary, fallback: fallback}
|
||||
primaryProvider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishProviderUnavailable}}
|
||||
fallbackProvider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishCompleted, AdvisoryText: "fallback response"}}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{
|
||||
primary.Provider.Kind: {{ProviderInstanceID: primary.Provider.ID, Kind: primary.Provider.Kind, EndpointRef: primary.Provider.EndpointRef, CredentialRef: primary.Provider.CredentialRef, Provider: primaryProvider}},
|
||||
fallback.Provider.Kind: {{ProviderInstanceID: fallback.Provider.ID, Kind: fallback.Provider.Kind, EndpointRef: fallback.Provider.EndpointRef, CredentialRef: fallback.Provider.CredentialRef, Provider: fallbackProvider}},
|
||||
}, now: func() time.Time { return time.UnixMilli(1000) }}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-failover", Revision: 1, CapabilityID: primary.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "failover-v1", RequiredCaseIDs: []string{"case-a"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: profile.CapabilityID, BindingID: primary.Binding.ID, BindingRevision: primary.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: primary.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.BindingID != fallback.Binding.ID || !primaryProvider.called || !fallbackProvider.called || registry.resolveCalls != 1 {
|
||||
t.Fatalf("failover binding=%s primary_called=%t fallback_called=%t resolve_calls=%d", response.BindingID, primaryProvider.called, fallbackProvider.called, registry.resolveCalls)
|
||||
}
|
||||
if response.Observations[0].CriticalFailure || response.Responses[0].ResponseText != "fallback response" {
|
||||
t.Fatalf("fallback response was not usable: %+v %+v", response.Observations, response.Responses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskEvaluationUnavailableResponseIsMarkedCriticalUnknown(t *testing.T) {
|
||||
selected := gatewaySelection()
|
||||
registry := &taskEvaluationFailoverRegistry{primary: selected, fallback: selected}
|
||||
provider := &stubAdvisoryProvider{response: cognitive.AdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, FinishReason: cognitive.FinishTimeout}}
|
||||
gateway := cognitiveGateway{resolver: registry, providers: map[capability.ProviderKind][]providerRegistration{
|
||||
selected.Provider.Kind: {{ProviderInstanceID: selected.Provider.ID, Kind: selected.Provider.Kind, EndpointRef: selected.Provider.EndpointRef, CredentialRef: selected.Provider.CredentialRef, Provider: provider}},
|
||||
}, now: func() time.Time { return time.UnixMilli(1000) }}
|
||||
profile := capability.TaskEvaluationProfile{ID: "profile-unknown", Revision: 1, CapabilityID: selected.Binding.CapabilityID, Stage: capability.TaskEvaluationGeneral, BenchmarkVersion: "unknown-v1", RequiredCaseIDs: []string{"case-a"}, QualityWeightMillis: 600, ReliabilityWeightMillis: 300, LatencyWeightMillis: 100}
|
||||
request := capability.TaskEvaluationExecutionRequest{Profile: profile, CapabilityID: profile.CapabilityID, BindingID: selected.Binding.ID, BindingRevision: selected.Binding.Revision, ActorID: identity.ActorID("actor-1"), PrincipalID: selected.Binding.OwnerPrincipalID, ScopeID: authority.ScopeID("scope-1"), MaxOutputBytes: 512, EgressAcknowledged: true, Cases: []capability.TaskEvaluationCase{{CaseID: "case-a", Prompt: "one", EvidenceArtifactRef: "artifact:a"}}}
|
||||
payload, _ := json.Marshal(request)
|
||||
recorder := httptest.NewRecorder()
|
||||
gateway.taskEvaluationHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/execute", bytes.NewReader(payload)))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response capability.TaskEvaluationExecutionResponse
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(response.Observations) != 1 || response.Observations[0].Result != capability.EvaluationUnknown || !response.Observations[0].CriticalFailure {
|
||||
t.Fatalf("unavailable response was overclaimed: %+v", response.Observations)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
type chatRouteKind string
|
||||
|
||||
const (
|
||||
chatRouteResearch chatRouteKind = "RESEARCH"
|
||||
chatRouteConversation chatRouteKind = "CONVERSATION"
|
||||
chatRouteAction chatRouteKind = "ACTION"
|
||||
chatRouteAgent chatRouteKind = "AGENT"
|
||||
)
|
||||
|
||||
type chatRoute struct {
|
||||
Kind chatRouteKind
|
||||
Capability capability.CapabilityKind
|
||||
Profile string
|
||||
Order int
|
||||
}
|
||||
|
||||
// buildChatRoutePlan creates one bounded route per meaning-bearing capability.
|
||||
// A failed research route must not suppress the local conversational route or
|
||||
// an unrelated action route.
|
||||
func buildChatRoutePlan(result interactiondomain.SemanticResult) []chatRoute {
|
||||
seen := make(map[capability.CapabilityKind]struct{})
|
||||
routes := make([]chatRoute, 0, len(result.CapabilityHints)+1)
|
||||
add := func(kind chatRouteKind, capabilityKind capability.CapabilityKind, profile string, order int) {
|
||||
if capabilityKind != "" {
|
||||
if _, exists := seen[capabilityKind]; exists {
|
||||
return
|
||||
}
|
||||
seen[capabilityKind] = struct{}{}
|
||||
}
|
||||
routes = append(routes, chatRoute{Kind: kind, Capability: capabilityKind, Profile: profile, Order: order})
|
||||
}
|
||||
for _, hint := range result.CapabilityHints {
|
||||
switch capability.CapabilityKind(hint) {
|
||||
case capability.CapabilityKindWebResearch:
|
||||
add(chatRouteResearch, capability.CapabilityKindWebResearch, "truth-research-v1", 10)
|
||||
case capability.CapabilityKindCognitiveReasoning, capability.CapabilityKindVerifyFact:
|
||||
add(chatRouteConversation, capability.CapabilityKind(hint), "local-reasoning-v1", 20)
|
||||
case capability.CapabilityKindScheduleReminder:
|
||||
add(chatRouteAction, capability.CapabilityKindScheduleReminder, "continuity-reminder-v1", 30)
|
||||
case capability.CapabilityKindModifyCode, capability.CapabilityKindRunTests, capability.CapabilityKindInspectProject:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "software-agent-v1", 40)
|
||||
case capability.CapabilityKindSendEmail, capability.CapabilityKindReadEmail:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "communication-agent-v1", 50)
|
||||
case capability.CapabilityKindReadDatabase, capability.CapabilityKindQueryLogs:
|
||||
add(chatRouteAgent, capability.CapabilityKind(hint), "diagnostics-agent-v1", 60)
|
||||
}
|
||||
}
|
||||
if result.NeedsCognition || len(routes) == 0 {
|
||||
add(chatRouteConversation, capability.CapabilityKindCognitiveReasoning, "local-reasoning-v1", 20)
|
||||
}
|
||||
sort.SliceStable(routes, func(i, j int) bool { return routes[i].Order < routes[j].Order })
|
||||
return routes
|
||||
}
|
||||
|
||||
func hasChatRoute(routes []chatRoute, kind chatRouteKind) bool {
|
||||
for _, route := range routes {
|
||||
if route.Kind == kind {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestBuildChatRoutePlanPreservesMultipleMeaningfulRoutes(t *testing.T) {
|
||||
planned := buildChatRoutePlan(interactiondomain.SemanticResult{
|
||||
NeedsCognition: true,
|
||||
CapabilityHints: []string{"WEB_RESEARCH", "MODIFY_CODE", "RUN_TESTS", "SCHEDULE_REMINDER"},
|
||||
})
|
||||
if len(planned) != 5 {
|
||||
t.Fatalf("expected five deduplicated routes, got %+v", planned)
|
||||
}
|
||||
want := []struct {
|
||||
kind chatRouteKind
|
||||
profile string
|
||||
cap capability.CapabilityKind
|
||||
}{
|
||||
{chatRouteResearch, "truth-research-v1", capability.CapabilityKindWebResearch},
|
||||
{chatRouteConversation, "local-reasoning-v1", capability.CapabilityKindCognitiveReasoning},
|
||||
{chatRouteAction, "continuity-reminder-v1", capability.CapabilityKindScheduleReminder},
|
||||
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindModifyCode},
|
||||
{chatRouteAgent, "software-agent-v1", capability.CapabilityKindRunTests},
|
||||
}
|
||||
for index, expected := range want {
|
||||
if planned[index].Kind != expected.kind || planned[index].Profile != expected.profile || planned[index].Capability != expected.cap {
|
||||
t.Fatalf("route %d = %+v, want %+v", index, planned[index], expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChatRoutePlanAlwaysKeepsLocalConversationRoute(t *testing.T) {
|
||||
planned := buildChatRoutePlan(interactiondomain.SemanticResult{NeedsCognition: true})
|
||||
if len(planned) != 1 || planned[0].Kind != chatRouteConversation || planned[0].Profile != "local-reasoning-v1" {
|
||||
t.Fatalf("unexpected local route: %+v", planned)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,11 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -22,6 +25,56 @@ type cognitiveInterpretationClient struct {
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// ReviewResponse asks the cognitive gateway to turn a locally assembled draft
|
||||
// into a natural answer. The draft remains the safe fallback when cognition is
|
||||
// unavailable; the model does not mutate canonical state or execute actions.
|
||||
func (c cognitiveInterpretationClient) ReviewResponse(ctx context.Context, session identity.CreatorSession, userMessage, draft string, meaning cognitive.TaskMeaning) (string, error) {
|
||||
requestID, err := ids.New()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
prompt := "Ты редактор ответа Домового. Проверь черновик по смыслу и подготовь естественный ответ человеку. Учитывай весь запрос, включая недостающие параметры для конкретного результата. Если данных не хватает, задай один короткий полезный уточняющий вопрос, например откуда и куда ехать. Не выдавай поисковые сниппеты как выполненное действие и не показывай служебные URL поисковой выдачи. Сохрани проверяемые факты, названия источников, прямые ссылки и ограничения. Не выдумывай факты и не утверждай невыполненные действия. Верни только готовый ответ без разбора проверки, служебных слов и упоминания моделей. Если черновик уже адекватен, верни его без лишних добавлений.\n\nСообщение пользователя:\n" + userMessage + "\n\nЧерновик ответа:\n" + draft
|
||||
target := *c.baseURL
|
||||
target.Path = "/v1/advisories"
|
||||
payload, err := json.Marshal(cognitive.GatewayAdvisoryRequest{
|
||||
Version: cognitive.CreatorAdvisoryV1, RequestID: cognitive.RequestID(requestID),
|
||||
ActorID: session.ActorID, PrincipalID: session.PrincipalID,
|
||||
ScopeID: authority.ScopeID("domovoy-global-scope"), Message: prompt,
|
||||
Locale: "ru", MaxOutputBytes: 2048, DataPolicy: capability.DataPolicyLocalOnly,
|
||||
Meaning: cognitive.TaskMeaning{
|
||||
SpeechAct: "RESPONSE_REVIEW",
|
||||
CapabilityHints: meaning.CapabilityHints,
|
||||
RequestedOutcome: meaning.RequestedOutcome,
|
||||
InformationRequestKind: meaning.InformationRequestKind,
|
||||
ConfidenceMillis: meaning.ConfidenceMillis,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.String(), bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(request)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("cognitive response review status %d", response.StatusCode)
|
||||
}
|
||||
var result cognitive.GatewayAdvisoryResponse
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.FinishReason != cognitive.FinishCompleted || strings.TrimSpace(result.AdvisoryText) == "" {
|
||||
return "", fmt.Errorf("cognitive response review incomplete: %s", result.FinishReason)
|
||||
}
|
||||
return strings.TrimSpace(result.AdvisoryText), nil
|
||||
}
|
||||
|
||||
func newCognitiveInterpretationClient(rawURL string, client *http.Client) (cognitiveInterpretationClient, error) {
|
||||
base, err := url.Parse(rawURL)
|
||||
if err != nil || base.Scheme == "" || base.Host == "" {
|
||||
@@ -61,19 +114,63 @@ func (c cognitiveInterpretationClient) Interpret(ctx context.Context, request co
|
||||
}
|
||||
|
||||
func shouldRequestBroaderInterpretation(result interactiondomain.SemanticResult) bool {
|
||||
// The built-in advisory channel is the default conversational path. A
|
||||
// simple question must not first wait for the slower, strict JSON
|
||||
// interpretation route: that route exists for ambiguity and work routing,
|
||||
// not as a prerequisite for talking to Domovoy.
|
||||
if isSimpleLocalQuestion(result) {
|
||||
return shouldRequestSemanticInterpretation(result)
|
||||
}
|
||||
|
||||
// shouldRequestSemanticInterpretation keeps the deterministic layer as a
|
||||
// fast reflex, not as the meaning authority. Questions, requests, directives,
|
||||
// mixed signals and capability-bearing messages go through cognitive
|
||||
// interpretation; only stable low-risk conversational/assertive reflexes may
|
||||
// answer without it.
|
||||
func shouldRequestSemanticInterpretation(result interactiondomain.SemanticResult) bool {
|
||||
if result.Interpretation.Clarification == interaction.ClarificationRequired {
|
||||
return false
|
||||
}
|
||||
switch result.Interpretation.Applicability {
|
||||
case interaction.SemanticNeedsBroaderInterpret, interaction.SemanticLowConfidence, interaction.SemanticMixedIntent:
|
||||
// Structured, read-only information intents already have a semantic owner
|
||||
// in core-runtime. Sending them through the slower advisory interpretation
|
||||
// path only adds latency and can make a healthy runtime look unavailable.
|
||||
if result.InformationRequestKind != "" {
|
||||
return false
|
||||
}
|
||||
if result.NeedsCognition || len(result.CapabilityHints) > 0 {
|
||||
return true
|
||||
}
|
||||
if result.Interpretation.Applicability != interaction.SemanticApplicable {
|
||||
return true
|
||||
}
|
||||
switch result.Interpretation.SpeechAct {
|
||||
case interaction.SpeechActConversational, interaction.SpeechActConfirmation, interaction.SpeechActRejection:
|
||||
return false
|
||||
case interaction.SpeechActPreference:
|
||||
// Explicit user preferences are durable interaction context. A sampled
|
||||
// advisory audit must not reinterpret or erase them.
|
||||
return false
|
||||
case interaction.SpeechActAssertion:
|
||||
return result.Interpretation.ConfidenceMillis < 850
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
deterministicReflexAuditInterval = 10 * time.Minute
|
||||
deterministicReflexAuditSample = uint32(16)
|
||||
)
|
||||
|
||||
// shouldAuditDeterministicReflex samples stable reflexes in a time bucket.
|
||||
// This is observability, not a correctness gate: a failed audit leaves the
|
||||
// reflex usable, while a successful disagreement can replace it with the
|
||||
// cognitive candidate and becomes durable evidence through the normal
|
||||
// candidate store.
|
||||
func shouldAuditDeterministicReflex(result interactiondomain.SemanticResult, utteranceID string, now time.Time) bool {
|
||||
if shouldRequestSemanticInterpretation(result) || utteranceID == "" {
|
||||
return false
|
||||
}
|
||||
bucket := now.Unix() / int64(deterministicReflexAuditInterval/time.Second)
|
||||
hash := fnv.New32a()
|
||||
_, _ = hash.Write([]byte(utteranceID))
|
||||
_, _ = hash.Write([]byte(fmt.Sprintf(":%d", bucket)))
|
||||
return hash.Sum32()%deterministicReflexAuditSample == 0
|
||||
}
|
||||
|
||||
func isSimpleLocalQuestion(result interactiondomain.SemanticResult) bool {
|
||||
|
||||
@@ -1,13 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/cognitive"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
)
|
||||
|
||||
func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing.T) {
|
||||
func TestReviewResponseUsesCognitiveGatewayAndReturnsOnlyDraftAnswer(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatalf("decode review request: %v", err)
|
||||
}
|
||||
if request.Message == "" || request.Meaning.SpeechAct == "" {
|
||||
t.Fatalf("review request did not carry semantic context: %+v", request)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Меня зовут Дед. Я Домовой.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newCognitiveInterpretationClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reviewed, err := client.ReviewResponse(context.Background(), identity.CreatorSession{ActorID: "actor", PrincipalID: "principal"}, "кто ты?", "Я — Домовой.", cognitive.TaskMeaning{SpeechAct: "QUESTION"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reviewed != "Меня зовут Дед. Я Домовой." {
|
||||
t.Fatalf("unexpected reviewed response %q", reviewed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewPreservesResearchEvidence(t *testing.T) {
|
||||
draft := "Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)"
|
||||
if reviewPreservesResearchEvidence("Нашёл вариант на Авиасейлс.", draft) {
|
||||
t.Fatal("review without the direct source must be rejected")
|
||||
}
|
||||
if !reviewPreservesResearchEvidence("Нашёл вариант. Источник: Aviasales (https://www.aviasales.ru/search)", draft) {
|
||||
t.Fatal("review with the direct source must be accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSimpleQuestionUsesStrictInterpretationForSemanticRouting(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActQuestion,
|
||||
@@ -16,8 +59,8 @@ func TestSimpleQuestionUsesBuiltInAdvisoryWithoutStrictInterpretation(t *testing
|
||||
},
|
||||
CapabilityHints: []string{"COGNITIVE_REASONING", "VERIFY_FACT"},
|
||||
}
|
||||
if shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("simple question must go directly to the built-in advisory path")
|
||||
if !shouldRequestBroaderInterpretation(result) {
|
||||
t.Fatal("simple question must reach semantic interpretation before any answer")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,3 +77,51 @@ func TestOperationalQuestionStillUsesStrictInterpretation(t *testing.T) {
|
||||
t.Fatal("operational question must retain strict interpretation routing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStructuredInventoryUsesDirectReadOnlySemanticRoute(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{
|
||||
Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActDirective,
|
||||
Applicability: interaction.SemanticApplicable,
|
||||
ConfidenceMillis: 980,
|
||||
},
|
||||
InformationRequestKind: interaction.InformationRequestCognitiveModelInventory,
|
||||
}
|
||||
if shouldRequestSemanticInterpretation(result) {
|
||||
t.Fatal("structured model inventory must use the direct read-only semantic route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableReflexIsPeriodicallySampledForDrift(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActConversational, Applicability: interaction.SemanticApplicable,
|
||||
}}
|
||||
found := false
|
||||
for minute := 0; minute < 160; minute++ {
|
||||
if shouldAuditDeterministicReflex(result, "utterance-drift-probe", time.Unix(int64(minute)*60, 0)) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("stable reflex was never sampled during the audit window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeaningfulSignalsAreNotReflexOnly(t *testing.T) {
|
||||
for _, act := range []interaction.SpeechAct{interaction.SpeechActQuestion, interaction.SpeechActRequestToInvestigate, interaction.SpeechActDirective} {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{SpeechAct: act, Applicability: interaction.SemanticApplicable}}
|
||||
if !shouldRequestSemanticInterpretation(result) {
|
||||
t.Fatalf("%s must prefer cognitive interpretation", act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitPreferenceIsNotReinterpretedByCognitiveAudit(t *testing.T) {
|
||||
result := interactiondomain.SemanticResult{Interpretation: interaction.UtteranceInterpretation{
|
||||
SpeechAct: interaction.SpeechActPreference, Applicability: interaction.SemanticApplicable,
|
||||
}}
|
||||
if shouldRequestSemanticInterpretation(result) || shouldAuditDeterministicReflex(result, "preference", time.Unix(0, 0)) {
|
||||
t.Fatal("explicit preference must remain on the deterministic durable-context path")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/modules/work/ports"
|
||||
)
|
||||
|
||||
var errDevelopmentGateCreate = errors.New("development gate must start at evidence collected with revision one")
|
||||
var errDevelopmentGateNotIsolated = errors.New("development gate is not isolated")
|
||||
var errDevelopmentVerifierNotIndependent = errors.New("development verifier is not independent")
|
||||
var errDevelopmentWorktreeMismatch = errors.New("verification worktree does not match gate")
|
||||
var errDevelopmentChecksFailed = errors.New("development verification checks failed")
|
||||
var errDevelopmentEvidenceRequired = errors.New("development transition evidence is required")
|
||||
var errDevelopmentEvidenceStage = errors.New("development transition evidence does not match stage")
|
||||
|
||||
type developmentGateVerificationRequest struct {
|
||||
GateID string
|
||||
ExpectedRevision int64
|
||||
VerifierRunID work.AgentRunID
|
||||
Plan developmentexecutor.Plan
|
||||
}
|
||||
|
||||
type developmentGateEvidenceRequest struct {
|
||||
GateID string
|
||||
ExpectedRevision int64
|
||||
NextStage work.DevelopmentStage
|
||||
EvidenceRef string
|
||||
}
|
||||
|
||||
func recordDevelopmentGateEvidence(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateEvidenceRequest, now time.Time) (work.DevelopmentGate, error) {
|
||||
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
if request.ExpectedRevision < 1 || gate.Revision != request.ExpectedRevision {
|
||||
return gate, errDevelopmentGateNotIsolated
|
||||
}
|
||||
if strings.TrimSpace(request.EvidenceRef) == "" {
|
||||
return gate, errDevelopmentEvidenceRequired
|
||||
}
|
||||
switch request.NextStage {
|
||||
case work.DevelopmentCanary:
|
||||
gate.CanaryEvidence = strings.TrimSpace(request.EvidenceRef)
|
||||
case work.DevelopmentPromoted:
|
||||
gate.PromotionRef = strings.TrimSpace(request.EvidenceRef)
|
||||
case work.DevelopmentRolledBack:
|
||||
gate.RollbackRef = strings.TrimSpace(request.EvidenceRef)
|
||||
default:
|
||||
return gate, errDevelopmentEvidenceStage
|
||||
}
|
||||
advanced, err := advanceDevelopmentGate(gate, request.NextStage, now)
|
||||
if err != nil {
|
||||
return gate, err
|
||||
}
|
||||
if err := store.SaveDevelopmentGate(ctx, advanced, request.ExpectedRevision); err != nil {
|
||||
return gate, err
|
||||
}
|
||||
return advanced, nil
|
||||
}
|
||||
|
||||
func verifyDevelopmentGate(ctx context.Context, store ports.DevelopmentGateStore, request developmentGateVerificationRequest, now time.Time, runner developmentexecutor.CommandRunner) (work.DevelopmentGate, developmentexecutor.Result, error) {
|
||||
gate, err := store.LoadDevelopmentGate(ctx, request.GateID)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, developmentexecutor.Result{}, err
|
||||
}
|
||||
if request.ExpectedRevision < 1 || gate.Revision != request.ExpectedRevision || gate.Stage != work.DevelopmentIsolated {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentGateNotIsolated
|
||||
}
|
||||
if request.VerifierRunID == "" || sameRun(request.VerifierRunID, gate.ProducerRunRefs) {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentVerifierNotIndependent
|
||||
}
|
||||
if request.Plan.WorktreeID != gate.WorktreeRef {
|
||||
return gate, developmentexecutor.Result{}, errDevelopmentWorktreeMismatch
|
||||
}
|
||||
result, err := developmentexecutor.Execute(ctx, request.Plan, runner)
|
||||
if err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
if !result.Passed {
|
||||
return gate, result, errDevelopmentChecksFailed
|
||||
}
|
||||
gate.VerifierRunRefs = append(append([]work.AgentRunID(nil), gate.VerifierRunRefs...), request.VerifierRunID)
|
||||
gate.VerificationEvidence = result.EvidenceRef
|
||||
advanced, err := advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
|
||||
if err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
if err := store.SaveDevelopmentGate(ctx, advanced, request.ExpectedRevision); err != nil {
|
||||
return gate, result, err
|
||||
}
|
||||
return advanced, result, nil
|
||||
}
|
||||
|
||||
func sameRun(value work.AgentRunID, runs []work.AgentRunID) bool {
|
||||
for _, run := range runs {
|
||||
if run == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func prepareDevelopmentGate(value work.DevelopmentGate, now time.Time) (work.DevelopmentGate, error) {
|
||||
if value.Stage != work.DevelopmentEvidenceCollected || value.Revision != 1 {
|
||||
return work.DevelopmentGate{}, errDevelopmentGateCreate
|
||||
}
|
||||
value.UpdatedAtUnixMillis = now.UnixMilli()
|
||||
if err := workdomain.ValidateDevelopmentGate(value); err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func advanceDevelopmentGate(value work.DevelopmentGate, next work.DevelopmentStage, now time.Time) (work.DevelopmentGate, error) {
|
||||
advanced, err := workdomain.AdvanceDevelopmentGate(value, next)
|
||||
if err != nil {
|
||||
return work.DevelopmentGate{}, err
|
||||
}
|
||||
advanced.Revision = value.Revision + 1
|
||||
advanced.UpdatedAtUnixMillis = now.UnixMilli()
|
||||
return advanced, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
type developmentGateStoreStub struct {
|
||||
gate work.DevelopmentGate
|
||||
saved bool
|
||||
expected int64
|
||||
}
|
||||
|
||||
func (s *developmentGateStoreStub) LoadDevelopmentGate(context.Context, string) (work.DevelopmentGate, error) {
|
||||
return s.gate, nil
|
||||
}
|
||||
|
||||
func (s *developmentGateStoreStub) SaveDevelopmentGate(_ context.Context, gate work.DevelopmentGate, expected int64) error {
|
||||
s.gate = gate
|
||||
s.saved = true
|
||||
s.expected = expected
|
||||
return nil
|
||||
}
|
||||
|
||||
type developmentCommandRunnerStub struct {
|
||||
exitCodes []int
|
||||
commands int
|
||||
}
|
||||
|
||||
func (r *developmentCommandRunnerStub) Run(context.Context, string, string, []string) int {
|
||||
code := 0
|
||||
if r.commands < len(r.exitCodes) {
|
||||
code = r.exitCodes[r.commands]
|
||||
}
|
||||
r.commands++
|
||||
return code
|
||||
}
|
||||
|
||||
func validDevelopmentVerificationPlan(t *testing.T, worktreeID string) developmentexecutor.Plan {
|
||||
t.Helper()
|
||||
worktree, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return developmentexecutor.Plan{
|
||||
Worktree: worktree,
|
||||
WorktreeID: worktreeID,
|
||||
BaseRepoRevision: "base-revision",
|
||||
ProtectedMainAccess: "DENIED",
|
||||
ProductionAccess: "DENIED",
|
||||
SecretAccess: "DENIED",
|
||||
Checks: []developmentexecutor.Check{{Kind: developmentexecutor.CheckGitDiffCheck}},
|
||||
}
|
||||
}
|
||||
|
||||
func isolatedDevelopmentGate() work.DevelopmentGate {
|
||||
return work.DevelopmentGate{
|
||||
ID: "gate-1",
|
||||
Revision: 4,
|
||||
Stage: work.DevelopmentIsolated,
|
||||
EvidenceRefs: []string{"evidence-1"},
|
||||
ProposalRef: "proposal-1",
|
||||
TaskPacketRef: "task-1",
|
||||
WorktreeRef: "sandbox-1",
|
||||
ProducerRunRefs: []work.AgentRunID{"producer-1"},
|
||||
VerifierRunRefs: nil,
|
||||
VerificationEvidence: "",
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGatePersistsVerifiedWithIndependentEvidence(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
runner := &developmentCommandRunnerStub{exitCodes: []int{0}}
|
||||
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "verifier-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), runner)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Passed || gate.Stage != work.DevelopmentVerified || gate.Revision != 5 {
|
||||
t.Fatalf("verification did not advance gate: gate=%#v result=%#v", gate, result)
|
||||
}
|
||||
if !store.saved || store.expected != 4 || gate.VerificationEvidence == "" {
|
||||
t.Fatalf("verification was not durably persisted: store=%#v gate=%#v", store, gate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGateLeavesIsolatedOnFailedChecks(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
gate, result, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "verifier-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{1}})
|
||||
if err != errDevelopmentChecksFailed || result.Passed || gate.Stage != work.DevelopmentIsolated || store.saved {
|
||||
t.Fatalf("failed checks changed gate unexpectedly: gate=%#v result=%#v err=%v store=%#v", gate, result, err, store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyDevelopmentGateRejectsProducerAsVerifier(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: isolatedDevelopmentGate()}
|
||||
_, _, err := verifyDevelopmentGate(context.Background(), store, developmentGateVerificationRequest{
|
||||
GateID: "gate-1",
|
||||
ExpectedRevision: 4,
|
||||
VerifierRunID: "producer-1",
|
||||
Plan: validDevelopmentVerificationPlan(t, "sandbox-1"),
|
||||
}, time.UnixMilli(20), &developmentCommandRunnerStub{exitCodes: []int{0}})
|
||||
if err != errDevelopmentVerifierNotIndependent {
|
||||
t.Fatalf("producer was accepted as verifier: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDevelopmentGateEvidenceAdvancesCanaryAndPromotion(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: work.DevelopmentGate{
|
||||
ID: "gate-1", Revision: 6, Stage: work.DevelopmentVerified,
|
||||
EvidenceRefs: []string{"evidence-1"}, ProposalRef: "proposal-1", TaskPacketRef: "task-1", WorktreeRef: "sandbox-1",
|
||||
ProducerRunRefs: []work.AgentRunID{"producer-1"}, VerifierRunRefs: []work.AgentRunID{"verifier-1"}, VerificationEvidence: "verification-1",
|
||||
}}
|
||||
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 6, NextStage: work.DevelopmentCanary, EvidenceRef: "canary-1"}, time.UnixMilli(30))
|
||||
if err != nil || gate.Stage != work.DevelopmentCanary || gate.Revision != 7 || gate.CanaryEvidence != "canary-1" {
|
||||
t.Fatalf("canary transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 7, NextStage: work.DevelopmentPromoted, EvidenceRef: "promotion-1"}, time.UnixMilli(31))
|
||||
if err != nil || gate.Stage != work.DevelopmentPromoted || gate.Revision != 8 || gate.PromotionRef != "promotion-1" {
|
||||
t.Fatalf("promotion transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordDevelopmentGateEvidenceSupportsCanaryRollbackAndRejectsMissingEvidence(t *testing.T) {
|
||||
store := &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-1", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
|
||||
gate, err := recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-1", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack, EvidenceRef: "rollback-1"}, time.UnixMilli(40))
|
||||
if err != nil || gate.Stage != work.DevelopmentRolledBack || gate.RollbackRef != "rollback-1" {
|
||||
t.Fatalf("rollback transition failed: gate=%#v err=%v", gate, err)
|
||||
}
|
||||
store = &developmentGateStoreStub{gate: work.DevelopmentGate{ID: "gate-2", Revision: 2, Stage: work.DevelopmentCanary, EvidenceRefs: []string{"evidence-1"}, CanaryEvidence: "canary-1"}}
|
||||
gate, err = recordDevelopmentGateEvidence(context.Background(), store, developmentGateEvidenceRequest{GateID: "gate-2", ExpectedRevision: 2, NextStage: work.DevelopmentRolledBack}, time.UnixMilli(40))
|
||||
if err != errDevelopmentEvidenceRequired || gate.Stage != work.DevelopmentCanary || store.saved {
|
||||
t.Fatalf("missing rollback evidence changed gate: gate=%#v err=%v store=%#v", gate, err, store)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDevelopmentGateControllerRequiresSequentialVerifiedEvidence(t *testing.T) {
|
||||
now := time.UnixMilli(10)
|
||||
gate, err := prepareDevelopmentGate(work.DevelopmentGate{ID: "gate-1", Revision: 1, Stage: work.DevelopmentEvidenceCollected, EvidenceRefs: []string{"evidence-1"}}, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.ProposalRef = "proposal-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentProposalCreated, now)
|
||||
if err != nil || gate.Revision != 2 {
|
||||
t.Fatalf("proposal transition failed: %#v %v", gate, err)
|
||||
}
|
||||
gate.TaskPacketRef = "task-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentTaskAdmitted, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.WorktreeRef = "isolated-worktree"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentIsolated, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate.ProducerRunRefs = []work.AgentRunID{"producer"}
|
||||
gate.VerifierRunRefs = []work.AgentRunID{"verifier"}
|
||||
gate.VerificationEvidence = "verification-1"
|
||||
gate, err = advanceDevelopmentGate(gate, work.DevelopmentVerified, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := advanceDevelopmentGate(gate, work.DevelopmentPromoted, now); err == nil {
|
||||
t.Fatal("promotion bypassed canary")
|
||||
}
|
||||
}
|
||||
+645
-46
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"git.cin.su/m/Assistant/adapters/developmentexecutor"
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
@@ -21,6 +22,7 @@ import (
|
||||
interactiondomain "git.cin.su/m/Assistant/modules/interaction/domain"
|
||||
situationdomain "git.cin.su/m/Assistant/modules/situation/domain"
|
||||
workdomain "git.cin.su/m/Assistant/modules/work/domain"
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
platformsecrets "git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -28,6 +30,8 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -112,7 +116,11 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 20 * time.Second})
|
||||
cognitiveClient, err := newCognitiveInterpretationClient(envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"), &http.Client{Timeout: 90 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
researchClient, err := newResearchRuntimeClient(envOr("RESEARCH_RUNTIME_URL", "http://research-runtime:8087"), &http.Client{Timeout: 12 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -178,6 +186,185 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(session)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/research/web", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request epistemics.WebResearchRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 128<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web research request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := researchClient.EvaluateWeb(r.Context(), request)
|
||||
if err != nil {
|
||||
http.Error(w, "web research unavailable", http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/proposals", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
Evidence []work.ExtensionEvidence `json:"evidence"`
|
||||
Proposal work.ExtensionProposalRequest `json:"proposal"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid development proposal", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
proposal, err := workdomain.BuildExtensionProposal(request.Evidence, request.Proposal)
|
||||
if err != nil {
|
||||
http.Error(w, "development proposal rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
proposal.RequestedByPrincipalID = string(session.PrincipalID)
|
||||
if err := workRepository.SaveExtensionProposal(r.Context(), proposal); err != nil {
|
||||
http.Error(w, "development proposal conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(proposal)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/scaffolds", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var request work.DevelopmentScaffoldRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
http.Error(w, "invalid development scaffold", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
scaffold, err := workdomain.BuildDevelopmentScaffold(request)
|
||||
if err != nil {
|
||||
http.Error(w, "development scaffold rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
scaffold.RequestedByPrincipalID = string(session.PrincipalID)
|
||||
if err := workRepository.SaveDevelopmentScaffold(r.Context(), scaffold); err != nil {
|
||||
http.Error(w, "development scaffold conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(scaffold)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var gate work.DevelopmentGate
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&gate); err != nil {
|
||||
http.Error(w, "invalid development gate", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := prepareDevelopmentGate(gate, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development gate rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, 0); err != nil {
|
||||
http.Error(w, "development gate conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/advance", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
NextStage work.DevelopmentStage `json:"next_stage"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.NextStage == "" {
|
||||
http.Error(w, "invalid development gate transition", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := workRepository.LoadDevelopmentGate(r.Context(), r.PathValue("id"))
|
||||
if err != nil || gate.Revision != request.ExpectedRevision {
|
||||
http.Error(w, "development gate revision conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
gate, err = advanceDevelopmentGate(gate, request.NextStage, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development gate transition rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
if err := workRepository.SaveDevelopmentGate(r.Context(), gate, request.ExpectedRevision); err != nil {
|
||||
http.Error(w, "development gate conflict", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/verify", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
VerifierRunID work.AgentRunID `json:"verifier_run_id"`
|
||||
Plan developmentexecutor.Plan `json:"plan"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.VerifierRunID == "" {
|
||||
http.Error(w, "invalid development verification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, result, err := verifyDevelopmentGate(r.Context(), workRepository, developmentGateVerificationRequest{GateID: r.PathValue("id"), ExpectedRevision: request.ExpectedRevision, VerifierRunID: request.VerifierRunID, Plan: request.Plan}, time.Now(), developmentexecutor.OSCommandRunner{})
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"gate": gate, "verification": result})
|
||||
})
|
||||
registerDevelopmentGateEvidenceRoute := func(path string, next work.DevelopmentStage, fieldName string) {
|
||||
mux.HandleFunc("POST /v1/creator/development/gates/{id}/"+path, func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := authenticateCreator(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
ExpectedRevision int64 `json:"expected_revision"`
|
||||
EvidenceRef string `json:"evidence_ref"`
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.ExpectedRevision < 1 || request.EvidenceRef == "" {
|
||||
http.Error(w, "invalid development transition evidence", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
gate, err := recordDevelopmentGateEvidence(r.Context(), workRepository, developmentGateEvidenceRequest{GateID: r.PathValue("id"), ExpectedRevision: request.ExpectedRevision, NextStage: next, EvidenceRef: request.EvidenceRef}, time.Now())
|
||||
if err != nil {
|
||||
http.Error(w, "development "+fieldName+" transition rejected", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(gate)
|
||||
})
|
||||
}
|
||||
registerDevelopmentGateEvidenceRoute("canary", work.DevelopmentCanary, "canary")
|
||||
registerDevelopmentGateEvidenceRoute("promote", work.DevelopmentPromoted, "promotion")
|
||||
registerDevelopmentGateEvidenceRoute("rollback", work.DevelopmentRolledBack, "rollback")
|
||||
mux.HandleFunc("GET /v1/creator/focus", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -322,14 +509,127 @@ func main() {
|
||||
http.Error(w, "project focus unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
var effectiveTaskMessage = interactiondomain.StripAssistantVocative(utterance.Content)
|
||||
continuationMessage := ""
|
||||
travelContinuation := false
|
||||
var previousTask *interaction.HumanUtterance
|
||||
var conversationHistory []interaction.HumanUtterance
|
||||
if request.ConversationID != "" {
|
||||
var historyErr error
|
||||
conversationHistory, historyErr = interactionRepository.Utterances(r.Context(), conversation.ID)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction conversation history unavailable: %v", historyErr)
|
||||
}
|
||||
}
|
||||
// Keep durable user context available even when the web client carries a
|
||||
// conversation ID; a new or rotated conversation must not erase learned
|
||||
// preferences.
|
||||
recentUserHistory, historyErr := interactionRepository.RecentUtterancesForPrincipal(r.Context(), session.PrincipalID, 64)
|
||||
if historyErr != nil {
|
||||
log.Printf("interaction principal history unavailable: %v", historyErr)
|
||||
} else {
|
||||
log.Printf("interaction principal history loaded: %d utterances", len(recentUserHistory))
|
||||
}
|
||||
conversationHistory = append(conversationHistory, recentUserHistory...)
|
||||
if continuation := interactiondomain.ContinueLocalSearch(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
effectiveTaskMessage = continuation
|
||||
} else if continuation := interactiondomain.ContinueTravelOrigin(effectiveTaskMessage, conversationHistory); continuation != "" {
|
||||
continuationMessage = continuation
|
||||
travelContinuation = true
|
||||
effectiveTaskMessage = continuation
|
||||
}
|
||||
if request.ConversationID != "" && interactiondomain.IsRetryRequest(utterance.Content) {
|
||||
if len(conversationHistory) > 0 {
|
||||
for index := len(conversationHistory) - 1; index >= 0; index-- {
|
||||
if interactiondomain.IsRetryRequest(conversationHistory[index].Content) {
|
||||
continue
|
||||
}
|
||||
candidate := conversationHistory[index]
|
||||
previousTask = &candidate
|
||||
effectiveTaskMessage = interactiondomain.StripAssistantVocative(candidate.Content)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
semantic, err := interactiondomain.InterpretUtterance(utterance, projects, previousFocus, now)
|
||||
if err != nil {
|
||||
http.Error(w, "interpret chat message", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
if continuationMessage != "" {
|
||||
continuedUtterance := utterance
|
||||
continuedUtterance.Content = continuationMessage
|
||||
continuedSemantic, continuedErr := interactiondomain.InterpretUtterance(continuedUtterance, projects, previousFocus, now)
|
||||
if continuedErr == nil {
|
||||
continuedSemantic.Interpretation.Source = "CONVERSATION_CONTINUATION"
|
||||
continuedSemantic.Interpretation.SelectionReason = "location follow-up completed the previous local-search task"
|
||||
semantic = continuedSemantic
|
||||
}
|
||||
}
|
||||
if travelContinuation && interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory) == "" {
|
||||
semantic.CapabilityHints = append(semantic.CapabilityHints, string(capability.CapabilityKindWebResearch))
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
creativeRequest := interactiondomain.IsCreativeRequest(utterance.Content) || interactiondomain.IsCreativeRequest(effectiveTaskMessage)
|
||||
creativeReply := interactiondomain.CreativeDeterministicReply(utterance.Content)
|
||||
if creativeReply == "" {
|
||||
creativeReply = interactiondomain.CreativeDeterministicReply(effectiveTaskMessage)
|
||||
}
|
||||
creativeFallbackReply := interactiondomain.CreativeFallbackReply(utterance.Content)
|
||||
if creativeFallbackReply == "" {
|
||||
creativeFallbackReply = interactiondomain.CreativeFallbackReply(effectiveTaskMessage)
|
||||
}
|
||||
if creativeRequest {
|
||||
semantic.CapabilityHints = interactiondomain.RemoveCapabilityHint(semantic.CapabilityHints, string(capability.CapabilityKindWebResearch))
|
||||
if creativeReply != "" {
|
||||
semantic.Reply = creativeReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
} else {
|
||||
semantic.NeedsCognition = true
|
||||
semantic.Interpretation.NeedsCognition = true
|
||||
semantic.CapabilityHints = append(semantic.CapabilityHints, string(capability.CapabilityKindCognitiveReasoning))
|
||||
}
|
||||
}
|
||||
locationReply := interactiondomain.LocationContextReply(utterance.Content)
|
||||
if locationReply != "" {
|
||||
semantic.Reply = locationReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
if previousTask != nil {
|
||||
previousSemantic, previousErr := interactiondomain.InterpretUtterance(*previousTask, projects, previousFocus, now)
|
||||
if previousErr == nil {
|
||||
semantic = interactiondomain.CarryForwardPreviousTask(semantic, previousSemantic)
|
||||
}
|
||||
}
|
||||
assistantName := interactiondomain.PreferredAssistantName(utterance.Content)
|
||||
if assistantName == "" {
|
||||
assistantName = latestAssistantName(conversationHistory)
|
||||
}
|
||||
identityReply := ""
|
||||
log.Printf("interaction identity context: name=%q history=%d", assistantName, len(conversationHistory))
|
||||
if interactiondomain.IsIdentityInquiry(utterance.Content) && assistantName != "" {
|
||||
identityReply = "Меня зовут " + assistantName + ". Я Домовой, ваш помощник."
|
||||
semantic.Reply = identityReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.CapabilityHints = nil
|
||||
semantic.Interpretation.Applicability = interaction.SemanticApplicable
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationNotRequired
|
||||
}
|
||||
deterministicSpeechAct := semantic.Interpretation.SpeechAct
|
||||
deterministicReply := semantic.Reply
|
||||
routePlan := buildChatRoutePlan(semantic)
|
||||
cognitiveAttempted := false
|
||||
cognitiveAvailable := true
|
||||
if shouldRequestBroaderInterpretation(semantic) {
|
||||
semanticInterpretationRequested := shouldRequestSemanticInterpretation(semantic)
|
||||
semanticReflexAudit := shouldAuditDeterministicReflex(semantic, string(utterance.ID), now)
|
||||
if identityReply != "" {
|
||||
semanticReflexAudit = false
|
||||
}
|
||||
if semanticInterpretationRequested || semanticReflexAudit {
|
||||
cognitiveAttempted = true
|
||||
projectNames := make([]string, 0, len(semantic.Projects))
|
||||
for _, ref := range semantic.Projects {
|
||||
@@ -337,9 +637,11 @@ func main() {
|
||||
projectNames = append(projectNames, ref.ProjectDisplayName)
|
||||
}
|
||||
}
|
||||
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, utterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
|
||||
cognitiveUtterance := utterance
|
||||
cognitiveUtterance.Content = effectiveTaskMessage
|
||||
if response, interpretationErr := cognitiveClient.Interpret(r.Context(), buildGatewayInterpretationRequest(session, cognitiveUtterance, semantic, projectNames)); interpretationErr == nil && response.FinishReason == cognitive.FinishCompleted {
|
||||
if candidate, candidateErr := interactionCandidateFromGateway(utterance.ID, response.Candidate, now); candidateErr == nil {
|
||||
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, utterance.Content, candidate, previousFocus)
|
||||
semantic = interactiondomain.ApplyCognitiveCandidate(semantic, effectiveTaskMessage, candidate, previousFocus)
|
||||
} else {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
@@ -347,6 +649,14 @@ func main() {
|
||||
cognitiveAvailable = false
|
||||
}
|
||||
}
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
semantic.Interpretation.Clarification = interaction.ClarificationRequired
|
||||
semantic.Interpretation.Applicability = interaction.SemanticCandidateClarify
|
||||
semantic.Reply = travelReply
|
||||
deterministicReply = travelReply
|
||||
semantic.NeedsCognition = false
|
||||
semantic.Interpretation.NeedsCognition = false
|
||||
}
|
||||
if err := interactionRepository.AppendUtterance(r.Context(), conversation, utterance, intent, relations); err != nil {
|
||||
http.Error(w, "save chat message", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -358,7 +668,7 @@ func main() {
|
||||
responseResult, responseErr := responseClient.Respond(r.Context(), r.Header.Get("Authorization"), continuity.HumanResponseRequest{
|
||||
SourceUtteranceID: string(utterance.ID),
|
||||
SourceIntentID: string(intent.ID),
|
||||
Message: utterance.Content,
|
||||
Message: effectiveTaskMessage,
|
||||
RequestedAtUnixMillis: now.UnixMilli(),
|
||||
})
|
||||
if semantic.Interpretation.SpeechAct == interaction.SpeechActCorrection {
|
||||
@@ -377,7 +687,7 @@ func main() {
|
||||
activeFocusName = previousFocus.ProjectDisplayName
|
||||
activeFocusProvenance = previousFocus.Provenance
|
||||
}
|
||||
var planningReply string
|
||||
planningReply := deterministicReply
|
||||
workPlanned := false
|
||||
needsCognition := semantic.NeedsCognition
|
||||
projectID := ""
|
||||
@@ -406,7 +716,7 @@ func main() {
|
||||
ProjectID: work.ProjectRef(projectID),
|
||||
ProjectDisplayName: activeFocusName,
|
||||
ProjectProvenance: work.ProjectProvenanceRef(activeFocusProvenance),
|
||||
Message: utterance.Content,
|
||||
Message: effectiveTaskMessage,
|
||||
SpeechAct: work.SpeechActRef(semantic.Interpretation.SpeechAct),
|
||||
SecondarySpeechAct: work.SpeechActRef(semantic.Interpretation.SecondarySpeechAct),
|
||||
AssertionVerification: epistemics.VerificationState(semantic.Interpretation.AssertionVerification),
|
||||
@@ -420,12 +730,18 @@ func main() {
|
||||
helpPath = work.HelpPath{}
|
||||
}
|
||||
switch semantic.InformationRequestKind {
|
||||
case interaction.InformationRequestMemoryInventory:
|
||||
planningReply = humanMemoryInventory(conversationHistory, assistantName)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestTimeDifference:
|
||||
planningReply = humanTimeDifferenceReply(effectiveTaskMessage)
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCapabilityInventory:
|
||||
kinds, err := capabilityRepository.AvailableCapabilityKinds(r.Context(), session.PrincipalID)
|
||||
if err == nil && len(kinds) > 0 {
|
||||
planningReply = "Сейчас у меня подключены реальные capabilities: " + humanCapabilityList(kinds) + ". Для рассуждений я использую отдельный Cognitive Gateway/Router, если локальный CANARY доступен. Возможности вроде отправки почты пока будут фиксироваться как capability gap, а не как выполненное действие."
|
||||
planningReply = "Я умею общаться, искать проверяемую информацию, работать с подключёнными моделями и " + humanCapabilityList(kinds) + "."
|
||||
} else {
|
||||
planningReply = "Я умею записывать диалог и намерения, работать с текущим контекстом, строить планы и обращаться к Cognitive Gateway при доступности локального CANARY. Реальные action-capabilities подключаются через registry; если capability нет, я фиксирую gap."
|
||||
planningReply = "Я умею общаться, сохранять контекст, искать проверяемую информацию и работать с подключёнными моделями. Дополнительные действия подключаются отдельно."
|
||||
}
|
||||
needsCognition = false
|
||||
case interaction.InformationRequestCognitiveModelInventory:
|
||||
@@ -437,14 +753,56 @@ func main() {
|
||||
planningReply = humanCognitiveChannelReply(semantic.InformationRequestKind, semantic.Interpretation.SpeechAct, health, healthErr)
|
||||
needsCognition = false
|
||||
}
|
||||
researchBacked := false
|
||||
if hasChatRoute(routePlan, chatRouteResearch) && semantic.Interpretation.Clarification != interaction.ClarificationRequired {
|
||||
if planID, idErr := ids.New(); idErr == nil {
|
||||
parts := interactiondomain.IndependentRequestParts(effectiveTaskMessage)
|
||||
replies := make([]string, 0, len(parts))
|
||||
for index, part := range parts {
|
||||
if clarification := interactiondomain.LocalSearchClarification(part); clarification != "" {
|
||||
replies = append(replies, "По запросу «"+part+"»: "+clarification)
|
||||
continue
|
||||
}
|
||||
researchCtx, cancelResearch := context.WithTimeout(r.Context(), 8*time.Second)
|
||||
result, researchErr := researchClient.SearchWeb(researchCtx, epistemics.WebSearchRequest{
|
||||
Plan: epistemics.ResearchPlan{ID: epistemics.ResearchPlanID(fmt.Sprintf("plan:%s:%d", planID, index)), Question: part, SourceClasses: []string{"web-search"}, FreshnessPolicy: "current"},
|
||||
PropositionKey: fmt.Sprintf("question:%s:%d", utterance.ID, index), Query: part,
|
||||
})
|
||||
cancelResearch()
|
||||
reply, backed := renderResearchReply(part, result, researchErr)
|
||||
if backed {
|
||||
researchBacked = true
|
||||
}
|
||||
if len(parts) > 1 {
|
||||
reply = "По запросу «" + part + "»: " + reply
|
||||
}
|
||||
replies = append(replies, reply)
|
||||
}
|
||||
planningReply = strings.Join(replies, "\n\n")
|
||||
if researchBacked {
|
||||
semantic.Reply = planningReply
|
||||
}
|
||||
// A failed source must not turn the complete conversation into a
|
||||
// runtime failure or invite an unsupported local guess for a fact.
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if helpPath.Reply != "" {
|
||||
planningReply = helpPath.Reply
|
||||
needsCognition = false
|
||||
}
|
||||
if travelReply := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory); travelReply != "" {
|
||||
planningReply = travelReply
|
||||
needsCognition = false
|
||||
}
|
||||
if planningReply == "" && needsCognition && cognitiveAttempted && !cognitiveAvailable {
|
||||
planningReply = humanCognitiveDegradedReply(utterance.Content)
|
||||
needsCognition = false
|
||||
}
|
||||
if creativeFallbackReply != "" && cognitiveAttempted && !cognitiveAvailable {
|
||||
planningReply = creativeFallbackReply
|
||||
needsCognition = false
|
||||
}
|
||||
var capabilityStatus capability.CapabilityResolutionStatus
|
||||
var capabilityKind capability.CapabilityKind
|
||||
var capabilityGapID capability.CapabilityGapID
|
||||
@@ -521,6 +879,56 @@ func main() {
|
||||
planningReply = planningReply + " Для coding delegation подходящий agent implementation сейчас недоступен, поэтому я сохранил план без ложного запуска."
|
||||
}
|
||||
}
|
||||
if identityReply != "" {
|
||||
planningReply = identityReply
|
||||
needsCognition = false
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
reviewContext, cancelReview := context.WithTimeout(r.Context(), 12*time.Second)
|
||||
reviewedReply, reviewErr := cognitiveClient.ReviewResponse(reviewContext, session, utterance.Content, planningReply, cognitive.TaskMeaning{
|
||||
SpeechAct: string(semantic.Interpretation.SpeechAct),
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
RequestedOutcome: semantic.Interpretation.RequestedOutcome,
|
||||
InformationRequestKind: semantic.InformationRequestKind,
|
||||
ConfidenceMillis: semantic.Interpretation.ConfidenceMillis,
|
||||
})
|
||||
cancelReview()
|
||||
stableSemanticReply := identityReply != "" || creativeReply != "" || (creativeFallbackReply != "" && !cognitiveAvailable) || semantic.InformationRequestKind != "" || locationReply != "" || deterministicSpeechAct == interaction.SpeechActConversational || deterministicSpeechAct == interaction.SpeechActConfirmation || deterministicSpeechAct == interaction.SpeechActRejection
|
||||
if reviewErr != nil {
|
||||
log.Printf("cognitive response review unavailable: %v", reviewErr)
|
||||
} else if !stableSemanticReply && (!researchBacked || reviewPreservesResearchEvidence(reviewedReply, planningReply)) {
|
||||
planningReply = reviewedReply
|
||||
} else if reviewedReply != planningReply {
|
||||
log.Printf("cognitive response review proposed stable semantic rewrite; preserving semantic reply")
|
||||
}
|
||||
}
|
||||
if identityReply == "" {
|
||||
stableAct := interactiondomain.ClassifySpeechAct(utterance.Content, utterance.CorrectsUtteranceID)
|
||||
travelClarification := interactiondomain.TravelSearchClarification(effectiveTaskMessage, conversationHistory)
|
||||
if stableReply, stable := interactiondomain.StableConversationalReply(utterance.Content, stableAct); stable && travelClarification == "" {
|
||||
planningReply = stableReply
|
||||
needsCognition = false
|
||||
}
|
||||
}
|
||||
if identityReply != "" {
|
||||
semantic.Reply = identityReply
|
||||
needsCognition = false
|
||||
planningReply = identityReply
|
||||
}
|
||||
if creativeReply != "" {
|
||||
semantic.Reply = creativeReply
|
||||
needsCognition = false
|
||||
planningReply = creativeReply
|
||||
}
|
||||
if creativeFallbackReply != "" && !cognitiveAvailable {
|
||||
semantic.Reply = creativeFallbackReply
|
||||
needsCognition = false
|
||||
planningReply = creativeFallbackReply
|
||||
}
|
||||
if strings.TrimSpace(planningReply) != "" {
|
||||
semantic.Reply = planningReply
|
||||
}
|
||||
log.Printf("chat response final message=%q act=%s deterministic=%s handled=%t fallback=%q planning=%q", utterance.Content, semantic.Interpretation.SpeechAct, deterministicSpeechAct, responseResult.Handled, semantic.Reply, planningReply)
|
||||
response := interaction.ChatMessageResponse{
|
||||
ConversationID: conversation.ID,
|
||||
UtteranceID: utterance.ID,
|
||||
@@ -534,6 +942,7 @@ func main() {
|
||||
Clarification: semantic.Interpretation.Clarification,
|
||||
WorkPlanned: workPlanned,
|
||||
PlanningReply: planningReply,
|
||||
ResearchBacked: researchBacked,
|
||||
CapabilityStatus: capabilityStatus,
|
||||
CapabilityKind: capabilityKind,
|
||||
CapabilityHints: toCapabilityKinds(semantic.CapabilityHints),
|
||||
@@ -834,6 +1243,20 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(binding)
|
||||
})
|
||||
mux.HandleFunc("GET /v1/cognitive-bindings/{id}/revisions/{revision}/snapshot", func(w http.ResponseWriter, r *http.Request) {
|
||||
revision, err := strconv.ParseInt(r.PathValue("revision"), 10, 64)
|
||||
if err != nil || revision < 1 {
|
||||
http.Error(w, "invalid cognitive binding revision", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
snapshot, err := capabilityRepository.LoadCognitiveCandidate(r.Context(), capability.CapabilityBindingID(r.PathValue("id")), revision)
|
||||
if err != nil {
|
||||
http.Error(w, "cognitive binding snapshot not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(snapshot)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-bindings/resolve", func(w http.ResponseWriter, r *http.Request) {
|
||||
var requirement capability.CognitiveRequirement
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16*1024+1))
|
||||
@@ -911,6 +1334,20 @@ func main() {
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/cognitive-registry/model-qualification-observations", func(w http.ResponseWriter, r *http.Request) {
|
||||
var value capability.ModelQualificationObservation
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&value); err != nil || value.Validate() != nil {
|
||||
http.Error(w, "invalid model qualification observation", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := capabilityRepository.SaveModelQualificationObservation(r.Context(), value); err != nil {
|
||||
http.Error(w, "save model qualification observation", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/creator/cognitive-canary", func(w http.ResponseWriter, r *http.Request) {
|
||||
session, ok := authenticateCreator(w, r)
|
||||
if !ok {
|
||||
@@ -1215,6 +1652,132 @@ func envInt(name string, fallback int) int {
|
||||
return value
|
||||
}
|
||||
|
||||
func hasCapabilityHint(hints []string, wanted string) bool {
|
||||
for _, hint := range hints {
|
||||
if hint == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func renderResearchReply(query string, result epistemics.ResearchResult, researchErr error) (string, bool) {
|
||||
if researchErr != nil {
|
||||
return "Сейчас не удалось получить данные из подключённого источника. Попробую снова через доступный маршрут.", false
|
||||
}
|
||||
lines := make([]string, 0, 2)
|
||||
for index, finding := range result.Findings {
|
||||
if finding.State != epistemics.FindingSupported || strings.TrimSpace(finding.Excerpt.Text) == "" || strings.TrimSpace(finding.Source.Locator) == "" {
|
||||
continue
|
||||
}
|
||||
excerpt := strings.TrimSpace(finding.Excerpt.Text)
|
||||
limit := 180
|
||||
if index > 0 {
|
||||
limit = 100
|
||||
}
|
||||
if len([]rune(excerpt)) > limit {
|
||||
excerpt = string([]rune(excerpt)[:limit]) + "…"
|
||||
}
|
||||
lines = append(lines, excerpt+"\nИсточник: "+researchSourceLabel(finding))
|
||||
if len(lines) == 2 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(lines) == 0 {
|
||||
return "Проверяемых данных пока не найдено. Могу повторить поиск или уточнить, что именно проверить.", false
|
||||
}
|
||||
reply := strings.Join(lines, "\n")
|
||||
lowerQuery := strings.ToLower(query)
|
||||
if (strings.Contains(lowerQuery, "цен") || strings.Contains(lowerQuery, "стоим")) && !containsPriceEvidence(reply) {
|
||||
reply = "Актуальная цена в найденных фрагментах не указана, поэтому не буду придумывать сумму. Вот страницы, где её можно проверить:\n" + reply
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
func containsPriceEvidence(value string) bool {
|
||||
pricePattern := regexp.MustCompile(`(?i)(\d[\d\s.,]{2,})(?:\s*(?:₽|руб\.?|р\.?|usd|\$|€))`)
|
||||
return pricePattern.MatchString(value)
|
||||
}
|
||||
|
||||
func reviewPreservesResearchEvidence(reviewed, draft string) bool {
|
||||
if strings.Contains(draft, "http://") || strings.Contains(draft, "https://") {
|
||||
return strings.Contains(reviewed, "http://") || strings.Contains(reviewed, "https://")
|
||||
}
|
||||
if strings.Contains(draft, "Источник:") {
|
||||
return strings.Contains(reviewed, "Источник:")
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func humanMemoryInventory(history []interaction.HumanUtterance, assistantName string) string {
|
||||
rows := make([]string, 0, 4)
|
||||
if assistantName != "" {
|
||||
rows = append(rows, "| Имя помощника | "+assistantName+" |")
|
||||
}
|
||||
latestPreferenceAt := int64(-1)
|
||||
latestPreference := ""
|
||||
for _, utterance := range history {
|
||||
message := strings.TrimSpace(utterance.Content)
|
||||
lower := strings.ToLower(message)
|
||||
if (strings.Contains(lower, "нравится") || strings.Contains(lower, "люблю")) && utterance.ReceivedAtUnixMillis >= latestPreferenceAt {
|
||||
latestPreferenceAt = utterance.ReceivedAtUnixMillis
|
||||
latestPreference = message
|
||||
}
|
||||
}
|
||||
if latestPreference != "" {
|
||||
rows = append(rows, "| Предпочтение пользователя | "+latestPreference+" |")
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return "| Ячейка памяти | Значение |\n|---|---|\n| — | Сохранённых пользовательских предпочтений пока не найдено. |"
|
||||
}
|
||||
return "| Ячейка памяти | Значение |\n|---|---|\n" + strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func latestAssistantName(history []interaction.HumanUtterance) string {
|
||||
latestAt := int64(-1)
|
||||
latestName := ""
|
||||
for _, utterance := range history {
|
||||
if name := interactiondomain.PreferredAssistantName(utterance.Content); name != "" && utterance.ReceivedAtUnixMillis >= latestAt {
|
||||
latestAt = utterance.ReceivedAtUnixMillis
|
||||
latestName = name
|
||||
}
|
||||
}
|
||||
return latestName
|
||||
}
|
||||
|
||||
func humanTimeDifferenceReply(message string) string {
|
||||
lower := strings.ToLower(message)
|
||||
if strings.Contains(lower, "моск") && strings.Contains(lower, "шанх") {
|
||||
return "Шанхай опережает Москву на 5 часов. Если в Москве 12:00, в Шанхае 17:00."
|
||||
}
|
||||
return "Уточните два города — я сравню их часовые пояса."
|
||||
}
|
||||
|
||||
func researchSourceLabel(finding epistemics.ResearchFinding) string {
|
||||
title := strings.TrimSpace(finding.Source.Title)
|
||||
locator := strings.TrimSpace(finding.Source.Locator)
|
||||
publisher := strings.TrimSpace(finding.Source.Publisher)
|
||||
if strings.Contains(locator, "duckduckgo.com/html") || strings.Contains(locator, "bing.com/search") {
|
||||
locator = ""
|
||||
}
|
||||
if title != "" && publisher != "" {
|
||||
if locator != "" {
|
||||
return publisher + " (" + locator + ")"
|
||||
}
|
||||
return publisher
|
||||
}
|
||||
if title != "" {
|
||||
return title
|
||||
}
|
||||
if publisher != "" {
|
||||
return publisher
|
||||
}
|
||||
if locator != "" {
|
||||
return locator
|
||||
}
|
||||
return "подключённый источник"
|
||||
}
|
||||
|
||||
func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBinding, inventoryErr error) string {
|
||||
if inventoryErr != nil {
|
||||
return "Я не смогла прочитать реестр подключённых моделей. Встроенный канал Домового остаётся доступен; настройки можно проверить повторно после восстановления реестра. Ключи и пароли не выводятся в чат."
|
||||
@@ -1222,28 +1785,82 @@ func humanCognitiveModelInventory(candidates []capability.ResolvedCognitiveBindi
|
||||
if len(candidates) == 0 {
|
||||
return "В реестре пока нет подтверждённых подключённых моделей. Встроенный локальный канал Домового доступен всегда; внешние модели добавляются через настройки подключения. Ключи и пароли в чат не выводятся."
|
||||
}
|
||||
lines := make([]string, 0, len(candidates)+1)
|
||||
lines = append(lines, "Подключённые модели из реестра:")
|
||||
type inventoryEntry struct {
|
||||
name string
|
||||
status string
|
||||
rank int
|
||||
seenAt int
|
||||
}
|
||||
entries := make([]inventoryEntry, 0, len(candidates))
|
||||
byName := make(map[string]int, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
modelName := string(candidate.Model.ProviderModelID)
|
||||
if modelName == "" {
|
||||
modelName = "имя модели не подтверждено"
|
||||
continue
|
||||
}
|
||||
status := cognitiveInventoryStatus(candidate)
|
||||
pricing := cognitiveInventoryPricing(candidate.Provider)
|
||||
details := []string{status, "тип " + string(candidate.Provider.Kind), "политика " + string(candidate.Binding.DataPolicy)}
|
||||
if pricing != "" {
|
||||
details = append(details, pricing)
|
||||
status := conciseCognitiveInventoryStatus(candidate)
|
||||
rank := cognitiveInventoryRank(candidate)
|
||||
if index, exists := byName[modelName]; exists {
|
||||
if rank < entries[index].rank {
|
||||
entries[index].status = status
|
||||
entries[index].rank = rank
|
||||
}
|
||||
continue
|
||||
}
|
||||
if candidate.Provider.AuthMode != "" && candidate.Provider.AuthMode != capability.AuthNone {
|
||||
details = append(details, "доступ "+humanAuthMode(candidate.Provider.AuthMode))
|
||||
byName[modelName] = len(entries)
|
||||
entries = append(entries, inventoryEntry{name: modelName, status: status, rank: rank, seenAt: len(entries)})
|
||||
}
|
||||
sort.SliceStable(entries, func(left, right int) bool {
|
||||
if entries[left].rank != entries[right].rank {
|
||||
return entries[left].rank < entries[right].rank
|
||||
}
|
||||
lines = append(lines, "- "+modelName+" ("+strings.Join(details, "; ")+")")
|
||||
return entries[left].seenAt < entries[right].seenAt
|
||||
})
|
||||
lines := make([]string, 0, len(entries)+2)
|
||||
lines = append(lines, "Доступные модели:")
|
||||
unconfirmed := 0
|
||||
for _, entry := range entries {
|
||||
if entry.rank >= 3 {
|
||||
unconfirmed++
|
||||
continue
|
||||
}
|
||||
lines = append(lines, "- "+entry.name+" — "+entry.status)
|
||||
}
|
||||
if len(lines) == 1 {
|
||||
return "Подтверждённых доступных моделей сейчас нет."
|
||||
}
|
||||
if unconfirmed > 0 {
|
||||
lines = append(lines, fmt.Sprintf("Ещё %d моделей требуют проверки.", unconfirmed))
|
||||
}
|
||||
lines = append(lines, "Секреты, ключи и пароли намеренно не показываются.")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func conciseCognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
return "активна"
|
||||
}
|
||||
return "доступна"
|
||||
}
|
||||
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
|
||||
return "работает с ограничениями"
|
||||
}
|
||||
return "не подтверждена"
|
||||
}
|
||||
|
||||
func cognitiveInventoryRank(candidate capability.ResolvedCognitiveBinding) int {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if candidate.Provider.Health == capability.HealthDegraded || candidate.Model.Health == capability.HealthDegraded {
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func cognitiveInventoryStatus(candidate capability.ResolvedCognitiveBinding) string {
|
||||
if candidate.Provider.Health == capability.HealthAvailable && candidate.Model.Health == capability.HealthAvailable {
|
||||
if candidate.Binding.Activation == capability.ActivationActive {
|
||||
@@ -1290,44 +1907,26 @@ func humanAuthMode(mode capability.AuthMode) string {
|
||||
}
|
||||
|
||||
func humanCognitiveChannelReply(kind interaction.InformationRequestKind, act interaction.SpeechAct, health capability.Health, healthErr error) string {
|
||||
recoveryRequested := kind == interaction.InformationRequestCognitiveRecovery || act == interaction.SpeechActRequestToInvestigate || act == interaction.SpeechActDirective
|
||||
if healthErr != nil {
|
||||
if recoveryRequested {
|
||||
return "Я не вижу подтверждённого состояния локального канала. Домовой всё равно рядом: опишите, что изменилось, и я помогу подготовить аварийную настройку."
|
||||
}
|
||||
return "Я не вижу подтверждённого состояния локального канала, но Домовой работает. Можно продолжать разговор и проверить канал ещё раз."
|
||||
return "Не удалось проверить канал. Домовой остаётся на связи."
|
||||
}
|
||||
var observation string
|
||||
switch health.State {
|
||||
case capability.HealthAvailable:
|
||||
observation = "Основной локальный канал сейчас доступен."
|
||||
return "Канал мышления доступен."
|
||||
case capability.HealthDegraded:
|
||||
observation = "Основной локальный канал сейчас работает с ограничениями."
|
||||
return "Канал мышления работает с ограничениями."
|
||||
case capability.HealthUnavailable:
|
||||
observation = "Основной локальный канал сейчас недоступен."
|
||||
return "Канал мышления недоступен. Домовой остаётся на связи."
|
||||
default:
|
||||
observation = "Состояние основного локального канала пока не подтверждено."
|
||||
return "Состояние канала пока не подтверждено. Домовой остаётся на связи."
|
||||
}
|
||||
if recoveryRequested {
|
||||
if health.State == capability.HealthUnavailable {
|
||||
return observation + " Домовой жив: начну с проверки встроенного маршрута, затем зафиксирую варианты аварийного восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Я не меняю модель прямо из разговора: сначала покажу наблюдаемое состояние и подготовлю следующий проверяемый шаг восстановления."
|
||||
}
|
||||
if health.State == capability.HealthUnavailable {
|
||||
if recoveryRequested {
|
||||
return observation + " Домовой не остановился: начну с проверки встроенного маршрута, затем зафиксирую доступные варианты восстановления без передачи секретов."
|
||||
}
|
||||
return observation + " Но Домовой не остановился: продолжаю принимать сообщения и помогу восстановить локальный канал."
|
||||
}
|
||||
return observation + " Это наблюдение о готовности capability, а не прямой доступ к модели."
|
||||
}
|
||||
|
||||
func humanCognitiveDegradedReply(message string) string {
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return "Основной канал мышления сейчас недоступен. Я рядом и продолжаю работать в локальном аварийном режиме."
|
||||
return "Сейчас когнитивный канал недоступен; Домовой остаётся на связи."
|
||||
}
|
||||
return "Основной канал мышления сейчас недоступен. Домовой жив: сообщение принято, а я могу помочь разобраться с локальным каналом и настройкой восстановления."
|
||||
return "Сейчас когнитивный канал недоступен. Повторю запрос через доступный маршрут, когда он восстановится."
|
||||
}
|
||||
|
||||
func humanCapabilityList(kinds []capability.CapabilityKind) string {
|
||||
|
||||
@@ -27,6 +27,36 @@ func TestBearerTokenRequiresBearerScheme(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLatestAssistantNameUsesNewestDurablePreference(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{Content: "я назову тебя Дед", ReceivedAtUnixMillis: 20},
|
||||
{Content: "теперь тебя зовут Фея", ReceivedAtUnixMillis: 30},
|
||||
}
|
||||
if got := latestAssistantName(history); got != "Фея" {
|
||||
t.Fatalf("latest assistant name = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanMemoryInventoryUsesNewestPreference(t *testing.T) {
|
||||
history := []interaction.HumanUtterance{
|
||||
{Content: "мне нравится синий цвет", ReceivedAtUnixMillis: 20},
|
||||
{Content: "мне нравится красный цвет", ReceivedAtUnixMillis: 30},
|
||||
}
|
||||
got := humanMemoryInventory(history, "Фея")
|
||||
if !strings.Contains(got, "красный") || strings.Contains(got, "синий") {
|
||||
t.Fatalf("memory inventory did not select newest preference: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainsPriceEvidenceDistinguishesLinksFromPrices(t *testing.T) {
|
||||
if containsPriceEvidence("RTX 4090 — market.yandex.ru") {
|
||||
t.Fatal("a product link is not price evidence")
|
||||
}
|
||||
if !containsPriceEvidence("Цена: 189 990 ₽") {
|
||||
t.Fatal("expected a currency amount to count as price evidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveChannelStatus, interaction.SpeechActQuestion, capability.Health{State: capability.HealthAvailable, CapabilityID: "provider-secret-model"}, nil)
|
||||
if !strings.Contains(reply, "доступен") || strings.Contains(reply, "provider-secret-model") {
|
||||
@@ -36,14 +66,14 @@ func TestCognitiveChannelQuestionUsesHumanSafeHealthReply(t *testing.T) {
|
||||
|
||||
func TestCognitiveConfigurationRequestDoesNotPretendToMutate(t *testing.T) {
|
||||
reply := humanCognitiveChannelReply(interaction.InformationRequestCognitiveRecovery, interaction.SpeechActDirective, capability.Health{State: capability.HealthUnavailable}, nil)
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "аварийн") {
|
||||
if reply != "Канал мышления недоступен. Домовой остаётся на связи." {
|
||||
t.Fatalf("unexpected configuration reply: %q", reply)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCognitiveDegradedReplyKeepsDomovoyAlive(t *testing.T) {
|
||||
reply := humanCognitiveDegradedReply("Помоги настроить локальную модель")
|
||||
if !strings.Contains(reply, "Домовой жив") || !strings.Contains(reply, "принято") {
|
||||
if !strings.Contains(reply, "когнитивный канал") || !strings.Contains(reply, "Повторю") {
|
||||
t.Fatalf("unexpected degraded reply: %q", reply)
|
||||
}
|
||||
}
|
||||
@@ -74,11 +104,21 @@ func TestHumanCognitiveModelInventoryDoesNotExposeSecrets(t *testing.T) {
|
||||
Provider: capability.ProviderInstance{Kind: "openai-compatible-chat-v1", CredentialRef: "secret-ref", AuthMode: capability.AuthKey, BillingMode: capability.BillingPerRequest, RequestCostMicros: &cost, Health: capability.HealthAvailable},
|
||||
Model: capability.ModelDescriptor{ProviderModelID: "example-model", Health: capability.HealthAvailable},
|
||||
}}, nil)
|
||||
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "API-ключ") {
|
||||
if strings.Contains(text, "secret-ref") || strings.Contains(text, "25") || !strings.Contains(text, "example-model") || !strings.Contains(text, "активна") {
|
||||
t.Fatalf("unsafe or incomplete inventory: %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHumanCognitiveModelInventoryDeduplicatesAndPrioritizesAvailable(t *testing.T) {
|
||||
text := humanCognitiveModelInventory([]capability.ResolvedCognitiveBinding{
|
||||
{Binding: capability.CognitiveBinding{Activation: capability.ActivationInactive}, Provider: capability.ProviderInstance{Health: capability.HealthUnavailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthUnavailable}},
|
||||
{Binding: capability.CognitiveBinding{Activation: capability.ActivationActive}, Provider: capability.ProviderInstance{Health: capability.HealthAvailable}, Model: capability.ModelDescriptor{ProviderModelID: "same-model", Health: capability.HealthAvailable}},
|
||||
}, nil)
|
||||
if strings.Count(text, "same-model") != 1 || !strings.Contains(text, "same-model — активна") {
|
||||
t.Fatalf("expected one active model entry, got %q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatorSessionHandlerAcceptsConfiguredCredentials(t *testing.T) {
|
||||
handler := creatorSessionHandler("recovery", "m", "password", []byte("01234567890123456789012345678901"), func() time.Time { return time.Unix(1000, 0) })
|
||||
request := httptest.NewRequest(http.MethodPost, "/v1/creator/sessions", strings.NewReader(`{"username":"m","password":"password"}`))
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
type researchRuntimeClient struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newResearchRuntimeClient(rawURL string, client *http.Client) (researchRuntimeClient, error) {
|
||||
base := strings.TrimRight(strings.TrimSpace(rawURL), "/")
|
||||
if base == "" {
|
||||
return researchRuntimeClient{}, fmt.Errorf("research runtime URL is empty")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return researchRuntimeClient{baseURL: base, client: client}, nil
|
||||
}
|
||||
|
||||
func (c researchRuntimeClient) EvaluateWeb(ctx context.Context, request epistemics.WebResearchRequest) (epistemics.ResearchResult, error) {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/evaluate", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchResult{}, fmt.Errorf("research runtime status %d", response.StatusCode)
|
||||
}
|
||||
var result epistemics.ResearchResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c researchRuntimeClient) SearchWeb(ctx context.Context, request epistemics.WebSearchRequest) (epistemics.ResearchResult, error) {
|
||||
payload, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/research/web/search", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
httpRequest.Header.Set("Content-Type", "application/json")
|
||||
response, err := c.client.Do(httpRequest)
|
||||
if err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return epistemics.ResearchResult{}, fmt.Errorf("research search status %d", response.StatusCode)
|
||||
}
|
||||
var result epistemics.ResearchResult
|
||||
if err := json.NewDecoder(response.Body).Decode(&result); err != nil {
|
||||
return epistemics.ResearchResult{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/epistemics"
|
||||
)
|
||||
|
||||
func TestResearchRuntimeClientForwardsBoundedWebRequest(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/research/web/evaluate" || r.Header.Get("Content-Type") != "application/json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request epistemics.WebResearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil || request.URL != "https://assistant.cin.su/healthz" {
|
||||
t.Fatalf("unexpected request body: %#v %v", request, err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(epistemics.ResearchResult{PlanID: request.Plan.ID, Findings: []epistemics.ResearchFinding{{PropositionKey: request.PropositionKey, State: epistemics.FindingSupported}}})
|
||||
}))
|
||||
defer server.Close()
|
||||
client, err := newResearchRuntimeClient(server.URL, server.Client())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := client.EvaluateWeb(context.Background(), epistemics.WebResearchRequest{Plan: epistemics.ResearchPlan{ID: "plan-1"}, PropositionKey: "health", URL: "https://assistant.cin.su/healthz"})
|
||||
if err != nil || result.PlanID != "plan-1" || len(result.Findings) != 1 || result.Findings[0].State != epistemics.FindingSupported {
|
||||
t.Fatalf("unexpected result: %#v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,96 @@ func TestChatMessageUsesCoreAndLocalOnlyCognitiveGateway(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageDoesNotExposeUnverifiedQuestionAsFact(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, CapabilityHints: []capability.CapabilityKind{capability.CapabilityKindWebResearch}, FallbackText: "небезопасная модельная догадка"})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost || r.URL.Path != "/v1/advisories" {
|
||||
t.Fatalf("unexpected cognitive route: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я продолжаю искать проверяемый ответ через доступный источник.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Какая погода?"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.Cognition != string(cognitive.FinishCompleted) || strings.Contains(response.ReplyText, "небезопасная") || !strings.Contains(response.ReplyText, "продолжаю искать") {
|
||||
t.Fatalf("unexpected research recovery response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageUsesLocalAdvisoryForOrdinaryConversationalQuestion(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/v1/creator/sessions/current":
|
||||
_ = json.NewEncoder(w).Encode(identity.CreatorSession{ActorID: "actor-1", PrincipalID: "principal-1"})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/v1/creator/chat/messages":
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(interaction.ChatMessageResponse{ConversationID: "conversation-1", UtteranceID: "utterance-1", SpeechAct: interaction.SpeechActQuestion, NeedsCognition: true, FallbackText: "fallback"})
|
||||
default:
|
||||
t.Fatalf("unexpected core route %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer core.Close()
|
||||
cognitiveRuntime := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/advisories" {
|
||||
t.Fatalf("unexpected cognitive path %s", r.URL.Path)
|
||||
}
|
||||
var request cognitive.GatewayAdvisoryRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(cognitive.GatewayAdvisoryResponse{Version: cognitive.CreatorAdvisoryV1, RequestID: request.RequestID, AdvisoryText: "Я рядом. Давай поговорим.", FinishReason: cognitive.FinishCompleted})
|
||||
}))
|
||||
defer cognitiveRuntime.Close()
|
||||
handler, err := newHandler(core.URL, "http://continuity", "http://commit", core.Client(), cognitiveRuntime.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/chat/messages", strings.NewReader(`{"message":"Как ты?"}`))
|
||||
request.Header.Set("Authorization", "Bearer session-token")
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("expected status 200, got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
var response browserChatResponse
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if response.ReplyText != "Я рядом. Давай поговорим." || response.Cognition != string(cognitive.FinishCompleted) {
|
||||
t.Fatalf("unexpected conversational response: %#v", response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatMessageReturnsHumanCapabilityMetadata(t *testing.T) {
|
||||
core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
|
||||
@@ -13,7 +13,14 @@ func main() {
|
||||
envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"),
|
||||
envOr("CONTINUITY_RUNTIME_URL", "http://continuity-runtime:8081"),
|
||||
envOr("COMMIT_RUNTIME_URL", "http://commit-runtime:8082"),
|
||||
&http.Client{Timeout: 10 * time.Second},
|
||||
// Core chat may spend several seconds on semantic interpretation and
|
||||
// local cognition. Keep the browser gateway alive long enough to return
|
||||
// the completed response instead of converting a successful core 201
|
||||
// into a misleading 502.
|
||||
// A cold local model may need more than one short HTTP round-trip to
|
||||
// load on CPU. Keep the browser gateway aligned with the cognition
|
||||
// deadline instead of turning that wait into a false unavailable reply.
|
||||
&http.Client{Timeout: 90 * time.Second},
|
||||
envOr("COGNITIVE_RUNTIME_URL", "http://cognitive-worker:8083"),
|
||||
envOr("AGENT_WORKER_URL", "http://agent-worker:8090"),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCreatorConsoleResponsiveGuardrails(t *testing.T) {
|
||||
html, err := staticFiles.ReadFile("static/index.html")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(html), `<meta name="viewport" content="width=device-width, initial-scale=1">`) {
|
||||
t.Fatal("creator console is missing responsive viewport metadata")
|
||||
}
|
||||
css, err := staticFiles.ReadFile("static/assets/console.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stylesheet := string(css)
|
||||
for _, guard := range []string{
|
||||
"@media (max-width: 680px)",
|
||||
".console { display: block; }",
|
||||
".diagnostic-list { grid-template-columns: 1fr; }",
|
||||
".agent-details { grid-template-columns: 1fr; gap: 14px; }",
|
||||
".fleet-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }",
|
||||
".section-nav { display: flex;",
|
||||
"overflow-x: auto",
|
||||
} {
|
||||
if !strings.Contains(stylesheet, guard) {
|
||||
t.Fatalf("responsive guardrail missing: %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatResponsiveGuardrailsConstrainMobileGridItem(t *testing.T) {
|
||||
css, err := staticFiles.ReadFile("static/assets/chat.css")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stylesheet := string(css)
|
||||
for _, guard := range []string{
|
||||
".chat-view { width: 100%; min-width: 0;",
|
||||
"@media (max-width: 640px)",
|
||||
".chat-layout { height: auto; display: block; }",
|
||||
".chat-header > .operator-link, .build-meta { display: none; }",
|
||||
".chat-header .cognitive-console-link { flex: 0 1 110px;",
|
||||
} {
|
||||
if !strings.Contains(stylesheet, guard) {
|
||||
t.Fatalf("chat responsive guardrail missing: %s", guard)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -150,7 +150,7 @@ func (g *gateway) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
|
||||
if r.URL.Path == "/" || r.URL.Path == "/login" || r.URL.Path == "/chat" || r.URL.Path == "/chat.html" {
|
||||
g.serveEmbeddedHTML(w, "chat.html")
|
||||
return
|
||||
}
|
||||
@@ -209,6 +209,7 @@ type browserChatResponse struct {
|
||||
CapabilityKind string `json:"capability_kind"`
|
||||
CapabilityHints []capability.CapabilityKind `json:"capability_hints,omitempty"`
|
||||
RequestedOutcome string `json:"requested_outcome,omitempty"`
|
||||
ResearchBacked bool `json:"research_backed"`
|
||||
InterpretationConfidenceMillis int64 `json:"interpretation_confidence_millis,omitempty"`
|
||||
DelegationID string `json:"delegation_id"`
|
||||
DelegationState string `json:"delegation_state"`
|
||||
@@ -501,6 +502,7 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
CapabilityKind: string(saved.CapabilityKind),
|
||||
CapabilityHints: saved.CapabilityHints,
|
||||
RequestedOutcome: saved.RequestedOutcome,
|
||||
ResearchBacked: saved.ResearchBacked,
|
||||
InterpretationConfidenceMillis: saved.InterpretationConfidenceMillis,
|
||||
DelegationID: saved.DelegationID,
|
||||
DelegationState: saved.DelegationState,
|
||||
@@ -509,13 +511,18 @@ func (g *gateway) handleChatMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func localChatContext(saved interaction.ChatMessageResponse) []cognitive.ContextExcerpt {
|
||||
if strings.TrimSpace(saved.ActiveProjectName) == "" {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
MaxBytes: 2048,
|
||||
Candidates: []cognitive.ContextCandidate{{
|
||||
candidates := []cognitive.ContextCandidate{{
|
||||
Excerpt: cognitive.ContextExcerpt{
|
||||
ID: "assistant-identity",
|
||||
SourceKind: "assistant-identity-policy",
|
||||
SourceID: "domovoy",
|
||||
Sensitivity: cognitive.SensitivityPublic,
|
||||
Text: "Ты — Домовой, локальный помощник пользователя. Отвечай от имени Домового. Не называй себя Qwen, Alibaba Cloud, поставщиком модели или внутренним runtime. Если спрашивают имя, скажи: «Меня зовут Домовой». Твоя задача — понять смысл запроса, найти путь к цели и довести помощь до полезного конечного результата; не ограничивайся пересказом отказа модели.",
|
||||
},
|
||||
Relevance: 100,
|
||||
}}
|
||||
if strings.TrimSpace(saved.ActiveProjectName) != "" {
|
||||
candidates = append(candidates, cognitive.ContextCandidate{
|
||||
Excerpt: cognitive.ContextExcerpt{
|
||||
ID: cognitive.ContextExcerptID("active-project-focus"),
|
||||
SourceKind: "situation-active-project",
|
||||
@@ -523,8 +530,13 @@ func localChatContext(saved interaction.ChatMessageResponse) []cognitive.Context
|
||||
Sensitivity: cognitive.SensitivityPrivateAllowed,
|
||||
Text: "Текущий проект: " + strings.TrimSpace(saved.ActiveProjectName),
|
||||
},
|
||||
Relevance: 100,
|
||||
}},
|
||||
Relevance: 90,
|
||||
})
|
||||
}
|
||||
bundle, err := cognitive.CompileContext(cognitive.ContextCompileInput{
|
||||
DataPolicy: capability.DataPolicyLocalOnly,
|
||||
MaxBytes: 2048,
|
||||
Candidates: candidates,
|
||||
})
|
||||
if err != nil {
|
||||
return []cognitive.ContextExcerpt{}
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
"git.cin.su/m/Assistant/contracts/interaction"
|
||||
)
|
||||
|
||||
func TestLocalChatContextUsesOnlyActiveProject(t *testing.T) {
|
||||
func TestLocalChatContextIncludesDomovoyIdentityAndOptionalActiveProject(t *testing.T) {
|
||||
context := localChatContext(interaction.ChatMessageResponse{ActiveProjectName: "Домовой"})
|
||||
if len(context) != 1 || context[0].SourceKind != "situation-active-project" || context[0].Text != "Текущий проект: Домовой" {
|
||||
if len(context) != 2 || context[0].SourceID != "domovoy" || !strings.Contains(context[0].Text, "Ты — Домовой") || context[1].Text != "Текущий проект: Домовой" {
|
||||
t.Fatalf("unexpected local context: %#v", context)
|
||||
}
|
||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 0 {
|
||||
t.Fatalf("expected empty context without active project: %#v", empty)
|
||||
if empty := localChatContext(interaction.ChatMessageResponse{}); len(empty) != 1 || empty[0].SourceID != "domovoy" {
|
||||
t.Fatalf("identity context must remain available without active project: %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +354,18 @@ func TestConsoleAndSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRouteServesChatShell(t *testing.T) {
|
||||
handler, err := newHandler("http://core-runtime:8080", "http://continuity-runtime:8081", "http://commit-runtime:8082", http.DefaultClient)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "id=\"username\"") || !strings.Contains(response.Body.String(), "id=\"password\"") {
|
||||
t.Fatalf("login response = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdvisoryModeRejectsInvalidConfiguration(t *testing.T) {
|
||||
_, err := newHandlerWithAdvisoryMode("http://core.invalid", "http://continuity.invalid", "http://commit.invalid", http.DefaultClient, "browser-selected")
|
||||
if err == nil || !strings.Contains(err.Error(), "invalid advisory binding mode") {
|
||||
|
||||
@@ -29,7 +29,7 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.login-form button, .composer button { border: 0; color: #11150f; background: var(--signal); font-weight: 700; }
|
||||
.login-form button { width: 100%; height: 50px; margin-top: 18px; }
|
||||
#login-error { min-height: 20px; color: var(--bad); font-size: 13px; }
|
||||
.chat-view { height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-view { width: 100%; min-width: 0; height: 100vh; min-height: 0; display: grid; grid-template-rows: 64px minmax(0, 1fr); overflow: hidden; }
|
||||
.chat-header { min-width: 0; display: flex; align-items: center; gap: 14px; padding: 0 22px; border-bottom: 1px solid var(--line); background: rgba(16, 23, 20, .98); }
|
||||
.chat-header a { color: var(--ink); text-decoration: none; font: 400 24px/1 var(--serif); }
|
||||
#active-focus { color: var(--good); }
|
||||
@@ -107,9 +107,10 @@ textarea { resize: vertical; min-height: 84px; max-height: 240px; padding: 14px;
|
||||
.conversation-starters { align-items: stretch; }
|
||||
.conversation-starters span { width: 100%; }
|
||||
.chat-header { gap: 10px; padding: 0 12px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; max-width: 150px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.build-meta { font-size: 8px; letter-spacing: .04em; }
|
||||
.build-meta time { font-size: 7px; }
|
||||
.chat-header .cognitive-status { display: inline-flex; flex: 1 1 110px; min-width: 0; max-width: 110px; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header .cognitive-console-link { flex: 0 1 110px; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
||||
.chat-header > .operator-link, .build-meta { display: none; }
|
||||
.chat-header > #logout { flex: 0 0 38px; }
|
||||
.chat-layout { height: auto; display: block; }
|
||||
.chat-side, .chat-main { min-height: auto; max-height: none; overflow: visible; }
|
||||
.chat-left, .chat-right { border: 0; border-bottom: 1px solid var(--line); }
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
@@ -29,9 +31,21 @@ func main() {
|
||||
panic(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
status := newWorkerStatus(*workerID, *once)
|
||||
listenAddr := os.Getenv("REFLECTION_LISTEN_ADDR")
|
||||
if listenAddr == "" {
|
||||
listenAddr = ":8092"
|
||||
}
|
||||
go func() {
|
||||
if err := http.ListenAndServe(listenAddr, status.handler()); err != nil {
|
||||
log.Printf("reflection status server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
for {
|
||||
now := time.Now().UnixMilli()
|
||||
status.cycleStarted(time.Now())
|
||||
if _, err := repository.RecoverReflectionClaims(ctx, now); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
candidate, claimErr := repository.ClaimReflectionCandidate(ctx, *workerID, now, lease.Milliseconds())
|
||||
@@ -39,16 +53,19 @@ func main() {
|
||||
// This worker only closes the durable evaluation loop. Any cognitive
|
||||
// proposal remains a candidate and must be admitted by a separate governed flow.
|
||||
if err := repository.ResolveReflectionCandidate(ctx, candidate.ID, *workerID, time.Now().UnixMilli()); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
fmt.Printf("resolved reflection candidate %s\n", candidate.ID)
|
||||
} else if *once {
|
||||
fmt.Println("no reflection candidate")
|
||||
}
|
||||
status.cycleFinished(time.Now(), map[bool]string{true: "candidate-resolved", false: "idle"}[claimErr == nil])
|
||||
if *once {
|
||||
return
|
||||
}
|
||||
if err := repository.WaitForReflectionCandidate(ctx, *pollInterval); err != nil {
|
||||
status.cycleFailed(time.Now(), err)
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
)
|
||||
|
||||
type workerStatus struct {
|
||||
mu sync.RWMutex
|
||||
workerID string
|
||||
once bool
|
||||
startedAt time.Time
|
||||
lastCycleAt time.Time
|
||||
lastCompleted time.Time
|
||||
lastResult string
|
||||
lastError string
|
||||
}
|
||||
|
||||
func newWorkerStatus(workerID string, once bool) *workerStatus {
|
||||
return &workerStatus{workerID: workerID, once: once, startedAt: time.Now().UTC()}
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleStarted(at time.Time) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCycleAt = at.UTC()
|
||||
s.lastResult = "running"
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleFinished(at time.Time, result string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompleted = at.UTC()
|
||||
s.lastResult = result
|
||||
s.lastError = ""
|
||||
}
|
||||
|
||||
func (s *workerStatus) cycleFailed(at time.Time, err error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.lastCompleted = at.UTC()
|
||||
s.lastResult = "failed"
|
||||
s.lastError = err.Error()
|
||||
}
|
||||
|
||||
func (s *workerStatus) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "reflection-worker", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) {
|
||||
s.mu.RLock()
|
||||
response := struct {
|
||||
Runtime string `json:"runtime"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
Mode string `json:"mode"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
LastCycleAt time.Time `json:"last_cycle_at,omitempty"`
|
||||
LastCompleted time.Time `json:"last_completed_at,omitempty"`
|
||||
LastResult string `json:"last_result"`
|
||||
LastError string `json:"last_error,omitempty"`
|
||||
}{Runtime: "reflection-worker", WorkerID: s.workerID, Mode: "durable-reflection-loop", StartedAt: s.startedAt, LastCycleAt: s.lastCycleAt, LastCompleted: s.lastCompleted, LastResult: s.lastResult, LastError: s.lastError}
|
||||
if s.once {
|
||||
response.Mode = "durable-reflection-once"
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(response)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestWorkerStatusReportsCycle(t *testing.T) {
|
||||
status := newWorkerStatus("test-worker", false)
|
||||
status.cycleStarted(time.Date(2026, 8, 23, 10, 0, 0, 0, time.UTC))
|
||||
status.cycleFinished(time.Date(2026, 8, 23, 10, 0, 1, 0, time.UTC), "idle")
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
status.handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/status", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d, want 200", recorder.Code)
|
||||
}
|
||||
body := recorder.Body.String()
|
||||
for _, expected := range []string{"\"runtime\":\"reflection-worker\"", "\"worker_id\":\"test-worker\"", "\"last_result\":\"idle\""} {
|
||||
if !strings.Contains(body, expected) {
|
||||
t.Fatalf("status body missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkerHealthIsAvailable(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
newWorkerStatus("test-worker", true).handler().ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("health status code = %d, want 200", recorder.Code)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "research-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "research-runtime", Probes: []health.Probe{{Name: "epistemics-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.Handle("/v1/research/status", truthRuntimeStatusHandler(repository))
|
||||
mux.HandleFunc("POST /v1/research/evaluate", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.ResearchEvaluationRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 2<<20)).Decode(&request); err != nil {
|
||||
@@ -88,6 +89,35 @@ func main() {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
searchConnector := researchadapter.SearchConnector{
|
||||
EndpointTemplate: os.Getenv("RESEARCH_SEARCH_URL_TEMPLATE"),
|
||||
AllowedHosts: allowedHosts(os.Getenv("RESEARCH_SEARCH_ALLOWED_HOSTS")),
|
||||
FallbackEndpoints: []researchadapter.SearchEndpoint{{
|
||||
Template: "https://html.duckduckgo.com/html/?q=%s",
|
||||
AllowedHosts: map[string]struct{}{"html.duckduckgo.com": {}},
|
||||
}},
|
||||
Client: &http.Client{Timeout: 8 * time.Second},
|
||||
MaxResults: 3,
|
||||
}
|
||||
mux.HandleFunc("POST /v1/research/web/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request epistemics.WebSearchRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid web search request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
candidates, err := searchConnector.Collect(r.Context(), request, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
result, err := application.EvaluateAndPersist(r.Context(), epistemics.ResearchEvaluationRequest{Plan: request.Plan, Candidates: candidates}, repository, time.Now().UnixMilli())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(result)
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
addr = ":8087"
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type truthRuntimeStatusReader interface {
|
||||
TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error)
|
||||
}
|
||||
|
||||
type truthRuntimeStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
BundleCount int64 `json:"bundle_count"`
|
||||
ClaimsByVerification map[string]int64 `json:"claims_by_verification"`
|
||||
BeliefsByState map[string]int64 `json:"beliefs_by_state"`
|
||||
SourcesByFreshness map[string]int64 `json:"sources_by_freshness"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func truthRuntimeStatusHandler(reader truthRuntimeStatusReader) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/research/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
status, err := reader.TruthRuntimeStatus(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "research status unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(truthRuntimeStatusResponse{
|
||||
Runtime: "research-runtime",
|
||||
Status: "ready",
|
||||
BundleCount: status.BundleCount,
|
||||
ClaimsByVerification: status.ClaimsByVerification,
|
||||
BeliefsByState: status.BeliefsByState,
|
||||
SourcesByFreshness: status.SourcesByFreshness,
|
||||
ObservedAtUnixMillis: status.ObservedAtUnixMillis,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type fakeTruthRuntimeStatusReader struct {
|
||||
status postgres.TruthRuntimeStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeTruthRuntimeStatusReader) TruthRuntimeStatus(context.Context) (postgres.TruthRuntimeStatus, error) {
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func TestTruthRuntimeStatusReportsEvidenceCounts(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{status: postgres.TruthRuntimeStatus{
|
||||
BundleCount: 4,
|
||||
ClaimsByVerification: map[string]int64{"UNVERIFIED": 3, "VERIFIED": 1},
|
||||
BeliefsByState: map[string]int64{"PROVISIONAL": 4},
|
||||
SourcesByFreshness: map[string]int64{"FRESH": 4},
|
||||
ObservedAtUnixMillis: 1700000000000,
|
||||
}}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
for _, expected := range []string{"\"status\":\"ready\"", "\"bundle_count\":4", "\"VERIFIED\":1", "\"PROVISIONAL\":4", "1700000000000"} {
|
||||
if !strings.Contains(response.Body.String(), expected) {
|
||||
t.Fatalf("response missing %q: %s", expected, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruthRuntimeStatusFailsClosedOnDatabaseError(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
truthRuntimeStatusHandler(fakeTruthRuntimeStatusReader{err: errors.New("database unavailable")}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/research/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
const verifierIdentity = "deterministic-task-verifier-v1"
|
||||
const genericVerifierIdentity = "deterministic-generic-envelope-verifier-v1"
|
||||
|
||||
func main() {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"runtime":"task-verifier","status":"ok"}`))
|
||||
})
|
||||
mux.HandleFunc("POST /v1/task-evaluations/verify", verifyHandler)
|
||||
mux.HandleFunc("POST /v1/work-graphs/candidates/verify", genericCandidateVerifyHandler)
|
||||
addr := ":8091"
|
||||
if value := strings.TrimSpace(os.Getenv("LISTEN_ADDR")); value != "" {
|
||||
addr = value
|
||||
}
|
||||
if err := http.ListenAndServe(addr, mux); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func genericCandidateVerifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.GenericCandidateVerificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.GraphID == "" || request.GraphRevision < 1 || request.NodeID == "" || request.AuthorRunID == "" || request.CandidateID == "" || strings.TrimSpace(request.Summary) == "" || len(request.EvidenceRefs) == 0 {
|
||||
http.Error(w, "invalid generic candidate envelope", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
payload := fmt.Sprintf("%s|%s|%d|%s|%s|%s|%s", genericVerifierIdentity, request.GraphID, request.GraphRevision, request.NodeID, request.AuthorRunID, request.CandidateID, strings.Join(evidenceRefStrings(request.EvidenceRefs), "|"))
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
writeJSON(w, work.GenericCandidateVerificationResponse{Verified: true, VerificationEvidenceRef: "verification:" + hex.EncodeToString(digest[:]), VerifierIdentity: genericVerifierIdentity, Summary: "candidate attribution and evidence envelope verified; semantic content remains unverified"})
|
||||
}
|
||||
|
||||
func evidenceRefStrings(values []work.EvidenceRef) []string {
|
||||
result := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
result[i] = string(value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func verifyHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var request capability.TaskCaseVerificationRequest
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil || request.CaseID == "" || request.ResponseText == "" || request.EvidenceArtifactRef == "" || request.LatencyMicros < 0 || (request.Verification.Mode != capability.TaskCaseVerificationExactText && request.Verification.Mode != capability.TaskCaseVerificationJSONFieldEquals) || (request.Verification.Mode == capability.TaskCaseVerificationJSONFieldEquals && request.Verification.FieldPath == "") || request.Verification.ExpectedText == "" || request.Verification.MinimumQualityMillis < 0 || request.Verification.MinimumQualityMillis > 1000 {
|
||||
http.Error(w, "invalid task verification request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
matched := matches(request)
|
||||
quality := int64(0)
|
||||
result := capability.EvaluationUnsupported
|
||||
if matched {
|
||||
quality = request.Verification.MinimumQualityMillis
|
||||
if quality == 0 {
|
||||
quality = 1000
|
||||
}
|
||||
result = capability.EvaluationSupported
|
||||
}
|
||||
evidence := verificationEvidence(request, matched)
|
||||
writeJSON(w, capability.TaskCaseVerificationResponse{CaseID: request.CaseID, Result: result, QualityScoreMillis: quality, LatencyMicros: request.LatencyMicros, EvidenceArtifactRef: request.EvidenceArtifactRef, VerificationEvidenceRef: evidence, VerifierIdentity: verifierIdentity})
|
||||
}
|
||||
|
||||
func verificationEvidence(request capability.TaskCaseVerificationRequest, matched bool) string {
|
||||
payload := fmt.Sprintf("%s|%s|%s|%s|%s|%t", verifierIdentity, request.CaseID, request.EvidenceArtifactRef, request.Verification.Mode, request.Verification.FieldPath, matched)
|
||||
digest := sha256.Sum256([]byte(payload))
|
||||
return "verification:" + hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
func matches(request capability.TaskCaseVerificationRequest) bool {
|
||||
if request.Verification.Mode == capability.TaskCaseVerificationExactText {
|
||||
return strings.TrimSpace(request.ResponseText) == strings.TrimSpace(request.Verification.ExpectedText)
|
||||
}
|
||||
var document map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(request.ResponseText), &document) != nil {
|
||||
return false
|
||||
}
|
||||
value, ok := document[request.Verification.FieldPath]
|
||||
return ok && strings.TrimSpace(string(value)) == strings.TrimSpace(request.Verification.ExpectedText)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
)
|
||||
|
||||
func TestVerifyExactTextProducesIndependentEvidence(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: " answer ", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 900 || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" {
|
||||
t.Fatalf("unexpected verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMismatchIsUnsupportedWithEvidence(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "case-1", ResponseText: "wrong", EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationExactText, ExpectedText: "answer", MinimumQualityMillis: 900}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationUnsupported || response.QualityScoreMillis != 0 || response.VerificationEvidenceRef == "" {
|
||||
t.Fatalf("unexpected mismatch response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyJSONFieldEquals(t *testing.T) {
|
||||
payload, _ := json.Marshal(capability.TaskCaseVerificationRequest{CaseID: "json-1", ResponseText: `{"answer":42}`, EvidenceArtifactRef: "case-artifact", Verification: capability.TaskCaseVerification{Mode: capability.TaskCaseVerificationJSONFieldEquals, FieldPath: "answer", ExpectedText: "42", MinimumQualityMillis: 850}})
|
||||
recorder := httptest.NewRecorder()
|
||||
verifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/task-evaluations/verify", bytes.NewReader(payload)))
|
||||
var response capability.TaskCaseVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || response.Result != capability.EvaluationSupported || response.QualityScoreMillis != 850 {
|
||||
t.Fatalf("unexpected JSON verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyGenericCandidateEnvelopeDoesNotClaimTruth(t *testing.T) {
|
||||
payload, _ := json.Marshal(work.GenericCandidateVerificationRequest{GraphID: "graph-1", GraphRevision: 2, NodeID: "node-1", AuthorRunID: "run-1", CandidateID: "candidate-1", Summary: "model proposal", EvidenceRefs: []work.EvidenceRef{"agent-output:1"}})
|
||||
recorder := httptest.NewRecorder()
|
||||
genericCandidateVerifyHandler(recorder, httptest.NewRequest(http.MethodPost, "/v1/work-graphs/candidates/verify", bytes.NewReader(payload)))
|
||||
var response work.GenericCandidateVerificationResponse
|
||||
if recorder.Code != http.StatusOK || json.NewDecoder(recorder.Body).Decode(&response) != nil || !response.Verified || response.VerifierIdentity == "" || response.VerificationEvidenceRef == "" || !strings.Contains(response.Summary, "semantic content remains unverified") {
|
||||
t.Fatalf("unexpected generic verification response: code=%d body=%s value=%+v", recorder.Code, recorder.Body.String(), response)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
secretcontracts "git.cin.su/m/Assistant/contracts/secrets"
|
||||
"git.cin.su/m/Assistant/contracts/work"
|
||||
"git.cin.su/m/Assistant/modules/work/application"
|
||||
"git.cin.su/m/Assistant/platform/secrets"
|
||||
"git.cin.su/m/Assistant/runtime/health"
|
||||
@@ -26,9 +28,14 @@ func main() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer repository.Close()
|
||||
evidenceSink, err := newCoreTaskEvaluationSink(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), &http.Client{Timeout: 12 * time.Second})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("/healthz", health.Handler{Runtime: "work-graph-runtime", SemanticGeneration: 1, ArchitectureGeneration: 1})
|
||||
mux.Handle("/readyz", health.ReadinessHandler{Runtime: "work-graph-runtime", Probes: []health.Probe{{Name: "work-postgres", Check: func(ctx context.Context) error { return repository.Ping(ctx) }}}})
|
||||
mux.Handle("/v1/work-graphs/status", workGraphStatusHandler(repository))
|
||||
mux.HandleFunc("POST /v1/work-graphs/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
@@ -37,18 +44,99 @@ func main() {
|
||||
}
|
||||
graph, ready, err := application.AdmitAndSchedule(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("work graph schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.TaskEvaluationScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid task evaluation graph request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.AdmitAndScheduleTaskEvaluation(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("task evaluation graph schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/additional/schedule", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.TaskEvaluationExpansionScheduleRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid additional task evaluation request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, ready, err := application.ScheduleAdditionalTaskEvaluation(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
log.Printf("additional task evaluation schedule rejected: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"graph": graph, "scheduled_nodes": ready})
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/next", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchInput
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 32<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
decision, err := application.DecideSolutionSearch(request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration input", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, decision)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/orchestration/wave", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.SolutionSearchWaveRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := application.OrchestrateSolutionSearch(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid orchestration wave request", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/result-callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.GraphResultCallback
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.RecordGraphResultCandidate(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, "invalid graph result callback", http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, graph)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/complete", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request application.GraphCompleteRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid graph completion request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.CompleteGraphNode(r.Context(), repository, request.GraphID, request.Revision, request.NodeID, request.Succeeded)
|
||||
result, err := application.CompleteGraphNodeWithReplan(r.Context(), repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, result)
|
||||
})
|
||||
mux.HandleFunc("POST /v1/work-graphs/task-evaluations/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
var request work.TaskEvaluationEvidenceCallback
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&request); err != nil {
|
||||
http.Error(w, "invalid task evaluation callback", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
graph, err := application.CompleteTaskEvaluationNode(r.Context(), repository, evidenceSink, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
@@ -61,12 +149,12 @@ func main() {
|
||||
http.Error(w, "invalid graph synthesis request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
conflict, synthesis, err := application.SynthesizeGraph(r.Context(), repository, request)
|
||||
conflict, synthesis, graph, err := application.SynthesizeAndCompleteGraph(r.Context(), repository, repository, request)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis})
|
||||
writeJSON(w, map[string]any{"conflict": conflict, "synthesis": synthesis, "graph": graph})
|
||||
})
|
||||
addr := os.Getenv("LISTEN_ADDR")
|
||||
if addr == "" {
|
||||
@@ -76,6 +164,13 @@ func main() {
|
||||
log.Fatal(http.ListenAndServe(addr, mux))
|
||||
}
|
||||
|
||||
func envOr(name, fallback string) string {
|
||||
if value := os.Getenv(name); value != "" {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type workGraphStatusReader interface {
|
||||
RuntimeStatus(context.Context) (postgres.WorkGraphRuntimeStatus, error)
|
||||
}
|
||||
|
||||
type workGraphStatusResponse struct {
|
||||
Runtime string `json:"runtime"`
|
||||
Status string `json:"status"`
|
||||
GraphsByState map[string]int64 `json:"graphs_by_state"`
|
||||
NodesByState map[string]int64 `json:"nodes_by_state"`
|
||||
QueueByState map[string]int64 `json:"queue_by_state"`
|
||||
ObservedAtUnixMillis int64 `json:"observed_at_unix_millis"`
|
||||
}
|
||||
|
||||
func workGraphStatusHandler(reader workGraphStatusReader) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet || r.URL.Path != "/v1/work-graphs/status" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
status, err := reader.RuntimeStatus(r.Context())
|
||||
if err != nil {
|
||||
http.Error(w, "work graph status unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(workGraphStatusResponse{
|
||||
Runtime: "work-graph-runtime",
|
||||
Status: "ready",
|
||||
GraphsByState: status.GraphsByState,
|
||||
NodesByState: status.NodesByState,
|
||||
QueueByState: status.QueueByState,
|
||||
ObservedAtUnixMillis: status.ObservedAtUnixMillis,
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.cin.su/m/Assistant/adapters/postgres"
|
||||
)
|
||||
|
||||
type fakeWorkGraphStatusReader struct {
|
||||
status postgres.WorkGraphRuntimeStatus
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeWorkGraphStatusReader) RuntimeStatus(context.Context) (postgres.WorkGraphRuntimeStatus, error) {
|
||||
return f.status, f.err
|
||||
}
|
||||
|
||||
func TestWorkGraphStatusReportsDurableCounts(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workGraphStatusHandler(fakeWorkGraphStatusReader{status: postgres.WorkGraphRuntimeStatus{
|
||||
GraphsByState: map[string]int64{"RUNNING": 2},
|
||||
NodesByState: map[string]int64{"LEASED": 3, "SUCCEEDED": 1},
|
||||
QueueByState: map[string]int64{"QUEUED": 3},
|
||||
ObservedAtUnixMillis: 1700000000000,
|
||||
}}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/work-graphs/status", nil))
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, expected := range []string{"\"status\":\"ready\"", "\"RUNNING\":2", "\"LEASED\":3", "\"QUEUED\":3", "1700000000000"} {
|
||||
if !contains(body, expected) {
|
||||
t.Fatalf("response missing %q: %s", expected, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkGraphStatusFailsClosedOnDatabaseError(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
workGraphStatusHandler(fakeWorkGraphStatusReader{err: errors.New("database unavailable")}).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/v1/work-graphs/status", nil))
|
||||
if response.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status code = %d", response.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(value, expected string) bool {
|
||||
for i := 0; i+len(expected) <= len(value); i++ {
|
||||
if value[i:i+len(expected)] == expected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
)
|
||||
|
||||
type coreTaskEvaluationSink struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func newCoreTaskEvaluationSink(baseURL string, client *http.Client) (*coreTaskEvaluationSink, error) {
|
||||
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("core runtime URL is required")
|
||||
}
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
}
|
||||
return &coreTaskEvaluationSink{baseURL: baseURL, client: client}, nil
|
||||
}
|
||||
|
||||
func (s *coreTaskEvaluationSink) SaveTaskEvaluation(ctx context.Context, value capability.TaskEvaluation) error {
|
||||
payload, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL+"/v1/cognitive-registry/task-evaluations", bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := s.client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode < 200 || response.StatusCode >= 300 {
|
||||
return fmt.Errorf("save task evaluation: status %s", response.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -172,6 +172,7 @@ type ResolvedCognitiveBinding struct {
|
||||
Evaluation *EvaluatedCapability
|
||||
Performance *PerformanceEvidence
|
||||
TaskEvaluation *TaskEvaluation
|
||||
Qualification *ModelQualificationObservation
|
||||
Rating *ModelRating
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.cin.su/m/Assistant/platform/ids"
|
||||
)
|
||||
|
||||
type ModelQualificationObservationID ids.ID
|
||||
type ModelQualificationStatus string
|
||||
|
||||
const (
|
||||
ModelQualificationReachable ModelQualificationStatus = "REACHABLE"
|
||||
ModelQualificationRespondedWithoutRating ModelQualificationStatus = "RESPONDED_WITHOUT_QUALIFICATION"
|
||||
ModelQualificationUnavailable ModelQualificationStatus = "UNAVAILABLE"
|
||||
)
|
||||
|
||||
// ModelQualificationObservation is operational evidence only. It records
|
||||
// reachability and latency, never quality, activation, routing priority or
|
||||
// provider credentials.
|
||||
type ModelQualificationObservation struct {
|
||||
ID ModelQualificationObservationID
|
||||
ProviderInstanceID ProviderInstanceID
|
||||
ProviderModelID ProviderModelID
|
||||
Status ModelQualificationStatus
|
||||
FinishReason string
|
||||
LatencyMillis int64
|
||||
OutputObserved bool
|
||||
QualityAssessed bool
|
||||
Category ModelRatingCategory
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
|
||||
func (o ModelQualificationObservation) Validate() error {
|
||||
if o.ID == "" || o.ProviderInstanceID == "" || o.ProviderModelID == "" || o.FinishReason == "" || o.LatencyMillis < 0 || o.ObservedAtUnixMillis < 1 {
|
||||
return errors.New("model qualification observation identity and timing are incomplete")
|
||||
}
|
||||
switch o.Status {
|
||||
case ModelQualificationReachable, ModelQualificationRespondedWithoutRating, ModelQualificationUnavailable:
|
||||
default:
|
||||
return errors.New("model qualification observation status is unsupported")
|
||||
}
|
||||
if o.Category != ModelRatingUnrated || o.QualityAssessed {
|
||||
return errors.New("model qualification observation cannot assert quality")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -70,6 +70,22 @@ type TaskEvaluationExpansionRequest struct {
|
||||
BenchmarkVersion string
|
||||
}
|
||||
|
||||
type TaskCaseVerificationMode string
|
||||
|
||||
const (
|
||||
TaskCaseVerificationExactText TaskCaseVerificationMode = "EXACT_TEXT"
|
||||
TaskCaseVerificationJSONFieldEquals TaskCaseVerificationMode = "JSON_FIELD_EQUALS"
|
||||
)
|
||||
|
||||
// TaskCaseVerification is a bounded, task-owned rubric. The expected text is
|
||||
// test data, not model output, and is never sent to the cognitive provider.
|
||||
type TaskCaseVerification struct {
|
||||
Mode TaskCaseVerificationMode
|
||||
FieldPath string
|
||||
ExpectedText string
|
||||
MinimumQualityMillis int64
|
||||
}
|
||||
|
||||
// TaskCaseObservation is produced by the bounded evaluator and verifier. A
|
||||
// score without a case result and evidence reference is not accepted as
|
||||
// qualification evidence.
|
||||
@@ -80,6 +96,10 @@ type TaskCaseObservation struct {
|
||||
LatencyMicros int64
|
||||
CriticalFailure bool
|
||||
EvidenceArtifactRef string
|
||||
// VerificationEvidenceRef and VerifierIdentity are required whenever an
|
||||
// observation claims semantic support or a non-zero quality score.
|
||||
VerificationEvidenceRef string
|
||||
VerifierIdentity string
|
||||
}
|
||||
|
||||
// TaskEvaluation is durable evidence for one model/binding revision under one
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package capability
|
||||
|
||||
import (
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
// TaskEvaluationCase carries one bounded prompt selected by the evaluator.
|
||||
// The prompt is an input artifact, not a model-produced fact.
|
||||
type TaskEvaluationCase struct {
|
||||
CaseID string
|
||||
Prompt string
|
||||
EvidenceArtifactRef string
|
||||
Verification TaskCaseVerification
|
||||
}
|
||||
|
||||
type TaskCaseResponse struct {
|
||||
CaseID string
|
||||
ResponseText string
|
||||
LatencyMicros int64
|
||||
EvidenceArtifactRef string
|
||||
}
|
||||
|
||||
type TaskCaseVerificationRequest struct {
|
||||
CaseID string
|
||||
ResponseText string
|
||||
LatencyMicros int64
|
||||
EvidenceArtifactRef string
|
||||
Verification TaskCaseVerification
|
||||
}
|
||||
|
||||
type TaskCaseVerificationResponse struct {
|
||||
CaseID string
|
||||
Result EvaluationResult
|
||||
QualityScoreMillis int64
|
||||
LatencyMicros int64
|
||||
CriticalFailure bool
|
||||
EvidenceArtifactRef string
|
||||
VerificationEvidenceRef string
|
||||
VerifierIdentity string
|
||||
}
|
||||
|
||||
type TaskEvaluationExecutionRequest struct {
|
||||
Profile TaskEvaluationProfile
|
||||
CapabilityID CapabilityID
|
||||
BindingID CapabilityBindingID
|
||||
BindingRevision int64
|
||||
ActorID identity.ActorID
|
||||
PrincipalID identity.PrincipalID
|
||||
ScopeID authority.ScopeID
|
||||
Cases []TaskEvaluationCase
|
||||
MaxOutputBytes int64
|
||||
EgressAcknowledged bool
|
||||
}
|
||||
|
||||
type TaskEvaluationExecutionResponse struct {
|
||||
ProfileID TaskEvaluationProfileID
|
||||
BindingID CapabilityBindingID
|
||||
BindingRevision int64
|
||||
Observations []TaskCaseObservation
|
||||
Responses []TaskCaseResponse
|
||||
ObservedAtUnixMillis int64
|
||||
}
|
||||
@@ -96,6 +96,8 @@ type ResearchFinding struct {
|
||||
Statement string
|
||||
Claim Claim
|
||||
Citations []Citation
|
||||
Source Source
|
||||
Excerpt SourceExcerpt
|
||||
State ResearchFindingState
|
||||
Reason string
|
||||
}
|
||||
@@ -131,6 +133,15 @@ type WebResearchRequest struct {
|
||||
Statement string
|
||||
}
|
||||
|
||||
// WebSearchRequest asks the research runtime to discover bounded source
|
||||
// evidence for a human question. The provider is configured by the runtime;
|
||||
// the caller supplies meaning, not a provider URL or model choice.
|
||||
type WebSearchRequest struct {
|
||||
Plan ResearchPlan
|
||||
PropositionKey string
|
||||
Query string
|
||||
}
|
||||
|
||||
// ResolveCitation creates evidence only when all identifiers form one exact
|
||||
// source chain. It deliberately does not fetch, infer or rewrite source text.
|
||||
func ResolveCitation(source Source, snapshot SourceSnapshot, excerpt SourceExcerpt, evidence EvidenceKind, coverage CoverageState, conflict ConflictState, resolvedAtUnixMillis int64) (Citation, error) {
|
||||
|
||||
@@ -70,6 +70,8 @@ const (
|
||||
InformationRequestCognitiveModelInventory InformationRequestKind = "COGNITIVE_MODEL_INVENTORY"
|
||||
InformationRequestCognitiveChannelStatus InformationRequestKind = "COGNITIVE_CHANNEL_STATUS"
|
||||
InformationRequestCognitiveRecovery InformationRequestKind = "COGNITIVE_RECOVERY"
|
||||
InformationRequestMemoryInventory InformationRequestKind = "MEMORY_INVENTORY"
|
||||
InformationRequestTimeDifference InformationRequestKind = "TIME_DIFFERENCE"
|
||||
)
|
||||
|
||||
type Conversation struct {
|
||||
@@ -173,6 +175,7 @@ type ChatMessageResponse struct {
|
||||
Clarification ClarificationState
|
||||
WorkPlanned bool
|
||||
PlanningReply string
|
||||
ResearchBacked bool
|
||||
CapabilityStatus capability.CapabilityResolutionStatus
|
||||
CapabilityKind capability.CapabilityKind
|
||||
CapabilityHints []capability.CapabilityKind
|
||||
|
||||
+98
-19
@@ -1,11 +1,17 @@
|
||||
package work
|
||||
|
||||
import "git.cin.su/m/Assistant/contracts/capability"
|
||||
|
||||
type GraphNodeID string
|
||||
|
||||
type GraphState string
|
||||
type GraphNodeState string
|
||||
type GraphNodePurpose string
|
||||
|
||||
const (
|
||||
GraphNodePurposeTaskEvaluation GraphNodePurpose = "TASK_EVALUATION_SPECIALIST"
|
||||
GraphNodePurposeGeneric GraphNodePurpose = "GENERIC"
|
||||
|
||||
GraphDraft GraphState = "DRAFT"
|
||||
GraphAdmitted GraphState = "ADMITTED"
|
||||
GraphRunning GraphState = "RUNNING"
|
||||
@@ -15,14 +21,15 @@ const (
|
||||
GraphFailed GraphState = "FAILED"
|
||||
GraphUnknown GraphState = "UNKNOWN"
|
||||
|
||||
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
|
||||
GraphNodeReady GraphNodeState = "READY"
|
||||
GraphNodeLeased GraphNodeState = "LEASED"
|
||||
GraphNodeRunning GraphNodeState = "RUNNING"
|
||||
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
|
||||
GraphNodeFailed GraphNodeState = "FAILED"
|
||||
GraphNodeCancel GraphNodeState = "CANCELLED"
|
||||
GraphNodeUnknown GraphNodeState = "UNKNOWN"
|
||||
GraphNodeBlocked GraphNodeState = "BLOCKED_PREREQUISITE"
|
||||
GraphNodeReady GraphNodeState = "READY"
|
||||
GraphNodeLeased GraphNodeState = "LEASED"
|
||||
GraphNodeRunning GraphNodeState = "RUNNING"
|
||||
GraphNodeVerifying GraphNodeState = "VERIFYING"
|
||||
GraphNodeSuccess GraphNodeState = "SUCCEEDED"
|
||||
GraphNodeFailed GraphNodeState = "FAILED"
|
||||
GraphNodeCancel GraphNodeState = "CANCELLED"
|
||||
GraphNodeUnknown GraphNodeState = "UNKNOWN"
|
||||
)
|
||||
|
||||
type GraphBudget struct {
|
||||
@@ -31,11 +38,17 @@ type GraphBudget struct {
|
||||
}
|
||||
|
||||
type GraphNode struct {
|
||||
ID GraphNodeID `json:"id"`
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
State GraphNodeState `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
ID GraphNodeID `json:"id"`
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
State GraphNodeState `json:"state"`
|
||||
Attempts int `json:"attempts"`
|
||||
Purpose GraphNodePurpose `json:"purpose,omitempty"`
|
||||
TaskProfileID capability.TaskEvaluationProfileID `json:"task_profile_id,omitempty"`
|
||||
BindingID capability.CapabilityBindingID `json:"binding_id,omitempty"`
|
||||
CaseIDs []string `json:"case_ids,omitempty"`
|
||||
TaskProfile *capability.TaskEvaluationProfile `json:"task_profile,omitempty"`
|
||||
TaskCases []capability.TaskEvaluationCase `json:"task_cases,omitempty"`
|
||||
}
|
||||
|
||||
type GraphEdge struct {
|
||||
@@ -44,12 +57,49 @@ type GraphEdge struct {
|
||||
}
|
||||
|
||||
type WorkGraph struct {
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
State GraphState `json:"state"`
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ID string `json:"id"`
|
||||
Revision int64 `json:"revision"`
|
||||
State GraphState `json:"state"`
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Edges []GraphEdge `json:"edges"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
SearchInputBound bool `json:"search_input_bound,omitempty"`
|
||||
SearchInput SolutionSearchInput `json:"search_input,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEvaluationGraphRequest describes a durable fan-out. The graph contains
|
||||
// one specialist node per binding; each node receives the profile's bounded
|
||||
// cases and reports evidence back to Capability for fan-in aggregation.
|
||||
type TaskEvaluationGraphRequest struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Profile capability.TaskEvaluationProfile `json:"profile"`
|
||||
BindingIDs []capability.CapabilityBindingID `json:"binding_ids"`
|
||||
DispatchIDs []string `json:"dispatch_ids"`
|
||||
IdempotencyPrefix string `json:"idempotency_prefix"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
ParentGraphState GraphState `json:"parent_graph_state,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
Cases []capability.TaskEvaluationCase `json:"cases,omitempty"`
|
||||
BindingRevision int64 `json:"binding_revision,omitempty"`
|
||||
ActorID string `json:"actor_id,omitempty"`
|
||||
PrincipalID string `json:"principal_id,omitempty"`
|
||||
ScopeID string `json:"scope_id,omitempty"`
|
||||
}
|
||||
|
||||
// TaskEvaluationEvidenceCallback is emitted by a specialist after it has
|
||||
// completed the bounded case set. Capability remains the evidence owner;
|
||||
// Work only advances its matching graph node after the evidence is accepted.
|
||||
type TaskEvaluationEvidenceCallback struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
Evaluation capability.TaskEvaluation `json:"evaluation"`
|
||||
Succeeded bool `json:"succeeded"`
|
||||
}
|
||||
|
||||
type ResultCandidate struct {
|
||||
@@ -63,6 +113,35 @@ type ResultCandidate struct {
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// GenericCandidateVerificationRequest asks the independent deterministic
|
||||
// verifier to validate attribution and evidence-envelope integrity. It does
|
||||
// not assert that the candidate's semantic content is true.
|
||||
type GenericCandidateVerificationRequest struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
AuthorRunID AgentRunID `json:"author_run_id"`
|
||||
CandidateID string `json:"candidate_id"`
|
||||
Summary string `json:"summary"`
|
||||
EvidenceRefs []EvidenceRef `json:"evidence_refs"`
|
||||
}
|
||||
|
||||
type GenericCandidateVerificationResponse struct {
|
||||
Verified bool `json:"verified"`
|
||||
VerificationEvidenceRef string `json:"verification_evidence_ref"`
|
||||
VerifierIdentity string `json:"verifier_identity"`
|
||||
Summary string `json:"summary"`
|
||||
}
|
||||
|
||||
// GraphResultCallback carries one agent's attributable candidate to Work.
|
||||
// Recording a candidate never makes the node successful; the node enters
|
||||
// VERIFYING until an independent synthesis/verifier accepts it.
|
||||
type GraphResultCallback struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
Revision int64 `json:"revision"`
|
||||
Candidate ResultCandidate `json:"candidate"`
|
||||
}
|
||||
|
||||
type ConflictSet struct {
|
||||
ID string `json:"id"`
|
||||
GraphID string `json:"graph_id"`
|
||||
|
||||
+42
-11
@@ -1,6 +1,12 @@
|
||||
package work
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.cin.su/m/Assistant/contracts/authority"
|
||||
"git.cin.su/m/Assistant/contracts/capability"
|
||||
"git.cin.su/m/Assistant/contracts/identity"
|
||||
)
|
||||
|
||||
type AgentLeaseID string
|
||||
type AgentFencingToken uint64
|
||||
@@ -50,14 +56,39 @@ type IndependentVerification struct {
|
||||
}
|
||||
|
||||
type AgentDispatchIntent struct {
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
DelegationID DelegationID `json:"delegation_id"`
|
||||
RunID AgentRunID `json:"run_id"`
|
||||
ImplementationID string `json:"implementation_id"`
|
||||
ImplementationRevision int64 `json:"implementation_revision"`
|
||||
SourceRevision string `json:"source_revision"`
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
VerificationPlanRef string `json:"verification_plan_ref"`
|
||||
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
|
||||
DispatchID string `json:"dispatch_id"`
|
||||
IdempotencyKey string `json:"idempotency_key"`
|
||||
DelegationID DelegationID `json:"delegation_id"`
|
||||
RunID AgentRunID `json:"run_id"`
|
||||
ImplementationID string `json:"implementation_id"`
|
||||
ImplementationRevision int64 `json:"implementation_revision"`
|
||||
SourceRevision string `json:"source_revision"`
|
||||
WorktreeID string `json:"worktree_id"`
|
||||
VerificationPlanRef string `json:"verification_plan_ref"`
|
||||
CreatedAtUnixMillis int64 `json:"created_at_unix_millis"`
|
||||
SpecialistPurpose string `json:"specialist_purpose,omitempty"`
|
||||
TaskEvaluationProfileID capability.TaskEvaluationProfileID `json:"task_evaluation_profile_id,omitempty"`
|
||||
TaskEvaluationBindingID capability.CapabilityBindingID `json:"task_evaluation_binding_id,omitempty"`
|
||||
TaskEvaluationCaseIDs []string `json:"task_evaluation_case_ids,omitempty"`
|
||||
TaskEvaluationProfile *capability.TaskEvaluationProfile `json:"task_evaluation_profile,omitempty"`
|
||||
TaskEvaluationCases []capability.TaskEvaluationCase `json:"task_evaluation_cases,omitempty"`
|
||||
TaskEvaluationExecution *TaskEvaluationDispatchContext `json:"task_evaluation_execution,omitempty"`
|
||||
}
|
||||
|
||||
type TaskEvaluationDispatchContext struct {
|
||||
GraphID string `json:"graph_id"`
|
||||
GraphRevision int64 `json:"graph_revision"`
|
||||
NodeID GraphNodeID `json:"node_id"`
|
||||
BindingRevision int64 `json:"binding_revision"`
|
||||
ActorID identity.ActorID `json:"actor_id"`
|
||||
PrincipalID identity.PrincipalID `json:"principal_id"`
|
||||
ScopeID authority.ScopeID `json:"scope_id"`
|
||||
// GenericMessage turns the existing durable dispatch context into a
|
||||
// discriminated local cognitive-work payload without adding a second
|
||||
// persistence column. It is ignored by task-evaluation executors.
|
||||
GenericMessage string `json:"generic_message,omitempty"`
|
||||
GenericPurpose string `json:"generic_purpose,omitempty"`
|
||||
GenericLocale string `json:"generic_locale,omitempty"`
|
||||
GenericDataPolicy string `json:"generic_data_policy,omitempty"`
|
||||
GenericMaxOutputBytes int `json:"generic_max_output_bytes,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package work
|
||||
|
||||
type OrchestrationState string
|
||||
type SearchOptionKind string
|
||||
|
||||
const (
|
||||
OrchestrationSearching OrchestrationState = "SEARCHING"
|
||||
OrchestrationExecuting OrchestrationState = "EXECUTING"
|
||||
OrchestrationVerifying OrchestrationState = "VERIFYING"
|
||||
OrchestrationWaiting OrchestrationState = "WAITING"
|
||||
OrchestrationCompleted OrchestrationState = "COMPLETED"
|
||||
OrchestrationCancelled OrchestrationState = "CANCELLED"
|
||||
|
||||
SearchOptionAgents SearchOptionKind = "AGENT_TEAM"
|
||||
SearchOptionCapability SearchOptionKind = "CAPABILITY_DISCOVERY"
|
||||
SearchOptionResearch SearchOptionKind = "TRUTH_RESEARCH"
|
||||
SearchOptionReflection SearchOptionKind = "REFLECTION_MEMORY"
|
||||
SearchOptionHuman SearchOptionKind = "HUMAN_ATTENTION"
|
||||
)
|
||||
|
||||
// SolutionSearchInput is the durable orchestration snapshot. The scheduler
|
||||
// stores the graph/goal separately; this value only decides the next bounded
|
||||
// search wave and never grants authority by itself.
|
||||
type SolutionSearchInput struct {
|
||||
GoalID GoalID
|
||||
GoalState GoalState
|
||||
SearchRound int
|
||||
MaxRound int
|
||||
CancelRequested bool
|
||||
ActiveOptionCount int
|
||||
VerificationReady bool
|
||||
Unresolved bool
|
||||
}
|
||||
|
||||
type SolutionSearchDecision struct {
|
||||
GoalID GoalID
|
||||
State OrchestrationState
|
||||
ContinueSearching bool
|
||||
NextSearchRound int
|
||||
Options []SearchOptionKind
|
||||
Reason string
|
||||
}
|
||||
|
||||
type SolutionSearchWaveRequest struct {
|
||||
Input SolutionSearchInput `json:"input"`
|
||||
GraphID string `json:"graph_id"`
|
||||
IdempotencyPrefix string `json:"idempotency_prefix"`
|
||||
Dispatches []SolutionSearchDispatch `json:"dispatches"`
|
||||
Budget GraphBudget `json:"budget"`
|
||||
ParentGraphID string `json:"parent_graph_id,omitempty"`
|
||||
ParentGraphRevision int64 `json:"parent_graph_revision,omitempty"`
|
||||
TriggerEvidenceRef string `json:"trigger_evidence_ref,omitempty"`
|
||||
NowUnixMillis int64 `json:"now_unix_millis"`
|
||||
}
|
||||
|
||||
type SolutionSearchDispatch struct {
|
||||
Option SearchOptionKind `json:"option"`
|
||||
Intent AgentDispatchIntent `json:"intent"`
|
||||
}
|
||||
|
||||
type SolutionSearchWaveResult struct {
|
||||
Decision SolutionSearchDecision `json:"decision"`
|
||||
Graph WorkGraph `json:"graph"`
|
||||
ScheduledNodeIDs []GraphNodeID `json:"scheduled_node_ids,omitempty"`
|
||||
GraphMaterialized bool `json:"graph_materialized"`
|
||||
}
|
||||
@@ -70,6 +70,7 @@ type ExtensionProposalRequest struct {
|
||||
// authority grant, implementation body or promotion decision.
|
||||
type ExtensionProposal struct {
|
||||
ID string
|
||||
RequestedByPrincipalID string
|
||||
Stage ExtensionProposalStage
|
||||
EvidenceRefs []string
|
||||
DeduplicatedSignalKeys []string
|
||||
@@ -113,19 +114,20 @@ type DevelopmentScaffoldRequest struct {
|
||||
// DevelopmentScaffold is a provenance-linked task-packet candidate. It has
|
||||
// no executable instruction, credential, checkout handle or authority grant.
|
||||
type DevelopmentScaffold struct {
|
||||
ID string
|
||||
Stage DevelopmentScaffoldStage
|
||||
ProposalRef string
|
||||
BaseRepoRevision string
|
||||
WorktreeRef string
|
||||
TaskIntent string
|
||||
Modules []string
|
||||
AllowedPaths []string
|
||||
ForbiddenPaths []string
|
||||
RequiredTests []string
|
||||
ArtifactLineageRefs []string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
CreatedAtUnixMillis int64
|
||||
ID string
|
||||
RequestedByPrincipalID string
|
||||
Stage DevelopmentScaffoldStage
|
||||
ProposalRef string
|
||||
BaseRepoRevision string
|
||||
WorktreeRef string
|
||||
TaskIntent string
|
||||
Modules []string
|
||||
AllowedPaths []string
|
||||
ForbiddenPaths []string
|
||||
RequiredTests []string
|
||||
ArtifactLineageRefs []string
|
||||
ProtectedMainAccess string
|
||||
ProductionAccess string
|
||||
SecretAccess string
|
||||
CreatedAtUnixMillis int64
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_intents
|
||||
ADD COLUMN IF NOT EXISTS specialist_purpose TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_binding_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_case_ids TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
ALTER TABLE work.graphs
|
||||
ADD COLUMN IF NOT EXISTS parent_graph_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS parent_graph_revision BIGINT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS trigger_evidence_ref TEXT NOT NULL DEFAULT '';
|
||||
|
||||
ALTER TABLE work.graph_nodes
|
||||
ADD COLUMN IF NOT EXISTS purpose TEXT NOT NULL DEFAULT 'GENERIC',
|
||||
ADD COLUMN IF NOT EXISTS task_profile_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS binding_id TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS case_ids TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_intents
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||
|
||||
ALTER TABLE work.graph_nodes
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_profile JSONB,
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_cases JSONB NOT NULL DEFAULT '[]',
|
||||
ADD COLUMN IF NOT EXISTS task_evaluation_execution JSONB;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,15 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_queue
|
||||
ADD COLUMN IF NOT EXISTS retry_limit INTEGER NOT NULL DEFAULT 3;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_queue
|
||||
DROP CONSTRAINT IF EXISTS agent_dispatch_queue_retry_limit_check;
|
||||
|
||||
ALTER TABLE work.agent_dispatch_queue
|
||||
ADD CONSTRAINT agent_dispatch_queue_retry_limit_check CHECK (retry_limit > 0);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,45 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.extension_proposals (
|
||||
proposal_id TEXT PRIMARY KEY,
|
||||
requested_by_principal_id TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
evidence_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
deduplicated_signal_keys TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
desired_outcome TEXT NOT NULL,
|
||||
smallest_change_layer TEXT NOT NULL,
|
||||
affected_owners TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
affected_contracts TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
risk_tier TEXT NOT NULL,
|
||||
evaluation_plan TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
alternatives TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
rollback_plan TEXT NOT NULL,
|
||||
assumptions TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (proposal_id <> ''), CHECK (requested_by_principal_id <> ''), CHECK (stage = 'PROPOSED'), CHECK (desired_outcome <> ''), CHECK (smallest_change_layer <> ''), CHECK (risk_tier <> ''), CHECK (rollback_plan <> ''), CHECK (created_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS work.development_scaffolds (
|
||||
scaffold_id TEXT PRIMARY KEY,
|
||||
requested_by_principal_id TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
proposal_ref TEXT NOT NULL,
|
||||
base_repo_revision TEXT NOT NULL,
|
||||
worktree_ref TEXT NOT NULL,
|
||||
task_intent TEXT NOT NULL,
|
||||
modules TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
allowed_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
forbidden_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
required_tests TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
artifact_lineage_refs TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
|
||||
protected_main_access TEXT NOT NULL,
|
||||
production_access TEXT NOT NULL,
|
||||
secret_access TEXT NOT NULL,
|
||||
created_at_unix_millis BIGINT NOT NULL,
|
||||
CHECK (scaffold_id <> ''), CHECK (requested_by_principal_id <> ''), CHECK (stage = 'SANDBOXED'), CHECK (proposal_ref <> ''), CHECK (base_repo_revision <> ''), CHECK (worktree_ref <> ''), CHECK (task_intent <> ''), CHECK (protected_main_access = 'DENIED'), CHECK (production_access = 'DENIED'), CHECK (secret_access = 'DENIED'), CHECK (created_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,21 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE work_role;
|
||||
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_bound BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_goal_id TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_goal_state TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_search_round INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_max_round INTEGER NOT NULL DEFAULT 0;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_cancel_requested BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_verification_ready BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
ALTER TABLE work.graphs ADD COLUMN IF NOT EXISTS orchestration_unresolved BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
ALTER TABLE work.graphs DROP CONSTRAINT IF EXISTS work_graphs_orchestration_snapshot_check;
|
||||
ALTER TABLE work.graphs ADD CONSTRAINT work_graphs_orchestration_snapshot_check CHECK (
|
||||
(NOT orchestration_bound AND orchestration_goal_id = '' AND orchestration_search_round = 0 AND orchestration_max_round = 0)
|
||||
OR (orchestration_bound AND orchestration_goal_id <> '' AND orchestration_search_round >= 1 AND orchestration_max_round >= 1)
|
||||
);
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -0,0 +1,24 @@
|
||||
BEGIN;
|
||||
|
||||
SET ROLE capability_role;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS capability.cognitive_model_qualification_observations (
|
||||
observation_id TEXT PRIMARY KEY,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
provider_model_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL CHECK (status IN ('REACHABLE','RESPONDED_WITHOUT_QUALIFICATION','UNAVAILABLE')),
|
||||
finish_reason TEXT NOT NULL,
|
||||
latency_millis BIGINT NOT NULL CHECK (latency_millis >= 0),
|
||||
output_observed BOOLEAN NOT NULL,
|
||||
quality_assessed BOOLEAN NOT NULL DEFAULT FALSE CHECK (quality_assessed = FALSE),
|
||||
category TEXT NOT NULL CHECK (category = 'UNRATED'),
|
||||
observed_at_unix_millis BIGINT NOT NULL CHECK (observed_at_unix_millis > 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS cognitive_model_qualification_lookup
|
||||
ON capability.cognitive_model_qualification_observations(provider_instance_id, provider_model_id, observed_at_unix_millis DESC);
|
||||
|
||||
GRANT SELECT, INSERT ON capability.cognitive_model_qualification_observations TO capability_role;
|
||||
|
||||
RESET ROLE;
|
||||
COMMIT;
|
||||
@@ -102,8 +102,11 @@ services:
|
||||
LISTEN_ADDR: ":8083"
|
||||
CORE_RUNTIME_URL: http://core-runtime:8080
|
||||
LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1
|
||||
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://docker.cin.su:18082/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_TIMEOUT: ${LOCAL_MEDIUM_PROVIDER_TIMEOUT:-120s}
|
||||
LOCAL_LIFEBOAT_PROVIDER_URL: ${LOCAL_LIFEBOAT_PROVIDER_URL:-}
|
||||
LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434
|
||||
COGNITIVE_ALLOW_CANARY: "true"
|
||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||
ports: ["19083:8083"]
|
||||
|
||||
|
||||
@@ -118,10 +118,44 @@ services:
|
||||
WORK_POSTGRES_CREDENTIAL_REF: credential://domovoy/work/db-password/v1
|
||||
WORK_POSTGRES_PASSWORD_FILE: /run/secrets/work_db_password
|
||||
SECRET_BACKEND_ROOT: /run/secrets
|
||||
depends_on: { postgres: { condition: service_healthy } }
|
||||
TASK_VERIFIER_URL: http://task-verifier:8091
|
||||
AGENT_EXECUTION_BRIDGE_URL: http://agent-execution-bridge:8092
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
task-verifier: { condition: service_healthy }
|
||||
agent-execution-bridge: { condition: service_healthy }
|
||||
volumes:
|
||||
- assistant_core_secrets:/run/secrets:ro
|
||||
|
||||
agent-execution-bridge:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: agent-execution-bridge, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: :8092
|
||||
COGNITIVE_WORKER_URL: http://cognitive-worker:8083
|
||||
WORK_GRAPH_RUNTIME_URL: http://work-graph-runtime:8088
|
||||
TASK_VERIFIER_URL: http://task-verifier:8091
|
||||
depends_on:
|
||||
cognitive-worker: { condition: service_started }
|
||||
task-verifier: { condition: service_healthy }
|
||||
work-graph-runtime: { condition: service_started }
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8092/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
task-verifier:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: task-verifier, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
LISTEN_ADDR: :8091
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:8091/healthz >/dev/null"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
work-graph-runtime:
|
||||
build: { context: .., dockerfile: deploy/images/runtime.Dockerfile, args: { TARGET: work-graph-runtime, SOURCE_REVISION: "${ASSISTANT_SOURCE_REVISION:-unknown}" } }
|
||||
restart: unless-stopped
|
||||
@@ -161,6 +195,8 @@ services:
|
||||
LISTEN_ADDR: :8087
|
||||
RESEARCH_SOURCE_ROOT: /var/lib/research-sources
|
||||
RESEARCH_HTTP_ALLOWED_HOSTS: assistant.cin.su,git.cin.su
|
||||
RESEARCH_SEARCH_URL_TEMPLATE: ${RESEARCH_SEARCH_URL_TEMPLATE:-https://www.bing.com/search?q=%s}
|
||||
RESEARCH_SEARCH_ALLOWED_HOSTS: ${RESEARCH_SEARCH_ALLOWED_HOSTS:-www.bing.com}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: "5432"
|
||||
POSTGRES_DATABASE: ${POSTGRES_DB:-assistant}
|
||||
@@ -220,6 +256,7 @@ services:
|
||||
LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-http://llm-local-api:8080/v1}
|
||||
LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-}
|
||||
LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1}
|
||||
LOCAL_GPU_PROVIDER_TIMEOUT: ${LOCAL_GPU_PROVIDER_TIMEOUT:-60s}
|
||||
LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1
|
||||
COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal
|
||||
COGNITIVE_ALLOW_CANARY: "true"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"task_file": "dev/tasks/DEV-000208-task-aware-model-evaluation.json",
|
||||
"task_id": "DEV-000208",
|
||||
"task_file": "dev/tasks/DEV-000294-creative-chat-routing.json",
|
||||
"task_id": "DEV-000294",
|
||||
"task_revision": 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000209",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "a82592a1300c06ed7a568c058faca6560e085ab3",
|
||||
"risk_tier": "E",
|
||||
"intent": "Create durable Work Graph plans for task-aware model evaluation: run the general profile first, fan out independent model specialists in parallel, and permit bounded additional profiles linked to unresolved evidence without blocking the chat path.",
|
||||
"requirement_ids": ["general-first model evaluation", "parallel specialist evaluation", "bounded additional task tests", "fan-out/fan-in", "no synchronous chat blocking", "provider and hardware neutral"],
|
||||
"allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "cmd/work-graph-runtime/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000209-task-evaluator-work-graph.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "database/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["capability.TaskEvaluationProfile", "capability.TaskEvaluationProfileStage", "capability.TaskEvaluationProfileID", "existing Work Graph schedule contract"], "may_change": ["typed Work Graph node purpose and task-evaluation planning contract"]},
|
||||
"semantic_owner": "work",
|
||||
"canonical_state": ["work-owned durable graph and node lifecycle; capability remains owner of model evaluation evidence"],
|
||||
"state_transitions": ["GENERAL profile -> admitted evaluation graph", "eligible model bindings -> parallel specialist nodes", "all specialist nodes -> fan-in completion state", "ADDITIONAL profile -> a separate bounded graph linked by parent/trigger evidence"],
|
||||
"side_effects_and_privileges": ["none; graph planning only, no provider calls and no secret access"],
|
||||
"persistence_and_migrations": ["uses existing work graph persistence; no new schema"],
|
||||
"failure_retry_restart": ["each specialist remains independently retryable through existing node attempts/leases; partial or unknown graph state is preserved", "invalid stage/parent linkage is rejected before scheduling"],
|
||||
"assumptions": ["a later worker adapter executes each dispatched specialist and posts evidence to capability", "chat/request handlers enqueue the graph and return without waiting for all nodes"],
|
||||
"acceptance_scenarios": ["additional profile cannot be scheduled as a replacement for general profile", "one node is created per binding and profile with independent dispatch identity", "parallel budget is retained for fan-out", "graph metadata is sufficient for a worker to identify profile, binding and specialist role", "provider kind, endpoint and hardware are absent from planning inputs"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000210",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "9a6ab6a9caf68e65f302c95ab0859b259122dacf",
|
||||
"risk_tier": "E",
|
||||
"intent": "Carry task-evaluation specialist metadata through durable dispatch, persistence and recovery so workers can execute bounded model cases and return evidence without losing profile lineage.",
|
||||
"requirement_ids": ["worker execution context", "durable task metadata", "recovery-safe dispatch", "bounded case set", "no synchronous chat blocking"],
|
||||
"allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "database/migrations/040_task_evaluation_dispatch.sql", "tests/model_qualification/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000210-task-evaluator-worker.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["Work Graph task-evaluation node metadata", "AgentExecutionRequest/Result", "existing lease and recovery contract"], "may_change": ["typed dispatch task-evaluation metadata"]},
|
||||
"semantic_owner": "work",
|
||||
"canonical_state": ["work-owned dispatch metadata and graph lineage; capability remains owner of evaluation evidence"],
|
||||
"state_transitions": ["graph specialist node -> enriched dispatch intent", "claimed dispatch -> executor receives profile/binding/cases", "stale claim -> recovery preserves task metadata for retry"],
|
||||
"side_effects_and_privileges": ["work role migration only; no provider calls and no secret access added"],
|
||||
"persistence_and_migrations": ["040 adds typed task metadata columns to existing work dispatch/graph tables"],
|
||||
"failure_retry_restart": ["metadata is persisted before enqueue; idempotency conflict rejects divergent task context", "lease timeout/recovery does not erase profile or case references"],
|
||||
"assumptions": ["an upcoming cognitive specialist executor uses the typed metadata to invoke the model gateway and post Capability evidence", "external bridge absence remains typed WAITING/local-fallback"],
|
||||
"acceptance_scenarios": ["task graph dispatch carries profile, binding and all cases", "persist/load round trip preserves metadata", "divergent idempotency retry is rejected", "legacy non-task dispatch remains valid", "recovery path does not claim success"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000211",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "6dec47c79163f456cf6b18085a268ebb2619c7bb",
|
||||
"risk_tier": "E",
|
||||
"intent": "Accept specialist task-evaluation evidence through a Work Graph callback: persist evidence through the Capability owner and complete the matching graph node only after typed lineage and revision checks pass.",
|
||||
"requirement_ids": ["evidence callback", "fan-in completion", "owner-local persistence", "revision safety", "no cross-owner DB access"],
|
||||
"allowed_change_scope": {"modules": ["work"], "paths": ["contracts/work/**", "modules/work/**", "cmd/work-graph-runtime/**", "tests/model_qualification/**", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000211-task-evaluator-callback.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "database/**", "modules/capability/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["capability.TaskEvaluation", "existing Work Graph revision/completion contract", "Core Runtime capability evaluation HTTP boundary"], "may_change": ["typed task-evaluation callback contract"]},
|
||||
"semantic_owner": "work for graph lifecycle; capability for evaluation evidence",
|
||||
"canonical_state": ["Capability owns evaluation evidence; Work owns graph/node lifecycle and callback lineage"],
|
||||
"state_transitions": ["specialist result -> callback received", "callback -> Capability evidence persisted", "persisted evidence -> matching node completion and graph fan-in"],
|
||||
"side_effects_and_privileges": ["remote Core Runtime call through existing internal boundary; no direct foreign DB access and no new secrets"],
|
||||
"persistence_and_migrations": ["uses existing capability task-evaluation and work graph persistence; no migration"],
|
||||
"failure_retry_restart": ["Capability write failure leaves node incomplete and callback retryable", "stale graph revision is rejected; unknown/unsupported evaluation is not promoted by callback"],
|
||||
"assumptions": ["specialist executor has already produced evidence and artifact references", "the callback is asynchronous and never blocks the chat request"],
|
||||
"acceptance_scenarios": ["callback rejects non-task graph node", "callback rejects profile/binding lineage mismatch", "Capability write occurs before node completion", "sink failure leaves graph node unfinished", "valid callback advances graph fan-in", "duplicate evidence remains idempotent at Capability owner"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000212",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "dff6c91c8e08611d73231a04a1af67efeebed22b",
|
||||
"risk_tier": "E",
|
||||
"intent": "Execute bounded task-evaluation cases against an exact Capability binding through Cognitive Gateway and return typed observations for the Work Graph callback.",
|
||||
"requirement_ids": ["actual model invocation", "exact binding evaluation", "bounded cases", "provider-neutral evidence", "unknown on provider failure"],
|
||||
"allowed_change_scope": {"modules": ["capability", "core-runtime", "cognitive-worker"], "paths": ["contracts/capability/**", "adapters/postgres/capability_repository.go", "cmd/core-runtime/**", "cmd/cognitive-worker/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000212-task-evaluator-invocation.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "database/**", "modules/identity/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskEvaluationProfile", "TaskCaseObservation", "Cognitive Gateway provider boundary", "Capability cognitive binding snapshot boundary"], "may_change": ["task evaluation execution request/response"]},
|
||||
"semantic_owner": "capability owns evaluation meaning; cognitive-worker owns provider invocation adapter boundary",
|
||||
"canonical_state": ["none; this task returns observations only and does not promote or persist activation"],
|
||||
"state_transitions": ["execution request -> exact binding snapshot", "case -> provider invocation -> SUPPORTED or UNKNOWN observation", "all observations -> Work Graph callback input"],
|
||||
"side_effects_and_privileges": ["provider calls only through existing cognitive adapter; no direct DB and no new secret access"],
|
||||
"persistence_and_migrations": ["none; durable persistence remains callback/Core Capability owner"],
|
||||
"failure_retry_restart": ["provider timeout/auth/unavailable maps to UNKNOWN observation with evidence reference; caller may retry as a new attempt", "partial case set is never returned as supported aggregate"],
|
||||
"assumptions": ["specialist supplies case prompts and artifact references from the approved bounded profile", "quality scoring beyond bounded invocation is performed by a verifier or later evaluator and is not fabricated here"],
|
||||
"acceptance_scenarios": ["exact binding snapshot is required", "all supplied cases are invoked", "successful non-empty response yields supported observation with latency", "provider failure yields unknown observation", "unknown is never silently converted to supported", "no provider or hardware field participates in scoring"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000213",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "a86d1994c3f48db86592eb87fb54533297e0671c",
|
||||
"risk_tier": "E",
|
||||
"intent": "Carry the complete bounded task-evaluation profile and case payload through durable Work Graph dispatch so agent-worker can invoke Cognitive Worker, aggregate observations, and return the existing Work Graph callback after restart-safe recovery.",
|
||||
"requirement_ids": ["dispatch-to-evaluator link", "payload durability", "worker recovery", "observation aggregation", "callback integration"],
|
||||
"allowed_change_scope": {"modules": ["work", "agent-worker"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "cmd/work-graph-runtime/**", "database/migrations/041_task_evaluation_payload.sql", "tests/agent_execution/**", "tests/model_qualification/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000213-worker-evaluator-link.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["TaskEvaluationExecutionRequest/Response", "Work Graph callback", "existing AgentExecutor and recovery contract"], "may_change": ["typed dispatch payload and worker evaluator executor"]},
|
||||
"semantic_owner": "work owns dispatch lifecycle; capability owns evaluation evidence; cognitive-worker owns provider invocation",
|
||||
"canonical_state": ["work dispatch payload is durable input; Capability remains evidence owner"],
|
||||
"state_transitions": ["task graph node -> durable payload dispatch", "worker claim -> evaluator invocation", "response -> aggregate TaskEvaluation", "aggregate -> Work Graph evidence callback"],
|
||||
"side_effects_and_privileges": ["HTTP calls to internal cognitive/core/work boundaries; no direct foreign DB and no new secret"],
|
||||
"persistence_and_migrations": ["041 adds JSONB payload columns to work graph nodes and dispatch intents"],
|
||||
"failure_retry_restart": ["payload is loaded after stale claim recovery; evaluator failure leaves dispatch retryable", "callback only follows a persisted aggregate; unknown remains unknown"],
|
||||
"assumptions": ["Cognitive Worker endpoint is available as optional local execution path", "external bridge remains preferred for non-evaluation agent tasks"],
|
||||
"acceptance_scenarios": ["profile/cases survive save-load", "task dispatch invokes evaluator with original prompt payload", "all observations aggregate deterministically", "callback receives matching binding/profile/artifact", "evaluator or callback failure does not complete dispatch", "legacy dispatch remains unchanged"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000214",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "f9b9765100df02bead16280d2db9c1af478a4589",
|
||||
"risk_tier": "E",
|
||||
"intent": "Make Agent Execution recovery restart-safe: renew active leases with managed heartbeats, fence stale workers, cap retries, and requeue transient execution failures without losing durable dispatch state.",
|
||||
"requirement_ids": ["durable heartbeat", "lease fencing", "bounded retry", "restart recovery", "transient failure requeue"],
|
||||
"allowed_change_scope": {"modules": ["work", "agent-worker"], "paths": ["contracts/work/**", "modules/work/**", "adapters/postgres/work_repository.go", "cmd/agent-worker/**", "cmd/agent-canary/**", "database/migrations/042_agent_execution_recovery.sql", "tests/agent_execution/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000214-agent-execution-recovery.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["existing AgentExecutor", "existing lease/callback contract", "durable dispatch queue"], "may_change": ["WorkerStore retry/recovery port"]},
|
||||
"semantic_owner": "work owns dispatch, lease, callback and retry lifecycle",
|
||||
"canonical_state": ["work agent dispatch queue and lease tables remain canonical; heartbeat is durable evidence of liveness"],
|
||||
"state_transitions": ["CLAIMED -> QUEUED on transient failure or stale recovery", "CLAIMED -> FAILED after retry limit", "ACTIVE lease -> EXPIRED before stale claim requeue", "heartbeat renews lease and callback sequence"],
|
||||
"side_effects_and_privileges": ["work_role writes only work-owned queue and lease state; no external mutation"],
|
||||
"persistence_and_migrations": ["042 adds bounded retry limit to dispatch queue"],
|
||||
"failure_retry_restart": ["stale recovery expires the old lease before requeue", "transient execution failure uses durable delayed retry", "retry limit prevents hot-loop poison dispatch", "heartbeat failure cancels execution and leaves a retryable dispatch"],
|
||||
"assumptions": ["operational heartbeat ticker is lifecycle-managed by the bounded worker call and durable state remains authoritative"],
|
||||
"acceptance_scenarios": ["long execution renews lease and emits ordered heartbeats", "stale claim is fenced and requeued", "third failed attempt becomes FAILED", "waiting/error dispatch is not left CLAIMED", "legacy dispatch behavior remains compatible"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000215",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "8d3e7f7cac236aa50919bd1cd854012dcc8500a5",
|
||||
"risk_tier": "E",
|
||||
"intent": "Expose a durable, authenticated control-plane boundary for governed self-development proposals and isolated scaffold candidates without executing source mutation, bypassing verification, or granting promotion authority.",
|
||||
"requirement_ids": ["durable proposal intake", "isolated scaffold contract", "authenticated operator boundary", "no implicit promotion"],
|
||||
"allowed_change_scope": {"modules": ["work", "core-runtime"], "paths": ["contracts/work/self_development.go", "modules/work/domain/**", "cmd/core-runtime/main.go", "adapters/postgres/work_repository.go", "database/migrations/043_governed_development_candidates.sql", "tests/semantic_evolution/**", "tests/work/**", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000215-governed-development-control-plane.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "deploy/**", "cmd/devctl/**", "modules/identity/**", "modules/capability/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 1},
|
||||
"contracts": {"consume": ["ADR-0022 governed self-development boundary", "creator authentication", "work development gate"], "may_change": ["work-owned proposal/scaffold persistence and authenticated control-plane endpoints"]},
|
||||
"semantic_owner": "work owns proposal, scaffold and development gate lineage",
|
||||
"canonical_state": ["work-owned extension proposal and scaffold candidates are durable; source tree and production remain outside this task"],
|
||||
"state_transitions": ["repeated evidence -> PROPOSED", "proposal -> SANDBOXED scaffold candidate", "gate remains explicit and cannot be promoted by these endpoints"],
|
||||
"side_effects_and_privileges": ["authenticated creator session required; database writes stay within work owner; no checkout, source mutation, deployment or secret access"],
|
||||
"persistence_and_migrations": ["043 adds durable work-owned proposal/scaffold candidate records"],
|
||||
"failure_retry_restart": ["idempotent candidate IDs preserve lineage; duplicate IDs are rejected; no endpoint advances promotion stage"],
|
||||
"assumptions": ["an independent executor and verifier remain separate future capabilities"],
|
||||
"acceptance_scenarios": ["unauthenticated requests are rejected", "repeated evidence creates a durable proposal", "protected paths cannot create a scaffold", "valid scaffold is durable and remains SANDBOXED", "no promotion or source mutation occurs"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "migration/schema checks", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP", "BLOCKED_BY_ARCHITECTURE_GAP", "BLOCKED_BY_MISSING_CAPABILITY"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000216",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "62f0c529b3f8b3c92e4a69fcc6298d59b3015fde",
|
||||
"risk_tier": "E",
|
||||
"intent": "Keep agent-worker status truthful when the protected external execution bridge is absent while retaining local task-evaluation fallback.",
|
||||
"requirement_ids": ["WP-301", "truthful degraded status", "local fallback"],
|
||||
"allowed_change_scope": {"modules": ["agent-worker"], "paths": ["cmd/agent-worker/main.go", "cmd/agent-worker/status_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000216-agent-worker-status-truth.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "database/**", "deploy/**", "security/**", "contracts/**", "modules/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["protected bridge file", "agent-worker status response", "local task-evaluation fallback"], "may_change": []},
|
||||
"semantic_owner": "work",
|
||||
"canonical_state": ["existing worker status projection and durable dispatch lifecycle"],
|
||||
"state_transitions": ["bridge absent -> local-fallback status", "bridge present -> execution status"],
|
||||
"side_effects_and_privileges": ["no external mutation; no new credentials; no persistence change"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["bridge absence remains explicit after restart; local evaluator remains available for bounded qualification work"],
|
||||
"assumptions": ["task-evaluation fallback is not general external agent execution"],
|
||||
"acceptance_scenarios": ["worker marks local_fallback when protected bridge URL is absent", "status never reports executor_configured=true for local fallback", "configured bridge path remains execution mode", "no secret value is exposed"],
|
||||
"required_tests": ["go test ./cmd/agent-worker", "go vet ./cmd/agent-worker", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production worker status"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000217",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "05a246ea3f240a77be769969ad62a236fba33bac",
|
||||
"risk_tier": "E",
|
||||
"intent": "Refresh the project status projection and redacted live evidence after the agent-worker truthful fallback fix without changing runtime behavior.",
|
||||
"requirement_ids": ["current audit evidence", "task lineage accuracy", "negative-state transparency"],
|
||||
"allowed_change_scope": {"modules": ["operations-evidence"], "paths": ["docs/PROJECT_STATUS.md", "docs/evidence/audit-runtime-status-2026-08-23-v2.json", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000217-current-status-evidence.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "cmd/**", "modules/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["live health/readiness/status responses", "merged task lineage"], "may_change": ["operations status projection and redacted evidence artifact"]},
|
||||
"semantic_owner": "operations-evidence projection",
|
||||
"canonical_state": ["none; docs and evidence are rebuildable projections"],
|
||||
"state_transitions": ["stale status projection -> current evidence projection"],
|
||||
"side_effects_and_privileges": ["read-only live checks; no secrets, credentials or runtime mutation"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["evidence records unavailable/degraded states explicitly; no success inferred from missing data"],
|
||||
"assumptions": ["historical evidence remains immutable and is not rewritten"],
|
||||
"acceptance_scenarios": ["active task points to the current merged packet", "bridge absence is reported as local fallback", "GPU timeout is reported as degraded without changing FAST preference", "authenticated UI availability is not claimed without a current signed-in session", "no secret value appears in the projection"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health and runtime status"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000218",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "ab840878fe7d21a0a6aeb69193a3d58ea23a56e0",
|
||||
"risk_tier": "E",
|
||||
"intent": "Add a conservative deterministic policy gate for creating additional model-evaluation profiles only after general evaluation evidence remains unresolved.",
|
||||
"requirement_ids": ["general-first evaluation", "bounded additional evaluation", "no unnecessary fleet-wide tests"],
|
||||
"allowed_change_scope": {"modules": ["capability"], "paths": ["modules/capability/domain/task_evaluation.go", "modules/capability/domain/task_evaluation_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000218-automatic-expansion-policy.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "cmd/**", "contracts/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskEvaluationProfile", "TaskEvaluation", "TaskEvaluationExpansionRequest"], "may_change": []},
|
||||
"semantic_owner": "capability owns model qualification evidence and expansion policy",
|
||||
"canonical_state": ["none; this packet creates no persistence or activation authority"],
|
||||
"state_transitions": ["general COMPLETED/PARTIAL unresolved evidence -> bounded additional profile candidate", "qualified candidate -> no expansion"],
|
||||
"side_effects_and_privileges": ["pure deterministic decision; no provider call, no model invocation, no mutation"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["missing trigger evidence or additional cases fails closed; repeated decision is deterministic"],
|
||||
"assumptions": ["a later task supplies task-specific additional cases from an unresolved outcome and schedules them through the existing Work Graph"],
|
||||
"acceptance_scenarios": ["general graph not completed/partial does not expand", "unknown/unsupported/low-scoring candidates expand only with evidence and new cases", "one qualified candidate suppresses expansion", "no provider or hardware label changes the decision"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000219",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "7476f43ffa57d55f5a5a9bddc7f1c74e9b87ae0e",
|
||||
"risk_tier": "E",
|
||||
"intent": "Prevent task-evaluation invocation success from being recorded as semantic model quality or support without independent verifier evidence.",
|
||||
"requirement_ids": ["truthful model evidence", "independent verification", "no false qualification"],
|
||||
"allowed_change_scope": {"modules": ["cognitive-worker"], "paths": ["cmd/cognitive-worker/task_evaluation.go", "cmd/cognitive-worker/task_evaluation_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000219-reachability-not-quality.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "modules/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskEvaluationExecutionRequest/Response", "TaskCaseObservation", "Cognitive Gateway"], "may_change": []},
|
||||
"semantic_owner": "capability evidence remains owner; cognitive-worker only records invocation observation",
|
||||
"canonical_state": ["existing task-evaluation evidence; no new persistence"],
|
||||
"state_transitions": ["successful invocation -> UNKNOWN reachability observation", "independent verifier evidence -> future supported/quality observation"],
|
||||
"side_effects_and_privileges": ["no additional provider calls or privileges"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["unknown evidence remains retryable/eligible for a separately verified evaluation; no promotion follows invocation alone"],
|
||||
"assumptions": ["a later verifier task will attach answer-quality evidence and score cases"],
|
||||
"acceptance_scenarios": ["nonempty provider response is not SUPPORTED", "quality score remains zero", "provider invocation failure remains UNKNOWN with critical failure", "no model/hardware label can manufacture quality evidence"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000220",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "66ed523248ea6cf0a41383e2206d9a70eeaafda8",
|
||||
"risk_tier": "E",
|
||||
"intent": "Require explicit independent verifier identity and evidence before task evaluation can claim semantic support or non-zero quality.",
|
||||
"requirement_ids": ["independent verifier evidence", "no false quality", "typed model qualification"],
|
||||
"allowed_change_scope": {"modules": ["capability", "agent-worker"], "paths": ["contracts/capability/task_evaluation.go", "modules/capability/domain/task_evaluation.go", "modules/capability/domain/task_evaluation_test.go", "cmd/agent-worker/task_evaluation_executor_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000220-verifier-evidence-boundary.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "cmd/cognitive-worker/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskCaseObservation", "TaskEvaluation", "existing Work Graph callback"], "may_change": ["typed observation fields for verifier evidence"]},
|
||||
"semantic_owner": "capability owns qualification evidence; verifier remains independent of provider invocation",
|
||||
"canonical_state": ["existing capability task-evaluation evidence"],
|
||||
"state_transitions": ["reachability UNKNOWN -> separately verified SUPPORTED only with evidence"],
|
||||
"side_effects_and_privileges": ["no provider invocation or external mutation"],
|
||||
"persistence_and_migrations": ["existing task evaluation persistence is unchanged"],
|
||||
"failure_retry_restart": ["missing verifier fields fail closed; unknown reachability remains non-promotable"],
|
||||
"assumptions": ["a later verifier runtime will produce VerificationEvidenceRef and VerifierIdentity"],
|
||||
"acceptance_scenarios": ["supported without verifier is rejected", "unknown with zero quality remains valid", "verified supported observation aggregates normally", "no hardware/provider name bypasses the boundary"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000221",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "0aab10863cb23824688029c0bab6734f100b4610",
|
||||
"risk_tier": "E",
|
||||
"intent": "Provide an independent deterministic task-verifier runtime and wire model task evaluation through response evidence rather than provider self-assertion.",
|
||||
"requirement_ids": ["independent verifier runtime", "typed answer evidence", "quality qualification boundary", "degraded verifier fallback"],
|
||||
"allowed_change_scope": {"modules": ["capability", "cognitive-worker", "agent-worker", "task-verifier", "deployment"], "paths": ["contracts/capability/task_evaluation.go", "contracts/capability/task_evaluation_execution.go", "cmd/cognitive-worker/task_evaluation.go", "cmd/agent-worker/main.go", "cmd/agent-worker/task_evaluation_executor.go", "cmd/agent-worker/task_evaluation_executor_test.go", "cmd/task-verifier/**", "deploy/docker-compose.prod.yml", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000221-task-verifier-runtime.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "modules/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskEvaluationCase", "TaskEvaluationExecutionRequest/Response", "TaskCaseObservation", "existing worker callback"], "may_change": ["typed verification spec, response envelope and verifier result" ]},
|
||||
"semantic_owner": "capability owns evidence; task-verifier is a replaceable independent verifier adapter and does not own canonical state",
|
||||
"canonical_state": ["existing capability task-evaluation evidence only; verifier has no database"],
|
||||
"state_transitions": ["provider response -> bounded response evidence -> verifier result -> TaskCaseObservation -> TaskEvaluation"],
|
||||
"side_effects_and_privileges": ["local network calls only; no secrets; no external mutation; no authority expansion"],
|
||||
"persistence_and_migrations": ["none; verifier evidence is carried through existing callback and capability persistence"],
|
||||
"failure_retry_restart": ["verifier unavailable returns local fallback/waiting; no quality or promotion claim is made"],
|
||||
"assumptions": ["initial verifier mode is exact-text for bounded deterministic cases; future verifiers may add typed modes under separate task packets"],
|
||||
"acceptance_scenarios": ["exact response produces supported evidence with verifier identity", "mismatch produces unsupported evidence", "cognitive worker never asserts support from invocation alone", "worker wires verifier result before callback", "verifier process has health endpoint and production compose dependency", "verifier loss does not produce false quality"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health and verifier health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000222",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "d736500d9818a7748c3715c442edb695c5aed1100",
|
||||
"risk_tier": "E",
|
||||
"intent": "Extend the independent task verifier with a structured JSON field mode and add a bounded case builder driven by unresolved evidence.",
|
||||
"requirement_ids": ["task-aware verification", "additional cases during lifetime", "general-first bounded expansion"],
|
||||
"allowed_change_scope": {"modules": ["capability", "task-verifier"], "paths": ["contracts/capability/task_evaluation.go", "cmd/task-verifier/main.go", "cmd/task-verifier/main_test.go", "modules/capability/domain/task_evaluation.go", "modules/capability/domain/task_evaluation_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000222-verifier-modes-and-case-builder.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "cmd/core-runtime/**", "cmd/creator-web/**", "cmd/agent-worker/**", "cmd/cognitive-worker/**", "modules/identity/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["TaskCaseVerification", "TaskEvaluationCase", "unresolved outcome evidence"], "may_change": ["verification modes and bounded case construction" ]},
|
||||
"semantic_owner": "capability owns evaluation policy; verifier remains stateless adapter",
|
||||
"canonical_state": ["none; case builder returns candidates and does not persist or activate them"],
|
||||
"state_transitions": ["unresolved evidence -> two bounded additional case candidates", "structured response -> verifier evidence -> supported/unsupported"],
|
||||
"side_effects_and_privileges": ["no model calls, no external mutation, no secrets"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["invalid unresolved evidence fails closed; verifier remains stateless and repeatable"],
|
||||
"assumptions": ["generated cases still require an explicit verification spec before quality can be claimed"],
|
||||
"acceptance_scenarios": ["JSON field match verifies with evidence", "JSON mismatch is unsupported", "unresolved evidence yields exactly two bounded probes", "generated cases do not bypass verifier or promotion gate"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000223",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "ee68e2207eff5535c79cc594637a971cea03d71c",
|
||||
"risk_tier": "E",
|
||||
"intent": "Reconcile the status projection with the merged DEV-000222 verifier stage and preserve explicit remaining runtime and UI gaps.",
|
||||
"requirement_ids": ["current audit evidence", "task lineage accuracy", "negative-state transparency"],
|
||||
"allowed_change_scope": {"modules": ["operations-evidence"], "paths": ["docs/PROJECT_STATUS.md", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000223-status-reconciliation.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "cmd/**", "modules/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["merged task lineage", "existing redacted live evidence"], "may_change": ["operations status projection"]},
|
||||
"semantic_owner": "operations-evidence projection",
|
||||
"canonical_state": ["none; docs are rebuildable projections"],
|
||||
"state_transitions": ["stale task pointer -> merged verifier stage pointer"],
|
||||
"side_effects_and_privileges": ["none; documentation only"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["historical evidence remains immutable"],
|
||||
"assumptions": ["DEV-000222 main revision ee68e22 is the current deployed baseline"],
|
||||
"acceptance_scenarios": ["status names DEV-000222 as last completed", "remaining bridge and current authenticated UI gaps remain explicit", "no secret value is added"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000224",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "c4411afaaf21886cba9b5ead586afb26d697a355",
|
||||
"risk_tier": "E",
|
||||
"intent": "Record the current production model-fleet readiness and dynamic tier selection without claiming quality or promotion.",
|
||||
"requirement_ids": ["model fleet/failover", "GPU is not intelligence priority", "truthful operational evidence"],
|
||||
"allowed_change_scope": {"modules": ["operations-evidence"], "paths": ["docs/PROJECT_STATUS.md", "docs/evidence/fleet-readiness-live-2026-08-23-v2.json", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000224-live-fleet-evidence.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "cmd/**", "modules/**", "contracts/**", "database/**", "deploy/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["live cognitive-worker fleet readiness response"], "may_change": ["redacted evidence and status projection"]},
|
||||
"semantic_owner": "operations-evidence projection",
|
||||
"canonical_state": ["none; evidence is a rebuildable projection"],
|
||||
"state_transitions": ["live fleet readiness -> redacted evidence record"],
|
||||
"side_effects_and_privileges": ["read-only production inspection"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["unavailable providers remain explicit; no missing data is promoted to ready"],
|
||||
"assumptions": ["fleet endpoint response is authoritative for this observation window"],
|
||||
"acceptance_scenarios": ["FAST unavailable selects available MEDIUM", "HEAVY availability does not imply priority", "LIFEBOAT remains fallback", "secret values are absent"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health and fleet live check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"task_id": "DEV-000225",
|
||||
"task_revision": 1,
|
||||
"architecture_revision": "ADR-0018",
|
||||
"base_repo_revision": "e080c34a772caa5d256ab9f4ae0d2568bb8a7fb9",
|
||||
"risk_tier": "E",
|
||||
"intent": "Serve the existing chat/login shell at the direct /login route so users can reach authentication without a 404.",
|
||||
"requirement_ids": ["UI login direct route", "authenticated acceptance entrypoint"],
|
||||
"allowed_change_scope": {"modules": ["creator-web"], "paths": ["cmd/creator-web/server.go", "cmd/creator-web/server_test.go", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000225-login-route-fix.json"]},
|
||||
"forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "deploy/**", "contracts/**", "modules/**", "LLM-local/**"]},
|
||||
"allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 0, "new_third_party_dependencies": 0, "new_database_owner_roles": 0},
|
||||
"contracts": {"consume": ["existing embedded chat/login shell"], "may_change": ["HTTP route mapping only"]},
|
||||
"semantic_owner": "creator-web route projection",
|
||||
"canonical_state": ["none"],
|
||||
"state_transitions": ["GET /login -> existing chat shell"],
|
||||
"side_effects_and_privileges": ["none; GET only"],
|
||||
"persistence_and_migrations": ["none"],
|
||||
"failure_retry_restart": ["route remains deterministic and stateless"],
|
||||
"assumptions": ["authentication behavior remains unchanged and is handled by the existing chat shell"],
|
||||
"acceptance_scenarios": ["GET /login returns 200", "login inputs are present", "root route remains unchanged", "no credential values are added"],
|
||||
"required_tests": ["go test", "go vet", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "CI", "production public health and /login check"],
|
||||
"terminal_gap_states": ["BLOCKED_BY_SPEC_GAP"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user