fix(agent-server): persist confirmation policy, analyzer and secrets to meta.json - #4859
fix(agent-server): persist confirmation policy, analyzer and secrets to meta.json#4859alanhuangyoo wants to merge 2 commits into
Conversation
…to meta.json
set_confirmation_policy, set_security_analyzer and update_secrets delegate to the
conversation, which writes base_state.json, and never touch StoredConversation.
Startup reads all three off StoredConversation, so after eviction or a restart
the conversation silently reverts to the values it was created with -- including
its security analyzer and confirmation policy.
The divergence is immediate rather than only on reload: stored is stale in
process as well, so anything reading it before the reload sees the old value too.
in-process confirmation_policy : NeverConfirm (set to AlwaysConfirm)
in-process security_analyzer : None (set to LLMSecurityAnalyzer)
meta.json confirmation_policy : {'kind': 'NeverConfirm'}
after restart : NeverConfirm
All three now write through to meta.json, using model_copy and save_meta as
apply_resume_secrets already does for the resume path. Rollback when save_meta
itself fails is a separate concern tracked in OpenHands#4810's sibling issue and is not
changed here.
update_secrets is typed for SecretValue (str | SecretSource) while
StoredConversation.secrets holds SecretSource, and model_copy does not validate,
so a plain string would have been stored unvalidated and only failed later when
meta.json was serialised. Strings are normalised on the way in.
Secrets keep the serialisation they already had: encrypted with the server's
cipher where one is configured, and where one is not the name persists while the
value loads back as None rather than as the mask placeholder.
|
📁 PR Artifacts Notice This PR contains a |
_get_or_load_event_service returns EventService | None, so pyright rejected reading .stored off it directly.
There was a problem hiding this comment.
Thank you for the PR, @alanhuangyoo ! I think maybe the right solution is rather to read them from base_state.json?
I believe we have another PR working through this… let me find it 🙏
Edit: #4813
|
You are right, and #4813 is the better fix. Closing this. Mine adds a sync; #4813 removes the need for one by taking the three fields off I had reached for the sync because Before closing I checked one thing #4813 depends on and one thing it could have broken, since I had the reproduction already set up. Posting the results on #4813 rather than here. Thanks @enyst. |
HUMAN:
Turned on confirmation prompts and an analyzer through the API, restarted the server, and they were both back to what the conversation started with. They stick now.
AGENT:
Why
Closes #4810.
set_confirmation_policy,set_security_analyzerandupdate_secretsdelegate to the conversation, which writesbase_state.json, and never touchStoredConversation:Startup reads all three off
StoredConversation(event_service.py:1105-1117), so after eviction or a restart the conversation reverts to the values it was created with. For two of the three that means the security analyzer and confirmation policy silently regress to the creation-time setting.It is worse than the issue describes:
storedis stale in process as well, so it is not only a reload problem.Summary
meta.json, viamodel_copy+save_meta— the same shapeapply_resume_secretsalready uses for the resume path, so there is one pattern rather than two.SecretSourcebefore they reachstored.save_metaitself fails is deliberately untouched: that is [Bug]: ConversationService: stored fields mutated in memory before save_meta() with no rollback on failure #4800, and mixing it in here would collide with whoever takes it.Why the normalisation is not incidental
update_secretsis typeddict[str, SecretValue], andSecretValue = str | SecretSource.StoredConversation.secretsisdict[str, SecretSource].model_copy(update=...)does not validate, so merging a raw string in produces a model that looks fine and then fails at the point of writing meta.json:The API route validates through
UpdateSecretsRequest, so the HTTP path never hits this, but the method accepts strings andapply_resume_secretsmerges the same way.test_a_plain_string_secret_is_stored_as_a_secret_sourcepasses a bare string so this cannot regress.How to Test
uv run pytest tests/agent_server/test_conversation_service.py -k "mutation or plain_string" -qFull suite:
ruff checkandruff format --checkclean on both changed files.End-to-end, not just unit tests
Start a conversation, mutate all three through the service, drop the
ConversationServiceentirely, and bring a fresh one up against the same directory:What happens to the secret at rest
I checked this rather than assuming, since the change is what starts routing API secret updates into
meta.json.With a cipher configured, the value is encrypted and round-trips:
{"MY_TOKEN": {"value": "gAAAAABqmvmIuMS90CqPhDCi_Xo-...", "kind": "StaticSecret"}}'shh' in meta.jsonisFalse, and the reloaded source returns'shh'.Without a cipher, the serialiser masks it, so the name persists and the value loads back as
None— not as the**********placeholder, which would otherwise reach a tool call. Both behaviours are whatapply_resume_secretsalready produced; this change does not alter them.Tests
Three added. All three fail on the parent commit:
test_mutations_are_written_to_meta_jsonasserts against the file rather than the object, because meta.json is what startup actually reads.Issue Number
Closes #4810
Type