Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/sin-code/browser_interaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions cmd/sin-code/chat_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ type chatOptions struct {
contextWindow int
preserveEvidence bool
compactionRecentTurns int
repetitionThreshold int
repetitionWindow int
}

func NewChatCmd() *cobra.Command {
Expand Down Expand Up @@ -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
}

Expand Down
1 change: 0 additions & 1 deletion cmd/sin-code/chat_mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 4 additions & 8 deletions cmd/sin-code/chat_tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
3 changes: 0 additions & 3 deletions cmd/sin-code/chat_tools_extra.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions cmd/sin-code/internal/agentloop/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions cmd/sin-code/internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
7 changes: 7 additions & 0 deletions cmd/sin-code/internal/permission_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/sin-code/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
176 changes: 176 additions & 0 deletions cmd/sin-code/research_cmd.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// SPDX-License-Identifier: MIT
// Purpose: `sin-code research <topic>` — 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 <topic>",
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 {

Check failure

Code scanning / gosec

Expect directory permissions to be 0750 or less Error

Expect directory permissions to be 0750 or less
return "", 0, err
}
path := filepath.Join(outDir, slug+".md")
data := []byte(body)
if err := os.WriteFile(path, data, 0o644); err != nil {

Check failure

Code scanning / gosec

Expect WriteFile permissions to be 0600 or less Error

Expect WriteFile permissions to be 0600 or less
return "", 0, err
}
return path, len(data), nil
}
Loading