Skip to content

Commit 7bfbaa4

Browse files
MCP: expose deterministic engine override on every compacting tool
wrap, compact, and all five tail_* tools now accept an optional `deterministic: true` argument that forces the codag-drain path (engine=deterministic on /v1/compact), matching the CLI's --deterministic flag. Default stays plan-based auto. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a5ff0c8 commit 7bfbaa4

3 files changed

Lines changed: 118 additions & 17 deletions

File tree

internal/mcp/dispatch.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ func wrapAPIError(err error) error {
9696
return err
9797
}
9898

99-
func runChildAndCompact(ctx context.Context, cmd string, args []string, level, service, incidentType string) (string, error) {
99+
func runChildAndCompact(ctx context.Context, cmd string, args []string, level, service, incidentType string, deterministic bool) (string, error) {
100100
if cmd == "" {
101101
return "", fmt.Errorf("empty command")
102102
}
@@ -139,6 +139,9 @@ func runChildAndCompact(ctx context.Context, cmd string, args []string, level, s
139139

140140
records := linesToRecords(lines, level, service)
141141
req := api.CapsuleRequest{Lines: records}
142+
if deterministic {
143+
req.Engine = "deterministic"
144+
}
142145
// The agent knows where these logs came from — it just ran the command.
143146
// Detect deterministically from the wrapped command (cmd + args).
144147
src := source.Detect(append([]string{cmd}, args...), "")
@@ -165,7 +168,7 @@ func withNotes(text string, notes []string) string {
165168
return b.String()
166169
}
167170

168-
func compactRawText(ctx context.Context, raw string, level, service, incidentType, src string) (string, error) {
171+
func compactRawText(ctx context.Context, raw string, level, service, incidentType, src string, deterministic bool) (string, error) {
169172
if raw == "" {
170173
return "", fmt.Errorf("empty input")
171174
}
@@ -181,6 +184,9 @@ func compactRawText(ctx context.Context, raw string, level, service, incidentTyp
181184
}
182185
records := linesToRecords(lines, level, service)
183186
req := api.CapsuleRequest{Lines: records}
187+
if deterministic {
188+
req.Engine = "deterministic"
189+
}
184190
// No command to detect from — the agent supplies the source. Fall back to
185191
// "pasted" (honest: the logs were pasted into the agent) so it's never null.
186192
if strings.TrimSpace(src) == "" {
@@ -210,6 +216,21 @@ func strArg(args map[string]any, key string) string {
210216
return fmt.Sprintf("%v", v)
211217
}
212218

219+
// boolArg tolerates the string forms agents sometimes emit for booleans.
220+
func boolArg(args map[string]any, key string) bool {
221+
v, ok := args[key]
222+
if !ok || v == nil {
223+
return false
224+
}
225+
switch x := v.(type) {
226+
case bool:
227+
return x
228+
case string:
229+
return x == "true" || x == "1"
230+
}
231+
return false
232+
}
233+
213234
func strSliceArg(args map[string]any, key string) []string {
214235
v, ok := args[key]
215236
if !ok || v == nil {

internal/mcp/tools.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ netlify/oc/podman/datadog-ci logs, docker logs or docker compose logs,
9999
wrangler tail, gh run view, journalctl, stern, logcli query, axiom query,
100100
and sentry-cli events/issues list.`,
101101
InputSchema: schemaWithArgs(map[string]string{
102-
"bin": "Provider CLI or file reader binary.",
103-
"args": "Flags + positional args. Must match a read-only log-reading shape.",
102+
"bin": "Provider CLI or file reader binary.",
103+
"args": "Flags + positional args. Must match a read-only log-reading shape.",
104+
"deterministic": deterministicDesc,
104105
}, []string{"bin"}),
105106
Annotations: longRunningAnnotations("wrap"),
106107
Handler: func(ctx ToolCtx, args map[string]any) (string, error) {
@@ -109,7 +110,7 @@ and sentry-cli events/issues list.`,
109110
if err := validateLogCommand(bin, pass); err != nil {
110111
return "", err
111112
}
112-
return runChildAndCompact(ctx.Context, bin, pass, "info", "", "")
113+
return runChildAndCompact(ctx.Context, bin, pass, "info", "", "", boolArg(args, "deterministic"))
113114
},
114115
})
115116

@@ -135,16 +136,18 @@ Args:
135136
level - default level if not auto-detected per line (info|warn|error|fatal).
136137
service - optional service tag for all lines.
137138
source - where these logs came from (e.g. railway, vercel, kubectl, sentry).
138-
You usually know this from context — pass it. Defaults to "pasted".`,
139+
You usually know this from context — pass it. Defaults to "pasted".
140+
deterministic - force the drain path (no LLM inference, reproducible).`,
139141
InputSchema: schemaWithArgs(map[string]string{
140-
"text": "Raw log text. Newline-separated.",
141-
"level": "Default level (info|warn|error|fatal). Auto-detected per line if 'info'.",
142-
"service": "Optional service tag.",
143-
"source": "Where these logs came from (railway, vercel, kubectl, sentry, …). Pass it when you know it.",
142+
"text": "Raw log text. Newline-separated.",
143+
"level": "Default level (info|warn|error|fatal). Auto-detected per line if 'info'.",
144+
"service": "Optional service tag.",
145+
"source": "Where these logs came from (railway, vercel, kubectl, sentry, …). Pass it when you know it.",
146+
"deterministic": deterministicDesc,
144147
}, []string{"text"}),
145148
Annotations: longRunningAnnotations("compact"),
146149
Handler: func(ctx ToolCtx, args map[string]any) (string, error) {
147-
return compactRawText(ctx.Context, strArg(args, "text"), strArg(args, "level"), strArg(args, "service"), "", strArg(args, "source"))
150+
return compactRawText(ctx.Context, strArg(args, "text"), strArg(args, "level"), strArg(args, "service"), "", strArg(args, "source"), boolArg(args, "deterministic"))
148151
},
149152
})
150153

@@ -199,7 +202,8 @@ func registerCLITool(s *Server, t cliTool) {
199202
Name: t.name,
200203
Description: desc,
201204
InputSchema: schemaWithArgs(map[string]string{
202-
"args": "Flags + positional args passed to the underlying CLI verbatim. Use the CLI's own syntax (e.g. --since=1h for vercel).",
205+
"args": "Flags + positional args passed to the underlying CLI verbatim. Use the CLI's own syntax (e.g. --since=1h for vercel).",
206+
"deterministic": deterministicDesc,
203207
}, nil),
204208
Annotations: longRunningAnnotations(t.name),
205209
Handler: func(ctx ToolCtx, args map[string]any) (string, error) {
@@ -208,17 +212,24 @@ func registerCLITool(s *Server, t cliTool) {
208212
if err := validateLogCommand(t.bin, full); err != nil {
209213
return "", err
210214
}
211-
return runChildAndCompact(ctx.Context, t.bin, full, "info", "", t.name)
215+
return runChildAndCompact(ctx.Context, t.bin, full, "info", "", t.name, boolArg(args, "deterministic"))
212216
},
213217
})
214218
}
215219

220+
// deterministicDesc is the shared schema description for the optional
221+
// engine-override parameter every compacting tool accepts.
222+
const deterministicDesc = "Force the deterministic drain compaction path (no LLM inference, reproducible output). Default false = pick engine by plan."
223+
216224
func schemaWithArgs(props map[string]string, required []string) map[string]interface{} {
217225
properties := map[string]interface{}{}
218226
for k, desc := range props {
219227
typ := "string"
220-
if k == "args" {
228+
switch k {
229+
case "args":
221230
typ = "array"
231+
case "deterministic":
232+
typ = "boolean"
222233
}
223234
entry := map[string]interface{}{"description": desc}
224235
if typ == "array" {

internal/mcp/tools_test.go

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/codag-megalith/codag-cli/internal/api"
15+
"github.com/codag-megalith/codag-cli/internal/config"
1516
)
1617

1718
func TestValidateLogCommand_AllowsKnownLogShapes(t *testing.T) {
@@ -223,7 +224,7 @@ func TestCompactRawTextPassesTinyInputThroughWithoutAPI(t *testing.T) {
223224
t.Setenv("CODAG_SERVER", srv.URL)
224225

225226
raw := "error 42\nINFO recovered\n"
226-
got, err := compactRawText(context.Background(), raw, "info", "api", "", "pasted")
227+
got, err := compactRawText(context.Background(), raw, "info", "api", "", "pasted", false)
227228
if err != nil {
228229
t.Fatalf("compactRawText: %v", err)
229230
}
@@ -272,7 +273,7 @@ func TestCompactRawTextUsesPlanAwareCompactWithAnonymousFallback(t *testing.T) {
272273
t.Setenv("CODAG_SERVER", srv.URL)
273274

274275
raw := strings.Repeat("error 42 database connection timeout active=20 waiting=30 service=api tenant=acme route=/api/orders request_id=abcdef\n", 12)
275-
got, err := compactRawText(context.Background(), raw, "info", "api", "", "railway")
276+
got, err := compactRawText(context.Background(), raw, "info", "api", "", "railway", false)
276277
if err != nil {
277278
t.Fatalf("compactRawText: %v", err)
278279
}
@@ -290,7 +291,7 @@ func TestCompactRawTextUsesPlanAwareCompactWithAnonymousFallback(t *testing.T) {
290291
func TestRunChildAndCompactAppendsExitStatusNote(t *testing.T) {
291292
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
292293
got, err := runChildAndCompact(context.Background(), "sh",
293-
[]string{"-c", "echo boom; exit 7"}, "info", "", "")
294+
[]string{"-c", "echo boom; exit 7"}, "info", "", "", false)
294295
if err != nil {
295296
t.Fatalf("runChildAndCompact: %v", err)
296297
}
@@ -302,6 +303,74 @@ func TestRunChildAndCompactAppendsExitStatusNote(t *testing.T) {
302303
}
303304
}
304305

306+
func TestCompactToolsSendEngineWhenDeterministic(t *testing.T) {
307+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
308+
if err := config.Save(&config.Config{
309+
AccessToken: "AT-test",
310+
ExpiresAt: time.Now().Add(time.Hour).Unix(),
311+
}); err != nil {
312+
t.Fatalf("save config: %v", err)
313+
}
314+
315+
var lastEngine atomic.Value
316+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
317+
var body struct {
318+
Engine string `json:"engine"`
319+
}
320+
_ = json.NewDecoder(r.Body).Decode(&body)
321+
lastEngine.Store(body.Engine)
322+
_ = json.NewEncoder(w).Encode(map[string]any{
323+
"text": "summary",
324+
"stats": map[string]any{"elapsed_ms": 1},
325+
})
326+
}))
327+
defer srv.Close()
328+
t.Setenv("CODAG_SERVER", srv.URL)
329+
330+
// Enough lines to beat the tiny-input passthrough.
331+
raw := strings.Repeat("error 42 database connection timeout active=20 waiting=30 service=api tenant=acme route=/api/orders request_id=abcdef\n", 12)
332+
333+
if _, err := compactRawText(context.Background(), raw, "info", "api", "", "railway", true); err != nil {
334+
t.Fatalf("compactRawText deterministic: %v", err)
335+
}
336+
if got, _ := lastEngine.Load().(string); got != "deterministic" {
337+
t.Fatalf("compactRawText engine = %q, want deterministic", got)
338+
}
339+
340+
script := `i=0; while [ $i -lt 12 ]; do echo "error 42 database connection timeout active=20 waiting=30 service=api tenant=acme route=/api/orders request_id=abcdef$i"; i=$((i+1)); done`
341+
if _, err := runChildAndCompact(context.Background(), "sh", []string{"-c", script}, "info", "", "", true); err != nil {
342+
t.Fatalf("runChildAndCompact deterministic: %v", err)
343+
}
344+
if got, _ := lastEngine.Load().(string); got != "deterministic" {
345+
t.Fatalf("runChildAndCompact engine = %q, want deterministic", got)
346+
}
347+
348+
if _, err := compactRawText(context.Background(), raw, "info", "api", "", "railway", false); err != nil {
349+
t.Fatalf("compactRawText default: %v", err)
350+
}
351+
if got, _ := lastEngine.Load().(string); got != "" {
352+
t.Fatalf("default engine = %q, want empty", got)
353+
}
354+
}
355+
356+
func TestBoolArgToleratesAgentShapes(t *testing.T) {
357+
cases := []struct {
358+
val any
359+
want bool
360+
}{
361+
{true, true}, {false, false}, {"true", true}, {"1", true},
362+
{"false", false}, {"yes", false}, {nil, false}, {42, false},
363+
}
364+
for _, c := range cases {
365+
if got := boolArg(map[string]any{"k": c.val}, "k"); got != c.want {
366+
t.Errorf("boolArg(%v) = %v, want %v", c.val, got, c.want)
367+
}
368+
}
369+
if boolArg(map[string]any{}, "missing") {
370+
t.Error("boolArg on missing key = true, want false")
371+
}
372+
}
373+
305374
func TestWithNotes(t *testing.T) {
306375
if got := withNotes("summary", nil); got != "summary" {
307376
t.Fatalf("no notes = %q", got)

0 commit comments

Comments
 (0)