Skip to content

Commit 7e4aca6

Browse files
authored
Merge pull request #15 from achetronic/feat/extract-contextguard-adkutils
Feat/extract contextguard adkutils
2 parents 283fc65 + 709ef93 commit 7e4aca6

File tree

16 files changed

+181
-1000
lines changed

16 files changed

+181
-1000
lines changed

.agents/ADK_TOOLS.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ filtered := tool.FilterToolset(myToolset, func(ctx agent.ReadonlyContext, t tool
223223

224224
### Memory tools: `adk-utils-go` vs ADK official
225225

226-
Magec currently uses custom memory tools from `adk-utils-go/tools/memory` (v0.2.2 in use):
226+
Magec currently uses custom memory tools from `adk-utils-go/tools/memory` (v0.7.0 in use):
227227

228-
| Tool | `adk-utils-go` (current v0.2.2) | ADK official (`main`) |
228+
| Tool | `adk-utils-go` (current v0.7.0) | ADK official (`main`) |
229229
|------|----------------------|----------------------|----------------------|
230230
| **Search** | `search_memory` (with entry IDs) | `loadmemorytool` (`load_memory`) |
231231
| **Save** | `save_to_memory` | — (no equivalent) |

.agents/AGENTS.md

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,7 @@ magec/
6060
│ │ └── docs/ # Generated swagger (userapi)
6161
│ ├── a2a/ # A2A protocol handler
6262
│ │ └── handler.go # Per-agent/flow JSON-RPC endpoints, agent cards, SSE streaming
63-
│ ├── plugin/ # ADK plugins
64-
│ │ └── contextguard/ # Context window management plugin
65-
│ │ ├── contextguard.go # BeforeModelCallback plugin, strategy dispatch, summary persistence
66-
│ │ ├── threshold.go # Token-based strategy (estimates tokens, summarizes when near limit)
67-
│ │ └── sliding_window.go # Turn-count strategy (compacts after maxTurns content entries)
68-
│ ├── contextwindow/ # Remote model metadata registry
69-
│ │ └── contextwindow.go # Fetches provider.json, caches context window sizes per model (6h refresh)
63+
7064
│ ├── middleware/
7165
│ │ ├── middleware.go # AccessLog (httpsnoop), CORS, ClientAuth, AdminAuth (rate-limited)
7266
│ │ ├── recorder.go # ConversationRecorder + ConversationRecorderSSE (dual-perspective)
@@ -234,8 +228,7 @@ log:
234228
- **MCP headers/TLS**: `MCPServer` struct has `Headers map[string]string` and `Insecure bool`. `httpClientForMCP()` creates transport with optional `InsecureSkipVerify`
235229
- **Skill injection**: Skills are injected into the agent system prompt at build time. Instructions appended as `--- Skill: {name} ---`, reference file contents appended as `[Reference: {filename}]`. Files read from `data/skills/{skillId}/`
236230
- **Encryption key**: `server.encryptionKey` in config.yaml. Independent from `adminPassword`. Used to encrypt secrets at rest (AES-256-GCM, PBKDF2-derived)
237-
- **ContextGuard plugin**: ADK `plugin.Plugin` with `BeforeModelCallback`. Two strategies: `threshold` (token-based, summarizes when near context limit) and `sliding_window` (turn-count, compacts after maxTurns). Each agent summarizes with its own LLM. Summary persisted in session state
238-
- **Context window registry**: `server/contextwindow/` fetches model context sizes from remote `provider.json` (6h cache, 128k default fallback). Used by ContextGuard threshold strategy
231+
- **ContextGuard plugin**: Externalized to `adk-utils-go/plugin/contextguard` (v0.7.0). Builder API: `contextguard.New(registry)` + `guard.Add(agentID, llm, opts...)` + `guard.PluginConfig()`. Two strategies: `threshold` (token-based, auto-detect via CrushRegistry or manual `WithMaxTokens`) and `sliding_window` (turn-count via `WithSlidingWindow`). Each agent summarizes with its own LLM. Summary persisted in session state with `{agentName}` suffix keys. `CrushRegistry` fetches model metadata from Crush's provider.json with 6h background refresh
239232
- **A2A protocol**: Agents/flows with `A2A.Enabled` get JSON-RPC endpoints via `a2a-go` + ADK `adka2a`. Agent cards auto-generated with capabilities and skills. SSE streaming for responses
240233
- **Dual-perspective conversation recording**: Middleware chains recorder twice: "admin" perspective (all events, before FlowResponseFilter) and "user" perspective (filtered, after). Each conversation has a `ParentID` linking the pair
241234
- **Store dual-copy pattern**: Store maintains `rawData` (unexpanded, with `${VAR}` refs) and `data` (env-expanded). API responses use raw data, runtime uses expanded. Secret values injected as env vars before expansion
@@ -304,7 +297,7 @@ GPU section commented out by default. Users who want cloud providers create diff
304297
**Go backend:**
305298
- `google.golang.org/adk` — Agent Development Kit (v0.4.0)
306299
- `google.golang.org/genai` — Google GenAI SDK (v1.40.0)
307-
- `github.com/achetronic/adk-utils-go` — ADK utilities (v0.2.2): providers, session, memory tools
300+
- `github.com/achetronic/adk-utils-go` — ADK utilities (v0.7.0): providers, session, memory tools, ContextGuard plugin
308301
- `github.com/a2aproject/a2a-go` — A2A protocol library (v0.3.3)
309302
- `github.com/modelcontextprotocol/go-sdk` — MCP client (v1.2.0)
310303
- `github.com/gorilla/mux` — HTTP router (v1.8.1)

.agents/TODO.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -390,17 +390,17 @@ ADK supports agents as tools — orchestrator decides at runtime which specialis
390390
**Solution (requires real user identity)**:
391391
1. Implement per-client user identity: each client generates a meaningful `userID` (e.g. `discord_123456`, `slack_U0ABC`, `telegram_98765`) instead of `default_user`
392392
2. Move ContextGuard state keys to `user:` tier (`session.KeyPrefixUser` prefix) so summaries are scoped per-user across all that user's sessions with a given agent
393-
3. The `user:` tier in `adk-utils-go` v0.5.0 already supports differentiated TTL (defaults to no expiration), so summaries survive indefinitely
393+
3. The `user:` tier in `adk-utils-go` v0.7.0 already supports differentiated TTL (defaults to no expiration), so summaries survive indefinitely
394394

395395
**What's already in place**:
396-
- `adk-utils-go` v0.5.0 has full tier support (`app:`, `user:`, `temp:`) with independent TTLs for app/user state (default: no expiration, matching canonical ADK DatabaseService behaviour)
397-
- ContextGuard state keys are simple string constants in `server/plugin/contextguard/contextguard.go` — adding the prefix is a one-line change per key
396+
- `adk-utils-go` v0.7.0 has full tier support (`app:`, `user:`, `temp:`) with independent TTLs for app/user state (default: no expiration, matching canonical ADK DatabaseService behaviour)
397+
- ContextGuard state keys are simple string constants in `adk-utils-go/plugin/contextguard/contextguard.go` — adding the prefix is a one-line change per key
398398
- The Redis session service stores `user:` state in a dedicated HASH (`userstate:{appName}:{userID}`) separate from session data
399399

400400
**Cross-client identity (future)**:
401401
If a single person uses Discord AND Telegram, they'd have two `userID`s and two separate summaries — which is actually correct (different conversational contexts). True cross-client identity (linking `discord_123` and `telegram_456` as the same person) is a separate, larger problem.
402402

403-
**Modify**: `server/plugin/contextguard/contextguard.go`, `server/clients/telegram/bot.go`, `server/clients/slack/bot.go`, `server/clients/discord/bot.go`, `server/clients/executor.go`
403+
**Modify**: `adk-utils-go/plugin/contextguard/contextguard.go` (state key prefixes), `server/clients/telegram/bot.go`, `server/clients/slack/bot.go`, `server/clients/discord/bot.go`, `server/clients/executor.go`
404404

405405
---
406406

frontend/admin-ui/src/views/agents/AgentDialog.vue

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,32 @@
5858
</div>
5959
</div>
6060

61+
<!-- LLM Headers -->
62+
<div>
63+
<FormLabel label="Headers" />
64+
<div class="space-y-2">
65+
<div v-for="(h, i) in form.llmHeaders" :key="i" class="flex gap-2 items-center">
66+
<input
67+
v-model="h.key"
68+
placeholder="anthropic-beta"
69+
class="flex-1 bg-piedra-800 border border-piedra-700 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-sol-500 focus:border-sol-500 outline-none"
70+
/>
71+
<input
72+
v-model="h.value"
73+
placeholder="context-1m-2025-08-07"
74+
class="flex-[2] bg-piedra-800 border border-piedra-700 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-sol-500 focus:border-sol-500 outline-none"
75+
/>
76+
<button @click="form.llmHeaders.splice(i, 1)" class="p-1.5 hover:bg-piedra-800 rounded-lg text-arena-400 hover:text-lava-400 flex-shrink-0" title="Remove header">
77+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 6L6 18M6 6l12 12"/></svg>
78+
</button>
79+
</div>
80+
<button @click="form.llmHeaders.push({ key: '', value: '' })" class="text-xs text-sol-400 hover:text-sol-500 transition-colors">
81+
+ Add header
82+
</button>
83+
</div>
84+
<p class="text-[10px] text-arena-500 mt-1">Extra HTTP headers for this agent's LLM requests. Override backend-level headers.</p>
85+
</div>
86+
6187
<!-- Context Guard -->
6288
<div class="border-t border-piedra-700/30 pt-3">
6389
<div class="flex items-center justify-between">
@@ -234,6 +260,7 @@ const form = reactive({
234260
systemPrompt: '',
235261
llmBackend: '',
236262
llmModel: '',
263+
llmHeaders: [],
237264
mcpServers: [],
238265
skills: [],
239266
tags: [],
@@ -250,6 +277,20 @@ const form = reactive({
250277
a2aEnabled: false,
251278
})
252279
280+
function headersToList(obj) {
281+
if (!obj || !Object.keys(obj).length) return []
282+
return Object.entries(obj).map(([key, value]) => ({ key, value }))
283+
}
284+
285+
function listToHeaders(list) {
286+
const obj = {}
287+
for (const h of list) {
288+
const k = h.key.trim()
289+
if (k) obj[k] = h.value
290+
}
291+
return Object.keys(obj).length ? obj : undefined
292+
}
293+
253294
function toggleMcp(id) {
254295
const idx = form.mcpServers.indexOf(id)
255296
if (idx === -1) form.mcpServers.push(id)
@@ -283,6 +324,7 @@ function open(agent = null) {
283324
form.systemPrompt = agent?.systemPrompt || ''
284325
form.llmBackend = agent?.llm?.backend || ''
285326
form.llmModel = agent?.llm?.model || ''
327+
form.llmHeaders = headersToList(agent?.llm?.headers)
286328
form.mcpServers = [...(agent?.mcpServers || [])]
287329
form.skills = [...(agent?.skills || [])]
288330
form.tags = [...(agent?.tags || [])]
@@ -306,7 +348,7 @@ async function save() {
306348
description: form.description.trim(),
307349
outputKey: form.outputKey.trim(),
308350
systemPrompt: form.systemPrompt.trim(),
309-
llm: { backend: form.llmBackend, model: form.llmModel.trim() },
351+
llm: { backend: form.llmBackend, model: form.llmModel.trim(), headers: listToHeaders(form.llmHeaders) },
310352
transcription: { backend: form.transcriptionBackend, model: form.transcriptionModel.trim() },
311353
tts: {
312354
backend: form.ttsBackend,

frontend/admin-ui/src/views/backends/BackendDialog.vue

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,30 @@
2121
<FormLabel label="API Key" />
2222
<FormInput v-model="form.apiKey" type="password" placeholder="sk-..." />
2323
</div>
24+
<div>
25+
<FormLabel label="Headers" />
26+
<div class="space-y-2">
27+
<div v-for="(h, i) in form.headers" :key="i" class="flex gap-2 items-center">
28+
<input
29+
v-model="h.key"
30+
placeholder="Authorization"
31+
class="flex-1 bg-piedra-800 border border-piedra-700 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-sol-500 focus:border-sol-500 outline-none"
32+
/>
33+
<input
34+
v-model="h.value"
35+
placeholder="Bearer sk-..."
36+
class="flex-[2] bg-piedra-800 border border-piedra-700 rounded-lg px-3 py-1.5 text-sm focus:ring-1 focus:ring-sol-500 focus:border-sol-500 outline-none"
37+
/>
38+
<button @click="form.headers.splice(i, 1)" class="p-1.5 hover:bg-piedra-800 rounded-lg text-arena-400 hover:text-lava-400 flex-shrink-0" title="Remove header">
39+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><path d="M18 6L6 18M6 6l12 12"/></svg>
40+
</button>
41+
</div>
42+
<button @click="form.headers.push({ key: '', value: '' })" class="text-xs text-sol-400 hover:text-sol-500 transition-colors">
43+
+ Add header
44+
</button>
45+
</div>
46+
<p class="text-[10px] text-arena-500 mt-1">Extra HTTP headers sent with every request to this backend. Agent-level headers override these.</p>
47+
</div>
2448
</div>
2549
</AppDialog>
2650
</template>
@@ -44,20 +68,38 @@ const form = reactive({
4468
type: 'openai',
4569
url: '',
4670
apiKey: '',
71+
headers: [],
4772
})
4873
74+
function headersToList(obj) {
75+
if (!obj || !Object.keys(obj).length) return []
76+
return Object.entries(obj).map(([key, value]) => ({ key, value }))
77+
}
78+
79+
function listToHeaders(list) {
80+
const obj = {}
81+
for (const h of list) {
82+
const k = h.key.trim()
83+
if (k) obj[k] = h.value
84+
}
85+
return Object.keys(obj).length ? obj : undefined
86+
}
87+
4988
function open(backend = null) {
5089
isEdit.value = !!backend
5190
editId.value = backend?.id || null
5291
form.name = backend?.name || ''
5392
form.type = backend?.type || 'openai'
5493
form.url = backend?.url || ''
5594
form.apiKey = backend?.apiKey || ''
95+
form.headers = headersToList(backend?.headers)
5696
dialogRef.value?.open()
5797
}
5898
5999
async function save() {
60100
const data = { name: form.name, type: form.type, url: form.url, apiKey: form.apiKey }
101+
const headers = listToHeaders(form.headers)
102+
if (headers) data.headers = headers
61103
try {
62104
if (isEdit.value) {
63105
await backendsApi.update(editId.value, data)

frontend/admin-ui/src/views/conversations/ConversationsList.vue

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,10 @@
6060
<option value="">All sources</option>
6161
<option value="voice-ui">Voice UI</option>
6262
<option value="telegram">Telegram</option>
63+
<option value="discord">Discord</option>
6364
<option value="slack">Slack</option>
64-
<option value="executor">Executor</option>
65+
<option value="webhook">Webhook</option>
66+
<option value="cron">Cron</option>
6567
<option value="flow">Flow</option>
6668
<option value="direct">Direct</option>
6769
</select>
@@ -297,6 +299,8 @@ function sourceIcon(source) {
297299
const map = {
298300
'voice-ui': 'phone',
299301
telegram: 'phone',
302+
discord: 'chat',
303+
slack: 'chat',
300304
executor: 'command',
301305
flow: 'flow',
302306
direct: 'phone',
@@ -310,6 +314,8 @@ function sourceBg(source) {
310314
const map = {
311315
'voice-ui': 'bg-teal-500/15',
312316
telegram: 'bg-atlantico-500/15',
317+
discord: 'bg-violet-500/15',
318+
slack: 'bg-emerald-500/15',
313319
executor: 'bg-indigo-500/15',
314320
flow: 'bg-rose-500/15',
315321
direct: 'bg-teal-500/15',
@@ -323,6 +329,8 @@ function sourceText(source) {
323329
const map = {
324330
'voice-ui': 'text-teal-400',
325331
telegram: 'text-atlantico-400',
332+
discord: 'text-violet-400',
333+
slack: 'text-emerald-400',
326334
executor: 'text-indigo-400',
327335
flow: 'text-rose-400',
328336
direct: 'text-teal-400',
@@ -336,6 +344,8 @@ function formatSource(source) {
336344
const map = {
337345
'voice-ui': 'Voice UI',
338346
telegram: 'Telegram',
347+
discord: 'Discord',
348+
slack: 'Slack',
339349
executor: 'Executor',
340350
flow: 'Flow',
341351
direct: 'Direct',

0 commit comments

Comments
 (0)