Skip to content

Commit 18baaef

Browse files
authored
runtime: make tool DisplayHint typed and durable (#92)
* runtime: make tool DisplayHint typed and durable Compute default tool call display hints using typed payload decoding at publish time and persist them to the run log. Remove best-effort hint rendering from hook constructors and add optional per-tool streaming hint overrides. * runtime: fix lint failures in DisplayHint enrichment
1 parent eb76f19 commit 18baaef

File tree

9 files changed

+290
-23
lines changed

9 files changed

+290
-23
lines changed

docs/dsl.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -741,7 +741,9 @@ Tool("get_data", "Get user data", func() {
741741

742742
### Display Hint Templates
743743

744-
`CallHintTemplate` and `ResultHintTemplate` configure Go templates for UI display:
744+
`CallHintTemplate` and `ResultHintTemplate` configure Go templates for UI display.
745+
These templates are rendered by the runtime against the tool's **typed** payload/result structs
746+
and surfaced via hook + stream events as `DisplayHint` (call) and result previews (result).
745747

746748
```go
747749
Tool("search", "Search documents", func() {
@@ -761,6 +763,21 @@ Tool("search", "Search documents", func() {
761763
Templates are compiled with `missingkey=error`. Keep hints concise (≤140 characters recommended).
762764
Template variables use Go field names (e.g., `.Query`, `.Limit`), not JSON keys.
763765

766+
**Runtime contract:**
767+
768+
- Tool call scheduled events default to `DisplayHint==""` at construction time. The runtime may enrich
769+
and persist a **durable default** hint when it can decode the typed payload and execute the template.
770+
- If you set `DisplayHint` explicitly (non-empty) before publishing the hook event, the runtime treats it
771+
as authoritative and will not overwrite it.
772+
- If typed decoding fails, the runtime leaves `DisplayHint` empty (strict contract: no rendering against
773+
raw JSON bytes).
774+
775+
**Per-consumer overrides (optional):**
776+
777+
If you need a different hint for a specific deployment/consumer (e.g., UI wording), configure a runtime
778+
override via `runtime.WithHintOverrides`. Overrides take precedence over DSL templates for streamed
779+
`tool_start` events.
780+
764781
### BoundedResult
765782

766783
`BoundedResult` marks a tool's result as a bounded view over potentially larger data. When set:

docs/overview.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,8 +443,8 @@ policies, and MCP servers within Goa service designs.
443443
| `Tags(...)` | Attach metadata labels for filtering/categorization |
444444
| `BindTo(method)` or `BindTo(service, method)` | Bind tool to service method implementation |
445445
| `Inject(fields...)` | Mark fields as infrastructure-only (hidden from LLM) |
446-
| `CallHintTemplate(tmpl)` | Go template for call display hint |
447-
| `ResultHintTemplate(tmpl)` | Go template for result display hint |
446+
| `CallHintTemplate(tmpl)` | Go template for tool call `DisplayHint` (typed payload; rendered by runtime) |
447+
| `ResultHintTemplate(tmpl)` | Go template for tool result display (typed result; rendered by runtime) |
448448
| `BoundedResult()` | Mark result as bounded view over larger data |
449449
| `ResultReminder(text)` | Static system reminder injected after tool result |
450450

docs/runtime.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -492,14 +492,32 @@ type ToolsetRegistration struct {
492492
Specs []tools.ToolSpec // JSON codecs and schemas
493493
TaskQueue string // Optional queue override
494494
Inline bool // Execute in workflow context
495-
CallHints map[tools.Ident]*template.Template // Display hint templates
496-
ResultHints map[tools.Ident]*template.Template // Result preview templates
495+
CallHints map[tools.Ident]*template.Template // Tool call DisplayHint templates (typed payload only)
496+
ResultHints map[tools.Ident]*template.Template // Tool result preview templates (typed result only)
497497
PayloadAdapter func(...) // Pre-decode transformation
498498
ResultAdapter func(...) // Post-encode transformation
499499
AgentTool *AgentToolConfig // Agent-as-tool configuration
500500
}
501501
```
502502

503+
### Tool Call Display Hints (DisplayHint)
504+
505+
The runtime can surface user-facing hints for tool calls (for example in UIs) via the `DisplayHint` field on
506+
hook + stream events.
507+
508+
Contract:
509+
510+
- Hook constructors do not render hints. Tool call scheduled events default to `DisplayHint==""`.
511+
- The runtime may enrich and persist a **durable default** hint at publish time by decoding the typed tool
512+
payload using generated codecs and executing the `CallHintTemplate` (if registered).
513+
- When typed decoding fails or no template is registered, the runtime leaves `DisplayHint` empty. Hints are
514+
never rendered against raw JSON bytes.
515+
- If a producer explicitly sets `DisplayHint` (non-empty) before publishing the hook event, the runtime treats
516+
it as authoritative and does not overwrite it.
517+
518+
For per-consumer wording changes, configure `runtime.WithHintOverrides` on the runtime. Overrides take precedence
519+
over DSL-authored templates for streamed `tool_start` events.
520+
503521
### Tool Implementation Patterns
504522

505523
**Method-backed tools** — Generated from `BindTo` DSL:

runtime/agent/hooks/events.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"goa.design/goa-ai/runtime/agent/prompt"
1313
"goa.design/goa-ai/runtime/agent/rawjson"
1414
"goa.design/goa-ai/runtime/agent/run"
15-
rthints "goa.design/goa-ai/runtime/agent/runtime/hints"
1615
"goa.design/goa-ai/runtime/agent/telemetry"
1716
"goa.design/goa-ai/runtime/agent/toolerrors"
1817
"goa.design/goa-ai/runtime/agent/tools"
@@ -769,12 +768,6 @@ func (e *AwaitExternalToolsEvent) Type() EventType { return AwaitExternalTools }
769768
// canonical JSON arguments for the scheduled tool; queue is the activity queue name.
770769
// ParentToolCallID and expectedChildren are optional (empty/0 for top-level calls).
771770
func NewToolCallScheduledEvent(runID string, agentID agent.Ident, sessionID string, toolName tools.Ident, toolCallID string, payload rawjson.RawJSON, queue string, parentToolCallID string, expectedChildren int) *ToolCallScheduledEvent {
772-
// Compute a best-effort call hint once at emit time so all subscribers can
773-
// reuse it. The payload is the canonical JSON arguments; templates that
774-
// depend on typed structs will be rerun by higher-level decorators (e.g.,
775-
// the runtime hinting sink) when needed.
776-
displayHint := rthints.FormatCallHint(toolName, payload.RawMessage())
777-
778771
be := newBaseEvent(runID, agentID)
779772
be.sessionID = sessionID
780773
return &ToolCallScheduledEvent{
@@ -785,7 +778,10 @@ func NewToolCallScheduledEvent(runID string, agentID agent.Ident, sessionID stri
785778
Queue: queue,
786779
ParentToolCallID: parentToolCallID,
787780
ExpectedChildrenTotal: expectedChildren,
788-
DisplayHint: displayHint,
781+
// DisplayHint is computed by the runtime at publish time using typed payloads
782+
// and registered templates. This keeps the contract strict: hints are never
783+
// rendered against raw JSON bytes.
784+
DisplayHint: "",
789785
}
790786
}
791787

runtime/agent/runtime/hook_activity.go

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package runtime
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/json"
57
"errors"
68
"time"
79

810
"goa.design/goa-ai/runtime/agent/hooks"
911
"goa.design/goa-ai/runtime/agent/prompt"
1012
"goa.design/goa-ai/runtime/agent/runlog"
13+
rthints "goa.design/goa-ai/runtime/agent/runtime/hints"
1114
"goa.design/goa-ai/runtime/agent/session"
1215
)
1316

@@ -36,6 +39,15 @@ func (r *Runtime) hookActivity(ctx context.Context, input *HookActivityInput) er
3639
if err != nil {
3740
return err
3841
}
42+
payload := append([]byte(nil), input.Payload...)
43+
if e, ok := evt.(*hooks.ToolCallScheduledEvent); ok {
44+
if enriched := r.enrichToolCallScheduledHint(ctx, e); enriched {
45+
reencoded, err := hooks.EncodeToHookInput(e, input.TurnID)
46+
if err == nil {
47+
payload = append([]byte(nil), reencoded.Payload.RawMessage()...)
48+
}
49+
}
50+
}
3951
// Tool call argument deltas are best-effort UX signals. They are intentionally
4052
// excluded from the canonical run event log to avoid bloating durable history.
4153
//
@@ -48,7 +60,7 @@ func (r *Runtime) hookActivity(ctx context.Context, input *HookActivityInput) er
4860
SessionID: input.SessionID,
4961
TurnID: input.TurnID,
5062
Type: input.Type,
51-
Payload: append([]byte(nil), input.Payload...),
63+
Payload: payload,
5264
Timestamp: time.UnixMilli(evt.Timestamp()).UTC(),
5365
}); err != nil {
5466
return err
@@ -81,6 +93,33 @@ func (r *Runtime) hookActivity(ctx context.Context, input *HookActivityInput) er
8193
return nil
8294
}
8395

96+
func (r *Runtime) enrichToolCallScheduledHint(ctx context.Context, evt *hooks.ToolCallScheduledEvent) bool {
97+
if evt == nil {
98+
return false
99+
}
100+
if evt.DisplayHint != "" {
101+
return false
102+
}
103+
raw := normalizeHintPayloadJSON(evt.Payload.RawMessage())
104+
typed, err := r.unmarshalToolValue(ctx, evt.ToolName, raw, true)
105+
if err != nil || typed == nil {
106+
return false
107+
}
108+
if hint := rthints.FormatCallHint(evt.ToolName, typed); hint != "" {
109+
evt.DisplayHint = hint
110+
return true
111+
}
112+
return false
113+
}
114+
115+
func normalizeHintPayloadJSON(raw json.RawMessage) json.RawMessage {
116+
trimmed := bytes.TrimSpace(raw)
117+
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
118+
return json.RawMessage("{}")
119+
}
120+
return raw
121+
}
122+
84123
func (r *Runtime) updateRunMetaFromHookEvent(ctx context.Context, evt hooks.Event) error {
85124
if evt == nil {
86125
return errors.New("runtime: hook event is nil")

runtime/agent/runtime/hook_activity_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,13 @@ import (
99
"github.com/stretchr/testify/require"
1010
"goa.design/goa-ai/runtime/agent/hooks"
1111
"goa.design/goa-ai/runtime/agent/prompt"
12+
"goa.design/goa-ai/runtime/agent/rawjson"
1213
"goa.design/goa-ai/runtime/agent/runlog"
14+
rthints "goa.design/goa-ai/runtime/agent/runtime/hints"
1315
"goa.design/goa-ai/runtime/agent/session"
1416
sessioninmem "goa.design/goa-ai/runtime/agent/session/inmem"
17+
"goa.design/goa-ai/runtime/agent/telemetry"
18+
"goa.design/goa-ai/runtime/agent/tools"
1519
)
1620

1721
type recordingRunlog struct {
@@ -126,6 +130,62 @@ func TestHookActivityAppendFailureAbortsPublish(t *testing.T) {
126130
require.Nil(t, published)
127131
}
128132

133+
func TestHookActivity_EnrichesToolCallScheduledDisplayHintInRunlog(t *testing.T) {
134+
t.Parallel()
135+
136+
toolID := tools.Ident("runtime.hints.test.scheduled")
137+
rthints.RegisterCallHint(toolID, mustTemplate(t, toolID, "Checking {{.Resolution}} energy rates"))
138+
139+
rl := &recordingRunlog{}
140+
bus := hooks.NewBus()
141+
store := sessioninmem.New()
142+
143+
rt := &Runtime{
144+
RunEventStore: rl,
145+
Bus: bus,
146+
SessionStore: store,
147+
logger: telemetry.NoopLogger{},
148+
toolSpecs: map[tools.Ident]tools.ToolSpec{
149+
toolID: newTypedHintSpec(toolID),
150+
},
151+
}
152+
153+
now := time.Now().UTC()
154+
_, err := store.CreateSession(context.Background(), "sess-1", now)
155+
require.NoError(t, err)
156+
require.NoError(t, store.UpsertRun(context.Background(), session.RunMeta{
157+
AgentID: "svc.agent",
158+
RunID: "run-1",
159+
SessionID: "sess-1",
160+
Status: session.RunStatusPending,
161+
StartedAt: now,
162+
UpdatedAt: now,
163+
}))
164+
165+
ev := hooks.NewToolCallScheduledEvent(
166+
"run-1",
167+
"svc.agent",
168+
"sess-1",
169+
toolID,
170+
"call-1",
171+
rawjson.RawJSON([]byte(`{"resolution":"hourly"}`)),
172+
"queue",
173+
"",
174+
0,
175+
)
176+
// Hooks constructors do not render hints. The runtime fills in a durable default
177+
// hint (when possible) using typed payloads at publish time.
178+
require.Empty(t, ev.DisplayHint)
179+
180+
input, err := hooks.EncodeToHookInput(ev, "turn-1")
181+
require.NoError(t, err)
182+
183+
require.NoError(t, rt.hookActivity(context.Background(), input))
184+
require.Len(t, rl.events, 1)
185+
require.Equal(t, hooks.ToolCallScheduled, rl.events[0].Type)
186+
require.Contains(t, string(rl.events[0].Payload), "Checking hourly energy rates")
187+
}
188+
129189
func TestHookActivityAccumulatesPromptRefsOnRunMeta(t *testing.T) {
130190
t.Parallel()
131191

runtime/agent/runtime/runtime.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,17 @@ import (
6565
)
6666

6767
type (
68+
// HintOverrideFunc can override the call hint for a tool invocation.
69+
//
70+
// Contract:
71+
// - Returning (hint, true) selects hint as the DisplayHint, even when a DSL
72+
// template exists.
73+
// - Returning ("", false) indicates no override applies and the runtime should
74+
// use its default behavior.
75+
// - The payload value is the typed payload decoded via the tool payload codec
76+
// when possible; it may be nil when decoding fails.
77+
HintOverrideFunc func(ctx context.Context, tool tools.Ident, payload any) (hint string, ok bool)
78+
6879
// Runtime orchestrates agent workflows, policy enforcement, memory persistence,
6980
// and event streaming. It serves as the central registry for agents, toolsets,
7081
// and models. All public methods are thread-safe and can be called concurrently.
@@ -150,6 +161,8 @@ type (
150161
// It is used to require explicit operator approval before executing certain tools.
151162
// See ToolConfirmationConfig for details.
152163
toolConfirmation *ToolConfirmationConfig
164+
165+
hintOverrides map[tools.Ident]HintOverrideFunc
153166
}
154167

155168
// Options configures the Runtime instance. All fields are optional except Engine
@@ -194,6 +207,10 @@ type (
194207
// tools (for example, requiring explicit operator approval before executing
195208
// additional tools that are not marked with design-time Confirmation).
196209
ToolConfirmation *ToolConfirmationConfig
210+
211+
// HintOverrides optionally overrides DSL-authored call hints for specific tools
212+
// when streaming tool_start events.
213+
HintOverrides map[tools.Ident]HintOverrideFunc
197214
}
198215

199216
// RuntimeOption configures the runtime via functional options passed to NewWith.
@@ -662,6 +679,7 @@ func newFromOptions(opts Options) *Runtime {
662679
workers: opts.Workers,
663680
reminders: reminder.NewEngine(),
664681
toolConfirmation: opts.ToolConfirmation,
682+
hintOverrides: opts.HintOverrides,
665683
}
666684
rt.PromptRegistry.SetObserver(rt.onPromptRendered)
667685
// Install runtime-owned toolsets before any agent registration so planners
@@ -906,6 +924,14 @@ func WithToolConfirmation(cfg *ToolConfirmationConfig) RuntimeOption {
906924
return func(o *Options) { o.ToolConfirmation = cfg }
907925
}
908926

927+
// WithHintOverrides configures per-tool call hint overrides.
928+
//
929+
// When provided, overrides take precedence over DSL-authored CallHint templates
930+
// when streaming tool_start events. Only tools present in the map are considered.
931+
func WithHintOverrides(m map[tools.Ident]HintOverrideFunc) RuntimeOption {
932+
return func(o *Options) { o.HintOverrides = m }
933+
}
934+
909935
// WithWorker configures the worker for a specific agent. Engines that support
910936
// worker polling use this configuration to bind the agent to a specific queue.
911937
// If unspecified, a default worker configuration is used.

runtime/agent/runtime/runtime_hints_sink.go

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"encoding/json"
66

7+
"goa.design/goa-ai/runtime/agent/rawjson"
78
rthints "goa.design/goa-ai/runtime/agent/runtime/hints"
89
"goa.design/goa-ai/runtime/agent/stream"
910
"goa.design/goa-ai/runtime/agent/tools"
@@ -32,10 +33,20 @@ func (h *hintingSink) Send(ctx context.Context, ev stream.Event) error {
3233
switch e := ev.(type) {
3334
case stream.ToolStart:
3435
data := e.Data
35-
if data.DisplayHint == "" {
36-
if typed := h.decodePayload(ctx, tools.Ident(data.ToolName), data.Payload); typed != nil {
37-
if s := rthints.FormatCallHint(tools.Ident(data.ToolName), typed); s != "" {
38-
data.DisplayHint = s
36+
37+
toolName := tools.Ident(data.ToolName)
38+
override := h.rt.hintOverrides[toolName]
39+
if data.DisplayHint == "" || override != nil {
40+
if typed := h.decodePayload(ctx, toolName, data.Payload); typed != nil {
41+
if override != nil {
42+
if hint, ok := override(ctx, toolName, typed); ok {
43+
data.DisplayHint = hint
44+
}
45+
}
46+
if data.DisplayHint == "" {
47+
if s := rthints.FormatCallHint(toolName, typed); s != "" {
48+
data.DisplayHint = s
49+
}
3950
}
4051
}
4152
}
@@ -58,16 +69,20 @@ func (h *hintingSink) Close(ctx context.Context) error {
5869
// runtime's tool codecs.
5970
//
6071
// Contract:
61-
// - Tool payloads are canonical JSON values for the tool payload schema.
62-
// - A missing/empty payload is normalized to "{}" (empty object) so tools with
63-
// empty payload schemas still render call hints deterministically.
64-
// - Hints are only rendered from typed payloads produced by registered codecs.
65-
// If decode fails, this function returns nil.
72+
// - Tool payloads are canonical JSON values for the tool payload schema.
73+
// - A missing/empty payload is normalized to "{}" (empty object) so tools with
74+
// empty payload schemas still render call hints deterministically.
75+
// - Hints are only rendered from typed payloads produced by registered codecs.
76+
// If decode fails, this function returns nil.
6677
func (h *hintingSink) decodePayload(ctx context.Context, tool tools.Ident, payload any) any {
6778
raw := json.RawMessage("{}")
6879
switch v := payload.(type) {
6980
case nil:
7081
// Keep canonical empty object.
82+
case rawjson.RawJSON:
83+
if len(v) > 0 {
84+
raw = v.RawMessage()
85+
}
7186
case json.RawMessage:
7287
if len(v) > 0 {
7388
raw = v

0 commit comments

Comments
 (0)