@agentsdock/server 1.0.8 → 1.0.10-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1112 -2102
- package/server/codex_history_repair.py +53 -8
- package/server/codex_provider.py +52 -2
- package/server/codex_response_stream.py +229 -0
- package/server/codex_side_chat_progress.py +110 -0
- package/server/codex_side_question.py +45 -5
- package/server/install.sh +121 -8
- package/server/instances.sh +0 -0
- package/server/side_questions.py +128 -31
package/server/install.sh
CHANGED
|
@@ -1839,7 +1839,7 @@ if [[ "$TEAM_HUB_MODE" == "host" && "$TEAM_HUB_OPERATION_PENDING" != "true" \
|
|
|
1839
1839
|
fi
|
|
1840
1840
|
fi
|
|
1841
1841
|
|
|
1842
|
-
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1842
|
+
RELEASE_FILES=(activation_transaction.py execution_activation.py execution_legacy_runner.py execution_preparation.py update_preparation.py update_handoff.py update_recovery.py execution_update_status.py execution_uninstall.py execution_recovery.py execution_recovery_status.py execution_http.py execution_durability.py execution_control.py execution_install.py execution_maintenance.py execution_manage.py execution_ownership.py execution_service.py execution_transport.py agent_server.py local_session_ownership.py cursor_history.py workspace_git.py team_hub_host.py secure_peer_runtime.py team_mail_runtime.py team_mail_websocket.py team_mail_grants.py secure_peer_delivery.py agentsdock_jobs.py agentsdock_chats.py chat_mailbox.py agentsdock_emergency.py agentsdock_publish.py agentsdock_mail.py agentsdock_team.py provider_commands.py provider_usage.py claude_sdk_client.py claude_model_catalog.py claude_goals.py claude_background_reconciliation.py codex_app_server.py codex_auth.py codex_provider.py codex_response_stream.py provider_connections.py cursor_api_key.py side_questions.py title_generation.py codex_side_question.py codex_side_chat_progress.py claude_side_question.py cursor_agent_client.py cursor_provider_mcp.py opencode_agent_client.py cursor_process_guard.py claude_history_repair.py claude_history_provenance.py codex_history_repair.py public_chat_shares.py public_chat_transcript.py public_chat_share_routes.py interactive_chat_shares.py interactive_chat_share_routes.py interactive_chat_share_web.py interactive_chat_projection.py interactive_chat_runtime.py interactive_chat_native.py shared_chat_videos.py shared_chat_video_stream.py interactive_chat_controls.py install.sh uninstall.sh instances.sh server_instances.py update_runner.py pyproject.toml uv.lock VERSION release-public-key.pem LICENSE NOTICE)
|
|
1843
1843
|
RELEASE_DIRECTORIES=(agentsdock_team_hub)
|
|
1844
1844
|
TEAM_HUB_RELEASE_FILES=(
|
|
1845
1845
|
__init__.py
|
|
@@ -3055,21 +3055,37 @@ install_lock_control() {
|
|
|
3055
3055
|
local action="$1"
|
|
3056
3056
|
local expected_device="${2:-0}"
|
|
3057
3057
|
local expected_inode="${3:-0}"
|
|
3058
|
+
local wait_for_prepared="false"
|
|
3059
|
+
# Old managed preparers do not pass a managed-update ID. The existing pinned
|
|
3060
|
+
# preparation/activation modes may serialize briefly; this grants no update
|
|
3061
|
+
# authority and leaves ordinary installation and recovery fail-fast.
|
|
3062
|
+
if [[ "$action" == "acquire" && "${RECOVER_ONLY:-false}" != "true" \
|
|
3063
|
+
&& "${RECOVER_UNARMED_ONLY:-false}" != "true" \
|
|
3064
|
+
&& ( "${PREPARE_ONLY:-false}" == "true" || -n "${ACTIVATE_PREPARED:-}" ) \
|
|
3065
|
+
&& "${PREPARED_ARCHIVE_SHA256:-}" =~ ^[0-9a-f]{64}$ \
|
|
3066
|
+
&& "${EXPECTED_API_CONTRACT:-}" =~ ^[1-9][0-9]*$ \
|
|
3067
|
+
&& -n "${RELEASE_VERSION:-}" ]]; then
|
|
3068
|
+
wait_for_prepared="true"
|
|
3069
|
+
fi
|
|
3058
3070
|
local python_path=""
|
|
3059
3071
|
python_path="$(install_lock_python)" || return 1
|
|
3060
3072
|
"$python_path" - \
|
|
3061
|
-
"$action" "$INSTALL_ROOT" "$$" "$expected_device" "$expected_inode" <<'PY'
|
|
3073
|
+
"$action" "$INSTALL_ROOT" "$$" "$expected_device" "$expected_inode" "$wait_for_prepared" <<'PY'
|
|
3062
3074
|
import errno
|
|
3063
3075
|
import os
|
|
3064
3076
|
from pathlib import Path
|
|
3065
3077
|
import secrets
|
|
3078
|
+
import signal
|
|
3066
3079
|
import stat
|
|
3067
3080
|
import sys
|
|
3081
|
+
import time
|
|
3068
3082
|
|
|
3069
|
-
action, raw_root, raw_pid, raw_device, raw_inode = sys.argv[1:]
|
|
3083
|
+
action, raw_root, raw_pid, raw_device, raw_inode, raw_wait = sys.argv[1:]
|
|
3070
3084
|
root = Path(os.path.abspath(raw_root))
|
|
3071
3085
|
pid = int(raw_pid)
|
|
3072
3086
|
lock = root / ".install-lock"
|
|
3087
|
+
PREPARED_LOCK_WAIT_SECONDS = 30.0
|
|
3088
|
+
wait_for_prepared = action == "acquire" and raw_wait == "true"
|
|
3073
3089
|
|
|
3074
3090
|
root_info = root.lstat()
|
|
3075
3091
|
if (
|
|
@@ -3145,7 +3161,56 @@ def read_owner(directory_descriptor: int) -> tuple[int, tuple[int, int]]:
|
|
|
3145
3161
|
return int(value), (int(info.st_dev), int(info.st_ino))
|
|
3146
3162
|
|
|
3147
3163
|
|
|
3164
|
+
def check_wait_root() -> None:
|
|
3165
|
+
current = root.lstat()
|
|
3166
|
+
if ((current.st_dev, current.st_ino) != (root_info.st_dev, root_info.st_ino)
|
|
3167
|
+
or not stat.S_ISDIR(current.st_mode) or current.st_uid != os.geteuid()
|
|
3168
|
+
or stat.S_IMODE(current.st_mode) & 0o022):
|
|
3169
|
+
raise PermissionError("AgentsServer install root changed while waiting")
|
|
3170
|
+
|
|
3171
|
+
|
|
3172
|
+
def same_wait_owner(directory_descriptor, directory_info, owner_pid, owner_identity) -> bool:
|
|
3173
|
+
check_wait_root()
|
|
3174
|
+
current = lock.lstat()
|
|
3175
|
+
if ((current.st_dev, current.st_ino) != (directory_info.st_dev, directory_info.st_ino)):
|
|
3176
|
+
return False
|
|
3177
|
+
if (not stat.S_ISDIR(current.st_mode) or current.st_uid != os.geteuid()
|
|
3178
|
+
or stat.S_IMODE(current.st_mode) & 0o077):
|
|
3179
|
+
raise PermissionError("AgentsServer install lock directory is unsafe")
|
|
3180
|
+
return read_owner(directory_descriptor) == (owner_pid, owner_identity)
|
|
3181
|
+
|
|
3182
|
+
|
|
3183
|
+
def cleanup_wait_candidate(path, expected) -> None:
|
|
3184
|
+
# Cancellation while waiting may remove only our still-unpublished draft.
|
|
3185
|
+
# An acquired-but-unreported lock is left for ordinary dead-owner reaping.
|
|
3186
|
+
try:
|
|
3187
|
+
check_wait_root()
|
|
3188
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_DIRECTORY)
|
|
3189
|
+
try:
|
|
3190
|
+
current = os.fstat(descriptor)
|
|
3191
|
+
linked = path.lstat()
|
|
3192
|
+
identity = (expected.st_dev, expected.st_ino)
|
|
3193
|
+
if ((current.st_dev, current.st_ino) != identity
|
|
3194
|
+
or (linked.st_dev, linked.st_ino) != identity):
|
|
3195
|
+
return
|
|
3196
|
+
owner, owner_identity = read_owner(descriptor)
|
|
3197
|
+
if owner != pid or read_owner(descriptor) != (owner, owner_identity):
|
|
3198
|
+
return
|
|
3199
|
+
os.unlink("pid", dir_fd=descriptor)
|
|
3200
|
+
os.fsync(descriptor)
|
|
3201
|
+
linked = path.lstat()
|
|
3202
|
+
if (linked.st_dev, linked.st_ino) == identity:
|
|
3203
|
+
path.rmdir()
|
|
3204
|
+
finally:
|
|
3205
|
+
os.close(descriptor)
|
|
3206
|
+
except (OSError, RuntimeError):
|
|
3207
|
+
pass # Preserve the original cancellation/failure; never guess ownership.
|
|
3208
|
+
|
|
3209
|
+
|
|
3148
3210
|
if action == "acquire":
|
|
3211
|
+
# One budget includes owner/namespace changes. Waiting only observes an
|
|
3212
|
+
# existing safe lock; all authority checks still follow atomic acquisition.
|
|
3213
|
+
deadline = time.monotonic() + PREPARED_LOCK_WAIT_SECONDS
|
|
3149
3214
|
candidate = root / f".install-lock.{pid}.{secrets.token_hex(12)}.tmp"
|
|
3150
3215
|
os.mkdir(candidate, 0o700)
|
|
3151
3216
|
os.chmod(candidate, 0o700)
|
|
@@ -3165,21 +3230,44 @@ if action == "acquire":
|
|
|
3165
3230
|
finally:
|
|
3166
3231
|
os.close(descriptor)
|
|
3167
3232
|
fsync_directory(candidate)
|
|
3168
|
-
|
|
3233
|
+
if wait_for_prepared:
|
|
3234
|
+
def cancelled(signum, _frame):
|
|
3235
|
+
raise SystemExit(128 + signum)
|
|
3236
|
+
for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
|
|
3237
|
+
signal.signal(signum, cancelled)
|
|
3238
|
+
namespace_attempts = 0
|
|
3239
|
+
while namespace_attempts < 32:
|
|
3240
|
+
if wait_for_prepared:
|
|
3241
|
+
check_wait_root()
|
|
3242
|
+
if time.monotonic() >= deadline:
|
|
3243
|
+
raise RuntimeError("AgentsServer prepared update timed out waiting for the installation lock")
|
|
3169
3244
|
try:
|
|
3170
3245
|
os.rename(candidate, lock)
|
|
3171
3246
|
except OSError as exc:
|
|
3172
3247
|
if exc.errno not in {errno.EEXIST, errno.ENOTEMPTY}:
|
|
3173
3248
|
raise
|
|
3174
|
-
directory_descriptor, directory_info = open_lock_directory()
|
|
3175
3249
|
try:
|
|
3250
|
+
directory_descriptor, directory_info = open_lock_directory()
|
|
3251
|
+
except FileNotFoundError:
|
|
3252
|
+
if not wait_for_prepared:
|
|
3253
|
+
raise
|
|
3254
|
+
# The incumbent retired after rename observed its lock.
|
|
3255
|
+
namespace_attempts += 1
|
|
3256
|
+
continue
|
|
3257
|
+
try:
|
|
3258
|
+
if wait_for_prepared and any(
|
|
3259
|
+
name != "pid" for name in os.listdir(directory_descriptor)
|
|
3260
|
+
):
|
|
3261
|
+
raise PermissionError("AgentsServer install lock contains unknown files")
|
|
3176
3262
|
try:
|
|
3177
3263
|
owner_pid, owner_identity = read_owner(directory_descriptor)
|
|
3178
3264
|
except FileNotFoundError:
|
|
3179
3265
|
# An old installer may have crashed in mkdir->pid. The
|
|
3180
3266
|
# next atomic directory rename replaces only that
|
|
3181
3267
|
# exact empty namespace; a populated replacement wins.
|
|
3268
|
+
namespace_attempts += 1
|
|
3182
3269
|
continue
|
|
3270
|
+
alive = False
|
|
3183
3271
|
try:
|
|
3184
3272
|
os.kill(owner_pid, 0)
|
|
3185
3273
|
except ProcessLookupError:
|
|
@@ -3189,6 +3277,22 @@ if action == "acquire":
|
|
|
3189
3277
|
f"another AgentsServer installation is active ({owner_pid})"
|
|
3190
3278
|
)
|
|
3191
3279
|
else:
|
|
3280
|
+
alive = True
|
|
3281
|
+
if alive:
|
|
3282
|
+
if wait_for_prepared:
|
|
3283
|
+
try:
|
|
3284
|
+
unchanged = same_wait_owner(directory_descriptor, directory_info,
|
|
3285
|
+
owner_pid, owner_identity)
|
|
3286
|
+
except FileNotFoundError:
|
|
3287
|
+
unchanged = False
|
|
3288
|
+
if not unchanged:
|
|
3289
|
+
namespace_attempts += 1
|
|
3290
|
+
continue
|
|
3291
|
+
remaining = deadline - time.monotonic()
|
|
3292
|
+
if remaining <= 0:
|
|
3293
|
+
raise RuntimeError("AgentsServer prepared update timed out waiting for the installation lock")
|
|
3294
|
+
time.sleep(min(0.1, remaining))
|
|
3295
|
+
continue
|
|
3192
3296
|
raise RuntimeError(
|
|
3193
3297
|
f"another AgentsServer installation is active ({owner_pid})"
|
|
3194
3298
|
)
|
|
@@ -3200,19 +3304,25 @@ if action == "acquire":
|
|
|
3200
3304
|
directory_descriptor
|
|
3201
3305
|
)
|
|
3202
3306
|
except FileNotFoundError:
|
|
3307
|
+
namespace_attempts += 1
|
|
3203
3308
|
continue
|
|
3204
3309
|
if current_owner != owner_pid or current_identity != owner_identity:
|
|
3310
|
+
namespace_attempts += 1
|
|
3205
3311
|
continue
|
|
3206
3312
|
try:
|
|
3207
3313
|
os.unlink("pid", dir_fd=directory_descriptor)
|
|
3208
3314
|
except FileNotFoundError:
|
|
3315
|
+
namespace_attempts += 1
|
|
3209
3316
|
continue
|
|
3210
3317
|
os.fsync(directory_descriptor)
|
|
3211
3318
|
finally:
|
|
3212
3319
|
os.close(directory_descriptor)
|
|
3320
|
+
namespace_attempts += 1
|
|
3213
3321
|
continue
|
|
3214
3322
|
else:
|
|
3215
3323
|
owned = True
|
|
3324
|
+
if wait_for_prepared:
|
|
3325
|
+
check_wait_root()
|
|
3216
3326
|
for _ in range(3):
|
|
3217
3327
|
try:
|
|
3218
3328
|
fsync_directory(root)
|
|
@@ -3232,7 +3342,9 @@ if action == "acquire":
|
|
|
3232
3342
|
raise SystemExit(0)
|
|
3233
3343
|
raise RuntimeError("AgentsServer install lock ownership kept changing")
|
|
3234
3344
|
finally:
|
|
3235
|
-
if not owned:
|
|
3345
|
+
if not owned and wait_for_prepared:
|
|
3346
|
+
cleanup_wait_candidate(candidate, candidate_info)
|
|
3347
|
+
elif not owned:
|
|
3236
3348
|
try:
|
|
3237
3349
|
(candidate / "pid").unlink()
|
|
3238
3350
|
except FileNotFoundError:
|
|
@@ -3639,11 +3751,12 @@ validate_staged_release_runtime() (
|
|
|
3639
3751
|
"$STAGE_DIR/claude_background_reconciliation.py" \
|
|
3640
3752
|
"$STAGE_DIR/codex_app_server.py" \
|
|
3641
3753
|
"$STAGE_DIR/codex_auth.py" \
|
|
3642
|
-
"$STAGE_DIR/codex_provider.py" \
|
|
3754
|
+
"$STAGE_DIR/codex_provider.py" "$STAGE_DIR/codex_response_stream.py" \
|
|
3643
3755
|
"$STAGE_DIR/provider_connections.py" "$STAGE_DIR/cursor_api_key.py" \
|
|
3644
3756
|
"$STAGE_DIR/side_questions.py" \
|
|
3645
3757
|
"$STAGE_DIR/workspace_git.py" \
|
|
3646
3758
|
"$STAGE_DIR/codex_side_question.py" \
|
|
3759
|
+
"$STAGE_DIR/codex_side_chat_progress.py" \
|
|
3647
3760
|
"$STAGE_DIR/title_generation.py" \
|
|
3648
3761
|
"$STAGE_DIR/claude_side_question.py" \
|
|
3649
3762
|
"$STAGE_DIR/cursor_agent_client.py" \
|
|
@@ -3668,7 +3781,7 @@ validate_staged_release_runtime() (
|
|
|
3668
3781
|
"$STAGE_DIR/update_runner.py"
|
|
3669
3782
|
"$STAGE_DIR/.venv/bin/python" -m py_compile "$STAGE_DIR/server_instances.py"
|
|
3670
3783
|
"$STAGE_DIR/.venv/bin/python" -m compileall -q "$STAGE_DIR/agentsdock_team_hub"
|
|
3671
|
-
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3784
|
+
PYTHONPATH="$STAGE_DIR" "$STAGE_DIR/.venv/bin/python" -c 'import execution_activation, execution_preparation, update_preparation, update_handoff, update_recovery, execution_update_status, execution_uninstall, execution_recovery, execution_recovery_status, execution_http, execution_durability, execution_control, execution_install, execution_maintenance, execution_manage, execution_ownership, execution_service, execution_transport; import workspace_git, claude_model_catalog, server_instances, local_session_ownership, cursor_history; import codex_auth, codex_provider, codex_response_stream, provider_connections, cursor_api_key, side_questions, title_generation, codex_side_question, codex_side_chat_progress, claude_side_question, agentsdock_team_hub, cursor_agent_client, cursor_provider_mcp, opencode_agent_client, cursor_process_guard, secure_peer_delivery, secure_peer_runtime, team_mail_runtime, team_mail_websocket, team_mail_grants, team_hub_host, agentsdock_mail, agentsdock_team, claude_history_repair, claude_history_provenance, claude_goals, claude_background_reconciliation, codex_history_repair, public_chat_shares, public_chat_transcript, public_chat_share_routes, interactive_chat_shares, interactive_chat_share_routes, interactive_chat_share_web, interactive_chat_projection, interactive_chat_runtime, interactive_chat_native, shared_chat_videos, shared_chat_video_stream, interactive_chat_controls, chat_mailbox, provider_commands, provider_usage; from agentsdock_team_hub import secure_peer, secure_peer_hub' >/dev/null
|
|
3672
3785
|
)
|
|
3673
3786
|
|
|
3674
3787
|
abort_unclaimed_team_hub_reactivation() {
|
package/server/instances.sh
CHANGED
|
File without changes
|
package/server/side_questions.py
CHANGED
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
10
|
from contextlib import suppress, closing
|
|
11
|
-
from dataclasses import dataclass
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
12
|
from datetime import datetime, timezone
|
|
13
13
|
import hashlib
|
|
14
14
|
import json
|
|
@@ -57,7 +57,36 @@ class SideQuestionError(Exception):
|
|
|
57
57
|
|
|
58
58
|
def capability():
|
|
59
59
|
return {"available": True, "version": 2, "native_context": True, "backends": ["codex", "claude"],
|
|
60
|
-
"max_question_chars": MAX_QUESTION_CHARS, "sync": True}
|
|
60
|
+
"max_question_chars": MAX_QUESTION_CHARS, "sync": True, "runtime_settings": True}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def validate_runtime_settings(value):
|
|
64
|
+
"""Only explicit per-turn choices; omission preserves the side runtime."""
|
|
65
|
+
result = {}
|
|
66
|
+
for key in ("model", "effort"):
|
|
67
|
+
if key not in value:
|
|
68
|
+
continue
|
|
69
|
+
setting = value[key]
|
|
70
|
+
if not isinstance(setting, str) or len(setting) > 256 or any(ord(c) < 32 for c in setting):
|
|
71
|
+
raise SideQuestionError(400, "Invalid side chat runtime setting")
|
|
72
|
+
try:
|
|
73
|
+
setting.encode("utf-8")
|
|
74
|
+
except UnicodeEncodeError:
|
|
75
|
+
raise SideQuestionError(400, "Invalid side chat runtime setting") from None
|
|
76
|
+
setting = setting.strip()
|
|
77
|
+
if key == "model" and not setting:
|
|
78
|
+
raise SideQuestionError(400, "Side chat model must not be empty")
|
|
79
|
+
if key == "effort" and setting not in {"", "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}:
|
|
80
|
+
raise SideQuestionError(400, "Invalid side chat reasoning effort")
|
|
81
|
+
result[key] = setting
|
|
82
|
+
return result
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def request_digest(question, runtime_settings=None):
|
|
86
|
+
# Retain old receipts exactly when the optional settings were absent.
|
|
87
|
+
payload = (json.dumps([question, runtime_settings], sort_keys=True, ensure_ascii=False)
|
|
88
|
+
if runtime_settings else question)
|
|
89
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
61
90
|
|
|
62
91
|
|
|
63
92
|
def validate_history(value) -> tuple[tuple[str, str], ...]:
|
|
@@ -328,6 +357,7 @@ class _Receipt:
|
|
|
328
357
|
waiters: int = 0
|
|
329
358
|
side_chat_id: str | None = None
|
|
330
359
|
after_request_id: str | None = None
|
|
360
|
+
runtime_settings: dict = field(default_factory=dict)
|
|
331
361
|
|
|
332
362
|
|
|
333
363
|
@dataclass
|
|
@@ -345,14 +375,15 @@ NATIVE_IDLE_SECONDS = 30 * 60
|
|
|
345
375
|
|
|
346
376
|
|
|
347
377
|
class SideQuestions:
|
|
348
|
-
def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None):
|
|
378
|
+
def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
|
|
349
379
|
self.answer = answer
|
|
350
380
|
self.native_factory = native_factory
|
|
351
381
|
self.admission_check = admission_check
|
|
352
382
|
self.receipts: dict[tuple[str, str, str], _Receipt] = {}
|
|
353
383
|
self.conversations: dict[tuple[str, str, str], _NativeConversation] = {}
|
|
354
384
|
self.cleanup_tasks: set[asyncio.Task] = set()
|
|
355
|
-
self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check
|
|
385
|
+
self.synced = (SyncedSideChats(storage_path, native_factory=native_factory, notify=notify, admission_check=admission_check,
|
|
386
|
+
runtime_settings_for_session=runtime_settings_for_session)
|
|
356
387
|
if storage_path is not None else None)
|
|
357
388
|
|
|
358
389
|
def active_session_ids(self):
|
|
@@ -395,7 +426,7 @@ class SideQuestions:
|
|
|
395
426
|
task.exception()
|
|
396
427
|
task.add_done_callback(finished)
|
|
397
428
|
|
|
398
|
-
async def _native_answer(self, owner, session_id, request_id, question, side_chat_id, after_request_id):
|
|
429
|
+
async def _native_answer(self, owner, session_id, request_id, question, side_chat_id, after_request_id, runtime_settings=None):
|
|
399
430
|
key = (owner, session_id, side_chat_id)
|
|
400
431
|
conversation = self.conversations.get(key)
|
|
401
432
|
if conversation is None:
|
|
@@ -416,7 +447,8 @@ class SideQuestions:
|
|
|
416
447
|
conversation.handle = await self.native_factory(session_id)
|
|
417
448
|
if conversation.closed:
|
|
418
449
|
raise SideQuestionError(410, "Side chat ended")
|
|
419
|
-
result = await conversation.handle.ask(question, history=list(conversation.history)
|
|
450
|
+
result = await conversation.handle.ask(question, history=list(conversation.history),
|
|
451
|
+
**({"runtime_settings": runtime_settings} if runtime_settings else {}))
|
|
420
452
|
if conversation.closed:
|
|
421
453
|
raise SideQuestionError(410, "Side chat ended")
|
|
422
454
|
# Claude /btw replays the last twenty side exchanges natively.
|
|
@@ -450,9 +482,10 @@ class SideQuestions:
|
|
|
450
482
|
self.receipts.pop(key, None)
|
|
451
483
|
|
|
452
484
|
def submit(self, owner: str, session_id: str, request_id: str, question: str, *, history: list[dict] | None = None,
|
|
453
|
-
side_chat_id: str | None = None, after_request_id: str | None = None):
|
|
485
|
+
side_chat_id: str | None = None, after_request_id: str | None = None, runtime_settings=None):
|
|
454
486
|
if self.admission_check is not None:
|
|
455
487
|
self.admission_check()
|
|
488
|
+
runtime_settings = validate_runtime_settings(runtime_settings or {})
|
|
456
489
|
frozen_history = validate_history([] if history is None else history)
|
|
457
490
|
self._prune()
|
|
458
491
|
key = (owner, session_id, request_id)
|
|
@@ -461,17 +494,19 @@ class SideQuestions:
|
|
|
461
494
|
if receipt.cancelled:
|
|
462
495
|
raise SideQuestionError(409, "Side question was cancelled; submit with a new request ID")
|
|
463
496
|
if (receipt.question != question or receipt.history != frozen_history
|
|
464
|
-
or receipt.side_chat_id != side_chat_id or receipt.after_request_id != after_request_id
|
|
497
|
+
or receipt.side_chat_id != side_chat_id or receipt.after_request_id != after_request_id
|
|
498
|
+
or receipt.runtime_settings != runtime_settings):
|
|
465
499
|
raise SideQuestionError(409, "Request ID already belongs to a different side question")
|
|
466
500
|
return receipt
|
|
467
501
|
if self.native_factory is not None and (not side_chat_id or frozen_history):
|
|
468
502
|
raise SideQuestionError(409, "Update the app to use native Side chat; copied conversation history is no longer accepted.")
|
|
469
|
-
receipt = _Receipt(question, history=frozen_history, side_chat_id=side_chat_id, after_request_id=after_request_id
|
|
503
|
+
receipt = _Receipt(question, history=frozen_history, side_chat_id=side_chat_id, after_request_id=after_request_id,
|
|
504
|
+
runtime_settings=runtime_settings)
|
|
470
505
|
self.receipts[key] = receipt
|
|
471
506
|
|
|
472
507
|
async def run():
|
|
473
508
|
try:
|
|
474
|
-
answer = (self._native_answer(owner, session_id, request_id, question, side_chat_id, after_request_id)
|
|
509
|
+
answer = (self._native_answer(owner, session_id, request_id, question, side_chat_id, after_request_id, runtime_settings)
|
|
475
510
|
if self.native_factory is not None else
|
|
476
511
|
self.answer(session_id, question, history=history_messages(frozen_history))
|
|
477
512
|
if frozen_history else self.answer(session_id, question))
|
|
@@ -603,7 +638,7 @@ class SideChatStore:
|
|
|
603
638
|
if receipt is not None:
|
|
604
639
|
database.execute("INSERT INTO receipts VALUES(?,?,?,?,?)",
|
|
605
640
|
(owner, session, receipt["request_id"], document["side_chat_id"],
|
|
606
|
-
|
|
641
|
+
request_digest(receipt["question"], receipt.get("runtime_settings"))))
|
|
607
642
|
provider_state = document.get("_provider_state") or {}
|
|
608
643
|
thread_id = (provider_state.get("codex") or {}).get("thread_id")
|
|
609
644
|
if isinstance(thread_id, str) and thread_id:
|
|
@@ -658,7 +693,8 @@ class SyncedSideChats:
|
|
|
658
693
|
after an app disconnect or an unexpected process exit.
|
|
659
694
|
"""
|
|
660
695
|
|
|
661
|
-
def __init__(self, path, *, native_factory, notify=None, admission_check=None):
|
|
696
|
+
def __init__(self, path, *, native_factory, notify=None, admission_check=None, runtime_settings_for_session=None):
|
|
697
|
+
self.runtime_settings_for_session = runtime_settings_for_session
|
|
662
698
|
self.store = SideChatStore(path)
|
|
663
699
|
self.native_factory = native_factory
|
|
664
700
|
self.notify = notify
|
|
@@ -700,9 +736,21 @@ class SyncedSideChats:
|
|
|
700
736
|
|
|
701
737
|
async def snapshot(self, owner, session):
|
|
702
738
|
async with self._lock((owner, session)):
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
739
|
+
document = self._load((owner, session))
|
|
740
|
+
if "model" not in document and self.runtime_settings_for_session is not None:
|
|
741
|
+
# Retained history stays readable when provider credentials,
|
|
742
|
+
# endpoint configuration or model discovery are unavailable.
|
|
743
|
+
settings = {}
|
|
744
|
+
with suppress(Exception):
|
|
745
|
+
settings = self.runtime_settings_for_session(session)
|
|
746
|
+
if settings:
|
|
747
|
+
document.update(settings)
|
|
748
|
+
document["revision"] += 1
|
|
749
|
+
self.store.save(owner, session, document)
|
|
750
|
+
return self.public(document)
|
|
751
|
+
|
|
752
|
+
async def submit(self, owner, session, request_id, question, side_chat_id, after_request_id=None, runtime_settings=None):
|
|
753
|
+
runtime_settings = validate_runtime_settings(runtime_settings or {})
|
|
706
754
|
key = (owner, session)
|
|
707
755
|
async with self._lock(key):
|
|
708
756
|
if self.stopping:
|
|
@@ -712,7 +760,7 @@ class SyncedSideChats:
|
|
|
712
760
|
document = self._load(key)
|
|
713
761
|
previous = self.store.receipt(*key, request_id)
|
|
714
762
|
if previous is not None:
|
|
715
|
-
if previous != (side_chat_id,
|
|
763
|
+
if previous != (side_chat_id, request_digest(question, runtime_settings)) or document["side_chat_id"] != side_chat_id:
|
|
716
764
|
raise SideQuestionError(409, "Request ID belongs to a different or cleared side chat")
|
|
717
765
|
return self.public(document)
|
|
718
766
|
if document["side_chat_id"] != side_chat_id:
|
|
@@ -725,14 +773,15 @@ class SyncedSideChats:
|
|
|
725
773
|
if document["last_request_id"] != after_request_id:
|
|
726
774
|
raise SideQuestionError(409, "Side chat changed; refresh before sending")
|
|
727
775
|
exchange = {"request_id": request_id, "question": question, "status": "running",
|
|
728
|
-
"created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp()
|
|
776
|
+
"created_at": side_chat_timestamp(), "updated_at": side_chat_timestamp(),
|
|
777
|
+
**({"runtime_settings": runtime_settings} if runtime_settings else {})}
|
|
729
778
|
document["exchanges"].append(exchange)
|
|
730
779
|
document["revision"] += 1
|
|
731
780
|
self.store.save(*key, document, receipt=exchange)
|
|
732
781
|
timer = self.timers.pop(key, None)
|
|
733
782
|
if timer is not None:
|
|
734
783
|
timer.cancel()
|
|
735
|
-
task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question), name="synced-side-question")
|
|
784
|
+
task = asyncio.create_task(self._answer(key, side_chat_id, request_id, question, runtime_settings), name="synced-side-question")
|
|
736
785
|
self.tasks[key] = task
|
|
737
786
|
def done(completed):
|
|
738
787
|
if self.tasks.get(key) is completed:
|
|
@@ -750,9 +799,33 @@ class SyncedSideChats:
|
|
|
750
799
|
if document["side_chat_id"] != side_chat_id:
|
|
751
800
|
raise SideQuestionError(409, "Side chat was cleared")
|
|
752
801
|
document["_provider_state"] = value
|
|
802
|
+
settings = value.get("runtime_settings") or {}
|
|
803
|
+
changed = any(document.get(name) != setting for name, setting in settings.items())
|
|
804
|
+
document.update(settings)
|
|
805
|
+
if changed:
|
|
806
|
+
document["revision"] += 1
|
|
753
807
|
self.store.save(*key, document)
|
|
808
|
+
if changed:
|
|
809
|
+
await self._changed(document)
|
|
810
|
+
|
|
811
|
+
async def _progress(self, key, side_chat_id, request_id, activity):
|
|
812
|
+
async with self._lock(key):
|
|
813
|
+
document = self.pending_writes.get(key) or self._load(key)
|
|
814
|
+
if document["side_chat_id"] != side_chat_id:
|
|
815
|
+
return
|
|
816
|
+
exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
|
|
817
|
+
if exchange is None or exchange["status"] != "running":
|
|
818
|
+
return
|
|
819
|
+
exchange.update(activity=activity, updated_at=side_chat_timestamp())
|
|
820
|
+
document["revision"] += 1
|
|
821
|
+
try:
|
|
822
|
+
self.store.save(*key, document)
|
|
823
|
+
self.pending_writes.pop(key, None)
|
|
824
|
+
except (OSError, sqlite3.Error):
|
|
825
|
+
self.pending_writes[key] = document
|
|
826
|
+
await self._changed(document)
|
|
754
827
|
|
|
755
|
-
async def _answer(self, key, side_chat_id, request_id, question):
|
|
828
|
+
async def _answer(self, key, side_chat_id, request_id, question, runtime_settings=None):
|
|
756
829
|
handle = None
|
|
757
830
|
failed = False
|
|
758
831
|
try:
|
|
@@ -762,11 +835,15 @@ class SyncedSideChats:
|
|
|
762
835
|
handle = self.handles.get(key)
|
|
763
836
|
if handle is None:
|
|
764
837
|
handle = await self.native_factory(key[1], persisted_state=document.get("_provider_state"),
|
|
765
|
-
persist_state=lambda value: self._provider_state(key, side_chat_id, value), durable=True
|
|
838
|
+
persist_state=lambda value: self._provider_state(key, side_chat_id, value), durable=True,
|
|
839
|
+
**({"initial_runtime_settings": {name: document[name] for name in ("model", "effort") if name in document}}
|
|
840
|
+
if "model" in document else {}))
|
|
766
841
|
self.handles[key] = handle
|
|
767
842
|
history = [{"question": item["question"], "response": item["answer"]}
|
|
768
843
|
for item in document["exchanges"] if item["status"] == "completed"][-20:]
|
|
769
|
-
result = await handle.ask(question, history=history
|
|
844
|
+
result = await handle.ask(question, history=history,
|
|
845
|
+
on_progress=lambda activity: self._progress(key, side_chat_id, request_id, activity),
|
|
846
|
+
**({"runtime_settings": runtime_settings} if runtime_settings else {}))
|
|
770
847
|
await self._finish(key, side_chat_id, request_id, "completed", **result)
|
|
771
848
|
except asyncio.CancelledError:
|
|
772
849
|
failed = True
|
|
@@ -802,17 +879,19 @@ class SyncedSideChats:
|
|
|
802
879
|
|
|
803
880
|
async def _finish(self, key, side_chat_id, request_id, status, **result):
|
|
804
881
|
async with self._lock(key):
|
|
805
|
-
document = self._load(key)
|
|
882
|
+
document = self.pending_writes.get(key) or self._load(key)
|
|
806
883
|
if document["side_chat_id"] != side_chat_id:
|
|
807
884
|
return
|
|
808
885
|
exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
|
|
809
886
|
if exchange is None or exchange["status"] != "running":
|
|
810
887
|
return
|
|
811
888
|
exchange.update(status=status, updated_at=side_chat_timestamp(), **result)
|
|
889
|
+
document.update({key: result[key] for key in ("model", "effort") if key in result})
|
|
812
890
|
document["last_request_id"] = request_id
|
|
813
891
|
document["revision"] += 1
|
|
814
892
|
try:
|
|
815
893
|
self.store.save(*key, document)
|
|
894
|
+
self.pending_writes.pop(key, None)
|
|
816
895
|
except (OSError, sqlite3.Error):
|
|
817
896
|
self.pending_writes[key] = document
|
|
818
897
|
await self._changed(document)
|
|
@@ -830,7 +909,9 @@ class SyncedSideChats:
|
|
|
830
909
|
async def cancel(self, owner, session, request_id):
|
|
831
910
|
key = (owner, session)
|
|
832
911
|
async with self._lock(key):
|
|
833
|
-
|
|
912
|
+
# Pending progress may be unsaved while provider work is active.
|
|
913
|
+
# Storage recovery must not gate the operator's Stop intent.
|
|
914
|
+
document = self.pending_writes.get(key) or self._load(key)
|
|
834
915
|
exchange = next((item for item in document["exchanges"] if item["request_id"] == request_id), None)
|
|
835
916
|
if exchange is None:
|
|
836
917
|
# Stop can overtake submission in transit. Preserve an opaque
|
|
@@ -839,15 +920,24 @@ class SyncedSideChats:
|
|
|
839
920
|
self.store.save(*key, document, receipt={"request_id": request_id, "question": ""})
|
|
840
921
|
return self.public(document)
|
|
841
922
|
if exchange["status"] != "running":
|
|
842
|
-
return self.public(
|
|
923
|
+
return self.public(self._load(key))
|
|
924
|
+
task = self.tasks.get(key)
|
|
925
|
+
snapshot_progress = getattr(self.handles.get(key), "snapshot_progress", None)
|
|
926
|
+
if task is not None and callable(snapshot_progress):
|
|
927
|
+
# Capture only native items received by this exact request
|
|
928
|
+
# before Stop. Later coalesced callbacks stay fenced below.
|
|
929
|
+
with suppress(Exception):
|
|
930
|
+
activity = snapshot_progress(task)
|
|
931
|
+
if activity is not None:
|
|
932
|
+
exchange["activity"] = activity
|
|
843
933
|
exchange.update(status="cancelled", updated_at=side_chat_timestamp())
|
|
844
934
|
document["last_request_id"] = request_id
|
|
845
935
|
document["revision"] += 1
|
|
846
936
|
try:
|
|
847
937
|
self.store.save(*key, document)
|
|
938
|
+
self.pending_writes.pop(key, None)
|
|
848
939
|
except (OSError, sqlite3.Error):
|
|
849
940
|
self.pending_writes[key] = document
|
|
850
|
-
task = self.tasks.get(key)
|
|
851
941
|
if task is not None:
|
|
852
942
|
task.cancel()
|
|
853
943
|
if task is not None:
|
|
@@ -858,15 +948,20 @@ class SyncedSideChats:
|
|
|
858
948
|
async def clear(self, owner, session, side_chat_id):
|
|
859
949
|
key = (owner, session)
|
|
860
950
|
async with self._lock(key):
|
|
861
|
-
|
|
951
|
+
# Preserve the new conversation fence even if progress storage
|
|
952
|
+
# is unavailable; still retire the old provider immediately.
|
|
953
|
+
document = self.pending_writes.get(key) or self._load(key)
|
|
862
954
|
if document["side_chat_id"] != side_chat_id:
|
|
863
955
|
# A repeated Clear is harmless. A stale device cannot clear a
|
|
864
956
|
# new conversation it has not seen.
|
|
865
|
-
return self.public(
|
|
957
|
+
return self.public(self._load(key))
|
|
866
958
|
document.update(side_chat_id=uuid.uuid4().hex, exchanges=[], last_request_id=None, _provider_state=None)
|
|
959
|
+
document.pop("model", None)
|
|
960
|
+
document.pop("effort", None)
|
|
867
961
|
document["revision"] += 1
|
|
868
962
|
try:
|
|
869
963
|
self.store.save(*key, document)
|
|
964
|
+
self.pending_writes.pop(key, None)
|
|
870
965
|
except (OSError, sqlite3.Error):
|
|
871
966
|
self.pending_writes[key] = document
|
|
872
967
|
task = self.tasks.get(key)
|
|
@@ -947,7 +1042,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
947
1042
|
except asyncio.TimeoutError:
|
|
948
1043
|
raise HTTPException(408, "Side question request was not received") from None
|
|
949
1044
|
if (not isinstance(value, dict) or not {"request_id", "question"} <= set(value)
|
|
950
|
-
or set(value) - {"request_id", "question", "history", "side_chat_id", "after_request_id"}):
|
|
1045
|
+
or set(value) - {"request_id", "question", "history", "side_chat_id", "after_request_id", "model", "effort"}):
|
|
951
1046
|
raise HTTPException(400, "Invalid side question fields")
|
|
952
1047
|
for field in ("side_chat_id", "after_request_id"):
|
|
953
1048
|
if field in value and (not isinstance(value[field], str) or not IDENTIFIER.fullmatch(value[field])):
|
|
@@ -977,7 +1072,8 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
977
1072
|
receipt = None
|
|
978
1073
|
try:
|
|
979
1074
|
receipt = runtime.submit(owner, session_id, request_id, question, history=history,
|
|
980
|
-
side_chat_id=value.get("side_chat_id"), after_request_id=value.get("after_request_id")
|
|
1075
|
+
side_chat_id=value.get("side_chat_id"), after_request_id=value.get("after_request_id"),
|
|
1076
|
+
runtime_settings=validate_runtime_settings(value))
|
|
981
1077
|
receipt.waiters += 1
|
|
982
1078
|
watcher = asyncio.create_task(disconnected())
|
|
983
1079
|
done, _ = await asyncio.wait({receipt.task, watcher}, return_when=asyncio.FIRST_COMPLETED)
|
|
@@ -1047,7 +1143,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1047
1143
|
except asyncio.TimeoutError:
|
|
1048
1144
|
raise HTTPException(408, "Side question request was not received") from None
|
|
1049
1145
|
if (not isinstance(value, dict) or not {"request_id", "question", "side_chat_id"} <= set(value)
|
|
1050
|
-
or set(value) - {"request_id", "question", "side_chat_id", "after_request_id"}):
|
|
1146
|
+
or set(value) - {"request_id", "question", "side_chat_id", "after_request_id", "model", "effort"}):
|
|
1051
1147
|
raise HTTPException(400, "Invalid side question fields")
|
|
1052
1148
|
for field in ("request_id", "side_chat_id", "after_request_id"):
|
|
1053
1149
|
if field == "after_request_id" and value.get(field) is None:
|
|
@@ -1062,7 +1158,8 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1062
1158
|
except UnicodeEncodeError:
|
|
1063
1159
|
raise HTTPException(400, "Question must contain valid Unicode text") from None
|
|
1064
1160
|
return await synced_response(synced_runtime().submit(owner, session_id, value["request_id"], question,
|
|
1065
|
-
value["side_chat_id"], value.get("after_request_id")
|
|
1161
|
+
value["side_chat_id"], value.get("after_request_id"),
|
|
1162
|
+
runtime_settings=value), status_code=202)
|
|
1066
1163
|
|
|
1067
1164
|
@router.delete("/api/sessions/{session_id}/side-chat/requests/{request_id}")
|
|
1068
1165
|
async def cancel_synced(session_id: str, request_id: str, request: Request):
|