bazaar-compute-node 0.1.9__tar.gz → 0.1.10__tar.gz

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.
Files changed (102) hide show
  1. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/PKG-INFO +10 -8
  2. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/README.md +8 -7
  3. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/pyproject.toml +2 -1
  4. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/pyproject.toml.orig +2 -1
  5. bazaar_compute_node-0.1.10/src/bazaar_compute_node/app/agent.py +505 -0
  6. bazaar_compute_node-0.1.10/src/bazaar_compute_node/app/agent_management.py +217 -0
  7. bazaar_compute_node-0.1.10/src/bazaar_compute_node/app/application.py +399 -0
  8. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/command.py +5 -1
  9. bazaar_compute_node-0.1.10/src/bazaar_compute_node/app/config.py +607 -0
  10. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/registry.py +30 -21
  11. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/transport.py +5 -1
  12. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/wrapper.py +13 -4
  13. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/bcc.py +21 -3
  14. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/cli.py +185 -132
  15. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/runtime.py +31 -22
  16. bazaar_compute_node-0.1.10/src/bazaar_compute_node/contrib/sqlite/__init__.py +10 -0
  17. bazaar_compute_node-0.1.10/src/bazaar_compute_node/contrib/sqlite/agent_migration.py +230 -0
  18. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/codec.py +6 -1
  19. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/database.py +39 -147
  20. bazaar_compute_node-0.1.10/src/bazaar_compute_node/contrib/sqlite/reminder_database.py +74 -0
  21. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/reminder_repository.py +218 -3
  22. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/repository.py +6 -5
  23. bazaar_compute_node-0.1.10/src/bazaar_compute_node/contrib/sqlite/scoped_repository.py +778 -0
  24. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/approval.py +4 -2
  25. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/channel.py +29 -13
  26. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/outbound.py +4 -5
  27. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/plugin.py +5 -2
  28. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/wecom/channel.py +10 -6
  29. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/wecom/plugin.py +8 -5
  30. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/channel.py +278 -0
  31. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/instruction.py +19 -5
  32. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/models/__init__.py +20 -14
  33. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/models/entities.py +25 -3
  34. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/models/reminder_owner.py +59 -0
  35. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/models/states.py +396 -0
  36. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/command.py +29 -104
  37. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/orchestration/delivery.py +110 -0
  38. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/orchestration/error_feedback.py +164 -0
  39. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/reminder.py +55 -25
  40. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/services.py +30 -18
  41. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/session.py +190 -189
  42. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/turn.py +47 -42
  43. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/outcomes.py +99 -0
  44. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/paths.py +6 -8
  45. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/runtime.py +38 -56
  46. bazaar_compute_node-0.1.10/src/bazaar_compute_node/core/storage.py +172 -0
  47. bazaar_compute_node-0.1.10/src/bazaar_compute_node/i18n/__init__.py +8 -0
  48. bazaar_compute_node-0.1.10/src/bazaar_compute_node/i18n/catalog.py +44 -0
  49. bazaar_compute_node-0.1.10/src/bazaar_compute_node/i18n/english.py +10 -0
  50. bazaar_compute_node-0.1.10/src/bazaar_compute_node/i18n/schinese.py +10 -0
  51. bazaar_compute_node-0.1.9/src/bazaar_compute_node/app/application.py +0 -462
  52. bazaar_compute_node-0.1.9/src/bazaar_compute_node/app/config.py +0 -143
  53. bazaar_compute_node-0.1.9/src/bazaar_compute_node/contrib/sqlite/__init__.py +0 -15
  54. bazaar_compute_node-0.1.9/src/bazaar_compute_node/contrib/sqlite/reminder_database.py +0 -21
  55. bazaar_compute_node-0.1.9/src/bazaar_compute_node/core/channel.py +0 -166
  56. bazaar_compute_node-0.1.9/src/bazaar_compute_node/core/models/states.py +0 -382
  57. bazaar_compute_node-0.1.9/src/bazaar_compute_node/core/outcomes.py +0 -51
  58. bazaar_compute_node-0.1.9/src/bazaar_compute_node/core/storage.py +0 -237
  59. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/LICENSE +0 -0
  60. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/__init__.py +0 -0
  61. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/__init__.py +0 -0
  62. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/attachments.py +0 -0
  63. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/reminder_dispatch.py +0 -0
  64. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/app/windows_pipe.py +0 -0
  65. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/__init__.py +0 -0
  66. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/__init__.py +0 -0
  67. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/approval.py +0 -0
  68. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/client.py +0 -0
  69. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/events.py +0 -0
  70. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/plugin.py +0 -0
  71. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/process.py +0 -0
  72. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/codex/protocol.py +0 -0
  73. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/logging/__init__.py +0 -0
  74. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/logging/audit.py +0 -0
  75. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/logging/plugin.py +0 -0
  76. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/migrations.py +0 -0
  77. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/plugin.py +0 -0
  78. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/reminder_codec.py +0 -0
  79. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/sqlite/reminder_migration.py +0 -0
  80. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/__init__.py +0 -0
  81. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/api.py +0 -0
  82. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/attachments.py +0 -0
  83. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/identity.py +0 -0
  84. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/telegram/markdown.py +0 -0
  85. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/wecom/__init__.py +0 -0
  86. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/wecom/markdown.py +0 -0
  87. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/contrib/wecom/outbound.py +0 -0
  88. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/__init__.py +0 -0
  89. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/approval.py +0 -0
  90. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/audit.py +0 -0
  91. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/client.py +0 -0
  92. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/command.py +0 -0
  93. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/concurrency.py +0 -0
  94. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/correlation.py +0 -0
  95. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/lifecycle.py +0 -0
  96. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/models/reminder.py +0 -0
  97. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/observability.py +0 -0
  98. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/__init__.py +0 -0
  99. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/orchestration/reminder_command.py +0 -0
  100. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/reminder.py +0 -0
  101. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/reminder_schedule.py +0 -0
  102. {bazaar_compute_node-0.1.9 → bazaar_compute_node-0.1.10}/src/bazaar_compute_node/core/timerwheel.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bazaar-compute-node
3
- Version: 0.1.9
3
+ Version: 0.1.10
4
4
  Summary: Runtime-agnostic computer node daemon for agents and channels.
5
5
  License-Expression: AGPL-3.0-only
6
6
  License-File: LICENSE
7
7
  Requires-Dist: aiohttp>=3.12.0
8
8
  Requires-Dist: aiosqlite>=0.20.0
9
9
  Requires-Dist: cryptography>=45.0.0
10
+ Requires-Dist: tzdata>=2024.1 ; sys_platform == 'win32'
10
11
  Requires-Python: >=3.14
11
12
  Project-URL: Repository, https://github.com/yuchanns/bazaar-compute-node
12
13
  Description-Content-Type: text/markdown
@@ -41,14 +42,15 @@ bcn --version
41
42
  # Upgrade
42
43
  uv tool upgrade bazaar-compute-node
43
44
 
44
- # Start daemon
45
- export BCN_WECOM_BOT_SECRET='your-bot-secret'
45
+ # Add an Agent
46
+ bcn agent add \
47
+ --name Tifa \
48
+ --channel telegram \
49
+ --runtime codex \
50
+ --set channel.token_env=BCN_TELEGRAM_TIFA_TOKEN
46
51
 
47
- bcn start --channel wecom --runtime codex \
48
- --model gpt-5.6-luna --effort max \
49
- --sandbox-mode workspace-write \
50
- --network-access true \
51
- --idle-timeout 600
52
+ # Start daemon
53
+ bcn start
52
54
 
53
55
  # Stop daemon
54
56
  bcn stop
@@ -28,14 +28,15 @@ bcn --version
28
28
  # Upgrade
29
29
  uv tool upgrade bazaar-compute-node
30
30
 
31
- # Start daemon
32
- export BCN_WECOM_BOT_SECRET='your-bot-secret'
31
+ # Add an Agent
32
+ bcn agent add \
33
+ --name Tifa \
34
+ --channel telegram \
35
+ --runtime codex \
36
+ --set channel.token_env=BCN_TELEGRAM_TIFA_TOKEN
33
37
 
34
- bcn start --channel wecom --runtime codex \
35
- --model gpt-5.6-luna --effort max \
36
- --sandbox-mode workspace-write \
37
- --network-access true \
38
- --idle-timeout 600
38
+ # Start daemon
39
+ bcn start
39
40
 
40
41
  # Stop daemon
41
42
  bcn stop
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "aiohttp>=3.12.0",
11
11
  "aiosqlite>=0.20.0",
12
12
  "cryptography>=45.0.0",
13
+ "tzdata>=2024.1; sys_platform == 'win32'",
13
14
  ]
14
15
 
15
16
  [project.urls]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "bazaar-compute-node"
3
- version = "0.1.9"
3
+ version = "0.1.10"
4
4
  description = "Runtime-agnostic computer node daemon for agents and channels."
5
5
  readme = "README.md"
6
6
  license = "AGPL-3.0-only"
@@ -10,6 +10,7 @@ dependencies = [
10
10
  "aiohttp>=3.12.0",
11
11
  "aiosqlite>=0.20.0",
12
12
  "cryptography>=45.0.0",
13
+ "tzdata>=2024.1; sys_platform == 'win32'",
13
14
  ]
14
15
 
15
16
  [project.urls]
@@ -0,0 +1,505 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import hmac
5
+ import logging
6
+ import math
7
+ import os
8
+ import re
9
+ import secrets
10
+ from collections.abc import Callable, Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+ from ..core.channel import AgentScopedChannel, ChannelContext, IChannel
15
+ from ..core.concurrency import ISessionConcurrency, SessionLockRegistry
16
+ from ..core.lifecycle import TimeoutBudget
17
+ from ..core.models import RuntimeSession
18
+ from ..core.observability import IAudit
19
+ from ..core.orchestration import SessionOrchestrator
20
+ from ..core.orchestration.reminder_command import ReminderCommandService
21
+ from ..core.paths import resolve_workspace_dir
22
+ from ..core.runtime import IRuntime, RuntimeCommandContext
23
+ from ..core.storage import IStorageScope
24
+ from ..core.timerwheel import TimerWheel
25
+ from ..i18n import Translator
26
+ from .attachments import AttachmentMaterializer
27
+ from .command import CommandDispatchError
28
+ from .config import AgentConfiguration
29
+ from .registry import AgentAdapterFactories
30
+ from .reminder_dispatch import CommandDispatcher
31
+ from .wrapper import install_bcc_wrapper, remove_bcc_wrapper
32
+
33
+ CommandRecord = tuple[str, tuple[str, ...]]
34
+
35
+ _ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
36
+ _PLATFORM_ENVIRONMENT = {
37
+ "HOME",
38
+ "LANG",
39
+ "LC_ALL",
40
+ "PATH",
41
+ "TMPDIR",
42
+ "TEMP",
43
+ "TMP",
44
+ "SystemRoot",
45
+ "ComSpec",
46
+ "PATHEXT",
47
+ "USERPROFILE",
48
+ }
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class _SessionCapabilityBinding:
53
+ agent_id: str
54
+ bcn_session_id: str
55
+ runtime_session_id: str
56
+ capability: str
57
+ token_values: tuple[str, ...]
58
+
59
+
60
+ class AgentApplication:
61
+ """Process-local composition for one configured BCN Agent."""
62
+
63
+ def __init__(
64
+ self,
65
+ *,
66
+ configuration: AgentConfiguration,
67
+ factories: AgentAdapterFactories,
68
+ storage: IStorageScope,
69
+ audit: IAudit,
70
+ timer_wheel: TimerWheel,
71
+ reminder_concurrency: ISessionConcurrency,
72
+ reminder_poke: Callable[[], None],
73
+ endpoint: Callable[[], str],
74
+ timeout_budget: TimeoutBudget,
75
+ translator: Translator,
76
+ ) -> None:
77
+ self.configuration = configuration
78
+ self.agent_id = configuration.id
79
+ self.name = configuration.name
80
+ self.storage = storage
81
+ self.audit = audit
82
+ self.timer_wheel = timer_wheel
83
+ self.timeout_budget = timeout_budget
84
+ self.translator = translator
85
+ self._endpoint = endpoint
86
+ self._wrapper_path: Path | None = None
87
+ self.command_log: list[CommandRecord] = []
88
+ self._session_capabilities: dict[str, _SessionCapabilityBinding] = {}
89
+ self._concurrency = SessionLockRegistry()
90
+ self._started = False
91
+ self._stopping = False
92
+ self._runtime_environment_include = configuration.runtime.env_include
93
+ self._logger = logging.getLogger("bazaar_compute_node.application.agent")
94
+ self._attachment_materializer = AttachmentMaterializer(
95
+ self.workspace_path,
96
+ self._referenced_attachment_paths,
97
+ )
98
+ provider_channel = factories.channel.build(
99
+ ChannelContext(
100
+ agent_id=self.agent_id,
101
+ attachments=self._attachment_materializer,
102
+ options=dict(configuration.channel.options),
103
+ workspace=self.workspace_path,
104
+ )
105
+ )
106
+ self.channel: IChannel = AgentScopedChannel(self.agent_id, provider_channel)
107
+ runtime_options: dict[str, str] = {
108
+ key: value
109
+ for key, value in configuration.runtime.options.items()
110
+ if isinstance(value, str)
111
+ }
112
+ if configuration.runtime.model is not None:
113
+ runtime_options["model"] = configuration.runtime.model
114
+ if configuration.runtime.effort is not None:
115
+ runtime_options["effort"] = configuration.runtime.effort
116
+ self._runtime_context = RuntimeCommandContext(
117
+ run_command=self._run_runtime_command,
118
+ environment_for_session=self._runtime_environment,
119
+ agent_id=self.agent_id,
120
+ agent_name=self.name,
121
+ bot_name=self._bot_name,
122
+ runtime_options=runtime_options,
123
+ sandbox_mode=configuration.runtime.sandbox_mode,
124
+ network_access=configuration.runtime.network_access,
125
+ startup_timeout_seconds=timeout_budget.startup_seconds,
126
+ )
127
+ self.runtime: IRuntime = factories.runtime(self._runtime_context)
128
+ idle_timeout_seconds = configuration.runtime.idle_timeout_seconds
129
+ if (
130
+ isinstance(idle_timeout_seconds, bool)
131
+ or not isinstance(idle_timeout_seconds, int | float)
132
+ or not math.isfinite(idle_timeout_seconds)
133
+ ):
134
+ raise ValueError("runtime idle timeout must be a finite number")
135
+ if (
136
+ idle_timeout_seconds > 0
137
+ and idle_timeout_seconds * 1_000 > timer_wheel.maximum_delay_ms
138
+ ):
139
+ raise ValueError("runtime idle timeout exceeds the timer horizon")
140
+ runtime_idle_timeout_ms = (
141
+ math.ceil(idle_timeout_seconds * 1_000) if idle_timeout_seconds > 0 else 0
142
+ )
143
+ self.orchestrator = SessionOrchestrator(
144
+ agent_id=self.agent_id,
145
+ channel=self.channel,
146
+ runtime=self.runtime,
147
+ storage=self.storage,
148
+ audit=self.audit,
149
+ timeout_budget=self.timeout_budget,
150
+ timer_wheel=self.timer_wheel,
151
+ runtime_idle_timeout_ms=runtime_idle_timeout_ms,
152
+ workspace=self.workspace_path,
153
+ translator=self.translator,
154
+ error_feedback_detail=self._error_feedback_detail,
155
+ concurrency=self._concurrency,
156
+ )
157
+ self.reminder_service = ReminderCommandService(
158
+ storage=self.storage,
159
+ concurrency=reminder_concurrency,
160
+ poke=reminder_poke,
161
+ )
162
+ self._provider_control_handler = (
163
+ factories.control(self._adapter_context())
164
+ if factories.control is not None
165
+ else None
166
+ )
167
+ self.command_dispatcher = CommandDispatcher(
168
+ self.orchestrator.command_service,
169
+ reminder_service=self.reminder_service,
170
+ timeout_budget=self.timeout_budget,
171
+ control_handler=(
172
+ self._handle_control
173
+ if self._provider_control_handler is not None
174
+ else None
175
+ ),
176
+ session_binding_validator=self._validate_session_binding,
177
+ )
178
+
179
+ @property
180
+ def started(self) -> bool:
181
+ return self._started
182
+
183
+ def workspace_path(self) -> Path:
184
+ return resolve_workspace_dir(self.agent_id)
185
+
186
+ async def start(self) -> None:
187
+ if self._started:
188
+ return
189
+ if self._stopping:
190
+ raise RuntimeError("Agent application is stopping")
191
+ workspace = self.workspace_path()
192
+ await asyncio.to_thread(
193
+ workspace.mkdir,
194
+ parents=True,
195
+ exist_ok=True,
196
+ mode=0o700,
197
+ )
198
+ if os.name != "nt":
199
+ await asyncio.to_thread(workspace.chmod, 0o700)
200
+ try:
201
+ self._wrapper_path = await asyncio.to_thread(
202
+ install_bcc_wrapper,
203
+ workspace / ".bcn" / "bin",
204
+ agent_id=self.agent_id,
205
+ )
206
+ await self._attachment_materializer.reconcile()
207
+ await self.orchestrator.start(
208
+ timeout=self.timeout_budget.startup_seconds,
209
+ )
210
+ except BaseException:
211
+ await self._cleanup_partial_start()
212
+ raise
213
+ self._started = True
214
+ self.command_dispatcher.start_accepting()
215
+
216
+ def _bot_name(self) -> str | None:
217
+ identity = self.channel.get_identity()
218
+ if identity is not None:
219
+ if identity.name is not None:
220
+ return identity.name
221
+ if identity.id is not None:
222
+ return identity.id
223
+ return None
224
+
225
+ async def stop(self) -> None:
226
+ if self._stopping:
227
+ return
228
+ self._stopping = True
229
+ self._started = False
230
+ self.command_dispatcher.stop_accepting()
231
+ errors: list[BaseException] = []
232
+ try:
233
+ await self.command_dispatcher.drain(
234
+ timeout=self.timeout_budget.shutdown_seconds,
235
+ )
236
+ except BaseException as error: # noqa: BLE001
237
+ errors.append(error)
238
+ try:
239
+ await self.orchestrator.stop(
240
+ timeout=self.timeout_budget.shutdown_seconds,
241
+ )
242
+ except BaseException as error: # noqa: BLE001
243
+ errors.append(error)
244
+ try:
245
+ await self._cleanup_bcc_wrapper()
246
+ except BaseException as error: # noqa: BLE001
247
+ errors.append(error)
248
+ self._session_capabilities.clear()
249
+ if errors:
250
+ raise RuntimeError(
251
+ "; ".join(type(error).__name__ for error in errors)
252
+ ) from errors[0]
253
+
254
+ async def dispatch(
255
+ self,
256
+ request: Mapping[str, object],
257
+ ) -> Mapping[str, object]:
258
+ return await self.command_dispatcher(request)
259
+
260
+ async def has_session(self, session_id: str) -> bool:
261
+ if not isinstance(session_id, str) or not session_id:
262
+ return False
263
+ async with self.storage.transaction() as transaction:
264
+ return await transaction.get_bcn_session(session_id) is not None
265
+
266
+ async def publish_reminder_wake(self, session_id: str) -> None:
267
+ if not self._started:
268
+ raise RuntimeError("Agent application is not started")
269
+ await self.orchestrator.publish_reminder_wake(session_id)
270
+
271
+ def health_record(self) -> dict[str, object]:
272
+ return {
273
+ "agent_id": self.agent_id,
274
+ "name": self.name,
275
+ "status": "started" if self.started else "stopped",
276
+ "channel": self.channel.name,
277
+ "runtime": self.runtime.name,
278
+ "channel_health": dict(self.channel.health),
279
+ }
280
+
281
+ async def _cleanup_partial_start(self) -> None:
282
+ try:
283
+ await self.orchestrator.stop(
284
+ timeout=self.timeout_budget.shutdown_seconds,
285
+ )
286
+ except BaseException as error:
287
+ self._logger.debug("orchestrator cleanup failed", exc_info=error)
288
+ try:
289
+ await self._cleanup_bcc_wrapper()
290
+ except BaseException as error:
291
+ self._logger.debug("bcc wrapper cleanup failed", exc_info=error)
292
+ self._session_capabilities.clear()
293
+
294
+ async def _cleanup_bcc_wrapper(self) -> None:
295
+ wrapper_path = self._wrapper_path
296
+ if wrapper_path is None:
297
+ return
298
+ await asyncio.to_thread(remove_bcc_wrapper, wrapper_path)
299
+ self._wrapper_path = None
300
+
301
+ async def _referenced_attachment_paths(self) -> set[str]:
302
+ async with self.storage.transaction() as transaction:
303
+ return set(await transaction.list_ready_attachment_paths())
304
+
305
+ def _adapter_context(self) -> Mapping[str, object]:
306
+ return {
307
+ "agent_id": self.agent_id,
308
+ "agent_name": self.name,
309
+ "channel": self.channel,
310
+ "runtime": self.runtime,
311
+ "storage": self.storage,
312
+ "audit": self.audit,
313
+ "command_log": self.command_log,
314
+ "is_started": lambda: self._started,
315
+ }
316
+
317
+ async def _handle_control(
318
+ self,
319
+ request: Mapping[str, object],
320
+ ) -> Mapping[str, object]:
321
+ session_id = request.get("session_id")
322
+ if not isinstance(session_id, str) or not session_id:
323
+ raise CommandDispatchError(
324
+ "SESSION_REQUIRED",
325
+ "session_id must be a non-empty string",
326
+ )
327
+ await self._validate_session_binding(session_id, request)
328
+ handler = self._provider_control_handler
329
+ if handler is None:
330
+ raise CommandDispatchError(
331
+ "INVALID_COMMAND",
332
+ "control operation is not supported",
333
+ )
334
+ return await handler(request)
335
+
336
+ async def _validate_session_binding(
337
+ self,
338
+ session_id: str,
339
+ request: Mapping[str, object],
340
+ ) -> None:
341
+ request_agent_id = request.get("agent_id")
342
+ if request_agent_id != self.agent_id:
343
+ raise CommandDispatchError(
344
+ "SESSION_BINDING_FAILED",
345
+ "Agent binding is invalid",
346
+ )
347
+ runtime_session_id = request.get("runtime_session_id")
348
+ session_capability = request.get("session_capability")
349
+ async with self.storage.transaction() as transaction:
350
+ if await transaction.get_bcn_session(session_id) is None:
351
+ raise CommandDispatchError(
352
+ "SESSION_NOT_FOUND",
353
+ f"unknown bcn session: {session_id}",
354
+ )
355
+ runtime_session = self.orchestrator.runtime_session(session_id)
356
+ if runtime_session is None or runtime_session_id != runtime_session.id:
357
+ raise CommandDispatchError(
358
+ "SESSION_BINDING_FAILED",
359
+ "runtime session binding is invalid",
360
+ )
361
+ binding = self._session_capabilities.get(session_id)
362
+ if (
363
+ binding is None
364
+ or binding.agent_id != self.agent_id
365
+ or binding.bcn_session_id != session_id
366
+ or binding.runtime_session_id != runtime_session.id
367
+ or not isinstance(session_capability, str)
368
+ or not hmac.compare_digest(
369
+ session_capability.encode(), binding.capability.encode()
370
+ )
371
+ ):
372
+ raise CommandDispatchError(
373
+ "SESSION_BINDING_FAILED",
374
+ "session capability is invalid",
375
+ )
376
+
377
+ def _runtime_environment(self, session: RuntimeSession) -> Mapping[str, str]:
378
+ runtime_session = self.orchestrator.runtime_session(session.bcn_session_id)
379
+ if runtime_session is None or runtime_session.id != session.id:
380
+ raise RuntimeError("runtime session is not the current live binding")
381
+ return self._build_command_environment(
382
+ session.bcn_session_id,
383
+ session.id,
384
+ )
385
+
386
+ def _build_command_environment(
387
+ self,
388
+ session_id: str,
389
+ runtime_session_id: str,
390
+ ) -> dict[str, str]:
391
+ if not session_id:
392
+ raise ValueError("session_id must be a non-empty string")
393
+ if not runtime_session_id:
394
+ raise ValueError("runtime_session_id must be a non-empty string")
395
+ wrapper_path = self._wrapper_path
396
+ if wrapper_path is None:
397
+ raise RuntimeError("bcc wrapper is not installed")
398
+ binding = self._session_capabilities.get(session_id)
399
+ if binding is None or binding.runtime_session_id != runtime_session_id:
400
+ capability = secrets.token_urlsafe(32)
401
+ token_values = [capability]
402
+ for name in self._runtime_environment_include:
403
+ if name != "TOKEN" and not name.endswith("_TOKEN"):
404
+ continue
405
+ value = os.environ.get(name)
406
+ if value and value not in token_values:
407
+ token_values.append(value)
408
+ binding = _SessionCapabilityBinding(
409
+ agent_id=self.agent_id,
410
+ bcn_session_id=session_id,
411
+ runtime_session_id=runtime_session_id,
412
+ capability=capability,
413
+ token_values=tuple(token_values),
414
+ )
415
+ self._session_capabilities[session_id] = binding
416
+ allowed = set(_PLATFORM_ENVIRONMENT)
417
+ for name in self.runtime.environment_variable_names():
418
+ if not _ENVIRONMENT_NAME.fullmatch(name):
419
+ raise ValueError(f"runtime environment name is invalid: {name}")
420
+ allowed.add(name)
421
+ for name in self._runtime_environment_include:
422
+ if not _ENVIRONMENT_NAME.fullmatch(name):
423
+ raise ValueError(f"runtime environment name is invalid: {name}")
424
+ if name not in os.environ:
425
+ raise ValueError(f"runtime environment variable is missing: {name}")
426
+ allowed.add(name)
427
+ environment = {
428
+ name: os.environ[name] for name in sorted(allowed) if os.environ.get(name)
429
+ }
430
+ environment["PATH"] = os.pathsep.join(
431
+ (str(wrapper_path.parent), environment.get("PATH", os.defpath))
432
+ )
433
+ environment.update(
434
+ {
435
+ "BCN_AGENT_ID": self.agent_id,
436
+ "BCN_ENDPOINT": self._endpoint(),
437
+ "BCN_SESSION_ID": session_id,
438
+ "BCN_RUNTIME_SESSION_ID": runtime_session_id,
439
+ "BCN_COMMAND_CAPABILITY": binding.capability,
440
+ }
441
+ )
442
+ return environment
443
+
444
+ def _error_feedback_detail(
445
+ self,
446
+ session_id: str,
447
+ error_message: str,
448
+ ) -> str:
449
+ if not isinstance(session_id, str) or not session_id:
450
+ raise ValueError("session_id must be a non-empty string")
451
+ if not isinstance(error_message, str):
452
+ raise TypeError("error_message must be text")
453
+ binding = self._session_capabilities.get(session_id)
454
+ if binding is None:
455
+ return error_message
456
+ for token in binding.token_values:
457
+ error_message = error_message.replace(token, "<redacted>")
458
+ return error_message
459
+
460
+ async def _run_runtime_command(
461
+ self,
462
+ session_id: str,
463
+ arguments: Sequence[str],
464
+ body: str | None,
465
+ ) -> None:
466
+ if not session_id:
467
+ raise ValueError("session_id must be a non-empty string")
468
+ if not arguments or any(
469
+ not isinstance(argument, str) for argument in arguments
470
+ ):
471
+ raise ValueError("runtime command arguments must be non-empty text")
472
+ wrapper_path = self._wrapper_path
473
+ if wrapper_path is None:
474
+ raise RuntimeError("bcc wrapper is not installed")
475
+ self.command_log.append((session_id, tuple(arguments)))
476
+ runtime_session = self.orchestrator.runtime_session(session_id)
477
+ if runtime_session is None:
478
+ raise RuntimeError("runtime session is not live")
479
+ environment = self._build_command_environment(
480
+ session_id,
481
+ runtime_session.id,
482
+ )
483
+ process = await asyncio.create_subprocess_exec(
484
+ str(wrapper_path),
485
+ *arguments,
486
+ stdin=asyncio.subprocess.PIPE,
487
+ stdout=asyncio.subprocess.PIPE,
488
+ stderr=asyncio.subprocess.PIPE,
489
+ env=environment,
490
+ )
491
+ input_data = body.encode() if body is not None else None
492
+ try:
493
+ _stdout, stderr = await process.communicate(input=input_data)
494
+ except asyncio.CancelledError:
495
+ if process.returncode is None:
496
+ process.terminate()
497
+ await process.wait()
498
+ raise
499
+ if process.returncode != 0:
500
+ error = stderr.decode(errors="replace").strip()
501
+ command = " ".join(arguments)
502
+ raise RuntimeError(f"bcc command failed ({command}): {error}")
503
+
504
+
505
+ __all__ = ["AgentApplication", "CommandRecord"]