Skip to content

Commit 0160797

Browse files
authored
Merge pull request #37 from achetronic/feature/always-artifacts
Feature/always artifacts
2 parents 65f0e35 + 61cb105 commit 0160797

29 files changed

Lines changed: 1265 additions & 267 deletions

File tree

‎.agents/AGENTS.md‎

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ magec/
5454
│ │ │ ├── voice.go # Voice provider types endpoint
5555
│ │ │ └── docs/ # Generated swagger
5656
│ │ └── user/ # User-facing REST API
57-
│ │ ├── handlers.go # Health, ClientInfo, Voice, Webhook swagger types
57+
│ │ ├── handlers.go # Health, ClientInfo, Voice, Webhook, EphemeralArtifact swagger types
5858
│ │ ├── doc.go # Swagger metadata
5959
│ │ ├── a2a_swagger.go # A2A swagger documentation stubs
6060
│ │ ├── adk_swagger.go # ADK REST API swagger documentation stubs
@@ -160,6 +160,7 @@ magec/
160160
| GET/POST | `/api/v1/agent/*` | ADK REST API (sessions, run, events) |
161161
| POST | `/api/v1/agent/run_sse` | Run agent (SSE streaming) — all clients use this |
162162
| POST | `/api/v1/webhooks/{clientId}` | Webhook endpoint — Bearer token auth |
163+
| GET | `/api/v1/ephemeral/artifacts/{token}` | Download an artifact's raw bytes via a short-lived signed URL |
163164
| POST | `/api/v1/voice/{agentId}/speech` | TTS proxy (per-agent backend) |
164165
| POST | `/api/v1/voice/{agentId}/transcription` | STT proxy (per-agent backend) |
165166
| WebSocket | `/api/v1/voice/events` | Voice events stream (wake word + VAD) |
@@ -186,7 +187,7 @@ magec/
186187
| | **Commands** | CRUD: `/commands`, `/commands/{id}` |
187188
| | **Flows** | CRUD: `/flows`, `/flows/{id}` |
188189
| | **Secrets** | CRUD: `/secrets`, `/secrets/{id}` (GET never returns value) |
189-
| | **Settings** | GET/PUT: `/settings` (global memory provider selection) |
190+
| | **Settings** | GET/PUT: `/settings` (global memory provider selection + `temporaryDir` for transient on-disk files) |
190191
| | **Conversations** | `/conversations`, `/conversations/{id}`, `/conversations/clear`, `/conversations/stats`, `/conversations/{id}/summary`, `/conversations/{id}/pair`, `/conversations/{id}/reset-session` |
191192
| | **Backup** | GET `/settings/backup`, POST `/settings/restore` (tar.gz of data/) |
192193
| | **Voice** | GET `/voice/types` (registered voice providers with JSON Schemas) |
@@ -236,7 +237,7 @@ log:
236237
- **MCP headers/TLS**: `MCPServer` struct has `Headers map[string]string` and `Insecure bool`. `httpClientForMCP()` creates transport with optional `InsecureSkipVerify`
237238
- **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}/`
238239
- **Encryption key**: `server.encryptionKey` in config.yaml. Independent from `adminPassword`. Used to encrypt secrets at rest (AES-256-GCM, PBKDF2-derived)
239-
- **ContextGuard plugin**: Externalized to `adk-utils-go/plugin/contextguard` (v0.13.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
240+
- **ContextGuard plugin**: Externalized to `adk-utils-go/plugin/contextguard` (v0.15.2). 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
240241
- **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
241242
- **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
242243
- **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
@@ -245,7 +246,8 @@ log:
245246
- **InstructionProvider pattern**: Agents use `InstructionProvider` instead of static `Instruction` strings to bypass ADK's built-in `{variable}` substitution (which conflicts with curly braces in prompts, JSON examples, scripts, etc). Magec resolves its own `{{agent.output:key}}` pattern from session state inside the provider. Plain `{text}` in prompts is never touched
246247
- **Flow wrapAgent pattern**: Same agent can appear in multiple flow steps — `wrapAgent()` creates uniquely-named delegate agents to satisfy ADK's single-parent constraint
247248
- **Voice provider registry**: TTS/STT proxies dispatch to per-backend-type providers via `voice.Get(backend.Type)`. Same pattern as clients/memory — `init()` + blank imports. OpenAI provider handles `/v1/audio/speech` and `/v1/audio/transcriptions`. Gemini provider translates to `generateContent` with `speechConfig`/`inlineData`. `TTSRef.Config` and `BackendRef.Config` use typed structs (`TTSConfig`, `STTConfig`) with per-provider namespaces matching the `ClientConfig` pattern (e.g. `config.openai.speed`, `config.gemini.languageCode`). `GET /voice/types` returns JSON Schemas per provider. Store migration moves legacy `tts.speed` → `tts.config.openai.speed` and flat config fields → `tts.config.gemini.*`
248-
- **Input artifact offloading**: User-uploaded files smaller than `msgutil.DefaultInlineThreshold` (1 MiB) ride inline in the `/run_sse` body as `inlineData` parts. Larger files are persisted through the ADK `artifact.Service` and replaced in the prompt with a `MAGEC_ATTACHED_ARTIFACTS` block telling the model to call `load_artifact` on demand. Reuses the universal artifact toolset (decision #17). The service is injected per-client through `SetArtifactService` and sourced from `agentRouterHandler.ArtifactService()` so it tracks store rebuilds. Helpers in `server/clients/msgutil/attachments.go`: `ShouldInline`, `InlinePart`, `StoreAsArtifact`, `AttachedArtifactsBlock` — each client runs its own short loop over platform-specific attachment types.
249+
- **Input artifact offloading**: User-uploaded files are persisted through the ADK `artifact.Service` and replaced in the prompt with a `MAGEC_ATTACHED_ARTIFACTS` block telling the model to call `load_artifact` on demand. Reuses the universal artifact toolset (decision #17). The service is injected per-client through `SetArtifactService` and sourced from `agentRouterHandler.ArtifactService()` so it tracks store rebuilds. Helpers in `server/clients/msgutil/attachments.go`: `StoreAsArtifact`, `AttachedArtifactsBlock` — each client runs its own short loop over platform-specific attachment types.
250+
- **Artifact toolset**: universal via `base_toolset.go` (decision #17). Exposes `save_artifact`, `load_artifact`, `list_artifacts`, `export_artifact` and `get_artifact_url`. `load_artifact` injects the artifact as a native multimodal `*genai.Part` via `ProcessRequest` rather than serialising base64 (decision #25). `export_artifact` writes raw bytes to disk under `Store.ResolveTemporaryDir()` and returns the absolute path so non-artifact-aware tools running in the same container can pick the file up (decision #26). `get_artifact_url` mints a short-lived HMAC-signed URL under `/api/v1/ephemeral/artifacts/{token}` so consumers in other processes/containers can fetch the artifact's raw bytes over HTTP without sharing volumes (decision #27); the tool stays unregistered when `server.encryptionKey` is unset. `Store.ResolveTemporaryDir()` is the single fallback point for `Settings.TemporaryDir` → `os.TempDir()`; nobody else may compute that fallback.
249251

250252
### Design Philosophy
251253

@@ -266,7 +268,7 @@ log:
266268
- **Tailwind v4**: `@tailwindcss/vite` plugin, `@theme` directive for custom colors
267269
- **11 active tabs**: backends, memory, mcps, agents, flows, commands, skills, clients, secrets, conversations, settings
268270
- **Keyboard shortcuts**: `n` (new entity), `r` (refresh), `Cmd+K` (search palette)
269-
- **Settings view**: Global memory provider selection + backup/restore (tar.gz)
271+
- **Settings view**: Runtime (`temporaryDir` for transient files used by tools like `export_artifact`) + backup/restore (tar.gz). Memory provider selection lives in the global settings struct but is not yet exposed in the UI.
270272

271273
### Frontend Conventions (voice-ui)
272274

@@ -318,7 +320,7 @@ GPU section commented out by default. Users who want cloud providers create diff
318320

319321
- `google.golang.org/adk` — Agent Development Kit (v1.0.0)
320322
- `google.golang.org/genai` — Google GenAI SDK (v1.40.0)
321-
- `github.com/achetronic/adk-utils-go` — ADK utilities (v0.13.0): providers, session, memory tools, ContextGuard plugin, Langfuse plugin, artifact filesystem service
323+
- `github.com/achetronic/adk-utils-go` — ADK utilities (v0.15.2): providers, session, memory tools, ContextGuard plugin, Langfuse plugin, artifact filesystem service
322324
- `github.com/a2aproject/a2a-go` — A2A protocol library (v0.3.10)
323325
- `github.com/modelcontextprotocol/go-sdk` — MCP client (v1.4.1)
324326
- `github.com/gorilla/mux` — HTTP router (v1.8.1)
@@ -354,10 +356,11 @@ GPU section commented out by default. Users who want cloud providers create diff
354356
13. **Git branch is `master`**, not `main`. All raw GitHub URLs use `master`.
355357
14. **Go 1.25+, Node 22+, Hugo v0.155+**.
356358
15. **A2A agent card endpoints bypass client auth**: `.well-known/agent-card.json` paths are exempted from `ClientAuth` middleware so external agents can discover cards.
357-
16. **ContextGuard `safeSplitIndex`**: When splitting conversation history for summarization, the split point is adjusted to avoid orphaning Anthropic `tool_result` blocks.
358-
17. **Store env var expansion**: All store fields support `${VAR}` syntax. Secrets are injected as env vars (`os.Setenv`) before the store is expanded, so secrets can be referenced in backend URLs, bot tokens, etc.
359-
18. **Voice API routes always registered**: STT/TTS proxy endpoints are available regardless of Voice UI toggle, since Telegram/Discord/Slack clients need them.
360-
19. **ADK REST API accepts both camelCase and snake_case**: The `SnakeCaseNormalize` middleware converts snake_case keys recursively before ADK sees the request. This applies only to `/run` and `/run_sse` — the only ADK endpoints with multi-word JSON body fields. Session create/get/delete use single-word body fields (`state`, `events`) or path parameters only.
359+
16. **Ephemeral artifact URLs bypass client auth**: `/api/v1/ephemeral/*` paths are exempted from `ClientAuth` because the HMAC-SHA256 signed token in the URL is the credential. Tokens are minted by the `get_artifact_url` tool and verified server-side with `server.encryptionKey`. See decision #27.
360+
17. **ContextGuard `safeSplitIndex`**: When splitting conversation history for summarization, the split point is adjusted to avoid orphaning Anthropic `tool_result` blocks.
361+
18. **Store env var expansion**: All store fields support `${VAR}` syntax. Secrets are injected as env vars (`os.Setenv`) before the store is expanded, so secrets can be referenced in backend URLs, bot tokens, etc.
362+
19. **Voice API routes always registered**: STT/TTS proxy endpoints are available regardless of Voice UI toggle, since Telegram/Discord/Slack clients need them.
363+
20. **ADK REST API accepts both camelCase and snake_case**: The `SnakeCaseNormalize` middleware converts snake_case keys recursively before ADK sees the request. This applies only to `/run` and `/run_sse` — the only ADK endpoints with multi-word JSON body fields. Session create/get/delete use single-word body fields (`state`, `events`) or path parameters only.
361364

362365
## Testing
363366

‎.agents/CLIENT_DESIGN.md‎

Lines changed: 10 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -450,17 +450,14 @@ On `loadFromDisk()`, these migrations run in order (all idempotent):
450450

451451
### Overview
452452

453-
Telegram, Slack and Discord clients accept user-uploaded files and forward them to the ADK `/run_sse` request. The adapter chooses between two paths based on size, using the helpers in `server/clients/msgutil/attachments.go`:
453+
Telegram, Slack and Discord clients accept user-uploaded files and forward them to the ADK `/run_sse` request. The adapter processes them using helpers in `server/clients/msgutil/attachments.go`:
454454

455-
- **Inline** — files at or below `msgutil.DefaultInlineThreshold` (1 MiB) ride in the request body as `inlineData` parts next to the user text. Fastest path, no tool calls required.
456-
- **Artifact** — files above the threshold are persisted through the ADK `artifact.Service` with a session-unique filename (`{messageID}_{originalName}`). The prompt is amended with a `MAGEC_ATTACHED_ARTIFACTS` HTML-comment block listing each file (name, MIME, human size) and instructing the model to call `load_artifact` on demand.
455+
- **Artifact** — files are persisted through the ADK `artifact.Service` with a session-unique filename (`{messageID}_{originalName}`). The prompt is amended with a `MAGEC_ATTACHED_ARTIFACTS` HTML-comment block listing each file (name, MIME, human size) and instructing the model to call `load_artifact` on demand.
457456

458457
See decision #24 in `.agents/DECISIONS.md` for the rationale and "do not" rules.
459458

460459
### Helper primitives (`server/clients/msgutil/attachments.go`)
461460

462-
- `ShouldInline(sizeBytes int) bool` — threshold check.
463-
- `InlinePart(mimeType string, data []byte) map[string]interface{}` — builds the `inlineData` map that ADK serializes to `genai.Part`.
464461
- `StoreAsArtifact(ctx, svc, appName, userID, sessionID, filename, mimeType, data) (descriptor, error)` — persists via `artifact.Service.Save` and returns a single descriptor line meant to be wrapped by `AttachedArtifactsBlock`.
465462
- `AttachedArtifactsBlock(lines []string) string` — wraps descriptor lines in the `MAGEC_ATTACHED_ARTIFACTS` block; returns `""` on empty input so callers can concatenate unconditionally.
466463

@@ -472,19 +469,16 @@ Each client has its own short loop over platform-specific attachment types (`msg
472469
User sends file in Telegram/Slack/Discord
473470
→ Client downloads bytes from platform API (per-platform size cap: 20–25 MiB)
474471
→ For each file:
475-
if ShouldInline(len(data)) || artifacts == nil:
476-
inline = append(inline, InlinePart(mime, data))
477-
else:
478-
descriptor := StoreAsArtifact(ctx, artifacts, agentID, userID, sessionID, name, mime, data)
479-
artifactLines = append(artifactLines, descriptor)
472+
descriptor := StoreAsArtifact(ctx, artifacts, agentID, userID, sessionID, name, mime, data)
473+
artifactLines = append(artifactLines, descriptor)
480474
→ prompt += AttachedArtifactsBlock(artifactLines)
481-
→ POST /run_sse with {parts: [{text: prompt}, ...inline]}
482-
→ LLM either reads inline content or calls load_artifact for the rest
475+
→ POST /run_sse with {parts: [{text: prompt}]}
476+
→ LLM calls load_artifact when it needs to read the file contents
483477
```
484478

485479
### Fallback
486480

487-
When `artifacts == nil` (no agents configured yet, or intentionally unset) the policy inlines everything regardless of size. When a `StoreAsArtifact` call fails (disk error, service down), the client logs a warning and falls back to inline for that file — the user message is still delivered.
481+
When `artifacts == nil` (no agents configured yet, or intentionally unset), the file is dropped with a warning. When a `StoreAsArtifact` call fails (disk error, service down), the client logs a warning and drops that file — the text part of the user message is still delivered.
488482

489483
### Platform specifics
490484

@@ -496,12 +490,11 @@ When `artifacts == nil` (no agents configured yet, or intentionally unset) the p
496490

497491
### Parts serialization
498492

499-
Clients construct parts as raw `map[string]interface{}` (not `genai` types) since they serialize directly to JSON for the HTTP call to ADK. `msgutil.InlinePart` returns exactly this shape, so callers just append its result:
493+
Clients construct parts as raw `map[string]interface{}` (not `genai` types) since they serialize directly to JSON for the HTTP call to ADK:
500494

501495
```go
502-
parts := []interface{}{map[string]string{"text": fullMessage}}
503-
for _, p := range inlineDataParts {
504-
parts = append(parts, p)
496+
parts := []interface{}{
497+
map[string]string{"text": fullMessage},
505498
}
506499
```
507500

0 commit comments

Comments
 (0)