diff --git a/cmd/sin-code/browser_interaction.go b/cmd/sin-code/browser_interaction.go index 4cf986d2..5e64daff 100644 --- a/cmd/sin-code/browser_interaction.go +++ b/cmd/sin-code/browser_interaction.go @@ -59,7 +59,12 @@ func toolBrowserWait(ctx context.Context, selector, timeoutStr string) (string, if browserSession == nil { return "", fmt.Errorf("no active browser session") } if selector == "" { return "", fmt.Errorf("selector required") } timeout := 10 * time.Second - if timeoutStr != "" { var sec int; if fmt.Sscanf(timeoutStr, "%d", &sec) == nil && sec > 0 && sec <= 120 { timeout = time.Duration(sec) * time.Second } } + if timeoutStr != "" { + var sec int + if n, _ := fmt.Sscanf(timeoutStr, "%d", &sec); n == 1 && sec > 0 && sec <= 120 { + timeout = time.Duration(sec) * time.Second + } + } waitCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() err := chromedp.Run(waitCtx, chromedp.WaitVisible(selector, chromedp.ByQuery)) diff --git a/cmd/sin-code/chat_cmd.go b/cmd/sin-code/chat_cmd.go index 0959c0d0..bb54de5e 100644 --- a/cmd/sin-code/chat_cmd.go +++ b/cmd/sin-code/chat_cmd.go @@ -131,6 +131,8 @@ type chatOptions struct { contextWindow int preserveEvidence bool compactionRecentTurns int + repetitionThreshold int + repetitionWindow int } func NewChatCmd() *cobra.Command { @@ -200,6 +202,8 @@ func NewChatCmd() *cobra.Command { f.IntVar(&opts.contextWindow, "context-window", 0, "effective token cap for compaction (0 = auto)") f.BoolVar(&opts.preserveEvidence, "compaction-preserve-evidence", false, "enable evidence preservation during compaction (M3, default true)") f.IntVar(&opts.compactionRecentTurns, "compaction-recent-turns", 0, "number of recent human turns to retain (default 4)") + f.IntVar(&opts.repetitionThreshold, "repetition-threshold", 0, "observer-loop detection: number of repetitions before aborting (0 = disabled, issue #377)") + f.IntVar(&opts.repetitionWindow, "repetition-window", 0, "observer-loop detection: window size for sequence detection (0 = default 1)") return cmd } diff --git a/cmd/sin-code/chat_mcp.go b/cmd/sin-code/chat_mcp.go index d7b00fbc..e77ecd73 100644 --- a/cmd/sin-code/chat_mcp.go +++ b/cmd/sin-code/chat_mcp.go @@ -15,7 +15,6 @@ import ( "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/agentloop" "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/mcpclient" - "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/toolretrieval" ) // mcp hook variables — injected by coverage tests to mock external MCP calls. diff --git a/cmd/sin-code/chat_tools.go b/cmd/sin-code/chat_tools.go index 6b184a74..e0186d9a 100644 --- a/cmd/sin-code/chat_tools.go +++ b/cmd/sin-code/chat_tools.go @@ -213,19 +213,15 @@ func toolBash(ctx context.Context, command string) (string, error) { if err != nil { return "", fmt.Errorf("sin_bash sandbox: %v", err) } out, err := cmd.CombinedOutput() text := string(out) - if len(text) > maxToolOutput { text = text[:maxToolOutput] + " -[... truncated]" } - if err != nil { return fmt.Sprintf("exit error: %v -%s", err, text), nil } + if len(text) > maxToolOutput { text = text[:maxToolOutput] + "\n[... truncated]" } + if err != nil { return fmt.Sprintf("exit error: %v\n%s", err, text), nil } return text, nil } cmd := exec.CommandContext(cctx, "sh", "-c", command) out, err := cmd.CombinedOutput() text := string(out) - if len(text) > maxToolOutput { text = text[:maxToolOutput] + " -[... truncated]" } - if err != nil { return fmt.Sprintf("exit error: %v -%s", err, text), nil } + if len(text) > maxToolOutput { text = text[:maxToolOutput] + "\n[... truncated]" } + if err != nil { return fmt.Sprintf("exit error: %v\n%s", err, text), nil } return text, nil } diff --git a/cmd/sin-code/chat_tools_extra.go b/cmd/sin-code/chat_tools_extra.go index 227e0487..f5d542ad 100644 --- a/cmd/sin-code/chat_tools_extra.go +++ b/cmd/sin-code/chat_tools_extra.go @@ -145,10 +145,7 @@ func extraSpecs() []agentloopToolSpecAlias { "window": str("correlation window in sequence steps for the current session (default 25)"), })}, } -} - allSpecs = append(allSpecs, registerBrowserInteractionSpecs()...) - return allSpecs } // extraTool is called from builtinTool()'s default branch. diff --git a/cmd/sin-code/internal/agentloop/loop.go b/cmd/sin-code/internal/agentloop/loop.go index e4948f47..d7e4d5f0 100644 --- a/cmd/sin-code/internal/agentloop/loop.go +++ b/cmd/sin-code/internal/agentloop/loop.go @@ -123,6 +123,10 @@ type Loop struct { // identical-criteria fingerprint count. MaxStopRejects int SessionID string + // LoopDetector, if set, observes tool calls during the run and flags + // observer loops (repeated identical tool calls or repeated sequences). + // When a loop is detected the loop returns a non-nil error. Issue #377. + LoopDetector *LoopDetector // SystemPrompt is prepended to every model request as a system // message. It is immutable for the lifetime of the loop (mandate M7). SystemPrompt string @@ -1105,6 +1109,10 @@ func (l *Loop) Run(ctx context.Context, sess *session.Session, prompt string) (* if l.Coverage != nil { l.Coverage.Record(tc.Name) } + if l.LoopDetector != nil && l.LoopDetector.Record(tc.Name) { + l.fire(ctx, hooks.LoopDetected, "", map[string]any{"tool": tc.Name}) + return nil, fmt.Errorf("observer loop detected: repeated tool calls (last tool %s)", tc.Name) + } if !toolsSeen[tc.Name] { toolsSeen[tc.Name] = true toolsUsed = append(toolsUsed, tc.Name) diff --git a/cmd/sin-code/internal/hooks/hooks.go b/cmd/sin-code/internal/hooks/hooks.go index 19ec247d..5eb16eb1 100644 --- a/cmd/sin-code/internal/hooks/hooks.go +++ b/cmd/sin-code/internal/hooks/hooks.go @@ -56,6 +56,9 @@ const ( // Token budget lifecycle (issue #151). BudgetWarn = "budget.warn" BudgetExhausted = "budget.exhausted" + // LoopDetected fires when the agent loop repeats the same tool call or + // sequence past the configured threshold (issue #377). + LoopDetected = "loop.detected" // ReflectIssues fires when the self-reflection pass finds problems the // worker must fix before completion is evaluated. ReflectIssues = "reflect.issues" diff --git a/cmd/sin-code/internal/permission_defaults.go b/cmd/sin-code/internal/permission_defaults.go index f2dcc172..525e2c37 100644 --- a/cmd/sin-code/internal/permission_defaults.go +++ b/cmd/sin-code/internal/permission_defaults.go @@ -136,6 +136,13 @@ func DefaultPermissionRules() []permission.Rule { {Tool: "fusion__oracle_tournament", Policy: "ask"}, {Tool: "fusion__status", Policy: "allow"}, {Tool: "fusion__config", Policy: "allow"}, + // v3.23.0: autonomous research report (issue #384). Dry-run / list + // are read-only projections; run enqueues a goal that may invoke the + // agent loop, web search, and LLM synthesis — gated at ask (M4). + {Tool: "research__dry_run", Policy: "allow"}, + {Tool: "research__list", Policy: "allow"}, + {Tool: "research__run", Policy: "ask"}, + {Tool: "research__*", Policy: "ask"}, // Read-only todo MCP tools (issue #323). In headless mode (daemon), // "ask" resolves to "deny" — so the daemon could not read todos // without --yolo. Read-only tools are "allow"; destructive tools diff --git a/cmd/sin-code/main.go b/cmd/sin-code/main.go index f46ac44e..658db057 100644 --- a/cmd/sin-code/main.go +++ b/cmd/sin-code/main.go @@ -107,6 +107,7 @@ func init() { NewImageGraphCmd(), // image-graph: deterministic chart generation (bar/line/pie/area) NewStatusCmd(), // v3.22.0 NewFusionCmd(), // v3.22.0 — fusion benchmark/rank/recommend (issue #395) — readiness/status snapshot (issue #326) + NewResearchCmd(), // v3.23.0 — autonomous research-report generation (issue #384) ) // Pass build-time version to self-update module. diff --git a/cmd/sin-code/research_cmd.go b/cmd/sin-code/research_cmd.go new file mode 100644 index 00000000..939ab58a --- /dev/null +++ b/cmd/sin-code/research_cmd.go @@ -0,0 +1,176 @@ +// SPDX-License-Identifier: MIT +// Purpose: `sin-code research ` — autonomous research-report +// generation (issue #384). +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "github.com/OpenSIN-Code/SIN-Code/cmd/sin-code/internal/autonomy" +) + +func NewResearchCmd() *cobra.Command { + var ( + priority int + retries int + sourcesCap int + fetchBytes int + outDir string + dryRun bool + jsonOut bool + frontmatter bool + criteria []string + ) + + cmd := &cobra.Command{ + Use: "research ", + Short: "Autonomous research-report generation (issue #384)", + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + topic := strings.TrimSpace(strings.Join(args, " ")) + if topic == "" { + return fmt.Errorf("research: empty topic") + } + ws, err := os.Getwd() + if err != nil { + return err + } + + if dryRun { + resolvedDir := resolveOutDir(outDir, ws) + payload := map[string]any{ + "topic": topic, + "priority": priority, + "retries": retries, + "sources_cap": sourcesCap, + "fetch_bytes": fetchBytes, + "frontmatter": frontmatter, + "workspace": ws, + "criteria": criteria, + "out_dir": resolvedDir, + "estimated_path": filepath.Join(resolvedDir, autonomy.Slugify(topic)+".md"), + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Fprintf(cmd.OutOrStdout(), "DRY-RUN: topic=%q priority=%d retries=%d out=%s\n", + topic, priority, retries, payload["estimated_path"]) + return nil + } + + q, err := autonomy.Open(autonomy.DefaultPath()) + if err != nil { + return err + } + defer q.Close() + + defaults := []string{ + "Report body is non-empty Markdown (>= 1 H1 / H2 header)", + "Report cites at least one Source URL", + fmt.Sprintf("Report written to .sin-code/reports/%s.md", autonomy.Slugify(topic)), + "Report passes byte-stable validation (ResearchReport.Validate)", + } + contractCriteria := append(defaults, criteria...) + prompt := buildResearchPrompt(topic, resolveOutDir(outDir, ws), sourcesCap, fetchBytes, frontmatter) + + id, err := q.AddWithContract(cmd.Context(), prompt, ws, priority, retries, marshalContract(contractCriteria)) + if err != nil { + return fmt.Errorf("research: enqueue: %w", err) + } + + payload := struct { + GoalID int64 `json:"goal_id"` + Topic string `json:"topic"` + Slug string `json:"slug"` + OutPath string `json:"out_path"` + Workspace string `json:"workspace"` + Priority int `json:"priority"` + Retries int `json:"retries"` + Criteria int `json:"criteria"` + }{ + GoalID: id, + Topic: topic, + Slug: autonomy.Slugify(topic), + OutPath: filepath.Join(resolveOutDir(outDir, ws), autonomy.Slugify(topic)+".md"), + Workspace: ws, + Priority: priority, + Retries: retries, + Criteria: len(contractCriteria), + } + if jsonOut { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + fmt.Fprintf(cmd.OutOrStdout(), + "research: goal %d enqueued topic=%q slug=%s out=%s retries=%d\n", + id, topic, payload.Slug, payload.OutPath, retries) + return nil + }, + } + + cmd.Flags().IntVar(&priority, "priority", 0, "higher runs sooner") + cmd.Flags().IntVar(&retries, "retries", 3, "retry budget when contract criteria unmet") + cmd.Flags().IntVar(&sourcesCap, "sources", 5, "max sources to fetch before synthesis") + cmd.Flags().IntVar(&fetchBytes, "fetch-bytes", 64*1024, "max bytes fetched per source") + cmd.Flags().StringVar(&outDir, "out-dir", "", "override .sin-code/reports/ output directory") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "print plan without enqueueing") + cmd.Flags().BoolVar(&jsonOut, "json", false, "emit JSON envelope") + cmd.Flags().BoolVar(&frontmatter, "frontmatter", false, "wrap report body in topic + timestamp header") + cmd.Flags().StringArrayVar(&criteria, "criteria", nil, "additional acceptance criterion (repeatable)") + return cmd +} + +func resolveOutDir(override, workspace string) string { + base := workspace + if base == "" { + base = "." + } + if strings.TrimSpace(override) != "" { + return override + } + return filepath.Join(base, ".sin-code", "reports") +} + +func buildResearchPrompt(topic, outDir string, sourcesCap, fetchBytes int, frontmatter bool) string { + var b strings.Builder + fmt.Fprintf(&b, "Generate an autonomous research report on %q.\n", topic) + b.WriteString("Pipeline: cmd/sin-code/internal/autonomy/research_report.go (Searcher -> Fetcher -> LLM).\n") + fmt.Fprintf(&b, "Constraints: max_sources=%d, max_bytes_per_fetch=%d, frontmatter=%t.\n", + sourcesCap, fetchBytes, frontmatter) + fmt.Fprintf(&b, "Output path: %s/%s.md\n", outDir, autonomy.Slugify(topic)) + b.WriteString("Verify the report is valid Markdown with at least one citation.\n") + b.WriteString("Mark the goal complete only after ResearchReport.Validate returns nil.\n") + return b.String() +} + +func marshalContract(criteria []string) string { + payload := struct { + SemanticCriteria []string `json:"semantic_criteria"` + }{SemanticCriteria: criteria} + b, err := json.Marshal(payload) + if err != nil { + return "" + } + return string(b) +} + +func WriteReport(outDir, slug, body string) (string, int, error) { + if err := os.MkdirAll(outDir, 0o755); err != nil { + return "", 0, err + } + path := filepath.Join(outDir, slug+".md") + data := []byte(body) + if err := os.WriteFile(path, data, 0o644); err != nil { + return "", 0, err + } + return path, len(data), nil +}