367 lines
14 KiB
Go
367 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"agent-system-backend/internal/projectcontext"
|
|
"agent-system-backend/internal/runtime"
|
|
"agent-system-backend/pkg/extension"
|
|
core "agent-system-core"
|
|
)
|
|
|
|
type projectContextSlot struct {
|
|
mu sync.RWMutex
|
|
holder string
|
|
provider extension.ProjectContextProvider
|
|
}
|
|
|
|
func (s *projectContextSlot) active() bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return s.provider != nil
|
|
}
|
|
|
|
func requireProjectContextProvider(required bool, slot *projectContextSlot) error {
|
|
if required && !slot.active() {
|
|
return errors.New("extension: required project context provider not assembled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r compiledRegistry) RegisterProjectContextProvider(extension.ProjectContextProvider) error {
|
|
return errors.New("extension: declarative plugins cannot register project context providers")
|
|
}
|
|
|
|
func (r pluginRegistry) RegisterProjectContextProvider(p extension.ProjectContextProvider) error {
|
|
if r.caps.contexts == nil || r.pluginID == "" || p == nil {
|
|
return errors.New("extension: project context provider unavailable")
|
|
}
|
|
slot := r.caps.contexts
|
|
slot.mu.Lock()
|
|
defer slot.mu.Unlock()
|
|
if slot.provider != nil {
|
|
return errors.New("extension: project context provider already registered")
|
|
}
|
|
slot.holder, slot.provider = r.pluginID, p
|
|
return nil
|
|
}
|
|
|
|
func (r recordRegistry) RegisterProjectContextProvider(p extension.ProjectContextProvider) error {
|
|
reg, ok := r.Registry.(extension.ProjectContextRegistry)
|
|
if !ok {
|
|
return errors.New("extension: project context registration unavailable")
|
|
}
|
|
return reg.RegisterProjectContextProvider(p)
|
|
}
|
|
|
|
func (s *projectContextSlot) resolve(ctx context.Context, req extension.ProjectContextRequest) (extension.ProjectContextBundle, error) {
|
|
if s == nil {
|
|
return extension.ProjectContextBundle{}, errors.New("extension: project context provider missing")
|
|
}
|
|
s.mu.RLock()
|
|
p, holder := s.provider, s.holder
|
|
s.mu.RUnlock()
|
|
if p == nil {
|
|
return extension.ProjectContextBundle{}, errors.New("extension: project context provider missing")
|
|
}
|
|
if req.Actor == "" || req.ProjectID == "" {
|
|
return extension.ProjectContextBundle{}, errors.New("extension: trusted project scope missing")
|
|
}
|
|
req.PluginID = holder
|
|
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
b, err := p.Resolve(bounded, req)
|
|
if err != nil {
|
|
return extension.ProjectContextBundle{}, err
|
|
}
|
|
if err := bounded.Err(); err != nil {
|
|
return extension.ProjectContextBundle{}, err
|
|
}
|
|
if b.ProjectID != req.ProjectID || b.Status != extension.ProjectContextReady {
|
|
return extension.ProjectContextBundle{}, fmt.Errorf("extension: project context unavailable: %s", b.Status)
|
|
}
|
|
return b, nil
|
|
}
|
|
|
|
func (s *projectContextSlot) readEvidence(ctx context.Context, req extension.ProjectEvidenceRequest) (extension.ProjectEvidence, error) {
|
|
if s == nil || req.Scope.Actor == "" || req.Scope.ProjectID == "" || req.Reference.ID == "" {
|
|
return extension.ProjectEvidence{}, errors.New("extension: trusted evidence scope missing")
|
|
}
|
|
s.mu.RLock()
|
|
p, holder := s.provider, s.holder
|
|
s.mu.RUnlock()
|
|
if p == nil {
|
|
return extension.ProjectEvidence{}, errors.New("extension: project context provider missing")
|
|
}
|
|
req.Scope.PluginID = holder
|
|
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
evidence, err := p.ReadEvidence(bounded, req)
|
|
if err != nil {
|
|
return extension.ProjectEvidence{}, err
|
|
}
|
|
if err := bounded.Err(); err != nil {
|
|
return extension.ProjectEvidence{}, err
|
|
}
|
|
if evidence.Reference != req.Reference || len([]rune(evidence.Text)) > req.Scope.BudgetChars {
|
|
return extension.ProjectEvidence{}, errors.New("extension: project evidence response mismatches reference or budget")
|
|
}
|
|
return evidence, nil
|
|
}
|
|
|
|
func (s *projectContextSlot) recheck(ctx context.Context, req extension.ProjectContextSourceRecheckRequest) (extension.ProjectContextSourceRecheckResult, error) {
|
|
// Each unusable scope is reported on its own so host-side logs name the
|
|
// actual defect; every branch still rejects (fail-closed).
|
|
if s == nil {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project context source recheck slot missing")
|
|
}
|
|
if req.Scope.Actor == "" {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no actor")
|
|
}
|
|
if req.Scope.ProjectID == "" {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: trusted source recheck scope has no project")
|
|
}
|
|
if req.Scope.Goal != "" {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: source recheck scope carries a generation goal")
|
|
}
|
|
s.mu.RLock()
|
|
p, holder := s.provider, s.holder
|
|
s.mu.RUnlock()
|
|
rechecker, ok := p.(extension.ProjectContextSourceRechecker)
|
|
if !ok {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project source rechecker unavailable")
|
|
}
|
|
req.Scope.PluginID = holder
|
|
bounded, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
result, err := rechecker.Recheck(bounded, req)
|
|
if err != nil {
|
|
return extension.ProjectContextSourceRecheckResult{}, err
|
|
}
|
|
if err := bounded.Err(); err != nil {
|
|
return extension.ProjectContextSourceRecheckResult{}, err
|
|
}
|
|
if result.Bundle.ProjectID != req.Scope.ProjectID || result.Bundle.Status != extension.ProjectContextReady {
|
|
return extension.ProjectContextSourceRecheckResult{}, errors.New("extension: project sources no longer authorized")
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
const workContextBudgetChars = 12000
|
|
|
|
// TaskCreateForWork fixes the project binding while the authenticated command
|
|
// dispatch still owns its conversation. Legacy TaskCreate has no such binding.
|
|
func (h inprocHost) TaskCreateForWork(kind, title string) (string, error) {
|
|
if h.actor == "" || h.actor != h.owner || h.pluginID == "" || h.conversationID == "" || h.caps.st == nil || !h.caps.contexts.active() {
|
|
return "", errors.New("extension: trusted project work dispatch required")
|
|
}
|
|
conv, err := h.caps.st.GetConversation(h.owner, string(h.conversationID))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if conv.ProjectID == "" || (h.hostProjectID != "" && h.hostProjectID != conv.ProjectID) || h.caps.projects == nil || h.caps.projects.work() == nil {
|
|
return "", errors.New("extension: trusted project work scope missing")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
allowed, err := h.caps.projects.work()(ctx, h.actor, conv.ProjectID)
|
|
if err != nil || !allowed {
|
|
return "", errors.New("extension: project work denied")
|
|
}
|
|
task, err := h.caps.st.CreateWorkTask(h.owner, h.pluginID, string(conv.ProjectID), string(conv.ID), kind, title)
|
|
return task.ID, err
|
|
}
|
|
|
|
// completeForWork is the dispatch-scoped completion path. The conversation is
|
|
// reloaded so a plugin's goal/target and a stale dispatch project cannot select
|
|
// another project's context. Work authorization is checked on every call.
|
|
func (h inprocHost) CompleteForWork(ctx context.Context, req extension.WorkCompletionRequest) (extension.WorkCompletionResult, error) {
|
|
if strings.TrimSpace(req.Goal) == "" {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: work goal required")
|
|
}
|
|
if h.caps.st == nil {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: trusted work store missing")
|
|
}
|
|
actor, convID, targetID := h.actor, h.conversationID, string(h.conversationID)
|
|
var boundProjectID core.ID
|
|
if actor == "" {
|
|
if req.TaskID == "" || convID != "" {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: persisted work task required for scoped host")
|
|
}
|
|
binding, err := h.caps.st.WorkTaskScope(h.owner, h.pluginID, req.TaskID)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if binding.Status == "done" || binding.Status == "completed" || binding.Status == "failed" || binding.Status == "cancelled" {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: work task already terminal")
|
|
}
|
|
actor, convID, boundProjectID, targetID = binding.Owner, core.ID(binding.ConversationID), core.ID(binding.ProjectID), binding.TaskID
|
|
} else if actor != h.owner || convID == "" || req.TaskID != "" {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: trusted work dispatch required")
|
|
}
|
|
conv, err := h.caps.st.GetConversation(h.owner, string(convID))
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if conv.ProjectID == "" || (h.hostProjectID != "" && h.hostProjectID != conv.ProjectID) || (boundProjectID != "" && boundProjectID != conv.ProjectID) {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: trusted project scope mismatch")
|
|
}
|
|
if h.caps.projects == nil || h.caps.projects.work() == nil {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: project work authorization missing")
|
|
}
|
|
allowed, err := h.caps.projects.work()(ctx, actor, conv.ProjectID)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if !allowed {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: project work denied")
|
|
}
|
|
requested := extension.ProjectContextRequest{
|
|
Actor: actor, ProjectID: conv.ProjectID, ConversationID: conv.ID, TaskID: core.ID(req.TaskID),
|
|
Goal: req.Goal, TargetID: req.TargetID, BudgetChars: workContextBudgetChars,
|
|
}
|
|
bundle, err := h.caps.contexts.resolve(ctx, requested)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
evidenceText, usedEvidence, err := h.readWorkEvidence(ctx, requested, bundle, req.EvidenceRefs)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
prompt, manifest, err := assembleWorkCompletionWithEvidence(req.Goal, bundle, workContextBudgetChars, evidenceText, usedEvidence)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
manifest.ProjectID = conv.ProjectID
|
|
manifest.TaskID = req.TaskID
|
|
route := runtime.ResolveChatModel(core.AgentProfile{}, h.caps.set.Effective(h.owner, ""))
|
|
manifest.Model = route.Model
|
|
if route.Model == "" || route.Base == "" {
|
|
return extension.WorkCompletionResult{}, errors.New("llm route not configured")
|
|
}
|
|
// Resolve can be slow. A member may be revoked while the provider is
|
|
// assembling summaries, so check the live work policy again at send time.
|
|
allowed, err = h.caps.projects.work()(ctx, actor, conv.ProjectID)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if !allowed {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: project work denied")
|
|
}
|
|
current, err := h.caps.contexts.resolve(ctx, requested)
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if err := projectcontext.ValidateCurrent(manifest, current); err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
for _, used := range manifest.ReadEvidence {
|
|
found := false
|
|
for _, ref := range current.Evidence {
|
|
if ref.ID != used.ReferenceID || ref.SourceID != used.SourceID || ref.Revision != used.Revision || ref.Hash != used.SourceHash {
|
|
continue
|
|
}
|
|
scope := requested
|
|
scope.BudgetChars = 8000
|
|
now, err := h.caps.contexts.readEvidence(ctx, extension.ProjectEvidenceRequest{Scope: scope, Reference: ref})
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
if fmt.Sprintf("%x", sha256.Sum256([]byte(now.Text))) == used.InputSHA256 {
|
|
found = true
|
|
}
|
|
break
|
|
}
|
|
if !found {
|
|
return extension.WorkCompletionResult{}, errors.New("extension: requested work evidence changed before model send")
|
|
}
|
|
}
|
|
attemptID, err := projectcontext.NewAttemptID()
|
|
if err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
manifest.AttemptID, manifest.Delivery = attemptID, "attempted"
|
|
if err := h.caps.st.AppendContextManifest(h.owner, targetID, manifest); err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
text, err := completeChat(ctx, route.Base, route.Key, route.Model, prompt)
|
|
if err != nil {
|
|
manifest.Delivery = "unknown"
|
|
_ = h.caps.st.AppendContextManifest(h.owner, targetID, manifest)
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
manifest.Delivery = "responded"
|
|
if err := h.caps.st.AppendContextManifest(h.owner, targetID, manifest); err != nil {
|
|
return extension.WorkCompletionResult{}, err
|
|
}
|
|
return extension.WorkCompletionResult{Text: text, Manifest: manifest}, nil
|
|
}
|
|
|
|
func assembleWorkCompletion(goal string, bundle extension.ProjectContextBundle, budget int) (string, extension.ProjectContextManifest, error) {
|
|
return assembleWorkCompletionWithEvidence(goal, bundle, budget, "", nil)
|
|
}
|
|
|
|
func assembleWorkCompletionWithEvidence(goal string, bundle extension.ProjectContextBundle, budget int, evidenceText string, used []extension.ProjectContextManifestEntry) (string, extension.ProjectContextManifest, error) {
|
|
prefix := "Work goal:\n" + goal + "\n\n"
|
|
ref, m, err := projectcontext.Assemble(bundle, budget-len([]rune(prefix))-len([]rune(evidenceText)))
|
|
if err != nil {
|
|
return "", m, err
|
|
}
|
|
prompt := prefix + ref + evidenceText
|
|
m.BudgetChars = budget
|
|
m.ReadEvidence = used
|
|
m.InputSHA256 = fmt.Sprintf("%x", sha256.Sum256([]byte(prompt)))
|
|
return prompt, m, nil
|
|
}
|
|
|
|
func (h inprocHost) readWorkEvidence(ctx context.Context, scope extension.ProjectContextRequest, bundle extension.ProjectContextBundle, ids []string) (string, []extension.ProjectContextManifestEntry, error) {
|
|
if len(ids) > 4 {
|
|
return "", nil, errors.New("extension: too many work evidence references")
|
|
}
|
|
var b strings.Builder
|
|
var used []extension.ProjectContextManifestEntry
|
|
seen := make(map[string]bool, len(ids))
|
|
for _, id := range ids {
|
|
if id == "" || seen[id] {
|
|
return "", nil, errors.New("extension: invalid or duplicate work evidence reference")
|
|
}
|
|
seen[id] = true
|
|
var selected extension.ProjectEvidenceRef
|
|
for _, ref := range bundle.Evidence {
|
|
if ref.ID == id {
|
|
selected = ref
|
|
break
|
|
}
|
|
}
|
|
if selected.ID == "" {
|
|
return "", nil, errors.New("extension: work evidence reference not in current bundle")
|
|
}
|
|
readScope := scope
|
|
readScope.BudgetChars = 8000
|
|
evidence, err := h.caps.contexts.readEvidence(ctx, extension.ProjectEvidenceRequest{Scope: readScope, Reference: selected})
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
b.WriteString("\n[Evidence ")
|
|
b.WriteString(selected.ID)
|
|
b.WriteString(" source ")
|
|
b.WriteString(selected.SourceID)
|
|
b.WriteString(" revision ")
|
|
b.WriteString(selected.Revision)
|
|
b.WriteString("]\n")
|
|
b.WriteString(evidence.Text)
|
|
used = append(used, extension.ProjectContextManifestEntry{ReferenceID: selected.ID, SourceID: selected.SourceID, Revision: selected.Revision, SourceHash: selected.Hash,
|
|
InputSHA256: fmt.Sprintf("%x", sha256.Sum256([]byte(evidence.Text)))})
|
|
}
|
|
return b.String(), used, nil
|
|
}
|