mirror of
https://github.com/BaaRai/ARTEX.git
synced 2026-11-13 11:40:53 +08:00
Add agentic guards for goals, hint origin and planner Bash (F5, F6, F11)
Audit findings F5, F6 and F11 for issue #124. F5, goal guards: - goal_met refuses while any goal node of the task is open, and when the goals cannot be read. A task without goal nodes keeps the old behavior. - prove_goal accepts a fact as evidence only when its confidence is "observed"; otherwise it refuses and the goal stays open. Findings are still accepted. F6, hint origin: - add_hint stores the calling ToolSet's worker as the hint origin instead of "human", and passes it to the planner trigger (TriggerEvent.HintOrigin). - renderTriggers names the writer: the human via the main agent, a cross-task orchestration, or an agent, with a neutral fallback. F11, built-in rules for the planner and the main agent: - Interceptor.MatchBuiltin evaluates the enabled "[built-in]" deny rules on the intercept-enabled tools. - The target-only guard applies them before the target gate, whether the gate is on or off. User rules and the judge stay out of it. - SetRulesForTest is a test seam for the store-less guard tests; Log is a no-op without a store.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
|
||||
// guardTaskStore creates a throwaway task (PG) and returns its exploration store.
|
||||
// It has no goal node until the test adds one.
|
||||
func guardTaskStore(t *testing.T) *db.ExplorationStore {
|
||||
t.Helper()
|
||||
d := testDB(t)
|
||||
t.Cleanup(func() { _ = d.Close() })
|
||||
task, err := d.CreateTaskWithOptions("agentic guards", "exercise the goal and hint guards", db.TaskCreateOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = d.DeleteTask(task.ID) })
|
||||
return d.Exploration(task.ExplorationID)
|
||||
}
|
||||
|
||||
func callGuardTool(t *testing.T, tool actool.CoreTool, input string) actool.Result {
|
||||
t.Helper()
|
||||
res, err := tool.Call(context.Background(), json.RawMessage(input), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: transport error: %v", tool.Name(), err)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func addGuardNode(t *testing.T, s *db.ExplorationStore, kind string, payload map[string]any, state string) int64 {
|
||||
t.Helper()
|
||||
id, err := s.AddNode(kind, payload, 0, state, "worker-1", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func nodeState(t *testing.T, s *db.ExplorationStore, id int64) string {
|
||||
t.Helper()
|
||||
n, err := s.GetNode(id)
|
||||
if err != nil || n == nil {
|
||||
t.Fatalf("node %d: %v", id, err)
|
||||
}
|
||||
return n.State
|
||||
}
|
||||
|
||||
// goal_met refuses while a goal node is open, and ends the task once every goal
|
||||
// is met (#124, F5).
|
||||
func TestGoalMetRefusesWhileGoalsOpen(t *testing.T) {
|
||||
s := guardTaskStore(t)
|
||||
open := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "read the admin panel"}, "open")
|
||||
addGuardNode(t, s, db.KindGoal, map[string]any{"text": "list the users"}, "met")
|
||||
|
||||
tsx := NewToolSet(s, "planner")
|
||||
res := callGuardTool(t, tsx.goalMet(), `{"reason":"the data says we are done"}`)
|
||||
if !res.IsError || !strings.Contains(res.Flatten(), "1 of 2 goals") || !strings.Contains(res.Flatten(), "prove_goal") {
|
||||
t.Fatalf("open goal: want a refusal naming the open goals and prove_goal, got IsError=%v %q", res.IsError, res.Flatten())
|
||||
}
|
||||
if tsx.GoalMet {
|
||||
t.Fatal("goal_met set GoalMet while a goal is open")
|
||||
}
|
||||
|
||||
if err := s.SetNodeState(open, "met"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res = callGuardTool(t, tsx.goalMet(), `{"reason":"both goals proven"}`)
|
||||
if res.IsError || !tsx.GoalMet || tsx.Reason != "both goals proven" {
|
||||
t.Fatalf("all goals met: IsError=%v GoalMet=%v reason=%q %q", res.IsError, tsx.GoalMet, tsx.Reason, res.Flatten())
|
||||
}
|
||||
}
|
||||
|
||||
// A task without goal nodes keeps the old goal_met behavior.
|
||||
func TestGoalMetWithoutGoalNodes(t *testing.T) {
|
||||
tsx := NewToolSet(guardTaskStore(t), "planner")
|
||||
res := callGuardTool(t, tsx.goalMet(), `{"reason":"task goal reached"}`)
|
||||
if res.IsError || !tsx.GoalMet {
|
||||
t.Fatalf("no goal node: IsError=%v GoalMet=%v %q", res.IsError, tsx.GoalMet, res.Flatten())
|
||||
}
|
||||
}
|
||||
|
||||
// prove_goal accepts a fact only when its confidence is observed; a finding is
|
||||
// still accepted (#124, F5).
|
||||
func TestProveGoalRequiresObservedFact(t *testing.T) {
|
||||
s := guardTaskStore(t)
|
||||
goalA := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "get admin"}, "open")
|
||||
goalB := addGuardNode(t, s, db.KindGoal, map[string]any{"text": "dump the users"}, "open")
|
||||
inferred := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "admin is probably reachable", "confidence": "inferred"}, "confirmed")
|
||||
unlabeled := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "admin reachable"}, "confirmed")
|
||||
observed := addGuardNode(t, s, db.KindFact, map[string]any{"summary": "logged in as admin", "confidence": " Observed "}, "confirmed")
|
||||
finding := addGuardNode(t, s, db.KindFinding, map[string]any{"summary": "user table dumped"}, "confirmed")
|
||||
|
||||
tsx := NewToolSet(s, "planner")
|
||||
prove := func(goal, ev int64) actool.Result {
|
||||
return callGuardTool(t, tsx.proveGoal(), `{"goal_id":`+strconv.FormatInt(goal, 10)+`,"evidence_id":`+strconv.FormatInt(ev, 10)+`,"reason":"x"}`)
|
||||
}
|
||||
|
||||
for name, ev := range map[string]int64{"inferred": inferred, "unlabeled": unlabeled} {
|
||||
res := prove(goalA, ev)
|
||||
if !res.IsError || !strings.Contains(res.Flatten(), "observed") || !strings.Contains(res.Flatten(), "stays open") {
|
||||
t.Fatalf("%s fact: want a refusal, got IsError=%v %q", name, res.IsError, res.Flatten())
|
||||
}
|
||||
if st := nodeState(t, s, goalA); st != "open" {
|
||||
t.Fatalf("%s fact: goal state %q, want open", name, st)
|
||||
}
|
||||
}
|
||||
if tsx.GoalMet {
|
||||
t.Fatal("a refused proof set GoalMet")
|
||||
}
|
||||
|
||||
if res := prove(goalA, observed); res.IsError || nodeState(t, s, goalA) != "met" {
|
||||
t.Fatalf("observed fact: IsError=%v state=%q %q", res.IsError, nodeState(t, s, goalA), res.Flatten())
|
||||
}
|
||||
if tsx.GoalMet {
|
||||
t.Fatal("GoalMet set while goal B is open")
|
||||
}
|
||||
if res := prove(goalB, finding); res.IsError || nodeState(t, s, goalB) != "met" || !tsx.GoalMet {
|
||||
t.Fatalf("finding: IsError=%v state=%q GoalMet=%v %q", res.IsError, nodeState(t, s, goalB), tsx.GoalMet, res.Flatten())
|
||||
}
|
||||
}
|
||||
|
||||
// add_hint stores the calling ToolSet as the hint's origin and passes it to the
|
||||
// trigger (#124, F6): an orchestrator hint is not labelled human, and the main
|
||||
// agent (worker "human") still is.
|
||||
func TestAddHintRecordsOrigin(t *testing.T) {
|
||||
s := guardTaskStore(t)
|
||||
for _, origin := range []string{"orchestrator", "human"} {
|
||||
tsx := NewToolSet(s, origin)
|
||||
var gotOrigin string
|
||||
var gotTexts []string
|
||||
tsx.SetNotifyHint(func(o string, texts []string) { gotOrigin, gotTexts = o, texts })
|
||||
res := callGuardTool(t, tsx.AddHintTool(), `{"text":"focus on the export endpoint"}`)
|
||||
out := res.Flatten()
|
||||
if res.IsError || !strings.HasPrefix(out, "hint added: ") {
|
||||
t.Fatalf("%s: add_hint failed: %q", origin, out)
|
||||
}
|
||||
id, err := strconv.ParseInt(strings.TrimPrefix(out, "hint added: "), 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: hint id in %q: %v", origin, out, err)
|
||||
}
|
||||
n, err := s.GetNode(id)
|
||||
if err != nil || n == nil || n.Kind != db.KindHint || n.Origin != origin {
|
||||
t.Fatalf("%s: hint node %+v err=%v, want origin %q", origin, n, err, origin)
|
||||
}
|
||||
if gotOrigin != origin || len(gotTexts) != 1 || gotTexts[0] != "focus on the export endpoint" {
|
||||
t.Fatalf("%s: trigger got origin=%q texts=%v", origin, gotOrigin, gotTexts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// renderTriggers names the writer of a hint from its origin; only the main agent
|
||||
// is "the human" (#124, F6). The hint case never reads the store.
|
||||
func TestRenderTriggersHintOrigin(t *testing.T) {
|
||||
ts := &db.ExplorationStore{}
|
||||
for origin, want := range map[string]string{
|
||||
"human": "- The human (via the main agent) added a strategic hint: h1",
|
||||
"orchestrator": "- A cross-task orchestration added a strategic hint: h1",
|
||||
"worker-3": "- An agent (worker-3) added a strategic hint: h1",
|
||||
"": "- A source that was not recorded added a strategic hint: h1",
|
||||
} {
|
||||
got := renderTriggers(ts, []TriggerEvent{{Kind: "hint", Hints: []string{"h1"}, HintOrigin: origin}})
|
||||
if !strings.Contains(got, want) || strings.Contains(got, "The human (main agent)") {
|
||||
t.Errorf("origin %q: got %q, want %q", origin, got, want)
|
||||
}
|
||||
}
|
||||
got := renderTriggers(ts, []TriggerEvent{{Kind: "hint", Hints: []string{"h1", "h2"}, HintOrigin: "orchestrator"}})
|
||||
if !strings.Contains(got, "- A cross-task orchestration added 2 strategic hints: h1; h2") {
|
||||
t.Errorf("batch: got %q", got)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -111,7 +111,7 @@ func mainAgentSystem(goal, dataDir, workDir string) string {
|
||||
// text / result) so the main-agent session shows its work — exactly like the
|
||||
// worker/planner sessions — not just the final answer. hooks (the task's
|
||||
// target-only Guard) sees every tool call; may be nil.
|
||||
func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, hooks harness.HookRunner, emit func(db.Activity), notify, resume func(), notifyGoal, notifyHint func([]string)) (string, error) {
|
||||
func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.AssetStore, ts *db.ExplorationStore, goal, message string, hooks harness.HookRunner, emit func(db.Activity), notify, resume func(), notifyGoal func([]string), notifyHint func(string, []string)) (string, error) {
|
||||
tsx := NewToolSet(ts, "human")
|
||||
tsx.SetFindingRecorder(m.findingRecorder)
|
||||
if as != nil {
|
||||
@@ -122,7 +122,7 @@ func (m *MainAgent) Chat(ctx context.Context, taskID int64, mainSeg int, as *db.
|
||||
tsx.SetNotify(notify) // generic wake-up (write ops without a dedicated callback go through it, debounced)
|
||||
tsx.SetResumeTask(resume) // set_goals adds a goal → pull a completed/paused task back to running
|
||||
tsx.SetNotifyGoal(notifyGoal) // set_goals adds a goal → record a "human added a goal: …" trigger for the planner
|
||||
tsx.SetNotifyHint(notifyHint) // add_hint adds hints → record a "human added N strategic hints: …" trigger for the planner
|
||||
tsx.SetNotifyHint(notifyHint) // add_hint adds hints → record a "human added N strategic hints: …" trigger for the planner (origin "human")
|
||||
tsx.steerWork = m.steerWork // enable steer_work tool (nil = unavailable)
|
||||
// domain tools + basic default toolset (Read/Write/Edit/MultiEdit/LS/Glob/Grep/Bash)
|
||||
// when the asset coverage feature is off, drop add_task_scope/list_untested_assets (not in the prompt).
|
||||
|
||||
+24
-2
@@ -195,6 +195,9 @@ func plannerGoalAnchor(goal, constraints string) string {
|
||||
// "cancelled" — the human deleted intent IntentID (Detail = the deletion reason).
|
||||
// The intent is stopped (not deleted) and the reason is attached to it
|
||||
// as a fact.
|
||||
// "hint" — one add_hint call added one OR MORE hints (Hints = the hint texts;
|
||||
// HintOrigin = who wrote them: "human" via the main agent,
|
||||
// "orchestrator" for cross-task orchestration, otherwise an agent name).
|
||||
type TriggerEvent struct {
|
||||
Kind string
|
||||
IntentID int64
|
||||
@@ -204,6 +207,25 @@ type TriggerEvent struct {
|
||||
OldGoal string // Kind=="goal_edited" only: the goal text before the edit
|
||||
NewGoal string // Kind=="goal_edited" only: the goal text after the edit
|
||||
Hints []string // Kind=="hint" only: the hint texts added by this add_hint call (one or more)
|
||||
// HintOrigin is the writer of the hints (Kind=="hint" only): the origin stored
|
||||
// on the hint nodes. Empty when unknown.
|
||||
HintOrigin string
|
||||
}
|
||||
|
||||
// hintAuthor names the writer of a hint for the planner, from its stored origin.
|
||||
// Only the main-agent chat writes as "human"; the other writers are named as
|
||||
// what they are, so an agent-written hint is not read as an operator instruction.
|
||||
func hintAuthor(origin string) string {
|
||||
switch origin {
|
||||
case "human":
|
||||
return "The human (via the main agent)"
|
||||
case "orchestrator":
|
||||
return "A cross-task orchestration"
|
||||
case "":
|
||||
return "A source that was not recorded"
|
||||
default:
|
||||
return "An agent (" + origin + ")"
|
||||
}
|
||||
}
|
||||
|
||||
// renderTriggers spells out the change(s) that fired this round: for a finished
|
||||
@@ -226,9 +248,9 @@ func renderTriggers(ts *db.ExplorationStore, evs []TriggerEvent) string {
|
||||
}
|
||||
case "hint":
|
||||
if len(ev.Hints) == 1 {
|
||||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added a strategic hint: %s — already attached to the exploration graph; please adjust/add exploration directions accordingly (if no corresponding intent exists yet).", ev.Hints[0]))
|
||||
b.WriteString(fmt.Sprintf("\n- %s added a strategic hint: %s — already attached to the exploration graph; please adjust/add exploration directions accordingly (if no corresponding intent exists yet).", hintAuthor(ev.HintOrigin), ev.Hints[0]))
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("\n- The human (main agent) added %d strategic hints: %s — all are attached to the exploration graph; please adjust/add exploration directions accordingly, one by one.", len(ev.Hints), strings.Join(ev.Hints, "; ")))
|
||||
b.WriteString(fmt.Sprintf("\n- %s added %d strategic hints: %s — all are attached to the exploration graph; please adjust/add exploration directions accordingly, one by one.", hintAuthor(ev.HintOrigin), len(ev.Hints), strings.Join(ev.Hints, "; ")))
|
||||
}
|
||||
case "goal_deleted":
|
||||
b.WriteString(fmt.Sprintf("\n- The human deleted this goal: %s — the goal has been removed; re-judge the remaining goals/directions accordingly (no need to dispatch intents for it anymore).", ev.Detail))
|
||||
|
||||
+47
-16
@@ -187,13 +187,14 @@ type ToolSet struct {
|
||||
// agent; nil for the goals decomposer (round-0 has no running planner to inform)
|
||||
// and workers → those fall back to the bare notify.
|
||||
notifyGoal func(texts []string)
|
||||
// notifyHint, if set, wakes the planner AND records ONE "human added N strategic
|
||||
// notifyHint, if set, wakes the planner AND records ONE "<origin> added N strategic
|
||||
// hints: …" trigger for a whole add_hint call (batch-aware — one call, one
|
||||
// trigger) so the next round is told the round was fired by a new hint and spells
|
||||
// the hint out, instead of the planner having to spot it folded into the graph
|
||||
// overview. Wired for the main agent + cross-task orchestration; nil elsewhere →
|
||||
// falls back to the bare notify.
|
||||
notifyHint func(texts []string)
|
||||
// overview. origin is the ToolSet's worker (human, orchestrator, or an agent name).
|
||||
// Wired for the main agent + cross-task orchestration; nil elsewhere → falls back
|
||||
// to the bare notify.
|
||||
notifyHint func(origin string, texts []string)
|
||||
}
|
||||
|
||||
// SetNotifyGoal wires the goal-add trigger callback (see ToolSet.notifyGoal). Set only
|
||||
@@ -203,7 +204,7 @@ func (t *ToolSet) SetNotifyGoal(fn func([]string)) { t.notifyGoal = fn }
|
||||
// SetNotifyHint wires the hint-add trigger callback (see ToolSet.notifyHint). Set by
|
||||
// the main-agent chat and cross-task orchestration, so a runtime-added hint fires a
|
||||
// planner round announced by name instead of a bare wake.
|
||||
func (t *ToolSet) SetNotifyHint(fn func([]string)) { t.notifyHint = fn }
|
||||
func (t *ToolSet) SetNotifyHint(fn func(string, []string)) { t.notifyHint = fn }
|
||||
|
||||
// SetResumeTask wires the task-revive callback (see ToolSet.resumeTask). Set only by
|
||||
// the main-agent chat, so runtime-added goals can pull a finished task back to running.
|
||||
@@ -1288,7 +1289,7 @@ func (t *ToolSet) listGoals() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) proveGoal() actool.CoreTool {
|
||||
return t.writeExpTool("prove_goal", "Call when you judge that a finding/fact proves a goal met: links the evidence node to the goal node and marks the goal met.",
|
||||
return t.writeExpTool("prove_goal", "Call when you judge that a finding/fact proves a goal met: links the evidence node to the goal node and marks the goal met. A fact counts as evidence only when its confidence is observed; an inferred fact is refused and the goal stays open.",
|
||||
obj(map[string]any{
|
||||
"goal_id": idp("goal node id"),
|
||||
"evidence_id": idp("id of the finding/fact node that proves it"),
|
||||
@@ -1313,6 +1314,20 @@ func (t *ToolSet) proveGoal() actool.CoreTool {
|
||||
if err != nil || evidenceNode == nil || (evidenceNode.Kind != db.KindFact && evidenceNode.Kind != db.KindFinding) {
|
||||
return actool.Errorf("evidence_id must be a fact/finding node of this task or a directly related task"), nil
|
||||
}
|
||||
// A fact proves a goal only when it was seen directly (#124, F5): an inferred
|
||||
// conclusion, or one without a confidence, is refused and the goal stays open.
|
||||
// The confidence is declared by the agent that recorded the fact.
|
||||
if evidenceNode.Kind == db.KindFact {
|
||||
var fp map[string]any
|
||||
_ = json.Unmarshal(evidenceNode.Payload, &fp)
|
||||
confidence, _ := fp["confidence"].(string)
|
||||
if strings.ToLower(strings.TrimSpace(confidence)) != "observed" {
|
||||
if confidence == "" {
|
||||
confidence = "none"
|
||||
}
|
||||
return actool.Errorf(fmt.Sprintf("prove_goal refused: fact %d has confidence %q, and a fact proves a goal only when its confidence is \"observed\" (seen directly in tool output). Goal %d stays open: prove it with a finding or with an observed fact.", ev, confidence, goal)), nil
|
||||
}
|
||||
}
|
||||
_ = t.ts.Link(ev, db.RelProves, goal)
|
||||
_ = t.ts.SetNodeState(goal, "met")
|
||||
// Each time a goal is marked met, check whether [all goals] of this task are
|
||||
@@ -1337,11 +1352,26 @@ func (t *ToolSet) proveGoal() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) goalMet() actool.CoreTool {
|
||||
return writeTool("goal_met", "[End the entire task immediately] — call only when you are sure [all of the task's goals are truly met and it is complete overall] (note: the task as a [whole]; having met just one goal / one flag / one finding [does not count] — in that case mark that goal with prove_goal). ⚠️ It is NOT for \"ending this planning round\": if there is no new intent to dispatch this round, or you're waiting on worker output, [just end the round — do NOT call this tool] (0 intents is a perfectly normal outcome). Prefer prove_goal to prove goals one by one; goal_met is only a means to skip per-goal proofs and complete the task globally.",
|
||||
return t.writeExpTool("goal_met", "[End the entire task immediately] — call only when you are sure [all of the task's goals are truly met and it is complete overall] (note: the task as a [whole]; having met just one goal / one flag / one finding [does not count] — in that case mark that goal with prove_goal). ⚠️ It is NOT for \"ending this planning round\": if there is no new intent to dispatch this round, or you're waiting on worker output, [just end the round — do NOT call this tool] (0 intents is a perfectly normal outcome). Prefer prove_goal to prove goals one by one; goal_met is only a means to skip per-goal proofs and complete the task globally.",
|
||||
obj(map[string]any{"reason": str("grounds for completion (must be evidence that the goals are truly met, not a round-ending reason like 'no new directions this round')")}, "reason"),
|
||||
func(_ context.Context, in json.RawMessage) (actool.Result, error) {
|
||||
var a struct{ Reason string }
|
||||
_ = json.Unmarshal(in, &a)
|
||||
// goal_met ends the task only when no goal node is open (#124, F5). A task
|
||||
// without goal nodes keeps the old behavior.
|
||||
goals, err := t.ts.ListByKind(db.KindGoal, 1000)
|
||||
if err != nil {
|
||||
return actool.Errorf("goal_met refused: the goals of this task could not be read (" + err.Error() + "); the task stays open"), nil
|
||||
}
|
||||
open := 0
|
||||
for _, g := range goals {
|
||||
if g.State != "met" {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if open > 0 {
|
||||
return actool.Errorf(fmt.Sprintf("goal_met refused: %d of %d goals of this task are still open; the task stays open. Prove each one with prove_goal(goal_id, evidence_id, reason): the task completes automatically when its last goal is marked met.", open, len(goals))), nil
|
||||
}
|
||||
t.GoalMet = true
|
||||
t.Reason = a.Reason
|
||||
return actool.Text("acknowledged: goal marked met"), nil
|
||||
@@ -1547,8 +1577,9 @@ type hintItem struct {
|
||||
TrafficRefs []db.TrafficRef `json:"traffic_refs"`
|
||||
}
|
||||
|
||||
// addOneHint attaches a hint node (active/human) to the exploration graph, optionally
|
||||
// anchored to assets; returns the id.
|
||||
// addOneHint attaches a hint node (active) to the exploration graph, optionally
|
||||
// anchored to assets; returns the id. Its origin is t.worker, the agent that wrote
|
||||
// it: "human" for the main-agent chat, "orchestrator" for cross-task orchestration.
|
||||
func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
|
||||
if len(it.TrafficRefs) > 0 && !findingTrafficBindingEnabled() {
|
||||
return 0, fmt.Errorf("automatic agent traffic binding is off; the hint carrying traffic_refs was not saved — enable it in system settings, or hand over text only")
|
||||
@@ -1573,7 +1604,7 @@ func (t *ToolSet) addOneHint(it hintItem) (int64, error) {
|
||||
// Waking the planner is not done here per item — addHint fires it once after the
|
||||
// whole batch is written (carrying the hint texts), so one add_hint with multiple
|
||||
// hints doesn't spam the planner's trigger lines one by one.
|
||||
return t.ts.AddNode(db.KindHint, payload, 0, "active", "human", anchors)
|
||||
return t.ts.AddNode(db.KindHint, payload, 0, "active", t.worker, anchors)
|
||||
}
|
||||
|
||||
type goalItem struct {
|
||||
@@ -1763,7 +1794,7 @@ func (t *ToolSet) setConstraints() actool.CoreTool {
|
||||
}
|
||||
|
||||
func (t *ToolSet) addHint() actool.CoreTool {
|
||||
return t.writeExpTool("add_hint", "Attach a strategic hint from the human/main agent to the exploration graph; the planner reads it the next time it generates intents.\n"+
|
||||
return t.writeExpTool("add_hint", "Attach a strategic hint to the exploration graph; the planner reads it the next time it generates intents. The hint is stored with the calling agent as its origin (human for the main-agent chat, orchestrator for cross-task orchestration, otherwise the agent's name).\n"+
|
||||
"★Batch preferred: put multiple hints in the hints array in a single submission (saves round trips vs. one call per item). Returns an ids array, same length and order as hints (failed items get id=0, details in errors). For a single hint, omit hints and pass the top-level text.",
|
||||
obj(map[string]any{
|
||||
"hints": map[string]any{"type": "array", "description": "[Prefer this] array of hints to add, processed in order. Each element has the same fields as the top-level ones below (text/asset_ids/traffic_refs). The returned ids match this array in length and order.", "items": obj(map[string]any{"text": str("hint content"), "asset_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, "traffic_refs": HintTrafficSchema()})},
|
||||
@@ -1797,13 +1828,13 @@ func (t *ToolSet) addHint() actool.CoreTool {
|
||||
}
|
||||
if len(addedTexts) > 0 {
|
||||
// Wake the planner (once for the whole batch). Prefer notifyHint: one
|
||||
// add_hint records a single "human added N strategic hints: …" trigger, so
|
||||
// the planner knows "this round was fired by a new hint" and sees its
|
||||
// content; when the callback isn't wired, fall back to plain notify (bare
|
||||
// wake; the hint is still folded into the graph for it to read).
|
||||
// add_hint records a single "<origin> added N strategic hints: …" trigger,
|
||||
// so the planner knows "this round was fired by a new hint", who wrote it,
|
||||
// and sees its content; when the callback isn't wired, fall back to plain
|
||||
// notify (bare wake; the hint is still folded into the graph for it to read).
|
||||
switch {
|
||||
case t.notifyHint != nil:
|
||||
t.notifyHint(addedTexts)
|
||||
t.notifyHint(t.worker, addedTexts)
|
||||
case t.notify != nil:
|
||||
t.notify()
|
||||
}
|
||||
|
||||
+21
-9
@@ -38,7 +38,7 @@ type Guard struct {
|
||||
attrib map[string]int // failure attribution counts (Observer / G5)
|
||||
reg *hook.Registry
|
||||
interceptor *intercept.Interceptor // optional; nil disables user-configured rules
|
||||
targetOnly bool // planner / main agent: target gate only, no rules, no judge, no audit ring
|
||||
targetOnly bool // planner / main agent: built-in deny rules and target gate only, no user rules, no judge, no audit ring
|
||||
gate targetGate // the target gate; nil without an interceptor
|
||||
}
|
||||
|
||||
@@ -50,9 +50,10 @@ func New() *Guard { return newGuard(nil) }
|
||||
func NewWithInterceptor(ic *intercept.Interceptor) *Guard { return newGuard(ic) }
|
||||
|
||||
// NewTargetOnly creates the Guard for the planner and the main agent. Their tool
|
||||
// calls pass through the target gate only: the intercept rules and the fallback
|
||||
// judge are not extended to them, and their calls are not written to the audit
|
||||
// ring (which keeps the worker history of the task's main Guard).
|
||||
// calls pass through the built-in deny rules and the target gate only: the user
|
||||
// intercept rules and the fallback judge are not extended to them, and their
|
||||
// calls that pass are not written to the audit ring (which keeps the worker
|
||||
// history of the task's main Guard).
|
||||
func NewTargetOnly(ic *intercept.Interceptor) *Guard {
|
||||
g := &Guard{attrib: map[string]int{}, interceptor: ic, targetOnly: true}
|
||||
g.setGate(ic)
|
||||
@@ -131,12 +132,23 @@ func (g *Guard) gateScope(ctx context.Context) (intercept.TargetScope, bool) {
|
||||
// then the target gate. Both rules and the fallback judge receive the complete
|
||||
// tool input.
|
||||
//
|
||||
// The rules and the judge apply to the enabled tools of a full guard. The
|
||||
// target gate applies to every tool call of a session whose gate is on: a deny
|
||||
// blocks first, before any target request exists; an allow by a rule or by the
|
||||
// judge does not satisfy the gate; an ask and an unpinned target share one
|
||||
// approval request.
|
||||
// The rules and the judge apply to the enabled tools of a full guard. A
|
||||
// target-only guard applies the built-in deny rules alone, whether its gate is
|
||||
// on or off. The target gate applies to every tool call of a session whose gate
|
||||
// is on: a deny blocks first, before any target request exists; an allow by a
|
||||
// rule or by the judge does not satisfy the gate; an ask and an unpinned target
|
||||
// share one approval request.
|
||||
func (g *Guard) applyIntercept(ctx context.Context, ev hook.Event) hook.Result {
|
||||
if g.targetOnly && g.interceptor != nil {
|
||||
// The planner and the main agent run Bash too (#124, F11): the built-in
|
||||
// deny rules apply to them, before the target gate. User rules and the
|
||||
// judge stay with the full guard.
|
||||
if dec, ok := g.interceptor.MatchBuiltin(ev.ToolName, ev.Input); ok {
|
||||
ctx = intercept.WithCall(ctx, ev.ToolName, ev.Input)
|
||||
g.interceptor.Log(ctx, intercept.ConvIDFromContext(ctx), dec, ev.ToolName, ev.Input, "denied")
|
||||
return g.block(ev.ToolName, systemBlockMessage(dec.Message), "")
|
||||
}
|
||||
}
|
||||
scope, gated := g.gateScope(ctx)
|
||||
rules := !g.targetOnly && g.interceptor != nil && g.interceptor.IsToolEnabled(ev.ToolName)
|
||||
if !rules && !gated {
|
||||
|
||||
+66
-5
@@ -3,8 +3,11 @@ package guard
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/hook"
|
||||
)
|
||||
@@ -37,11 +40,14 @@ func TestPreToolUsePassthrough(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The target-only guard (planner, main agent) runs the target gate and nothing
|
||||
// else: no intercept rule, no fallback judge, no audit-ring entry. Here the gate
|
||||
// is off (no scope, or a scope without the flag). Its interceptor has no database
|
||||
// behind it, so any read or write through it would panic — passing proves that
|
||||
// with the gate off every call is allowed without touching the store.
|
||||
// The target-only guard (planner, main agent) runs the built-in deny rules and
|
||||
// the target gate: no user intercept rule, no fallback judge, no audit-ring entry
|
||||
// for a call that passes (in production the built-in rules come from the store,
|
||||
// see TestTargetOnlyGuardAppliesBuiltinDenyRules). Here the gate is off (no
|
||||
// scope, or a scope without the flag). Its interceptor has no database behind it
|
||||
// and no loaded rules, so it matches no built-in rule, and any read or write
|
||||
// through it would panic — passing proves that with the gate off every call is
|
||||
// allowed without touching the store.
|
||||
func TestTargetOnlyGuardSkipsRules(t *testing.T) {
|
||||
calls := []struct {
|
||||
tool string
|
||||
@@ -89,4 +95,59 @@ func TestTargetOnlyGuardSkipsRules(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The target-only guard applies the built-in deny rules (#124, F11), and no user
|
||||
// rule. The rules are injected into a store-less interceptor through the
|
||||
// SetRulesForTest seam, so nothing is read from or logged to a database.
|
||||
func TestTargetOnlyGuardAppliesBuiltinDenyRules(t *testing.T) {
|
||||
newIC := func() *intercept.Interceptor {
|
||||
ic := intercept.New(nil)
|
||||
ic.SetRulesForTest([]db.InterceptRule{
|
||||
{ID: 1, Name: "[built-in] Destructive system commands", Enabled: true, Priority: 100,
|
||||
MatchTarget: "tool_input", MatchType: "regex", Action: "deny",
|
||||
Pattern: `(?i)\b(rm\s+-rf\s+/|mkfs|dd\s+if=|:\(\)\s*\{|shutdown|reboot|>\s*/dev/sd)`,
|
||||
Message: "Destructive command rejected"},
|
||||
{ID: 2, Name: "No whoami", Enabled: true, Priority: 50,
|
||||
MatchTarget: "tool_input", MatchType: "string", Action: "deny",
|
||||
Pattern: "whoami", Message: "user rule: whoami is forbidden"},
|
||||
})
|
||||
return ic
|
||||
}
|
||||
const platform = "[ARTEX platform control, not target defense]"
|
||||
|
||||
g := NewTargetOnly(newIC())
|
||||
gate := &fakeGate{t: t}
|
||||
g.gate = gate
|
||||
for name, ctx := range map[string]context.Context{
|
||||
"gate off": context.Background(),
|
||||
"gate on": gateOn(context.Background(), "7", time.Minute),
|
||||
} {
|
||||
if block, msg := pre(g, ctx, "Bash", `{"command":"rm -rf /"}`); !block ||
|
||||
!strings.HasPrefix(msg, platform) || !strings.Contains(msg, "Destructive command rejected") {
|
||||
t.Errorf("%s: rm -rf / block=%v msg=%q, want a built-in deny", name, block, msg)
|
||||
}
|
||||
if block, msg := pre(g, ctx, "Bash", `{"command":"ls -la"}`); block {
|
||||
t.Errorf("%s: a benign command was blocked: %s", name, msg)
|
||||
}
|
||||
if block, msg := pre(g, ctx, "Bash", `{"command":"whoami"}`); block {
|
||||
t.Errorf("%s: a user rule was applied to the target-only guard: %s", name, msg)
|
||||
}
|
||||
// A tool outside the rule system is not matched, even when its text names a
|
||||
// destructive command (a hint or an intent that describes one).
|
||||
if block, msg := pre(g, ctx, "add_hint", `{"text":"the reset endpoint runs rm -rf / on the target"}`); block {
|
||||
t.Errorf("%s: a tool outside the rule system was blocked: %s", name, msg)
|
||||
}
|
||||
}
|
||||
// The built-in deny is decided before the gate: only the two passing Bash calls
|
||||
// and add_hint of the gated round reached it.
|
||||
if gate.checks != 3 || len(gate.asked) != 0 {
|
||||
t.Errorf("gate checks=%d requests=%d, want 3 and 0", gate.checks, len(gate.asked))
|
||||
}
|
||||
|
||||
// The full guard (workers) still applies the user rule.
|
||||
full := NewWithInterceptor(newIC())
|
||||
if block, msg := pre(full, context.Background(), "Bash", `{"command":"whoami"}`); !block || !strings.Contains(msg, "user rule: whoami is forbidden") {
|
||||
t.Errorf("full guard: whoami block=%v msg=%q, want the user rule", block, msg)
|
||||
}
|
||||
}
|
||||
|
||||
var _ = hook.PreToolUse
|
||||
|
||||
@@ -615,6 +615,74 @@ func (i *Interceptor) Match(toolName string, input []byte) (Decision, bool) {
|
||||
return Decision{}, false
|
||||
}
|
||||
|
||||
// builtinRulePrefix marks the rules seeded by the platform (db.seedDefaultInterceptRules
|
||||
// and its v2/v3 migrations).
|
||||
const builtinRulePrefix = "[built-in]"
|
||||
|
||||
// MatchBuiltin is Match restricted to the built-in deny rules: the enabled rules
|
||||
// whose name starts with "[built-in]" and whose action is deny, on the tools that
|
||||
// enter the rule system. It returns the first match in priority order. User rules,
|
||||
// allow and ask rules, and the judge are not consulted. The planner and the main
|
||||
// agent use it (#124, F11). An interceptor without a database and without loaded
|
||||
// rules matches nothing.
|
||||
func (i *Interceptor) MatchBuiltin(toolName string, input []byte) (Decision, bool) {
|
||||
i.mu.RLock()
|
||||
loaded := i.cached != nil
|
||||
i.mu.RUnlock()
|
||||
if !loaded && i.db == nil {
|
||||
return Decision{}, false
|
||||
}
|
||||
if !i.IsToolEnabled(toolName) {
|
||||
return Decision{}, false
|
||||
}
|
||||
rules, err := i.rules()
|
||||
if err != nil {
|
||||
return Decision{}, false
|
||||
}
|
||||
for _, r := range rules {
|
||||
if r.Action != "deny" || !strings.HasPrefix(r.Name, builtinRulePrefix) || !ruleMatches(r, toolName, input) {
|
||||
continue
|
||||
}
|
||||
msg := r.Message
|
||||
if msg == "" {
|
||||
msg = defaultMessage(r.Action, r.Name)
|
||||
}
|
||||
configJSON, _ := json.Marshal(r.InterceptRule)
|
||||
return Decision{
|
||||
RuleName: r.Name, ConfigDigest: digestInput(configJSON),
|
||||
Action: r.Action,
|
||||
Message: msg,
|
||||
RuleID: r.ID,
|
||||
}, true
|
||||
}
|
||||
return Decision{}, false
|
||||
}
|
||||
|
||||
// SetRulesForTest installs rules (in priority order) as the loaded rule set, with
|
||||
// the default intercept-enabled tools, on an interceptor that may have no
|
||||
// database. It is a test seam for the guard package, which cannot reach the
|
||||
// unexported cache; production code loads the rules from the database.
|
||||
func (i *Interceptor) SetRulesForTest(rules []db.InterceptRule) {
|
||||
out := []compiledRule{}
|
||||
for _, r := range rules {
|
||||
if !r.Enabled {
|
||||
continue
|
||||
}
|
||||
cr := compiledRule{InterceptRule: r}
|
||||
if r.MatchType == "regex" {
|
||||
cr.re = regexp.MustCompile(r.Pattern)
|
||||
}
|
||||
out = append(out, cr)
|
||||
}
|
||||
tools := make(map[string]bool, len(defaultEnabledTools))
|
||||
for _, n := range defaultEnabledTools {
|
||||
tools[n] = true
|
||||
}
|
||||
i.mu.Lock()
|
||||
i.cached, i.enabledTools = out, tools
|
||||
i.mu.Unlock()
|
||||
}
|
||||
|
||||
func ruleMatches(r compiledRule, toolName string, input []byte) bool {
|
||||
var subject string
|
||||
switch r.MatchTarget {
|
||||
@@ -649,6 +717,9 @@ func defaultMessage(action, name string) string {
|
||||
// error is swallowed so logging never changes the tool call's outcome. The pending list
|
||||
// (status='pending') is unaffected, so it still shows only asks awaiting a decision.
|
||||
func (i *Interceptor) Log(ctx context.Context, convID int64, dec Decision, toolName string, input []byte, status string) {
|
||||
if i.db == nil {
|
||||
return // no store to record into (SetRulesForTest)
|
||||
}
|
||||
taskID, agentName := taskInfoFromCtx(ctx)
|
||||
audit := auditFor(ctx, dec, input, status)
|
||||
id, err := i.db.CreateDecidedIntercept(dec.RuleID, convID, taskID, agentName, toolName, input, status, dec.Message, audit)
|
||||
|
||||
+6
-5
@@ -1888,16 +1888,17 @@ func (t *Task) NotifyGoal(texts []string) {
|
||||
}
|
||||
|
||||
// NotifyHint records that one OR MORE hints were added in a single add_hint call —
|
||||
// by the human via the main agent, or by cross-task orchestration — then wakes the
|
||||
// planner, so the next round is told "the human added N strategy hints: ..." and looks at them
|
||||
// directly instead of having to spot the new hint folded into the graph overview.
|
||||
// by the human via the main agent (origin "human"), or by cross-task orchestration
|
||||
// (origin "orchestrator") — then wakes the planner, so the next round is told who
|
||||
// added N strategy hints and looks at them directly instead of having to spot the
|
||||
// new hint folded into the graph overview.
|
||||
// One call → one trigger event (a batched add_hint counts as one, not one per hint).
|
||||
func (t *Task) NotifyHint(texts []string) {
|
||||
func (t *Task) NotifyHint(origin string, texts []string) {
|
||||
if len(texts) == 0 {
|
||||
return
|
||||
}
|
||||
t.trigMu.Lock()
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "hint", Hints: texts})
|
||||
t.pendingTriggers = append(t.pendingTriggers, agent.TriggerEvent{Kind: "hint", Hints: texts, HintOrigin: origin})
|
||||
t.trigMu.Unlock()
|
||||
t.Notify()
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ func (s *Server) delegateToTask(ctx context.Context, in json.RawMessage, pick fu
|
||||
tsx.SetAssetStore(s.m.Assets(), s.m.Assets().Companies())
|
||||
}
|
||||
tsx.SetNotify(t.Notify) // generic wake-up (write ops without a dedicated callback go through it; no-op for read tools)
|
||||
tsx.SetNotifyHint(t.NotifyHint) // add_hint → records a "human added N strategic hints: ..." entry and wakes the planner
|
||||
tsx.SetNotifyHint(t.NotifyHint) // add_hint → records an "orchestrator added N strategic hints: ..." entry and wakes the planner
|
||||
return pick(tsx).Call(ctx, inner, nil)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user