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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Run without `--apply` first to see what a command would do.
| `doctor` | report broken, foreign, or mismatched links and stale captures; never writes |
| `reconcile` | act on that drift, gated on what agentsync itself recorded |
| `inventory` | regenerate `inventory.json`, a record of every agent on the machine |
| `optimize` | ask an OpenRouter advisor which model and effort each agent should use; report only |
| `optimize` | ask an OpenRouter advisor which model and effort each agent should use; `--apply` writes the suggested changes |
| `override` | set one checkout's model and effort for a Claude agent without forking it |

`agentsync help` lists every flag.
Expand Down
17 changes: 11 additions & 6 deletions cmd/agentsync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
// confirmed, and refuses to replace an existing one without --force.
//
// optimize is the only command that uses the network: it asks an OpenRouter
// advisor about each agent's model and effort, and writes nothing.
// advisor about each agent's model and effort, and with --apply writes the
// suggested model: and effort: values into the agent files.
//
// override writes only the project's .claude/settings.local.json, with --apply.
package main
Expand Down Expand Up @@ -69,7 +70,7 @@ Usage:
agentsync reconcile [flags] act on that drift, gated on what this tool recorded
agentsync inventory [flags] regenerate inventory.json: every agent on this machine
agentsync optimize [flags] ask an OpenRouter advisor which model and effort each
agent should use (report only; the one networked command)
agent should use (--apply writes it; the one networked command)
agentsync override [flags] set this checkout's model and effort for a Claude agent
agentsync version

Expand Down Expand Up @@ -232,13 +233,16 @@ optimize flags:
(default 0.50)
--min-confidence P
least probability a pick needs before it is suggested
(default 0.6); below it the row says keep (low confidence)
(default 0.6); a differing pick below it is not suggested
--agent NAME ask only about this agent (repeatable)
--apply write each suggested change; without it optimize is a dry run

optimize needs $OPENROUTER_API_KEY and sends each agent file (first 24 KB) to
OpenRouter, one request per agent with no retries. It reads model: and effort:
from each agent's frontmatter and writes nothing. Exit 1 when a row is
unresolved or skipped.
from each agent's frontmatter. --apply edits only the suggested parts of those
lines, in the file a link points at: a canonical repo file changes for every
project linked to it, and agentsync never commits it. Exit 1 when a row is
unresolved or skipped, or a change was refused; exit 2 when a write failed.

override flags:
--agent NAME the agent to override, by its frontmatter name; it must be
Expand All @@ -257,7 +261,8 @@ scan and doctor are read-only. import writes only into this repo, only with
command that writes into a project's .claude/agents, only with --apply, and it
refuses any project holding a file this repo has no copy of — import that
first. override writes only the project's .claude/settings.local.json, also
only with --apply.
only with --apply. optimize --apply edits only the model: and effort: lines of
agent files it advised on.

inventory also writes only into this repo, and takes no --apply: its sole
output is inventory.json, which it owns, recomputes from scratch every run, and
Expand Down
190 changes: 179 additions & 11 deletions cmd/agentsync/optimize.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ import (
"github.com/agentic-tooling/agentsync/internal/discover"
"github.com/agentic-tooling/agentsync/internal/openrouter"
"github.com/agentic-tooling/agentsync/internal/optimize"
"github.com/agentic-tooling/agentsync/internal/override"
"github.com/agentic-tooling/agentsync/internal/scan"
"github.com/agentic-tooling/agentsync/internal/state"
)

// openrouterURL is where optimize sends its requests; tests point it at an
Expand All @@ -33,7 +35,7 @@ const (
var claudeOverrides = []string{"CLAUDE_CODE_SUBAGENT_MODEL", "CLAUDE_CODE_EFFORT_LEVEL"}

// runOptimize asks an advisor model about each Claude agent under the root
// and reports its advice. It writes nothing.
// and reports its advice. Only --apply writes, and only the parts it suggests.
func runOptimize(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("optimize", flag.ContinueOnError)
fs.SetOutput(stderr)
Expand All @@ -45,6 +47,7 @@ func runOptimize(args []string, stdout, stderr io.Writer) int {
advisor := fs.String("advisor", defaultAdvisor, "OpenRouter decision model to ask")
maxCost := fs.Float64("max-cost", 0.50, "most this run may spend, in USD")
minConf := fs.Float64("min-confidence", 0.6, "least probability a pick needs to be suggested")
apply := fs.Bool("apply", false, "write each suggested change into its agent's file")
var names stringList
fs.Var(&names, "agent", "ask only about this agent (repeatable)")

Expand Down Expand Up @@ -125,20 +128,172 @@ func runOptimize(args []string, stdout, stderr io.Writer) int {

rep := optimize.Run(ctx, client, agents, cfg)
rep.Overrides = overrides
code := exitOK
if rep.Count(optimize.StatusUnresolved)+rep.Count(optimize.StatusSkipped) > 0 {
code = exitAtRisk
}
var applied strings.Builder
if *apply {
code = max(code, applyChanges(&applied, stderr, repo, res, ov, agents, rep.Rows))
}
if *asJSON {
fmt.Fprint(notes, applied.String())
enc := json.NewEncoder(stdout)
enc.SetIndent("", " ")
if err := enc.Encode(rep); err != nil {
fmt.Fprintf(stderr, "agentsync: %v\n", err)
return exitFailure
}
} else {
emitOptimizeText(stdout, rep, cfg)
emitOptimizeText(stdout, rep, cfg, *apply, applied.String())
}
if rep.Count(optimize.StatusUnresolved)+rep.Count(optimize.StatusSkipped) > 0 {
return exitAtRisk
return code
}

// refusal is a change left unwritten on purpose, as opposed to a failed write.
type refusal struct{ error }

// applyChanges writes each change row into the file its agent resolves to and
// records the outcome on the row; rows[i] is the advice for agents[i]. It
// returns exitAtRisk for a refused row and exitFailure for a failed write.
func applyChanges(w, stderr io.Writer, repo *scan.Repo, res scan.Result, ov map[string]override.Value, agents []optimize.Agent, rows []optimize.Row) int {
code := exitOK
repoReal := resolved(repo.Root)
links, stateErr := state.Load(repo)
inRepo, warned := false, false
for i := range rows {
r := &rows[i]
if r.Status != optimize.StatusChange {
continue
}
real, err := writeChange(agents[i], r.SuggestedModel, r.SuggestedEffort, repoReal, links, stateErr)
if err != nil {
r.ApplyError = err.Error()
fmt.Fprintf(w, "%s: not written: %v\n", r.Agent, err)
if errors.As(err, new(refusal)) {
code = max(code, exitAtRisk)
} else {
code = exitFailure
}
continue
}
r.Applied = true
where := ""
if within(real, repoReal) {
inRepo = true
if stateErr != nil && !warned {
warned = true
fmt.Fprintf(stderr, "agentsync: warning: link counts not shown: %v\n", stateErr)
}
n := 0
if links != nil {
for _, l := range links.Records() {
if resolved(l.Target) == real {
n++
}
}
}
where = " (canonical file)"
if n > 0 {
where = fmt.Sprintf(" (canonical; linked into %d project%s)", n, plural(n, "", "s"))
}
}
fmt.Fprintf(w, "wrote %s%s\n", displayPath(repo.Home, real), where)
for _, e := range res.Entries {
v, ok := ov[e.Path]
masks := (v.Model != "" && r.SuggestedModel != "") || (v.Effort != "" && r.SuggestedEffort != "")
if ok && masks && resolved(e.Path) == real {
fmt.Fprintf(w, "Note: %s/%s's project override (%s) still wins over the written frontmatter there.\n", e.Project, e.Name, v)
}
}
}
if inRepo {
fmt.Fprintf(w, "Changed files in %s are not committed; review and commit them there.\n", displayPath(repo.Home, repo.Root))
}
return code
}

// writeChange edits the file a.Path resolves to, never the link itself, and
// returns that file's path. stateErr is why the state file could not be read,
// which leaves no way to tell a regular file from a dereferenced link.
func writeChange(a optimize.Agent, model, effort, repoReal string, links *state.File, stateErr error) (string, error) {
li, err := os.Lstat(a.Path)
var real string
if err == nil {
real, err = filepath.EvalSymlinks(a.Path)
}
var fi os.FileInfo
if err == nil {
fi, err = os.Stat(real)
}
var data []byte
if err == nil {
data, err = os.ReadFile(real)
}
if err != nil {
return "", err
}
return exitOK
link := li.Mode()&os.ModeSymlink != 0
recorded := false
if stateErr == nil {
_, recorded = links.LookupLink(a.Path)
}
switch {
case link && !within(real, repoReal):
return "", refusal{errors.New("it links outside the canonical repo, and agentsync leaves foreign links alone")}
case !link && stateErr != nil:
return "", fmt.Errorf("the state file could not be read to check that this was never an agentsync link: %w", stateErr)
case !link && recorded:
// Editing a dereferenced copy would turn reconcile's clean relink into a conflict.
return "", refusal{errors.New("agentsync recorded a link here that is now a copy; run agentsync link --apply first")}
case fi.Mode().Perm()&0o200 == 0:
return "", refusal{errors.New("the file is read-only")}
case string(data) != a.Content:
return "", refusal{errors.New("the file changed since it was read; run optimize again")}
}
out, err := optimize.Edit(a.Content, model, effort)
if err != nil {
return "", refusal{err}
}
return real, writeAtomic(real, []byte(out), fi.Mode().Perm())
}

// within reports whether p lies below dir.
func within(p, dir string) bool {
return strings.HasPrefix(p, dir+string(filepath.Separator))
}

// writeAtomic replaces path through a temp file beside it, so a reader sees
// the old file or the new one and never half of either. The temp name does not
// end in .md, so a scan running meanwhile never takes it for an agent.
func writeAtomic(path string, data []byte, mode os.FileMode) error {
tmp, err := os.CreateTemp(filepath.Dir(path), ".agentsync-*.tmp")
if err != nil {
return err
}
defer func() { _ = os.Remove(tmp.Name()) }()
_, err = tmp.Write(data)
if err == nil {
err = tmp.Chmod(mode)
}
if err == nil {
err = tmp.Sync()
}
if cerr := tmp.Close(); err == nil {
err = cerr
}
if err == nil {
err = os.Rename(tmp.Name(), path)
}
return err
}

// resolved is p with every symlink followed, or p as given when that fails.
func resolved(p string) string {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r
}
return p
}

func optimizeFlagsErr(fs *flag.FlagSet, advisor string, maxCost, minConf float64) error {
Expand Down Expand Up @@ -189,10 +344,7 @@ func optimizeAgents(res scan.Result, names []string) ([]optimize.Agent, error) {
continue
}
want[e.Name] = true
real := e.Path
if r, err := filepath.EvalSymlinks(e.Path); err == nil {
real = r
}
real := resolved(e.Path)
if seen[real] {
continue
}
Expand All @@ -211,7 +363,7 @@ func optimizeAgents(res scan.Result, names []string) ([]optimize.Agent, error) {
return out, nil
}

func emitOptimizeText(w io.Writer, rep optimize.Report, cfg optimize.Config) {
func emitOptimizeText(w io.Writer, rep optimize.Report, cfg optimize.Config, apply bool, applied string) {
skipped := rep.Count(optimize.StatusSkipped)
asked := len(rep.Rows) - skipped
fmt.Fprintf(w, "Asked %s about %d agent%s", rep.Advisor, asked, plural(asked, "", "s"))
Expand All @@ -236,5 +388,21 @@ func emitOptimizeText(w io.Writer, rep optimize.Report, cfg optimize.Config) {
if skipped > 0 {
fmt.Fprintf(w, "%d agent%s skipped (reasons above).\n", skipped, plural(skipped, " was", "s were"))
}
fmt.Fprintf(w, "Spent $%.6f (reported by OpenRouter). Dry run: nothing written.\n", rep.Spent)
fmt.Fprint(w, applied)
fmt.Fprintf(w, "Spent $%.6f (reported by OpenRouter). ", rep.Spent)
n := rep.Count(optimize.StatusChange)
switch {
case apply:
written := 0
for _, r := range rep.Rows {
if r.Applied {
written++
}
}
fmt.Fprintf(w, "Wrote %d change%s.\n", written, plural(written, "", "s"))
case n > 0:
fmt.Fprintf(w, "Dry run: nothing written. Run with --apply to write %d change%s.\n", n, plural(n, "", "s"))
default:
fmt.Fprintln(w, "Dry run: nothing written.")
}
}
Loading
Loading