|
| 1 | +# Custom Agent Model Merge Contract |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +`PUT /api/custom-agents/{agent_id}` and `POST /api/custom-agents` currently |
| 6 | +treat omitted model fields as empty strings because the request payload uses |
| 7 | +`String` fields with `#[serde(default)]`. That collapses two different client |
| 8 | +intents: |
| 9 | + |
| 10 | +- field absent: keep the stored value when updating an existing agent |
| 11 | +- field present with `""`: replace the stored value with provider default |
| 12 | + |
| 13 | +The same endpoint already has merge semantics for adjacent optional fields such |
| 14 | +as provider environment, default workspace, and avatar data. Model selection, |
| 15 | +reasoning effort, and service tier should use the same contract. |
| 16 | + |
| 17 | +## Contract |
| 18 | + |
| 19 | +For custom-agent upsert requests: |
| 20 | + |
| 21 | +| Request state | Existing agent update | New agent create | |
| 22 | +| --- | --- | --- | |
| 23 | +| `model` absent | keep stored `model` | store `""` | |
| 24 | +| `model: "x"` | store `"x"` after trimming | store `"x"` after trimming | |
| 25 | +| `model: ""` | store `""` (provider default) | store `""` | |
| 26 | + |
| 27 | +`model_reasoning_effort` and `model_service_tier` follow the same rules. The |
| 28 | +camelCase aliases `modelReasoningEffort` and `modelServiceTier` remain accepted. |
| 29 | + |
| 30 | +Stored `CustomAgentProfile` fields stay as `String`; the tri-state behavior is |
| 31 | +only a request-layer concern. |
| 32 | + |
| 33 | +## Design |
| 34 | + |
| 35 | +### Gateway |
| 36 | + |
| 37 | +- Change `CustomAgentUpsertPayload.model`, `model_reasoning_effort`, and |
| 38 | + `model_service_tier` from `String` to `Option<String>`. |
| 39 | +- Change `UpsertCustomAgentRequest` the same way. |
| 40 | +- In `CustomAgentStore::upsert_agent`, resolve each field with one helper: |
| 41 | + - `Some(value)` trims and stores the value, including `""`. |
| 42 | + - `None` keeps the existing profile value. |
| 43 | + - `None` on create stores `""`. |
| 44 | +- Keep built-in-agent modification rejection before persisting changes. |
| 45 | +- Keep provider environment, auth, workspace, avatar, native config, and prompt |
| 46 | + behavior unchanged. |
| 47 | + |
| 48 | +### CLI |
| 49 | + |
| 50 | +- Stop serializing `model`, `model_reasoning_effort`, and |
| 51 | + `model_service_tier` when the caller omitted the matching option. This lets |
| 52 | + the gateway preserve existing values on update. |
| 53 | +- Keep create behavior unchanged: omitted keys create empty stored values, which |
| 54 | + means provider defaults. |
| 55 | +- Add `--clear-model` to `garyx agent update` and `garyx agent upsert`; it sends |
| 56 | + `model: ""` explicitly and conflicts with `--model`. |
| 57 | +- Keep existing explicit-empty support for reasoning effort and service tier, |
| 58 | + because passing `--model-reasoning-effort ""` or `--model-service-tier ""` |
| 59 | + still sends an explicit replacement value. |
| 60 | +- Update help text so omission on update/upsert is described as preserve, while |
| 61 | + omission on create remains provider default. |
| 62 | + |
| 63 | +### Mobile |
| 64 | + |
| 65 | +`GaryxCustomAgentRequest` already has optional request fields, so its shape does |
| 66 | +not change. The mobile create/edit flows must send model and reasoning effort |
| 67 | +values whenever the user is saving those controls: |
| 68 | + |
| 69 | +- create: send the trimmed `model` and `model_reasoning_effort` strings even |
| 70 | + when they are empty |
| 71 | +- update: send the next model, next reasoning effort, and preserved service tier |
| 72 | + strings even when they are empty |
| 73 | + |
| 74 | +This keeps "reset to Provider default" working under the new absent-as-preserve |
| 75 | +contract. |
| 76 | + |
| 77 | +Desktop already sends model values from required controls, so no desktop change |
| 78 | +is planned. |
| 79 | + |
| 80 | +## Tradeoffs |
| 81 | + |
| 82 | +- Using `Option<String>` at the request boundary is more explicit than adding a |
| 83 | + separate clear flag to the API. JSON already has a native absent-vs-present |
| 84 | + distinction, and existing optional fields use that pattern. |
| 85 | +- Keeping stored fields as `String` avoids a storage migration and preserves |
| 86 | + the existing provider-default representation. |
| 87 | +- The CLI grows only the missing `--clear-model` affordance. Empty string |
| 88 | + values remain supported for the other two fields to avoid a broader CLI flag |
| 89 | + expansion. |
| 90 | + |
| 91 | +## Rollout/compatibility |
| 92 | + |
| 93 | +The new gateway treats absent model fields as preserve. Older iOS builds send |
| 94 | +`nil` for empty model controls, so those builds temporarily lose the ability to |
| 95 | +reset an agent model or reasoning effort to Provider default after the gateway |
| 96 | +change ships. They can still preserve existing settings. |
| 97 | + |
| 98 | +Ship the gateway and iOS app changes together so mobile regains explicit |
| 99 | +empty-string clears. Desktop sends concrete model values from required controls, |
| 100 | +and the CLI change adds an explicit clear path, so desktop and CLI are not |
| 101 | +affected by this compatibility window. |
| 102 | + |
| 103 | +## Validation |
| 104 | + |
| 105 | +- Gateway store tests: |
| 106 | + - update without model fields preserves existing model settings |
| 107 | + - update with `Some("")` clears model settings |
| 108 | + - update with `Some("x")` replaces model settings |
| 109 | + - create without model fields stores empty strings |
| 110 | +- Gateway build coverage: `cargo build` |
| 111 | +- Focused gateway tests: `cargo test -p garyx-gateway custom_agents` |
| 112 | +- CLI tests: |
| 113 | + - update without model options omits the three keys |
| 114 | + - update with `--clear-model` sends `model: ""` |
| 115 | +- CLI end-to-end check against a real persisted agent: dump current agent, |
| 116 | + update without `--model`, and confirm the stored model is preserved. |
| 117 | +- Mobile core test: `GaryxCustomAgentRequest(model: "")` encodes a present |
| 118 | + `"model": ""` key. |
| 119 | +- iOS app target build: run `xcodebuild` for the app target because SwiftPM |
| 120 | + tests do not cover app-target wiring. |
0 commit comments