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
3 changes: 2 additions & 1 deletion cmd/tools/gen-error-codes/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ import (

// Side-effect imports: load every package that calls errcode.Register
// so the registry is populated before we read it.
_ "github.com/sqlrush/opendbx/internal/app/skills" // spec-2.1 D-6: SKILL.* codes
_ "github.com/sqlrush/opendbx/internal/app/skills" // spec-2.1 D-6: SKILL.* codes
_ "github.com/sqlrush/opendbx/internal/app/skills/invoke" // spec-2.3 D-6: SKILL.* invoke codes
_ "github.com/sqlrush/opendbx/internal/entrypoints"
"github.com/sqlrush/opendbx/internal/platform/errcode"
_ "github.com/sqlrush/opendbx/internal/platform/logger"
Expand Down
48 changes: 48 additions & 0 deletions internal/app/cli/render/block/adapter/skill.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

// File skill.go — Skill tool adapter (spec-2.3 D-4).
//
// CC shows a "Using <skill-name>" banner when the model enters a skill
// (survey skills.md § 2.3); the exact CC tool-row text is line-level TBD
// (survey § 7 T-11), so this header is the spec-2.3 pinned provisional
// form: "Skill(<name>)" plus a compact args summary when args are
// present. Errata-aligned after T-11 verify (spec-2.1 R-1 precedent).

package adapter

import "strings"

// Skill implements HeaderRenderer for the spec-2.3 SkillTool
// (wire name "Skill"; input {skill, args}).
type Skill struct{}

// RenderHeader returns `Skill(<name>)`, appending a sorted compact
// `k=v` args summary when a non-empty args object is present. A missing
// or non-string skill field renders the "(no skill)" placeholder
// (mirrors Bash's "(no command)" convention).
func (Skill) RenderHeader(input map[string]any, ctx Context) (string, error) {
name, ok := input["skill"].(string)
if !ok || strings.TrimSpace(name) == "" {
return "Skill(no skill)", nil
}
head := "Skill(" + name + ")"
args, ok := input["args"].(map[string]any)
if !ok || len(args) == 0 {
return head, nil
}
maxLen := ctx.Cols / 2
if maxLen < 8 {
maxLen = 8
}
summary := compactArgs(args, maxLen-len(head)-1)
if summary == "" {
return head, nil
}
return head + " " + summary, nil
}

func init() {
Default.Register("Skill", Skill{})
}
72 changes: 72 additions & 0 deletions internal/app/cli/render/block/adapter/skill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

package adapter

import (
"strings"
"testing"
)

// TestSkill_Registered — the Skill adapter self-registers under the
// CC-parity wire name (spec-2.3 D-4).
func TestSkill_Registered(t *testing.T) {
t.Parallel()
if Default.Lookup("Skill") == nil {
t.Fatal("Skill adapter not registered in Default registry")
}
}

// TestSkill_RenderHeader — table over name/args shapes (spec-2.3 D-4).
func TestSkill_RenderHeader(t *testing.T) {
t.Parallel()
ctx := Context{Cols: 80}
tests := []struct {
name string
input map[string]any
want string
}{
{"plain", map[string]any{"skill": "code-reviewer"}, "Skill(code-reviewer)"},
{"missing skill", map[string]any{}, "Skill(no skill)"},
{"non-string skill", map[string]any{"skill": 7}, "Skill(no skill)"},
{"blank skill", map[string]any{"skill": " "}, "Skill(no skill)"},
{"empty args object", map[string]any{"skill": "s", "args": map[string]any{}}, "Skill(s)"},
{"non-object args ignored for display", map[string]any{"skill": "s", "args": "x"}, "Skill(s)"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := Skill{}.RenderHeader(tc.input, ctx)
if err != nil || got != tc.want {
t.Errorf("RenderHeader(%v) = %q, %v; want %q", tc.input, got, err, tc.want)
}
})
}
}

// TestSkill_RenderHeader_ArgsSummary — args render as a sorted compact
// summary after the head; narrow columns degrade to head-only.
func TestSkill_RenderHeader_ArgsSummary(t *testing.T) {
t.Parallel()
input := map[string]any{
"skill": "topsql",
"args": map[string]any{"limit": "10", "db": "main"},
}
got, err := Skill{}.RenderHeader(input, Context{Cols: 120})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, "Skill(topsql) ") {
t.Errorf("want head prefix, got %q", got)
}
// Sorted keys: db before limit.
if !strings.Contains(got, "db=") || strings.Index(got, "db=") > strings.Index(got, "limit=") {
t.Errorf("args summary must be key-sorted: %q", got)
}
// Narrow terminal → summary dropped, head kept.
gotNarrow, _ := Skill{}.RenderHeader(input, Context{Cols: 20})
if gotNarrow != "Skill(topsql)" {
t.Errorf("narrow cols should keep head only, got %q", gotNarrow)
}
}
25 changes: 25 additions & 0 deletions internal/app/diagnose/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
package diagnose

import (
"fmt"
"strings"

"github.com/sqlrush/opendbx/internal/platform/errcode"
)

Expand Down Expand Up @@ -60,4 +63,26 @@ var (
"收到非预期 pause_turn (spec-1.21 未启用 server tool)",
"server-side tool 是未来 spec; 检查 model/请求未启用 server tool",
)
// ErrScopeToolDenied — the model called a registered tool that the
// active skill scope filters out (spec-2.3 D-3 dispatch guard).
// Feedback class — written into ToolResult.Content with IsError=true;
// the model self-corrects. NOT terminal. Registered here (not in
// app/skills/invoke, which owns the other two spec-2.3 codes) because
// the Loop composes the denial text and diagnose cannot import invoke
// (cycle); same placement precedent as DIAGNOSE.TOOL_TIMEOUT.
ErrScopeToolDenied = errcode.Register(
"SKILL.SCOPE_TOOL_DENIED",
"tool is not allowed in the active skill scope",
"use an allowed tool or invoke another skill",
)
)

// scopeDeniedContent composes the recoverable ToolResult.Content for a
// scope-filtered tool call (spec-2.3 D-6 pinned template — colon style,
// matching the invoke-side templates; post-impl cr MED-1). allowed is the
// active normalized filter ("Skill" is implicitly retained and therefore
// listed separately in the fixed "(+ Skill)" suffix).
func scopeDeniedContent(tool string, allowed []string) string {
return fmt.Sprintf("%s: tool %q is not allowed in the active skill scope. Allowed: [%s] (+ Skill). Hint: %s.",
ErrScopeToolDenied.Code(), tool, strings.Join(allowed, ", "), ErrScopeToolDenied.Hint())
}
46 changes: 44 additions & 2 deletions internal/app/diagnose/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ func (l *Loop) Run(ctx context.Context, req llm.Request, emit EmitFunc) (Result,
tools = append(tools, l.registry.Schemas()...)
}

// Run-local allowed-tools scope (spec-2.3 D-3). nil = no scope. Set
// from a successful ToolOutput.ToolFilter (fresh OR cached — never
// gated on !cached); replaced wholesale by the next one; dies with
// this Run. v1 enforcement covers registry-dispatchable tools only
// (production req.Tools is nil; spec-2.3 ❌-11).
var activeFilter []string

result := Result{}

// Per-Run dedup cache (spec-1.22 D-1/D-2). Lifetime == this Run; not
Expand All @@ -174,10 +181,12 @@ func (l *Loop) Run(ctx context.Context, req llm.Request, emit EmitFunc) (Result,
return finalize(result, msgs, fr, "", ferr), ferr
}

// Per-turn provider call.
// Per-turn provider call. The advertised tool set is re-derived
// every turn so a scope entered mid-run narrows the next turn's
// offer (spec-2.3 D-3; nil filter → same slice, zero-cost).
turnReq := req
turnReq.Messages = msgs
turnReq.Tools = tools
turnReq.Tools = applyFilter(tools, activeFilter)
turnCtx, cancelTurn := context.WithTimeout(totalCtx, l.reqTimeout)
stream, perr := l.provider.Stream(turnCtx, turnReq)
if perr != nil {
Expand Down Expand Up @@ -337,6 +346,29 @@ func (l *Loop) Run(ctx context.Context, req llm.Request, emit EmitFunc) (Result,
tu := &toolUses[i]
exec, _ := lookup(l.registry, tu.Name)

// spec-2.3 D-3 dispatch guard — AFTER the registry lookup
// (unknown names already went terminal above) and BEFORE
// dedup (a denied call must not touch the cache). The
// denied tool still gets a full recoverable tool_result so
// the Phase 1 EventToolCall is paired (UI never stuck
// Running) and the paired commit keeps its shape. Scope
// entered earlier in THIS same Phase 2 applies immediately
// (Q12 user decision: minimal scope beats advertised-
// earlier permissiveness).
if scopeDenied(activeFilter, tu.Name) {
results = append(results, llm.ToolResult{
ToolUseID: tu.ID,
Content: scopeDeniedContent(tu.Name, activeFilter),
IsError: true,
})
if eerr := emit(ctx, Event{Kind: EventToolResult, Turn: turn, ToolResult: &results[len(results)-1]}); eerr != nil {
fr, ferr := classifyEmitErr(eerr)
// errcode-lint:exempt -- spec-1.21 D-4: emit-error pass-through (EventToolResult variant).
return finalize(result, msgs, fr, "", ferr), ferr
}
continue
}

// spec-1.22 D-2: dedup interception. dKey is "" when the tool
// is non-cacheable / dedup disabled / key derivation failed
// (invariant #5: such a call derives no key, never stores).
Expand Down Expand Up @@ -366,6 +398,16 @@ func (l *Loop) Run(ctx context.Context, req llm.Request, emit EmitFunc) (Result,
// errcode-lint:exempt -- spec-1.21 D-4: ferr is a registered DIAGNOSE.TOTAL_TIMEOUT sentinel or ctx.Canceled from classifyToolErr; pass-through.
return finalize(result, msgs, fr, code, ferr), ferr
}
// spec-2.3 D-3 scope update — reads the raw `out` (NOT tr:
// classifyToolErr copies Content/IsError only, which is
// also what keeps ToolFilter off the wire). Gated on
// success alone — never on !cached, so a dedup replay
// re-applies its filter identically (spec-2.3 DoD). A nil
// ToolFilter (fresh OR cached) means inherit: the current
// scope is left untouched, never cleared (Q4/go LOW-2).
if execErr == nil && !out.IsError && out.ToolFilter != nil {
activeFilter = normalizeFilter(out.ToolFilter)
}
// spec-1.22 R-4: store ONLY a freshly-executed success
// (execErr==nil && !IsError). A hit is not re-stored; an
// error/timeout (which may leave out zero-value) never caches.
Expand Down
86 changes: 86 additions & 0 deletions internal/app/diagnose/scope.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2026 opendbx contributors. See LICENSE.
//
// Author: sqlrush

// File scope.go — allowed-tools scope filtering (spec-2.3 D-3).
//
// A successful ToolOutput carrying a non-nil ToolFilter REPLACES the
// run-local execution-tool scope: subsequent turns advertise only the
// filtered set, and a dispatch of a registered-but-filtered tool is
// answered with a recoverable SKILL.SCOPE_TOOL_DENIED tool_result (never
// terminal — the model self-corrects). Scope state lives entirely in
// Loop.Run locals; it dies with the Run (spec-2.3 R-6).
//
// "Skill" is implicitly retained in both the advertised set and the
// dispatch guard (spec-2.3 Q13, user decision 4/4): it is the
// scope-control verb — keeping it reachable means a Run can always
// switch scope, while execution tools stay filtered. This is a
// scope-control exception, NOT a permission bypass.

package diagnose

import "github.com/sqlrush/opendbx/internal/domain/llm"

// skillToolName is the wire name of the scope-control tool (spec-2.3
// Q3 CC parity). Kept as a local constant because diagnose cannot import
// app/skills/invoke (invoke imports diagnose); the integration suite
// asserts invoke.ToolName == this value to prevent drift.
const skillToolName = "Skill"

// applyFilter narrows the advertised tool set to the active scope.
// nil filter → tools returned unchanged (same slice identity, keeping
// the no-skill path byte-stable for prompt caching). A non-nil filter
// returns a NEW slice (Rule 12 immutability — the shared backing array is never
// mutated) holding tools whose Name is in the filter, plus "Skill".
func applyFilter(tools []llm.ToolSchema, filter []string) []llm.ToolSchema {
if filter == nil {
return tools
}
allowed := make(map[string]bool, len(filter)+1)
for _, n := range filter {
allowed[n] = true
}
allowed[skillToolName] = true
out := make([]llm.ToolSchema, 0, len(tools))
for _, ts := range tools {
if allowed[ts.Name] {
out = append(out, ts)
}
}
return out
}

// normalizeFilter canonicalizes a ToolFilter before it becomes the
// active scope (spec-2.3 R2 pinned contract): case-sensitive dedupe
// preserving first-occurrence order; nil stays nil (inherit — no scope
// change); a non-nil empty slice stays non-nil (an empty execution
// scope: only "Skill" remains callable). Entries are already trimmed
// upstream (Schema.AllowedToolsList filters blanks).
func normalizeFilter(filter []string) []string {
if filter == nil {
return nil
}
seen := make(map[string]bool, len(filter))
out := make([]string, 0, len(filter))
for _, n := range filter {
if !seen[n] {
seen[n] = true
out = append(out, n)
}
}
return out
}

// scopeDenied reports whether the active filter blocks a dispatch of
// name. nil filter denies nothing; "Skill" is never denied (Q13).
func scopeDenied(filter []string, name string) bool {
if filter == nil || name == skillToolName {
return false
}
for _, n := range filter {
if n == name {
return false
}
}
return true
}
Loading
Loading