Skip to content

Commit 22aa249

Browse files
committed
feat: agentic harness patterns - dream consolidation, scoped context, compaction
- Dream Consolidation: session-end hook extracts knowledge from completed tasks via LLM, stores with source_agent="dream" and confidence 0.6 - Scoped Context Assembly: task enricher now uses task scope for targeted retrieval ("auth" -> "auth jwt cookies patterns" instead of generic query) - Progressive Context Compaction: compact_summary field on nodes, used by FormatCompact() when available (falls back to truncation) - Command Risk Classification: ADR documenting T0-T3 risk tiers for future destructive MCP tools
1 parent 813bfd0 commit 22aa249

6 files changed

Lines changed: 184 additions & 9 deletions

File tree

cmd/hook.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import (
1212
"strings"
1313
"time"
1414

15+
"github.com/cloudwego/eino/schema"
16+
"github.com/josephgoksu/TaskWing/internal/agents/core"
1517
"github.com/josephgoksu/TaskWing/internal/config"
1618
"github.com/josephgoksu/TaskWing/internal/knowledge"
1719
"github.com/josephgoksu/TaskWing/internal/llm"
@@ -475,6 +477,11 @@ Tasks Completed: %d
475477
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
476478
`, session.SessionID, int(elapsed.Minutes()), session.TasksCompleted)
477479

480+
// Dream Consolidation: extract knowledge from completed tasks
481+
if session.TasksCompleted > 0 {
482+
dreamConsolidate(session)
483+
}
484+
478485
// Remove session file
479486
sessionPath, err := getHookSessionPath()
480487
if err == nil {
@@ -484,6 +491,115 @@ Tasks Completed: %d
484491
return nil
485492
}
486493

494+
// dreamConsolidate extracts architectural knowledge from completed tasks
495+
// and writes it to the knowledge graph with source_agent="dream".
496+
func dreamConsolidate(session *HookSession) {
497+
repo, err := openRepo()
498+
if err != nil {
499+
return
500+
}
501+
defer func() { _ = repo.Close() }()
502+
503+
// Get completed tasks from the active plan
504+
plan, err := repo.GetActivePlan()
505+
if err != nil || plan == nil {
506+
return
507+
}
508+
509+
// Collect completed task summaries
510+
var taskSummaries []string
511+
for _, t := range plan.Tasks {
512+
if t.Status == task.StatusCompleted && t.CompletionSummary != "" {
513+
taskSummaries = append(taskSummaries, fmt.Sprintf("- %s: %s", t.Title, t.CompletionSummary))
514+
}
515+
}
516+
if len(taskSummaries) == 0 {
517+
return
518+
}
519+
520+
// Get LLM config - use fast model for cheap background work
521+
llmCfg, err := config.LoadLLMConfig()
522+
if err != nil {
523+
return
524+
}
525+
if llmCfg.APIKey == "" {
526+
return
527+
}
528+
fastModel := llm.GetRecommendedModelForRole(string(llmCfg.Provider), llm.RoleQuery)
529+
if fastModel != nil {
530+
llmCfg.Model = fastModel.ID
531+
}
532+
533+
// Generate findings via LLM
534+
prompt := fmt.Sprintf(`You completed these tasks in a development session:
535+
536+
%s
537+
538+
Extract any NEW architectural decisions, patterns, or constraints that were established or discovered during this work. Only include items that would be valuable for future sessions.
539+
540+
Respond in JSON:
541+
{"findings": [{"type": "decision|pattern|constraint", "title": "...", "description": "..."}]}
542+
543+
If nothing notable was established, respond with: {"findings": []}`, strings.Join(taskSummaries, "\n"))
544+
545+
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
546+
defer cancel()
547+
548+
chatModel, err := llm.NewCloseableChatModel(ctx, llmCfg)
549+
if err != nil {
550+
return
551+
}
552+
defer func() { _ = chatModel.Close() }()
553+
554+
resp, err := chatModel.Generate(ctx, []*schema.Message{schema.UserMessage(prompt)})
555+
if err != nil {
556+
return
557+
}
558+
559+
// Parse findings
560+
type dreamFinding struct {
561+
Type string `json:"type"`
562+
Title string `json:"title"`
563+
Description string `json:"description"`
564+
}
565+
type dreamResponse struct {
566+
Findings []dreamFinding `json:"findings"`
567+
}
568+
569+
parsed, err := core.ParseJSONResponse[dreamResponse](resp.Content)
570+
if err != nil || len(parsed.Findings) == 0 {
571+
return
572+
}
573+
574+
// Convert to core.Finding and ingest
575+
var findings []core.Finding
576+
for _, f := range parsed.Findings {
577+
findingType := core.FindingTypeDecision
578+
switch f.Type {
579+
case "pattern":
580+
findingType = core.FindingTypePattern
581+
case "constraint":
582+
findingType = core.FindingTypeConstraint
583+
}
584+
findings = append(findings, core.Finding{
585+
Type: findingType,
586+
Title: f.Title,
587+
Description: f.Description,
588+
ConfidenceScore: 0.6,
589+
SourceAgent: "dream",
590+
})
591+
}
592+
593+
ks := knowledge.NewService(repo, llmCfg)
594+
memoryPath, _ := config.GetMemoryBasePath()
595+
if memoryPath != "" {
596+
ks.SetBasePath(filepath.Dir(filepath.Dir(memoryPath)))
597+
}
598+
_ = ks.IngestFindings(ctx, findings, nil, false)
599+
600+
fmt.Printf(" Dream: extracted %d knowledge items from session\n", len(findings))
601+
}
602+
487603
// Session persistence helpers
488604

489605
func getHookSessionPath() (string, error) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# ADR: Command Risk Classification for MCP Tools
2+
3+
## Status
4+
Proposed (documentation only - no runtime implementation yet)
5+
6+
## Context
7+
TaskWing exposes MCP tools (ask, task, plan, code, debug, remember) to AI assistants via stdio transport. Currently all tools are available immediately with no risk gating. As TaskWing adds more capable tools (file writes, git operations, node deletion), a classification scheme is needed to prevent destructive actions without explicit user approval.
8+
9+
## Risk Tiers
10+
11+
| Tier | Label | Behavior | Examples |
12+
|---|---|---|---|
13+
| **T0** | Safe | Auto-execute, no confirmation | `ask`, `code` (read-only queries) |
14+
| **T1** | Write | Execute with audit trail | `remember`, `task complete` (writes to local SQLite) |
15+
| **T2** | Risky | Require explicit user confirmation | Future: `delete-node`, `rewrite-file`, `git commit` |
16+
| **T3** | Destructive | Block unless plan-approved + user confirmed | Future: `clear-knowledge`, `git push --force`, `rm -rf` |
17+
18+
## Decision
19+
When destructive tools (T2/T3) are added to the MCP surface:
20+
21+
1. Each MCP tool handler must declare its risk tier
22+
2. The MCP handler chain checks the tier before execution
23+
3. T2 tools prompt for confirmation via the MCP response (tool returns a confirmation request instead of executing)
24+
4. T3 tools require both an active approved plan AND explicit user confirmation
25+
5. The existing OPA policy engine (`internal/policy/`) can evaluate T2/T3 tool calls against project policies
26+
27+
## Gating Rules
28+
29+
T2/T3 tools must satisfy these gates (consistent with the Workflow Contract v1):
30+
31+
- **Plan gate**: A clarified and approved plan must be active
32+
- **Task gate**: The tool call must be relevant to the current in-progress task
33+
- **Evidence gate**: For T3, prior root-cause evidence must exist before destructive action
34+
- **Confirmation gate**: User must explicitly approve (not just "auto" or "skip")
35+
36+
## Current State
37+
- All current MCP tools are T0 (read-only) or T1 (local SQLite writes)
38+
- No T2/T3 tools exist yet
39+
- The OPA policy engine is built but only runs during task completion, not per-tool-call
40+
- When T2/T3 tools are introduced, wire `policy.NewPolicyEvaluatorAdapter()` into the MCP handler chain
41+
42+
## Implementation Notes (for future reference)
43+
- Add a `RiskTier` field to the MCP tool registration in `internal/mcp/handlers.go`
44+
- Check tier in the handler dispatch before calling the tool implementation
45+
- For T2: return a structured confirmation request in the MCP response
46+
- For T3: check `policy.Engine.Evaluate()` with the tool call context
47+
- Log all T1+ tool executions to the session audit trail

internal/app/plan.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ type TaskPlanner interface {
116116
// TaskContextEnricher executes ask queries and returns aggregated context for a task.
117117
// This is used during task creation to populate ContextSummary (early binding).
118118
// See docs/architecture/ADR_CONTEXT_BINDING.md for the full context binding design.
119-
type TaskContextEnricher func(ctx context.Context, queries []string) (string, error)
119+
type TaskContextEnricher func(ctx context.Context, queries []string, scope string) (string, error)
120120

121121
const (
122122
defaultClarifyMaxRounds = 5
@@ -167,17 +167,21 @@ func (a *PlanApp) retrieveContext(ctx context.Context, ks *knowledge.Service, go
167167
}
168168

169169
// defaultTaskEnricher uses GetProjectContext with compact options to enrich tasks.
170-
func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string) (string, error) {
170+
func (a *PlanApp) defaultTaskEnricher(ctx context.Context, queries []string, scope string) (string, error) {
171171
if a.ctx == nil || a.ctx.Repo == nil {
172172
return "", nil
173173
}
174174

175175
ks := knowledge.NewService(a.ctx.Repo, a.ctx.LLMCfg)
176176

177-
// Use the task's specific queries as the search query, or fall back to baseline
178-
query := "project constraints and key technology decisions"
177+
// Build scope-aware query: prefer task queries, fall back to scope-based, then generic
178+
var query string
179179
if len(queries) > 0 {
180180
query = strings.Join(queries, " ")
181+
} else if scope != "" {
182+
query = scope + " patterns constraints decisions"
183+
} else {
184+
query = "project constraints and key technology decisions"
181185
}
182186

183187
modelID := a.ctx.LLMCfg.Model
@@ -1002,8 +1006,8 @@ func (a *PlanApp) parseTasksFromMetadata(ctx context.Context, metadata map[strin
10021006
t.EnrichAIFields()
10031007

10041008
// Populate ContextSummary by executing ask queries
1005-
if a.TaskEnricher != nil && len(t.SuggestedAskQueries) > 0 {
1006-
if contextSummary, err := a.TaskEnricher(ctx, t.SuggestedAskQueries); err == nil && contextSummary != "" {
1009+
if a.TaskEnricher != nil && (len(t.SuggestedAskQueries) > 0 || t.Scope != "") {
1010+
if contextSummary, err := a.TaskEnricher(ctx, t.SuggestedAskQueries, t.Scope); err == nil && contextSummary != "" {
10071011
t.ContextSummary = contextSummary
10081012
}
10091013
}
@@ -1096,8 +1100,8 @@ func (a *PlanApp) parseTasksFromMetadata(ctx context.Context, metadata map[strin
10961100
newTask.EnrichAIFields()
10971101

10981102
// Populate ContextSummary by executing ask queries
1099-
if a.TaskEnricher != nil && len(newTask.SuggestedAskQueries) > 0 {
1100-
if contextSummary, err := a.TaskEnricher(ctx, newTask.SuggestedAskQueries); err == nil && contextSummary != "" {
1103+
if a.TaskEnricher != nil && (len(newTask.SuggestedAskQueries) > 0 || newTask.Scope != "") {
1104+
if contextSummary, err := a.TaskEnricher(ctx, newTask.SuggestedAskQueries, newTask.Scope); err == nil && contextSummary != "" {
11011105
newTask.ContextSummary = contextSummary
11021106
}
11031107
}

internal/knowledge/context.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,10 @@ func (pc *ProjectContext) FormatCompact(modelID ...string) string {
203203
if node.Node == nil {
204204
continue
205205
}
206-
content := utils.Truncate(node.Node.Text(), nodeChars)
206+
content := node.Node.CompactSummary
207+
if content == "" {
208+
content = utils.Truncate(node.Node.Text(), nodeChars)
209+
}
207210
sb.WriteString(fmt.Sprintf("- **%s** (%s): %s\n", node.Node.Summary, node.Node.Type, content))
208211
}
209212
}

internal/memory/models.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ type Node struct {
6161

6262
// RefactorHint provides guidance on how to eliminate this debt
6363
RefactorHint string `json:"refactorHint,omitempty"`
64+
65+
// CompactSummary is an LLM-generated dense summary for context packing.
66+
// Populated during bootstrap ingestion. Used by FormatCompact() instead of truncation.
67+
CompactSummary string `json:"compactSummary,omitempty"`
6468
}
6569

6670
// DebtLevel returns human-readable debt classification for a node.

internal/memory/sqlite.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,6 +569,7 @@ func (s *SQLiteStore) initSchema() error {
569569
// 'root' = global knowledge at repo root, service names (e.g., 'osprey', 'studio') for scoped knowledge
570570
{"workspace", "ALTER TABLE nodes ADD COLUMN workspace TEXT DEFAULT 'root'"},
571571
{"stale_count", "ALTER TABLE nodes ADD COLUMN stale_count INTEGER DEFAULT 0"},
572+
{"compact_summary", "ALTER TABLE nodes ADD COLUMN compact_summary TEXT DEFAULT ''"},
572573
}
573574

574575
for _, m := range migrations {

0 commit comments

Comments
 (0)