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
8 changes: 4 additions & 4 deletions docs/mkdocs/en/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,11 +565,11 @@ Tool-call pairing and second entry into LLM:

#### Placeholder Variables in LLM Instructions

LLM nodes support placeholder injection in their `instruction` string (same rules as LLMAgent):
LLM nodes support placeholder injection in their `instruction` string (same rules as LLMAgent). Both native `{key}` and Mustache `{{key}}` syntaxes are accepted (Mustache is normalized to the native form automatically):

- `{key}` → replaced by `session.State["key"]`
- `{key?}` → optional; missing values become empty
- `{user:subkey}`, `{app:subkey}`, `{temp:subkey}` → access user/app/temp scopes (session services merge app/user state into session with these prefixes)
- `{key}` / `{{key}}` → replaced by `session.State["key"]`
- `{key?}` / `{{key?}}` → optional; missing values become empty
- `{user:subkey}`, `{app:subkey}`, `{temp:subkey}` (and their Mustache forms) → access user/app/temp scopes (session services merge app/user state into session with these prefixes)

Notes:

Expand Down
8 changes: 4 additions & 4 deletions docs/mkdocs/zh/graph.md
Original file line number Diff line number Diff line change
Expand Up @@ -471,11 +471,11 @@ stateGraph.AddLLMNode("analyze", model,

#### LLM 指令中的占位符

LLM 节点的 `instruction` 支持占位符注入(与 LLMAgent 规则一致):
LLM 节点的 `instruction` 支持占位符注入(与 LLMAgent 规则一致)。支持原生 `{key}` 与 Mustache `{{key}}` 两种写法(Mustache 会自动规整为原生写法)

- `{key}` → 替换为 `session.State["key"]`
- `{key?}` → 可选,缺失时替换为空
- `{user:subkey}`、`{app:subkey}`、`{temp:subkey}` → 访问用户/应用/临时命名空间(SessionService 会将 app/user 作用域合并到 session,并带上前缀)
- `{key}` / `{{key}}` → 替换为 `session.State["key"]`
- `{key?}` / `{{key?}}` → 可选,缺失时替换为空
- `{user:subkey}`、`{app:subkey}`、`{temp:subkey}`(以及其 Mustache 写法)→ 访问用户/应用/临时命名空间(SessionService 会将 app/user 作用域合并到 session,并带上前缀)

说明:

Expand Down
1 change: 1 addition & 0 deletions examples/graph/retrieval_placeholder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ This pattern is ideal when retrieval results are per‑turn ephemeral and should

- Placeholder expansion for LLM (Large Language Model) nodes reads from the session’s state, see [graph/state_graph.go](graph/state_graph.go).
- GraphAgent injects the current `*session.Session` into graph state, see [agent/graphagent/graph_agent.go](agent/graphagent/graph_agent.go).
- Templates can use both `{key}` and `{{key}}` styles (Mustache is normalized automatically).
- For per‑turn data used only to build this round’s prompt, writing `temp:*` directly on `session.State` is appropriate and simple. It won’t be persisted.

## When to Use `SessionService` Instead
Expand Down
27 changes: 27 additions & 0 deletions internal/state/state_injection.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,27 @@ import (
"trpc.group/trpc-go/trpc-agent-go/session"
)

// mustachePlaceholderRE matches Mustache-style placeholders like {{key}},
// optionally allowing namespaces (user:, app:, temp:) and the optional
// suffix '?' (e.g., {{key?}}, {{temp:value}}). It purposely restricts the
// allowed characters to avoid over-replacing in free text.
var mustachePlaceholderRE = regexp.MustCompile(`\{\{\s*([A-Za-z_][A-Za-z0-9_]*:(?:[A-Za-z_][A-Za-z0-9_]*)|[A-Za-z_][A-Za-z0-9_]*)(\?)?\s*\}\}`)

// normalizePlaceholders converts supported Mustache-style placeholders
// to the framework's native single-brace form before injection.
// Examples:
//
// {{key}} -> {key}
// {{key?}} -> {key?}
// {{user:name}} -> {user:name}
// {{temp:value?}} -> {temp:value?}
func normalizePlaceholders(s string) string {
if s == "" {
return s
}
return mustachePlaceholderRE.ReplaceAllString(s, `{$1$2}`)
}

// InjectSessionState replaces state variables in the instruction template with their corresponding values from session state.
// This function supports the following patterns:
// - {variable_name}: Replaces with the value of the variable from session state.
Expand All @@ -36,6 +57,12 @@ func InjectSessionState(template string, invocation *agent.Invocation) (string,
return template, nil
}

// 1) Normalize Mustache-style placeholders ({{...}}) into the framework's
// native single-brace form so downstream logic works uniformly.
// This provides global compatibility for templates authored with
// systems like Agno without requiring callers to pre-process.
template = normalizePlaceholders(template)

// Regular expression to match state variables in curly braces.
// Supports optional variables with ? suffix.
stateVarPattern := regexp.MustCompile(`\{([^{}]+)\}`)
Expand Down
53 changes: 53 additions & 0 deletions internal/state/state_injection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,3 +195,56 @@ func TestIsValidStateName(t *testing.T) {
})
}
}

func TestNormalizePlaceholders_MustacheToNative(t *testing.T) {
cases := map[string]string{
"{{key}}": "{key}",
"{{ key }}": "{key}",
"{{key?}}": "{key?}",
"{{ user:name }}": "{user:name}",
"{{app:setting}}": "{app:setting}",
"x {{temp:value?}} y": "x {temp:value?} y",
// invalid names should remain untouched
"{{invalid-name}}": "{{invalid-name}}",
"{{ 123abc }}": "{{ 123abc }}",
}
for in, want := range cases {
got := normalizePlaceholders(in)
if got != want {
t.Fatalf("normalizePlaceholders(%q)=%q, want=%q", in, got, want)
}
}
}

func TestInjectSessionState_MustachePlaceholders(t *testing.T) {
// Prepare invocation session state
sm := make(session.StateMap)
sm["key"] = []byte(`"v"`)
sm["user:name"] = []byte(`"alice"`)
sm["temp:value"] = []byte(`"ctx"`)
inv := &agent.Invocation{Session: &session.Session{State: sm}}

// Simple mustache
s, err := InjectSessionState("hi {{key}}", inv)
if err != nil || s != "hi v" {
t.Fatalf("InjectSessionState simple: got %q err=%v", s, err)
}

// Namespaced and optional + spaces
s, err = InjectSessionState("U={{ user:name }}, C={{ temp:value? }}", inv)
if err != nil || s != "U=alice, C=ctx" {
t.Fatalf("InjectSessionState ns: got %q err=%v", s, err)
}

// Optional missing
s, err = InjectSessionState("X={{missing?}}.", inv)
if err != nil || s != "X=." {
t.Fatalf("InjectSessionState missing optional: got %q err=%v", s, err)
}

// Invalid name stays
s, err = InjectSessionState("bad {{invalid-name}}", inv)
if err != nil || s != "bad {{invalid-name}}" {
t.Fatalf("InjectSessionState invalid mustache: got %q err=%v", s, err)
}
}
Loading