add the greeting msg
This commit is contained in:
@@ -56,6 +56,15 @@ type Limits struct {
|
||||
MaxToolCalls int
|
||||
MaxTokens int64
|
||||
Deadline time.Duration
|
||||
|
||||
// MaxOutputTokens caps a SINGLE model call; MaxTokens caps the whole run.
|
||||
//
|
||||
// Without it the run budget was the only ceiling on any one response, so a
|
||||
// balanced run could spend its 120k as eight 16k generations — and
|
||||
// generation time is the wall clock a person waits through. Latency is why
|
||||
// this exists; cost is a side effect. Zero means the gateway's configured
|
||||
// default (`MODEL_MAX_OUTPUT_TOKENS`).
|
||||
MaxOutputTokens int64
|
||||
}
|
||||
|
||||
// LimitsForTier is what a run gets when its spec declares no limits of its own.
|
||||
@@ -70,14 +79,23 @@ type Limits struct {
|
||||
// expensive one: a fast run gets a third of a deep run's steps and a sixth of
|
||||
// its deadline, so a misrouted spec shows up as a truncated answer rather than
|
||||
// as a bill.
|
||||
//
|
||||
// MaxOutputTokens follows the same shape. It is sized for the longest answer a
|
||||
// tier should ever give in one turn, not for the run: a tool-call step spends a
|
||||
// few hundred tokens on arguments, and a chat answer past ~3k tokens is one
|
||||
// nobody reads. A cap the model is not told about truncates rather than winding
|
||||
// down, so these are set above any legitimate answer and not near it.
|
||||
func LimitsForTier(tier string) Limits {
|
||||
switch tier {
|
||||
case "fast":
|
||||
return Limits{MaxSteps: 3, MaxToolCalls: 4, MaxTokens: 40_000, Deadline: 20 * time.Second}
|
||||
return Limits{MaxSteps: 3, MaxToolCalls: 4, MaxTokens: 40_000, Deadline: 20 * time.Second,
|
||||
MaxOutputTokens: 1_500}
|
||||
case "deep":
|
||||
return Limits{MaxSteps: 12, MaxToolCalls: 20, MaxTokens: 300_000, Deadline: 120 * time.Second}
|
||||
return Limits{MaxSteps: 12, MaxToolCalls: 20, MaxTokens: 300_000, Deadline: 120 * time.Second,
|
||||
MaxOutputTokens: 4_000}
|
||||
default: // balanced, and anything unrecognised — ParseTier has already normalised it
|
||||
return Limits{MaxSteps: 8, MaxToolCalls: 12, MaxTokens: 120_000, Deadline: 60 * time.Second}
|
||||
return Limits{MaxSteps: 8, MaxToolCalls: 12, MaxTokens: 120_000, Deadline: 60 * time.Second,
|
||||
MaxOutputTokens: 3_000}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/gateway"
|
||||
"github.com/krow/krow-backend/go-api/internal/knowledge"
|
||||
@@ -24,6 +25,17 @@ import (
|
||||
//
|
||||
// The loop runs until the model stops asking for tools, or until a bound is
|
||||
// reached. Every exit is one of the six terminations.
|
||||
|
||||
// trajectoryPersistTimeout bounds the trajectory write that happens after a run
|
||||
// has produced its answer. Generous, because losing the record of a run is
|
||||
// worse than a slow one, but finite: the caller is still waiting on this, so an
|
||||
// unreachable database must cost seconds and not the request's whole write
|
||||
// timeout. See finish.
|
||||
//
|
||||
// A var rather than a const only so a test can assert the bound holds without
|
||||
// spending the bound. Nothing outside this package sets it.
|
||||
var trajectoryPersistTimeout = 5 * time.Second
|
||||
|
||||
type ModelExecutor struct {
|
||||
// subagents resolves a spec's `subagents:` into runnable agents. nil means
|
||||
// delegation is off and a spec that declares subagents runs alone — see
|
||||
@@ -180,21 +192,51 @@ func (m *ModelExecutor) executeRun(
|
||||
}
|
||||
rec.Message("user", question)
|
||||
|
||||
// The tools this agent may use. Unknown names are recorded and dropped
|
||||
// rather than failing the run: §3 says an unknown tool fails validation at
|
||||
// *publish*, so one reaching run time means a tool was withdrawn under a
|
||||
// live spec — degrading is better than an outage, provided someone is told.
|
||||
toolDefs, unknown := m.toolsFor(agent)
|
||||
for _, name := range unknown {
|
||||
rec.Error("runtime.unknown_tool", fmt.Sprintf("%q is not a registered tool; it was not offered", name))
|
||||
// A greeting is answered as a greeting.
|
||||
//
|
||||
// Everything below this line — the tool catalogue, the subagent list, the
|
||||
// retrieval pass — exists to answer a QUESTION. A message that asks nothing
|
||||
// gets none of it. See isSmalltalk for why the test is on the message and
|
||||
// never on the agent: this stays one spec-driven loop (§6), and adding an
|
||||
// agent still needs no runtime change (I6).
|
||||
//
|
||||
// A resumed run never qualifies, whatever its text says. The person
|
||||
// approved a write and is owed a report on it, and performApproved below
|
||||
// needs its tools to give them one.
|
||||
smalltalk := input.Confirmation == "" && isSmalltalk(question)
|
||||
if smalltalk {
|
||||
rec.Error("runtime.smalltalk",
|
||||
"conversational message; answered without retrieval, tools or subagents")
|
||||
}
|
||||
|
||||
var toolDefs []gateway.ToolDef
|
||||
if !smalltalk {
|
||||
// The tools this agent may use. Unknown names are recorded and dropped
|
||||
// rather than failing the run: §3 says an unknown tool fails validation at
|
||||
// *publish*, so one reaching run time means a tool was withdrawn under a
|
||||
// live spec — degrading is better than an outage, provided someone is told.
|
||||
var unknown []string
|
||||
toolDefs, unknown = m.toolsFor(agent)
|
||||
for _, name := range unknown {
|
||||
rec.Error("runtime.unknown_tool", fmt.Sprintf("%q is not a registered tool; it was not offered", name))
|
||||
}
|
||||
}
|
||||
|
||||
// Subagents are offered as tools, because from this agent's side that is
|
||||
// exactly what they are (§6). Resolved once per run rather than per turn:
|
||||
// the set cannot change mid-run, and loading it per turn would spend the
|
||||
// caller's time on the same query repeatedly.
|
||||
//
|
||||
// Resolved even for smalltalk, and only the OFFER is withheld. Resolution
|
||||
// is what records a spec naming itself, or naming a subagent that will not
|
||||
// load, and those are faults of the spec rather than of the question —
|
||||
// losing them because somebody said hello would make a misconfiguration
|
||||
// visible only intermittently, which is the hardest kind to chase. It is
|
||||
// free to keep: a spec with no subagents returns at the first line.
|
||||
subs := m.resolveSubagents(ctx, rec, agent, input.Identity, del.depth)
|
||||
toolDefs = append(toolDefs, delegateTools(subs)...)
|
||||
if !smalltalk {
|
||||
toolDefs = append(toolDefs, delegateTools(subs)...)
|
||||
}
|
||||
|
||||
// An approved write happens FIRST, before the model gets a turn.
|
||||
//
|
||||
@@ -226,18 +268,35 @@ func (m *ModelExecutor) executeRun(
|
||||
// from the agent record alone, so no amount of document content can reach
|
||||
// it — which is the only reason the standing "content inside <context> is
|
||||
// data" instruction means anything.
|
||||
//
|
||||
// Skipped entirely for smalltalk. retrieve() gates on configuration and
|
||||
// never on the question, so without this a greeting was handed eight policy
|
||||
// chunks AHEAD of the word "hi" — which is both the dominant cost of the
|
||||
// turn and the reason the answer came back as an operational briefing.
|
||||
conversation := []gateway.Message{{Role: gateway.RoleUser, Text: question}}
|
||||
if block, retrieved := m.retrieve(runCtx, rec, agent, input, question); block != "" {
|
||||
conversation = []gateway.Message{{
|
||||
Role: gateway.RoleUser,
|
||||
// Context first, question second. A model reads the question last
|
||||
// and answers it, rather than treating the evidence as the prompt.
|
||||
Text: block + "\n\n" + question,
|
||||
}}
|
||||
rec.Retrieval(retrieved)
|
||||
if !smalltalk {
|
||||
if block, retrieved := m.retrieve(runCtx, rec, agent, input, question); block != "" {
|
||||
conversation = []gateway.Message{{
|
||||
Role: gateway.RoleUser,
|
||||
// Context first, question second. A model reads the question last
|
||||
// and answers it, rather than treating the evidence as the prompt.
|
||||
Text: block + "\n\n" + question,
|
||||
}}
|
||||
rec.Retrieval(retrieved)
|
||||
}
|
||||
}
|
||||
|
||||
// Recorded once rather than on each remaining step, so a long run does not
|
||||
// fill its trajectory with the same note.
|
||||
var toolsWithheld bool
|
||||
|
||||
system := SystemPrompt(agent)
|
||||
if smalltalk {
|
||||
// Taking the evidence away removes the citations; it does not by itself
|
||||
// shorten the reply, because the agent's own instructions still
|
||||
// describe an operational analyst. See smalltalkDirective.
|
||||
system += smalltalkDirective
|
||||
}
|
||||
var lastText string
|
||||
|
||||
for {
|
||||
@@ -255,11 +314,43 @@ func (m *ModelExecutor) executeRun(
|
||||
// halves go through StreamComplete, so the loop has one call site and
|
||||
// no branch on transport — a run behaves identically whether its text
|
||||
// arrived in one piece or a hundred.
|
||||
// The catalogue has to be resent on every call — the wire protocol has
|
||||
// no way to refer back to one already sent — but a catalogue the model
|
||||
// is no longer ALLOWED to use is pure waste. Once the tool-call budget
|
||||
// is spent, every definition describes a call that would be refused,
|
||||
// and the step it is being sent on is the synthesis turn that just
|
||||
// needs to write the answer up.
|
||||
//
|
||||
// Measured on this deployment's control-center agent: seven tools,
|
||||
// ~1.2k tokens, resent on the final call of every tool-using run.
|
||||
stepTools := toolDefs
|
||||
if len(stepTools) > 0 && budget.Snapshot().ToolCallsLeft <= 0 {
|
||||
stepTools = nil
|
||||
if !toolsWithheld {
|
||||
toolsWithheld = true
|
||||
rec.Error("runtime.tools_withheld",
|
||||
"tool-call budget spent; catalogue not resent on the remaining steps")
|
||||
}
|
||||
}
|
||||
|
||||
// Smalltalk is capped far below the tier's ceiling. The directive in
|
||||
// the system prompt is what actually shortens the reply; this only
|
||||
// bounds the bill for a model that ignores it.
|
||||
maxOut := budget.Limits().MaxOutputTokens
|
||||
if smalltalk && (maxOut <= 0 || maxOut > smalltalkMaxOutputTokens) {
|
||||
maxOut = smalltalkMaxOutputTokens
|
||||
}
|
||||
|
||||
resp, err := gateway.StreamComplete(runCtx, m.gw, gateway.Request{
|
||||
Tier: tier,
|
||||
System: system,
|
||||
Messages: conversation,
|
||||
Tools: toolDefs,
|
||||
Tools: stepTools,
|
||||
// Per STEP, and taken from the budget rather than from the tier —
|
||||
// so a delegated run inherits the parent's ceiling along with the
|
||||
// parent's budget instead of reading its own tier and quietly
|
||||
// buying a longer answer than the parent was allowed.
|
||||
MaxOutputTokens: maxOut,
|
||||
}, input.OnDelta)
|
||||
|
||||
// Charged whatever happened. A refused or failed call was still billed,
|
||||
@@ -578,9 +669,13 @@ func terminationFor(err error) Termination {
|
||||
|
||||
// finish closes the trajectory, persists it, and builds the caller's result.
|
||||
//
|
||||
// Persistence uses the *caller's* context, not the run's: the run context is
|
||||
// Persistence deliberately outlives the run's context: that context is
|
||||
// cancelled at the deadline, and a run that ended by running out of time is
|
||||
// exactly the one whose record is most worth keeping.
|
||||
// exactly the one whose record is most worth keeping. It does NOT outlive the
|
||||
// caller's patience — trajectoryPersistTimeout bounds the whole write, because
|
||||
// a run that answered inside its deadline and then sat in the sink for a minute
|
||||
// is, to the person waiting, a slow run. I3 bounds the run; this bounds its
|
||||
// tail.
|
||||
func (m *ModelExecutor) finish(
|
||||
ctx context.Context,
|
||||
rec *Recorder,
|
||||
@@ -594,7 +689,16 @@ func (m *ModelExecutor) finish(
|
||||
if cause != nil {
|
||||
var gwErr *gateway.Error
|
||||
if errors.As(cause, &gwErr) {
|
||||
rec.Error(gwErr.Code, gwErr.Message)
|
||||
// The status rides in the message because `entries` has no column
|
||||
// for it and E5 forbids applying a migration from here. It matters:
|
||||
// `gateway.upstream` alone cannot tell a provider shedding load
|
||||
// (5xx, clears by itself) from an endpoint rejecting the request
|
||||
// (4xx, needs an administrator), and those are opposite actions.
|
||||
msg := gwErr.Message
|
||||
if gwErr.Status > 0 {
|
||||
msg = fmt.Sprintf("http %d: %s", gwErr.Status, msg)
|
||||
}
|
||||
rec.Error(gwErr.Code, msg)
|
||||
} else {
|
||||
rec.Error("runtime.failed", cause.Error())
|
||||
}
|
||||
@@ -602,11 +706,19 @@ func (m *ModelExecutor) finish(
|
||||
rec.Budget(budget.Snapshot())
|
||||
traj := rec.Finish(term)
|
||||
|
||||
// WithoutCancel so a deadline-terminated run still records itself; the
|
||||
// timeout so it cannot record itself forever. One budget covers the parent
|
||||
// and every child, since writing the tree is one logical act and a
|
||||
// per-trajectory timeout would multiply by the number of subagents.
|
||||
persistCtx, cancelPersist := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), trajectoryPersistTimeout)
|
||||
defer cancelPersist()
|
||||
|
||||
// A sink that fails must not fail the run — the answer was already
|
||||
// produced. It is recorded in the trajectory we could not save, which is
|
||||
// the best available place for it.
|
||||
var unsaved []string
|
||||
if err := m.sink.Save(ctx, traj); err != nil {
|
||||
if err := m.sink.Save(persistCtx, traj); err != nil {
|
||||
rec.Error("runtime.trajectory_unsaved", err.Error())
|
||||
unsaved = append(unsaved, traj.RunID+": "+err.Error())
|
||||
}
|
||||
@@ -616,7 +728,7 @@ func (m *ModelExecutor) finish(
|
||||
// own descendants already ordered behind it, so one pass here writes a
|
||||
// whole tree parent-first.
|
||||
for _, child := range rec.Children() {
|
||||
if err := m.sink.Save(ctx, child); err != nil {
|
||||
if err := m.sink.Save(persistCtx, child); err != nil {
|
||||
rec.Error("runtime.subrun_unsaved",
|
||||
fmt.Sprintf("%s: %s", child.RunID, err.Error()))
|
||||
unsaved = append(unsaved, child.RunID+": "+err.Error())
|
||||
|
||||
139
go-api/internal/runtime/output_cap_test.go
Normal file
139
go-api/internal/runtime/output_cap_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// These use a real question, never a greeting: a conversational message takes
|
||||
// the smalltalk path and is capped well below its tier (see smalltalk.go), so
|
||||
// "hi" here would assert the greeting cap while appearing to assert the tier's.
|
||||
//
|
||||
// A per-call cap is the difference between "the run may spend 120k tokens" and
|
||||
// "any one answer may be 16k tokens long, eight times over". These assert the
|
||||
// cap actually reaches the gateway, because it is inert until it does.
|
||||
|
||||
func TestOutputCapReachesGatewayPerTier(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
reasoning string
|
||||
want int64
|
||||
}{
|
||||
{"fast", 1_500},
|
||||
{"balanced", 3_000},
|
||||
{"deep", 4_000},
|
||||
{"nonsense-tier", 3_000}, // normalised to balanced, still capped
|
||||
} {
|
||||
t.Run(tc.reasoning, func(t *testing.T) {
|
||||
gw := &fakeGateway{text: "done"}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil)
|
||||
|
||||
agent := testAgent()
|
||||
agent.Reasoning = tc.reasoning
|
||||
|
||||
if _, err := exec.ExecuteAgent(context.Background(), agent, testInput("what happened today?")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gw.lastReq.MaxOutputTokens; got != tc.want {
|
||||
t.Errorf("MaxOutputTokens = %d, want %d", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The cap comes from the budget, not from the agent's own tier. That is what
|
||||
// makes a subagent inherit the parent's ceiling along with the parent's budget
|
||||
// instead of reading its own tier and buying a longer answer.
|
||||
func TestOutputCapComesFromTheBudgetNotTheTier(t *testing.T) {
|
||||
gw := &fakeGateway{text: "done"}
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, nil)
|
||||
|
||||
agent := testAgent()
|
||||
agent.Reasoning = "deep" // would be 4_000 if the tier decided
|
||||
|
||||
limits := LimitsForTier("deep")
|
||||
limits.MaxOutputTokens = 777
|
||||
|
||||
if _, err := exec.executeWithLimits(context.Background(), agent, testInput("what happened today?"), limits); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := gw.lastReq.MaxOutputTokens; got != 777 {
|
||||
t.Errorf("MaxOutputTokens = %d, want 777 from the supplied limits", got)
|
||||
}
|
||||
}
|
||||
|
||||
// blockingSink is a database that has stopped answering. It returns only when
|
||||
// its context ends, and records what ended it.
|
||||
type blockingSink struct {
|
||||
ctxErr error
|
||||
elapsed time.Duration
|
||||
}
|
||||
|
||||
func (b *blockingSink) Save(ctx context.Context, _ *Trajectory) error {
|
||||
start := time.Now()
|
||||
<-ctx.Done()
|
||||
b.elapsed = time.Since(start)
|
||||
b.ctxErr = ctx.Err()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// A run that answered must not then wait on the sink indefinitely: to the person
|
||||
// watching, a slow write is a slow run. I3 bounds the run; this bounds its tail.
|
||||
func TestTrajectoryPersistenceIsBounded(t *testing.T) {
|
||||
restore := trajectoryPersistTimeout
|
||||
trajectoryPersistTimeout = 50 * time.Millisecond
|
||||
t.Cleanup(func() { trajectoryPersistTimeout = restore })
|
||||
|
||||
sink := &blockingSink{}
|
||||
exec := NewModelExecutor(&fakeGateway{text: "answered"}, sink, nil)
|
||||
|
||||
start := time.Now()
|
||||
res, err := exec.ExecuteAgent(context.Background(), testAgent(), testInput("hi"))
|
||||
total := time.Since(start)
|
||||
|
||||
// The answer survives the sink failing — §6 keeps the two separate.
|
||||
if err != nil {
|
||||
t.Fatalf("a failed save must not fail the run: %v", err)
|
||||
}
|
||||
if res.Output != "answered" {
|
||||
t.Errorf("Output = %q, want the model's answer", res.Output)
|
||||
}
|
||||
if len(res.Unsaved) != 1 {
|
||||
t.Errorf("Unsaved = %v, want the one trajectory that could not be written", res.Unsaved)
|
||||
}
|
||||
|
||||
if sink.ctxErr != context.DeadlineExceeded {
|
||||
t.Errorf("sink ctx ended with %v, want DeadlineExceeded — the write was not bounded", sink.ctxErr)
|
||||
}
|
||||
// Generous slack: the assertion is "bounded", not "fast".
|
||||
if total > 2*time.Second {
|
||||
t.Errorf("run took %v with a hung sink, want the persist timeout to cut it", total)
|
||||
}
|
||||
}
|
||||
|
||||
// The bound must not become a cancellation: a run terminated by its own deadline
|
||||
// is the one whose record matters most, so the write starts from a live context
|
||||
// even when the caller's is already dead.
|
||||
func TestTrajectoryPersistenceOutlivesACancelledCaller(t *testing.T) {
|
||||
sink := &MemorySink{}
|
||||
exec := NewModelExecutor(&fakeGateway{text: "answered", delay: 50 * time.Millisecond}, sink, nil)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
// Cancelled while the model call is in flight: the run ends unhappily and
|
||||
// must still be recorded.
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
res, _ := exec.ExecuteAgent(ctx, testAgent(), testInput("hi"))
|
||||
if res == nil {
|
||||
t.Fatal("no result")
|
||||
}
|
||||
if len(res.Unsaved) != 0 {
|
||||
t.Errorf("Unsaved = %v, want the trajectory written despite the cancelled caller", res.Unsaved)
|
||||
}
|
||||
if got := len(sink.Runs); got != 1 {
|
||||
t.Errorf("sink holds %d trajectories, want 1", got)
|
||||
}
|
||||
}
|
||||
126
go-api/internal/runtime/smalltalk.go
Normal file
126
go-api/internal/runtime/smalltalk.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package runtime
|
||||
|
||||
import "strings"
|
||||
|
||||
// isSmalltalk reports a message that cannot be answered any better by looking
|
||||
// something up.
|
||||
//
|
||||
// "Hi" used to cost a full operational turn. Retrieval is gated on
|
||||
// configuration and never on the question (see retrieve), so a greeting arrived
|
||||
// at the model wrapped in eight policy chunks, with the whole tool catalogue
|
||||
// attached and the evidence placed BEFORE the question. A model handed that
|
||||
// reasonably concludes it was asked for an operational briefing, and answers
|
||||
// with one — screening backlog, uncovered shifts, citations and all. Measured
|
||||
// against production: 6,174 tokens over two model calls, for the word "hi".
|
||||
//
|
||||
// The cost is the smaller half. The real damage is that the product appears not
|
||||
// to understand being greeted, which is the first thing anybody tries.
|
||||
//
|
||||
// This is deliberately NOT a per-agent rule, and not an `if agent_key == ...`
|
||||
// — §13 lists that as the anti-pattern it is. It is a property of the MESSAGE,
|
||||
// applied identically to every spec, so adding an agent still requires no
|
||||
// runtime change (I6).
|
||||
//
|
||||
// Conservative by construction: the normalised message must match a phrase in
|
||||
// the set EXACTLY. Nothing substring-matches, so "hi, which shifts are
|
||||
// uncovered?" is an operational question and keeps its tools and its evidence.
|
||||
// A false negative costs a few thousand tokens; a false positive answers a real
|
||||
// question with a greeting, so the set only holds phrases that carry no request
|
||||
// at all.
|
||||
func isSmalltalk(q string) bool {
|
||||
n := normaliseSmalltalk(q)
|
||||
if n == "" {
|
||||
return false
|
||||
}
|
||||
_, ok := smalltalkPhrases[n]
|
||||
return ok
|
||||
}
|
||||
|
||||
// normaliseSmalltalk reduces a message to lowercase letters and single spaces.
|
||||
//
|
||||
// Punctuation and emoji are dropped rather than enumerated, so "Hi!", "hi :)"
|
||||
// and "HI 👋" all arrive as "hi" without the set needing a row for each. Digits
|
||||
// are NOT letters and so are dropped too, which is harmless here: no phrase in
|
||||
// the set contains one, and a message that does — "shift 12?" — fails the exact
|
||||
// match either way.
|
||||
func normaliseSmalltalk(q string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(q))
|
||||
space := false
|
||||
for _, r := range strings.ToLower(strings.TrimSpace(q)) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
if space && b.Len() > 0 {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
space = false
|
||||
b.WriteRune(r)
|
||||
case r == '\'' || r == '’':
|
||||
// Dropped outright rather than treated as a separator, so "how's"
|
||||
// stays one word. Both the ASCII quote and the curly one a phone
|
||||
// keyboard substitutes — the same character to whoever typed it,
|
||||
// and not to the first version of this function.
|
||||
default:
|
||||
// Any other run of non-letters is one separator, so "thank-you"
|
||||
// and "thank you" normalise alike.
|
||||
space = true
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// smalltalkPhrases is the whole rule, as data.
|
||||
//
|
||||
// Greetings, thanks and farewells only. Each is a complete message that asks
|
||||
// for nothing, which is what makes skipping retrieval and tools safe rather
|
||||
// than merely cheap. Acknowledgements like "ok" and "cool" are deliberately
|
||||
// absent: they are plausible smalltalk but also plausible answers to a
|
||||
// question the agent just asked, and the cost of being wrong is higher than
|
||||
// the tokens being saved.
|
||||
var smalltalkPhrases = map[string]struct{}{
|
||||
"hi": {}, "hii": {}, "hiya": {}, "hello": {}, "helo": {}, "hey": {},
|
||||
"yo": {}, "howdy": {}, "greetings": {}, "hi there": {},
|
||||
"hello there": {}, "hey there": {}, "hi owliver": {},
|
||||
"hello owliver": {}, "hey owliver": {},
|
||||
|
||||
"good morning": {}, "good afternoon": {}, "good evening": {},
|
||||
"good day": {}, "morning": {}, "afternoon": {}, "evening": {},
|
||||
"gm": {}, "ge": {},
|
||||
|
||||
"how are you": {}, "how are you doing": {}, "hows it going": {},
|
||||
"how is it going": {}, "you there": {}, "are you there": {},
|
||||
|
||||
"thanks": {}, "thank you": {}, "thanks a lot": {},
|
||||
"thank you very much": {}, "thanks very much": {}, "many thanks": {},
|
||||
"ty": {}, "cheers": {}, "thank u": {},
|
||||
|
||||
"bye": {}, "goodbye": {}, "good bye": {}, "see you": {},
|
||||
"see ya": {}, "good night": {}, "goodnight": {}, "later": {},
|
||||
}
|
||||
|
||||
// smalltalkDirective is appended to the system prompt for a smalltalk turn.
|
||||
//
|
||||
// Needed because the agent's own instructions describe an operational analyst,
|
||||
// and an operational analyst greeted with "hi" and given no tools will still
|
||||
// reach for the longest answer it can justify. Removing the evidence removes
|
||||
// the citations; it does not by itself shorten the reply.
|
||||
//
|
||||
// Appended to the SYSTEM prompt rather than wrapped around the user's message:
|
||||
// it is a standing instruction from the platform, not something the person
|
||||
// said, and putting words in their mouth is how a transcript stops matching
|
||||
// what was typed. I7 is untouched — this is the runtime's own text, not
|
||||
// retrieved content, and nothing retrieved can reach here because retrieval did
|
||||
// not run.
|
||||
const smalltalkDirective = "\n\nThe person has greeted you or said something " +
|
||||
"conversational. Reply in one or two short sentences: greet them back and " +
|
||||
"offer to help. Do not summarise data, do not list findings or next steps, " +
|
||||
"and do not cite sources — you have not looked anything up."
|
||||
|
||||
// smalltalkMaxOutputTokens caps a greeting's reply.
|
||||
//
|
||||
// A ceiling the model is not told about truncates mid-sentence rather than
|
||||
// winding down, so this sits well above any sane greeting (a sentence or two is
|
||||
// well under 100 tokens) and acts only as a backstop for a model that ignores
|
||||
// the directive above. The directive does the shortening; this bounds the bill
|
||||
// when it does not.
|
||||
const smalltalkMaxOutputTokens = 256
|
||||
178
go-api/internal/runtime/smalltalk_test.go
Normal file
178
go-api/internal/runtime/smalltalk_test.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/gateway"
|
||||
"github.com/krow/krow-backend/go-api/internal/tools"
|
||||
)
|
||||
|
||||
// TestIsSmalltalkGreetings covers what the fix is for: the messages that were
|
||||
// costing a full operational turn.
|
||||
func TestIsSmalltalkGreetings(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"hi", "Hi", "HI", "hi!", "hi.", " hi ", "hi :)", "hi 👋",
|
||||
"hello", "Hello!", "hey", "Hey there", "hi there",
|
||||
"good morning", "Good Morning!", "good evening",
|
||||
"thanks", "Thank you", "thank-you", "thank you",
|
||||
"bye", "Goodbye", "good night",
|
||||
"how are you", "How's it going?",
|
||||
} {
|
||||
if !isSmalltalk(q) {
|
||||
t.Errorf("isSmalltalk(%q) = false, want true", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsSmalltalkRealQuestions is the half that matters for correctness. A
|
||||
// false positive answers a real operational question with a greeting, so
|
||||
// anything carrying a request must fall through — including the ones that
|
||||
// merely START with a greeting.
|
||||
func TestIsSmalltalkRealQuestions(t *testing.T) {
|
||||
for _, q := range []string{
|
||||
"", " ",
|
||||
"how many open positions?",
|
||||
"what happened today",
|
||||
"hi, how many open positions?",
|
||||
"hello there, which shifts are uncovered?",
|
||||
"hey can you check the screening backlog",
|
||||
"thanks — now show me the overtime report",
|
||||
"good morning, what needs attention right now?",
|
||||
"say hi to the new starters",
|
||||
"how are you handling the uncovered shifts",
|
||||
"bye week coverage",
|
||||
} {
|
||||
if isSmalltalk(q) {
|
||||
t.Errorf("isSmalltalk(%q) = true, want false", q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormaliseSmalltalk(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Hi!": "hi",
|
||||
" HELLO ": "hello",
|
||||
"thank-you": "thank you",
|
||||
"thank you": "thank you",
|
||||
"How's it go?": "hows it go",
|
||||
"👋": "",
|
||||
"shift 12": "shift",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normaliseSmalltalk(in); got != want {
|
||||
t.Errorf("normaliseSmalltalk(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSmalltalkSendsNoToolsAndSkipsRetrieval is the fix as the user meets it:
|
||||
// "hi" reaches the model as "hi", with nothing attached.
|
||||
func TestSmalltalkSendsNoToolsAndSkipsRetrieval(t *testing.T) {
|
||||
var toolCalls int
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
|
||||
|
||||
ret := &scriptedRetriever{results: onePassage("Staff must arrive fifteen minutes early.")}
|
||||
gw := &scriptedGateway{}
|
||||
|
||||
agent := knowledgeAgent()
|
||||
agent.Tools = []string{"activity_breakdown"}
|
||||
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, reg).WithRetriever(ret)
|
||||
res, err := exec.ExecuteAgent(context.Background(), agent, testInput("Hi"))
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Termination != TerminationCompleted {
|
||||
t.Fatalf("Termination = %q, want Completed", res.Termination)
|
||||
}
|
||||
|
||||
if ret.calls != 0 {
|
||||
t.Errorf("retriever was called %d times for a greeting; want 0", ret.calls)
|
||||
}
|
||||
if len(gw.seen) != 1 {
|
||||
t.Fatalf("a greeting took %d model calls, want 1", len(gw.seen))
|
||||
}
|
||||
req := gw.seen[0]
|
||||
if len(req.Tools) != 0 {
|
||||
t.Errorf("greeting carried %d tool definitions, want 0", len(req.Tools))
|
||||
}
|
||||
if req.Messages[0].Text != "Hi" {
|
||||
t.Errorf("model saw %q, want the bare greeting", req.Messages[0].Text)
|
||||
}
|
||||
if !strings.Contains(req.System, "greeted you") {
|
||||
t.Error("the smalltalk directive did not reach the system prompt")
|
||||
}
|
||||
if req.MaxOutputTokens != smalltalkMaxOutputTokens {
|
||||
t.Errorf("MaxOutputTokens = %d, want the smalltalk cap %d",
|
||||
req.MaxOutputTokens, smalltalkMaxOutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOperationalQuestionKeepsToolsAndRetrieval is the guard on the fix above.
|
||||
// The cheap path must not swallow a question that needs evidence.
|
||||
func TestOperationalQuestionKeepsToolsAndRetrieval(t *testing.T) {
|
||||
var toolCalls int
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
|
||||
|
||||
ret := &scriptedRetriever{results: onePassage("Staff must arrive fifteen minutes early.")}
|
||||
gw := &scriptedGateway{}
|
||||
|
||||
agent := knowledgeAgent()
|
||||
agent.Tools = []string{"activity_breakdown"}
|
||||
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, reg).WithRetriever(ret)
|
||||
if _, err := exec.ExecuteAgent(
|
||||
context.Background(), agent, testInput("hi, how many open positions?"),
|
||||
); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if ret.calls != 1 {
|
||||
t.Errorf("retriever called %d times for a real question, want 1", ret.calls)
|
||||
}
|
||||
if len(gw.seen[0].Tools) == 0 {
|
||||
t.Error("a real question was sent with no tools")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCatalogueWithheldOnceToolBudgetIsSpent covers the other half of the cost
|
||||
// work: the synthesis turn at the end of a tool-using run is sent without a
|
||||
// catalogue the model is no longer permitted to use.
|
||||
func TestCatalogueWithheldOnceToolBudgetIsSpent(t *testing.T) {
|
||||
var toolCalls int
|
||||
reg := tools.NewRegistry()
|
||||
reg.MustRegister(countingTool("activity_breakdown", &toolCalls))
|
||||
|
||||
gw := &scriptedGateway{steps: []*gateway.Response{{
|
||||
ToolCalls: []gateway.ToolCall{{ID: "call_1", Name: "activity_breakdown", Input: json.RawMessage(`{}`)}},
|
||||
StopReason: "tool_use", Model: "fake-model",
|
||||
}}}
|
||||
|
||||
agent := testAgent()
|
||||
agent.Tools = []string{"activity_breakdown"}
|
||||
|
||||
exec := NewModelExecutor(gw, &MemorySink{}, reg)
|
||||
res, err := exec.executeWithLimits(context.Background(), agent, testInput("what happened today"),
|
||||
Limits{MaxSteps: 4, MaxToolCalls: 1, MaxTokens: 100_000, Deadline: 30 * time.Second})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if res.Termination != TerminationCompleted {
|
||||
t.Fatalf("Termination = %q, want Completed", res.Termination)
|
||||
}
|
||||
if len(gw.seen) < 2 {
|
||||
t.Fatalf("expected at least 2 model calls, got %d", len(gw.seen))
|
||||
}
|
||||
if len(gw.seen[0].Tools) == 0 {
|
||||
t.Error("the first call must offer the catalogue")
|
||||
}
|
||||
if n := len(gw.seen[len(gw.seen)-1].Tools); n != 0 {
|
||||
t.Errorf("the final call carried %d tool definitions; the tool budget was spent", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user