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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The format is based on Keep a Changelog and the project follows Semantic Version

### Fixed

- **온보딩 마법사 저장이 커스텀 tier를 지우던 문제 (#931)** — `buildConfigPayload`는 `llm_providers`를 디스크 기존값과 병합하면서 `llm_tiers`는 `heavy`/`standard`/`light`만으로 새로 만들어 통째로 반환했다. alias-keyed PATCH가 보낸 값으로 on-disk 집합을 대체하므로, `vision` 같은 커스텀 tier를 쓰는 사용자가 마법사에서 모델 하나만 바꿔 저장해도 그 tier가 설정에서 사라졌다. Config 페이지가 `llm_tiers` 편집을 마법사 딥링크로 넘기면서 이 경로가 일반 사용자에게 열렸다. 이제 tier도 provider와 동일하게 기존값 위에 덮어쓴다.
- **Inspect 뷰가 저장되지 않은 값을 보여주던 문제 (#931)** — Quick Start 밖에서는 Save/Discard 버튼이 숨는데 `getDisplayValue`는 view mode와 무관하게 dirty 값을 우선해서, "Read-only inspection" 배너 아래에 커밋되지 않은 편집이 서버 상태처럼 보였다. 사용자가 반영됐다고 믿고 재시작하면 편집은 사라진다. 이제 Inspect는 서버가 실제로 로드한 값만 보여준다.
- **DESIGN.md가 스스로 모순되던 문제 (#931)** — Quick Start 13개 게이트가 "stay interactive"라고 적힌 줄과, 같은 필드가 read-only가 된다고 적힌 줄이 공존했다. `embodiment_providers_json`의 실제 동작, 자격증명 7개 중 콘솔 입력란이 남은 2개와 YAML 전용이 된 5개(토큰 rotation에 호스트 파일 접근이 필요해진다는 결과 포함), 그리고 편집기 삭제 후 참조가 끊긴 `configStructured.ts` 드래프트 빌더 목록을 명시했다.

- **rolling cache breakpoint가 마커를 조용히 흘리던 문제 (#921)** — 예산은 마킹 가능 여부를 따지기 전에 최신 2개 턴으로 잘려 있어서, 가장 새 턴이 마커를 못 받는 형태(내용이 빈 assistant 메시지, 끝이 `tool_use`인 블록)면 그 슬롯을 더 오래된 턴으로 넘기지 않고 그냥 버렸다. 이제 최신 턴부터 역순으로 훑으며 **실제로 마커가 찍힌 경우에만** 예산을 소모하므로, 마킹 불가능한 턴은 건너뛰고 그 앞 턴이 fallback 자리를 채운다. 아울러 breakpoint 예산 계산에서 실행되지 않던 분기를 걷어내고(`hasSystemBlocks`/`hasTools` bool 두 개 → 예약 슬롯 수 `int` 하나), `anthropicMessageCacheBudget`으로 분리해 예약 수준별로 테스트한다. `cache_control` 리터럴 4곳은 `anthropicEphemeralCacheControl()` 한 곳으로 모았다.

- **프롬프트 캐시가 매 턴 무효화되던 문제 (#920)** — 시스템 프롬프트 첫 줄이 초 단위 wall-clock 타임스탬프라 어떤 프로바이더의 prefix 캐시에도 걸리지 않았고, 정적 본문 전체가 매 턴 write 요금으로 재과금됐다. 시각은 이제 프롬프트 맨 뒤 `## Current Time` 블록으로 내려가 분 단위로 truncate되고, `## Prior Context` 회상도 skills/style/goal/critic 등 세션 고정 섹션 **뒤**로 재배치된다. Anthropic 요청은 system을 블록 배열로 보내 `cache_control` breakpoint를 안정 블록에만 찍으므로 동적 tail이 캐시 prefix를 깨지 않는다. 정렬 규칙은 `prompt.BuildResultFor`에 불변식으로 문서화했다.
Expand Down
5 changes: 3 additions & 2 deletions frontend/console/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ Server schema exposes 165 fields (`internal/config/schema.go`) grouped into 15 s

**(a) Keep UI editing** — onboarding, credentials, session control:

- Quick Start set (13 curated gates in `lib/quickStartFields.ts`): `api_auth_mode`, `llm_providers`, `llm_tiers`, `llm_default_tier`, `workspace_dir`, `telegram_bot_token`, `companion_enabled`, `embodiment_enabled`, `embodiment_providers_json`, `pulse_enabled`, `reflection_enabled`, `log_level`, `session_telegram_scope`. These gate first-run readiness and stay interactive.
- Credential/sensitive fields (masked entry preserved wherever surfaced): `api_auth_token`, `api_user_token`, `api_admin_token`, `memory_embed_api_key`, `tools_web_search_api_key`, `tools_web_search_perplexity_api_key`, `work_scheduler_a2a_bearer_token`.
- Quick Start set (13 curated gates in `lib/quickStartFields.ts`): `api_auth_mode`, `llm_providers`, `llm_tiers`, `llm_default_tier`, `workspace_dir`, `telegram_bot_token`, `companion_enabled`, `embodiment_enabled`, `embodiment_providers_json`, `pulse_enabled`, `reflection_enabled`, `log_level`, `session_telegram_scope`. These gate first-run readiness and stay interactive — **except `embodiment_providers_json`**, which is a `json` field and follows the (c) rule below: its Quick Start card reports readiness but the value is edited in YAML. `llm_providers` / `llm_tiers` stay interactive via the wizard deep link (next bullet), not via a local editor.
- Credential/sensitive fields render masked and never echo a stored secret back. Only two still have a console input, both in the wizard: `memory_embed_api_key` (`OnboardingIntegrations.svelte`) and `tools_web_search_api_key` (`OnboardingTools.svelte`). The rest — `api_auth_token`, `api_user_token`, `api_admin_token`, `tools_web_search_perplexity_api_key`, `work_scheduler_a2a_bearer_token` — are read-only in the console and rotated in YAML under the (c) policy. This is a deliberate consequence of the file-first cut, and it means **token rotation requires filesystem access to the host**; a console-only operator (remote/Tailscale) cannot rotate them. Revisit as a follow-up if that proves too strict.
- Structured LLM provider/tier editing stays in the console product — but lives in the onboarding wizard reentry (`/console/onboarding?reentry=1&section=provider|tiers`), which already implements alias-replace saves with masked-key preservation. `Config.svelte` links there instead of hosting duplicate editors.
- `SessionConfigPanel.svelte` (session-scoped tools/skills/commands/MCP allowlists, automation consent, style controls) is session/cwd control — core purpose. Unchanged.

Expand All @@ -294,6 +294,7 @@ Server schema exposes 165 fields (`internal/config/schema.go`) grouped into 15 s

- All four heavyweight modal editors: generic JSON editor, LLM tier editor, LLM provider editor, embodiment provider preset editor.
- Consequence table: `llm_providers` / `llm_tiers` → deep link to onboarding wizard sections (capability preserved). `embodiment_providers_json`, `llm_role_defaults`, `usage_price_overrides_json`, `mcp_servers_json`, `agentruntime_agents_json`, `agentruntime_task_override`, and every other `json`/`string_list` field → read-only summary plus documented YAML key (`config/tars.config.example.yaml`). If a dedicated UI for embodiment presets proves necessary later, it should be a follow-up issue scoped against this policy.
- **Retained but currently unreferenced:** deleting the modal editors left `lib/configStructured.ts`'s draft builders (`makeLLMTierDrafts`, `buildLLMTiersFromDrafts`, `makeLLMProviderDrafts`, `buildLLMProvidersFromDrafts`, `makeEmbodimentProviderDrafts`, `makeEmbodimentProviderPresetDraft`, `buildEmbodimentProvidersFromDrafts`) and their constant tables with no component consumer — `Config.svelte` imports only the four display helpers. They are kept, still unit-tested, for the embodiment-preset follow-up named above; the client wrappers `saveConfig` and `getProviders` in `lib/api/config.ts` are unreferenced for the same reason. Treat this list as the inventory to delete if that follow-up is declined, rather than as code to quietly reuse.

### Shared form primitives (#931 first cut: deferred)

Expand Down
5 changes: 5 additions & 0 deletions frontend/console/src/components/Config.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,12 @@
selectField(field, target.value)
}

// Uncommitted edits are only shown where the user can act on them. Inspect
// reports what the server actually loaded (DESIGN.md), and it hides
// Save/Discard, so surfacing a dirty value there reads as committed state
// and invites the user to restart believing an edit landed.
function getDisplayValue(field: ConfigFieldMeta): unknown {
if (!shouldShowFieldActions) return values[field.key]
return dirtyFields[field.key] !== undefined ? dirtyFields[field.key] : values[field.key]
}

Expand Down
3 changes: 2 additions & 1 deletion frontend/console/src/components/Onboarding.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,8 @@
try {
const existingProviders =
(configValues.llm_providers as Record<string, unknown>) || {}
await patchConfigValues(buildConfigPayload(form, existingProviders))
const existingTiers = (configValues.llm_tiers as Record<string, unknown>) || {}
await patchConfigValues(buildConfigPayload(form, existingProviders, existingTiers))
} catch (err) {
saveError = (err as Error).message || 'failed to save config'
restartPhase = 'idle'
Expand Down
10 changes: 9 additions & 1 deletion frontend/console/src/lib/onboarding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,14 @@ export function validateForm(form: OnboardingFormState, allKnownAliases?: string
// every existing alias here, then overlay the wizard's currently
// edited alias on top. previousAlias (when the user renamed) is
// removed so the rename actually takes effect on disk.
// existingTiers is the on-disk tier map and exists for the same reason: the
// wizard only knows heavy/standard/light, but `llm_tiers` may legitimately
// carry custom tiers (schema.go documents "heavy/standard/light or custom
// tiers"). Emitting only the three required ones would delete the rest.
export function buildConfigPayload(
form: OnboardingFormState,
existingProviders: Record<string, unknown> = {},
existingTiers: Record<string, unknown> = {},
): Record<string, unknown> {
const provider: Record<string, unknown> = {
kind: form.provider.kind,
Expand All @@ -260,7 +265,10 @@ export function buildConfigPayload(
provider.base_url = form.provider.base_url.trim()
}

const tiers: Record<string, Record<string, unknown>> = {}
const tiers: Record<string, unknown> = {}
for (const [key, value] of Object.entries(existingTiers)) {
tiers[key] = value
}
for (const tier of requiredTiers) {
const binding = form.tiers[tier]
const entry: Record<string, unknown> = {
Expand Down
22 changes: 22 additions & 0 deletions frontend/console/tests/onboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,28 @@ test('buildConfigPayload shapes a clean PATCH updates map', () => {
})
})

// The alias-keyed PATCH replaces the on-disk tier set with whatever the patch
// sends, exactly as it does for providers. Config.svelte now deep links here
// for llm_tiers editing, so dropping unknown tiers would silently delete them.
test('buildConfigPayload preserves custom tiers already on disk', () => {
const form = emptyOnboardingForm()
form.provider.alias = 'openai'
form.provider.kind = 'openai'
form.provider.api_key = 'sk-test'
for (const tier of ['heavy', 'standard', 'light'] as const) {
form.tiers[tier].provider = 'openai'
form.tiers[tier].model = 'gpt-5.4'
}

const payload = buildConfigPayload(form, {}, {
heavy: { provider: 'openai', model: 'stale' },
vision: { provider: 'gemini', model: 'gemini-3-pro' },
}) as { llm_tiers: Record<string, unknown> }

assert.deepEqual(payload.llm_tiers.vision, { provider: 'gemini', model: 'gemini-3-pro' })
assert.deepEqual(payload.llm_tiers.heavy, { provider: 'openai', model: 'gpt-5.4' })
})

test('buildConfigPayload omits empty optional fields', () => {
const form = emptyOnboardingForm()
form.provider.alias = 'codex'
Expand Down
Loading