mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-30 15:10:41 +00:00
Compare commits
29 Commits
v2.2.0-rc0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d50cbf8dc5 | ||
|
|
882433ae50 | ||
|
|
8062d4ba12 | ||
|
|
d8d0830e16 | ||
|
|
2488ba7c27 | ||
|
|
3f1438d4a0 | ||
|
|
b4b2a68ee5 | ||
|
|
c50cdc5161 | ||
|
|
9e6ce2f98e | ||
|
|
9e7448cabd | ||
|
|
b86be89f09 | ||
|
|
f7da737118 | ||
|
|
c28e64fc8f | ||
|
|
15433dda2a | ||
|
|
62de7f3425 | ||
|
|
8d52d2541d | ||
|
|
81e1ab8dcc | ||
|
|
4fd2b211cc | ||
|
|
1e24a2115c | ||
|
|
477e53386d | ||
|
|
771cf619c1 | ||
|
|
8d11aeb97b | ||
|
|
30aa601f6a | ||
|
|
44b88151be | ||
|
|
b3f23326a5 | ||
|
|
dedb154e4c | ||
|
|
6ca83a32cd | ||
|
|
5c4ba4d2fe | ||
|
|
76835c1ff2 |
16
AGENTS.md
16
AGENTS.md
@ -238,14 +238,12 @@ Repo-wide conventions:
|
|||||||
- **Skill text encoding** — use explicit UTF-8 for textual skill resources.
|
- **Skill text encoding** — use explicit UTF-8 for textual skill resources.
|
||||||
Read `SKILL.md` with `encoding="utf-8-sig"` to strip a leading BOM; write with
|
Read `SKILL.md` with `encoding="utf-8-sig"` to strip a leading BOM; write with
|
||||||
`encoding="utf-8"`. Never rely on the platform locale.
|
`encoding="utf-8"`. Never rely on the platform locale.
|
||||||
- **Version sources must stay in lockstep** — a release version must match identically in
|
- **Version lockstep** — keep `backend/pyproject.toml`,
|
||||||
`backend/pyproject.toml`, `backend/packages/harness/pyproject.toml`, `frontend/package.json`,
|
`backend/packages/harness/pyproject.toml`, `frontend/package.json`, and
|
||||||
and `deploy/helm/deer-flow/Chart.yaml` (`version` + `appVersion`), and `backend/uv.lock` must
|
`deploy/helm/deer-flow/Chart.yaml` (`version`/`appVersion`) identical.
|
||||||
record the same version for the root `deer-flow` and `deerflow-harness` workspace members
|
`backend/uv.lock` must match both `deer-flow` and `deerflow-harness` in PEP 440 form.
|
||||||
(uv stores its PEP 440 form, e.g. `2.1.0rc0`). Pushing a `v*` git tag triggers CI
|
Run `scripts/bump_version.sh <ver>` (requires `uv` on `PATH`) to update all five
|
||||||
that runs `scripts/verify_versions.sh` and **blocks all publishing** if any source drifts.
|
fields and the lock, then `scripts/verify_versions.sh <ver>`. `v*` tag CI blocks
|
||||||
Before bumping a version, run `scripts/bump_version.sh <ver>` (aligns the five fields and
|
all publishing on drift. See [RELEASING.md](RELEASING.md).
|
||||||
refreshes `backend/uv.lock` at once — it needs `uv` on `PATH`) and
|
|
||||||
`scripts/verify_versions.sh <ver>` to catch drift early. See [RELEASING.md](RELEASING.md).
|
|
||||||
- **Don't edit `CLAUDE.md`** — it only contains `@AGENTS.md`. All agent guidance changes
|
- **Don't edit `CLAUDE.md`** — it only contains `@AGENTS.md`. All agent guidance changes
|
||||||
belong here in `AGENTS.md`; `CLAUDE.md` is a thin import shim.
|
belong here in `AGENTS.md`; `CLAUDE.md` is a thin import shim.
|
||||||
|
|||||||
54
CHANGELOG.md
54
CHANGELOG.md
@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
This section accumulates work toward the **2.3.0** milestone
|
||||||
|
([2.3.0](https://github.com/bytedance/deer-flow/milestone/4)).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **dev:** `scripts/dev_multi_instance.sh logs all` now prints the nginx logs
|
||||||
|
as well as both Gateway logs; it printed only the Gateway logs, the same as
|
||||||
|
`logs` with no argument. `logs nginx` and `logs all` also include `nginx.log`,
|
||||||
|
the nginx start command's own output, which no option printed before.
|
||||||
|
([#7723])
|
||||||
|
|
||||||
|
- **memory:** `GET /api/memory/config` and `/api/memory/status` now mask
|
||||||
|
credential values in `backend_config` (`api_key`, tokens, passwords,
|
||||||
|
`Authorization` headers, at any depth) as `***`. They returned the config as
|
||||||
|
loaded, with `$VAR` references already resolved, so a DeerMem extraction
|
||||||
|
model key or a Honcho `api_key` reached every caller with `memory:read` in
|
||||||
|
plaintext, which with authorization disabled (the default) is every
|
||||||
|
signed-in user. Non-secret knobs, `api_key_env` names and unset values are
|
||||||
|
unchanged. ([#7665])
|
||||||
|
|
||||||
## [2.2.0] — 2026-10-29
|
## [2.2.0] — 2026-10-29
|
||||||
|
|
||||||
This section accumulates work toward the **2.2.0** milestone
|
This section accumulates work toward the **2.2.0** milestone
|
||||||
@ -13838,8 +13858,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
- **ci:** Consolidate PR/issue labeling and fix the reviewing-job crash and
|
- **ci:** Consolidate PR/issue labeling and fix the reviewing-job crash and
|
||||||
label thrash. ([#3455])
|
label thrash. ([#3455])
|
||||||
|
|
||||||
[Unreleased]: https://github.com/bytedance/deer-flow/compare/v2.2.0...HEAD
|
[Unreleased]: https://github.com/bytedance/deer-flow/compare/v2.1.0...HEAD
|
||||||
[2.2.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.2.0
|
|
||||||
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
|
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
|
||||||
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
|
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
|
||||||
|
|
||||||
@ -14681,7 +14700,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5217]: https://github.com/bytedance/deer-flow/pull/5217
|
[#5217]: https://github.com/bytedance/deer-flow/pull/5217
|
||||||
[#5219]: https://github.com/bytedance/deer-flow/pull/5219
|
[#5219]: https://github.com/bytedance/deer-flow/pull/5219
|
||||||
[#5221]: https://github.com/bytedance/deer-flow/pull/5221
|
[#5221]: https://github.com/bytedance/deer-flow/pull/5221
|
||||||
[#5224]: https://github.com/bytedance/deer-flow/pull/5224
|
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
|
||||||
[#5225]: https://github.com/bytedance/deer-flow/pull/5225
|
[#5225]: https://github.com/bytedance/deer-flow/pull/5225
|
||||||
[#5227]: https://github.com/bytedance/deer-flow/pull/5227
|
[#5227]: https://github.com/bytedance/deer-flow/pull/5227
|
||||||
[#5228]: https://github.com/bytedance/deer-flow/pull/5228
|
[#5228]: https://github.com/bytedance/deer-flow/pull/5228
|
||||||
@ -14690,9 +14709,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5236]: https://github.com/bytedance/deer-flow/pull/5236
|
[#5236]: https://github.com/bytedance/deer-flow/pull/5236
|
||||||
[#5238]: https://github.com/bytedance/deer-flow/pull/5238
|
[#5238]: https://github.com/bytedance/deer-flow/pull/5238
|
||||||
[#5239]: https://github.com/bytedance/deer-flow/pull/5239
|
[#5239]: https://github.com/bytedance/deer-flow/pull/5239
|
||||||
[#5244]: https://github.com/bytedance/deer-flow/pull/5244
|
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
|
||||||
[#5245]: https://github.com/bytedance/deer-flow/pull/5245
|
[#5245]: https://github.com/bytedance/deer-flow/pull/5245
|
||||||
[#5247]: https://github.com/bytedance/deer-flow/pull/5247
|
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
|
||||||
[#5249]: https://github.com/bytedance/deer-flow/pull/5249
|
[#5249]: https://github.com/bytedance/deer-flow/pull/5249
|
||||||
[#5251]: https://github.com/bytedance/deer-flow/pull/5251
|
[#5251]: https://github.com/bytedance/deer-flow/pull/5251
|
||||||
[#5254]: https://github.com/bytedance/deer-flow/pull/5254
|
[#5254]: https://github.com/bytedance/deer-flow/pull/5254
|
||||||
@ -14749,7 +14768,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5367]: https://github.com/bytedance/deer-flow/pull/5367
|
[#5367]: https://github.com/bytedance/deer-flow/pull/5367
|
||||||
[#5369]: https://github.com/bytedance/deer-flow/pull/5369
|
[#5369]: https://github.com/bytedance/deer-flow/pull/5369
|
||||||
[#5371]: https://github.com/bytedance/deer-flow/pull/5371
|
[#5371]: https://github.com/bytedance/deer-flow/pull/5371
|
||||||
[#5373]: https://github.com/bytedance/deer-flow/pull/5373
|
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
|
||||||
[#5374]: https://github.com/bytedance/deer-flow/pull/5374
|
[#5374]: https://github.com/bytedance/deer-flow/pull/5374
|
||||||
[#5375]: https://github.com/bytedance/deer-flow/pull/5375
|
[#5375]: https://github.com/bytedance/deer-flow/pull/5375
|
||||||
[#5377]: https://github.com/bytedance/deer-flow/pull/5377
|
[#5377]: https://github.com/bytedance/deer-flow/pull/5377
|
||||||
@ -14776,7 +14795,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5408]: https://github.com/bytedance/deer-flow/pull/5408
|
[#5408]: https://github.com/bytedance/deer-flow/pull/5408
|
||||||
[#5410]: https://github.com/bytedance/deer-flow/pull/5410
|
[#5410]: https://github.com/bytedance/deer-flow/pull/5410
|
||||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||||
[#5413]: https://github.com/bytedance/deer-flow/pull/5413
|
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
|
||||||
[#5415]: https://github.com/bytedance/deer-flow/pull/5415
|
[#5415]: https://github.com/bytedance/deer-flow/pull/5415
|
||||||
[#5416]: https://github.com/bytedance/deer-flow/pull/5416
|
[#5416]: https://github.com/bytedance/deer-flow/pull/5416
|
||||||
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
||||||
@ -14959,8 +14978,8 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5736]: https://github.com/bytedance/deer-flow/pull/5736
|
[#5736]: https://github.com/bytedance/deer-flow/pull/5736
|
||||||
[#5738]: https://github.com/bytedance/deer-flow/pull/5738
|
[#5738]: https://github.com/bytedance/deer-flow/pull/5738
|
||||||
[#5739]: https://github.com/bytedance/deer-flow/pull/5739
|
[#5739]: https://github.com/bytedance/deer-flow/pull/5739
|
||||||
[#5740]: https://github.com/bytedance/deer-flow/pull/5740
|
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
|
||||||
[#5741]: https://github.com/bytedance/deer-flow/pull/5741
|
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
|
||||||
[#5745]: https://github.com/bytedance/deer-flow/pull/5745
|
[#5745]: https://github.com/bytedance/deer-flow/pull/5745
|
||||||
[#5748]: https://github.com/bytedance/deer-flow/pull/5748
|
[#5748]: https://github.com/bytedance/deer-flow/pull/5748
|
||||||
[#5750]: https://github.com/bytedance/deer-flow/pull/5750
|
[#5750]: https://github.com/bytedance/deer-flow/pull/5750
|
||||||
@ -14973,7 +14992,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5760]: https://github.com/bytedance/deer-flow/pull/5760
|
[#5760]: https://github.com/bytedance/deer-flow/pull/5760
|
||||||
[#5761]: https://github.com/bytedance/deer-flow/pull/5761
|
[#5761]: https://github.com/bytedance/deer-flow/pull/5761
|
||||||
[#5762]: https://github.com/bytedance/deer-flow/pull/5762
|
[#5762]: https://github.com/bytedance/deer-flow/pull/5762
|
||||||
[#5763]: https://github.com/bytedance/deer-flow/pull/5763
|
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
|
||||||
[#5767]: https://github.com/bytedance/deer-flow/pull/5767
|
[#5767]: https://github.com/bytedance/deer-flow/pull/5767
|
||||||
[#5769]: https://github.com/bytedance/deer-flow/pull/5769
|
[#5769]: https://github.com/bytedance/deer-flow/pull/5769
|
||||||
[#5775]: https://github.com/bytedance/deer-flow/pull/5775
|
[#5775]: https://github.com/bytedance/deer-flow/pull/5775
|
||||||
@ -15041,7 +15060,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5880]: https://github.com/bytedance/deer-flow/pull/5880
|
[#5880]: https://github.com/bytedance/deer-flow/pull/5880
|
||||||
[#5881]: https://github.com/bytedance/deer-flow/pull/5881
|
[#5881]: https://github.com/bytedance/deer-flow/pull/5881
|
||||||
[#5883]: https://github.com/bytedance/deer-flow/pull/5883
|
[#5883]: https://github.com/bytedance/deer-flow/pull/5883
|
||||||
[#5884]: https://github.com/bytedance/deer-flow/pull/5884
|
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
|
||||||
[#5888]: https://github.com/bytedance/deer-flow/pull/5888
|
[#5888]: https://github.com/bytedance/deer-flow/pull/5888
|
||||||
[#5890]: https://github.com/bytedance/deer-flow/pull/5890
|
[#5890]: https://github.com/bytedance/deer-flow/pull/5890
|
||||||
[#5892]: https://github.com/bytedance/deer-flow/pull/5892
|
[#5892]: https://github.com/bytedance/deer-flow/pull/5892
|
||||||
@ -15103,9 +15122,9 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#5999]: https://github.com/bytedance/deer-flow/pull/5999
|
[#5999]: https://github.com/bytedance/deer-flow/pull/5999
|
||||||
[#6000]: https://github.com/bytedance/deer-flow/pull/6000
|
[#6000]: https://github.com/bytedance/deer-flow/pull/6000
|
||||||
[#6008]: https://github.com/bytedance/deer-flow/pull/6008
|
[#6008]: https://github.com/bytedance/deer-flow/pull/6008
|
||||||
[#6009]: https://github.com/bytedance/deer-flow/pull/6009
|
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
|
||||||
[#6013]: https://github.com/bytedance/deer-flow/pull/6013
|
[#6013]: https://github.com/bytedance/deer-flow/pull/6013
|
||||||
[#6015]: https://github.com/bytedance/deer-flow/pull/6015
|
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
|
||||||
[#6017]: https://github.com/bytedance/deer-flow/pull/6017
|
[#6017]: https://github.com/bytedance/deer-flow/pull/6017
|
||||||
[#6018]: https://github.com/bytedance/deer-flow/pull/6018
|
[#6018]: https://github.com/bytedance/deer-flow/pull/6018
|
||||||
[#6020]: https://github.com/bytedance/deer-flow/pull/6020
|
[#6020]: https://github.com/bytedance/deer-flow/pull/6020
|
||||||
@ -15129,7 +15148,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#6060]: https://github.com/bytedance/deer-flow/pull/6060
|
[#6060]: https://github.com/bytedance/deer-flow/pull/6060
|
||||||
[#6062]: https://github.com/bytedance/deer-flow/pull/6062
|
[#6062]: https://github.com/bytedance/deer-flow/pull/6062
|
||||||
[#6063]: https://github.com/bytedance/deer-flow/pull/6063
|
[#6063]: https://github.com/bytedance/deer-flow/pull/6063
|
||||||
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
|
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
|
||||||
[#6067]: https://github.com/bytedance/deer-flow/pull/6067
|
[#6067]: https://github.com/bytedance/deer-flow/pull/6067
|
||||||
[#6068]: https://github.com/bytedance/deer-flow/pull/6068
|
[#6068]: https://github.com/bytedance/deer-flow/pull/6068
|
||||||
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
||||||
@ -15155,7 +15174,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#6105]: https://github.com/bytedance/deer-flow/pull/6105
|
[#6105]: https://github.com/bytedance/deer-flow/pull/6105
|
||||||
[#6107]: https://github.com/bytedance/deer-flow/pull/6107
|
[#6107]: https://github.com/bytedance/deer-flow/pull/6107
|
||||||
[#6109]: https://github.com/bytedance/deer-flow/pull/6109
|
[#6109]: https://github.com/bytedance/deer-flow/pull/6109
|
||||||
[#6112]: https://github.com/bytedance/deer-flow/pull/6112
|
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
|
||||||
[#6113]: https://github.com/bytedance/deer-flow/pull/6113
|
[#6113]: https://github.com/bytedance/deer-flow/pull/6113
|
||||||
[#6115]: https://github.com/bytedance/deer-flow/pull/6115
|
[#6115]: https://github.com/bytedance/deer-flow/pull/6115
|
||||||
[#6117]: https://github.com/bytedance/deer-flow/pull/6117
|
[#6117]: https://github.com/bytedance/deer-flow/pull/6117
|
||||||
@ -15256,7 +15275,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#6298]: https://github.com/bytedance/deer-flow/pull/6298
|
[#6298]: https://github.com/bytedance/deer-flow/pull/6298
|
||||||
[#6299]: https://github.com/bytedance/deer-flow/pull/6299
|
[#6299]: https://github.com/bytedance/deer-flow/pull/6299
|
||||||
[#6302]: https://github.com/bytedance/deer-flow/pull/6302
|
[#6302]: https://github.com/bytedance/deer-flow/pull/6302
|
||||||
[#6304]: https://github.com/bytedance/deer-flow/pull/6304
|
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
|
||||||
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
|
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
|
||||||
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
|
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
|
||||||
[#6307]: https://github.com/bytedance/deer-flow/pull/6307
|
[#6307]: https://github.com/bytedance/deer-flow/pull/6307
|
||||||
@ -15640,6 +15659,7 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#7017]: https://github.com/bytedance/deer-flow/pull/7017
|
[#7017]: https://github.com/bytedance/deer-flow/pull/7017
|
||||||
[#7018]: https://github.com/bytedance/deer-flow/pull/7018
|
[#7018]: https://github.com/bytedance/deer-flow/pull/7018
|
||||||
[#7020]: https://github.com/bytedance/deer-flow/pull/7020
|
[#7020]: https://github.com/bytedance/deer-flow/pull/7020
|
||||||
|
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
|
||||||
[#7023]: https://github.com/bytedance/deer-flow/pull/7023
|
[#7023]: https://github.com/bytedance/deer-flow/pull/7023
|
||||||
[#7024]: https://github.com/bytedance/deer-flow/pull/7024
|
[#7024]: https://github.com/bytedance/deer-flow/pull/7024
|
||||||
[#7031]: https://github.com/bytedance/deer-flow/pull/7031
|
[#7031]: https://github.com/bytedance/deer-flow/pull/7031
|
||||||
@ -15912,3 +15932,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
|
|||||||
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
|
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
|
||||||
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
|
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
|
||||||
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
|
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
|
||||||
|
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
|
||||||
|
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
|
||||||
|
|||||||
@ -9,6 +9,22 @@
|
|||||||
|
|
||||||
## [未发布]
|
## [未发布]
|
||||||
|
|
||||||
|
本节累积面向 **2.3.0** 里程碑([2.3.0](https://github.com/bytedance/deer-flow/milestone/4))的工作。
|
||||||
|
截至 2026-10-28,该里程碑已合并 **1068 个拉取请求**。
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
|
||||||
|
- **开发:** `scripts/dev_multi_instance.sh logs all` 现在除两个 Gateway 日志外也会输出
|
||||||
|
nginx 日志;此前它只输出 Gateway 日志,与不带参数的 `logs` 相同。`logs nginx` 与
|
||||||
|
`logs all` 还会包含 `nginx.log`(nginx 启动命令自身的输出),此前没有任何选项会输出它。
|
||||||
|
([#7723])
|
||||||
|
|
||||||
|
- **记忆:** `GET /api/memory/config` 与 `/api/memory/status` 现在会把 `backend_config`
|
||||||
|
中的凭据值(`api_key`、token、密码、`Authorization` 请求头,任意层级)遮蔽为 `***`。
|
||||||
|
此前它们按加载结果原样返回配置,而 `$VAR` 引用此时已被解析,因此 DeerMem 提取模型的密钥
|
||||||
|
或 Honcho 的 `api_key` 会以明文返回给所有拥有 `memory:read` 的调用方;在默认关闭授权时,
|
||||||
|
这就是所有已登录用户。非机密配置项、`api_key_env` 变量名和未设置的值保持不变。([#7665])
|
||||||
|
|
||||||
## [2.2.0] — 2026-10-29
|
## [2.2.0] — 2026-10-29
|
||||||
|
|
||||||
本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。
|
本节累积面向 **2.2.0** 里程碑([2.2.0](https://github.com/bytedance/deer-flow/milestone/3))的工作。
|
||||||
@ -10609,8 +10625,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
- **CI:** 统一 PR / issue 打标签逻辑,修复 reviewing 任务的崩溃与标签抖动。
|
- **CI:** 统一 PR / issue 打标签逻辑,修复 reviewing 任务的崩溃与标签抖动。
|
||||||
([#3455])
|
([#3455])
|
||||||
|
|
||||||
[未发布]: https://github.com/bytedance/deer-flow/compare/v2.2.0...HEAD
|
[未发布]: https://github.com/bytedance/deer-flow/compare/v2.1.0...HEAD
|
||||||
[2.2.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.2.0
|
|
||||||
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
|
[2.1.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.1.0
|
||||||
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
|
[2.0.0]: https://github.com/bytedance/deer-flow/releases/tag/v2.0.0
|
||||||
|
|
||||||
@ -11452,7 +11467,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5217]: https://github.com/bytedance/deer-flow/pull/5217
|
[#5217]: https://github.com/bytedance/deer-flow/pull/5217
|
||||||
[#5219]: https://github.com/bytedance/deer-flow/pull/5219
|
[#5219]: https://github.com/bytedance/deer-flow/pull/5219
|
||||||
[#5221]: https://github.com/bytedance/deer-flow/pull/5221
|
[#5221]: https://github.com/bytedance/deer-flow/pull/5221
|
||||||
[#5224]: https://github.com/bytedance/deer-flow/pull/5224
|
[#5224]: https://github.com/bytedance/deer-flow/commit/572744975d369fda88168aaa7b481282f939affe
|
||||||
[#5225]: https://github.com/bytedance/deer-flow/pull/5225
|
[#5225]: https://github.com/bytedance/deer-flow/pull/5225
|
||||||
[#5227]: https://github.com/bytedance/deer-flow/pull/5227
|
[#5227]: https://github.com/bytedance/deer-flow/pull/5227
|
||||||
[#5228]: https://github.com/bytedance/deer-flow/pull/5228
|
[#5228]: https://github.com/bytedance/deer-flow/pull/5228
|
||||||
@ -11461,9 +11476,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5236]: https://github.com/bytedance/deer-flow/pull/5236
|
[#5236]: https://github.com/bytedance/deer-flow/pull/5236
|
||||||
[#5238]: https://github.com/bytedance/deer-flow/pull/5238
|
[#5238]: https://github.com/bytedance/deer-flow/pull/5238
|
||||||
[#5239]: https://github.com/bytedance/deer-flow/pull/5239
|
[#5239]: https://github.com/bytedance/deer-flow/pull/5239
|
||||||
[#5244]: https://github.com/bytedance/deer-flow/pull/5244
|
[#5244]: https://github.com/bytedance/deer-flow/commit/9ce6fdcb2252ea005b4fa98ca50bd3857e1f681a
|
||||||
[#5245]: https://github.com/bytedance/deer-flow/pull/5245
|
[#5245]: https://github.com/bytedance/deer-flow/pull/5245
|
||||||
[#5247]: https://github.com/bytedance/deer-flow/pull/5247
|
[#5247]: https://github.com/bytedance/deer-flow/commit/fa89a125267be4213009376ec0e232a0c2411ca8
|
||||||
[#5249]: https://github.com/bytedance/deer-flow/pull/5249
|
[#5249]: https://github.com/bytedance/deer-flow/pull/5249
|
||||||
[#5251]: https://github.com/bytedance/deer-flow/pull/5251
|
[#5251]: https://github.com/bytedance/deer-flow/pull/5251
|
||||||
[#5254]: https://github.com/bytedance/deer-flow/pull/5254
|
[#5254]: https://github.com/bytedance/deer-flow/pull/5254
|
||||||
@ -11520,7 +11535,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5367]: https://github.com/bytedance/deer-flow/pull/5367
|
[#5367]: https://github.com/bytedance/deer-flow/pull/5367
|
||||||
[#5369]: https://github.com/bytedance/deer-flow/pull/5369
|
[#5369]: https://github.com/bytedance/deer-flow/pull/5369
|
||||||
[#5371]: https://github.com/bytedance/deer-flow/pull/5371
|
[#5371]: https://github.com/bytedance/deer-flow/pull/5371
|
||||||
[#5373]: https://github.com/bytedance/deer-flow/pull/5373
|
[#5373]: https://github.com/bytedance/deer-flow/commit/d5ae3882b6708fa9af7289a0c3c0be3eee157203
|
||||||
[#5374]: https://github.com/bytedance/deer-flow/pull/5374
|
[#5374]: https://github.com/bytedance/deer-flow/pull/5374
|
||||||
[#5375]: https://github.com/bytedance/deer-flow/pull/5375
|
[#5375]: https://github.com/bytedance/deer-flow/pull/5375
|
||||||
[#5377]: https://github.com/bytedance/deer-flow/pull/5377
|
[#5377]: https://github.com/bytedance/deer-flow/pull/5377
|
||||||
@ -11547,7 +11562,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5408]: https://github.com/bytedance/deer-flow/pull/5408
|
[#5408]: https://github.com/bytedance/deer-flow/pull/5408
|
||||||
[#5410]: https://github.com/bytedance/deer-flow/pull/5410
|
[#5410]: https://github.com/bytedance/deer-flow/pull/5410
|
||||||
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
[#5411]: https://github.com/bytedance/deer-flow/pull/5411
|
||||||
[#5413]: https://github.com/bytedance/deer-flow/pull/5413
|
[#5413]: https://github.com/bytedance/deer-flow/commit/daf32cd24632f2cc7465aeb62f2a1e1e768eceb2
|
||||||
[#5415]: https://github.com/bytedance/deer-flow/pull/5415
|
[#5415]: https://github.com/bytedance/deer-flow/pull/5415
|
||||||
[#5416]: https://github.com/bytedance/deer-flow/pull/5416
|
[#5416]: https://github.com/bytedance/deer-flow/pull/5416
|
||||||
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
[#5418]: https://github.com/bytedance/deer-flow/pull/5418
|
||||||
@ -11730,8 +11745,8 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5736]: https://github.com/bytedance/deer-flow/pull/5736
|
[#5736]: https://github.com/bytedance/deer-flow/pull/5736
|
||||||
[#5738]: https://github.com/bytedance/deer-flow/pull/5738
|
[#5738]: https://github.com/bytedance/deer-flow/pull/5738
|
||||||
[#5739]: https://github.com/bytedance/deer-flow/pull/5739
|
[#5739]: https://github.com/bytedance/deer-flow/pull/5739
|
||||||
[#5740]: https://github.com/bytedance/deer-flow/pull/5740
|
[#5740]: https://github.com/bytedance/deer-flow/commit/e7128dc7d6b4d49153b10f7276f010384ec4e6af
|
||||||
[#5741]: https://github.com/bytedance/deer-flow/pull/5741
|
[#5741]: https://github.com/bytedance/deer-flow/commit/363b0fc1b4a5bd6c142d1f1df9fb9743cca550a3
|
||||||
[#5745]: https://github.com/bytedance/deer-flow/pull/5745
|
[#5745]: https://github.com/bytedance/deer-flow/pull/5745
|
||||||
[#5748]: https://github.com/bytedance/deer-flow/pull/5748
|
[#5748]: https://github.com/bytedance/deer-flow/pull/5748
|
||||||
[#5750]: https://github.com/bytedance/deer-flow/pull/5750
|
[#5750]: https://github.com/bytedance/deer-flow/pull/5750
|
||||||
@ -11744,7 +11759,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5760]: https://github.com/bytedance/deer-flow/pull/5760
|
[#5760]: https://github.com/bytedance/deer-flow/pull/5760
|
||||||
[#5761]: https://github.com/bytedance/deer-flow/pull/5761
|
[#5761]: https://github.com/bytedance/deer-flow/pull/5761
|
||||||
[#5762]: https://github.com/bytedance/deer-flow/pull/5762
|
[#5762]: https://github.com/bytedance/deer-flow/pull/5762
|
||||||
[#5763]: https://github.com/bytedance/deer-flow/pull/5763
|
[#5763]: https://github.com/bytedance/deer-flow/commit/39351315bf5d096fc84ad8307d3702b6aa5b3e24
|
||||||
[#5767]: https://github.com/bytedance/deer-flow/pull/5767
|
[#5767]: https://github.com/bytedance/deer-flow/pull/5767
|
||||||
[#5769]: https://github.com/bytedance/deer-flow/pull/5769
|
[#5769]: https://github.com/bytedance/deer-flow/pull/5769
|
||||||
[#5775]: https://github.com/bytedance/deer-flow/pull/5775
|
[#5775]: https://github.com/bytedance/deer-flow/pull/5775
|
||||||
@ -11812,7 +11827,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5880]: https://github.com/bytedance/deer-flow/pull/5880
|
[#5880]: https://github.com/bytedance/deer-flow/pull/5880
|
||||||
[#5881]: https://github.com/bytedance/deer-flow/pull/5881
|
[#5881]: https://github.com/bytedance/deer-flow/pull/5881
|
||||||
[#5883]: https://github.com/bytedance/deer-flow/pull/5883
|
[#5883]: https://github.com/bytedance/deer-flow/pull/5883
|
||||||
[#5884]: https://github.com/bytedance/deer-flow/pull/5884
|
[#5884]: https://github.com/bytedance/deer-flow/commit/e8cb5e9c4f5f274401eeb555aff62e713035cbf4
|
||||||
[#5888]: https://github.com/bytedance/deer-flow/pull/5888
|
[#5888]: https://github.com/bytedance/deer-flow/pull/5888
|
||||||
[#5890]: https://github.com/bytedance/deer-flow/pull/5890
|
[#5890]: https://github.com/bytedance/deer-flow/pull/5890
|
||||||
[#5892]: https://github.com/bytedance/deer-flow/pull/5892
|
[#5892]: https://github.com/bytedance/deer-flow/pull/5892
|
||||||
@ -11874,9 +11889,9 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#5999]: https://github.com/bytedance/deer-flow/pull/5999
|
[#5999]: https://github.com/bytedance/deer-flow/pull/5999
|
||||||
[#6000]: https://github.com/bytedance/deer-flow/pull/6000
|
[#6000]: https://github.com/bytedance/deer-flow/pull/6000
|
||||||
[#6008]: https://github.com/bytedance/deer-flow/pull/6008
|
[#6008]: https://github.com/bytedance/deer-flow/pull/6008
|
||||||
[#6009]: https://github.com/bytedance/deer-flow/pull/6009
|
[#6009]: https://github.com/bytedance/deer-flow/commit/b87ebd940227465d611084e5bf49582aaeb4e9bd
|
||||||
[#6013]: https://github.com/bytedance/deer-flow/pull/6013
|
[#6013]: https://github.com/bytedance/deer-flow/pull/6013
|
||||||
[#6015]: https://github.com/bytedance/deer-flow/pull/6015
|
[#6015]: https://github.com/bytedance/deer-flow/commit/f87a04e581829ff35afd4fa228240071b486192f
|
||||||
[#6017]: https://github.com/bytedance/deer-flow/pull/6017
|
[#6017]: https://github.com/bytedance/deer-flow/pull/6017
|
||||||
[#6018]: https://github.com/bytedance/deer-flow/pull/6018
|
[#6018]: https://github.com/bytedance/deer-flow/pull/6018
|
||||||
[#6020]: https://github.com/bytedance/deer-flow/pull/6020
|
[#6020]: https://github.com/bytedance/deer-flow/pull/6020
|
||||||
@ -11900,7 +11915,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#6060]: https://github.com/bytedance/deer-flow/pull/6060
|
[#6060]: https://github.com/bytedance/deer-flow/pull/6060
|
||||||
[#6062]: https://github.com/bytedance/deer-flow/pull/6062
|
[#6062]: https://github.com/bytedance/deer-flow/pull/6062
|
||||||
[#6063]: https://github.com/bytedance/deer-flow/pull/6063
|
[#6063]: https://github.com/bytedance/deer-flow/pull/6063
|
||||||
[#6066]: https://github.com/bytedance/deer-flow/pull/6066
|
[#6066]: https://github.com/bytedance/deer-flow/commit/73fdbcfdf4b5f4037b62bc2ce9d8b60ec04e69f8
|
||||||
[#6067]: https://github.com/bytedance/deer-flow/pull/6067
|
[#6067]: https://github.com/bytedance/deer-flow/pull/6067
|
||||||
[#6068]: https://github.com/bytedance/deer-flow/pull/6068
|
[#6068]: https://github.com/bytedance/deer-flow/pull/6068
|
||||||
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
[#6069]: https://github.com/bytedance/deer-flow/pull/6069
|
||||||
@ -11926,7 +11941,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#6105]: https://github.com/bytedance/deer-flow/pull/6105
|
[#6105]: https://github.com/bytedance/deer-flow/pull/6105
|
||||||
[#6107]: https://github.com/bytedance/deer-flow/pull/6107
|
[#6107]: https://github.com/bytedance/deer-flow/pull/6107
|
||||||
[#6109]: https://github.com/bytedance/deer-flow/pull/6109
|
[#6109]: https://github.com/bytedance/deer-flow/pull/6109
|
||||||
[#6112]: https://github.com/bytedance/deer-flow/pull/6112
|
[#6112]: https://github.com/bytedance/deer-flow/commit/52b21d13ac6e674a19cb76013d097e73f108c994
|
||||||
[#6113]: https://github.com/bytedance/deer-flow/pull/6113
|
[#6113]: https://github.com/bytedance/deer-flow/pull/6113
|
||||||
[#6115]: https://github.com/bytedance/deer-flow/pull/6115
|
[#6115]: https://github.com/bytedance/deer-flow/pull/6115
|
||||||
[#6117]: https://github.com/bytedance/deer-flow/pull/6117
|
[#6117]: https://github.com/bytedance/deer-flow/pull/6117
|
||||||
@ -12027,7 +12042,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#6298]: https://github.com/bytedance/deer-flow/pull/6298
|
[#6298]: https://github.com/bytedance/deer-flow/pull/6298
|
||||||
[#6299]: https://github.com/bytedance/deer-flow/pull/6299
|
[#6299]: https://github.com/bytedance/deer-flow/pull/6299
|
||||||
[#6302]: https://github.com/bytedance/deer-flow/pull/6302
|
[#6302]: https://github.com/bytedance/deer-flow/pull/6302
|
||||||
[#6304]: https://github.com/bytedance/deer-flow/pull/6304
|
[#6304]: https://github.com/bytedance/deer-flow/commit/b85e4ae74bb407e1e2396243a8a6e8f5b2420e3f
|
||||||
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
|
[#6305]: https://github.com/bytedance/deer-flow/pull/6305
|
||||||
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
|
[#6306]: https://github.com/bytedance/deer-flow/pull/6306
|
||||||
[#6307]: https://github.com/bytedance/deer-flow/pull/6307
|
[#6307]: https://github.com/bytedance/deer-flow/pull/6307
|
||||||
@ -12411,6 +12426,7 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#7017]: https://github.com/bytedance/deer-flow/pull/7017
|
[#7017]: https://github.com/bytedance/deer-flow/pull/7017
|
||||||
[#7018]: https://github.com/bytedance/deer-flow/pull/7018
|
[#7018]: https://github.com/bytedance/deer-flow/pull/7018
|
||||||
[#7020]: https://github.com/bytedance/deer-flow/pull/7020
|
[#7020]: https://github.com/bytedance/deer-flow/pull/7020
|
||||||
|
[#7022]: https://github.com/bytedance/deer-flow/issues/7022
|
||||||
[#7023]: https://github.com/bytedance/deer-flow/pull/7023
|
[#7023]: https://github.com/bytedance/deer-flow/pull/7023
|
||||||
[#7024]: https://github.com/bytedance/deer-flow/pull/7024
|
[#7024]: https://github.com/bytedance/deer-flow/pull/7024
|
||||||
[#7031]: https://github.com/bytedance/deer-flow/pull/7031
|
[#7031]: https://github.com/bytedance/deer-flow/pull/7031
|
||||||
@ -12683,3 +12699,5 @@ DeerFlow 2.0 是围绕"超级智能体"框架的彻底重写,核心包含子
|
|||||||
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
|
[#7643]: https://github.com/bytedance/deer-flow/pull/7643
|
||||||
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
|
[#7651]: https://github.com/bytedance/deer-flow/pull/7651
|
||||||
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
|
[#7659]: https://github.com/bytedance/deer-flow/pull/7659
|
||||||
|
[#7665]: https://github.com/bytedance/deer-flow/pull/7665
|
||||||
|
[#7723]: https://github.com/bytedance/deer-flow/pull/7723
|
||||||
|
|||||||
30
README.md
30
README.md
@ -694,7 +694,11 @@ DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser
|
|||||||
> The producer-lease recovery behavior above is gated by `stream_bridge.producer_lease_recovery_enabled`. Keep it disabled while old Gateway binaries or their runs remain; enable it on every instance only after the writer rollout and drain complete.
|
> The producer-lease recovery behavior above is gated by `stream_bridge.producer_lease_recovery_enabled`. Keep it disabled while old Gateway binaries or their runs remain; enable it on every instance only after the writer rollout and drain complete.
|
||||||
>
|
>
|
||||||
> With run ownership heartbeats enabled, terminal success requires acknowledgment
|
> With run ownership heartbeats enabled, terminal success requires acknowledgment
|
||||||
> from that worker's own durable write. A matching database row alone cannot
|
> from that worker's own durable write. Run creation and replacement serialize
|
||||||
|
> per conversation: a slow admission does not hold the manager-wide lock needed
|
||||||
|
> by another conversation's cached run reads or heartbeat snapshots. Their own
|
||||||
|
> Store I/O can still encounter database or pool contention. New runs remain
|
||||||
|
> hidden until their initial Store write succeeds. A matching database row alone cannot
|
||||||
> confirm success, and completion metadata cannot retry a rejected terminal
|
> confirm success, and completion metadata cannot retry a rejected terminal
|
||||||
> transition. Each run renews independently, so a stalled store operation does
|
> transition. Each run renews independently, so a stalled store operation does
|
||||||
> not delay other runs. If acknowledgment remains unavailable when the last
|
> not delay other runs. If acknowledgment remains unavailable when the last
|
||||||
@ -1341,6 +1345,7 @@ Notes:
|
|||||||
- Telegram shutdown retains ongoing cleanup across a timeout or cancellation so a retry can finish it. Stopping during startup also cancels initialization and closes the partially initialized bot's HTTP clients before retiring its worker loop. If the Telegram loop has already stopped, shutdown warns that download clients may remain open and releases the abandoned cleanup state; later stops do not repeat that warning.
|
- Telegram shutdown retains ongoing cleanup across a timeout or cancellation so a retry can finish it. Stopping during startup also cancels initialization and closes the partially initialized bot's HTTP clients before retiring its worker loop. If the Telegram loop has already stopped, shutdown warns that download clients may remain open and releases the abandoned cleanup state; later stops do not repeat that warning.
|
||||||
- Slack stop waits up to five seconds for an in-flight Socket Mode connection and its cleanup. If cleanup is still pending, the previous instance stays tracked and a restart can be retried after it finishes. SDK cleanup failures report their original error and remain retryable.
|
- Slack stop waits up to five seconds for an in-flight Socket Mode connection and its cleanup. If cleanup is still pending, the previous instance stays tracked and a restart can be retried after it finishes. SDK cleanup failures report their original error and remain retryable.
|
||||||
- In Telegram private chats, text, attachment and streaming replies (including "Working on it...") can still be delivered if their referenced user or bot message was deleted, such as after clearing chat history during a run. Group reply-target requirements remain unchanged to preserve forum routing.
|
- In Telegram private chats, text, attachment and streaming replies (including "Working on it...") can still be delivered if their referenced user or bot message was deleted, such as after clearing chat history during a run. Group reply-target requirements remain unchanged to preserve forum routing.
|
||||||
|
- In Telegram groups, replying to a delivered bot response or attachment continues its DeerFlow conversation. Reply mappings survive restarts and stay scoped to the user's connection when one is bound. New, unrelated group messages still start separate conversations; private-chat routing is unchanged.
|
||||||
- Feishu/Lark now queues rapid follow-up messages per mapped DeerFlow `thread_id` instead of immediately surfacing the generic busy reply, and topic replies keep a per-message card with a compact source-message preview across queued/running/final patches.
|
- Feishu/Lark now queues rapid follow-up messages per mapped DeerFlow `thread_id` instead of immediately surfacing the generic busy reply, and topic replies keep a per-message card with a compact source-message preview across queued/running/final patches.
|
||||||
- Streaming IM channels treat backend `error` events like transport failures and follow the same reply and retry path. The channel logs the error type and message for diagnosis.
|
- Streaming IM channels treat backend `error` events like transport failures and follow the same reply and retry path. The channel logs the error type and message for diagnosis.
|
||||||
|
|
||||||
@ -1769,6 +1774,8 @@ Discovery follows operator-managed directory symlinks, but skips links back to a
|
|||||||
|
|
||||||
Skill Markdown and bundled text resources use UTF-8. Skill-creator CLI and review utilities read and write text explicitly as UTF-8 so localized skills behave consistently across operating systems.
|
Skill Markdown and bundled text resources use UTF-8. Skill-creator CLI and review utilities read and write text explicitly as UTF-8 so localized skills behave consistently across operating systems.
|
||||||
|
|
||||||
|
For standalone skill exports, the bundled skill creator's `scripts/package_skill.py` can be run by file path from any directory, or as `python -m scripts.package_skill` from the skill-creator directory. Both entry points validate the skill before writing a `.skill` archive.
|
||||||
|
|
||||||
Custom-skill writes and security-scan staging preserve submitted line endings
|
Custom-skill writes and security-scan staging preserve submitted line endings
|
||||||
on every platform, so the scanner checks the bytes that will be stored.
|
on every platform, so the scanner checks the bytes that will be stored.
|
||||||
|
|
||||||
@ -2457,6 +2464,8 @@ Unsent Web UI composer drafts survive page reloads and switching between convers
|
|||||||
|
|
||||||
If an attachment upload reports `success: false`, the Web UI stops the send even when the HTTP response is 200. The upload error is shown and the composer keeps its draft and attachments for correction or retry. Files already accepted by a partially successful upload remain on the server; this does not roll them back.
|
If an attachment upload reports `success: false`, the Web UI stops the send even when the HTTP response is 200. The upload error is shown and the composer keeps its draft and attachments for correction or retry. Files already accepted by a partially successful upload remain on the server; this does not roll them back.
|
||||||
|
|
||||||
|
Document conversion reads the live PDF-converter setting and parses documents in a background worker, keeping configuration-file checks off the Gateway event loop.
|
||||||
|
|
||||||
The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported.
|
The Web UI composer supports `@` references at the cursor or through the `@` button. Search enabled skills, documents in the current project, and other conversations in a grouped picker; selected references appear inline alongside the draft text and can be removed with normal editing. Multiple skills can be selected in one message (up to 16 unique skills); selecting a checked skill again removes it, with each selection checked against the current user registry and agent allowlist. Project files are attached only after ingestion succeeds; conversation references require the `read_conversation` capability and retain its per-run limit. If capability discovery fails, saved conversation references remain intact and sending waits for a successful retry. Polishing preserves complete reference labels when instructions are reordered. The file group also offers local uploads. `/goal`, `/compact`, and legacy `/skill-name` input remain supported.
|
||||||
|
|
||||||
After picking an inline reference, typing continues immediately after it. Moving the cursor keeps the position you chose, including when editing text beside a reference.
|
After picking an inline reference, typing continues immediately after it. Moving the cursor keeps the position you chose, including when editing text beside a reference.
|
||||||
@ -2786,6 +2795,8 @@ Explicitly enabled nested `task` delegation propagates omitted opaque source IDs
|
|||||||
|
|
||||||
If an item's final lease expires after exhausting the retry budget, the batch reaches a terminal state once all items are terminal: `failed` when none succeeded, or `completed` when at least one succeeded. The item remains `failed` in both cases so partial results stay visible.
|
If an item's final lease expires after exhausting the retry budget, the batch reaches a terminal state once all items are terminal: `failed` when none succeeded, or `completed` when at least one succeeded. The item remains `failed` in both cases so partial results stay visible.
|
||||||
|
|
||||||
|
Paused batches also reconcile expired attempts after a worker exits. Retryable items remain queued without starting new work until you explicitly resume the batch; exhausted attempts become failed and can be retried individually.
|
||||||
|
|
||||||
Direct `create_deerflow_agent(...)` integrations can own the same boundary explicitly instead of relying on Gateway startup. Construct one `SubagentRuntime` and share it across every graph in that application; its `max_running`, ordinary per-run total, bound `task` tool, and optional durable-batch tools then use the same caller-owned snapshot and execution controller. A runtime with a batch repository owns a worker and must be started before graph construction and stopped during application shutdown:
|
Direct `create_deerflow_agent(...)` integrations can own the same boundary explicitly instead of relying on Gateway startup. Construct one `SubagentRuntime` and share it across every graph in that application; its `max_running`, ordinary per-run total, bound `task` tool, and optional durable-batch tools then use the same caller-owned snapshot and execution controller. A runtime with a batch repository owns a worker and must be started before graph construction and stopped during application shutdown:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@ -2812,6 +2823,8 @@ The factory still does not load YAML or create SQL infrastructure: the caller su
|
|||||||
|
|
||||||
Administrators can add, edit, disable, and delete reusable worker definitions from **Settings → Subagents**. Built-in and `config.yaml` definitions remain visible there as read-only entries. The default Lead Agent can use every enabled runtime sub-agent; each page-created Custom Agent can instead allow all, none, or a selected set. That selection is enforced both in the model-visible directory and by the server-side `task` tool. Managed definitions are deployment-wide in this version and follow `agent_storage.backend`: atomic files for a local deployment or the shared application database for multiple instances.
|
Administrators can add, edit, disable, and delete reusable worker definitions from **Settings → Subagents**. Built-in and `config.yaml` definitions remain visible there as read-only entries. The default Lead Agent can use every enabled runtime sub-agent; each page-created Custom Agent can instead allow all, none, or a selected set. That selection is enforced both in the model-visible directory and by the server-side `task` tool. Managed definitions are deployment-wide in this version and follow `agent_storage.backend`: atomic files for a local deployment or the shared application database for multiple instances.
|
||||||
|
|
||||||
|
Native workers support per-subagent `thinking_enabled` and `reasoning_effort` through YAML configuration and the managed-subagent API. Unconfigured workers keep the existing thinking-off request; the selected model's required-thinking policy, supported effort values and aliases still apply. These settings do not inherit the lead agent's thinking preferences.
|
||||||
|
|
||||||
For example, independent read-only research can run concurrently when the wall-clock savings outweigh duplicated discovery and synthesis cost, while a repository refactor with shared files and sequential test feedback remains with the lead agent. When `max_concurrent_subagents` is `1`, parallel and multi-batch routing guidance is disabled; delegation remains available only for material specialist or context-isolation benefit.
|
For example, independent read-only research can run concurrently when the wall-clock savings outweigh duplicated discovery and synthesis cost, while a repository refactor with shared files and sequential test feedback remains with the lead agent. When `max_concurrent_subagents` is `1`, parallel and multi-batch routing guidance is disabled; delegation remains available only for material specialist or context-isolation benefit.
|
||||||
|
|
||||||
### Sandbox & File System
|
### Sandbox & File System
|
||||||
@ -2976,6 +2989,8 @@ Each task gets its own execution environment with a full filesystem view — ski
|
|||||||
|
|
||||||
The read-before-write gate ties each read mark to that `read_file` call's result, including custom tools returning multi-message `Command` updates. An unrelated result cannot authorize a write after a failed read or hide a successful read.
|
The read-before-write gate ties each read mark to that `read_file` call's result, including custom tools returning multi-message `Command` updates. An unrelated result cannot authorize a write after a failed read or hide a successful read.
|
||||||
|
|
||||||
|
Deeply nested JSON returned by tools, including `read_file`, keeps its original content when result analysis reaches the JSON parser's depth limit. Explicit tool failures remain errors with an unknown category when depth prevents analysis.
|
||||||
|
|
||||||
On Linux and macOS, local sandbox `write_file` replacements and `str_replace` stage complete text before replacing the destination, so write, flush or publication errors retain the previous file. Existing write access is checked; ownership, permission bits, ACLs and extended attributes are transferred before writing staging content. Failure to transfer metadata aborts the save. Successful revisions get a fresh modification time, including empty saves; source-descriptor cleanup errors are warnings and do not change the save result. Files with set-id bits, macOS file flags or content-bound Linux privilege attributes are refused unchanged. Replacement requires parent-directory write access and publishes a new inode; other hard links retain the previous version. Symlinks follow the existing path-validation rules. Append writes, other host platforms (including Windows), shell writes and remote providers keep their existing behavior. This is not a concurrent-writer or power-loss recovery guarantee.
|
On Linux and macOS, local sandbox `write_file` replacements and `str_replace` stage complete text before replacing the destination, so write, flush or publication errors retain the previous file. Existing write access is checked; ownership, permission bits, ACLs and extended attributes are transferred before writing staging content. Failure to transfer metadata aborts the save. Successful revisions get a fresh modification time, including empty saves; source-descriptor cleanup errors are warnings and do not change the save result. Files with set-id bits, macOS file flags or content-bound Linux privilege attributes are refused unchanged. Replacement requires parent-directory write access and publishes a new inode; other hard links retain the previous version. Symlinks follow the existing path-validation rules. Append writes, other host platforms (including Windows), shell writes and remote providers keep their existing behavior. This is not a concurrent-writer or power-loss recovery guarantee.
|
||||||
|
|
||||||
Loop warnings let agents save collected results before their final answer. If the save is blocked by the read-before-write gate, the agent may read the target file and retry the save once. A looping or stopped tool must be replaced by an available alternative; if no permitted save path exists or the retry fails, the final answer includes the collected results and reports that the file was not saved. When `present_files` is available, the warning also asks the agent to present the files it already created in `/mnt/user-data/outputs` before answering, so finished outputs are delivered. Recovery calls still count toward the loop limits, and a hard stop prevents further tool execution.
|
Loop warnings let agents save collected results before their final answer. If the save is blocked by the read-before-write gate, the agent may read the target file and retry the save once. A looping or stopped tool must be replaced by an available alternative; if no permitted save path exists or the retry fails, the final answer includes the collected results and reports that the file was not saved. When `present_files` is available, the warning also asks the agent to present the files it already created in `/mnt/user-data/outputs` before answering, so finished outputs are delivered. Recovery calls still count toward the loop limits, and a hard stop prevents further tool execution.
|
||||||
@ -3047,7 +3062,7 @@ recording the others. A concurrent scratch-file removal therefore does not
|
|||||||
discard the whole snapshot or suppress detection of other produced outputs.
|
discard the whole snapshot or suppress detection of other produced outputs.
|
||||||
This remains a best-effort scan, not an atomic filesystem snapshot.
|
This remains a best-effort scan, not an atomic filesystem snapshot.
|
||||||
|
|
||||||
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback.
|
Files presented through `present_files` remain part of the thread's artifact state, and the Web UI restores the artifact panel and selected document after a page refresh. Long artifact lists scroll within the panel on desktop and in the mobile drawer, keeping every file reachable. When a completed response successfully presents between 2 and 50 files, its final file card also offers one ZIP download. Archive membership comes from the terminal delivery receipt rather than browser-supplied paths, and the ZIP contains the current file versions, which may have changed since the response. The currently selected formal artifact is refreshed once when the run finishes so edits become visible without a manual reload. Existing UTF-8 text artifacts under `/mnt/user-data/outputs` can also be edited and explicitly saved from the panel on Unix and Windows while the thread is idle; saves use content revisions to prevent overwriting agent changes. A delayed background read cannot replace a successfully saved revision in the editor; cancelled preview and full-file reads also abort their HTTP requests. Source previews also recognize extensionless `Dockerfile` and `Makefile` artifacts by their file names. Unknown file types, including names such as `constructor` and `__proto__`, retain the download fallback.
|
||||||
|
|
||||||
Unsaved artifact edits survive switching chats, workspace pages, or visiting the
|
Unsaved artifact edits survive switching chats, workspace pages, or visiting the
|
||||||
home page and returning in the same browser tab. Drafts stay in memory for the
|
home page and returning in the same browser tab. Drafts stay in memory for the
|
||||||
@ -3219,6 +3234,8 @@ Then uncomment the `group: browser` tool entries in `config.yaml` (`browser_navi
|
|||||||
|
|
||||||
Existing, non-mock Custom Agent chats expose the same Browser Live controls when browser control is available and the agent either leaves `tool_groups` unrestricted or includes the `browser` group. An explicit tool-group allowlist without `browser` keeps those controls hidden.
|
Existing, non-mock Custom Agent chats expose the same Browser Live controls when browser control is available and the agent either leaves `tool_groups` unrestricted or includes the `browser` group. An explicit tool-group allowlist without `browser` keeps those controls hidden.
|
||||||
|
|
||||||
|
Closing a login popup returns browser control to a surviving tab in the same session, preserving cookies and site storage. If every tab closes, the next browser action opens a tab in that same session. Recovered tabs are brought to the front in DeerFlow-managed browsers; CDP-attached Chrome keeps the user's current tab focus.
|
||||||
|
|
||||||
The workspace Browser Live client negotiates binary JPEG WebSocket frames,
|
The workspace Browser Live client negotiates binary JPEG WebSocket frames,
|
||||||
keeps only the newest pending frame per display refresh, and revokes replaced
|
keeps only the newest pending frame per display refresh, and revokes replaced
|
||||||
object URLs. Gateway control messages remain JSON, and clients that do not
|
object URLs. Gateway control messages remain JSON, and clients that do not
|
||||||
@ -3280,6 +3297,11 @@ become available in the next batch, when rejected additions can be retried.
|
|||||||
|
|
||||||
### Long-Term Memory
|
### Long-Term Memory
|
||||||
|
|
||||||
|
DeerMem trims oversized user/history summaries against the configured token
|
||||||
|
counter, including the omission marker and retained facts. Mixed-language text
|
||||||
|
does not rely on an average character/token ratio; the existing guaranteed-fact
|
||||||
|
allowance remains unchanged.
|
||||||
|
|
||||||
Settings > Appearance > Language also supplies the preferred language for replies
|
Settings > Appearance > Language also supplies the preferred language for replies
|
||||||
and new automatic DeerMem facts and summary updates on subsequent chat turns and
|
and new automatic DeerMem facts and summary updates on subsequent chat turns and
|
||||||
replays. Explicit user language requests take precedence. Existing memories are
|
replays. Explicit user language requests take precedence. Existing memories are
|
||||||
@ -3490,6 +3512,10 @@ Thread IDs may be supplied by callers and do not have to be UUIDs. Explicit
|
|||||||
IDs must contain 1–64 ASCII letters, digits, hyphens, or underscores
|
IDs must contain 1–64 ASCII letters, digits, hyphens, or underscores
|
||||||
(`^[A-Za-z0-9_-]{1,64}$`). DeerFlow generates a UUID only when `thread_id` is
|
(`^[A-Za-z0-9_-]{1,64}$`). DeerFlow generates a UUID only when `thread_id` is
|
||||||
omitted or `None`; an explicitly supplied empty string is invalid.
|
omitted or `None`; an explicitly supplied empty string is invalid.
|
||||||
|
Concurrent `POST /api/threads` requests and automatic run metadata creation share
|
||||||
|
same-ID admission in one Gateway, preserving the first thread's metadata and status.
|
||||||
|
HTTP retries also preserve its current checkpoint. SQL-backed Gateways retain
|
||||||
|
duplicate-insert recovery across workers.
|
||||||
Existing route-addressable threads created under older, looser rules remain
|
Existing route-addressable threads created under older, looser rules remain
|
||||||
readable and deletable, but cannot start new runs or create new filesystem or
|
readable and deletable, but cannot start new runs or create new filesystem or
|
||||||
sandbox state. Legacy deletion skips local path cleanup when the ID is not
|
sandbox state. Legacy deletion skips local path cleanup when the ID is not
|
||||||
|
|||||||
@ -156,8 +156,8 @@ to a boundary (SQLite) or causing the search to fail (PostgreSQL).
|
|||||||
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
| `POST /api/skills/install` | Install skill from `.skill` archive |
|
||||||
| `GET /api/memory` | Retrieve memory data |
|
| `GET /api/memory` | Retrieve memory data |
|
||||||
| `POST /api/memory/reload` | Force memory reload |
|
| `POST /api/memory/reload` | Force memory reload |
|
||||||
| `GET /api/memory/config` | Memory configuration |
|
| `GET /api/memory/config` | Memory configuration; credential values in `backend_config` are masked |
|
||||||
| `GET /api/memory/status` | Combined config + data |
|
| `GET /api/memory/status` | Combined config (masked the same way) + data |
|
||||||
| `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity |
|
| `GET /api/threads/{id}/runs/{run_id}/events` | Debug/audit events for one run; filter `event_types=context:memory` for effective memory identity |
|
||||||
| `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) |
|
| `POST /api/threads/{id}/uploads` | Upload files (optional PDF/PPT/Excel/Word-to-Markdown conversion, rejects directory paths, auto-renames duplicate filenames in one request) |
|
||||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||||
|
|||||||
@ -126,8 +126,8 @@ FastAPI 应用程序,为前端集成提供 REST 接口:
|
|||||||
| `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 |
|
| `POST /api/skills/install` | 从 `.skill` 归档文件安装技能 |
|
||||||
| `GET /api/memory` | 获取记忆数据 |
|
| `GET /api/memory` | 获取记忆数据 |
|
||||||
| `POST /api/memory/reload` | 强制重新加载记忆 |
|
| `POST /api/memory/reload` | 强制重新加载记忆 |
|
||||||
| `GET /api/memory/config` | 获取记忆配置 |
|
| `GET /api/memory/config` | 获取记忆配置;`backend_config` 中的凭据值会被遮蔽 |
|
||||||
| `GET /api/memory/status` | 获取组合后的配置和数据状态 |
|
| `GET /api/memory/status` | 获取组合后的配置(同样遮蔽凭据)和数据状态 |
|
||||||
| `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 |
|
| `GET /api/threads/{id}/runs/{run_id}/events` | 获取某次运行的调试或审计事件;可使用 `event_types=context:memory` 筛选实际记忆标识 |
|
||||||
| `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) |
|
| `POST /api/threads/{id}/uploads` | 上传文件(可选地将 PDF、PPT、Excel、Word 转换为 Markdown;拒绝目录路径;自动重命名单次请求中的重复文件名) |
|
||||||
| `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 |
|
| `GET /api/threads/{id}/uploads/list` | 列出已上传的文件 |
|
||||||
|
|||||||
@ -47,7 +47,7 @@ Shared attachment feedback uses public `attachment_delivery_text`, preserves rou
|
|||||||
6. Slack/Discord chat: `runs.wait()` → extract final response → publish outbound
|
6. Slack/Discord chat: `runs.wait()` → extract final response → publish outbound
|
||||||
6b. GitHub chat (`ChannelRunPolicy.fire_and_forget=True`): `runs.create()` returns once the run is `pending`; the manager does not wait for the final state and does not publish an outbound; the agent posts its own reply via sandbox `gh`. `ConflictError` on a busy thread still trips the standard `THREAD_BUSY_MESSAGE` path (log-only on GitHub); with `buffer_followups_on_busy=True` (GitHub's default) the triggering message is captured into a per-thread buffer so a concurrent comment is not silently dropped (see "Follow-up buffering while busy").
|
6b. GitHub chat (`ChannelRunPolicy.fire_and_forget=True`): `runs.create()` returns once the run is `pending`; the manager does not wait for the final state and does not publish an outbound; the agent posts its own reply via sandbox `gh`. `ConflictError` on a busy thread still trips the standard `THREAD_BUSY_MESSAGE` path (log-only on GitHub); with `buffer_followups_on_busy=True` (GitHub's default) the triggering message is captured into a per-thread buffer so a concurrent comment is not silently dropped (see "Follow-up buffering while busy").
|
||||||
7. Feishu patches one card per source (`config.update_multi=true`), with topic previews and queued → running → final states. Opt-in cards show context/question; bounded tokens preserve request ID/source. Originator/option checks consume once; consumption fails closed across workers (`feishu_card_interaction.py`). Card replies keep bound aliases in fields/metadata; ordinary replies preserve `/new`. Accepted intake retires only that card's buttons/text hint before terminal PATCH; rejected intake keeps text fallback. Non-final chunks store each thread alias once per reply; the final rewrites all.
|
7. Feishu patches one card per source (`config.update_multi=true`), with topic previews and queued → running → final states. Opt-in cards show context/question; bounded tokens preserve request ID/source. Originator/option checks consume once; consumption fails closed across workers (`feishu_card_interaction.py`). Card replies keep bound aliases in fields/metadata; ordinary replies preserve `/new`. Accepted intake retires only that card's buttons/text hint before terminal PATCH; rejected intake keeps text fallback. Non-final chunks store each thread alias once per reply; the final rewrites all.
|
||||||
8. Telegram edits the "Working on it..." stream target: 1s private/3s group throttle (20 msg/min), 4096-char truncation, rate-limited updates dropped; final edits split overflow. Text/preview/photo/document reply sends use `allow_sending_without_reply` only for `chat_id > 0`: missing private targets degrade atomically; group/forum routing and transport retries stay unchanged.
|
8. Telegram previews: 1s private/3s group, 20/min, 4096 chars; drop rate-limited updates, split final overflow. Only private sends (`chat_id > 0`) use `allow_sending_without_reply`; degrade missing targets atomically. Preserve group/forum routing/retries and durable owner aliases. Stream mapping errors log one traceback/answer, then debug; delivery/retries continue.
|
||||||
9. DingTalk AI Cards (`card_template_id`): `runs.stream()` → create card with initial text → `PUT /v1.0/card/streaming` → finalize (`is_final=True`). Creation/streaming failure uses `sampleMarkdown`; tables preserve escaped pipes and empty edge cells. Card calls get the channel token, not the SDK's.
|
9. DingTalk AI Cards (`card_template_id`): `runs.stream()` → create card with initial text → `PUT /v1.0/card/streaming` → finalize (`is_final=True`). Creation/streaming failure uses `sampleMarkdown`; tables preserve escaped pipes and empty edge cells. Card calls get the channel token, not the SDK's.
|
||||||
10. For commands (`/new`, `/status`, `/models`, `/model`, `/memory`, `/goal`, `/agent`, `/help`): handle locally or query Gateway API. `/agent list` reads the effective owner's Custom Agents; `/agent use <name>` validates in the same owner bucket, creates a new thread, and persists `channel_agent_name` in its metadata. A Custom Agent writes canonical `metadata.agent_name` so thread-search routes Web continuation to `/workspace/agents/<name>/chats/<thread_id>`; `lead_agent` omits it and stays on the ordinary chat route.
|
10. For commands (`/new`, `/status`, `/models`, `/model`, `/memory`, `/goal`, `/agent`, `/help`): handle locally or query Gateway API. `/agent list` reads the effective owner's Custom Agents; `/agent use <name>` validates in the same owner bucket, creates a new thread, and persists `channel_agent_name` in its metadata. A Custom Agent writes canonical `metadata.agent_name` so thread-search routes Web continuation to `/workspace/agents/<name>/chats/<thread_id>`; `lead_agent` omits it and stays on the ordinary chat route.
|
||||||
`/model <name>` persists `channel_model_name` in the *current* thread's metadata (in place, no fresh thread); `/model` reports the effective model, `/model default` clears it (stale pins self-clear).
|
`/model <name>` persists `channel_model_name` in the *current* thread's metadata (in place, no fresh thread); `/model` reports the effective model, `/model default` clears it (stale pins self-clear).
|
||||||
|
|||||||
@ -28,6 +28,7 @@ from app.channels.message_bus import (
|
|||||||
OutboundMessage,
|
OutboundMessage,
|
||||||
ResolvedAttachment,
|
ResolvedAttachment,
|
||||||
)
|
)
|
||||||
|
from deerflow.utils.atomic_write import publish_bytes_atomically
|
||||||
from deerflow.utils.file_io import await_drained
|
from deerflow.utils.file_io import await_drained
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -275,7 +276,10 @@ class DiscordChannel(Channel):
|
|||||||
try:
|
try:
|
||||||
snapshot = dict(self._active_threads)
|
snapshot = dict(self._active_threads)
|
||||||
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
|
self._thread_store_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._thread_store_path.write_text(json.dumps(snapshot, indent=2), encoding="utf-8", newline="")
|
# Publish through the shared helper: it fsyncs the staged bytes
|
||||||
|
# before the rename (so a machine crash cannot leave an empty
|
||||||
|
# mapping behind) and re-applies an existing file's mode.
|
||||||
|
publish_bytes_atomically(self._thread_store_path, json.dumps(snapshot, indent=2).encode("utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("[Discord] failed to persist thread mappings")
|
logger.exception("[Discord] failed to persist thread mappings")
|
||||||
|
|
||||||
|
|||||||
@ -323,14 +323,20 @@ class TelegramChannel(Channel):
|
|||||||
|
|
||||||
if not msg.is_final:
|
if not msg.is_final:
|
||||||
await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts))
|
await self._send_stream_update(chat_id, key, msg.text, reply_to=self._parse_message_id(msg.thread_ts))
|
||||||
|
state = self._stream_messages.get(key)
|
||||||
|
if state is not None:
|
||||||
|
binding = (msg.thread_id, msg.connection_id, msg.owner_user_id, msg.thread_ts, state["message_id"])
|
||||||
|
if state.get("thread_binding") != binding and await self._remember_reply_thread(msg, state["message_id"], stream_state=state):
|
||||||
|
state["thread_binding"] = binding
|
||||||
return
|
return
|
||||||
|
|
||||||
state = self._stream_messages.pop(key, None)
|
state = self._stream_messages.pop(key, None)
|
||||||
if state is not None:
|
if state is not None:
|
||||||
|
await self._remember_reply_thread(msg, state["message_id"], stream_state=state)
|
||||||
if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text):
|
if self._can_send_rich(msg.text) and await self._edit_rich_message(chat_id, state["message_id"], msg.text):
|
||||||
self._last_bot_message[msg.chat_id] = state["message_id"]
|
self._last_bot_message[msg.chat_id] = state["message_id"]
|
||||||
return
|
return
|
||||||
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text)
|
await self._finalize_stream_message(chat_id, msg.chat_id, state, msg.text, msg)
|
||||||
return
|
return
|
||||||
|
|
||||||
if self._can_send_rich(msg.text):
|
if self._can_send_rich(msg.text):
|
||||||
@ -340,10 +346,51 @@ class TelegramChannel(Channel):
|
|||||||
logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc)
|
logger.warning("[Telegram] Rich Message send failed in chat=%s; falling back to plain text: %s", chat_id, exc)
|
||||||
message_id = None
|
message_id = None
|
||||||
if message_id is not None:
|
if message_id is not None:
|
||||||
|
await self._remember_reply_thread(msg, message_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
for chunk in self._split_message(msg.text):
|
for chunk in self._split_message(msg.text):
|
||||||
await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
|
message_id = await self._send_new_message(chat_id, msg.chat_id, chunk, _max_retries=_max_retries)
|
||||||
|
await self._remember_reply_thread(msg, message_id)
|
||||||
|
|
||||||
|
async def _remember_reply_thread(self, msg: OutboundMessage, message_id: int | None, *, stream_state: dict[str, Any] | None = None) -> bool:
|
||||||
|
"""Keep group replies on their source thread, without undoing a later /new."""
|
||||||
|
if not msg.thread_id or message_id is None or int(msg.chat_id) >= 0:
|
||||||
|
return True
|
||||||
|
store = self.config.get("channel_store")
|
||||||
|
if msg.connection_id:
|
||||||
|
# Bound conversations must never fall back to the shared legacy store.
|
||||||
|
if self._connection_repo is None or not msg.owner_user_id:
|
||||||
|
return False
|
||||||
|
elif store is None:
|
||||||
|
return False
|
||||||
|
topic_ids = {str(message_id)}
|
||||||
|
if msg.thread_ts:
|
||||||
|
topic_ids.add(msg.thread_ts)
|
||||||
|
try:
|
||||||
|
for topic_id in topic_ids:
|
||||||
|
if msg.connection_id:
|
||||||
|
await self._connection_repo.claim_thread_id(
|
||||||
|
connection_id=msg.connection_id,
|
||||||
|
owner_user_id=msg.owner_user_id,
|
||||||
|
provider=self.name,
|
||||||
|
external_conversation_id=msg.chat_id,
|
||||||
|
external_topic_id=topic_id,
|
||||||
|
thread_id=msg.thread_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await store.claim_thread_id(self.name, msg.chat_id, msg.thread_id, topic_id=topic_id)
|
||||||
|
except Exception:
|
||||||
|
# Delivery has already succeeded. Retry the binding on the next update,
|
||||||
|
# not the Telegram send, which could duplicate a visible reply.
|
||||||
|
if stream_state is not None and stream_state.get("thread_binding_error_logged"):
|
||||||
|
logger.debug("[Telegram] reply thread mapping retry failed in chat=%s", msg.chat_id)
|
||||||
|
else:
|
||||||
|
logger.exception("[Telegram] failed to remember reply thread mapping in chat=%s", msg.chat_id)
|
||||||
|
if stream_state is not None:
|
||||||
|
stream_state["thread_binding_error_logged"] = True
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None:
|
async def _send_stream_update(self, chat_id: int, key: str, text: str, reply_to: int | None = None) -> None:
|
||||||
"""Edit the in-flight streamed message with accumulated text.
|
"""Edit the in-flight streamed message with accumulated text.
|
||||||
@ -409,7 +456,7 @@ class TelegramChannel(Channel):
|
|||||||
state["last_edit_at"] = _monotonic()
|
state["last_edit_at"] = _monotonic()
|
||||||
state["last_text"] = display
|
state["last_text"] = display
|
||||||
|
|
||||||
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str) -> None:
|
async def _finalize_stream_message(self, chat_id: int, chat_key: str, state: dict[str, Any], text: str, msg: OutboundMessage) -> None:
|
||||||
"""Apply the final text: edit the streamed message, splitting overflow into follow-ups."""
|
"""Apply the final text: edit the streamed message, splitting overflow into follow-ups."""
|
||||||
bot = self._application.bot
|
bot = self._application.bot
|
||||||
chunks = self._split_message(text or "")
|
chunks = self._split_message(text or "")
|
||||||
@ -423,10 +470,12 @@ class TelegramChannel(Channel):
|
|||||||
else:
|
else:
|
||||||
# Edit could not be applied (e.g. message deleted) — deliver the
|
# Edit could not be applied (e.g. message deleted) — deliver the
|
||||||
# first chunk as a fresh message with the standard retry policy.
|
# first chunk as a fresh message with the standard retry policy.
|
||||||
await self._send_new_message(chat_id, chat_key, chunks[0])
|
message_id = await self._send_new_message(chat_id, chat_key, chunks[0])
|
||||||
|
await self._remember_reply_thread(msg, message_id, stream_state=state)
|
||||||
|
|
||||||
for chunk in chunks[1:]:
|
for chunk in chunks[1:]:
|
||||||
await self._send_new_message(chat_id, chat_key, chunk)
|
message_id = await self._send_new_message(chat_id, chat_key, chunk)
|
||||||
|
await self._remember_reply_thread(msg, message_id, stream_state=state)
|
||||||
|
|
||||||
async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool:
|
async def _edit_final_chunk(self, bot, chat_id: int, message_id: int, text: str) -> bool:
|
||||||
"""Edit with one rate-limit retry. Returns False if the edit could not be applied."""
|
"""Edit with one rate-limit retry. Returns False if the edit could not be applied."""
|
||||||
@ -561,6 +610,7 @@ class TelegramChannel(Channel):
|
|||||||
sent = await bot.send_document(**kwargs)
|
sent = await bot.send_document(**kwargs)
|
||||||
|
|
||||||
self._last_bot_message[msg.chat_id] = sent.message_id
|
self._last_bot_message[msg.chat_id] = sent.message_id
|
||||||
|
await self._remember_reply_thread(msg, sent.message_id)
|
||||||
logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id)
|
logger.info("[Telegram] file sent: %s to chat=%s", attachment.filename, msg.chat_id)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -32,6 +32,7 @@ from app.channels.commands import is_known_channel_command
|
|||||||
from app.channels.connection_identity import attach_connection_identity
|
from app.channels.connection_identity import attach_connection_identity
|
||||||
from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix
|
from app.channels.credential_staging import cleanup_stale_credential_staging, credential_staging_prefix
|
||||||
from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files
|
from app.channels.message_bus import INBOUND_FILE_CLEANUP_KEY, InboundMessage, InboundMessageType, MessageBus, OutboundMessage, ResolvedAttachment, cleanup_inbound_files
|
||||||
|
from deerflow.utils.atomic_write import publish_bytes_atomically
|
||||||
from deerflow.utils.file_io import await_drained
|
from deerflow.utils.file_io import await_drained
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -1707,7 +1708,10 @@ class WechatChannel(Channel):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self._cursor_path.parent.mkdir(parents=True, exist_ok=True)
|
self._cursor_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
self._cursor_path.write_text(json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2), encoding="utf-8", newline="")
|
# Publish through the shared helper: it fsyncs the staged bytes
|
||||||
|
# before the rename (so a machine crash cannot leave a truncated
|
||||||
|
# cursor behind) and re-applies an existing file's mode.
|
||||||
|
publish_bytes_atomically(self._cursor_path, json.dumps({"get_updates_buf": self._get_updates_buf}, ensure_ascii=False, indent=2).encode("utf-8"))
|
||||||
except OSError:
|
except OSError:
|
||||||
logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path)
|
logger.warning("[WeChat] failed to persist cursor state to %s", self._cursor_path)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,11 @@ Reject external `sandbox`, `thread_data`, `viewed_images`, `goal_outcome`. Non-n
|
|||||||
|
|
||||||
Studio retains sanitized creation metadata.
|
Studio retains sanitized creation metadata.
|
||||||
|
|
||||||
|
HTTP and run metadata creation share `goal_thread_lock`. Lock in detached
|
||||||
|
`_ensure_thread_metadata`; preserve await-free attachment and cancel/drain
|
||||||
|
ownership. Its 5s budget covers admission and I/O: timeout fails strict starts;
|
||||||
|
others omit incarnation (MCP fails closed). Tests: `test_thread_create_run_metadata_race.py`.
|
||||||
|
|
||||||
Capability Center's `business` adapter validates bundled credentials and creates
|
Capability Center's `business` adapter validates bundled credentials and creates
|
||||||
an MCP connection. The MCP API permits only its isolated launcher and credential
|
an MCP connection. The MCP API permits only its isolated launcher and credential
|
||||||
environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
|
environment keys; reject arbitrary commands and manifest-policy bypasses. Keep
|
||||||
@ -76,28 +81,32 @@ OIDC redirects retain existing endpoint query bytes (RFC 6749 section 3.1).
|
|||||||
Malformed IdP bodies raise `OIDCError` (502/`sso_failed`).
|
Malformed IdP bodies raise `OIDCError` (502/`sso_failed`).
|
||||||
`tests/test_oidc_auth.py` pins query, state-cookie, nonce and PKCE agreement.
|
`tests/test_oidc_auth.py` pins query, state-cookie, nonce and PKCE agreement.
|
||||||
|
|
||||||
Personal Access Tokens (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) run as their owning user: an invalid Bearer is a hard 401 with no cookie fallback, which keeps `CSRFMiddleware`'s Bearer skip safe (origin checks still run). Scopes narrow within the allowlisted threads/runs/projects routes (including `POST /api/threads/{id}/move`); every other authenticated route 403s PAT callers (admin included). PAT management and `/change-password` require session auth; only SHA-256 digests are stored (`0017`).
|
PATs (`app.gateway.auth.pat`, `Authorization: Bearer dfp_...`) act as their owner.
|
||||||
|
Invalid Bearers return 401 without cookie fallback, keeping `CSRFMiddleware`'s
|
||||||
|
Bearer skip safe; origin checks still run. Scopes narrow allowlisted thread/run/
|
||||||
|
project routes, including `POST /api/threads/{id}/move`; other authenticated
|
||||||
|
routes reject PATs with 403, even for admins. PAT management and `/change-password`
|
||||||
|
require session auth. Store only SHA-256 digests (`0017`).
|
||||||
|
|
||||||
Thread→project membership is written by thread creation (`POST /api/threads` with
|
Thread→project membership is written only by thread creation (`POST /api/threads`
|
||||||
a validated `project_id`), branch creation (the new row inherits the source
|
with validated `project_id`), branch creation (inherit the source project;
|
||||||
thread's project; an archived/deleted project degrades the branch to unassigned
|
archived/deleted projects become unassigned rather than failing), and explicit
|
||||||
instead of failing), and explicit moves (`POST /api/threads/{id}/move`); run
|
`POST /api/threads/{id}/move`. Run admission pins resolved project context
|
||||||
admission never modifies membership; it only pins the resolved project context
|
read-only and never changes membership. Server-reserved `deerflow_project_id`
|
||||||
read-only into the run context. The server-reserved `deerflow_project_id`
|
metadata exposes `threads_meta.project_id` read-only and is stripped from client writes.
|
||||||
metadata key is a read-only exposure of the `threads_meta.project_id` column and
|
|
||||||
is stripped from client writes.
|
|
||||||
|
|
||||||
Localhost persistence deliberately reads the direct request `Host` and ignores `Forwarded` / `X-Forwarded-Host`. Scheme and auth-origin reconstruction still consume forwarding headers. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header, so the outer trusted proxy must replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
|
Localhost persistence trusts direct `Host`, not `Forwarded`/`X-Forwarded-Host`.
|
||||||
|
Scheme/auth-origin reconstruction still uses forwarding headers. Nginx sets
|
||||||
|
`X-Forwarded-Proto` but preserves upstream HTTPS and some other forwarded headers;
|
||||||
|
the outer trusted proxy must replace/strip client copies before DeerFlow.
|
||||||
|
|
||||||
Standalone local LangGraph Studio is recognized only through the upstream
|
Recognize standalone Studio only by upstream `Auth.types.StudioUser`, never its
|
||||||
`Auth.types.StudioUser` principal type, never by its reusable identity string.
|
reusable identity string. Resolve the type once at import; older SDKs without
|
||||||
The type is resolved once at import; an older SDK without it degrades to normal
|
it retain normal owner scoping. For its assistant reads/searches,
|
||||||
owner scoping.
|
`langgraph_auth.add_owner_filter` admits genuine server-registered assistants
|
||||||
For that principal's assistant reads/searches, `langgraph_auth.add_owner_filter`
|
and Studio-owned rows; other resources stay owner-scoped. Assistant create/update
|
||||||
selects genuine server-registered assistants plus assistants owned by Studio;
|
forces server-owned `user_id` and `created_by=user`: LangGraph treats
|
||||||
all other resources remain owner-scoped. Assistant create/update handlers make
|
`created_by=system` as privileged run ownership. The
|
||||||
both `user_id` and `created_by=user` server-owned, because LangGraph gives
|
|
||||||
`created_by=system` privileged ownership semantics during run creation. The
|
|
||||||
custom application module in `langgraph_studio.py` is imported before the
|
custom application module in `langgraph_studio.py` is imported before the
|
||||||
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
|
locked in-memory runtime lifespan. At that pre-runtime boundary it derives
|
||||||
genuine system assistant IDs from the CLI-provided graph registry, removes
|
genuine system assistant IDs from the CLI-provided graph registry, removes
|
||||||
|
|||||||
@ -1,5 +1,11 @@
|
|||||||
# Router contracts
|
# Router contracts
|
||||||
|
|
||||||
|
HTTP creation holds `goal_thread_lock` from the idempotency lookup through the
|
||||||
|
initial checkpoint write. Same-id HTTP creates must return the existing row,
|
||||||
|
including in memory mode; retain the native memory store's overwrite contract
|
||||||
|
and SQL duplicate-insert recovery across workers. Release on failure/cancellation
|
||||||
|
without blocking different thread IDs. Tests: `test_thread_create_idempotency.py`.
|
||||||
|
|
||||||
`GET /api/plugins/diagnostics` uses `require_admin_user` before reading the
|
`GET /api/plugins/diagnostics` uses `require_admin_user` before reading the
|
||||||
canonical runtime diagnostic snapshot. Return bounded entry-point identifiers,
|
canonical runtime diagnostic snapshot. Return bounded entry-point identifiers,
|
||||||
known levels and fixed summaries; raw messages stay in logs. Never clear the
|
known levels and fixed summaries; raw messages stay in logs. Never clear the
|
||||||
|
|||||||
@ -15,6 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_valida
|
|||||||
from app.gateway.deps import require_admin_user
|
from app.gateway.deps import require_admin_user
|
||||||
from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens
|
from app.gateway.mcp_oauth_tokens import forget_unconfigured_mcp_oauth_tokens
|
||||||
from app.gateway.persistent_writes import run_drained_write
|
from app.gateway.persistent_writes import run_drained_write
|
||||||
|
from app.gateway.secret_masking import MASKED_VALUE, mask_secret_values, normalize_config_key
|
||||||
from deerflow.config.extensions_config import (
|
from deerflow.config.extensions_config import (
|
||||||
ExtensionsConfig,
|
ExtensionsConfig,
|
||||||
McpRoutingConfig,
|
McpRoutingConfig,
|
||||||
@ -552,29 +553,19 @@ class McpCacheResetResponse(BaseModel):
|
|||||||
message: str = Field(description="Human-readable reset status")
|
message: str = Field(description="Human-readable reset status")
|
||||||
|
|
||||||
|
|
||||||
_MASKED_VALUE = "***"
|
_MASKED_VALUE = MASKED_VALUE
|
||||||
_SENSITIVE_EXTRA_KEY_RE = re.compile(
|
_SENSITIVE_EXTRA_KEY_RE = re.compile(
|
||||||
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
|
r"(^|_)(api_key|apikey|access_key|private_key|client_secret|secret|token|password|passwd|credential|credentials|authorization|bearer)(_|$)",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_config_key(key: str) -> str:
|
|
||||||
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
|
|
||||||
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
|
|
||||||
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_sensitive_extra_key(key: str) -> bool:
|
def _is_sensitive_extra_key(key: str) -> bool:
|
||||||
return bool(_SENSITIVE_EXTRA_KEY_RE.search(_normalize_config_key(key)))
|
return bool(_SENSITIVE_EXTRA_KEY_RE.search(normalize_config_key(key)))
|
||||||
|
|
||||||
|
|
||||||
def _mask_sensitive_extra_value(value: Any) -> Any:
|
def _mask_sensitive_extra_value(value: Any) -> Any:
|
||||||
if isinstance(value, dict):
|
return mask_secret_values(value, _is_sensitive_extra_key)
|
||||||
return {key: _MASKED_VALUE if _is_sensitive_extra_key(str(key)) else _mask_sensitive_extra_value(nested) for key, nested in value.items()}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_mask_sensitive_extra_value(item) for item in value]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool:
|
def _contains_masked_sensitive_extra_value(key: str, value: Any) -> bool:
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
"""Memory API router for retrieving and managing global memory data."""
|
"""Memory API router for retrieving and managing global memory data."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import re
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
@ -9,6 +10,7 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from app.gateway.authz import require_permission
|
from app.gateway.authz import require_permission
|
||||||
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
from app.gateway.internal_auth import get_trusted_internal_owner_user_id
|
||||||
from app.gateway.persistent_writes import run_drained_write
|
from app.gateway.persistent_writes import run_drained_write
|
||||||
|
from app.gateway.secret_masking import mask_secret_values, normalize_config_key
|
||||||
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
|
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError, MemoryManager, get_memory_manager
|
||||||
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
from deerflow.config.agents_config import AGENT_NAME_PATTERN
|
||||||
from deerflow.config.memory_config import get_memory_config
|
from deerflow.config.memory_config import get_memory_config
|
||||||
@ -234,6 +236,18 @@ class FactPatchRequest(BaseModel):
|
|||||||
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
|
confidence: float | None = Field(default=None, ge=0.0, le=1.0, description="Confidence score (0-1)")
|
||||||
|
|
||||||
|
|
||||||
|
# Anchored at the end of the key: DeerMem knobs such as ``token_counting`` and
|
||||||
|
# ``guaranteed_token_budget`` are not secrets, and ``api_key_env`` names an
|
||||||
|
# environment variable rather than holding the key. This is a denylist: plural
|
||||||
|
# ``tokens`` stays out because ``max_injection_tokens`` is a knob, and bare
|
||||||
|
# ``key``, ``auth`` or ``session`` are too generic to mask.
|
||||||
|
_SECRET_CONFIG_KEY_RE = re.compile(r"(^|_)(api_?keys?|access_keys?|secret_keys?|private_keys?|auth_keys?|secrets?|token|passwords?|passwds?|credentials?|authorization|bearer|cookies?)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret_config_key(key: str) -> bool:
|
||||||
|
return bool(_SECRET_CONFIG_KEY_RE.search(normalize_config_key(key)))
|
||||||
|
|
||||||
|
|
||||||
class MemoryConfigResponse(BaseModel):
|
class MemoryConfigResponse(BaseModel):
|
||||||
"""Response model for memory configuration."""
|
"""Response model for memory configuration."""
|
||||||
|
|
||||||
@ -242,7 +256,13 @@ class MemoryConfigResponse(BaseModel):
|
|||||||
injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).")
|
injection_enabled: bool = Field(..., description="Whether memory is injected into the system prompt (call-site gate).")
|
||||||
shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.")
|
shutdown_flush_timeout_seconds: float = Field(..., description="Hard budget (s) to drain pending memory updates on Gateway graceful shutdown; must fit inside the pod's K8s terminationGracePeriodSeconds.")
|
||||||
manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).")
|
manager_class: str = Field(..., description="Active memory backend selector (backend name or dotted path).")
|
||||||
backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend).")
|
backend_config: dict = Field(..., description="Backend-private config (self-interpreted by the backend); credential values such as api_key are masked as ***.")
|
||||||
|
|
||||||
|
@field_validator("backend_config")
|
||||||
|
@classmethod
|
||||||
|
def _mask_backend_secrets(cls, value: dict) -> dict:
|
||||||
|
"""Config loading already resolved ``$VAR`` references, so keys would be plaintext."""
|
||||||
|
return mask_secret_values(value, _is_secret_config_key, keep_unset=True)
|
||||||
|
|
||||||
|
|
||||||
class MemoryStatusResponse(BaseModel):
|
class MemoryStatusResponse(BaseModel):
|
||||||
|
|||||||
@ -35,6 +35,8 @@ class SubagentResponse(BaseModel):
|
|||||||
disallowed_tools: list[str] | None = None
|
disallowed_tools: list[str] | None = None
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
model: str = "inherit"
|
model: str = "inherit"
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
|
reasoning_effort: str | None = None
|
||||||
max_turns: int = 50
|
max_turns: int = 50
|
||||||
timeout_seconds: int = 900
|
timeout_seconds: int = 900
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@ -57,6 +59,8 @@ class ManagedSubagentCreateRequest(BaseModel):
|
|||||||
disallowed_tools: list[str] | None = None
|
disallowed_tools: list[str] | None = None
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
model: str = "inherit"
|
model: str = "inherit"
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
|
reasoning_effort: str | None = None
|
||||||
max_turns: int = Field(default=50, ge=1)
|
max_turns: int = Field(default=50, ge=1)
|
||||||
timeout_seconds: int = Field(default=900, ge=1)
|
timeout_seconds: int = Field(default=900, ge=1)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@ -70,6 +74,8 @@ class ManagedSubagentUpdateRequest(BaseModel):
|
|||||||
disallowed_tools: list[str] | None = None
|
disallowed_tools: list[str] | None = None
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
|
reasoning_effort: str | None = None
|
||||||
max_turns: int | None = Field(default=None, ge=1)
|
max_turns: int | None = Field(default=None, ge=1)
|
||||||
timeout_seconds: int | None = Field(default=None, ge=1)
|
timeout_seconds: int | None = Field(default=None, ge=1)
|
||||||
enabled: bool | None = None
|
enabled: bool | None = None
|
||||||
@ -94,7 +100,7 @@ def _explicit_overrides(name: str, app_config) -> dict[str, Any]:
|
|||||||
if override is None:
|
if override is None:
|
||||||
return {}
|
return {}
|
||||||
result: dict[str, Any] = {}
|
result: dict[str, Any] = {}
|
||||||
for field in ("timeout_seconds", "max_turns", "model", "skills"):
|
for field in ("timeout_seconds", "max_turns", "model", "thinking_enabled", "reasoning_effort", "skills"):
|
||||||
if field not in override.model_fields_set:
|
if field not in override.model_fields_set:
|
||||||
continue
|
continue
|
||||||
value = getattr(override, field)
|
value = getattr(override, field)
|
||||||
@ -119,6 +125,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
|
|||||||
disallowed_tools=definition.disallowed_tools,
|
disallowed_tools=definition.disallowed_tools,
|
||||||
skills=definition.skills,
|
skills=definition.skills,
|
||||||
model=definition.model,
|
model=definition.model,
|
||||||
|
thinking_enabled=definition.thinking_enabled,
|
||||||
|
reasoning_effort=definition.reasoning_effort,
|
||||||
max_turns=definition.max_turns,
|
max_turns=definition.max_turns,
|
||||||
timeout_seconds=definition.timeout_seconds,
|
timeout_seconds=definition.timeout_seconds,
|
||||||
source="builtin",
|
source="builtin",
|
||||||
@ -136,6 +144,8 @@ def _catalog(include_system_prompt: bool) -> SubagentsListResponse:
|
|||||||
disallowed_tools=definition.disallowed_tools,
|
disallowed_tools=definition.disallowed_tools,
|
||||||
skills=definition.skills,
|
skills=definition.skills,
|
||||||
model=definition.model,
|
model=definition.model,
|
||||||
|
thinking_enabled=definition.thinking_enabled,
|
||||||
|
reasoning_effort=definition.reasoning_effort,
|
||||||
max_turns=definition.max_turns,
|
max_turns=definition.max_turns,
|
||||||
timeout_seconds=definition.timeout_seconds,
|
timeout_seconds=definition.timeout_seconds,
|
||||||
source="config",
|
source="config",
|
||||||
|
|||||||
@ -965,11 +965,17 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
and an empty checkpoint (so state endpoints work immediately).
|
and an empty checkpoint (so state endpoints work immediately).
|
||||||
Idempotent: returns the existing record when ``thread_id`` already exists.
|
Idempotent: returns the existing record when ``thread_id`` already exists.
|
||||||
"""
|
"""
|
||||||
|
thread_id = resolve_thread_id(body.thread_id)
|
||||||
|
async with goal_thread_lock(thread_id):
|
||||||
|
return await _create_thread_locked(body, request, thread_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_thread_locked(body: ThreadCreateRequest, request: Request, thread_id: str) -> ThreadResponse:
|
||||||
|
"""Keep the idempotency read and initial writes in one lifecycle lock."""
|
||||||
from app.gateway.deps import get_thread_store
|
from app.gateway.deps import get_thread_store
|
||||||
|
|
||||||
checkpointer = get_checkpointer(request)
|
checkpointer = get_checkpointer(request)
|
||||||
thread_store = get_thread_store(request)
|
thread_store = get_thread_store(request)
|
||||||
thread_id = resolve_thread_id(body.thread_id)
|
|
||||||
now = now_iso()
|
now = now_iso()
|
||||||
thread_owner_user_id = get_trusted_internal_owner_user_id(request)
|
thread_owner_user_id = get_trusted_internal_owner_user_id(request)
|
||||||
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
|
thread_owner_kwargs = {"user_id": thread_owner_user_id} if thread_owner_user_id else {}
|
||||||
@ -1006,13 +1012,13 @@ async def create_thread(body: ThreadCreateRequest, request: Request) -> ThreadRe
|
|||||||
raise HTTPException(status_code=404, detail="Thread not found") from None
|
raise HTTPException(status_code=404, detail="Thread not found") from None
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
# The idempotency read above and this insert are not atomic: a
|
# The idempotency read above and this insert are not atomic: a
|
||||||
# concurrent request for the same thread_id can commit in between, so
|
# another worker's request for the same thread_id can commit in between, so
|
||||||
# the SQL-backed store rejects ours on the duplicate primary key.
|
# the SQL-backed store rejects ours on the duplicate primary key.
|
||||||
# Honour the documented idempotency contract by resolving the
|
# Honour the documented idempotency contract by resolving the
|
||||||
# now-existing record — running the same owner reconciliation the fast
|
# now-existing record — running the same owner reconciliation the fast
|
||||||
# path does — instead of surfacing the conflict as a 500. The memory
|
# path does — instead of surfacing the conflict as a 500. Same-process
|
||||||
# store serializes same-id creates under its per-thread lock and keeps
|
# HTTP creates share the lifecycle lock; the memory store retains its
|
||||||
# its historical overwrite behavior, so it does not reach this branch.
|
# historical overwrite behavior for direct callers.
|
||||||
existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs)
|
existing_record = await _resolve_existing_thread(thread_store, thread_id, thread_owner_user_id, thread_owner_kwargs)
|
||||||
if existing_record is not None:
|
if existing_record is not None:
|
||||||
return _existing_thread_response(thread_id, existing_record)
|
return _existing_thread_response(thread_id, existing_record)
|
||||||
|
|||||||
30
backend/app/gateway/secret_masking.py
Normal file
30
backend/app/gateway/secret_masking.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Shared masking for credential values that Gateway routes echo back from config."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MASKED_VALUE = "***"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_key(key: str) -> str:
|
||||||
|
"""Snake-case a config key so ``apiKey``, ``API-Key`` and ``api_key`` compare equal."""
|
||||||
|
with_boundaries = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", key)
|
||||||
|
with_boundaries = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", with_boundaries)
|
||||||
|
return re.sub(r"[^a-z0-9]+", "_", with_boundaries.lower()).strip("_")
|
||||||
|
|
||||||
|
|
||||||
|
def mask_secret_values(value: Any, is_secret_key: Callable[[str], bool], *, keep_unset: bool = False) -> Any:
|
||||||
|
"""Return a copy of ``value`` with every value under a secret-named key masked, at any depth.
|
||||||
|
|
||||||
|
A secret-named key masks its whole value, containers included: a
|
||||||
|
``credentials`` mapping can hold keys under arbitrary names, so masking only
|
||||||
|
its secret-named leaves could pass one through. With ``keep_unset``, a
|
||||||
|
``None`` or ``""`` value stays as it is, so a response still shows that no
|
||||||
|
secret is configured.
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: MASKED_VALUE if is_secret_key(str(key)) and not (keep_unset and nested in (None, "")) else mask_secret_values(nested, is_secret_key, keep_unset=keep_unset) for key, nested in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [mask_secret_values(item, is_secret_key, keep_unset=keep_unset) for item in value]
|
||||||
|
return value
|
||||||
@ -164,6 +164,8 @@ _TERMINAL_RUN_STATUSES = {
|
|||||||
_TERMINAL_STREAM_RECOVERED = "recovered"
|
_TERMINAL_STREAM_RECOVERED = "recovered"
|
||||||
_TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired"
|
_TERMINAL_STREAM_PRODUCER_EXPIRED = "producer_expired"
|
||||||
|
|
||||||
|
# Includes same-thread create admission and metadata I/O; timeout rejects strict
|
||||||
|
# starts and leaves non-strict starts without verified metadata (MCP fails closed).
|
||||||
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
|
_THREAD_METADATA_SETUP_TIMEOUT_SECONDS = 5.0
|
||||||
|
|
||||||
# Message metadata a scheduled launch puts on its prompt: the task, run and the
|
# Message metadata a scheduled launch puts on its prompt: the task, run and the
|
||||||
@ -261,6 +263,18 @@ async def _ensure_thread_metadata(
|
|||||||
require_existing_thread: bool = False,
|
require_existing_thread: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Ensure an admitted run's thread exists without delaying task attachment."""
|
"""Ensure an admitted run's thread exists without delaying task attachment."""
|
||||||
|
async with goal_thread_lock(record.thread_id):
|
||||||
|
return await _ensure_thread_metadata_locked(run_ctx, record, owner_user_id=owner_user_id, require_existing_thread=require_existing_thread)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_thread_metadata_locked(
|
||||||
|
run_ctx: RunContext,
|
||||||
|
record: RunRecord,
|
||||||
|
*,
|
||||||
|
owner_user_id: str | None,
|
||||||
|
require_existing_thread: bool,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Share create admission with HTTP while retaining detached task ownership."""
|
||||||
thread_store = run_ctx.thread_store
|
thread_store = run_ctx.thread_store
|
||||||
existing = await thread_store.get(record.thread_id)
|
existing = await thread_store.get(record.thread_id)
|
||||||
if existing is None and owner_user_id:
|
if existing is None and owner_user_id:
|
||||||
|
|||||||
@ -1689,11 +1689,60 @@ class McpTaskService:
|
|||||||
_CANCELLATION_DRAIN_TIMEOUT_SECONDS,
|
_CANCELLATION_DRAIN_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _log_pending_compensation_tasks(self, *, reason: str) -> None:
|
||||||
|
pending_count = sum(not task.done() for task in self._compensation_tasks)
|
||||||
|
if pending_count:
|
||||||
|
logger.warning(
|
||||||
|
"%s; %d MCP compensation task(s) remain in the background",
|
||||||
|
reason,
|
||||||
|
pending_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _drain_compensation_tasks(self, *, deadline: float) -> None:
|
||||||
|
"""Drain service-owned compensation work before Gateway dependencies close."""
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
while self._compensation_tasks:
|
||||||
|
tasks = tuple(self._compensation_tasks)
|
||||||
|
remaining = deadline - loop.time()
|
||||||
|
if remaining <= 0:
|
||||||
|
break
|
||||||
|
done, _ = await asyncio.wait(
|
||||||
|
tasks,
|
||||||
|
timeout=remaining,
|
||||||
|
return_when=asyncio.FIRST_COMPLETED,
|
||||||
|
)
|
||||||
|
if not done:
|
||||||
|
break
|
||||||
|
# Run completion callbacks before taking the next snapshot. A
|
||||||
|
# handoff may finish one task while registering another.
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
self._log_pending_compensation_tasks(
|
||||||
|
reason="MCP compensation drain reached its deadline",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _drain_compensation_tasks_preserving_cancellation(self, *, deadline: float) -> None:
|
||||||
|
"""Finish the bounded drain before propagating caller cancellation."""
|
||||||
|
drain_task = asyncio.create_task(
|
||||||
|
self._drain_compensation_tasks(deadline=deadline),
|
||||||
|
name="deerflow-mcp-compensation-drain",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.shield(drain_task)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await wait_for_task_until(drain_task, deadline=deadline)
|
||||||
|
if drain_task.done():
|
||||||
|
drain_task.result()
|
||||||
|
raise
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
task = self._task
|
task = self._task
|
||||||
if task is None:
|
if task is None:
|
||||||
|
await self._drain_compensation_tasks_preserving_cancellation(
|
||||||
|
deadline=loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
return
|
return
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
if self._stopping_task is not task:
|
if self._stopping_task is not task:
|
||||||
self._stopping_task = task
|
self._stopping_task = task
|
||||||
self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS
|
self._stop_deadline = loop.time() + _CANCELLATION_DRAIN_TIMEOUT_SECONDS
|
||||||
@ -1710,9 +1759,19 @@ class McpTaskService:
|
|||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
if not await wait_for_task_until(task, deadline=deadline):
|
if not await wait_for_task_until(task, deadline=deadline):
|
||||||
self._log_stop_timeout(task)
|
self._log_stop_timeout(task)
|
||||||
|
self._log_pending_compensation_tasks(
|
||||||
|
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
|
||||||
raise
|
raise
|
||||||
if task not in done:
|
if task not in done:
|
||||||
self._log_stop_timeout(task)
|
self._log_stop_timeout(task)
|
||||||
|
self._log_pending_compensation_tasks(
|
||||||
|
reason="MCP task poller cleanup reached its deadline before compensation drain could start",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
await self._drain_compensation_tasks_preserving_cancellation(deadline=deadline)
|
||||||
|
|
||||||
async def _run_loop(self) -> None:
|
async def _run_loop(self) -> None:
|
||||||
while not self._stop.is_set():
|
while not self._stop.is_set():
|
||||||
|
|||||||
@ -813,7 +813,7 @@ Notes:
|
|||||||
scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx
|
scripts/dev_multi_instance.sh up # containers, config, Gateway A, then B, then nginx
|
||||||
scripts/dev_multi_instance.sh check # automated cross-instance checks (below)
|
scripts/dev_multi_instance.sh check # automated cross-instance checks (below)
|
||||||
scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back
|
scripts/dev_multi_instance.sh stop b --kill # SIGKILL B to simulate a crash; `start b` brings it back
|
||||||
scripts/dev_multi_instance.sh logs a -f
|
scripts/dev_multi_instance.sh logs a -f # follow Gateway A; also b, nginx, or all (both Gateways and nginx)
|
||||||
scripts/dev_multi_instance.sh down
|
scripts/dev_multi_instance.sh down
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -333,6 +333,25 @@ Checkpointer storage runs in one of two channel modes, selected by `checkpoint_c
|
|||||||
|
|
||||||
## Deferred terminal commits and lease ownership
|
## Deferred terminal commits and lease ownership
|
||||||
|
|
||||||
|
RunManager serializes creation, replacement and assembly publication per thread.
|
||||||
|
The global registry lock never spans Store I/O; unrelated cached reads and
|
||||||
|
heartbeat snapshots do not queue behind an admission gate. Their own Store I/O
|
||||||
|
can still encounter database or connection-pool contention. A pending-ID map
|
||||||
|
lets readers of preallocated run IDs join the same-thread gate before the record
|
||||||
|
is published. Failed or cancelled admissions remove that temporary membership;
|
||||||
|
normal creation registers only after its initial write succeeds. Heartbeats use
|
||||||
|
the short registry snapshot, not the thread gate, so an admitted run can renew
|
||||||
|
while its replacement awaits a Store result. Preserve terminal-attribution and
|
||||||
|
cancellation compensation below; changing lock granularity does not grant a new
|
||||||
|
worker authority over a durable row.
|
||||||
|
|
||||||
|
Assembly evidence persists a full row snapshot. Serialize that write with the
|
||||||
|
same run's lease renewal and local acknowledgment, so it cannot restore an old
|
||||||
|
lease after renewal. Lock order is thread gate, then snapshot gate, then the
|
||||||
|
short registry lock. Renewal uses only the snapshot gate and must release it
|
||||||
|
before signaling local cancellation, which acquires the thread gate. Keep the
|
||||||
|
lease-expiry timer armed while waiting for the snapshot gate.
|
||||||
|
|
||||||
**Deferred terminal commit:** With an event store, the worker stages its terminal
|
**Deferred terminal commit:** With an event store, the worker stages its terminal
|
||||||
status locally and commits it only after finalization's receipt and duration
|
status locally and commits it only after finalization's receipt and duration
|
||||||
writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that
|
writes. `RunRecord.terminal_commit_pending` keeps `_renew_leases()` renewing that
|
||||||
|
|||||||
@ -58,15 +58,9 @@ logout and restores the complete old tree on any switch failure.
|
|||||||
Offload config/DDGS together. Only returned empties mean `No images found`;
|
Offload config/DDGS together. Only returned empties mean `No images found`;
|
||||||
the SDK sentinel can hide timeouts. See `tests/test_image_search_errors.py`.
|
the SDK sentinel can hide timeouts. See `tests/test_image_search_errors.py`.
|
||||||
|
|
||||||
### Browser Progress Screenshots (`community/browser_automation/`)
|
### Browser Automation
|
||||||
|
|
||||||
Hidden per-action browser progress frames use JPEG at quality 80 to keep their
|
Screenshot and session rules: [browser guidance](community/browser_automation/AGENTS.md).
|
||||||
storage and transfer cost bounded relative to lossless PNG. The explicit
|
|
||||||
`browser_screenshot` tool remains PNG because it creates a user-requested
|
|
||||||
artifact. New automatic capture entry points must reuse the shared progress
|
|
||||||
encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot
|
|
||||||
drift. `browser_screenshot` never replaces an `outputs/` file: it hard-links
|
|
||||||
onto the first free `-N` name.
|
|
||||||
|
|
||||||
### Embedded Client (`packages/harness/deerflow/client.py`)
|
### Embedded Client (`packages/harness/deerflow/client.py`)
|
||||||
|
|
||||||
|
|||||||
@ -383,6 +383,17 @@ Do not add embedded OpenViking imports, root-key access, or trusted identity hea
|
|||||||
Honcho is a remote HTTP adapter for user-model memory.
|
Honcho is a remote HTTP adapter for user-model memory.
|
||||||
It creates one workspace per resolved `user_id`.
|
It creates one workspace per resolved `user_id`.
|
||||||
A missing user fails closed to no memory.
|
A missing user fails closed to no memory.
|
||||||
|
Capture stable-ID messages incrementally using remote `deerflow_source_id`
|
||||||
|
metadata, scoped by session and including owner/role/text in the opaque identity.
|
||||||
|
Never deduplicate distinct source IDs by text. Consume every filtered lookup page
|
||||||
|
before appending a batch; failed/malformed lookups must not become blind replays.
|
||||||
|
Cap each lookup at 10 pages, aborting over-budget captures without partial results;
|
||||||
|
duplicate stored identities may span pages. Distinguish lookup/write warnings.
|
||||||
|
Bound batches to Honcho's 100-message limit. Keep lookup + append under the
|
||||||
|
process-local session lock; do not claim cross-process exactly-once delivery.
|
||||||
|
ID-less direct callers remain append-only; legacy records lack capture identities.
|
||||||
|
Pin cumulative graph turns, restart/compaction, failures and pagination through
|
||||||
|
the real HTTP client in `tests/test_honcho_incremental_capture.py`.
|
||||||
Its async methods offload synchronous HTTP work with `asyncio.to_thread`.
|
Its async methods offload synchronous HTTP work with `asyncio.to_thread`.
|
||||||
Honcho's `from_config` consumes the `should_keep_hidden_message` host hook.
|
Honcho's `from_config` consumes the `should_keep_hidden_message` host hook.
|
||||||
Its write path drops hidden human messages unless that hook accepts them;
|
Its write path drops hidden human messages unless that hook accepts them;
|
||||||
@ -411,6 +422,10 @@ Checkpoint reuse requires the block to exist before the run.
|
|||||||
Gateway input handling removes forged dynamic-context markers.
|
Gateway input handling removes forged dynamic-context markers.
|
||||||
|
|
||||||
`prompt.py::_count_tokens` controls the injection budget.
|
`prompt.py::_count_tokens` controls the injection budget.
|
||||||
|
Oversized summaries use bounded prefix search, counting each assembled candidate
|
||||||
|
with its omission marker, separators and protected Facts block. Only counted
|
||||||
|
fits replace the facts-only fallback; BPE prefix counts need not be monotone,
|
||||||
|
so the search guarantees a fitting candidate rather than the longest prefix.
|
||||||
Default `tiktoken` mode loads and caches its encoding lazily.
|
Default `tiktoken` mode loads and caches its encoding lazily.
|
||||||
A failed load uses character estimation for a 600-second cooldown.
|
A failed load uses character estimation for a 600-second cooldown.
|
||||||
Concurrent callers use character estimation while one load is active.
|
Concurrent callers use character estimation while one load is active.
|
||||||
|
|||||||
@ -734,27 +734,27 @@ def format_memory_for_injection(
|
|||||||
# equation collapses to the original prefix-truncation against
|
# equation collapses to the original prefix-truncation against
|
||||||
# ``max_tokens``, so backward compatibility is preserved.
|
# ``max_tokens``, so backward compatibility is preserved.
|
||||||
facts_block = (facts_header + "\n".join(all_fact_lines)) if all_fact_lines else ""
|
facts_block = (facts_header + "\n".join(all_fact_lines)) if all_fact_lines else ""
|
||||||
facts_block_tokens = _count_tokens(facts_block, use_tiktoken=use_tiktoken)
|
|
||||||
separator_tokens = _count_tokens("\n\n", use_tiktoken=use_tiktoken)
|
|
||||||
budget_for_non_facts = max(
|
|
||||||
0,
|
|
||||||
effective_limit - facts_block_tokens - (separator_tokens if facts_block else 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Build the preceding (non-facts) portion from *sections* excluding
|
# Build the preceding (non-facts) portion from *sections* excluding
|
||||||
# the trailing Facts block.
|
# the trailing Facts block.
|
||||||
preceding_sections = sections[:-1] if all_fact_lines else sections
|
preceding_sections = sections[:-1] if all_fact_lines else sections
|
||||||
preceding = "\n\n".join(preceding_sections)
|
preceding = "\n\n".join(preceding_sections)
|
||||||
|
|
||||||
|
result = facts_block
|
||||||
if preceding:
|
if preceding:
|
||||||
preceding_tokens = _count_tokens(preceding, use_tiktoken=use_tiktoken)
|
# Average density does not bound a prefix's cost, and BPE counts
|
||||||
if preceding_tokens > budget_for_non_facts:
|
# need not be monotone. Count each assembled candidate and retain
|
||||||
char_per_token = len(preceding) / max(preceding_tokens, 1)
|
# only fitting prefixes; this need not find the longest one.
|
||||||
target_chars = int(budget_for_non_facts * char_per_token * 0.95)
|
low, high = 0, len(preceding)
|
||||||
preceding = preceding[:target_chars].rstrip() + "\n..."
|
while low <= high:
|
||||||
result = (preceding + "\n\n" + facts_block) if facts_block else preceding
|
middle = (low + high) // 2
|
||||||
else:
|
candidate = preceding[:middle].rstrip() + "\n..."
|
||||||
result = facts_block
|
if facts_block:
|
||||||
|
candidate += "\n\n" + facts_block
|
||||||
|
if _count_tokens(candidate, use_tiktoken=use_tiktoken) <= effective_limit:
|
||||||
|
result = candidate
|
||||||
|
low = middle + 1
|
||||||
|
else:
|
||||||
|
high = middle - 1
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@ -71,6 +71,39 @@ cannot see each other's memory by construction.
|
|||||||
workspace-scoped search, while the passive per-turn write middleware is
|
workspace-scoped search, while the passive per-turn write middleware is
|
||||||
retained because Honcho's deriver learns from `add()` writes.
|
retained because Honcho's deriver learns from `add()` writes.
|
||||||
|
|
||||||
|
## Incremental conversation capture
|
||||||
|
|
||||||
|
Checkpoint history is cumulative. For messages with stable source IDs, the
|
||||||
|
adapter stores an opaque `deerflow_source_id` in Honcho message metadata and
|
||||||
|
checks the session's metadata-filtered `messages/list` endpoint before appending
|
||||||
|
new messages. This requires session-read access as well as message-write access.
|
||||||
|
Lookups and writes use batches of at most 100 messages. Each lookup requests
|
||||||
|
100 records per page and consumes at most 10 pages before publishing that batch.
|
||||||
|
All advertised pages must be consumed; a larger page count aborts capture rather
|
||||||
|
than using partial results. The budget allows multiple stored records per source
|
||||||
|
identity, but exceptionally duplicate-heavy histories may exceed it.
|
||||||
|
|
||||||
|
The identity includes the owner, source message ID, role, peer and normalized
|
||||||
|
text. Distinct messages with identical text are retained; edited source messages
|
||||||
|
are captured as new versions, not replacements of old Honcho records. Stored
|
||||||
|
identities survive manager/process restarts and overlapping compacted history.
|
||||||
|
An unavailable, malformed or over-budget lookup aborts that capture with a
|
||||||
|
`capture lookup failed` warning instead of assuming the history is new.
|
||||||
|
This is distinct from `write failed` for setup or message-write errors; a later
|
||||||
|
capture can retry normally once the lookup succeeds.
|
||||||
|
|
||||||
|
Lookup and append are serialized per endpoint/workspace/session within one
|
||||||
|
process, including across manager instances. This is **not exactly-once delivery**:
|
||||||
|
independent processes must serialize access to a session upstream. A retry can
|
||||||
|
recognize an earlier write whose response was lost once the committed records
|
||||||
|
are visible to the lookup, but Honcho does not provide an atomic deduplication
|
||||||
|
constraint here.
|
||||||
|
|
||||||
|
Direct callers without stable message IDs retain append-on-every-call behavior.
|
||||||
|
Records written before this metadata was introduced cannot be matched reliably:
|
||||||
|
the first capture after upgrading may replay that older history once. This
|
||||||
|
change does not remove historical duplicates or migrate existing records.
|
||||||
|
|
||||||
## Limitations
|
## Limitations
|
||||||
|
|
||||||
- No DeerMem-style fact CRUD: `create_fact`/`delete_fact`/`update_fact`,
|
- No DeerMem-style fact CRUD: `create_fact`/`delete_fact`/`update_fact`,
|
||||||
@ -81,8 +114,10 @@ cannot see each other's memory by construction.
|
|||||||
unsupported-operation error; conversation writes still happen through the
|
unsupported-operation error; conversation writes still happen through the
|
||||||
retained middleware. DeerMem remains the default backend.
|
retained middleware. DeerMem remains the default backend.
|
||||||
- No migration of existing DeerMem data.
|
- No migration of existing DeerMem data.
|
||||||
- Writes are fire-and-forget per call: a failed write is logged and dropped
|
- Writes run synchronously within each call. Failed captures are logged; no
|
||||||
(at-most-once); nothing is buffered locally, so `shutdown_flush` is a no-op.
|
retry is scheduled or buffered locally, so `shutdown_flush` is a no-op.
|
||||||
|
A later call can retry messages still present in its history, while stored
|
||||||
|
source identities suppress already committed messages.
|
||||||
- `agent_name` is not mapped — Honcho models the user, not per-agent facts.
|
- `agent_name` is not mapped — Honcho models the user, not per-agent facts.
|
||||||
|
|
||||||
## Async execution and failure behavior
|
## Async execution and failure behavior
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
"""Minimal synchronous Honcho v3 HTTP client for the memory backend.
|
"""Minimal synchronous Honcho v3 HTTP client for the memory backend.
|
||||||
|
|
||||||
Deliberately dependency-light: plain httpx against the v3 REST API (peers and
|
Deliberately dependency-light: plain httpx against the v3 REST API. Peers and
|
||||||
sessions are get-or-create server-side, so every call here is idempotent).
|
sessions are get-or-create server-side; message creation is append-only.
|
||||||
Moving to the official ``honcho-ai`` SDK is a possible follow-up, mirroring the
|
Moving to the official ``honcho-ai`` SDK is a possible follow-up, mirroring the
|
||||||
OpenViking custom-HTTP -> official-adapter arc.
|
OpenViking custom-HTTP -> official-adapter arc.
|
||||||
"""
|
"""
|
||||||
@ -14,6 +14,10 @@ import httpx
|
|||||||
|
|
||||||
from .config import HonchoConfig
|
from .config import HonchoConfig
|
||||||
|
|
||||||
|
SOURCE_ID_KEY = "deerflow_source_id"
|
||||||
|
MESSAGE_BATCH_SIZE = 100
|
||||||
|
MAX_LOOKUP_PAGES = 10
|
||||||
|
|
||||||
|
|
||||||
class HonchoRequestError(RuntimeError):
|
class HonchoRequestError(RuntimeError):
|
||||||
"""A Honcho API call failed (transport error or non-2xx response)."""
|
"""A Honcho API call failed (transport error or non-2xx response)."""
|
||||||
@ -57,9 +61,40 @@ class HonchoClient:
|
|||||||
def set_session_peers(self, workspace: str, session_id: str, peer_ids: list[str]) -> None:
|
def set_session_peers(self, workspace: str, session_id: str, peer_ids: list[str]) -> None:
|
||||||
self._post(f"/v3/workspaces/{workspace}/sessions/{session_id}/peers", {peer_id: {} for peer_id in peer_ids})
|
self._post(f"/v3/workspaces/{workspace}/sessions/{session_id}/peers", {peer_id: {} for peer_id in peer_ids})
|
||||||
|
|
||||||
def add_messages(self, workspace: str, session_id: str, messages: list[dict[str, str]]) -> None:
|
def add_messages(self, workspace: str, session_id: str, messages: list[dict[str, Any]]) -> None:
|
||||||
self._post(f"/v3/workspaces/{workspace}/sessions/{session_id}/messages", {"messages": messages})
|
self._post(f"/v3/workspaces/{workspace}/sessions/{session_id}/messages", {"messages": messages})
|
||||||
|
|
||||||
|
def existing_source_ids(self, workspace: str, session_id: str, source_ids: list[str]) -> set[str]:
|
||||||
|
"""Consume all lookup pages within a bounded budget, or fail the batch."""
|
||||||
|
if not source_ids:
|
||||||
|
return set()
|
||||||
|
requested = set(source_ids)
|
||||||
|
payload = {"filters": {"OR": [{"metadata": {SOURCE_ID_KEY: key}} for key in source_ids]}}
|
||||||
|
found: set[str] = set()
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
data = self._post(f"/v3/workspaces/{workspace}/sessions/{session_id}/messages/list?page={page}&size=100", payload)
|
||||||
|
if not isinstance(data, dict) or not isinstance(data.get("items"), list):
|
||||||
|
raise HonchoRequestError("Honcho message lookup returned an invalid page")
|
||||||
|
pages = data.get("pages")
|
||||||
|
if type(pages) is not int or pages < 0 or type(data.get("page")) is not int or data["page"] != page:
|
||||||
|
raise HonchoRequestError("Honcho message lookup returned invalid pagination")
|
||||||
|
if pages < page and not (page == 1 and pages == 0 and not data["items"]):
|
||||||
|
raise HonchoRequestError("Honcho message lookup returned inconsistent pagination")
|
||||||
|
# Duplicate stored identities can span multiple pages even for one
|
||||||
|
# requested key. Bound requests, not the number of matching rows.
|
||||||
|
if pages > MAX_LOOKUP_PAGES:
|
||||||
|
raise HonchoRequestError(f"Honcho message lookup exceeded the {MAX_LOOKUP_PAGES}-page limit")
|
||||||
|
for item in data["items"]:
|
||||||
|
metadata = item.get("metadata") if isinstance(item, dict) else None
|
||||||
|
key = metadata.get(SOURCE_ID_KEY) if isinstance(metadata, dict) else None
|
||||||
|
if not isinstance(key, str) or key not in requested:
|
||||||
|
raise HonchoRequestError("Honcho message lookup returned an invalid source identity")
|
||||||
|
found.add(key)
|
||||||
|
if page >= pages:
|
||||||
|
return found
|
||||||
|
page += 1
|
||||||
|
|
||||||
def working_representation(self, workspace: str, peer_id: str, *, max_conclusions: int = 25) -> str:
|
def working_representation(self, workspace: str, peer_id: str, *, max_conclusions: int = 25) -> str:
|
||||||
data = self._post(f"/v3/workspaces/{workspace}/peers/{peer_id}/representation", {"max_conclusions": max_conclusions})
|
data = self._post(f"/v3/workspaces/{workspace}/peers/{peer_id}/representation", {"max_conclusions": max_conclusions})
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
|
|||||||
@ -35,7 +35,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
|
import weakref
|
||||||
from typing import Any, ClassVar, Literal
|
from typing import Any, ClassVar, Literal
|
||||||
|
|
||||||
from pydantic import PrivateAttr
|
from pydantic import PrivateAttr
|
||||||
@ -43,12 +46,27 @@ from pydantic import PrivateAttr
|
|||||||
# ABC contract -- the ONE allowed `from deerflow` import in this backend folder.
|
# ABC contract -- the ONE allowed `from deerflow` import in this backend folder.
|
||||||
from deerflow.agents.memory.manager import MemoryManager, MemoryManagerError, MemoryReadError
|
from deerflow.agents.memory.manager import MemoryManager, MemoryManagerError, MemoryReadError
|
||||||
|
|
||||||
from .client import HonchoClient
|
from .client import MESSAGE_BATCH_SIZE, SOURCE_ID_KEY, HonchoClient
|
||||||
from .config import HonchoConfig, sanitize_id
|
from .config import HonchoConfig, sanitize_id
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_UTC_NOW_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
_UTC_NOW_FORMAT = "%Y-%m-%dT%H:%M:%SZ"
|
||||||
|
_CaptureKey = tuple[str, str, str]
|
||||||
|
_capture_locks: weakref.WeakValueDictionary[_CaptureKey, threading.RLock] = weakref.WeakValueDictionary()
|
||||||
|
_capture_locks_guard = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_lock(base_url: str, workspace: str, session_id: str) -> threading.RLock:
|
||||||
|
# Serialize lookup + append across managers in this process without retaining
|
||||||
|
# inactive sessions. Independent processes still need upstream admission.
|
||||||
|
key = (base_url.rstrip("/"), workspace, session_id)
|
||||||
|
with _capture_locks_guard:
|
||||||
|
lock = _capture_locks.get(key)
|
||||||
|
if lock is None:
|
||||||
|
lock = threading.RLock()
|
||||||
|
_capture_locks[key] = lock
|
||||||
|
return lock
|
||||||
|
|
||||||
|
|
||||||
def _now_iso() -> str:
|
def _now_iso() -> str:
|
||||||
@ -92,10 +110,10 @@ class HonchoMemoryManager(MemoryManager):
|
|||||||
"""MemoryManager backed by a Honcho v3 instance (self-hosted or hosted).
|
"""MemoryManager backed by a Honcho v3 instance (self-hosted or hosted).
|
||||||
|
|
||||||
Known limitation: ``search`` scopes ``category`` server-side to messages
|
Known limitation: ``search`` scopes ``category`` server-side to messages
|
||||||
stored with ``metadata.category``, but ``add()`` writes plain metadata-less
|
stored with ``metadata.category``, but ``add()`` sets only source-identity
|
||||||
messages and fact CRUD is unsupported, so nothing this backend stores ever
|
metadata and fact CRUD is unsupported, so nothing this backend stores ever
|
||||||
carries a category — category queries return empty results here until the
|
carries a category — category queries return empty results here until the
|
||||||
write path gains metadata support (see ``search``).
|
write path gains category support (see ``search``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_config: HonchoConfig = PrivateAttr(default=None)
|
_config: HonchoConfig = PrivateAttr(default=None)
|
||||||
@ -185,7 +203,8 @@ class HonchoMemoryManager(MemoryManager):
|
|||||||
return
|
return
|
||||||
user_peer = self._user_peer(user_id)
|
user_peer = self._user_peer(user_id)
|
||||||
assistant_peer = self._config.assistant_peer
|
assistant_peer = self._config.assistant_peer
|
||||||
outgoing: list[dict[str, str]] = []
|
outgoing: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
for message in messages or []:
|
for message in messages or []:
|
||||||
msg_type = getattr(message, "type", None)
|
msg_type = getattr(message, "type", None)
|
||||||
if msg_type == "human":
|
if msg_type == "human":
|
||||||
@ -202,22 +221,46 @@ class HonchoMemoryManager(MemoryManager):
|
|||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
if msg_type == "human":
|
if msg_type == "human":
|
||||||
outgoing.append({"peer_id": user_peer, "content": text[: self._config.message_char_limit]})
|
peer_id = user_peer
|
||||||
elif msg_type in ("ai", "AIMessageChunk"):
|
elif msg_type in ("ai", "AIMessageChunk"):
|
||||||
outgoing.append({"peer_id": assistant_peer, "content": text[: self._config.message_char_limit]})
|
peer_id = assistant_peer
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
item: dict[str, Any] = {"peer_id": peer_id, "content": text[: self._config.message_char_limit]}
|
||||||
|
message_id = getattr(message, "id", None)
|
||||||
|
if isinstance(message_id, str) and message_id:
|
||||||
|
# Include owner and role even when operators deliberately share
|
||||||
|
# workspace/peer overrides; edits of the same source remain new.
|
||||||
|
source = json.dumps([user_id, message_id, "human" if msg_type == "human" else "ai", peer_id, text], ensure_ascii=True)
|
||||||
|
source_id = hashlib.sha256(source.encode("utf-8")).hexdigest()
|
||||||
|
if source_id in seen:
|
||||||
|
continue
|
||||||
|
seen.add(source_id)
|
||||||
|
item["metadata"] = {SOURCE_ID_KEY: source_id}
|
||||||
|
outgoing.append(item)
|
||||||
if not outgoing:
|
if not outgoing:
|
||||||
return
|
return
|
||||||
session_id = f"df-{_stable_id(thread_id)}"
|
session_id = f"df-{_stable_id(thread_id)}"
|
||||||
|
operation = "write"
|
||||||
try:
|
try:
|
||||||
self._client.get_or_create_peer(workspace, user_peer)
|
with _capture_lock(self._config.base_url, workspace, session_id):
|
||||||
self._client.get_or_create_peer(workspace, assistant_peer)
|
self._client.get_or_create_peer(workspace, user_peer)
|
||||||
self._client.get_or_create_session(workspace, session_id)
|
self._client.get_or_create_peer(workspace, assistant_peer)
|
||||||
self._client.set_session_peers(workspace, session_id, [user_peer, assistant_peer])
|
self._client.get_or_create_session(workspace, session_id)
|
||||||
self._client.add_messages(workspace, session_id, outgoing)
|
self._client.set_session_peers(workspace, session_id, [user_peer, assistant_peer])
|
||||||
|
for offset in range(0, len(outgoing), MESSAGE_BATCH_SIZE):
|
||||||
|
batch = outgoing[offset : offset + MESSAGE_BATCH_SIZE]
|
||||||
|
source_ids = [item["metadata"][SOURCE_ID_KEY] for item in batch if "metadata" in item]
|
||||||
|
operation = "capture lookup"
|
||||||
|
existing = self._client.existing_source_ids(workspace, session_id, source_ids) if source_ids else set()
|
||||||
|
operation = "write"
|
||||||
|
pending = [item for item in batch if item.get("metadata", {}).get(SOURCE_ID_KEY) not in existing]
|
||||||
|
if pending:
|
||||||
|
self._client.add_messages(workspace, session_id, pending)
|
||||||
except MemoryManagerError:
|
except MemoryManagerError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("honcho memory: write failed for thread %s: %s", thread_id, exc)
|
logger.warning("honcho memory: %s failed for thread %s: %s", operation, thread_id, exc)
|
||||||
|
|
||||||
# ── Tier 1: read ─────────────────────────────────────────────────────
|
# ── Tier 1: read ─────────────────────────────────────────────────────
|
||||||
def get_context(
|
def get_context(
|
||||||
@ -251,10 +294,10 @@ class HonchoMemoryManager(MemoryManager):
|
|||||||
|
|
||||||
Known limitation: that filter only matches messages stored with
|
Known limitation: that filter only matches messages stored with
|
||||||
``metadata.category``, and nothing this backend writes has it — ``add()``
|
``metadata.category``, and nothing this backend writes has it — ``add()``
|
||||||
sends plain ``{"peer_id", "content"}`` messages with no metadata, and
|
sends source-identity metadata but no category, and
|
||||||
fact CRUD is unsupported, so no other write path can set it either. A
|
fact CRUD is unsupported, so no other write path can set it either. A
|
||||||
``category`` query therefore returns empty results in this backend
|
``category`` query therefore returns empty results in this backend
|
||||||
until the write path gains metadata support."""
|
until the write path gains category support."""
|
||||||
workspace = self._workspace(user_id)
|
workspace = self._workspace(user_id)
|
||||||
if workspace is None:
|
if workspace is None:
|
||||||
return []
|
return []
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
### Middleware Chain
|
### Middleware Chain
|
||||||
|
|
||||||
|
Tool-result JSON analysis is best-effort: parser depth failures must preserve the
|
||||||
|
original content and must not abort the graph. Explicit error results fall back
|
||||||
|
to `unknown` / recoverable / `try_alternative`, without classifying incidental
|
||||||
|
JSON fields as auth failures. Producer metadata remains authoritative. Keep
|
||||||
|
depth regressions in `tests/test_tool_result_meta_json_depth.py`, including real
|
||||||
|
LocalSandbox reads through both synchronous and asynchronous agent graphs.
|
||||||
|
Deep graph cases must establish a real parser depth failure or report a skip.
|
||||||
|
|
||||||
Async gate waiters own separate cross-loop futures. Cancel only their signal;
|
Async gate waiters own separate cross-loop futures. Cancel only their signal;
|
||||||
notify outside the state lock. `_await_off_thread` drains started I/O across
|
notify outside the state lock. `_await_off_thread` drains started I/O across
|
||||||
repeated cancellation.
|
repeated cancellation.
|
||||||
|
|||||||
@ -156,7 +156,7 @@ def _extract_json_error_text(content: str) -> str | None:
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
data = json.loads(content)
|
data = json.loads(content)
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError, RecursionError):
|
||||||
return None
|
return None
|
||||||
error = data.get("error") if isinstance(data, dict) else None
|
error = data.get("error") if isinstance(data, dict) else None
|
||||||
if not error:
|
if not error:
|
||||||
@ -305,9 +305,14 @@ def normalize_tool_message(msg: ToolMessage) -> ToolMessage:
|
|||||||
# the tool. Classify raw text only when the content is not valid JSON.
|
# the tool. Classify raw text only when the content is not valid JSON.
|
||||||
try:
|
try:
|
||||||
is_json_dict = isinstance(json.loads(content), dict)
|
is_json_dict = isinstance(json.loads(content), dict)
|
||||||
|
except RecursionError:
|
||||||
|
# Depth-limited analysis cannot establish an error category.
|
||||||
|
# Do not infer auth/stop from incidental fields in the raw JSON.
|
||||||
|
attrs = {**_UNKNOWN_ERROR}
|
||||||
except (json.JSONDecodeError, ValueError):
|
except (json.JSONDecodeError, ValueError):
|
||||||
is_json_dict = False
|
attrs = _classify_error_text(content)
|
||||||
attrs = {**_UNKNOWN_ERROR} if is_json_dict else _classify_error_text(content)
|
else:
|
||||||
|
attrs = {**_UNKNOWN_ERROR} if is_json_dict else _classify_error_text(content)
|
||||||
meta = _make_meta(status="error", source="tool_return", **attrs)
|
meta = _make_meta(status="error", source="tool_return", **attrs)
|
||||||
elif content.startswith(_ERROR_PREFIX):
|
elif content.startswith(_ERROR_PREFIX):
|
||||||
attrs = _classify_error_text(content[len(_ERROR_PREFIX) :])
|
attrs = _classify_error_text(content[len(_ERROR_PREFIX) :])
|
||||||
|
|||||||
@ -0,0 +1,27 @@
|
|||||||
|
# Browser Automation
|
||||||
|
|
||||||
|
### Browser Egress Resolution
|
||||||
|
|
||||||
|
SOCKS DNS runs on the private Playwright loop. Use a dedicated bounded executor
|
||||||
|
so timed-out lookups cannot starve that loop's request guard. Retain permits until
|
||||||
|
threads exit, including after loop shutdown; reject at capacity and propagate
|
||||||
|
ContextVars. Keep the Gateway and browser loops separate in starvation tests.
|
||||||
|
See `backend/tests/test_browser_egress.py`.
|
||||||
|
|
||||||
|
### Browser Progress Screenshots
|
||||||
|
|
||||||
|
Hidden per-action browser progress frames use JPEG at quality 80 to keep their
|
||||||
|
storage and transfer cost bounded relative to lossless PNG. The explicit
|
||||||
|
`browser_screenshot` tool remains PNG because it creates a user-requested
|
||||||
|
artifact. New automatic capture entry points must reuse the shared progress
|
||||||
|
encoding definition in `tools.py` so the byte encoding and `.jpg` suffix cannot
|
||||||
|
drift. `browser_screenshot` never replaces an `outputs/` file: it hard-links
|
||||||
|
onto the first free `-N` name.
|
||||||
|
|
||||||
|
### Session Lifecycle
|
||||||
|
|
||||||
|
Contexts own cookies and origin storage: retain a live context after page
|
||||||
|
closure; rebuild only when the browser disconnects or the context is gone.
|
||||||
|
Reset `_request_guard_bound` / `_page_listener_bound` only on context replacement.
|
||||||
|
Await `bring_to_front()` before `_set_active_page()` during managed-page
|
||||||
|
recovery; CDP sessions preserve user focus and intentionally skip the request guard.
|
||||||
@ -18,9 +18,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
|
import contextvars
|
||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -47,6 +50,49 @@ _HANDSHAKE_TIMEOUT_S = 15
|
|||||||
_RESOLVE_TIMEOUT_S = 15
|
_RESOLVE_TIMEOUT_S = 15
|
||||||
_CONNECT_TIMEOUT_S = 15
|
_CONNECT_TIMEOUT_S = 15
|
||||||
_CLOSE_TIMEOUT_S = 2
|
_CLOSE_TIMEOUT_S = 2
|
||||||
|
_EGRESS_RESOLVER_WORKERS = 8
|
||||||
|
|
||||||
|
# A timed-out synchronous resolver keeps running in its worker thread. Keep
|
||||||
|
# those calls out of the browser loop's default executor, and acquire a
|
||||||
|
# permit before submission so the dedicated executor never accumulates an
|
||||||
|
# unbounded work queue. Saturated egress fails closed instead of queuing DNS
|
||||||
|
# behind work that may itself be stuck.
|
||||||
|
_RESOLVER_EXECUTOR = ThreadPoolExecutor(max_workers=_EGRESS_RESOLVER_WORKERS, thread_name_prefix="browser-egress-dns")
|
||||||
|
_RESOLVER_SLOTS = threading.BoundedSemaphore(_EGRESS_RESOLVER_WORKERS)
|
||||||
|
|
||||||
|
|
||||||
|
class _ResolverCapacityExceeded(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _consume_resolver_result(future: asyncio.Future[object]) -> None:
|
||||||
|
"""Consume abandoned results after a timeout or cancelled SOCKS handler."""
|
||||||
|
try:
|
||||||
|
future.exception()
|
||||||
|
except (asyncio.CancelledError, Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_with_timeout(resolve: EgressResolver, host: str) -> list[str]:
|
||||||
|
slots = _RESOLVER_SLOTS
|
||||||
|
if not slots.acquire(blocking=False):
|
||||||
|
raise _ResolverCapacityExceeded("browser egress resolver capacity is full")
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
context = contextvars.copy_context()
|
||||||
|
try:
|
||||||
|
worker = _RESOLVER_EXECUTOR.submit(context.run, resolve, host)
|
||||||
|
except BaseException:
|
||||||
|
slots.release()
|
||||||
|
raise
|
||||||
|
# Release from the concurrent future's callback, which runs with the worker
|
||||||
|
# completion and does not depend on the event loop still being alive.
|
||||||
|
worker.add_done_callback(lambda _completed: slots.release())
|
||||||
|
future = asyncio.wrap_future(worker, loop=loop)
|
||||||
|
future.add_done_callback(_consume_resolver_result)
|
||||||
|
# Shield the executor future: cancellation of the SOCKS handler must not
|
||||||
|
# release its permit before the already-started resolver thread exits.
|
||||||
|
return await asyncio.wait_for(asyncio.shield(future), timeout=_RESOLVE_TIMEOUT_S)
|
||||||
|
|
||||||
|
|
||||||
class _RefusedRequest(Exception):
|
class _RefusedRequest(Exception):
|
||||||
@ -97,11 +143,15 @@ class BrowserEgressProxy:
|
|||||||
await _reply(writer, exc.reply)
|
await _reply(writer, exc.reply)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
addresses = await asyncio.wait_for(asyncio.to_thread(self._resolve, host), timeout=_RESOLVE_TIMEOUT_S)
|
addresses = await _resolve_with_timeout(self._resolve, host)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
logger.warning("browser egress refused for %s:%d: %s", host, port, exc)
|
logger.warning("browser egress refused for %s:%d: %s", host, port, exc)
|
||||||
await _reply(writer, _REPLY_NOT_ALLOWED)
|
await _reply(writer, _REPLY_NOT_ALLOWED)
|
||||||
return
|
return
|
||||||
|
except _ResolverCapacityExceeded:
|
||||||
|
logger.warning("browser egress resolution capacity full for %s:%d", host, port)
|
||||||
|
await _reply(writer, _REPLY_HOST_UNREACHABLE)
|
||||||
|
return
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
logger.warning("browser egress resolution timed out for %s:%d", host, port)
|
logger.warning("browser egress resolution timed out for %s:%d", host, port)
|
||||||
await _reply(writer, _REPLY_HOST_UNREACHABLE)
|
await _reply(writer, _REPLY_HOST_UNREACHABLE)
|
||||||
|
|||||||
@ -411,11 +411,25 @@ class BrowserSession:
|
|||||||
async with self._ensure_page_lock:
|
async with self._ensure_page_lock:
|
||||||
if self._page is not None and not self._page.is_closed():
|
if self._page is not None and not self._page.is_closed():
|
||||||
return self._page
|
return self._page
|
||||||
|
# A closed popup/tab does not end the browsing session. Reuse a
|
||||||
|
# surviving tab, or open one in the same context to retain cookies
|
||||||
|
# and storage. Closed contexts disappear from browser.contexts.
|
||||||
|
if self._browser is not None and self._browser.is_connected() and self._context is not None and self._context in self._browser.contexts:
|
||||||
|
pages = [page for page in self._context.pages if not page.is_closed()]
|
||||||
|
page = pages[-1] if pages else await self._context.new_page()
|
||||||
|
if not self._connected_over_cdp:
|
||||||
|
await page.bring_to_front()
|
||||||
|
self._set_active_page(page)
|
||||||
|
return self._page
|
||||||
|
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
if self._playwright is None:
|
if self._playwright is None:
|
||||||
self._playwright = await async_playwright().start()
|
self._playwright = await async_playwright().start()
|
||||||
|
|
||||||
|
# These registrations belong to the context being replaced.
|
||||||
|
self._request_guard_bound = False
|
||||||
|
self._page_listener_bound = False
|
||||||
if self._cdp_url:
|
if self._cdp_url:
|
||||||
# Attach to the user's running Chrome (started with
|
# Attach to the user's running Chrome (started with
|
||||||
# --remote-debugging-port). Reuse its default context + an existing
|
# --remote-debugging-port). Reuse its default context + an existing
|
||||||
|
|||||||
@ -106,6 +106,14 @@ class SubagentOverrideConfig(BaseModel):
|
|||||||
min_length=1,
|
min_length=1,
|
||||||
description="Model name for this subagent (None = inherit from parent agent)",
|
description="Model name for this subagent (None = inherit from parent agent)",
|
||||||
)
|
)
|
||||||
|
thinking_enabled: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether to enable thinking/reasoning for this subagent (None = no override)",
|
||||||
|
)
|
||||||
|
reasoning_effort: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Reasoning effort accepted or mapped by the selected model (None = use its default)",
|
||||||
|
)
|
||||||
skills: list[str] | None = Field(
|
skills: list[str] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
|
description="Skill names whitelist for this subagent (None = inherit all enabled skills, [] = no skills)",
|
||||||
@ -146,6 +154,14 @@ class CustomSubagentConfig(BaseModel):
|
|||||||
default="inherit",
|
default="inherit",
|
||||||
description="Model to use - 'inherit' uses parent's model",
|
description="Model to use - 'inherit' uses parent's model",
|
||||||
)
|
)
|
||||||
|
thinking_enabled: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Whether to enable thinking/reasoning for this subagent (None = no override)",
|
||||||
|
)
|
||||||
|
reasoning_effort: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Reasoning effort accepted or mapped by the selected model (None = use its default)",
|
||||||
|
)
|
||||||
max_turns: int = Field(
|
max_turns: int = Field(
|
||||||
default=50,
|
default=50,
|
||||||
ge=1,
|
ge=1,
|
||||||
@ -266,6 +282,20 @@ class SubagentsAppConfig(BaseModel):
|
|||||||
return override.skills
|
return override.skills
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def get_thinking_enabled_for(self, agent_name: str) -> bool | None:
|
||||||
|
"""Return an explicit per-agent thinking override, including False."""
|
||||||
|
override = self.agents.get(agent_name)
|
||||||
|
if override is not None and override.thinking_enabled is not None:
|
||||||
|
return override.thinking_enabled
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_reasoning_effort_for(self, agent_name: str) -> str | None:
|
||||||
|
"""Return the per-agent effort request; the selected model resolves it."""
|
||||||
|
override = self.agents.get(agent_name)
|
||||||
|
if override is not None and override.reasoning_effort is not None:
|
||||||
|
return override.reasoning_effort
|
||||||
|
return None
|
||||||
|
|
||||||
def get_token_budget_for(
|
def get_token_budget_for(
|
||||||
self,
|
self,
|
||||||
agent_name: str,
|
agent_name: str,
|
||||||
@ -331,6 +361,10 @@ def load_subagents_config_from_dict(config_dict: dict) -> None:
|
|||||||
parts.append(f"max_turns={override.max_turns}")
|
parts.append(f"max_turns={override.max_turns}")
|
||||||
if override.model is not None:
|
if override.model is not None:
|
||||||
parts.append(f"model={override.model}")
|
parts.append(f"model={override.model}")
|
||||||
|
if override.thinking_enabled is not None:
|
||||||
|
parts.append(f"thinking_enabled={override.thinking_enabled}")
|
||||||
|
if override.reasoning_effort is not None:
|
||||||
|
parts.append(f"reasoning_effort={override.reasoning_effort}")
|
||||||
if override.skills is not None:
|
if override.skills is not None:
|
||||||
parts.append(f"skills={override.skills}")
|
parts.append(f"skills={override.skills}")
|
||||||
if parts:
|
if parts:
|
||||||
|
|||||||
@ -11,7 +11,7 @@ The optional background-task `status` filter flows through `McpTaskSubmitter.lis
|
|||||||
|
|
||||||
- Uses `langchain-mcp-adapters` `MultiServerMCPClient` for multi-server management
|
- Uses `langchain-mcp-adapters` `MultiServerMCPClient` for multi-server management
|
||||||
- **OAuth expiry**: Missing, non-convertible or overflowing `expires_in` values use the one-hour default. Guard both `timedelta` construction and addition to one captured UTC `now`; avoid float-derived range ceilings. `tests/test_mcp_oauth.py` pins both overflow stages and the exact `datetime` boundary with a fixed clock.
|
- **OAuth expiry**: Missing, non-convertible or overflowing `expires_in` values use the one-hour default. Guard both `timedelta` construction and addition to one captured UTC `now`; avoid float-derived range ceilings. `tests/test_mcp_oauth.py` pins both overflow stages and the exact `datetime` boundary with a fixed clock.
|
||||||
- **Long-running task foundation**: `mcp/tasks/` defines the protocol-neutral `McpTaskDriver` and `TaskSnapshot` states (`submitted`, `working`, `input_required`, `completed`, `failed`, `cancelled`); validate finite positive `poll_after_seconds` at that shared boundary. `persistence/mcp_tasks/` owns remote handles, schedules, notification state, leases and separate consecutive poll/delivery error counters. `app/mcp_tasks/McpTaskService` runs outside the Agent/LLM loop. Every claim gets a fresh token; releases and snapshot writes match owner/token, and results require an unexpired lease even before a peer reclaims. Each claim phase has a single-flight owner while its database outcome is ambiguous: later scans skip with a warning. Once it resolves, release that phase owner immediately and hand returned rows to bounded, service-owned release work. Token fencing protects newer generations; a stuck release must not block later claims. Expiry is the cross-process crash fallback, not normal recovery for a live service's late claim. Poll timestamps and retry schedules use remote completion time. Submission persistence failure/cancellation best-effort cancels the remote handle while preserving the original error, except `uq_mcp_tasks_user_server_remote`: an existing row owns that handle and must not be cancelled. Isolate per-task failures; unexpected poll failures recover by expiry. Shutdown cancels the poller with bounded handoff. Cancellation release clears only owner/token and preserves diagnostics, preclaim schedules and poll-failure state; cancellation is not a task failure. Real failures retain backoff and tracking degradation. `input_required` and terminal events become pending notifications; delivery budgets, idempotency and strict target rejection are detailed below. Durable recovery needs SQLite/PostgreSQL; memory leaves the service unavailable. The startup-configured `mcp_tasks` runtime is disabled by default until a concrete driver is registered and does not change ordinary tool behavior.
|
- **Long-running task foundation**: `mcp/tasks/` defines the protocol-neutral `McpTaskDriver` and `TaskSnapshot` states (`submitted`, `working`, `input_required`, `completed`, `failed`, `cancelled`); validate finite positive `poll_after_seconds` at that shared boundary. `persistence/mcp_tasks/` owns remote handles, schedules, notification state, leases and separate consecutive poll/delivery error counters. `app/mcp_tasks/McpTaskService` runs outside the Agent/LLM loop. Every claim gets a fresh token; releases and snapshot writes match owner/token, and results require an unexpired lease even before a peer reclaims. Each claim phase has a single-flight owner while its database outcome is ambiguous: later scans skip with a warning. Once it resolves, release that phase owner immediately and hand returned rows to bounded, service-owned release work. Token fencing protects newer generations; a stuck release must not block later claims. Expiry is the cross-process crash fallback, not normal recovery for a live service's late claim. Poll timestamps and retry schedules use remote completion time. Submission persistence failure/cancellation best-effort cancels the remote handle while preserving the original error, except `uq_mcp_tasks_user_server_remote`: an existing row owns that handle and must not be cancelled. Isolate per-task failures; unexpected poll failures recover by expiry. Shutdown cancels the poller with bounded handoff, then drains service-owned compensation work within the same deadline; timed-out work remains tracked in the background. Cancellation release clears only owner/token and preserves diagnostics, preclaim schedules and poll-failure state; cancellation is not a task failure. Real failures retain backoff and tracking degradation. `input_required` and terminal events become pending notifications; delivery budgets, idempotency and strict target rejection are detailed below. Durable recovery needs SQLite/PostgreSQL; memory leaves the service unavailable. The startup-configured `mcp_tasks` runtime is disabled by default until a concrete driver is registered and does not change ordinary tool behavior.
|
||||||
- **Control-call ownership**: Poll claims recheck eligibility with a conditional UPDATE after SELECT; cancel claims reserve SQLite writers with `BEGIN IMMEDIATE` before SELECT. PostgreSQL retains `SKIP LOCKED`. `renew_control_claim` atomically matches phase, owner, token, nonterminal state and unexpired lease, preserves `updated_at`, and never shortens expiry on a backward clock correction. `McpTaskService._with_control_lease` validates admission, renews every lease/3 with a lease/3 renewal deadline, and supervises the entire operation through persistence. Ownership loss or uncertain renewal cancels the local operation; the task-local loss fence rejects late snapshot/retry writes if the driver swallows cancellation. Bounded handoff retains operation/renewal cleanup through caller cancellation or autonomous completion/loss. Local active IDs remain excluded from poll/cancel claims and consume control capacity until cleanup settles. Notification claims use their existing independent lifecycle. Contract/tests: [control leases](../../../../docs/mcp-control-leases.md), `tests/test_mcp_task_lease_lifecycle.py`.
|
- **Control-call ownership**: Poll claims recheck eligibility with a conditional UPDATE after SELECT; cancel claims reserve SQLite writers with `BEGIN IMMEDIATE` before SELECT. PostgreSQL retains `SKIP LOCKED`. `renew_control_claim` atomically matches phase, owner, token, nonterminal state and unexpired lease, preserves `updated_at`, and never shortens expiry on a backward clock correction. `McpTaskService._with_control_lease` validates admission, renews every lease/3 with a lease/3 renewal deadline, and supervises the entire operation through persistence. Ownership loss or uncertain renewal cancels the local operation; the task-local loss fence rejects late snapshot/retry writes if the driver swallows cancellation. Bounded handoff retains operation/renewal cleanup through caller cancellation or autonomous completion/loss. Local active IDs remain excluded from poll/cancel claims and consume control capacity until cleanup settles. Notification claims use their existing independent lifecycle. Contract/tests: [control leases](../../../../docs/mcp-control-leases.md), `tests/test_mcp_task_lease_lifecycle.py`.
|
||||||
- **Runtime availability boundary**: the installed process-local submitter is the source of truth for durable task-management tool exposure. `mcp_tasks` is startup-only; changing it on disk does not alter the live toolset until the Gateway restarts.
|
- **Runtime availability boundary**: the installed process-local submitter is the source of truth for durable task-management tool exposure. `mcp_tasks` is startup-only; changing it on disk does not alter the live toolset until the Gateway restarts.
|
||||||
- **Task OAuth lifetime**: `tasks/runtime.py` owns shared OAuth state only for enabled deployment servers with `task_toolsets`, from `set_mcp_task_config_snapshot(config)` until its `None` teardown. Use `_task_enabled_servers` for both snapshot and OAuth selection. Validate the frozen snapshot before reusing that state for discovery, ordinary tools, or durable calls. Cache resets must not discard it. Keep tokens and the cross-loop refresh lock together; rotate a private config copy, never the parsed snapshot or config file. Non-task servers remain reloadable and personal callers use independent managers, even on a name collision.
|
- **Task OAuth lifetime**: `tasks/runtime.py` owns shared OAuth state only for enabled deployment servers with `task_toolsets`, from `set_mcp_task_config_snapshot(config)` until its `None` teardown. Use `_task_enabled_servers` for both snapshot and OAuth selection. Validate the frozen snapshot before reusing that state for discovery, ordinary tools, or durable calls. Cache resets must not discard it. Keep tokens and the cross-loop refresh lock together; rotate a private config copy, never the parsed snapshot or config file. Non-task servers remain reloadable and personal callers use independent managers, even on a name collision.
|
||||||
|
|||||||
@ -12,6 +12,14 @@ Alembic stamp/upgrade workers started inside `bootstrap_schema()` remain owned b
|
|||||||
|
|
||||||
On SQLite, `BEGIN IMMEDIATE` takes the database-wide write lock, so every unrelated writer (run status, thread metadata, the scheduler) waits for the transaction and fails with `database is locked` after `busy_timeout` (30s). Do slow work such as document conversion before opening a locked transaction; lock only to revalidate and publish, as `ProjectDocumentRepository.publish_under_live_lock` does. `tests/test_project_document_tools.py::TestConversionSerialization` pins this.
|
On SQLite, `BEGIN IMMEDIATE` takes the database-wide write lock, so every unrelated writer (run status, thread metadata, the scheduler) waits for the transaction and fails with `database is locked` after `busy_timeout` (30s). Do slow work such as document conversion before opening a locked transaction; lock only to revalidate and publish, as `ProjectDocumentRepository.publish_under_live_lock` does. `tests/test_project_document_tools.py::TestConversionSerialization` pins this.
|
||||||
|
|
||||||
|
## Batch lease recovery
|
||||||
|
|
||||||
|
Batch lease recovery includes paused parents only when they have expired active
|
||||||
|
items. Reconcile through the existing owner/expiry fence, then skip promotion and
|
||||||
|
claiming while paused. Preserve explicit retry/resume and terminal projection;
|
||||||
|
`tests/test_paused_batch_lease_recovery.py` covers HTTP, restart and export, and
|
||||||
|
`test_batch_lease_lifecycle.py` covers a concurrent renewal.
|
||||||
|
|
||||||
## Unique-key upserts
|
## Unique-key upserts
|
||||||
|
|
||||||
A repository method that creates or updates a row by a unique key must survive two first writers: both can miss the SELECT, and the later INSERT raises `IntegrityError`, a 500 for the caller. Use a dialect `on_conflict_do_update` when every key column is non-null, or retry the select-then-write after rolling back the session, as `ChannelConnectionRepository.upsert_connection` / `set_thread_id` and `FeedbackRepository.upsert` do. Feedback keeps the retry because `user_id` is nullable and a NULL key never conflicts, so ON CONFLICT would stop updating the NULL-user row.
|
A repository method that creates or updates a row by a unique key must survive two first writers: both can miss the SELECT, and the later INSERT raises `IntegrityError`, a 500 for the caller. Use a dialect `on_conflict_do_update` when every key column is non-null, or retry the select-then-write after rolling back the session, as `ChannelConnectionRepository.upsert_connection` / `set_thread_id` and `FeedbackRepository.upsert` do. Feedback keeps the retry because `user_id` is nullable and a NULL key never conflicts, so ON CONFLICT would stop updating the NULL-user row.
|
||||||
|
|||||||
@ -32,6 +32,8 @@ class ManagedSubagentDefinition(BaseModel):
|
|||||||
disallowed_tools: list[str] = Field(default_factory=lambda: sorted(REQUIRED_DISALLOWED_TOOLS))
|
disallowed_tools: list[str] = Field(default_factory=lambda: sorted(REQUIRED_DISALLOWED_TOOLS))
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
model: str = "inherit"
|
model: str = "inherit"
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
|
reasoning_effort: str | None = None
|
||||||
max_turns: int = Field(default=50, ge=1)
|
max_turns: int = Field(default=50, ge=1)
|
||||||
timeout_seconds: int = Field(default=900, ge=1)
|
timeout_seconds: int = Field(default=900, ge=1)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from collections import Counter
|
|||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
from sqlalchemy.orm import defer
|
from sqlalchemy.orm import defer
|
||||||
@ -250,12 +250,27 @@ class SubagentBatchRepository:
|
|||||||
limit: int,
|
limit: int,
|
||||||
exclude_item_ids: tuple[str, ...] = (),
|
exclude_item_ids: tuple[str, ...] = (),
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Promote pending work and atomically claim runnable items."""
|
"""Recover expired attempts, then promote and claim only runnable work."""
|
||||||
if limit <= 0:
|
if limit <= 0:
|
||||||
return []
|
return []
|
||||||
claimed: list[dict[str, Any]] = []
|
claimed: list[dict[str, Any]] = []
|
||||||
async with self._sf() as session:
|
async with self._sf() as session:
|
||||||
batches = list((await session.execute(select(SubagentBatchRow).where(SubagentBatchRow.status.in_(("queued", "running"))).order_by(SubagentBatchRow.created_at, SubagentBatchRow.id).with_for_update(skip_locked=True))).scalars())
|
expired_items = select(SubagentBatchItemRow.id).where(
|
||||||
|
SubagentBatchItemRow.batch_id == SubagentBatchRow.id,
|
||||||
|
SubagentBatchItemRow.status.in_(("leased", "running")),
|
||||||
|
SubagentBatchItemRow.lease_expires_at < now,
|
||||||
|
SubagentBatchItemRow.id.not_in(exclude_item_ids),
|
||||||
|
)
|
||||||
|
batches = list(
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(SubagentBatchRow)
|
||||||
|
.where(or_(SubagentBatchRow.status.in_(("queued", "running")), (SubagentBatchRow.status == "paused") & expired_items.exists()))
|
||||||
|
.order_by(SubagentBatchRow.created_at, SubagentBatchRow.id)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
).scalars()
|
||||||
|
)
|
||||||
for batch in batches:
|
for batch in batches:
|
||||||
if len(claimed) >= limit:
|
if len(claimed) >= limit:
|
||||||
break
|
break
|
||||||
@ -305,6 +320,11 @@ class SubagentBatchRepository:
|
|||||||
if terminalized_expired:
|
if terminalized_expired:
|
||||||
await self._refresh_batch_status(session, batch, now=now)
|
await self._refresh_batch_status(session, batch, now=now)
|
||||||
|
|
||||||
|
# Pausing blocks new work, not recovery of an abandoned attempt.
|
||||||
|
# Retryable items remain queued until an explicit resume.
|
||||||
|
if batch.status == "paused":
|
||||||
|
continue
|
||||||
|
|
||||||
counts = await self._counts(session, batch.id)
|
counts = await self._counts(session, batch.id)
|
||||||
live = counts["queued"] + counts["leased"] + counts["running"]
|
live = counts["queued"] + counts["leased"] + counts["running"]
|
||||||
promote_count = max(0, batch.max_live_items - live)
|
promote_count = max(0, batch.max_live_items - live)
|
||||||
|
|||||||
@ -13,6 +13,13 @@ errors preserve cache fallbacks and valid neighbors, including Redis outages/pur
|
|||||||
Escape literal stems for SCAN; recheck byte/string keys before UNLINK. Preserve
|
Escape literal stems for SCAN; recheck byte/string keys before UNLINK. Preserve
|
||||||
namespace/thread bounds, cursor iteration and outage/TTL fallback.
|
namespace/thread bounds, cursor iteration and outage/TTL fallback.
|
||||||
|
|
||||||
|
### Run Admission
|
||||||
|
|
||||||
|
Run admission and assembly publication serialize per thread across Store I/O.
|
||||||
|
Keep registry locks short; heartbeat snapshots must not queue on an
|
||||||
|
admission gate. Preserve same-thread publication visibility, including reads of
|
||||||
|
preallocated run IDs. Details: [admission isolation](../../../../docs/runtime-guidance-details.md#deferred-terminal-commits-and-lease-ownership).
|
||||||
|
|
||||||
### Stream Bridge Heartbeats
|
### Stream Bridge Heartbeats
|
||||||
|
|
||||||
Observed Redis streams removed during replay yield `StreamGap` with empty bounds; initially empty streams still wait. After this gap, `/wait` refreshes store-only status and completes only for a terminal run with no retained stream.
|
Observed Redis streams removed during replay yield `StreamGap` with empty bounds; initially empty streams still wait. After this gap, `/wait` refreshes store-only status and completes only for a terminal run with no retained stream.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import socket
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import AsyncExitStack, asynccontextmanager
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
@ -19,6 +19,7 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from sqlalchemy.exc import IntegrityError as SAIntegrityError
|
from sqlalchemy.exc import IntegrityError as SAIntegrityError
|
||||||
|
|
||||||
from deerflow.runtime.events.terminal import RecoveryTerminalPublisher
|
from deerflow.runtime.events.terminal import RecoveryTerminalPublisher
|
||||||
|
from deerflow.runtime.keyed_lock import AsyncKeyedLockTable
|
||||||
from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_user_id
|
from deerflow.runtime.user_context import AUTO, _AutoSentinel, get_current_user, resolve_user_id
|
||||||
from deerflow.utils.time import is_lease_expired
|
from deerflow.utils.time import is_lease_expired
|
||||||
from deerflow.utils.time import now_iso as _now_iso
|
from deerflow.utils.time import now_iso as _now_iso
|
||||||
@ -353,6 +354,9 @@ class RunManager:
|
|||||||
# preserving ``_runs`` iteration order (see ``_thread_records_locked``).
|
# preserving ``_runs`` iteration order (see ``_thread_records_locked``).
|
||||||
self._runs_by_thread: dict[str, dict[str, None]] = {}
|
self._runs_by_thread: dict[str, dict[str, None]] = {}
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
self._admission_locks = AsyncKeyedLockTable[str]()
|
||||||
|
self._snapshot_locks = AsyncKeyedLockTable[str]()
|
||||||
|
self._pending_admissions: dict[str, dict[str, int]] = {}
|
||||||
self._store = store
|
self._store = store
|
||||||
self._persistence_retry_policy = persistence_retry_policy or PersistenceRetryPolicy()
|
self._persistence_retry_policy = persistence_retry_policy or PersistenceRetryPolicy()
|
||||||
self._worker_id = worker_id or _generate_worker_id()
|
self._worker_id = worker_id or _generate_worker_id()
|
||||||
@ -370,6 +374,43 @@ class RunManager:
|
|||||||
self._lease_operations: dict[str, asyncio.Task] = {}
|
self._lease_operations: dict[str, asyncio.Task] = {}
|
||||||
self._lease_deadlines: dict[str, asyncio.TimerHandle] = {}
|
self._lease_deadlines: dict[str, asyncio.TimerHandle] = {}
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _admission_scope(self, thread_id: str, run_id: str) -> AsyncIterator[None]:
|
||||||
|
# Manager state is owned by one event loop. Register before the first
|
||||||
|
# await so readers of a preallocated ID also wait for its publication.
|
||||||
|
threads = self._pending_admissions.setdefault(run_id, {})
|
||||||
|
threads[thread_id] = threads.get(thread_id, 0) + 1
|
||||||
|
try:
|
||||||
|
async with self._admission_locks.hold(thread_id):
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
threads[thread_id] -= 1
|
||||||
|
if not threads[thread_id]:
|
||||||
|
del threads[thread_id]
|
||||||
|
if not threads:
|
||||||
|
self._pending_admissions.pop(run_id, None)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _run_scope(self, run_id: str) -> AsyncIterator[None]:
|
||||||
|
threads = set(self._pending_admissions.get(run_id, ()))
|
||||||
|
record = self._runs.get(run_id)
|
||||||
|
if record is not None:
|
||||||
|
threads.add(record.thread_id)
|
||||||
|
async with AsyncExitStack() as stack:
|
||||||
|
for thread_id in sorted(threads):
|
||||||
|
await stack.enter_async_context(self._admission_locks.hold(thread_id))
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _run_registry_lock(self, run_id: str) -> AsyncIterator[None]:
|
||||||
|
async with self._run_scope(run_id), self._lock:
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def _thread_registry_lock(self, thread_id: str) -> AsyncIterator[None]:
|
||||||
|
async with self._admission_locks.hold(thread_id), self._lock:
|
||||||
|
yield
|
||||||
|
|
||||||
def _index_run_locked(self, record: RunRecord) -> None:
|
def _index_run_locked(self, record: RunRecord) -> None:
|
||||||
"""Register *record* in the thread index. Caller must hold ``self._lock``."""
|
"""Register *record* in the thread index. Caller must hold ``self._lock``."""
|
||||||
self._runs_by_thread.setdefault(record.thread_id, {})[record.run_id] = None
|
self._runs_by_thread.setdefault(record.thread_id, {})[record.run_id] = None
|
||||||
@ -500,8 +541,8 @@ class RunManager:
|
|||||||
Initial run creation is part of the run visibility boundary: callers
|
Initial run creation is part of the run visibility boundary: callers
|
||||||
should not observe a run in memory unless its backing store row exists.
|
should not observe a run in memory unless its backing store row exists.
|
||||||
Unlike follow-up status/model updates, failures are propagated so the
|
Unlike follow-up status/model updates, failures are propagated so the
|
||||||
caller can treat creation as failed. Rollback is the caller's
|
caller can treat creation as failed. The caller keeps the record private
|
||||||
responsibility after inserting the record into ``_runs``.
|
until this method returns successfully.
|
||||||
"""
|
"""
|
||||||
if self._store is None:
|
if self._store is None:
|
||||||
return
|
return
|
||||||
@ -793,7 +834,7 @@ class RunManager:
|
|||||||
"""Persist token usage and completion data to the backing store."""
|
"""Persist token usage and completion data to the backing store."""
|
||||||
row_recovery_payload: dict[str, Any] | None = None
|
row_recovery_payload: dict[str, Any] | None = None
|
||||||
record: RunRecord | None = None
|
record: RunRecord | None = None
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None and record.ownership_lost:
|
if record is not None and record.ownership_lost:
|
||||||
logger.warning("Skipped completion persistence for run %s after lease ownership was lost", run_id)
|
logger.warning("Skipped completion persistence for run %s after lease ownership was lost", run_id)
|
||||||
@ -889,7 +930,7 @@ class RunManager:
|
|||||||
async def update_run_progress(self, run_id: str, **kwargs) -> None:
|
async def update_run_progress(self, run_id: str, **kwargs) -> None:
|
||||||
"""Persist a running token/message snapshot without changing status."""
|
"""Persist a running token/message snapshot without changing status."""
|
||||||
should_persist = True
|
should_persist = True
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
should_persist = record.status == RunStatus.running and not record.ownership_lost
|
should_persist = record.status == RunStatus.running and not record.ownership_lost
|
||||||
@ -907,7 +948,7 @@ class RunManager:
|
|||||||
async def update_finalizing_progress(self, run_id: str, **kwargs) -> None:
|
async def update_finalizing_progress(self, run_id: str, **kwargs) -> None:
|
||||||
"""Persist final fields while the durable row is deliberately active."""
|
"""Persist final fields while the durable row is deliberately active."""
|
||||||
should_persist = False
|
should_persist = False
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None and not record.ownership_lost:
|
if record is not None and not record.ownership_lost:
|
||||||
should_persist = record.status not in (RunStatus.pending, RunStatus.running)
|
should_persist = record.status not in (RunStatus.pending, RunStatus.running)
|
||||||
@ -931,7 +972,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def seal_completed_evidence(self, run_id: str, receipt) -> bool:
|
async def seal_completed_evidence(self, run_id: str, receipt) -> bool:
|
||||||
"""Best-effort evidence metadata is independent of business completion."""
|
"""Best-effort evidence metadata is independent of business completion."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None or record.ownership_lost:
|
if record is None or record.ownership_lost:
|
||||||
return False
|
return False
|
||||||
@ -942,7 +983,7 @@ class RunManager:
|
|||||||
return await seal(run_id, owner_worker_id=owner_worker_id, receipt=receipt)
|
return await seal(run_id, owner_worker_id=owner_worker_id, receipt=receipt)
|
||||||
|
|
||||||
async def mark_evidence_partial(self, run_id: str, *, error: str) -> bool:
|
async def mark_evidence_partial(self, run_id: str, *, error: str) -> bool:
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None or record.ownership_lost:
|
if record is None or record.ownership_lost:
|
||||||
return False
|
return False
|
||||||
@ -998,21 +1039,15 @@ class RunManager:
|
|||||||
evidence_origin=evidence_origin,
|
evidence_origin=evidence_origin,
|
||||||
evidence_agent_id=evidence_agent_id,
|
evidence_agent_id=evidence_agent_id,
|
||||||
)
|
)
|
||||||
async with self._lock:
|
async with self._admission_scope(thread_id, run_id):
|
||||||
self._runs[run_id] = record
|
|
||||||
self._index_run_locked(record)
|
|
||||||
persisted = False
|
|
||||||
try:
|
try:
|
||||||
await self._persist_new_run_to_store(record)
|
await self._persist_new_run_to_store(record)
|
||||||
persisted = True
|
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Failed to persist run %s; rolled back in-memory record", run_id, exc_info=True)
|
logger.warning("Failed to persist run %s; record was not registered", run_id, exc_info=True)
|
||||||
raise
|
raise
|
||||||
finally:
|
async with self._lock:
|
||||||
# Also covers cancellation, which bypasses ``except Exception``.
|
self._runs[run_id] = record
|
||||||
if not persisted:
|
self._index_run_locked(record)
|
||||||
self._runs.pop(run_id, None)
|
|
||||||
self._unindex_run_locked(run_id, record.thread_id)
|
|
||||||
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
logger.info("Run created: run_id=%s thread_id=%s", run_id, thread_id)
|
||||||
return record
|
return record
|
||||||
|
|
||||||
@ -1031,7 +1066,7 @@ class RunManager:
|
|||||||
raise_on_store_error: Propagate store hydration/mapping failures so
|
raise_on_store_error: Propagate store hydration/mapping failures so
|
||||||
lifecycle callers can distinguish them from a missing run.
|
lifecycle callers can distinguish them from a missing run.
|
||||||
"""
|
"""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
return record
|
return record
|
||||||
@ -1046,7 +1081,7 @@ class RunManager:
|
|||||||
return None
|
return None
|
||||||
# Re-check after store await: a concurrent create() may have inserted the
|
# Re-check after store await: a concurrent create() may have inserted the
|
||||||
# in-memory record while the store call was in flight.
|
# in-memory record while the store call was in flight.
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
return record
|
return record
|
||||||
@ -1115,7 +1150,7 @@ class RunManager:
|
|||||||
def _page(records: list[RunRecord]) -> list[RunRecord]:
|
def _page(records: list[RunRecord]) -> list[RunRecord]:
|
||||||
return sorted(records, key=lambda record: run_sort_key(record.created_at, record.run_id), reverse=True)[:limit]
|
return sorted(records, key=lambda record: run_sort_key(record.created_at, record.run_id), reverse=True)[:limit]
|
||||||
|
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
memory_records = [
|
memory_records = [
|
||||||
record
|
record
|
||||||
for record in self._thread_records_locked(thread_id)
|
for record in self._thread_records_locked(thread_id)
|
||||||
@ -1169,7 +1204,7 @@ class RunManager:
|
|||||||
correct pagination.
|
correct pagination.
|
||||||
"""
|
"""
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.list_successful_regenerate_sources")
|
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.list_successful_regenerate_sources")
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
memory_records = [record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and (resolved_user_id is None or record.user_id == resolved_user_id)]
|
memory_records = [record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and (resolved_user_id is None or record.user_id == resolved_user_id)]
|
||||||
|
|
||||||
sources = set(await self._store.list_successful_regenerate_sources(thread_id, user_id=resolved_user_id)) if self._store is not None else set()
|
sources = set(await self._store.list_successful_regenerate_sources(thread_id, user_id=resolved_user_id)) if self._store is not None else set()
|
||||||
@ -1240,7 +1275,7 @@ class RunManager:
|
|||||||
continue
|
continue
|
||||||
records_by_id[record.run_id] = record
|
records_by_id[record.run_id] = record
|
||||||
|
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
memory_records = [record for record in self._thread_records_locked(thread_id) if resolved_user_id is None or record.user_id == resolved_user_id]
|
memory_records = [record for record in self._thread_records_locked(thread_id) if resolved_user_id is None or record.user_id == resolved_user_id]
|
||||||
for record in memory_records:
|
for record in memory_records:
|
||||||
records_by_id[record.run_id] = record
|
records_by_id[record.run_id] = record
|
||||||
@ -1249,13 +1284,13 @@ class RunManager:
|
|||||||
|
|
||||||
async def try_start(self, run_id: str) -> RunStartOutcome:
|
async def try_start(self, run_id: str) -> RunStartOutcome:
|
||||||
"""Transition an uncancelled pending run to running before building the agent."""
|
"""Transition an uncancelled pending run to running before building the agent."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
raise RunStartupError(f"Cannot start unknown run {run_id}")
|
raise RunStartupError(f"Cannot start unknown run {run_id}")
|
||||||
|
|
||||||
async with record.start_lock:
|
async with record.start_lock:
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
if record.abort_event.is_set() or record.status != RunStatus.pending:
|
if record.abort_event.is_set() or record.status != RunStatus.pending:
|
||||||
return RunStartOutcome.cancelled
|
return RunStartOutcome.cancelled
|
||||||
|
|
||||||
@ -1269,7 +1304,7 @@ class RunManager:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise RunStartupError(f"Failed to start run {run_id}: {exc}") from exc
|
raise RunStartupError(f"Failed to start run {run_id}: {exc}") from exc
|
||||||
if updated is False:
|
if updated is False:
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
if record.status == RunStatus.pending:
|
if record.status == RunStatus.pending:
|
||||||
record.status = RunStatus.interrupted
|
record.status = RunStatus.interrupted
|
||||||
record.terminal_status_persisted = False
|
record.terminal_status_persisted = False
|
||||||
@ -1277,7 +1312,7 @@ class RunManager:
|
|||||||
record.updated_at = _now_iso()
|
record.updated_at = _now_iso()
|
||||||
return RunStartOutcome.cancelled
|
return RunStartOutcome.cancelled
|
||||||
|
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
if record.abort_event.is_set() or record.status != RunStatus.pending:
|
if record.abort_event.is_set() or record.status != RunStatus.pending:
|
||||||
restore_status = record.status
|
restore_status = record.status
|
||||||
restore_error = record.error
|
restore_error = record.error
|
||||||
@ -1299,7 +1334,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def fail_start_if_pending(self, run_id: str, *, error: str) -> bool:
|
async def fail_start_if_pending(self, run_id: str, *, error: str) -> bool:
|
||||||
"""Mark an admitted run as failed if its worker task could not be attached."""
|
"""Mark an admitted run as failed if its worker task could not be attached."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None or record.status != RunStatus.pending:
|
if record is None or record.status != RunStatus.pending:
|
||||||
return False
|
return False
|
||||||
@ -1314,7 +1349,7 @@ class RunManager:
|
|||||||
RunStatus.error,
|
RunStatus.error,
|
||||||
error=error,
|
error=error,
|
||||||
)
|
)
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
if self._runs.get(run_id) is record:
|
if self._runs.get(run_id) is record:
|
||||||
record.terminal_status_persisted = persisted
|
record.terminal_status_persisted = persisted
|
||||||
return True
|
return True
|
||||||
@ -1330,7 +1365,7 @@ class RunManager:
|
|||||||
if not run_ids:
|
if not run_ids:
|
||||||
return {}
|
return {}
|
||||||
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.get_many_by_thread")
|
resolved_user_id = resolve_user_id(user_id, method_name="RunManager.get_many_by_thread")
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
records_by_id = {
|
records_by_id = {
|
||||||
record.run_id: record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and record.run_id in run_ids and (resolved_user_id is None or record.user_id == resolved_user_id)
|
record.run_id: record for record in self._thread_records_locked(thread_id) if record.operation_kind == ThreadOperationKind.run and record.run_id in run_ids and (resolved_user_id is None or record.user_id == resolved_user_id)
|
||||||
}
|
}
|
||||||
@ -1365,7 +1400,7 @@ class RunManager:
|
|||||||
persist: bool = True,
|
persist: bool = True,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Transition a run to a new status."""
|
"""Transition a run to a new status."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
logger.warning("set_status called for unknown run %s", run_id)
|
logger.warning("set_status called for unknown run %s", run_id)
|
||||||
@ -1401,7 +1436,7 @@ class RunManager:
|
|||||||
if persist:
|
if persist:
|
||||||
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
|
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
|
||||||
if status not in (RunStatus.pending, RunStatus.running):
|
if status not in (RunStatus.pending, RunStatus.running):
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
||||||
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
|
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
|
||||||
await self._mark_ownership_lost(
|
await self._mark_ownership_lost(
|
||||||
@ -1415,7 +1450,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def persist_current_status(self, run_id: str) -> bool:
|
async def persist_current_status(self, run_id: str) -> bool:
|
||||||
"""Persist the status already staged on the in-memory run record."""
|
"""Persist the status already staged on the in-memory run record."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
logger.warning("persist_current_status called for unknown run %s", run_id)
|
logger.warning("persist_current_status called for unknown run %s", run_id)
|
||||||
@ -1425,7 +1460,7 @@ class RunManager:
|
|||||||
stop_reason = record.stop_reason
|
stop_reason = record.stop_reason
|
||||||
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
|
persisted = await self._persist_status(record, status, error=error, stop_reason=stop_reason)
|
||||||
if status not in (RunStatus.pending, RunStatus.running):
|
if status not in (RunStatus.pending, RunStatus.running):
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
||||||
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
|
if not persisted and self.heartbeat_enabled and status == RunStatus.success and not record.ownership_lost:
|
||||||
await self._mark_ownership_lost(
|
await self._mark_ownership_lost(
|
||||||
@ -1457,7 +1492,7 @@ class RunManager:
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None or record.store_only or record.ownership_lost:
|
if record is None or record.store_only or record.ownership_lost:
|
||||||
return None
|
return None
|
||||||
@ -1507,7 +1542,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def set_finalizing(self, run_id: str, finalizing: bool) -> None:
|
async def set_finalizing(self, run_id: str, finalizing: bool) -> None:
|
||||||
"""Mark whether a run is performing post-cancel cleanup."""
|
"""Mark whether a run is performing post-cancel cleanup."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
logger.warning("set_finalizing called for unknown run %s", run_id)
|
logger.warning("set_finalizing called for unknown run %s", run_id)
|
||||||
@ -1525,7 +1560,7 @@ class RunManager:
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Wait until older same-thread runs have finished post-cancel cleanup."""
|
"""Wait until older same-thread runs have finished post-cancel cleanup."""
|
||||||
while True:
|
while True:
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
found_current = False
|
found_current = False
|
||||||
prior_finalizing = False
|
prior_finalizing = False
|
||||||
for record in self._thread_records_locked(thread_id):
|
for record in self._thread_records_locked(thread_id):
|
||||||
@ -1549,7 +1584,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def has_later_run(self, thread_id: str, run_id: str) -> bool:
|
async def has_later_run(self, thread_id: str, run_id: str) -> bool:
|
||||||
"""Return whether a newer in-memory run has been admitted for the thread."""
|
"""Return whether a newer in-memory run has been admitted for the thread."""
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
seen_current = False
|
seen_current = False
|
||||||
for record in self._thread_records_locked(thread_id):
|
for record in self._thread_records_locked(thread_id):
|
||||||
if record.run_id == run_id:
|
if record.run_id == run_id:
|
||||||
@ -1561,7 +1596,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def has_later_started_run(self, thread_id: str, run_id: str) -> bool:
|
async def has_later_started_run(self, thread_id: str, run_id: str) -> bool:
|
||||||
"""Return whether a newer same-thread run may have already advanced state."""
|
"""Return whether a newer same-thread run may have already advanced state."""
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
seen_current = False
|
seen_current = False
|
||||||
for record in self._thread_records_locked(thread_id):
|
for record in self._thread_records_locked(thread_id):
|
||||||
if record.run_id == run_id:
|
if record.run_id == run_id:
|
||||||
@ -1588,22 +1623,23 @@ class RunManager:
|
|||||||
"""Publish only host assembly evidence, once, before execution."""
|
"""Publish only host assembly evidence, once, before execution."""
|
||||||
from deerflow.runtime.assembly_evidence import EVIDENCE_KEY
|
from deerflow.runtime.assembly_evidence import EVIDENCE_KEY
|
||||||
|
|
||||||
async with self._lock:
|
async with self._run_scope(run_id), self._snapshot_locks.hold(run_id):
|
||||||
record = self._runs.get(run_id)
|
async with self._lock:
|
||||||
if record is None or evidence.get("run_id") != run_id or evidence.get("thread_id") != record.thread_id:
|
record = self._runs.get(run_id)
|
||||||
raise ValueError("Assembly/run binding mismatch")
|
if record is None or evidence.get("run_id") != run_id or evidence.get("thread_id") != record.thread_id:
|
||||||
if EVIDENCE_KEY in record.metadata:
|
raise ValueError("Assembly/run binding mismatch")
|
||||||
if record.metadata[EVIDENCE_KEY] != evidence:
|
if EVIDENCE_KEY in record.metadata:
|
||||||
raise ValueError("Run assembly evidence is immutable")
|
if record.metadata[EVIDENCE_KEY] != evidence:
|
||||||
return
|
raise ValueError("Run assembly evidence is immutable")
|
||||||
record.metadata = {**record.metadata, EVIDENCE_KEY: copy.deepcopy(evidence)}
|
return
|
||||||
|
record.metadata = {**record.metadata, EVIDENCE_KEY: copy.deepcopy(evidence)}
|
||||||
if not await self._persist_to_store(record):
|
if not await self._persist_to_store(record):
|
||||||
record.metadata.pop(EVIDENCE_KEY, None)
|
record.metadata.pop(EVIDENCE_KEY, None)
|
||||||
raise RuntimeError("Unable to persist assembly evidence")
|
raise RuntimeError("Unable to persist assembly evidence")
|
||||||
|
|
||||||
async def update_model_name(self, run_id: str, model_name: str | None) -> None:
|
async def update_model_name(self, run_id: str, model_name: str | None) -> None:
|
||||||
"""Update the model name for a run."""
|
"""Update the model name for a run."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
logger.warning("update_model_name called for unknown run %s", run_id)
|
logger.warning("update_model_name called for unknown run %s", run_id)
|
||||||
@ -1688,7 +1724,7 @@ class RunManager:
|
|||||||
action: str,
|
action: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Set process-local abort state without status persistence or cleanup."""
|
"""Set process-local abort state without status persistence or cleanup."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is None or (record.status not in (RunStatus.pending, RunStatus.running) and not self._awaits_terminal_commit(record)) or record.abort_event.is_set():
|
if record is None or (record.status not in (RunStatus.pending, RunStatus.running) and not self._awaits_terminal_commit(record)) or record.abort_event.is_set():
|
||||||
return
|
return
|
||||||
@ -1746,7 +1782,7 @@ class RunManager:
|
|||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Local path — this worker owns the run in-memory.
|
# Local path — this worker owns the run in-memory.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record.status == RunStatus.interrupted:
|
if record.status == RunStatus.interrupted:
|
||||||
@ -1771,7 +1807,7 @@ class RunManager:
|
|||||||
elif outcome != CancelOutcome.lease_valid_elsewhere:
|
elif outcome != CancelOutcome.lease_valid_elsewhere:
|
||||||
return outcome
|
return outcome
|
||||||
|
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.get(run_id)
|
record = self._runs.get(run_id)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
if record.status == RunStatus.interrupted or record.abort_event.is_set():
|
if record.status == RunStatus.interrupted or record.abort_event.is_set():
|
||||||
@ -1797,7 +1833,7 @@ class RunManager:
|
|||||||
# cancellation; ordinary runs retain immediate persistence.
|
# cancellation; ordinary runs retain immediate persistence.
|
||||||
return CancelOutcome.cancelled
|
return CancelOutcome.cancelled
|
||||||
persisted = await self._persist_status(record, RunStatus.interrupted)
|
persisted = await self._persist_status(record, RunStatus.interrupted)
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
if self._runs.get(run_id) is record:
|
if self._runs.get(run_id) is record:
|
||||||
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
record.terminal_status_persisted = self._has_final_terminal_ack(record) if self.heartbeat_enabled and self._store is not None else persisted
|
||||||
if not persisted and self._store is not None:
|
if not persisted and self._store is not None:
|
||||||
@ -2001,14 +2037,14 @@ class RunManager:
|
|||||||
raise RuntimeError(f"Cancelled admission {record.run_id} remains active in the run store")
|
raise RuntimeError(f"Cancelled admission {record.run_id} remains active in the run store")
|
||||||
|
|
||||||
if stored is None:
|
if stored is None:
|
||||||
async with self._lock:
|
async with self._run_registry_lock(record.run_id):
|
||||||
if self._runs.get(record.run_id) is record:
|
if self._runs.get(record.run_id) is record:
|
||||||
self._runs.pop(record.run_id, None)
|
self._runs.pop(record.run_id, None)
|
||||||
self._unindex_run_locked(record.run_id, record.thread_id)
|
self._unindex_run_locked(record.run_id, record.thread_id)
|
||||||
return
|
return
|
||||||
|
|
||||||
stored_status = RunStatus(stored.get("status") or RunStatus.pending.value)
|
stored_status = RunStatus(stored.get("status") or RunStatus.pending.value)
|
||||||
async with self._lock:
|
async with self._run_registry_lock(record.run_id):
|
||||||
if self._runs.get(record.run_id) is record:
|
if self._runs.get(record.run_id) is record:
|
||||||
record.status = stored_status
|
record.status = stored_status
|
||||||
record.terminal_status_persisted = stored_status not in (
|
record.terminal_status_persisted = stored_status not in (
|
||||||
@ -2044,7 +2080,7 @@ class RunManager:
|
|||||||
already has a pending/running run. For ``interrupt``/``rollback``,
|
already has a pending/running run. For ``interrupt``/``rollback``,
|
||||||
cancels inflight runs before creating.
|
cancels inflight runs before creating.
|
||||||
|
|
||||||
Lock ordering invariant: the local ``self._lock`` is held across
|
Lock ordering invariant: the per-thread admission gate is held across
|
||||||
the local check, the store insert, and the local register, so the
|
the local check, the store insert, and the local register, so the
|
||||||
store insert can never succeed while a same-worker ConflictError
|
store insert can never succeed while a same-worker ConflictError
|
||||||
is about to fire (which would leak a pending row in the store).
|
is about to fire (which would leak a pending row in the store).
|
||||||
@ -2093,52 +2129,53 @@ class RunManager:
|
|||||||
idempotency_request=idempotency_request,
|
idempotency_request=idempotency_request,
|
||||||
)
|
)
|
||||||
|
|
||||||
async with self._lock:
|
async with self._admission_scope(thread_id, run_id):
|
||||||
if idempotency_key is not None and execution_fence is None:
|
async with self._lock:
|
||||||
for existing in self._runs.values():
|
if idempotency_key is not None and execution_fence is None:
|
||||||
if existing.idempotency_key != idempotency_key:
|
for existing in self._runs.values():
|
||||||
continue
|
if existing.idempotency_key != idempotency_key:
|
||||||
|
continue
|
||||||
|
if existing.thread_id != thread_id or existing.user_id != user_id:
|
||||||
|
raise RunIdempotencyKeyMismatch("Run idempotency key resolved to a different thread or user")
|
||||||
|
existing.idempotency_reused = True
|
||||||
|
return existing
|
||||||
|
|
||||||
|
def reuse_idempotent_run(conflict: RunIdempotencyConflict) -> RunRecord:
|
||||||
|
# A locally held record for this key already returned above, so
|
||||||
|
# the conflicting row belongs to a peer or to a run this worker
|
||||||
|
# has cleaned up. Return a store-only handle without registering
|
||||||
|
# it: nothing here finalizes or cleans up that record, so a
|
||||||
|
# registered copy would keep its admission-time status, reject
|
||||||
|
# later admissions for the thread, and shadow the durable row
|
||||||
|
# for get(), cancel(), and orphan reconciliation.
|
||||||
|
existing = self._record_from_store(conflict.existing)
|
||||||
if existing.thread_id != thread_id or existing.user_id != user_id:
|
if existing.thread_id != thread_id or existing.user_id != user_id:
|
||||||
raise RunIdempotencyKeyMismatch("Run idempotency key resolved to a different thread or user")
|
raise RunIdempotencyKeyMismatch("Run idempotency key resolved to a different thread or user") from conflict
|
||||||
existing.idempotency_reused = True
|
existing.idempotency_reused = True
|
||||||
return existing
|
return existing
|
||||||
|
|
||||||
def reuse_idempotent_run(conflict: RunIdempotencyConflict) -> RunRecord:
|
# 1) Local inflight check (same-worker guard; cross-worker is the
|
||||||
# A locally held record for this key already returned above, so
|
# store's partial unique index below).
|
||||||
# the conflicting row belongs to a peer or to a run this worker
|
local_inflight = [r for r in self._thread_records_locked(thread_id) if r.status in (RunStatus.pending, RunStatus.running) or r.finalizing or r.scheduled_goal_cleanup_pending]
|
||||||
# has cleaned up. Return a store-only handle without registering
|
|
||||||
# it: nothing here finalizes or cleans up that record, so a
|
|
||||||
# registered copy would keep its admission-time status, reject
|
|
||||||
# later admissions for the thread, and shadow the durable row
|
|
||||||
# for get(), cancel(), and orphan reconciliation.
|
|
||||||
existing = self._record_from_store(conflict.existing)
|
|
||||||
if existing.thread_id != thread_id or existing.user_id != user_id:
|
|
||||||
raise RunIdempotencyKeyMismatch("Run idempotency key resolved to a different thread or user") from conflict
|
|
||||||
existing.idempotency_reused = True
|
|
||||||
return existing
|
|
||||||
|
|
||||||
# 1) Local inflight check (same-worker guard; cross-worker is the
|
if any(r.scheduled_goal_cleanup_pending for r in local_inflight):
|
||||||
# store's partial unique index below).
|
raise ConflictError(f"Thread {thread_id} has a scheduled goal awaiting finalization")
|
||||||
local_inflight = [r for r in self._thread_records_locked(thread_id) if r.status in (RunStatus.pending, RunStatus.running) or r.finalizing or r.scheduled_goal_cleanup_pending]
|
|
||||||
|
|
||||||
if any(r.scheduled_goal_cleanup_pending for r in local_inflight):
|
if multitask_strategy in ("interrupt", "rollback") and any(record.operation_kind != ThreadOperationKind.run for record in local_inflight):
|
||||||
raise ConflictError(f"Thread {thread_id} has a scheduled goal awaiting finalization")
|
raise ConflictError(f"Thread {thread_id} has an active checkpoint write")
|
||||||
|
|
||||||
if multitask_strategy in ("interrupt", "rollback") and any(record.operation_kind != ThreadOperationKind.run for record in local_inflight):
|
if multitask_strategy == "reject" and local_inflight:
|
||||||
raise ConflictError(f"Thread {thread_id} has an active checkpoint write")
|
raise ConflictError(f"Thread {thread_id} already has an active run")
|
||||||
|
|
||||||
if multitask_strategy == "reject" and local_inflight:
|
if multitask_strategy in ("interrupt", "rollback") and local_inflight:
|
||||||
raise ConflictError(f"Thread {thread_id} already has an active run")
|
logger.info(
|
||||||
|
"Preparing to cancel %d inflight run(s) on thread %s (strategy=%s)",
|
||||||
|
len(local_inflight),
|
||||||
|
thread_id,
|
||||||
|
multitask_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
if multitask_strategy in ("interrupt", "rollback") and local_inflight:
|
# 2) Persist with only the same-thread admission gate held. The
|
||||||
logger.info(
|
|
||||||
"Preparing to cancel %d inflight run(s) on thread %s (strategy=%s)",
|
|
||||||
len(local_inflight),
|
|
||||||
thread_id,
|
|
||||||
multitask_strategy,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2) Persist to store while still holding the local lock. The
|
|
||||||
# store is the source of truth for cross-process atomicity.
|
# store is the source of truth for cross-process atomicity.
|
||||||
if self._store is not None:
|
if self._store is not None:
|
||||||
if idempotency_request is not None and not _store_accepts_idempotency_parameter(self._store, "idempotency_request"):
|
if idempotency_request is not None and not _store_accepts_idempotency_parameter(self._store, "idempotency_request"):
|
||||||
@ -2242,27 +2279,28 @@ class RunManager:
|
|||||||
# rows as interrupted in the same transaction; no extra
|
# rows as interrupted in the same transaction; no extra
|
||||||
# store write is needed for them.
|
# store write is needed for them.
|
||||||
|
|
||||||
# 3) Only now safe to register locally — store insert succeeded.
|
async with self._lock:
|
||||||
self._runs[run_id] = record
|
# 3) Only now safe to register locally — store insert succeeded.
|
||||||
self._index_run_locked(record)
|
self._runs[run_id] = record
|
||||||
self._arm_lease_deadline(record)
|
self._index_run_locked(record)
|
||||||
|
self._arm_lease_deadline(record)
|
||||||
|
|
||||||
# 4) Cancel local in-memory inflight (interrupt/rollback). The
|
# 4) Cancel local in-memory inflight (interrupt/rollback). The
|
||||||
# store-side counterparts were already cancelled in step 2.
|
# store-side counterparts were already cancelled in step 2.
|
||||||
if self._store is None and multitask_strategy in ("interrupt", "rollback"):
|
if self._store is None and multitask_strategy in ("interrupt", "rollback"):
|
||||||
for r in local_inflight:
|
for r in local_inflight:
|
||||||
if r.finalizing:
|
if r.finalizing:
|
||||||
continue
|
continue
|
||||||
r.abort_action = multitask_strategy
|
r.abort_action = multitask_strategy
|
||||||
r.abort_event.set()
|
r.abort_event.set()
|
||||||
task_active = r.task is not None and not r.task.done()
|
task_active = r.task is not None and not r.task.done()
|
||||||
r.finalizing = task_active
|
r.finalizing = task_active
|
||||||
if task_active:
|
if task_active:
|
||||||
r.task.cancel()
|
r.task.cancel()
|
||||||
r.status = RunStatus.interrupted
|
r.status = RunStatus.interrupted
|
||||||
r.terminal_status_persisted = self._store is None
|
r.terminal_status_persisted = self._store is None
|
||||||
r.updated_at = now
|
r.updated_at = now
|
||||||
interrupted_records.append(r)
|
interrupted_records.append(r)
|
||||||
|
|
||||||
# Outside the lock: persist interrupted status for locally-cancelled
|
# Outside the lock: persist interrupted status for locally-cancelled
|
||||||
# runs. Store-side claimed rows are already finalised. Cancellation at
|
# runs. Store-side claimed rows are already finalised. Cancellation at
|
||||||
@ -2328,7 +2366,7 @@ class RunManager:
|
|||||||
if reservation_task is None:
|
if reservation_task is None:
|
||||||
raise RuntimeError("Thread operation reservation requires an active asyncio task")
|
raise RuntimeError("Thread operation reservation requires an active asyncio task")
|
||||||
lease_lost = True
|
lease_lost = True
|
||||||
async with self._lock:
|
async with self._run_registry_lock(record.run_id):
|
||||||
if self._runs.get(record.run_id) is record:
|
if self._runs.get(record.run_id) is record:
|
||||||
record.task = reservation_task
|
record.task = reservation_task
|
||||||
lease_lost = record.abort_event.is_set()
|
lease_lost = record.abort_event.is_set()
|
||||||
@ -2355,7 +2393,7 @@ class RunManager:
|
|||||||
exc_info=True,
|
exc_info=True,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
async with self._lock:
|
async with self._run_registry_lock(record.run_id):
|
||||||
removed = self._runs.pop(record.run_id, None)
|
removed = self._runs.pop(record.run_id, None)
|
||||||
if removed is not None:
|
if removed is not None:
|
||||||
self._unindex_run_locked(record.run_id, removed.thread_id)
|
self._unindex_run_locked(record.run_id, removed.thread_id)
|
||||||
@ -2472,7 +2510,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def has_inflight(self, thread_id: str) -> bool:
|
async def has_inflight(self, thread_id: str) -> bool:
|
||||||
"""Return ``True`` if *thread_id* has a pending or running run."""
|
"""Return ``True`` if *thread_id* has a pending or running run."""
|
||||||
async with self._lock:
|
async with self._thread_registry_lock(thread_id):
|
||||||
return any(r.operation_kind == ThreadOperationKind.run and (r.status in (RunStatus.pending, RunStatus.running) or r.finalizing) for r in self._thread_records_locked(thread_id))
|
return any(r.operation_kind == ThreadOperationKind.run and (r.status in (RunStatus.pending, RunStatus.running) or r.finalizing) for r in self._thread_records_locked(thread_id))
|
||||||
|
|
||||||
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
|
async def cleanup(self, run_id: str, *, delay: float = 300) -> None:
|
||||||
@ -2488,7 +2526,7 @@ class RunManager:
|
|||||||
return
|
return
|
||||||
if delay > 0:
|
if delay > 0:
|
||||||
await asyncio.sleep(delay)
|
await asyncio.sleep(delay)
|
||||||
async with self._lock:
|
async with self._run_registry_lock(run_id):
|
||||||
record = self._runs.pop(run_id, None)
|
record = self._runs.pop(run_id, None)
|
||||||
if record is not None:
|
if record is not None:
|
||||||
self._unindex_run_locked(run_id, record.thread_id)
|
self._unindex_run_locked(run_id, record.thread_id)
|
||||||
@ -2505,7 +2543,7 @@ class RunManager:
|
|||||||
|
|
||||||
async def owns_active_admission(self, record: RunRecord) -> bool:
|
async def owns_active_admission(self, record: RunRecord) -> bool:
|
||||||
"""Confirm the existing run slot without holding a SQL writer transaction."""
|
"""Confirm the existing run slot without holding a SQL writer transaction."""
|
||||||
async with self._lock:
|
async with self._run_registry_lock(record.run_id):
|
||||||
if self._runs.get(record.run_id) is not record or record.ownership_lost:
|
if self._runs.get(record.run_id) is not record or record.ownership_lost:
|
||||||
return False
|
return False
|
||||||
if self._store is None:
|
if self._store is None:
|
||||||
@ -2841,6 +2879,18 @@ class RunManager:
|
|||||||
return loop.time() < deadline and self._rollback_retry_authorized(record, initial_allow_interrupted=initial_allow_interrupted)
|
return loop.time() < deadline and self._rollback_retry_authorized(record, initial_allow_interrupted=initial_allow_interrupted)
|
||||||
|
|
||||||
async def _renew_one_lease(self, record: RunRecord) -> None:
|
async def _renew_one_lease(self, record: RunRecord) -> None:
|
||||||
|
# Assembly publication writes a complete snapshot. Keep the Store
|
||||||
|
# renewal and its local ACK together so that snapshot cannot restore
|
||||||
|
# the previous lease. Waiting for this gate retains the expiry timer.
|
||||||
|
self._arm_lease_deadline(record)
|
||||||
|
async with self._snapshot_locks.hold(record.run_id):
|
||||||
|
action = await self._renew_one_lease_locked(record)
|
||||||
|
if action is not None:
|
||||||
|
# Do not acquire the thread gate while holding the snapshot gate:
|
||||||
|
# assembly publication takes them in the opposite order.
|
||||||
|
await self._signal_local_cancel(record.run_id, action=action)
|
||||||
|
|
||||||
|
async def _renew_one_lease_locked(self, record: RunRecord) -> str | None:
|
||||||
assert self._store is not None and self._run_ownership_config is not None
|
assert self._store is not None and self._run_ownership_config is not None
|
||||||
if self._has_final_terminal_ack(record):
|
if self._has_final_terminal_ack(record):
|
||||||
await self._release_confirmed_terminal_barrier(record, timeout=0)
|
await self._release_confirmed_terminal_barrier(record, timeout=0)
|
||||||
@ -2915,7 +2965,7 @@ class RunManager:
|
|||||||
if renewal.cancel_action is not None:
|
if renewal.cancel_action is not None:
|
||||||
action = renewal.cancel_action if renewal.cancel_action in ("interrupt", "rollback") else "interrupt"
|
action = renewal.cancel_action if renewal.cancel_action in ("interrupt", "rollback") else "interrupt"
|
||||||
# Signal here, before joining any other run's decision.
|
# Signal here, before joining any other run's decision.
|
||||||
await self._signal_local_cancel(record.run_id, action=action)
|
return action
|
||||||
return
|
return
|
||||||
if (
|
if (
|
||||||
retry_index == 0
|
retry_index == 0
|
||||||
|
|||||||
@ -10,29 +10,47 @@ Consumers: ``deerflow.runtime.runs.worker`` (SSE publishing) and
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
def serialize_lc_object(obj: Any) -> Any:
|
def serialize_lc_object(obj: Any) -> Any:
|
||||||
"""Recursively serialize a LangChain object to a JSON-serialisable dict."""
|
"""Recursively serialize a LangChain object to a JSON-serialisable dict.
|
||||||
|
|
||||||
|
Non-finite floats (``NaN`` / ``inf`` / ``-inf``) are normalized to ``None``.
|
||||||
|
They are not valid JSON (RFC 8259) and reach this serializer from ordinary
|
||||||
|
runtime data — e.g. ``json.loads("1e400")`` yields ``inf`` for a tool parsing
|
||||||
|
external JSON, or a provider logprob of ``-inf`` for a zero-probability token.
|
||||||
|
Left untouched they break both display paths that share this serializer: the
|
||||||
|
SSE wire (``format_sse`` / the Redis bridge encode with the lenient default,
|
||||||
|
emitting bare ``NaN`` / ``Infinity`` tokens the ``langgraph-sdk`` / useStream
|
||||||
|
decoder rejects) and the REST surface (Starlette's ``JSONResponse`` uses
|
||||||
|
``allow_nan=False`` and raises, turning a thread read into HTTP 500). This is
|
||||||
|
a display/wire normalization only; persisted checkpoint state is never routed
|
||||||
|
through here.
|
||||||
|
"""
|
||||||
if obj is None:
|
if obj is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(obj, (str, int, float, bool)):
|
# ``bool`` is a subclass of ``int``, so it passes through unchanged here.
|
||||||
|
if isinstance(obj, (str, int)):
|
||||||
return obj
|
return obj
|
||||||
|
if isinstance(obj, float):
|
||||||
|
return obj if math.isfinite(obj) else None
|
||||||
if isinstance(obj, dict):
|
if isinstance(obj, dict):
|
||||||
return {k: serialize_lc_object(v) for k, v in obj.items()}
|
return {k: serialize_lc_object(v) for k, v in obj.items()}
|
||||||
if isinstance(obj, (list, tuple)):
|
if isinstance(obj, (list, tuple)):
|
||||||
return [serialize_lc_object(item) for item in obj]
|
return [serialize_lc_object(item) for item in obj]
|
||||||
# Pydantic v2
|
# Pydantic v2 — recurse so non-finite floats nested in the dump (e.g. a
|
||||||
|
# message's ``response_metadata``) are normalized too, not returned raw.
|
||||||
if hasattr(obj, "model_dump"):
|
if hasattr(obj, "model_dump"):
|
||||||
try:
|
try:
|
||||||
return obj.model_dump()
|
return serialize_lc_object(obj.model_dump())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Pydantic v1 / older objects
|
# Pydantic v1 / older objects
|
||||||
if hasattr(obj, "dict"):
|
if hasattr(obj, "dict"):
|
||||||
try:
|
try:
|
||||||
return obj.dict()
|
return serialize_lc_object(obj.dict())
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Interrupt is a __slots__ class — no model_dump/dict/__dict__, so it
|
# Interrupt is a __slots__ class — no model_dump/dict/__dict__, so it
|
||||||
@ -124,10 +142,22 @@ def serialize_channel_values_for_api(channel_values: dict[str, Any]) -> dict[str
|
|||||||
|
|
||||||
|
|
||||||
def serialize_messages_tuple(obj: Any) -> Any:
|
def serialize_messages_tuple(obj: Any) -> Any:
|
||||||
"""Serialize a messages-mode tuple ``(chunk, metadata)``."""
|
"""Serialize a messages-mode tuple ``(chunk, metadata)``.
|
||||||
|
|
||||||
|
The metadata dict rides the same wire as the chunk, so it goes through the
|
||||||
|
same canonical serializer: a non-finite float in metadata (LangGraph merges
|
||||||
|
caller-supplied run metadata, which can carry one) would otherwise reach
|
||||||
|
``format_sse`` / the Redis bridge encode and emit a bare ``NaN`` /
|
||||||
|
``Infinity`` token the SSE decoder rejects. Real LangGraph metadata also
|
||||||
|
contains tuples (``langgraph_triggers`` / ``langgraph_path``); they become
|
||||||
|
lists here, their JSON wire shape anyway.
|
||||||
|
"""
|
||||||
if isinstance(obj, tuple) and len(obj) == 2:
|
if isinstance(obj, tuple) and len(obj) == 2:
|
||||||
chunk, metadata = obj
|
chunk, metadata = obj
|
||||||
return [serialize_lc_object(chunk), metadata if isinstance(metadata, dict) else {}]
|
return [
|
||||||
|
serialize_lc_object(chunk),
|
||||||
|
serialize_lc_object(metadata) if isinstance(metadata, dict) else {},
|
||||||
|
]
|
||||||
return serialize_lc_object(obj)
|
return serialize_lc_object(obj)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
"""Bounded byte snapshots for managed custom packages (POSIX, no links)."""
|
"""Bounded byte snapshots for managed custom packages (no links).
|
||||||
|
|
||||||
|
POSIX walks the tree with ``dir_fd`` descriptors so every path component is
|
||||||
|
anchored. Hosts without ``dir_fd``/``O_NOFOLLOW`` (Windows) use
|
||||||
|
:func:`_capture_package_portable`, which re-checks each component with ``lstat``
|
||||||
|
before use and compares the pre-open and post-open ``stat`` of every file, so a
|
||||||
|
swap during the walk is still rejected instead of followed.
|
||||||
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -47,7 +54,7 @@ class PackageSnapshot:
|
|||||||
def capture_package(root: Path) -> PackageSnapshot:
|
def capture_package(root: Path) -> PackageSnapshot:
|
||||||
"""Caller owns mutation guard. Never read outside the opened package tree."""
|
"""Caller owns mutation guard. Never read outside the opened package tree."""
|
||||||
if os.name != "posix":
|
if os.name != "posix":
|
||||||
raise ValueError("UNSUPPORTED_ASSET")
|
return _capture_package_portable(root)
|
||||||
files: list[PackageFile] = []
|
files: list[PackageFile] = []
|
||||||
total = 0
|
total = 0
|
||||||
entries = 0
|
entries = 0
|
||||||
@ -146,3 +153,101 @@ def open_directory_chain(path: Path) -> int:
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
os.close(fd)
|
os.close(fd)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_unlinked_ancestors(path: Path) -> Path:
|
||||||
|
"""Return *path* after rejecting a link in any component above it.
|
||||||
|
|
||||||
|
The non-POSIX twin of :func:`open_directory_chain`: there is no ``dir_fd``
|
||||||
|
to anchor the walk, so each component is ``lstat``-ed in order and a link
|
||||||
|
stops the walk instead of being traversed.
|
||||||
|
"""
|
||||||
|
path = path.absolute()
|
||||||
|
if ".." in path.parts or len(path.parts) > 128 or len(os.fsencode(path)) > 4096:
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET")
|
||||||
|
current = Path(path.anchor)
|
||||||
|
for part in path.parts[1:]:
|
||||||
|
current = current / part
|
||||||
|
info = os.lstat(current)
|
||||||
|
if stat.S_ISLNK(info.st_mode) or not stat.S_ISDIR(info.st_mode):
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _capture_package_portable(root: Path) -> PackageSnapshot:
|
||||||
|
"""Capture a package where ``dir_fd``/``O_NOFOLLOW`` are unavailable.
|
||||||
|
|
||||||
|
``os.lstat`` runs before every open and the opened descriptor is compared
|
||||||
|
against that ``lstat`` afterwards, so a component replaced between the two
|
||||||
|
calls raises ``REVISION_CONFLICT`` instead of publishing foreign bytes.
|
||||||
|
Windows keeps ``st_dev``/``st_ino`` for NTFS volumes, which makes that
|
||||||
|
comparison meaningful there; the executable bit stays unset because the
|
||||||
|
platform has no mode bits, and that is consistent within a host.
|
||||||
|
"""
|
||||||
|
files: list[PackageFile] = []
|
||||||
|
total = 0
|
||||||
|
entries = 0
|
||||||
|
open_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOINHERIT", 0)
|
||||||
|
|
||||||
|
def walk(directory: Path, prefix: str = "", depth: int = 0) -> None:
|
||||||
|
nonlocal total, entries
|
||||||
|
if depth > MAX_DEPTH:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
names = []
|
||||||
|
with os.scandir(directory) as iterator:
|
||||||
|
for entry in iterator:
|
||||||
|
entries += 1
|
||||||
|
if entries > MAX_FILES * 2:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
names.append(entry.name)
|
||||||
|
for name in sorted(names):
|
||||||
|
if "\\" in name or any(ord(c) < 32 for c in name):
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET")
|
||||||
|
path = f"{prefix}/{name}" if prefix else name
|
||||||
|
if len(path.encode("utf-8")) > 1024:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
target = directory / name
|
||||||
|
info = os.lstat(target)
|
||||||
|
if stat.S_ISLNK(info.st_mode):
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET")
|
||||||
|
if stat.S_ISDIR(info.st_mode):
|
||||||
|
walk(target, path, depth + 1)
|
||||||
|
continue
|
||||||
|
if not stat.S_ISREG(info.st_mode):
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET")
|
||||||
|
if len(files) >= MAX_FILES:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
limit = min(MAX_PACKAGE_BYTES - total, MAX_MAIN_BYTES if path == "SKILL.md" else MAX_PACKAGE_BYTES)
|
||||||
|
if info.st_size > limit:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
fd = os.open(target, open_flags)
|
||||||
|
try:
|
||||||
|
opened = os.fstat(fd)
|
||||||
|
if (info.st_dev, info.st_ino, info.st_mode) != (opened.st_dev, opened.st_ino, opened.st_mode):
|
||||||
|
raise ValueError("REVISION_CONFLICT")
|
||||||
|
data = bytearray()
|
||||||
|
while True:
|
||||||
|
chunk = os.read(fd, min(65536, limit + 1 - len(data)))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
data.extend(chunk)
|
||||||
|
if len(data) > limit:
|
||||||
|
raise ValueError("QUOTA_EXCEEDED")
|
||||||
|
after = os.fstat(fd)
|
||||||
|
if (opened.st_size, opened.st_mtime_ns, opened.st_ctime_ns) != (after.st_size, after.st_mtime_ns, after.st_ctime_ns):
|
||||||
|
raise ValueError("REVISION_CONFLICT")
|
||||||
|
total += len(data)
|
||||||
|
files.append(PackageFile(path, bytes(data), bool(opened.st_mode & 0o111)))
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
try:
|
||||||
|
walk(_resolve_unlinked_ancestors(root))
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET") from exc
|
||||||
|
result = PackageSnapshot(tuple(sorted(files, key=lambda item: item.path)))
|
||||||
|
try:
|
||||||
|
result.main_content
|
||||||
|
except (StopIteration, UnicodeError) as exc:
|
||||||
|
raise ValueError("UNSUPPORTED_ASSET") from exc
|
||||||
|
return result
|
||||||
|
|||||||
@ -76,6 +76,8 @@ by lease/3. `tests/test_batch_lease_lifecycle.py` covers real SQLite interleavin
|
|||||||
threaded initialization, peer cancellation and renewal drain.
|
threaded initialization, peer cancellation and renewal drain.
|
||||||
|
|
||||||
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
|
**Built-in Agents**: `general-purpose` (all tools except `task`) and `bash` (command specialist)
|
||||||
|
|
||||||
|
Native worker reasoning settings come from YAML or managed definitions, with explicit per-agent overrides. Unset values request False/None, never parent thinking. Send original settings to the model factory to preserve validation and adjustment logs; record normalized effective settings in the assembly observer. Legacy stored definitions may omit both fields.
|
||||||
**Registry and managed definitions**: Resolve built-ins → `config.yaml custom_agents` → enabled admin-managed definitions → explicit `subagents.agents.<name>` overrides. Managed definitions are deployment-wide, use Custom Agents' `agent_storage.backend`, and remain stored but inactive on built-in/config name collisions. Default Lead Agent sees the full enabled catalog. Snapshot a Custom Agent's `allowed_subagents` into run metadata (`None` = all, `[]` = deny, list = allowlist); enforce it in prompt discovery and `task`, never reload mutable caller policy inside the tool.
|
**Registry and managed definitions**: Resolve built-ins → `config.yaml custom_agents` → enabled admin-managed definitions → explicit `subagents.agents.<name>` overrides. Managed definitions are deployment-wide, use Custom Agents' `agent_storage.backend`, and remain stored but inactive on built-in/config name collisions. Default Lead Agent sees the full enabled catalog. Snapshot a Custom Agent's `allowed_subagents` into run metadata (`None` = all, `[]` = deny, list = allowlist); enforce it in prompt discovery and `task`, never reload mutable caller policy inside the tool.
|
||||||
**Benefit-based routing policy**: Default to direct execution. Permit `task` only when parallel latency, specialist skill or context isolation clearly outweigh startup, duplicate discovery, synthesis, state conflicts and side effects. Parallel scopes need independent outputs and disjoint mutable state; use the fewest useful agents, reassessing each batch. Bounded sequential delegation requires strong specialist/context benefit. A per-response limit of 1 permits only material specialist/context benefit: omit parallel/multi-batch benefits. Align `lead_agent/prompt.py`, the `task` description and built-in roles. Tests: `test_subagent_routing_prompt.py`, `test_subagent_prompt_security.py`, `test_lead_agent_prompt.py`.
|
**Benefit-based routing policy**: Default to direct execution. Permit `task` only when parallel latency, specialist skill or context isolation clearly outweigh startup, duplicate discovery, synthesis, state conflicts and side effects. Parallel scopes need independent outputs and disjoint mutable state; use the fewest useful agents, reassessing each batch. Bounded sequential delegation requires strong specialist/context benefit. A per-response limit of 1 permits only material specialist/context benefit: omit parallel/multi-batch benefits. Align `lead_agent/prompt.py`, the `task` description and built-in roles. Tests: `test_subagent_routing_prompt.py`, `test_subagent_prompt_security.py`, `test_lead_agent_prompt.py`.
|
||||||
**User-scoped Skills**: Use `get_or_new_user_skill_storage(user_id)` with parent runtime identity (`DEFAULT_USER_ID` only if absent), preserving lead-agent custom-skill shadowing/visibility; never use the global-only catalog.
|
**User-scoped Skills**: Use `get_or_new_user_skill_storage(user_id)` with parent runtime identity (`DEFAULT_USER_ID` only if absent), preserving lead-agent custom-skill shadowing/visibility; never use the global-only catalog.
|
||||||
|
|||||||
@ -25,6 +25,9 @@ class SubagentConfig:
|
|||||||
allowed-tools is compatibility metadata only and never restricts
|
allowed-tools is compatibility metadata only and never restricts
|
||||||
this subagent's tools.
|
this subagent's tools.
|
||||||
model: Model to use - 'inherit' uses parent's model.
|
model: Model to use - 'inherit' uses parent's model.
|
||||||
|
thinking_enabled: Whether to enable thinking/reasoning for this subagent.
|
||||||
|
None means use default (False).
|
||||||
|
reasoning_effort: Selected model's reasoning effort or None for its default.
|
||||||
max_turns: Maximum agent turns — model call plus the tools it runs —
|
max_turns: Maximum agent turns — model call plus the tools it runs —
|
||||||
before stopping. Built-in agents use the value set here
|
before stopping. Built-in agents use the value set here
|
||||||
(general-purpose=150, bash=60) unless the global
|
(general-purpose=150, bash=60) unless the global
|
||||||
@ -46,6 +49,8 @@ class SubagentConfig:
|
|||||||
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
|
disallowed_tools: list[str] | None = field(default_factory=lambda: ["task"])
|
||||||
skills: list[str] | None = None
|
skills: list[str] | None = None
|
||||||
model: str = "inherit"
|
model: str = "inherit"
|
||||||
|
thinking_enabled: bool | None = None
|
||||||
|
reasoning_effort: str | None = None
|
||||||
max_turns: int = 50
|
max_turns: int = 50
|
||||||
timeout_seconds: int = 900
|
timeout_seconds: int = 900
|
||||||
prompt_overlay: PromptOverlay = field(default_factory=PromptOverlay)
|
prompt_overlay: PromptOverlay = field(default_factory=PromptOverlay)
|
||||||
|
|||||||
@ -66,6 +66,8 @@ from deerflow.utils.file_io import await_drained
|
|||||||
from deerflow.utils.messages import message_content_to_text
|
from deerflow.utils.messages import message_content_to_text
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
|
from deerflow.models.reasoning import ResolvedReasoning
|
||||||
|
|
||||||
# Imported lazily at runtime inside _build_initial_state: importing
|
# Imported lazily at runtime inside _build_initial_state: importing
|
||||||
# tool_search eagerly would run tools/builtins/__init__ -> task_tool ->
|
# tool_search eagerly would run tools/builtins/__init__ -> task_tool ->
|
||||||
# `from deerflow.subagents import SubagentExecutor`, which re-enters this
|
# `from deerflow.subagents import SubagentExecutor`, which re-enters this
|
||||||
@ -989,6 +991,11 @@ class SubagentExecutor:
|
|||||||
self.model_name: str | None = resolve_subagent_model_name(config, parent_model, app_config=app_config)
|
self.model_name: str | None = resolve_subagent_model_name(config, parent_model, app_config=app_config)
|
||||||
else:
|
else:
|
||||||
self.model_name = None
|
self.model_name = None
|
||||||
|
# Resolve thinking_enabled: config explicit > False
|
||||||
|
self.thinking_enabled = config.thinking_enabled if config.thinking_enabled is not None else False
|
||||||
|
# Resolve reasoning_effort: config explicit > None
|
||||||
|
self.reasoning_effort = config.reasoning_effort
|
||||||
|
|
||||||
self.sandbox_state = sandbox_state
|
self.sandbox_state = sandbox_state
|
||||||
self.thread_data = thread_data
|
self.thread_data = thread_data
|
||||||
self.uploaded_files = deepcopy(uploaded_files) if uploaded_files is not None else None
|
self.uploaded_files = deepcopy(uploaded_files) if uploaded_files is not None else None
|
||||||
@ -1100,7 +1107,24 @@ class SubagentExecutor:
|
|||||||
app_config = self._get_resolved_app_config()
|
app_config = self._get_resolved_app_config()
|
||||||
if self.model_name is None:
|
if self.model_name is None:
|
||||||
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
|
self.model_name = resolve_subagent_model_name(self.config, self.parent_model, app_config=app_config)
|
||||||
model = create_chat_model(name=self.model_name, thinking_enabled=False, app_config=app_config, attach_tracing=False)
|
from deerflow.models.reasoning import ReasoningPolicyError, resolve_reasoning_contract, resolve_reasoning_request
|
||||||
|
|
||||||
|
get_model_config = getattr(app_config, "get_model_config", None)
|
||||||
|
model_config = get_model_config(self.model_name) if callable(get_model_config) else None
|
||||||
|
if model_config is None:
|
||||||
|
model_config = next((profile for profile in getattr(app_config, "models", []) if profile.name == self.model_name), None)
|
||||||
|
try:
|
||||||
|
effective_reasoning = resolve_reasoning_request(resolve_reasoning_contract(model_config), thinking_enabled=self.thinking_enabled, reasoning_effort=self.reasoning_effort)
|
||||||
|
except ReasoningPolicyError as exc:
|
||||||
|
raise ValueError(f"Model {self.model_name}: {exc}") from exc
|
||||||
|
# Preserve the original request for the factory's validation and logs.
|
||||||
|
model = create_chat_model(
|
||||||
|
name=self.model_name,
|
||||||
|
thinking_enabled=self.thinking_enabled,
|
||||||
|
reasoning_effort=self.reasoning_effort,
|
||||||
|
app_config=app_config,
|
||||||
|
attach_tracing=False,
|
||||||
|
)
|
||||||
|
|
||||||
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
|
from deerflow.agents.middlewares.tool_error_handling_middleware import build_subagent_runtime_middlewares
|
||||||
|
|
||||||
@ -1218,7 +1242,8 @@ class SubagentExecutor:
|
|||||||
tool_node = tool_graph_node.data if tool_graph_node is not None else None
|
tool_node = tool_graph_node.data if tool_graph_node is not None else None
|
||||||
self._return_direct_tools = {tool.name for tool in tool_node.tools_by_name.values() if tool.return_direct} if isinstance(tool_node, ToolNode) else set()
|
self._return_direct_tools = {tool.name for tool in tool_node.tools_by_name.values() if tool.return_direct} if isinstance(tool_node, ToolNode) else set()
|
||||||
self._describe_assembly(
|
self._describe_assembly(
|
||||||
app_config=app_config,
|
model_config=model_config,
|
||||||
|
effective_reasoning=effective_reasoning,
|
||||||
tools=bound_tools,
|
tools=bound_tools,
|
||||||
middlewares=middlewares,
|
middlewares=middlewares,
|
||||||
deferred_setup=deferred_setup,
|
deferred_setup=deferred_setup,
|
||||||
@ -1267,7 +1292,8 @@ class SubagentExecutor:
|
|||||||
def _describe_assembly(
|
def _describe_assembly(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
app_config: Any,
|
model_config: Any,
|
||||||
|
effective_reasoning: "ResolvedReasoning",
|
||||||
tools: list[Any],
|
tools: list[Any],
|
||||||
middlewares: list[Any],
|
middlewares: list[Any],
|
||||||
deferred_setup: "DeferredToolSetup | None",
|
deferred_setup: "DeferredToolSetup | None",
|
||||||
@ -1289,8 +1315,6 @@ class SubagentExecutor:
|
|||||||
from deerflow.extensions.notify import notify_agent_assembled
|
from deerflow.extensions.notify import notify_agent_assembled
|
||||||
|
|
||||||
try:
|
try:
|
||||||
get_model_config = getattr(app_config, "get_model_config", None)
|
|
||||||
model_config = get_model_config(self.model_name) if callable(get_model_config) else None
|
|
||||||
if model_config is None:
|
if model_config is None:
|
||||||
# A name the profile table does not know still has an identity;
|
# A name the profile table does not know still has an identity;
|
||||||
# a missing profile must not blank out the whole descriptor.
|
# a missing profile must not blank out the whole descriptor.
|
||||||
@ -1302,12 +1326,6 @@ class SubagentExecutor:
|
|||||||
supports_vision=False,
|
supports_vision=False,
|
||||||
)
|
)
|
||||||
deferred_names = deferred_setup.deferred_names if deferred_setup is not None else frozenset()
|
deferred_names = deferred_setup.deferred_names if deferred_setup is not None else frozenset()
|
||||||
# Subagents request thinking off; the model's reasoning contract
|
|
||||||
# decides what that means (a required-thinking model stays on), and
|
|
||||||
# the descriptor reports the effective policy the factory applied.
|
|
||||||
from deerflow.models.reasoning import resolve_reasoning_contract, resolve_reasoning_request
|
|
||||||
|
|
||||||
effective_reasoning = resolve_reasoning_request(resolve_reasoning_contract(model_config), thinking_enabled=False, reasoning_effort=None)
|
|
||||||
descriptor = build_assembly_descriptor(
|
descriptor = build_assembly_descriptor(
|
||||||
namespace="deerflow",
|
namespace="deerflow",
|
||||||
agent_name=self.config.name,
|
agent_name=self.config.name,
|
||||||
|
|||||||
@ -49,6 +49,8 @@ def _build_custom_subagent_config(name: str, *, app_config: Any | None = None) -
|
|||||||
disallowed_tools=custom.disallowed_tools,
|
disallowed_tools=custom.disallowed_tools,
|
||||||
skills=custom.skills,
|
skills=custom.skills,
|
||||||
model=custom.model,
|
model=custom.model,
|
||||||
|
thinking_enabled=custom.thinking_enabled,
|
||||||
|
reasoning_effort=custom.reasoning_effort,
|
||||||
max_turns=custom.max_turns,
|
max_turns=custom.max_turns,
|
||||||
timeout_seconds=custom.timeout_seconds,
|
timeout_seconds=custom.timeout_seconds,
|
||||||
)
|
)
|
||||||
@ -97,6 +99,8 @@ def _build_managed_subagent_config(name: str, *, app_config: Any | None = None)
|
|||||||
disallowed_tools=definition.disallowed_tools,
|
disallowed_tools=definition.disallowed_tools,
|
||||||
skills=definition.skills,
|
skills=definition.skills,
|
||||||
model=definition.model,
|
model=definition.model,
|
||||||
|
thinking_enabled=definition.thinking_enabled,
|
||||||
|
reasoning_effort=definition.reasoning_effort,
|
||||||
max_turns=definition.max_turns,
|
max_turns=definition.max_turns,
|
||||||
timeout_seconds=definition.timeout_seconds,
|
timeout_seconds=definition.timeout_seconds,
|
||||||
)
|
)
|
||||||
@ -110,7 +114,7 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
|
|||||||
1. Built-in subagents (general-purpose, bash)
|
1. Built-in subagents (general-purpose, bash)
|
||||||
2. Custom subagents from config.yaml custom_agents section
|
2. Custom subagents from config.yaml custom_agents section
|
||||||
3. Enabled administrator-managed subagents
|
3. Enabled administrator-managed subagents
|
||||||
4. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, skills)
|
4. Per-agent overrides from config.yaml agents section (timeout, max_turns, model, thinking_enabled, reasoning_effort, skills)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: The name of the subagent.
|
name: The name of the subagent.
|
||||||
@ -173,6 +177,18 @@ def get_subagent_config(name: str, *, app_config: Any | None = None) -> Subagent
|
|||||||
logger.debug("Subagent '%s': skills overridden (%s -> %s)", name, config.skills, effective_skills)
|
logger.debug("Subagent '%s': skills overridden (%s -> %s)", name, config.skills, effective_skills)
|
||||||
overrides["skills"] = effective_skills
|
overrides["skills"] = effective_skills
|
||||||
|
|
||||||
|
# Thinking: per-agent override only (no global default)
|
||||||
|
effective_thinking = subagents_config.get_thinking_enabled_for(name)
|
||||||
|
if effective_thinking is not None and effective_thinking != config.thinking_enabled:
|
||||||
|
logger.debug("Subagent '%s': thinking_enabled overridden (%s -> %s)", name, config.thinking_enabled, effective_thinking)
|
||||||
|
overrides["thinking_enabled"] = effective_thinking
|
||||||
|
|
||||||
|
# Reasoning effort: per-agent override only (no global default)
|
||||||
|
effective_reasoning = subagents_config.get_reasoning_effort_for(name)
|
||||||
|
if effective_reasoning is not None and effective_reasoning != config.reasoning_effort:
|
||||||
|
logger.debug("Subagent '%s': reasoning_effort overridden (%s -> %s)", name, config.reasoning_effort, effective_reasoning)
|
||||||
|
overrides["reasoning_effort"] = effective_reasoning
|
||||||
|
|
||||||
if overrides:
|
if overrides:
|
||||||
config = replace(config, **overrides)
|
config = replace(config, **overrides)
|
||||||
|
|
||||||
|
|||||||
@ -136,6 +136,13 @@ pins offload, executor isolation, request context, and failure ordering.
|
|||||||
|
|
||||||
Tool and agent assembly re-enters `get_available_tools()` and may block on MCP discovery, so the four async assembly entry points — `run_agent`'s `agent_factory` call, `task_tool`, durable batch `_execute_item`, and `abuild_checkpoint_state_accessor` — dispatch through `deerflow.utils.assembly_io.run_assembly`, a dedicated ContextVar-preserving bounded executor (`DEER_FLOW_ASSEMBLY_WORKERS`, default 8) rather than the loop's default executor; a hung MCP server therefore parks an assembly worker instead of queueing unrelated default-executor work, and the pool logs a warning when pending assemblies exceed the worker count. `tests/blocking_io/test_tool_assembly_offloop.py` pins all four offloads plus the ContextVar propagation.
|
Tool and agent assembly re-enters `get_available_tools()` and may block on MCP discovery, so the four async assembly entry points — `run_agent`'s `agent_factory` call, `task_tool`, durable batch `_execute_item`, and `abuild_checkpoint_state_accessor` — dispatch through `deerflow.utils.assembly_io.run_assembly`, a dedicated ContextVar-preserving bounded executor (`DEER_FLOW_ASSEMBLY_WORKERS`, default 8) rather than the loop's default executor; a hung MCP server therefore parks an assembly worker instead of queueing unrelated default-executor work, and the pool logs a warning when pending assemblies exceed the worker count. `tests/blocking_io/test_tool_assembly_offloop.py` pins all four offloads plus the ContextVar propagation.
|
||||||
|
|
||||||
|
### Document Conversion
|
||||||
|
|
||||||
|
`file_conversion.convert_file_to_markdown` resolves the live PDF converter in
|
||||||
|
its drained parsing worker. Config cache hits still stat/hash files; keep them
|
||||||
|
off-loop with parsing. Cold/cache-hit coverage uses real temporary configs in
|
||||||
|
`tests/blocking_io/test_file_conversion_offloop.py`.
|
||||||
|
|
||||||
### Uploaded Document Summaries
|
### Uploaded Document Summaries
|
||||||
|
|
||||||
Outlines skip root-level HTML comment blocks (`<!--` with up to three leading
|
Outlines skip root-level HTML comment blocks (`<!--` with up to three leading
|
||||||
|
|||||||
@ -8,9 +8,9 @@ PDF conversion strategy (auto mode):
|
|||||||
total when page count is unavailable), treat as image-based and fall back to MarkItDown.
|
total when page count is unavailable), treat as image-based and fall back to MarkItDown.
|
||||||
3. If pymupdf4llm is not installed, use MarkItDown directly (existing behaviour).
|
3. If pymupdf4llm is not installed, use MarkItDown directly (existing behaviour).
|
||||||
|
|
||||||
All document parsing runs in a thread pool via asyncio.to_thread() so compressed
|
Configuration resolution and all document parsing run in a thread pool via
|
||||||
Office files and small PDFs cannot block the serving event loop. Source byte
|
asyncio.to_thread() so config reads, compressed Office files and small PDFs cannot
|
||||||
size does not bound parsing time.
|
block the serving event loop. Source byte size does not bound parsing time.
|
||||||
|
|
||||||
No FastAPI or HTTP dependencies — pure utility functions.
|
No FastAPI or HTTP dependencies — pure utility functions.
|
||||||
"""
|
"""
|
||||||
@ -154,12 +154,17 @@ async def convert_file_to_markdown(file_path: Path, output_path: Path | None = N
|
|||||||
Returns:
|
Returns:
|
||||||
Path to the generated .md file, or None if conversion failed.
|
Path to the generated .md file, or None if conversion failed.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def convert_source() -> str:
|
||||||
|
return _do_convert(file_path, _get_pdf_converter())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
pdf_converter = _get_pdf_converter()
|
# Config resolution also stats and hashes files, including cache hits.
|
||||||
|
# Keep it in the same drained worker as parsing.
|
||||||
# Compressed size cannot predict parsing cost (e.g. a small XLSX can
|
# Compressed size cannot predict parsing cost (e.g. a small XLSX can
|
||||||
# contain many cells). Always offload, and drain across cancellation so
|
# contain many cells). Always offload, and drain across cancellation so
|
||||||
# the caller retains its source until the converter finishes reading.
|
# the caller retains its source until the converter finishes reading.
|
||||||
text = await await_drained(asyncio.to_thread(_do_convert, file_path, pdf_converter))
|
text = await await_drained(asyncio.to_thread(convert_source))
|
||||||
|
|
||||||
md_path = output_path if output_path is not None else file_path.with_suffix(".md")
|
md_path = output_path if output_path is not None else file_path.with_suffix(".md")
|
||||||
# Drain the write-back across caller cancellation: run_file_io awaits
|
# Drain the write-back across caller cancellation: run_file_io awaits
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "deerflow-harness"
|
name = "deerflow-harness"
|
||||||
version = "2.2.0-rc0"
|
version = "2.3.0-dev"
|
||||||
description = "DeerFlow agent harness framework"
|
description = "DeerFlow agent harness framework"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "deer-flow"
|
name = "deer-flow"
|
||||||
version = "2.2.0-rc0"
|
version = "2.3.0-dev"
|
||||||
description = "LangGraph-based AI agent system with sandbox execution capabilities"
|
description = "LangGraph-based AI agent system with sandbox execution capabilities"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
|
|||||||
@ -220,6 +220,13 @@ contract probes execute the actual migration test setup through SQLAlchemy's
|
|||||||
dialect argument conversion, stopping before database acquisition; the real
|
dialect argument conversion, stopping before database acquisition; the real
|
||||||
SQLite/PostgreSQL cases still exercise upgrade, downgrade and re-upgrade.
|
SQLite/PostgreSQL cases still exercise upgrade, downgrade and re-upgrade.
|
||||||
|
|
||||||
|
## Batch lease renewal races
|
||||||
|
|
||||||
|
`test_batch_lease_lifecycle.py` parks recovery after the per-item expiry SELECT,
|
||||||
|
identified by selected item columns; the parent query can contain the same expiry
|
||||||
|
predicate. Assert one rejected item UPDATE as well as the preserved row, so an
|
||||||
|
empty expiry scan cannot satisfy the concurrency guard.
|
||||||
|
|
||||||
## DingTalk stream tasks
|
## DingTalk stream tasks
|
||||||
|
|
||||||
DingTalk stream lifecycle tests drive `_serve_stream` with a fake websocket and
|
DingTalk stream lifecycle tests drive `_serve_stream` with a fake websocket and
|
||||||
|
|||||||
@ -93,3 +93,56 @@ async def test_cancelled_conversion_cleanup_failure_preserves_cancellation(
|
|||||||
finish.set()
|
finish.set()
|
||||||
|
|
||||||
assert any("partial conversion output" in record.getMessage() for record in caplog.records), "cleanup failure should be logged inside the handler, not escaped"
|
assert any("partial conversion output" in record.getMessage() for record in caplog.records), "cleanup failure should be logged inside the handler, not escaped"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pdf_converter", ["markitdown", "pymupdf4llm"])
|
||||||
|
@pytest.mark.parametrize("warm_cache", [False, True], ids=["cold_config", "cached_config"])
|
||||||
|
async def test_conversion_resolves_real_config_in_the_parser_worker(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, pdf_converter: str, warm_cache: bool) -> None:
|
||||||
|
from deerflow.config.app_config import AppConfig, get_app_config, reset_app_config
|
||||||
|
from deerflow.config.extensions_config import reset_extensions_config
|
||||||
|
from deerflow.utils import file_conversion
|
||||||
|
|
||||||
|
config_path = tmp_path / "config.yaml"
|
||||||
|
extensions_path = tmp_path / "extensions_config.json"
|
||||||
|
source = tmp_path / "document.pdf"
|
||||||
|
|
||||||
|
def seed_files() -> None:
|
||||||
|
config_path.write_text(f"sandbox:\n use: deerflow.sandbox.local:LocalSandboxProvider\nuploads:\n pdf_converter: {pdf_converter}\n", encoding="utf-8")
|
||||||
|
extensions_path.write_text('{"mcpServers": {}, "skills": {}}', encoding="utf-8")
|
||||||
|
source.write_text("synthetic source", encoding="utf-8")
|
||||||
|
|
||||||
|
await asyncio.to_thread(seed_files)
|
||||||
|
monkeypatch.setenv("DEER_FLOW_CONFIG_PATH", str(config_path))
|
||||||
|
monkeypatch.setenv("DEER_FLOW_EXTENSIONS_CONFIG_PATH", str(extensions_path))
|
||||||
|
reset_app_config()
|
||||||
|
reset_extensions_config()
|
||||||
|
config_threads: list[int] = []
|
||||||
|
parser_threads: list[int] = []
|
||||||
|
selected: list[str] = []
|
||||||
|
loop_thread = threading.get_ident()
|
||||||
|
|
||||||
|
def load_config() -> AppConfig:
|
||||||
|
config_threads.append(threading.get_ident())
|
||||||
|
return get_app_config()
|
||||||
|
|
||||||
|
def parse_document(path: Path, converter: str) -> str:
|
||||||
|
parser_threads.append(threading.get_ident())
|
||||||
|
selected.append(converter)
|
||||||
|
return path.read_text(encoding="utf-8") + " converted"
|
||||||
|
|
||||||
|
monkeypatch.setattr(file_conversion, "get_app_config", load_config)
|
||||||
|
monkeypatch.setattr(file_conversion, "_do_convert", parse_document)
|
||||||
|
try:
|
||||||
|
if warm_cache:
|
||||||
|
await asyncio.to_thread(get_app_config)
|
||||||
|
output = await file_conversion.convert_file_to_markdown(source)
|
||||||
|
finally:
|
||||||
|
reset_app_config()
|
||||||
|
reset_extensions_config()
|
||||||
|
|
||||||
|
assert config_threads == parser_threads
|
||||||
|
assert len(config_threads) == 1 and config_threads[0] != loop_thread
|
||||||
|
assert selected == [pdf_converter]
|
||||||
|
assert output is not None
|
||||||
|
assert output == source.with_suffix(".md")
|
||||||
|
assert output.read_text(encoding="utf-8") == "synthetic source converted"
|
||||||
|
|||||||
@ -16,6 +16,8 @@ class _BlockingFakeClient:
|
|||||||
|
|
||||||
def __init__(self, probe_dir: Path):
|
def __init__(self, probe_dir: Path):
|
||||||
self._probe = probe_dir / "probe.txt"
|
self._probe = probe_dir / "probe.txt"
|
||||||
|
self.looked_up = False
|
||||||
|
self.written = False
|
||||||
|
|
||||||
def _block(self) -> None:
|
def _block(self) -> None:
|
||||||
self._probe.write_text("blocked io")
|
self._probe.write_text("blocked io")
|
||||||
@ -31,6 +33,12 @@ class _BlockingFakeClient:
|
|||||||
|
|
||||||
def add_messages(self, ws: str, sid: str, msgs: list[Any]) -> None:
|
def add_messages(self, ws: str, sid: str, msgs: list[Any]) -> None:
|
||||||
self._block()
|
self._block()
|
||||||
|
self.written = True
|
||||||
|
|
||||||
|
def existing_source_ids(self, ws: str, sid: str, source_ids: list[str]) -> set[str]:
|
||||||
|
self._block()
|
||||||
|
self.looked_up = True
|
||||||
|
return set()
|
||||||
|
|
||||||
def working_representation(self, ws: str, pid: str, *, max_conclusions: int = 25) -> str:
|
def working_representation(self, ws: str, pid: str, *, max_conclusions: int = 25) -> str:
|
||||||
self._block()
|
self._block()
|
||||||
@ -65,6 +73,8 @@ async def test_async_honcho_operations_do_not_block_event_loop(
|
|||||||
messages,
|
messages,
|
||||||
user_id="alice",
|
user_id="alice",
|
||||||
)
|
)
|
||||||
|
assert manager._client.looked_up
|
||||||
|
assert manager._client.written
|
||||||
assert await manager.aget_context("alice") is not None
|
assert await manager.aget_context("alice") is not None
|
||||||
assert (
|
assert (
|
||||||
await manager.asearch(
|
await manager.asearch(
|
||||||
|
|||||||
@ -65,6 +65,10 @@ class _FakeHonchoClient:
|
|||||||
def set_session_peers(self, ws: str, sid: str, pids: list[str]) -> None:
|
def set_session_peers(self, ws: str, sid: str, pids: list[str]) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def existing_source_ids(self, ws: str, sid: str, source_ids: list[str]) -> set[str]:
|
||||||
|
self._writes.write([])
|
||||||
|
return set()
|
||||||
|
|
||||||
def add_messages(self, ws: str, sid: str, msgs: list[dict[str, str]]) -> None:
|
def add_messages(self, ws: str, sid: str, msgs: list[dict[str, str]]) -> None:
|
||||||
self._writes.write([message["content"] for message in msgs])
|
self._writes.write([message["content"] for message in msgs])
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
|||||||
"backend/packages/harness/deerflow/agents/memory/AGENTS.md",
|
"backend/packages/harness/deerflow/agents/memory/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/agents/task_continuity/AGENTS.md",
|
"backend/packages/harness/deerflow/agents/task_continuity/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/brave/AGENTS.md",
|
"backend/packages/harness/deerflow/community/brave/AGENTS.md",
|
||||||
|
"backend/packages/harness/deerflow/community/browser_automation/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/groundroute/AGENTS.md",
|
"backend/packages/harness/deerflow/community/groundroute/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/image_search/AGENTS.md",
|
"backend/packages/harness/deerflow/community/image_search/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/infoquest/AGENTS.md",
|
"backend/packages/harness/deerflow/community/infoquest/AGENTS.md",
|
||||||
@ -33,6 +34,7 @@ EXPECTED_GUIDANCE_PATHS = {
|
|||||||
"backend/packages/harness/deerflow/community/ddg_search/AGENTS.md",
|
"backend/packages/harness/deerflow/community/ddg_search/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md",
|
"backend/packages/harness/deerflow/community/e2b_sandbox/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/aio_sandbox/AGENTS.md",
|
"backend/packages/harness/deerflow/community/aio_sandbox/AGENTS.md",
|
||||||
|
"backend/packages/harness/deerflow/community/browser_automation/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/community/opensandbox/AGENTS.md",
|
"backend/packages/harness/deerflow/community/opensandbox/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/config/AGENTS.md",
|
"backend/packages/harness/deerflow/config/AGENTS.md",
|
||||||
"backend/packages/harness/deerflow/extensions/AGENTS.md",
|
"backend/packages/harness/deerflow/extensions/AGENTS.md",
|
||||||
|
|||||||
@ -308,17 +308,31 @@ async def test_each_scheduler_claim_has_a_fresh_ownership_generation(repository,
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_recovery_does_not_overwrite_a_renewal_after_its_expiry_read(repository, monkeypatch):
|
@pytest.mark.parametrize("batch_paused", [False, True])
|
||||||
|
async def test_recovery_does_not_overwrite_a_renewal_after_its_expiry_read(repository, monkeypatch, batch_paused):
|
||||||
await create_batch(repository)
|
await create_batch(repository)
|
||||||
now = datetime.now(UTC)
|
now = datetime.now(UTC)
|
||||||
item = (await repository.claim_items(now=now, lease_owner="live-worker", lease_seconds=10, limit=1))[0]
|
item = (await repository.claim_items(now=now, lease_owner="live-worker", lease_seconds=10, limit=1))[0]
|
||||||
|
if batch_paused:
|
||||||
|
await repository.pause_batch("batch-lease-test", user_id="user-1")
|
||||||
paused, release = asyncio.Event(), asyncio.Event()
|
paused, release = asyncio.Event(), asyncio.Event()
|
||||||
original = AsyncSession.execute
|
original = AsyncSession.execute
|
||||||
recovery = None
|
recovery = None
|
||||||
|
recovery_update_rowcounts = []
|
||||||
|
|
||||||
async def execute(session, statement, *args, **kwargs):
|
async def execute(session, statement, *args, **kwargs):
|
||||||
result = await original(session, statement, *args, **kwargs)
|
result = await original(session, statement, *args, **kwargs)
|
||||||
if asyncio.current_task() is recovery and not paused.is_set() and getattr(statement, "is_select", False) and "lease_expires_at <" in str(statement):
|
if asyncio.current_task() is recovery and getattr(statement, "is_update", False) and statement.table.name == SubagentBatchItemRow.__tablename__:
|
||||||
|
recovery_update_rowcounts.append(result.rowcount)
|
||||||
|
if (
|
||||||
|
asyncio.current_task() is recovery
|
||||||
|
and not paused.is_set()
|
||||||
|
and getattr(statement, "is_select", False)
|
||||||
|
and "lease_expires_at <" in str(statement)
|
||||||
|
and any(getattr(column, "table", None) is SubagentBatchItemRow.__table__ for column in statement.selected_columns)
|
||||||
|
):
|
||||||
|
# The parent SELECT also contains the expiry predicate via EXISTS.
|
||||||
|
# Park only after the expired item itself has been read.
|
||||||
paused.set()
|
paused.set()
|
||||||
await release.wait()
|
await release.wait()
|
||||||
return result
|
return result
|
||||||
@ -332,6 +346,7 @@ async def test_recovery_does_not_overwrite_a_renewal_after_its_expiry_read(repos
|
|||||||
finally:
|
finally:
|
||||||
release.set()
|
release.set()
|
||||||
claimed = await asyncio.wait_for(recovery, 3)
|
claimed = await asyncio.wait_for(recovery, 3)
|
||||||
|
assert recovery_update_rowcounts == [0], "recovery must attempt an item update and reject the renewed lease"
|
||||||
assert claimed == []
|
assert claimed == []
|
||||||
assert await row_snapshot(repository, item["id"]) == expected
|
assert await row_snapshot(repository, item["id"]) == expected
|
||||||
|
|
||||||
|
|||||||
@ -608,6 +608,146 @@ async def test_ensure_page_serializes_concurrent_rebuilds():
|
|||||||
context.new_page.assert_awaited_once()
|
context.new_page.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_playwright_api(monkeypatch):
|
||||||
|
"""Lifecycle unit tests do not require the optional browser extra."""
|
||||||
|
async_api = ModuleType("playwright.async_api")
|
||||||
|
async_api.async_playwright = MagicMock()
|
||||||
|
playwright = ModuleType("playwright")
|
||||||
|
playwright.async_api = async_api
|
||||||
|
monkeypatch.setitem(sys.modules, "playwright", playwright)
|
||||||
|
monkeypatch.setitem(sys.modules, "playwright.async_api", async_api)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("surviving_tab", [True, False])
|
||||||
|
@pytest.mark.parametrize("cdp", [False, True])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_page_reuses_context_after_active_tab_closes(surviving_tab, cdp, fake_playwright_api):
|
||||||
|
"""Closing an OAuth popup or the final tab must retain session storage."""
|
||||||
|
session = BrowserSession(MagicMock(), headless=True, timeout_ms=1000, viewport={"width": 1000, "height": 500})
|
||||||
|
closed = MagicMock()
|
||||||
|
closed.is_closed.return_value = True
|
||||||
|
page = MagicMock()
|
||||||
|
page.is_closed.return_value = False
|
||||||
|
focused = False
|
||||||
|
|
||||||
|
async def focus_page():
|
||||||
|
nonlocal focused
|
||||||
|
assert session._page is closed
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
focused = True
|
||||||
|
|
||||||
|
page.bring_to_front = AsyncMock(side_effect=focus_page)
|
||||||
|
original_set_active_page = session._set_active_page
|
||||||
|
|
||||||
|
def adopt_page(target):
|
||||||
|
assert focused is not cdp
|
||||||
|
original_set_active_page(target)
|
||||||
|
|
||||||
|
session._set_active_page = MagicMock(side_effect=adopt_page)
|
||||||
|
context = MagicMock()
|
||||||
|
context.pages = [page] if surviving_tab else []
|
||||||
|
creation_started = asyncio.Event()
|
||||||
|
release_creation = asyncio.Event()
|
||||||
|
|
||||||
|
async def new_page():
|
||||||
|
creation_started.set()
|
||||||
|
await release_creation.wait()
|
||||||
|
return page
|
||||||
|
|
||||||
|
context.new_page = AsyncMock(side_effect=new_page)
|
||||||
|
browser = MagicMock()
|
||||||
|
browser.is_connected.return_value = True
|
||||||
|
browser.contexts = [context]
|
||||||
|
browser.new_context = AsyncMock(side_effect=AssertionError("discarded the existing context"))
|
||||||
|
session._browser = browser
|
||||||
|
session._connected_over_cdp = cdp
|
||||||
|
session._context = context
|
||||||
|
session._page = closed
|
||||||
|
session._request_guard_bound = True
|
||||||
|
session._page_listener_bound = True
|
||||||
|
session._playwright = MagicMock()
|
||||||
|
|
||||||
|
if surviving_tab:
|
||||||
|
assert await session._ensure_page() is page
|
||||||
|
context.new_page.assert_not_awaited()
|
||||||
|
else:
|
||||||
|
first = asyncio.create_task(session._ensure_page())
|
||||||
|
try:
|
||||||
|
# Await the operation too, so a broken implementation fails without
|
||||||
|
# stranding a caller while waiting for the creation signal.
|
||||||
|
signal = asyncio.create_task(creation_started.wait())
|
||||||
|
await asyncio.wait({first, signal}, return_when=asyncio.FIRST_COMPLETED)
|
||||||
|
if first.done():
|
||||||
|
await first
|
||||||
|
second = asyncio.create_task(session._ensure_page())
|
||||||
|
release_creation.set()
|
||||||
|
assert await asyncio.gather(first, second) == [page, page]
|
||||||
|
finally:
|
||||||
|
release_creation.set()
|
||||||
|
signal.cancel()
|
||||||
|
await asyncio.gather(first, signal, return_exceptions=True)
|
||||||
|
context.new_page.assert_awaited_once()
|
||||||
|
|
||||||
|
assert session._context is context
|
||||||
|
assert session._page is page
|
||||||
|
assert session._request_guard_bound is True
|
||||||
|
assert session._page_listener_bound is True
|
||||||
|
browser.new_context.assert_not_awaited()
|
||||||
|
context.on.assert_not_called()
|
||||||
|
session._set_active_page.assert_called_once_with(page)
|
||||||
|
if cdp:
|
||||||
|
page.bring_to_front.assert_not_awaited()
|
||||||
|
else:
|
||||||
|
page.bring_to_front.assert_awaited_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("cdp", [False, True])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_ensure_page_rebinds_hooks_when_context_is_replaced(cdp, fake_playwright_api):
|
||||||
|
session = BrowserSession(
|
||||||
|
MagicMock(),
|
||||||
|
headless=True,
|
||||||
|
timeout_ms=1000,
|
||||||
|
viewport={"width": 1000, "height": 500},
|
||||||
|
cdp_url="http://127.0.0.1:9222" if cdp else None,
|
||||||
|
url_guard=lambda _url: None,
|
||||||
|
)
|
||||||
|
page = MagicMock()
|
||||||
|
page.is_closed.return_value = False
|
||||||
|
closed = MagicMock()
|
||||||
|
closed.is_closed.return_value = True
|
||||||
|
old_context = MagicMock()
|
||||||
|
context = MagicMock()
|
||||||
|
context.pages = [page] if cdp else []
|
||||||
|
context.new_page = AsyncMock(return_value=page)
|
||||||
|
context.route = AsyncMock()
|
||||||
|
browser = MagicMock()
|
||||||
|
browser.is_connected.return_value = True
|
||||||
|
browser.contexts = [context] if cdp else []
|
||||||
|
browser.new_context = AsyncMock(return_value=context)
|
||||||
|
session._browser = browser
|
||||||
|
session._context = old_context
|
||||||
|
session._page = closed
|
||||||
|
session._request_guard_bound = True
|
||||||
|
session._page_listener_bound = True
|
||||||
|
session._playwright = MagicMock()
|
||||||
|
|
||||||
|
assert await session._ensure_page() is page
|
||||||
|
context.on.assert_called_once()
|
||||||
|
event, on_page = context.on.call_args.args
|
||||||
|
assert event == "page"
|
||||||
|
popup = MagicMock()
|
||||||
|
on_page(popup)
|
||||||
|
assert session._page is popup
|
||||||
|
if cdp:
|
||||||
|
context.route.assert_not_awaited()
|
||||||
|
assert session._request_guard_bound is False
|
||||||
|
else:
|
||||||
|
context.route.assert_awaited_once()
|
||||||
|
assert session._request_guard_bound is True
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tabs_report_active_page():
|
async def test_tabs_report_active_page():
|
||||||
session = BrowserSession(
|
session = BrowserSession(
|
||||||
@ -1111,6 +1251,47 @@ async def test_real_playwright_navigate_click_type():
|
|||||||
session_mod.reset_browser_session_manager()
|
session_mod.reset_browser_session_manager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_real_playwright_preserves_context_after_popup_closes():
|
||||||
|
"""Real popup/tab closure keeps the opener, login cookie and local storage."""
|
||||||
|
pytest.importorskip("playwright.async_api")
|
||||||
|
session = BrowserSession(MagicMock(), headless=True, timeout_ms=5000, viewport={"width": 800, "height": 600})
|
||||||
|
try:
|
||||||
|
opener = await session._ensure_page()
|
||||||
|
context = session._context
|
||||||
|
|
||||||
|
async def app_page(route):
|
||||||
|
await route.fulfill(body="<title>Authenticated app</title><button>Continue</button>", content_type="text/html")
|
||||||
|
|
||||||
|
await context.route("https://app.example/**", app_page)
|
||||||
|
await session._navigate("https://app.example/home")
|
||||||
|
await opener.evaluate("localStorage.setItem('account', 'alice')")
|
||||||
|
async with context.expect_page() as popup_event:
|
||||||
|
await opener.evaluate("window.open('/login')")
|
||||||
|
popup = await popup_event.value
|
||||||
|
await popup.wait_for_load_state("domcontentloaded")
|
||||||
|
assert session._page is popup
|
||||||
|
await popup.evaluate("document.cookie = 'logged_in=yes; path=/'")
|
||||||
|
await popup.close()
|
||||||
|
|
||||||
|
tabs = await session._tabs()
|
||||||
|
assert [(tab.url, tab.active) for tab in tabs] == [("https://app.example/home", True)]
|
||||||
|
assert session._page is opener
|
||||||
|
assert session._context is context
|
||||||
|
assert len(session._browser.contexts) == 1
|
||||||
|
assert await opener.evaluate("localStorage.getItem('account')") == "alice"
|
||||||
|
assert any(cookie["name"] == "logged_in" for cookie in await context.cookies())
|
||||||
|
|
||||||
|
await opener.close()
|
||||||
|
replacement = await session._ensure_page()
|
||||||
|
assert session._context is context
|
||||||
|
await replacement.goto("https://app.example/home")
|
||||||
|
assert await replacement.evaluate("localStorage.getItem('account')") == "alice"
|
||||||
|
assert any(cookie["name"] == "logged_in" for cookie in await context.cookies())
|
||||||
|
finally:
|
||||||
|
await session._close()
|
||||||
|
|
||||||
|
|
||||||
class _FakeRoute:
|
class _FakeRoute:
|
||||||
def __init__(self, url: str):
|
def __init__(self, url: str):
|
||||||
self.request = SimpleNamespace(url=url)
|
self.request = SimpleNamespace(url=url)
|
||||||
|
|||||||
@ -14,12 +14,15 @@ import ipaddress
|
|||||||
import socket
|
import socket
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from contextvars import ContextVar
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from types import ModuleType, SimpleNamespace
|
from types import ModuleType, SimpleNamespace
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway import context_usage
|
||||||
from deerflow.community import url_safety
|
from deerflow.community import url_safety
|
||||||
from deerflow.community.browser_automation import egress
|
from deerflow.community.browser_automation import egress
|
||||||
from deerflow.community.browser_automation import session as session_mod
|
from deerflow.community.browser_automation import session as session_mod
|
||||||
@ -223,6 +226,193 @@ async def test_proxy_bounds_a_stuck_resolution(monkeypatch):
|
|||||||
assert reply == 0x04
|
assert reply == 0x04
|
||||||
|
|
||||||
|
|
||||||
|
def test_timed_out_egress_resolution_does_not_starve_browser_request_guard(monkeypatch):
|
||||||
|
browser_loop = session_mod._PlaywrightLoopThread()
|
||||||
|
browser_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
gateway_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
resolver_executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", resolver_executor, raising=False)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", threading.BoundedSemaphore(1), raising=False)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVE_TIMEOUT_S", 0.1)
|
||||||
|
resolver_started = threading.Event()
|
||||||
|
release_resolver = threading.Event()
|
||||||
|
resolver_calls: list[str] = []
|
||||||
|
|
||||||
|
def resolve(host: str) -> list[str]:
|
||||||
|
resolver_calls.append(host)
|
||||||
|
resolver_started.set()
|
||||||
|
release_resolver.wait(timeout=10)
|
||||||
|
return ["127.0.0.1"]
|
||||||
|
|
||||||
|
guard = MagicMock(return_value=None)
|
||||||
|
session = BrowserSession(browser_loop, headless=True, timeout_ms=1000, viewport={}, url_guard=guard, egress_resolver=resolve)
|
||||||
|
session._context = SimpleNamespace(route=AsyncMock(), close=AsyncMock())
|
||||||
|
|
||||||
|
async def start_session_proxy():
|
||||||
|
asyncio.get_running_loop().set_default_executor(browser_executor)
|
||||||
|
await session._install_request_guard()
|
||||||
|
return (await session._egress_proxy_settings())["server"]
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
gateway_loop = asyncio.get_running_loop()
|
||||||
|
gateway_loop.set_default_executor(gateway_executor)
|
||||||
|
assert gateway_loop is not browser_loop._loop
|
||||||
|
assert gateway_executor is not browser_executor
|
||||||
|
|
||||||
|
messages = [SimpleNamespace(content="hello")]
|
||||||
|
snapshot = SimpleNamespace(values={"messages": messages})
|
||||||
|
|
||||||
|
class Accessor:
|
||||||
|
async def aget(self, _config):
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
model = SimpleNamespace(name="test-model", context_window=1000)
|
||||||
|
app_config = SimpleNamespace(models=[model], get_model_config=lambda _name: model)
|
||||||
|
|
||||||
|
class RunStore:
|
||||||
|
async def list_by_thread(self, *_args, **_kwargs):
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def build_accessor(_request, *, thread_id):
|
||||||
|
return Accessor(), {"configurable": {"thread_id": thread_id}}
|
||||||
|
|
||||||
|
monkeypatch.setattr(context_usage, "_count_messages_approximately", lambda _messages: 250)
|
||||||
|
monkeypatch.setattr(context_usage, "build_thread_checkpoint_state_accessor", build_accessor)
|
||||||
|
monkeypatch.setattr(context_usage, "get_config", lambda: app_config)
|
||||||
|
|
||||||
|
connect_task = None
|
||||||
|
guard_task = None
|
||||||
|
try:
|
||||||
|
# Use the production session entry point: SOCKS DNS runs on the
|
||||||
|
# private Playwright loop, not the caller's Gateway loop.
|
||||||
|
proxy_url = await browser_loop.run(start_session_proxy())
|
||||||
|
connect_task = asyncio.create_task(_socks_connect(proxy_url, 0x03, _domain("wedged.example"), 80))
|
||||||
|
assert await asyncio.wait_for(asyncio.to_thread(resolver_started.wait, 2), timeout=3)
|
||||||
|
_method, reply, _reader, writer = await asyncio.wait_for(connect_task, timeout=2)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
assert reply == 0x04
|
||||||
|
assert not release_resolver.is_set()
|
||||||
|
|
||||||
|
_method, overloaded_reply, _reader, overloaded_writer = await asyncio.wait_for(_socks_connect(proxy_url, 0x03, _domain("second.example"), 80), timeout=2)
|
||||||
|
overloaded_writer.close()
|
||||||
|
await overloaded_writer.wait_closed()
|
||||||
|
assert overloaded_reply == 0x04
|
||||||
|
assert resolver_calls == ["wedged.example"]
|
||||||
|
|
||||||
|
# Gateway token counting has its own default pool and succeeds on
|
||||||
|
# both the base and PR head while the browser resolver is held.
|
||||||
|
assert await asyncio.wait_for(
|
||||||
|
context_usage.build_context_usage(request=SimpleNamespace(app=SimpleNamespace()), thread_id="thread-1", run_store=RunStore()),
|
||||||
|
timeout=2,
|
||||||
|
) == {"token_count": 250, "max_context_tokens": 1000, "percentage": 25.0}
|
||||||
|
|
||||||
|
# The actual contention is on the browser loop: its installed route
|
||||||
|
# callback must still run the synchronous request guard.
|
||||||
|
route = SimpleNamespace(request=SimpleNamespace(url="https://public.example/"), continue_=AsyncMock(), abort=AsyncMock())
|
||||||
|
route_handler = session._context.route.await_args.args[1]
|
||||||
|
guard_task = asyncio.create_task(browser_loop.run(route_handler(route)))
|
||||||
|
await asyncio.wait_for(guard_task, timeout=2)
|
||||||
|
guard.assert_called_once_with("https://public.example/")
|
||||||
|
route.continue_.assert_awaited_once()
|
||||||
|
route.abort.assert_not_awaited()
|
||||||
|
assert not release_resolver.is_set()
|
||||||
|
finally:
|
||||||
|
release_resolver.set()
|
||||||
|
for task in (connect_task, guard_task):
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
await browser_loop.run(session._close())
|
||||||
|
await browser_loop.run(browser_loop._loop.shutdown_default_executor())
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(scenario())
|
||||||
|
finally:
|
||||||
|
release_resolver.set()
|
||||||
|
resolver_executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
browser_executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
gateway_executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
browser_loop._loop.call_soon_threadsafe(browser_loop._loop.stop)
|
||||||
|
browser_loop._thread.join(timeout=3)
|
||||||
|
assert not browser_loop._thread.is_alive()
|
||||||
|
browser_loop._loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_egress_resolver_propagates_caller_context(monkeypatch):
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", threading.BoundedSemaphore(1))
|
||||||
|
resolver_context = ContextVar("test_browser_egress_context", default="worker-default")
|
||||||
|
token = resolver_context.set("request-context")
|
||||||
|
try:
|
||||||
|
assert await egress._resolve_with_timeout(lambda _host: [resolver_context.get()], "public.example") == ["request-context"]
|
||||||
|
finally:
|
||||||
|
resolver_context.reset(token)
|
||||||
|
executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
assert resolver_context.get() == "worker-default"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_egress_resolver_submission_failure_releases_slot(monkeypatch):
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
slots = threading.BoundedSemaphore(1)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", slots)
|
||||||
|
try:
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
with pytest.raises(RuntimeError, match="cannot schedule new futures after shutdown"):
|
||||||
|
await egress._resolve_with_timeout(MagicMock(), "public.example")
|
||||||
|
assert slots.acquire(blocking=False), "failed submission must return its permit"
|
||||||
|
slots.release()
|
||||||
|
finally:
|
||||||
|
executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_timed_out_resolver_releases_slot_after_event_loop_closes(monkeypatch):
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
slots = threading.BoundedSemaphore(1)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_EXECUTOR", executor)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVER_SLOTS", slots)
|
||||||
|
monkeypatch.setattr(egress, "_RESOLVE_TIMEOUT_S", 0.05)
|
||||||
|
|
||||||
|
resolver_started = threading.Event()
|
||||||
|
release_resolver = threading.Event()
|
||||||
|
resolver_finished = threading.Event()
|
||||||
|
|
||||||
|
def resolve(_host: str) -> list[str]:
|
||||||
|
resolver_started.set()
|
||||||
|
release_resolver.wait(timeout=10)
|
||||||
|
resolver_finished.set()
|
||||||
|
return ["127.0.0.1"]
|
||||||
|
|
||||||
|
async def time_out_resolver():
|
||||||
|
task = asyncio.create_task(egress._resolve_with_timeout(resolve, "wedged.example"))
|
||||||
|
try:
|
||||||
|
# Cancelling to_thread's awaiter cannot interrupt Event.wait itself.
|
||||||
|
assert await asyncio.wait_for(asyncio.to_thread(resolver_started.wait, 2), timeout=3)
|
||||||
|
with pytest.raises(TimeoutError):
|
||||||
|
await task
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
loop.run_until_complete(time_out_resolver())
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
release_resolver.set()
|
||||||
|
assert resolver_finished.wait(timeout=2)
|
||||||
|
assert slots.acquire(timeout=2), "resolver worker completion must release capacity without its event loop"
|
||||||
|
slots.release()
|
||||||
|
finally:
|
||||||
|
release_resolver.set()
|
||||||
|
executor.shutdown(wait=True, cancel_futures=True)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_proxy_close_is_bounded_by_a_tunnel_it_could_not_sweep(monkeypatch):
|
async def test_proxy_close_is_bounded_by_a_tunnel_it_could_not_sweep(monkeypatch):
|
||||||
monkeypatch.setattr(egress, "_CLOSE_TIMEOUT_S", 0.2)
|
monkeypatch.setattr(egress, "_CLOSE_TIMEOUT_S", 0.2)
|
||||||
|
|||||||
70
backend/tests/test_changelog_reference_links.py
Normal file
70
backend/tests/test_changelog_reference_links.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""The change logs must render every ``[#1234]`` mention as a link.
|
||||||
|
|
||||||
|
Both change logs list pull requests with reference-style links: the entry text
|
||||||
|
carries ``[#1234]`` and a ``[#1234]: <url>`` line at the bottom supplies the
|
||||||
|
target. A missing definition renders as the literal characters ``[#1234]``, and a
|
||||||
|
definition whose number or repository differs from its key silently points at a
|
||||||
|
different page. Neither is visible while reviewing an entry, so pin both here.
|
||||||
|
|
||||||
|
These checks read the two files only — no network, no fixtures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
CHANGELOGS = ("CHANGELOG.md", "CHANGELOG_zh.md")
|
||||||
|
|
||||||
|
_DEFINITION = re.compile(r"^\[\s*#?(?P<key>\d+)\s*\]:\s*(?P<url>\S+)\s*$")
|
||||||
|
# ``[#1234]`` used as reference text. A following ``(`` or ``[`` makes it an
|
||||||
|
# inline link (``[#1234](url)``), which needs no definition.
|
||||||
|
_REFERENCE = re.compile(r"\[#(\d+)\](?![\[(])")
|
||||||
|
_SAME_REPO_TARGET = re.compile(r"^https://github\.com/bytedance/deer-flow/(?:pull|issues)/(?P<number>\d+)$")
|
||||||
|
_COMMIT_PERMALINK = re.compile(r"^https://github\.com/bytedance/deer-flow/commit/[0-9a-f]{40}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _scan(path: Path) -> tuple[set[int], dict[int, str]]:
|
||||||
|
"""Return the ``[#n]`` tokens used in prose and the ``[#n]:`` definitions."""
|
||||||
|
used: set[int] = set()
|
||||||
|
defined: dict[int, str] = {}
|
||||||
|
in_fence = False
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith("```"):
|
||||||
|
in_fence = not in_fence
|
||||||
|
continue
|
||||||
|
definition = _DEFINITION.match(stripped)
|
||||||
|
if definition:
|
||||||
|
defined[int(definition.group("key"))] = definition.group("url")
|
||||||
|
continue
|
||||||
|
if in_fence:
|
||||||
|
continue
|
||||||
|
used.update(int(match.group(1)) for match in _REFERENCE.finditer(line))
|
||||||
|
return used, defined
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("changelog", CHANGELOGS)
|
||||||
|
def test_every_reference_mention_has_a_definition(changelog: str) -> None:
|
||||||
|
used, defined = _scan(REPO_ROOT / changelog)
|
||||||
|
|
||||||
|
missing = sorted(used - set(defined))
|
||||||
|
assert not missing, f"{changelog} mentions {['#' + str(n) for n in missing]} as [#n] reference text without a '[#n]: <url>' definition, so it renders as literal text."
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("changelog", CHANGELOGS)
|
||||||
|
def test_definitions_target_their_own_number_in_this_repository(changelog: str) -> None:
|
||||||
|
_, defined = _scan(REPO_ROOT / changelog)
|
||||||
|
|
||||||
|
drifted = []
|
||||||
|
for number, url in defined.items():
|
||||||
|
same_repo = _SAME_REPO_TARGET.match(url)
|
||||||
|
if same_repo is not None:
|
||||||
|
if int(same_repo.group("number")) != number:
|
||||||
|
drifted.append(f"[#{number}]: {url}")
|
||||||
|
elif _COMMIT_PERMALINK.match(url) is None:
|
||||||
|
drifted.append(f"[#{number}]: {url}")
|
||||||
|
assert not drifted, f"{changelog} defines a reference outside bytedance/deer-flow, or aimed at a different number than its key: {drifted[:5]}"
|
||||||
148
backend/tests/test_channel_state_atomic_publish.py
Normal file
148
backend/tests/test_channel_state_atomic_publish.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""Channel JSON state must be published whole, never truncated in place.
|
||||||
|
|
||||||
|
``DiscordChannel._persist_thread_mappings`` and ``WechatChannel._save_state``
|
||||||
|
write state a crash could otherwise truncate: the Discord mapping would fail to
|
||||||
|
load and the WeChat cursor buffer would be lost, re-reading from the start. Both
|
||||||
|
now stage a sibling and replace it, and a stage that cannot be discarded is
|
||||||
|
reported instead of replacing the outcome.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.channels.discord import DiscordChannel
|
||||||
|
from app.channels.wechat import WechatChannel
|
||||||
|
|
||||||
|
|
||||||
|
def _discord(tmp_path):
|
||||||
|
channel = DiscordChannel.__new__(DiscordChannel)
|
||||||
|
channel._thread_store_lock = threading.Lock()
|
||||||
|
channel._active_threads = {"a": {"thread_id": "t1"}}
|
||||||
|
channel._thread_store_path = tmp_path / "state" / "discord-threads.json"
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def _wechat(tmp_path):
|
||||||
|
channel = WechatChannel.__new__(WechatChannel)
|
||||||
|
channel._cursor_path = tmp_path / "state" / "wechat-cursor.json"
|
||||||
|
channel._get_updates_buf = "cursor-1"
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_staged_write(monkeypatch):
|
||||||
|
"""Fail the staged write the way a full disk would, before any rename.
|
||||||
|
|
||||||
|
With the shared helper this is the earliest injectable point: the bytes are
|
||||||
|
written to a sibling, fsync fails, and the rename never happens. The point of
|
||||||
|
the test is that the live file is untouched by that failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def failing_fsync(_fd):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
monkeypatch.setattr("deerflow.utils.atomic_write.os.fsync", failing_fsync)
|
||||||
|
|
||||||
|
|
||||||
|
def _no_sidecar(target: Path) -> bool:
|
||||||
|
prefix = f".{target.name}."
|
||||||
|
return [p.name for p in target.parent.iterdir() if p.name.startswith(prefix)] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_state_is_replaced_whole(tmp_path):
|
||||||
|
channel = _discord(tmp_path)
|
||||||
|
target = tmp_path / "state" / "discord-threads.json"
|
||||||
|
|
||||||
|
channel._persist_thread_mappings()
|
||||||
|
|
||||||
|
assert json.loads(target.read_text(encoding="utf-8")) == {"a": {"thread_id": "t1"}}
|
||||||
|
assert _no_sidecar(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_failed_staged_write_keeps_the_previous_mapping(tmp_path, monkeypatch):
|
||||||
|
channel = _discord(tmp_path)
|
||||||
|
target = tmp_path / "state" / "discord-threads.json"
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_text('{"kept": true}', encoding="utf-8")
|
||||||
|
_fail_staged_write(monkeypatch)
|
||||||
|
|
||||||
|
channel._persist_thread_mappings()
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == '{"kept": true}'
|
||||||
|
assert json.loads(target.read_text(encoding="utf-8")) == {"kept": True}
|
||||||
|
assert _no_sidecar(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wechat_state_is_replaced_whole(tmp_path):
|
||||||
|
channel = _wechat(tmp_path)
|
||||||
|
target = tmp_path / "state" / "wechat-cursor.json"
|
||||||
|
|
||||||
|
channel._save_state()
|
||||||
|
|
||||||
|
assert json.loads(target.read_text(encoding="utf-8")) == {"get_updates_buf": "cursor-1"}
|
||||||
|
assert _no_sidecar(target)
|
||||||
|
|
||||||
|
|
||||||
|
def test_wechat_failed_staged_write_keeps_the_previous_cursor(tmp_path, monkeypatch):
|
||||||
|
channel = _wechat(tmp_path)
|
||||||
|
target = tmp_path / "state" / "wechat-cursor.json"
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_text('{"get_updates_buf": "kept"}', encoding="utf-8")
|
||||||
|
_fail_staged_write(monkeypatch)
|
||||||
|
|
||||||
|
channel._save_state()
|
||||||
|
|
||||||
|
assert json.loads(target.read_text(encoding="utf-8")) == {"get_updates_buf": "kept"}
|
||||||
|
assert _no_sidecar(target)
|
||||||
|
|
||||||
|
|
||||||
|
def _fail_staged_write_and_cleanup(monkeypatch):
|
||||||
|
"""Fail the staged write, and fail discarding the staged sibling too."""
|
||||||
|
|
||||||
|
def failing_fsync(_fd):
|
||||||
|
raise OSError("disk full")
|
||||||
|
|
||||||
|
real_unlink = Path.unlink
|
||||||
|
|
||||||
|
def failing_unlink(self, *args, **kwargs):
|
||||||
|
if self.name.startswith(".") and self.name.endswith(".tmp"):
|
||||||
|
raise PermissionError("staging is still open")
|
||||||
|
return real_unlink(self, *args, **kwargs)
|
||||||
|
|
||||||
|
monkeypatch.setattr("deerflow.utils.atomic_write.os.fsync", failing_fsync)
|
||||||
|
monkeypatch.setattr(Path, "unlink", failing_unlink)
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_failed_cleanup_still_reports_and_keeps_the_outcome(tmp_path, monkeypatch, caplog):
|
||||||
|
"""A discard that fails must not escape, and both failures stay visible."""
|
||||||
|
channel = _discord(tmp_path)
|
||||||
|
target = tmp_path / "state" / "discord-threads.json"
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_text('{"kept": true}', encoding="utf-8")
|
||||||
|
_fail_staged_write_and_cleanup(monkeypatch)
|
||||||
|
caplog.clear()
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
channel._persist_thread_mappings()
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == '{"kept": true}'
|
||||||
|
assert "failed to persist thread mappings" in caplog.text
|
||||||
|
assert "Failed to remove temporary output file" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_wechat_failed_cleanup_still_reports_and_keeps_the_outcome(tmp_path, monkeypatch, caplog):
|
||||||
|
channel = _wechat(tmp_path)
|
||||||
|
target = tmp_path / "state" / "wechat-cursor.json"
|
||||||
|
target.parent.mkdir(parents=True)
|
||||||
|
target.write_text('{"get_updates_buf": "kept"}', encoding="utf-8")
|
||||||
|
_fail_staged_write_and_cleanup(monkeypatch)
|
||||||
|
caplog.clear()
|
||||||
|
|
||||||
|
with caplog.at_level("WARNING"):
|
||||||
|
channel._save_state()
|
||||||
|
|
||||||
|
assert target.read_text(encoding="utf-8") == '{"get_updates_buf": "kept"}'
|
||||||
|
assert "failed to persist cursor state" in caplog.text
|
||||||
|
assert "Failed to remove temporary output file" in caplog.text
|
||||||
@ -22,10 +22,8 @@ def _state_io_calls(filename: str, class_name: str, method_name: str, call_name:
|
|||||||
("filename", "class_name", "method_name", "call_name"),
|
("filename", "class_name", "method_name", "call_name"),
|
||||||
[
|
[
|
||||||
("store.py", "JsonChannelStore", "_save", "NamedTemporaryFile"),
|
("store.py", "JsonChannelStore", "_save", "NamedTemporaryFile"),
|
||||||
("discord.py", "DiscordChannel", "_persist_thread_mappings", "write_text"),
|
|
||||||
("runtime_config_store.py", "ChannelRuntimeConfigStore", "_save", "NamedTemporaryFile"),
|
("runtime_config_store.py", "ChannelRuntimeConfigStore", "_save", "NamedTemporaryFile"),
|
||||||
("buzz_seen_events.py", "BuzzSeenEventStore", "_write_snapshot", "NamedTemporaryFile"),
|
("buzz_seen_events.py", "BuzzSeenEventStore", "_write_snapshot", "NamedTemporaryFile"),
|
||||||
("wechat.py", "WechatChannel", "_save_state", "write_text"),
|
|
||||||
("wechat.py", "WechatChannel", "_save_auth_state", "NamedTemporaryFile"),
|
("wechat.py", "WechatChannel", "_save_auth_state", "NamedTemporaryFile"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -57,3 +55,67 @@ def test_discord_state_read_is_explicitly_utf8() -> None:
|
|||||||
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
||||||
encoding = keywords.get("encoding")
|
encoding = keywords.get("encoding")
|
||||||
assert isinstance(encoding, ast.Constant) and encoding.value == "utf-8"
|
assert isinstance(encoding, ast.Constant) and encoding.value == "utf-8"
|
||||||
|
|
||||||
|
|
||||||
|
def test_discord_thread_mappings_publish_utf8_lf_bytes(tmp_path: Path) -> None:
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from app.channels.discord import DiscordChannel
|
||||||
|
|
||||||
|
channel = DiscordChannel.__new__(DiscordChannel)
|
||||||
|
channel._thread_store_lock = threading.Lock()
|
||||||
|
channel._active_threads = {"微信:资源": {"thread_id": "thread-讨论"}}
|
||||||
|
channel._thread_store_path = tmp_path / "discord-threads.json"
|
||||||
|
|
||||||
|
channel._persist_thread_mappings()
|
||||||
|
|
||||||
|
text = channel._thread_store_path.read_bytes().decode("utf-8")
|
||||||
|
assert "\r" not in text
|
||||||
|
assert json.loads(text)["微信:资源"]["thread_id"] == "thread-讨论"
|
||||||
|
|
||||||
|
|
||||||
|
def test_wechat_cursor_state_publishes_utf8_lf_bytes(tmp_path: Path) -> None:
|
||||||
|
from app.channels.wechat import WechatChannel
|
||||||
|
|
||||||
|
channel = WechatChannel.__new__(WechatChannel)
|
||||||
|
channel._cursor_path = tmp_path / "wechat-cursor.json"
|
||||||
|
channel._get_updates_buf = "游标-1"
|
||||||
|
|
||||||
|
channel._save_state()
|
||||||
|
|
||||||
|
text = channel._cursor_path.read_bytes().decode("utf-8")
|
||||||
|
assert "\r" not in text
|
||||||
|
assert json.loads(text) == {"get_updates_buf": "游标-1"}
|
||||||
|
|
||||||
|
|
||||||
|
def _method_calls(filename: str, class_name: str, method_name: str) -> list[ast.Call]:
|
||||||
|
module = ast.parse((CHANNELS / filename).read_text(encoding="utf-8"))
|
||||||
|
owner = next(node for node in module.body if isinstance(node, ast.ClassDef) and node.name == class_name)
|
||||||
|
method = next(node for node in owner.body if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == method_name)
|
||||||
|
return [node for node in ast.walk(method) if isinstance(node, ast.Call)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("filename", "class_name", "method_name"),
|
||||||
|
[
|
||||||
|
("discord.py", "DiscordChannel", "_persist_thread_mappings"),
|
||||||
|
("wechat.py", "WechatChannel", "_save_state"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_json_channel_state_writers_publish_through_the_helper(filename: str, class_name: str, method_name: str) -> None:
|
||||||
|
"""Platform-independent: the call shape, not just the bytes it happens to emit.
|
||||||
|
|
||||||
|
CI runs with ``PYTHONUTF8=1``, so a regression to ``write_text(...)`` without
|
||||||
|
``encoding=`` would still produce valid UTF-8 there; only a source-level check
|
||||||
|
catches it on every platform.
|
||||||
|
"""
|
||||||
|
calls = _method_calls(filename, class_name, method_name)
|
||||||
|
names = [call.func.attr if isinstance(call.func, ast.Attribute) else call.func.id for call in calls if isinstance(call.func, (ast.Attribute, ast.Name))]
|
||||||
|
assert "publish_bytes_atomically" in names, f"{filename}:{class_name}.{method_name} must publish through the helper"
|
||||||
|
|
||||||
|
for call in calls:
|
||||||
|
if not isinstance(call.func, ast.Attribute) or call.func.attr not in {"write_text", "write_bytes"}:
|
||||||
|
continue
|
||||||
|
keywords = {keyword.arg: keyword.value for keyword in call.keywords}
|
||||||
|
encoding = keywords.get("encoding")
|
||||||
|
assert isinstance(encoding, ast.Constant) and encoding.value == "utf-8", f"{filename}:{call.lineno} writes text without an explicit utf-8 encoding"
|
||||||
|
|||||||
@ -607,6 +607,47 @@ def test_shell_script_rejects_unknown_commands(tmp_path):
|
|||||||
assert "Unknown command" in result.stderr
|
assert "Unknown command" in result.stderr
|
||||||
|
|
||||||
|
|
||||||
|
_GATEWAY_LOGS = ("gateway-a.log", "gateway-b.log")
|
||||||
|
_NGINX_LOGS = ("nginx.log", "nginx-error.log", "nginx-access.log")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("args", "expected"),
|
||||||
|
[((), _GATEWAY_LOGS), (("b",), ("gateway-b.log",)), (("nginx",), _NGINX_LOGS), (("all",), _GATEWAY_LOGS + _NGINX_LOGS)],
|
||||||
|
ids=["default", "b", "nginx", "all"],
|
||||||
|
)
|
||||||
|
def test_shell_script_logs_prints_the_selected_files(tmp_path, args, expected):
|
||||||
|
logs = tmp_path / "state" / "logs"
|
||||||
|
logs.mkdir(parents=True)
|
||||||
|
for name in _GATEWAY_LOGS + _NGINX_LOGS:
|
||||||
|
(logs / name).write_text(f"line from {name}\n", encoding="utf-8")
|
||||||
|
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state")}
|
||||||
|
|
||||||
|
result = subprocess.run([require_script_bash(), str(SHELL_PATH), "logs", *args], capture_output=True, text=True, timeout=30, env=env)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
headers = [line.removeprefix("==> ").removesuffix(" <==") for line in result.stdout.splitlines() if line.startswith("==> ")]
|
||||||
|
assert [Path(header).name for header in headers] == list(expected)
|
||||||
|
for name in expected:
|
||||||
|
assert f"line from {name}" in result.stdout
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("args", "expected"), [(("all",), _GATEWAY_LOGS), (("nginx",), ())], ids=["all", "nginx"])
|
||||||
|
def test_shell_script_logs_skips_nginx_files_when_nginx_never_started(tmp_path, args, expected):
|
||||||
|
# start_nginx bails on a missing binary or busy port, leaving only Gateway logs.
|
||||||
|
logs = tmp_path / "state" / "logs"
|
||||||
|
logs.mkdir(parents=True)
|
||||||
|
for name in _GATEWAY_LOGS:
|
||||||
|
(logs / name).write_text(f"line from {name}\n", encoding="utf-8")
|
||||||
|
env = {**os.environ, "DEERFLOW_MI_STATE_DIR": str(tmp_path / "state")}
|
||||||
|
|
||||||
|
result = subprocess.run([require_script_bash(), str(SHELL_PATH), "logs", *args], capture_output=True, text=True, timeout=30, env=env)
|
||||||
|
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
headers = [line.removeprefix("==> ").removesuffix(" <==") for line in result.stdout.splitlines() if line.startswith("==> ")]
|
||||||
|
assert [Path(header).name for header in headers] == list(expected)
|
||||||
|
|
||||||
|
|
||||||
def test_makefile_exposes_harness_targets():
|
def test_makefile_exposes_harness_targets():
|
||||||
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
makefile = (REPO_ROOT / "Makefile").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|||||||
31
backend/tests/test_gateway_secret_masking.py
Normal file
31
backend/tests/test_gateway_secret_masking.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""Shared credential masking used by the MCP and memory config routes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.gateway.secret_masking import MASKED_VALUE, mask_secret_values, normalize_config_key
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("key", "normalized"),
|
||||||
|
[("api_key", "api_key"), ("apiKey", "api_key"), ("APIKey", "api_key"), ("API-Key", "api_key"), ("accessToken", "access_token"), ("X-Api-Key", "x_api_key")],
|
||||||
|
)
|
||||||
|
def test_normalize_config_key_snake_cases_spellings(key: str, normalized: str) -> None:
|
||||||
|
assert normalize_config_key(key) == normalized
|
||||||
|
|
||||||
|
|
||||||
|
def _is_secret(key: str) -> bool:
|
||||||
|
return key in {"api_key", "credentials"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mask_secret_values_masks_at_any_depth_without_mutating() -> None:
|
||||||
|
value = {"api_key": "sk-top", "nested": {"api_key": "sk-nested", "model": "m"}, "items": [{"api_key": "sk-list"}, "plain"], "credentials": {"client_id": "id", "client_secret": "s"}}
|
||||||
|
|
||||||
|
masked = mask_secret_values(value, _is_secret)
|
||||||
|
|
||||||
|
assert masked == {"api_key": MASKED_VALUE, "nested": {"api_key": MASKED_VALUE, "model": "m"}, "items": [{"api_key": MASKED_VALUE}, "plain"], "credentials": MASKED_VALUE}
|
||||||
|
assert value["nested"]["api_key"] == "sk-nested"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("keep_unset", "expected"), [(False, {"api_key": MASKED_VALUE, "credentials": MASKED_VALUE}), (True, {"api_key": None, "credentials": ""})])
|
||||||
|
def test_mask_secret_values_keeps_unset_values_only_when_asked(keep_unset: bool, expected: dict) -> None:
|
||||||
|
assert mask_secret_values({"api_key": None, "credentials": ""}, _is_secret, keep_unset=keep_unset) == expected
|
||||||
@ -4,7 +4,7 @@ import copy
|
|||||||
import pytest
|
import pytest
|
||||||
from deerflow_extension_api import ExtensionData
|
from deerflow_extension_api import ExtensionData
|
||||||
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||||
from langgraph.checkpoint.base import empty_checkpoint, uuid6
|
from langgraph.checkpoint.base import BaseCheckpointSaver, empty_checkpoint, uuid6
|
||||||
from langgraph.checkpoint.memory import InMemorySaver
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
|
||||||
from deerflow.extensions.registry import ExtensionRegistry
|
from deerflow.extensions.registry import ExtensionRegistry
|
||||||
@ -92,7 +92,7 @@ class _ClearBeforeSecondGoalReadCheckpointer:
|
|||||||
return await self.inner.aput(*args, **kwargs)
|
return await self.inner.aput(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class _RaceAfterFirstContinuationCommitCheckpointer:
|
class _RaceAfterFirstContinuationCommitCheckpointer(BaseCheckpointSaver):
|
||||||
"""Wrap a saver and inject a racing user message right after the first
|
"""Wrap a saver and inject a racing user message right after the first
|
||||||
goal-continuation commit lands.
|
goal-continuation commit lands.
|
||||||
|
|
||||||
@ -107,6 +107,7 @@ class _RaceAfterFirstContinuationCommitCheckpointer:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, inner: InMemorySaver, thread_id: str) -> None:
|
def __init__(self, inner: InMemorySaver, thread_id: str) -> None:
|
||||||
|
super().__init__(serde=inner.serde)
|
||||||
self.inner = inner
|
self.inner = inner
|
||||||
self.thread_id = thread_id
|
self.thread_id = thread_id
|
||||||
self.put_count = 0
|
self.put_count = 0
|
||||||
|
|||||||
340
backend/tests/test_honcho_incremental_capture.py
Normal file
340
backend/tests/test_honcho_incremental_capture.py
Normal file
@ -0,0 +1,340 @@
|
|||||||
|
"""Honcho captures checkpoint history incrementally through the real HTTP client."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from threading import Barrier
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, messages_to_dict
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
|
||||||
|
from deerflow.agents.memory.backends.honcho import honcho_manager
|
||||||
|
from deerflow.agents.memory.backends.honcho.client import HonchoClient
|
||||||
|
from deerflow.agents.middlewares import memory_middleware
|
||||||
|
from deerflow.agents.middlewares.memory_middleware import MemoryMiddleware
|
||||||
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
|
|
||||||
|
SOURCE_ID = "deerflow_source_id"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStore:
|
||||||
|
"""Emulate append-only messages and Honcho's metadata-filtered offset pages."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.messages = defaultdict(list)
|
||||||
|
self.batches = []
|
||||||
|
self.lookups = []
|
||||||
|
self.fail_write = None
|
||||||
|
self.write_count = 0
|
||||||
|
self.lose_response = False
|
||||||
|
self.list_error = None
|
||||||
|
self.page_size = 2
|
||||||
|
|
||||||
|
def receive(self, request):
|
||||||
|
path = request.url.path
|
||||||
|
body = json.loads(request.content)
|
||||||
|
if path.endswith("/messages/list"):
|
||||||
|
self.lookups.append(request)
|
||||||
|
if self.list_error is not None:
|
||||||
|
return self.list_error
|
||||||
|
filters = body["filters"]["OR"]
|
||||||
|
assert 0 < len(filters) <= 100
|
||||||
|
keys = {entry["metadata"][SOURCE_ID] for entry in filters}
|
||||||
|
items = [item for item in self.messages[path.removesuffix("/list")] if item.get("metadata", {}).get(SOURCE_ID) in keys]
|
||||||
|
page = int(request.url.params["page"])
|
||||||
|
size = min(int(request.url.params["size"]), self.page_size)
|
||||||
|
return httpx.Response(200, json={"items": items[(page - 1) * size : page * size], "page": page, "pages": math.ceil(len(items) / size), "total": len(items)})
|
||||||
|
if path.endswith("/messages"):
|
||||||
|
incoming = body["messages"]
|
||||||
|
if not 0 < len(incoming) <= 100:
|
||||||
|
return httpx.Response(422, json={"detail": "message batch size"})
|
||||||
|
self.write_count += 1
|
||||||
|
if self.write_count == self.fail_write and not self.lose_response:
|
||||||
|
return httpx.Response(503, json={"detail": "unavailable"})
|
||||||
|
self.messages[path].extend(incoming)
|
||||||
|
self.batches.append(incoming)
|
||||||
|
if self.write_count == self.fail_write:
|
||||||
|
raise httpx.ReadTimeout("response lost after commit", request=request)
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
def contents(self):
|
||||||
|
return [item["content"] for batch in self.batches for item in batch]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def capture(monkeypatch):
|
||||||
|
store = MessageStore()
|
||||||
|
managers = []
|
||||||
|
monkeypatch.setattr(honcho_manager, "HonchoClient", lambda config: HonchoClient(config, transport=httpx.MockTransport(store.receive)))
|
||||||
|
|
||||||
|
def create(**config):
|
||||||
|
manager = honcho_manager.HonchoMemoryManager.from_config({"base_url": "https://honcho.invalid", **config})
|
||||||
|
managers.append(manager)
|
||||||
|
monkeypatch.setattr(memory_middleware, "get_memory_manager", lambda: manager)
|
||||||
|
return manager
|
||||||
|
|
||||||
|
create()
|
||||||
|
yield create, store
|
||||||
|
for manager in managers:
|
||||||
|
manager.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_turns(*, asynchronous, repeat_text=False, separate_threads=False, turns=3):
|
||||||
|
agent = create_agent(
|
||||||
|
model=FakeMessagesListChatModel(responses=[AIMessage(content=f"Answer {n}") for n in range(turns)]),
|
||||||
|
middleware=[MemoryMiddleware(memory_config=MemoryConfig(enabled=True))],
|
||||||
|
checkpointer=InMemorySaver(),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run():
|
||||||
|
for n in range(turns):
|
||||||
|
thread_id = f"thread-{n}" if separate_threads else "thread-1"
|
||||||
|
state = {"messages": [HumanMessage(content="Please continue" if repeat_text else f"Question {n}")]}
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
context = {"thread_id": thread_id, "user_id": "repro-user"}
|
||||||
|
if asynchronous:
|
||||||
|
await agent.ainvoke(state, config, context=context)
|
||||||
|
else:
|
||||||
|
agent.invoke(state, config, context=context)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
||||||
|
def test_acknowledged_history_is_not_resubmitted(capture, asynchronous):
|
||||||
|
_, store = capture
|
||||||
|
run_turns(asynchronous=asynchronous)
|
||||||
|
assert [len(batch) for batch in store.batches] == [2, 2, 2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
||||||
|
def test_same_text_in_distinct_turns_is_preserved_once_per_turn(capture, asynchronous):
|
||||||
|
_, store = capture
|
||||||
|
run_turns(asynchronous=asynchronous, repeat_text=True)
|
||||||
|
assert store.contents().count("Please continue") == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
||||||
|
def test_first_turn_control(capture, asynchronous):
|
||||||
|
_, store = capture
|
||||||
|
run_turns(asynchronous=asynchronous, turns=1)
|
||||||
|
assert [len(batch) for batch in store.batches] == [2]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("asynchronous", [False, True], ids=["sync", "async"])
|
||||||
|
def test_independent_threads_control(capture, asynchronous):
|
||||||
|
_, store = capture
|
||||||
|
run_turns(asynchronous=asynchronous, separate_threads=True, repeat_text=True)
|
||||||
|
assert [len(batch) for batch in store.batches] == [2, 2, 2]
|
||||||
|
assert len(store.messages) == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_and_compacted_overlap_use_remote_identity(capture):
|
||||||
|
create, store = capture
|
||||||
|
messages = [HumanMessage("question", id="h1"), AIMessage("answer", id="a1"), HumanMessage("next", id="h2")]
|
||||||
|
original = messages_to_dict(messages)
|
||||||
|
first = create()
|
||||||
|
first.add("thread", messages[:2], user_id="alice")
|
||||||
|
first.close()
|
||||||
|
second = create()
|
||||||
|
second.add("thread", messages[1:], user_id="alice")
|
||||||
|
second.add_nowait("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == ["question", "answer", "next"]
|
||||||
|
assert any(request.url.params["page"] == "2" for request in store.lookups)
|
||||||
|
assert messages_to_dict(messages) == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_distinct_sources_edits_and_roles_are_not_text_deduplicated(capture):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
h1 = HumanMessage("same", id="h1")
|
||||||
|
messages = [h1, h1, HumanMessage("same", id="h2"), HumanMessage("edited", id="h1"), AIMessage("same", id="h1")]
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == ["same", "same", "edited", "same"]
|
||||||
|
assert len({item["metadata"][SOURCE_ID] for item in store.batches[0]}) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_shared_workspace_peer_and_message_ids_still_preserve_each_user(capture):
|
||||||
|
create, store = capture
|
||||||
|
manager = create(workspace_overrides={"alice": "shared", "bob": "shared"}, user_peer_overrides={"alice": "shared-peer", "bob": "shared-peer"})
|
||||||
|
messages = [HumanMessage("same", id="same")]
|
||||||
|
for user in ("alice", "bob", "alice", "bob"):
|
||||||
|
manager.add("thread", messages, user_id=user)
|
||||||
|
assert store.contents() == ["same", "same"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_idless_callers_keep_append_behavior(capture):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
for _ in range(2):
|
||||||
|
manager.add("thread", [HumanMessage("same")], user_id="alice")
|
||||||
|
assert store.contents() == ["same", "same"]
|
||||||
|
assert not store.lookups
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_source_id_in_independent_threads_is_not_suppressed(capture):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
message = HumanMessage("same", id="h1")
|
||||||
|
for thread_id in ("first", "second", "first", "second"):
|
||||||
|
manager.add(thread_id, [message], user_id="alice")
|
||||||
|
assert store.contents() == ["same", "same"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_later_lookup_page_does_not_publish_partial_results(capture, monkeypatch):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
messages = [HumanMessage(str(n), id=str(n)) for n in range(4)]
|
||||||
|
manager.add("thread", messages[:3], user_id="alice")
|
||||||
|
original = manager._client._post
|
||||||
|
|
||||||
|
def post(path, payload):
|
||||||
|
if "/messages/list?page=2" in path:
|
||||||
|
return {"items": [None], "page": 2, "pages": 2}
|
||||||
|
return original(path, payload)
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager._client, "_post", post)
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == ["0", "1", "2"]
|
||||||
|
monkeypatch.setattr(manager._client, "_post", original)
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == ["0", "1", "2", "3"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lost_response", [False, True])
|
||||||
|
def test_retry_reconciles_failed_or_committed_writes(capture, lost_response, caplog):
|
||||||
|
create, store = capture
|
||||||
|
messages = [HumanMessage("first", id="h1"), AIMessage("reply", id="a1"), HumanMessage("next", id="h2")]
|
||||||
|
store.fail_write = 1
|
||||||
|
store.lose_response = lost_response
|
||||||
|
create().add("thread", messages[:2], user_id="alice")
|
||||||
|
assert "write failed for thread thread" in caplog.text
|
||||||
|
assert "capture lookup failed" not in caplog.text
|
||||||
|
create().add("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == ["first", "reply", "next"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batched_retry_preserves_already_committed_prefix(capture):
|
||||||
|
create, store = capture
|
||||||
|
store.page_size = 10
|
||||||
|
messages = [HumanMessage(f"message {n}", id=f"h{n}") for n in range(105)]
|
||||||
|
store.fail_write = 2
|
||||||
|
create().add("thread", messages, user_id="alice")
|
||||||
|
assert len(store.contents()) == 100
|
||||||
|
create().add("thread", messages, user_id="alice")
|
||||||
|
assert store.contents() == [message.content for message in messages]
|
||||||
|
assert [len(batch) for batch in store.batches] == [100, 5]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("growing", [False, True], ids=["excessive-total", "growing-total"])
|
||||||
|
def test_lookup_page_limit_aborts_capture_without_partial_results(capture, monkeypatch, caplog, growing):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
message = HumanMessage("private-history", id="h1")
|
||||||
|
manager.add("thread", [message], user_id="alice")
|
||||||
|
original = manager._client._post
|
||||||
|
requested_pages = []
|
||||||
|
|
||||||
|
def post(path, payload):
|
||||||
|
if "/messages/list?" not in path:
|
||||||
|
return original(path, payload)
|
||||||
|
page = int(httpx.URL(path).params["page"])
|
||||||
|
requested_pages.append(page)
|
||||||
|
# Keep the pre-fix implementation finite without providing its bound.
|
||||||
|
if len(requested_pages) > 10:
|
||||||
|
raise AssertionError("lookup exceeded the test's request budget")
|
||||||
|
return {"items": store.batches[0], "page": page, "pages": page + 1 if growing else 1_000_000}
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager._client, "_post", post)
|
||||||
|
manager.add("thread", [message, HumanMessage("next", id="h2")], user_id="alice")
|
||||||
|
assert requested_pages == (list(range(1, 11)) if growing else [1])
|
||||||
|
assert store.write_count == 1
|
||||||
|
assert store.contents() == ["private-history"]
|
||||||
|
assert "capture lookup failed" in caplog.text
|
||||||
|
assert "page limit" in caplog.text
|
||||||
|
assert "private-history" not in caplog.text
|
||||||
|
monkeypatch.setattr(manager._client, "_post", original)
|
||||||
|
manager.add("thread", [message, HumanMessage("next", id="h2")], user_id="alice")
|
||||||
|
assert store.contents() == ["private-history", "next"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_lookup_accepts_duplicate_source_records_through_last_allowed_page(capture):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
message = HumanMessage("first", id="h1")
|
||||||
|
manager.add("thread", [message], user_id="alice")
|
||||||
|
records = next(iter(store.messages.values()))
|
||||||
|
records.extend([records[0]] * 9)
|
||||||
|
store.page_size = 1
|
||||||
|
store.lookups.clear()
|
||||||
|
manager.add("thread", [message, HumanMessage("next", id="h2")], user_id="alice")
|
||||||
|
assert [int(request.url.params["page"]) for request in store.lookups] == list(range(1, 11))
|
||||||
|
assert store.contents() == ["first", "next"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"response",
|
||||||
|
[
|
||||||
|
httpx.Response(403, json={"detail": "session read permission required"}),
|
||||||
|
httpx.Response(503, json={"detail": "unavailable"}),
|
||||||
|
httpx.Response(200, json={}),
|
||||||
|
httpx.Response(200, json={"items": "invalid", "page": 1, "pages": 1}),
|
||||||
|
httpx.Response(200, json={"items": [], "page": 1, "pages": True}),
|
||||||
|
httpx.Response(200, json={"items": [], "page": 2, "pages": 2}),
|
||||||
|
httpx.Response(200, json={"items": [{"metadata": {SOURCE_ID: "not-requested"}}], "page": 1, "pages": 1}),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_lookup_failure_does_not_blindly_replay(capture, response, caplog):
|
||||||
|
create, store = capture
|
||||||
|
manager = create()
|
||||||
|
messages = [HumanMessage("private-history", id="h1")]
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
store.list_error = response
|
||||||
|
manager.add("thread", messages + [HumanMessage("next", id="h2")], user_id="alice")
|
||||||
|
assert store.contents() == ["private-history"]
|
||||||
|
assert store.write_count == 1
|
||||||
|
assert "capture lookup failed for thread thread" in caplog.text
|
||||||
|
assert "write failed" not in caplog.text
|
||||||
|
assert "private-history" not in caplog.text
|
||||||
|
store.list_error = None
|
||||||
|
manager.add("thread", messages + [HumanMessage("next", id="h2")], user_id="alice")
|
||||||
|
assert store.contents() == ["private-history", "next"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_concurrent_managers_share_session_capture_lock(capture, monkeypatch):
|
||||||
|
create, store = capture
|
||||||
|
managers = [create(), create()]
|
||||||
|
start = Barrier(2)
|
||||||
|
messages = [HumanMessage("hello", id="h1")]
|
||||||
|
original = HonchoClient.add_messages
|
||||||
|
|
||||||
|
def write(client, *args):
|
||||||
|
# The publication must still own the same session lock as the lookup.
|
||||||
|
lock = honcho_manager._capture_lock("https://honcho.invalid", args[0], args[1])
|
||||||
|
with ThreadPoolExecutor(max_workers=1) as probe:
|
||||||
|
|
||||||
|
def acquire_elsewhere():
|
||||||
|
acquired = lock.acquire(blocking=False)
|
||||||
|
if acquired:
|
||||||
|
lock.release()
|
||||||
|
return acquired
|
||||||
|
|
||||||
|
assert not probe.submit(acquire_elsewhere).result(timeout=5)
|
||||||
|
original(client, *args)
|
||||||
|
|
||||||
|
monkeypatch.setattr(HonchoClient, "add_messages", write)
|
||||||
|
|
||||||
|
def run(manager):
|
||||||
|
start.wait(timeout=5)
|
||||||
|
manager.add("thread", messages, user_id="alice")
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||||
|
list(pool.map(run, managers))
|
||||||
|
assert store.contents() == ["hello"]
|
||||||
@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@ -393,6 +394,7 @@ class _FakeClient:
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.calls: list[tuple[str, tuple]] = []
|
self.calls: list[tuple[str, tuple]] = []
|
||||||
|
self.message_batches: list[list[dict]] = []
|
||||||
self.representation_text = "ajayr likes concise answers"
|
self.representation_text = "ajayr likes concise answers"
|
||||||
self.raise_on: str | None = None
|
self.raise_on: str | None = None
|
||||||
# Which exception type _maybe_raise raises; default matches the real
|
# Which exception type _maybe_raise raises; default matches the real
|
||||||
@ -422,6 +424,12 @@ class _FakeClient:
|
|||||||
def add_messages(self, ws, sid, msgs):
|
def add_messages(self, ws, sid, msgs):
|
||||||
self._maybe_raise("messages")
|
self._maybe_raise("messages")
|
||||||
self.calls.append(("messages", (ws, sid, tuple((m["peer_id"], m["content"]) for m in msgs))))
|
self.calls.append(("messages", (ws, sid, tuple((m["peer_id"], m["content"]) for m in msgs))))
|
||||||
|
self.message_batches.append(msgs)
|
||||||
|
|
||||||
|
def existing_source_ids(self, ws, sid, source_ids):
|
||||||
|
self._maybe_raise("lookup")
|
||||||
|
self.calls.append(("lookup", (ws, sid, tuple(source_ids))))
|
||||||
|
return set()
|
||||||
|
|
||||||
def working_representation(self, ws, pid, *, max_conclusions=25):
|
def working_representation(self, ws, pid, *, max_conclusions=25):
|
||||||
self._maybe_raise("representation")
|
self._maybe_raise("representation")
|
||||||
@ -465,6 +473,18 @@ class TestHonchoClientTransport:
|
|||||||
|
|
||||||
|
|
||||||
class TestHonchoManagerWrite:
|
class TestHonchoManagerWrite:
|
||||||
|
def test_add_with_source_id_looks_up_before_writing_metadata(self):
|
||||||
|
mgr, fake = _manager(workspace_overrides={"u1": "shared"}, user_peer_overrides={"u1": "alice"})
|
||||||
|
mgr.add("t-1", [SimpleNamespace(type="human", content=" hello ", id="h1")], user_id="u1")
|
||||||
|
sid = f"df-{_stable_id('t-1')}"
|
||||||
|
source_id = hashlib.sha256(json.dumps(["u1", "h1", "human", "alice", "hello"]).encode("utf-8")).hexdigest()
|
||||||
|
assert ("messages", ("shared", sid, (("alice", "hello"),))) in fake.calls
|
||||||
|
assert fake.calls[-2:] == [
|
||||||
|
("lookup", ("shared", sid, (source_id,))),
|
||||||
|
("messages", ("shared", sid, (("alice", "hello"),))),
|
||||||
|
]
|
||||||
|
assert fake.message_batches == [[{"peer_id": "alice", "content": "hello", "metadata": {"deerflow_source_id": source_id}}]]
|
||||||
|
|
||||||
def test_add_maps_messages_to_peers(self):
|
def test_add_maps_messages_to_peers(self):
|
||||||
mgr, fake = _manager(workspace_overrides={"u1": "shared"}, user_peer_overrides={"u1": "alice"})
|
mgr, fake = _manager(workspace_overrides={"u1": "shared"}, user_peer_overrides={"u1": "alice"})
|
||||||
mgr.add("t-1", [_msg("human", "please check the deploy"), _msg("ai", "deploy is green"), _msg("tool", "ignored")], user_id="u1")
|
mgr.add("t-1", [_msg("human", "please check the deploy"), _msg("ai", "deploy is green"), _msg("tool", "ignored")], user_id="u1")
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
@ -48,7 +49,7 @@ def sql_store(tmp_path):
|
|||||||
def test_crud_and_signature(request, store_fixture):
|
def test_crud_and_signature(request, store_fixture):
|
||||||
store = request.getfixturevalue(store_fixture)
|
store = request.getfixturevalue(store_fixture)
|
||||||
empty_signature = store.signature()
|
empty_signature = store.signature()
|
||||||
definition = _definition()
|
definition = _definition(thinking_enabled=True, reasoning_effort="xhigh")
|
||||||
|
|
||||||
store.create(definition)
|
store.create(definition)
|
||||||
assert store.get("RESEARCHER") == definition
|
assert store.get("RESEARCHER") == definition
|
||||||
@ -60,6 +61,8 @@ def test_crud_and_signature(request, store_fixture):
|
|||||||
store.update(updated)
|
store.update(updated)
|
||||||
assert store.get("researcher").enabled is False
|
assert store.get("researcher").enabled is False
|
||||||
assert store.get("researcher").max_turns == 75
|
assert store.get("researcher").max_turns == 75
|
||||||
|
assert store.get("researcher").thinking_enabled is True
|
||||||
|
assert store.get("researcher").reasoning_effort == "xhigh"
|
||||||
updated_signature = store.signature()
|
updated_signature = store.signature()
|
||||||
assert updated_signature != created_signature
|
assert updated_signature != created_signature
|
||||||
|
|
||||||
@ -70,6 +73,24 @@ def test_crud_and_signature(request, store_fixture):
|
|||||||
store.get("researcher")
|
store.get("researcher")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("store_fixture", ["file_store", "sql_store"])
|
||||||
|
def test_legacy_definition_without_reasoning_fields_keeps_defaults(request, store_fixture, tmp_path):
|
||||||
|
store = request.getfixturevalue(store_fixture)
|
||||||
|
legacy = {"name": "researcher", "description": "Research", "system_prompt": "Research the task."}
|
||||||
|
if store_fixture == "file_store":
|
||||||
|
root = tmp_path / "managed-subagents"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "researcher.json").write_text(json.dumps(legacy), encoding="utf-8")
|
||||||
|
else:
|
||||||
|
with store._Session() as session:
|
||||||
|
session.add(ManagedSubagentRow(id="legacy", name="researcher", definition=legacy))
|
||||||
|
session.commit()
|
||||||
|
definition = store.get("researcher")
|
||||||
|
assert definition.thinking_enabled is None
|
||||||
|
assert definition.reasoning_effort is None
|
||||||
|
assert store.list() == [definition]
|
||||||
|
|
||||||
|
|
||||||
def test_sql_signature_detects_update_behind_existing_max_timestamp(sql_store):
|
def test_sql_signature_detects_update_behind_existing_max_timestamp(sql_store):
|
||||||
sql_store.create(_definition("ahead"))
|
sql_store.create(_definition("ahead"))
|
||||||
sql_store.create(_definition("writer"))
|
sql_store.create(_definition("writer"))
|
||||||
|
|||||||
@ -4845,3 +4845,170 @@ async def test_background_compensation_failure_is_consumed(monkeypatch, caplog):
|
|||||||
assert len(failures) == 1
|
assert len(failures) == 1
|
||||||
assert any("release remained unavailable" in failure.getMessage() for failure in failures)
|
assert any("release remained unavailable" in failure.getMessage() for failure in failures)
|
||||||
assert not service._compensation_tasks
|
assert not service._compensation_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_drains_service_owned_compensation_tasks(monkeypatch):
|
||||||
|
monkeypatch.setattr(service_module, "_CANCELLATION_DRAIN_TIMEOUT_SECONDS", 0.5)
|
||||||
|
service = McpTaskService(
|
||||||
|
repository=FakeRepository(),
|
||||||
|
drivers=McpTaskDriverRegistry(),
|
||||||
|
poll_interval_seconds=60,
|
||||||
|
lease_seconds=120,
|
||||||
|
max_concurrent_polls=3,
|
||||||
|
)
|
||||||
|
release_started = asyncio.Event()
|
||||||
|
release_gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def release_claim():
|
||||||
|
release_started.set()
|
||||||
|
await release_gate.wait()
|
||||||
|
|
||||||
|
compensation = asyncio.create_task(release_claim(), name="test-mcp-compensation")
|
||||||
|
service._track_compensation_task(compensation, action="release poll claim", task_id="task-1")
|
||||||
|
await release_started.wait()
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
original_drain = service._drain_compensation_tasks
|
||||||
|
drain_started = asyncio.Event()
|
||||||
|
|
||||||
|
async def observed_drain(*, deadline: float):
|
||||||
|
drain_started.set()
|
||||||
|
await original_drain(deadline=deadline)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_drain_compensation_tasks", observed_drain)
|
||||||
|
stopping = asyncio.create_task(service.stop())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(drain_started.wait(), timeout=1)
|
||||||
|
assert not stopping.done(), "stop must drain service-owned compensation work"
|
||||||
|
finally:
|
||||||
|
release_gate.set()
|
||||||
|
await asyncio.wait_for(stopping, timeout=1)
|
||||||
|
|
||||||
|
await _wait_for_compensation_tasks_to_clear(service)
|
||||||
|
assert not service._compensation_tasks
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_bounds_drain_of_service_owned_compensation_tasks(monkeypatch, caplog):
|
||||||
|
monkeypatch.setattr(service_module, "_CANCELLATION_DRAIN_TIMEOUT_SECONDS", 0.05)
|
||||||
|
service = McpTaskService(
|
||||||
|
repository=FakeRepository(),
|
||||||
|
drivers=McpTaskDriverRegistry(),
|
||||||
|
poll_interval_seconds=60,
|
||||||
|
lease_seconds=120,
|
||||||
|
max_concurrent_polls=3,
|
||||||
|
)
|
||||||
|
release_started = asyncio.Event()
|
||||||
|
release_gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def release_claim():
|
||||||
|
release_started.set()
|
||||||
|
await release_gate.wait()
|
||||||
|
|
||||||
|
compensation = asyncio.create_task(release_claim(), name="test-mcp-stuck-compensation")
|
||||||
|
service._track_compensation_task(compensation, action="release poll claim", task_id="task-1")
|
||||||
|
await release_started.wait()
|
||||||
|
await service.start()
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await asyncio.wait_for(service.stop(), timeout=0.5)
|
||||||
|
assert "MCP compensation task(s)" in caplog.text
|
||||||
|
assert compensation in service._compensation_tasks
|
||||||
|
finally:
|
||||||
|
release_gate.set()
|
||||||
|
await asyncio.wait_for(compensation, timeout=1)
|
||||||
|
await _wait_for_compensation_tasks_to_clear(service)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_logs_pending_compensation_on_poller_timeout(monkeypatch, caplog):
|
||||||
|
monkeypatch.setattr(service_module, "_CANCELLATION_DRAIN_TIMEOUT_SECONDS", 0.05)
|
||||||
|
service = McpTaskService(
|
||||||
|
repository=FakeRepository(),
|
||||||
|
drivers=McpTaskDriverRegistry(),
|
||||||
|
poll_interval_seconds=60,
|
||||||
|
lease_seconds=120,
|
||||||
|
max_concurrent_polls=3,
|
||||||
|
)
|
||||||
|
poller_started = asyncio.Event()
|
||||||
|
poller_release = asyncio.Event()
|
||||||
|
compensation_started = asyncio.Event()
|
||||||
|
compensation_release = asyncio.Event()
|
||||||
|
|
||||||
|
async def blocked_run_once(*, now):
|
||||||
|
poller_started.set()
|
||||||
|
try:
|
||||||
|
await asyncio.Future()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
await poller_release.wait()
|
||||||
|
|
||||||
|
async def release_claim():
|
||||||
|
compensation_started.set()
|
||||||
|
await compensation_release.wait()
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "run_once", blocked_run_once)
|
||||||
|
compensation = asyncio.create_task(release_claim(), name="test-mcp-timeout-compensation")
|
||||||
|
service._track_compensation_task(compensation, action="release poll claim", task_id="task-1")
|
||||||
|
await compensation_started.wait()
|
||||||
|
await service.start()
|
||||||
|
await poller_started.wait()
|
||||||
|
|
||||||
|
try:
|
||||||
|
with caplog.at_level(logging.WARNING):
|
||||||
|
await asyncio.wait_for(service.stop(), timeout=0.5)
|
||||||
|
assert "before compensation drain could start" in caplog.text
|
||||||
|
assert "1 MCP compensation task(s)" in caplog.text
|
||||||
|
assert compensation in service._compensation_tasks
|
||||||
|
finally:
|
||||||
|
poller_release.set()
|
||||||
|
compensation_release.set()
|
||||||
|
if service._task is not None:
|
||||||
|
await asyncio.wait_for(service._task, timeout=1)
|
||||||
|
await _wait_for_compensation_tasks_to_clear(service)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_stop_preserves_cancellation_while_draining_compensation_tasks(monkeypatch):
|
||||||
|
monkeypatch.setattr(service_module, "_CANCELLATION_DRAIN_TIMEOUT_SECONDS", 0.5)
|
||||||
|
service = McpTaskService(
|
||||||
|
repository=FakeRepository(),
|
||||||
|
drivers=McpTaskDriverRegistry(),
|
||||||
|
poll_interval_seconds=60,
|
||||||
|
lease_seconds=120,
|
||||||
|
max_concurrent_polls=3,
|
||||||
|
)
|
||||||
|
release_started = asyncio.Event()
|
||||||
|
release_gate = asyncio.Event()
|
||||||
|
|
||||||
|
async def release_claim():
|
||||||
|
release_started.set()
|
||||||
|
await release_gate.wait()
|
||||||
|
|
||||||
|
compensation = asyncio.create_task(release_claim(), name="test-mcp-cancelled-drain")
|
||||||
|
service._track_compensation_task(compensation, action="release poll claim", task_id="task-1")
|
||||||
|
await release_started.wait()
|
||||||
|
await service.start()
|
||||||
|
|
||||||
|
original_drain = service._drain_compensation_tasks
|
||||||
|
drain_started = asyncio.Event()
|
||||||
|
|
||||||
|
async def observed_drain(*, deadline: float):
|
||||||
|
drain_started.set()
|
||||||
|
await original_drain(deadline=deadline)
|
||||||
|
|
||||||
|
monkeypatch.setattr(service, "_drain_compensation_tasks", observed_drain)
|
||||||
|
stopping = asyncio.create_task(service.stop())
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(drain_started.wait(), timeout=1)
|
||||||
|
stopping.cancel()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert not stopping.done(), "stop must keep draining after caller cancellation"
|
||||||
|
release_gate.set()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(stopping, timeout=1)
|
||||||
|
finally:
|
||||||
|
release_gate.set()
|
||||||
|
if not stopping.done():
|
||||||
|
stopping.cancel()
|
||||||
|
await _wait_for_compensation_tasks_to_clear(service)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import math
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.agents.memory.backends.deermem.deermem.core import prompt
|
||||||
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
|
from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
|
||||||
FACT_EXTRACTION_PROMPT,
|
FACT_EXTRACTION_PROMPT,
|
||||||
_coerce_confidence,
|
_coerce_confidence,
|
||||||
@ -12,6 +13,46 @@ from deerflow.agents.memory.backends.deermem.deermem.core.prompt import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("use_tiktoken", [False, True])
|
||||||
|
@pytest.mark.parametrize("with_guaranteed_fact", [False, True])
|
||||||
|
@pytest.mark.parametrize("max_tokens", [100, 2000])
|
||||||
|
def test_summary_truncation_counts_the_selected_prefix(monkeypatch, use_tiktoken, with_guaranteed_fact, max_tokens):
|
||||||
|
if use_tiktoken:
|
||||||
|
tiktoken = pytest.importorskip("tiktoken")
|
||||||
|
# Exercise real UTF-8/BPE counting without downloading a vocabulary.
|
||||||
|
encoding = tiktoken.Encoding(name="bytes", pat_str=r"[\s\S]", mergeable_ranks={bytes([i]): i for i in range(256)}, special_tokens={})
|
||||||
|
monkeypatch.setattr(prompt, "_get_tiktoken_encoding", lambda _name: encoding)
|
||||||
|
|
||||||
|
fact = {"content": "Always keep a rollback backup", "category": "correction", "confidence": 1.0}
|
||||||
|
memory = {
|
||||||
|
"user": {"workContext": {"summary": "数据库迁移采用事务并保留回滚记录。" * 500}},
|
||||||
|
"history": {"recentMonths": {"summary": "Release notes retain migration records. " * 1000}},
|
||||||
|
"facts": [fact] if with_guaranteed_fact else [],
|
||||||
|
}
|
||||||
|
result = format_memory_for_injection(memory, max_tokens=max_tokens, use_tiktoken=use_tiktoken, guaranteed_categories=["correction"], guaranteed_token_budget=100)
|
||||||
|
allowance = prompt._count_tokens(prompt._format_fact_line(fact), use_tiktoken=use_tiktoken) if with_guaranteed_fact else 0
|
||||||
|
|
||||||
|
assert prompt._count_tokens(result, use_tiktoken=use_tiktoken) <= max_tokens + allowance
|
||||||
|
assert result.startswith("User Context:")
|
||||||
|
assert "\ufffd" not in result
|
||||||
|
if with_guaranteed_fact:
|
||||||
|
assert fact["content"] in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_truncation_handles_nonmonotone_bpe_counts(monkeypatch):
|
||||||
|
tiktoken = pytest.importorskip("tiktoken")
|
||||||
|
ranks = {bytes([i]): i for i in range(256)} | {b"bc": 256, b"abc": 257}
|
||||||
|
encoding = tiktoken.Encoding(name="merging", pat_str=r"[\s\S]+", mergeable_ranks=ranks, special_tokens={})
|
||||||
|
assert len(encoding.encode("ab")) == 2
|
||||||
|
assert len(encoding.encode("abc")) == 1
|
||||||
|
monkeypatch.setattr(prompt, "_get_tiktoken_encoding", lambda _name: encoding)
|
||||||
|
|
||||||
|
result = format_memory_for_injection({"user": {"workContext": {"summary": "abc" * 1000}}}, max_tokens=100)
|
||||||
|
|
||||||
|
assert result.startswith("User Context:")
|
||||||
|
assert len(encoding.encode(result)) <= 100
|
||||||
|
|
||||||
|
|
||||||
def test_format_memory_includes_facts_section() -> None:
|
def test_format_memory_includes_facts_section() -> None:
|
||||||
memory_data = {
|
memory_data = {
|
||||||
"user": {},
|
"user": {},
|
||||||
|
|||||||
@ -4,12 +4,15 @@ import threading
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
from _router_auth_helpers import call_unwrapped, make_authed_test_app
|
from _router_auth_helpers import call_unwrapped, make_authed_test_app
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.gateway.routers import memory
|
from app.gateway.routers import memory
|
||||||
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError
|
from deerflow.agents.memory import MemoryConflictError, MemoryCorruptionError
|
||||||
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
|
from deerflow.agents.memory.backends.deermem.deer_mem import DeerMem
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.memory_config import MemoryConfig
|
||||||
|
|
||||||
|
|
||||||
def _sample_memory(facts: list[dict] | None = None) -> dict:
|
def _sample_memory(facts: list[dict] | None = None) -> dict:
|
||||||
@ -678,6 +681,79 @@ def test_update_memory_fact_route_returns_specific_error_for_invalid_confidence(
|
|||||||
assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1."
|
assert response.json()["detail"] == "Invalid confidence value; must be between 0 and 1."
|
||||||
|
|
||||||
|
|
||||||
|
# ── config / status ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_route(route: str, config: MemoryConfig):
|
||||||
|
app = make_authed_test_app()
|
||||||
|
app.include_router(memory.router)
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.get_memory.return_value = _sample_memory()
|
||||||
|
with (
|
||||||
|
patch("app.gateway.routers.memory.get_memory_config", return_value=config),
|
||||||
|
patch("app.gateway.routers.memory.get_memory_manager", return_value=manager),
|
||||||
|
TestClient(app) as client,
|
||||||
|
):
|
||||||
|
response = client.get(route)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("route", "config_path"), [("/api/memory/config", ()), ("/api/memory/status", ("config",))])
|
||||||
|
def test_memory_config_routes_mask_resolved_backend_secrets(monkeypatch, route: str, config_path: tuple[str, ...]) -> None:
|
||||||
|
"""Loading resolves ``$VAR`` references, so backend keys reached these routes in plaintext."""
|
||||||
|
monkeypatch.setenv("TEST_MEMORY_LLM_KEY", "sk-memory-llm-secret")
|
||||||
|
monkeypatch.setenv("TEST_HONCHO_KEY", "hk-honcho-secret")
|
||||||
|
raw = {
|
||||||
|
"memory": {
|
||||||
|
"enabled": True,
|
||||||
|
"manager_class": "honcho",
|
||||||
|
"backend_config": {
|
||||||
|
"base_url": "https://honcho.example",
|
||||||
|
"api_key": "$TEST_HONCHO_KEY",
|
||||||
|
"model": {"provider": "openai", "model": "gpt-4o-mini", "api_key": "$TEST_MEMORY_LLM_KEY", "default_headers": {"Authorization": "Bearer header-secret"}},
|
||||||
|
"peers": [{"name": "deerflow", "accessToken": "list-token-secret"}],
|
||||||
|
"api_keys": {"prod": "plural-secret"},
|
||||||
|
"credentials": {"client_id": "deerflow-client", "client_secret": "container-secret"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
config = MemoryConfig(**AppConfig.resolve_env_variables(raw)["memory"])
|
||||||
|
|
||||||
|
response = _get_config_route(route, config)
|
||||||
|
|
||||||
|
for secret in ("sk-memory-llm-secret", "hk-honcho-secret", "header-secret", "list-token-secret", "plural-secret", "container-secret"):
|
||||||
|
assert secret not in response.text
|
||||||
|
body = response.json()
|
||||||
|
for key in config_path:
|
||||||
|
body = body[key]
|
||||||
|
assert body["backend_config"] == {
|
||||||
|
"base_url": "https://honcho.example",
|
||||||
|
"api_key": "***",
|
||||||
|
"model": {"provider": "openai", "model": "gpt-4o-mini", "api_key": "***", "default_headers": {"Authorization": "***"}},
|
||||||
|
"peers": [{"name": "deerflow", "accessToken": "***"}],
|
||||||
|
# A secret-named container is masked whole: its keys can be arbitrary names.
|
||||||
|
"api_keys": "***",
|
||||||
|
"credentials": "***",
|
||||||
|
}
|
||||||
|
assert config.backend_config["model"]["api_key"] == "sk-memory-llm-secret", "the backend itself still gets the real key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_memory_config_keeps_non_secret_and_unset_backend_values() -> None:
|
||||||
|
backend_config = {
|
||||||
|
"api_key_env": "OPENVIKING_API_KEY",
|
||||||
|
"token_counting": "tiktoken",
|
||||||
|
"max_injection_tokens": 2000,
|
||||||
|
"guaranteed_token_budget": 500,
|
||||||
|
"model": {"model": "gpt-4o-mini", "api_key": None, "base_url": ""},
|
||||||
|
"password": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = _get_config_route("/api/memory/config", MemoryConfig(enabled=True, backend_config=backend_config))
|
||||||
|
|
||||||
|
assert response.json()["backend_config"] == backend_config
|
||||||
|
|
||||||
|
|
||||||
# ── bound-owner (internal caller) ──────────────────────────────────────────
|
# ── bound-owner (internal caller) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
173
backend/tests/test_paused_batch_lease_recovery.py
Normal file
173
backend/tests/test_paused_batch_lease_recovery.py
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
"""Paused batches still reconcile abandoned attempts without starting new work."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from _router_auth_helpers import make_authed_test_app
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
|
from app.gateway.auth_disabled import AUTH_SOURCE_SESSION
|
||||||
|
from app.gateway.routers import subagent_batches
|
||||||
|
from deerflow.config.database_config import DatabaseConfig
|
||||||
|
from deerflow.config.subagent_batches_config import SubagentBatchesConfig
|
||||||
|
from deerflow.config.subagent_runtime_config import SubagentRuntimeConfig
|
||||||
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine_from_config
|
||||||
|
from deerflow.persistence.subagent_batches import SubagentBatchRepository
|
||||||
|
from deerflow.persistence.subagent_batches.model import SubagentBatchItemRow
|
||||||
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||||
|
from deerflow.subagents.batch_service import SubagentBatchService
|
||||||
|
|
||||||
|
_URL = "/api/threads/thread-1/subagent-batches/batch-1"
|
||||||
|
|
||||||
|
|
||||||
|
async def _open_database(app: FastAPI, tmp_path: Path) -> tuple[SubagentBatchRepository, SubagentBatchService]:
|
||||||
|
await init_engine_from_config(DatabaseConfig(backend="sqlite", sqlite_dir=str(tmp_path)))
|
||||||
|
sessions = get_session_factory()
|
||||||
|
app.state.subagent_batch_repo = SubagentBatchRepository(sessions)
|
||||||
|
app.state.thread_store = ThreadMetaRepository(sessions)
|
||||||
|
service = SubagentBatchService(
|
||||||
|
repository=app.state.subagent_batch_repo,
|
||||||
|
config=SubagentBatchesConfig(lease_seconds=10),
|
||||||
|
runtime_config=SubagentRuntimeConfig(),
|
||||||
|
)
|
||||||
|
# No executor is needed: a paused batch must never dispatch its items.
|
||||||
|
service._execute_item = AsyncMock()
|
||||||
|
app.state.subagent_batch_service = service
|
||||||
|
return app.state.subagent_batch_repo, service
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def api(tmp_path):
|
||||||
|
# Synthetic session identity; routing, owner checks and SQL are real.
|
||||||
|
user = SimpleNamespace(id="user-1", system_role="user")
|
||||||
|
app = make_authed_test_app(user_factory=lambda: user, bind_current_user=True)
|
||||||
|
app.include_router(subagent_batches.router)
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def session_source(request, call_next):
|
||||||
|
request.state.auth_source = AUTH_SOURCE_SESSION
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await _open_database(app, tmp_path)
|
||||||
|
await app.state.thread_store.create("thread-1", user_id="user-1")
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
yield app, client, tmp_path
|
||||||
|
finally:
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
async def _create(repo: SubagentBatchRepository, *, count: int, max_attempts: int) -> None:
|
||||||
|
await repo.create_batch(
|
||||||
|
batch_id="batch-1",
|
||||||
|
user_id="user-1",
|
||||||
|
thread_id="thread-1",
|
||||||
|
run_id="run-1",
|
||||||
|
tool_call_id="call-1",
|
||||||
|
submission_key="run-1:call-1",
|
||||||
|
title="Paused research",
|
||||||
|
subagent_type="general-purpose",
|
||||||
|
items=[{"key": f"item-{index}", "prompt": f"Process {index}"} for index in range(count)],
|
||||||
|
max_live_items=1,
|
||||||
|
max_running_items=1,
|
||||||
|
max_attempts=max_attempts,
|
||||||
|
execution_spec={},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _finish(repo: SubagentBatchRepository, item: dict, *, owner: str, now: datetime) -> None:
|
||||||
|
assert await repo.finalize_item(
|
||||||
|
item["id"],
|
||||||
|
lease_owner=owner,
|
||||||
|
succeeded=True,
|
||||||
|
result="Saved report",
|
||||||
|
result_preview="Saved report",
|
||||||
|
result_truncated=False,
|
||||||
|
error=None,
|
||||||
|
stop_reason=None,
|
||||||
|
token_usage=None,
|
||||||
|
model_name=None,
|
||||||
|
completed_at=now,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("max_attempts", [1, 2])
|
||||||
|
async def test_paused_batch_recovers_expired_item_without_dispatch_after_restart(api, max_attempts):
|
||||||
|
app, client, tmp_path = api
|
||||||
|
repo = app.state.subagent_batch_repo
|
||||||
|
await _create(repo, count=2, max_attempts=max_attempts)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
lost = (await repo.claim_items(now=now, lease_owner="lost-worker", lease_seconds=10, limit=1))[0]
|
||||||
|
assert await repo.mark_item_running(lost["id"], lease_owner="lost-worker", now=now)
|
||||||
|
assert (await client.post(f"{_URL}/pause")).json()["status"] == "paused"
|
||||||
|
|
||||||
|
# Reopen the same file, with no heartbeat/finalization from the lost worker.
|
||||||
|
await close_engine()
|
||||||
|
repo, service = await _open_database(app, tmp_path)
|
||||||
|
await service.run_once(now=now + timedelta(seconds=10))
|
||||||
|
before = (await client.get(f"{_URL}/items")).json()
|
||||||
|
assert before[0]["status"] == "running" # Equality is still a live lease.
|
||||||
|
|
||||||
|
await service.run_once(now=now + timedelta(seconds=11))
|
||||||
|
response = await client.get(f"{_URL}/items")
|
||||||
|
assert response.status_code == 200
|
||||||
|
items = response.json()
|
||||||
|
assert [item["status"] for item in items] == ["failed" if max_attempts == 1 else "queued", "pending"]
|
||||||
|
assert items[0]["attempt"] == 1
|
||||||
|
async with get_session_factory()() as session:
|
||||||
|
stored = await session.get(SubagentBatchItemRow, lost["id"])
|
||||||
|
assert stored.lease_owner is None
|
||||||
|
assert stored.lease_expires_at is None
|
||||||
|
assert (await client.get(_URL)).json()["status"] == "paused"
|
||||||
|
service._execute_item.assert_not_awaited()
|
||||||
|
|
||||||
|
if max_attempts == 1:
|
||||||
|
retried = await client.post(f"{_URL}/items/{lost['id']}/retry")
|
||||||
|
assert retried.status_code == 200
|
||||||
|
assert retried.json()["attempt"] == 0
|
||||||
|
await service.run_once(now=now + timedelta(seconds=12))
|
||||||
|
assert (await client.get(_URL)).json()["status"] == "paused"
|
||||||
|
service._execute_item.assert_not_awaited()
|
||||||
|
|
||||||
|
# Explicit resume is still required to claim retryable work.
|
||||||
|
assert (await client.post(f"{_URL}/resume")).json()["status"] == "queued"
|
||||||
|
resumed = (await repo.claim_items(now=now + timedelta(seconds=13), lease_owner="new-worker", lease_seconds=10, limit=1))[0]
|
||||||
|
assert resumed["id"] == lost["id"]
|
||||||
|
assert resumed["attempt"] == (1 if max_attempts == 1 else 2)
|
||||||
|
await _finish(repo, resumed, owner="new-worker", now=now + timedelta(seconds=14))
|
||||||
|
exported = await client.get(f"{_URL}/results.jsonl")
|
||||||
|
rows = [json.loads(line) for line in exported.text.splitlines()]
|
||||||
|
assert rows[0]["result"] == "Saved report"
|
||||||
|
assert rows[1]["status"] == "pending"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("live_completion", [False, True])
|
||||||
|
@pytest.mark.parametrize("succeeded_sibling", [False, True])
|
||||||
|
async def test_last_paused_item_reaches_terminal_status_on_completion_or_expiry(api, live_completion, succeeded_sibling):
|
||||||
|
app, client, _ = api
|
||||||
|
repo = app.state.subagent_batch_repo
|
||||||
|
await _create(repo, count=2 if succeeded_sibling else 1, max_attempts=1)
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
item = (await repo.claim_items(now=now, lease_owner="worker", lease_seconds=10, limit=1))[0]
|
||||||
|
if succeeded_sibling:
|
||||||
|
await _finish(repo, item, owner="worker", now=now)
|
||||||
|
item = (await repo.claim_items(now=now, lease_owner="worker", lease_seconds=10, limit=1))[0]
|
||||||
|
assert (await client.post(f"{_URL}/pause")).json()["status"] == "paused"
|
||||||
|
if live_completion:
|
||||||
|
await _finish(repo, item, owner="worker", now=now + timedelta(seconds=1))
|
||||||
|
else:
|
||||||
|
await app.state.subagent_batch_service.run_once(now=now + timedelta(seconds=11))
|
||||||
|
|
||||||
|
batch = (await client.get(_URL)).json()
|
||||||
|
assert batch["status"] == ("completed" if live_completion or succeeded_sibling else "failed")
|
||||||
|
assert batch["completed_at"] is not None
|
||||||
|
assert batch["counts"]["leased"] == batch["counts"]["running"] == 0
|
||||||
|
app.state.subagent_batch_service._execute_item.assert_not_awaited()
|
||||||
280
backend/tests/test_run_admission_isolation.py
Normal file
280
backend/tests/test_run_admission_isolation.py
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
"""An admission waiting on its store must not block unrelated run control."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
||||||
|
from deerflow.runtime.runs.manager import ConflictError, RunManager
|
||||||
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
|
||||||
|
|
||||||
|
class HeldAdmissionStore(MemoryRunStore):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.entered = asyncio.Event()
|
||||||
|
self.release = asyncio.Event()
|
||||||
|
self.renewed = asyncio.Event()
|
||||||
|
|
||||||
|
async def put(self, run_id, *, thread_id, **kwargs):
|
||||||
|
if thread_id == "slow-thread":
|
||||||
|
self.entered.set()
|
||||||
|
await self.release.wait()
|
||||||
|
return await super().put(run_id, thread_id=thread_id, **kwargs)
|
||||||
|
|
||||||
|
async def create_thread_operation_atomic(self, *, thread_id, idempotency_key=None, **kwargs):
|
||||||
|
if thread_id == "slow-thread":
|
||||||
|
self.entered.set()
|
||||||
|
await self.release.wait()
|
||||||
|
return await super().create_thread_operation_atomic(thread_id=thread_id, idempotency_key=idempotency_key, **kwargs)
|
||||||
|
|
||||||
|
async def renew_lease(self, run_id, **kwargs):
|
||||||
|
result = await super().renew_lease(run_id, **kwargs)
|
||||||
|
if result.renewed:
|
||||||
|
self.renewed.set()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("entrypoint", ["create", "create_or_reject", "record_assembly_evidence"])
|
||||||
|
async def test_admission_wait_does_not_block_another_threads_read_and_cancel(entrypoint):
|
||||||
|
store = HeldAdmissionStore()
|
||||||
|
manager = RunManager(store=store)
|
||||||
|
record = await manager.create_or_reject("other-thread", user_id="owner")
|
||||||
|
await manager.try_start(record.run_id)
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def work():
|
||||||
|
started.set()
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
|
||||||
|
record.task = asyncio.create_task(work())
|
||||||
|
await started.wait()
|
||||||
|
if entrypoint == "record_assembly_evidence":
|
||||||
|
store.release.set()
|
||||||
|
other = await manager.create_or_reject("slow-thread", user_id="owner")
|
||||||
|
store.entered.clear()
|
||||||
|
store.release.clear()
|
||||||
|
admission = asyncio.create_task(manager.record_assembly_evidence(other.run_id, {"run_id": other.run_id, "thread_id": other.thread_id}))
|
||||||
|
else:
|
||||||
|
admission = asyncio.create_task(getattr(manager, entrypoint)("slow-thread", user_id="owner"))
|
||||||
|
readers = []
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(store.entered.wait(), timeout=5)
|
||||||
|
readers = [asyncio.create_task(manager.get(record.run_id, user_id="owner")), asyncio.create_task(manager.cancel(record.run_id))]
|
||||||
|
result = await asyncio.wait_for(asyncio.gather(*readers), timeout=5)
|
||||||
|
assert result[0] is record
|
||||||
|
await asyncio.wait_for(asyncio.gather(record.task, return_exceptions=True), timeout=5)
|
||||||
|
assert record.task.cancelled()
|
||||||
|
assert not admission.done()
|
||||||
|
finally:
|
||||||
|
store.release.set()
|
||||||
|
await asyncio.wait_for(asyncio.gather(admission, return_exceptions=True), timeout=5)
|
||||||
|
record.task.cancel()
|
||||||
|
await asyncio.gather(record.task, *readers, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_admission_wait_does_not_stop_healthy_run_heartbeats():
|
||||||
|
store = HeldAdmissionStore()
|
||||||
|
manager = RunManager(store=store, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=5, grace_seconds=0))
|
||||||
|
record = await manager.create_or_reject("healthy-thread", user_id="owner")
|
||||||
|
await manager.try_start(record.run_id)
|
||||||
|
record.task = asyncio.create_task(asyncio.Event().wait())
|
||||||
|
initial_lease = record.lease_expires_at
|
||||||
|
store.renewed.clear()
|
||||||
|
admission = asyncio.create_task(manager.create_or_reject("slow-thread", user_id="owner"))
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(store.entered.wait(), timeout=5)
|
||||||
|
await manager.start_heartbeat()
|
||||||
|
await asyncio.wait_for(store.renewed.wait(), timeout=5)
|
||||||
|
# The Store operation and its Manager decision run in separate tasks.
|
||||||
|
# Observe the Manager's acknowledgment, not merely the Store return.
|
||||||
|
async with asyncio.timeout(5):
|
||||||
|
while record.lease_expires_at == initial_lease:
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
assert record.lease_expires_at > initial_lease
|
||||||
|
assert not record.ownership_lost
|
||||||
|
assert not record.task.done()
|
||||||
|
assert not admission.done()
|
||||||
|
finally:
|
||||||
|
store.release.set()
|
||||||
|
admitted = await asyncio.wait_for(asyncio.gather(admission, return_exceptions=True), timeout=5)
|
||||||
|
if not isinstance(admitted[0], BaseException):
|
||||||
|
await manager.cancel(admitted[0].run_id)
|
||||||
|
await manager.cancel(record.run_id)
|
||||||
|
await manager.stop_heartbeat(timeout=3)
|
||||||
|
record.task.cancel()
|
||||||
|
await asyncio.gather(record.task, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_thread_admissions_and_preallocated_id_reads_wait_for_publication():
|
||||||
|
store = HeldAdmissionStore()
|
||||||
|
manager = RunManager(store=store)
|
||||||
|
first = asyncio.create_task(manager.create_or_reject("slow-thread", run_id="first-run"))
|
||||||
|
tasks = [first]
|
||||||
|
started = asyncio.Event()
|
||||||
|
|
||||||
|
async def second_admission():
|
||||||
|
started.set()
|
||||||
|
return await manager.create_or_reject("slow-thread", run_id="second-run")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(store.entered.wait(), timeout=5)
|
||||||
|
second = asyncio.create_task(second_admission())
|
||||||
|
read = asyncio.create_task(manager.get("first-run"))
|
||||||
|
history = asyncio.create_task(manager.list_by_thread("slow-thread"))
|
||||||
|
tasks.extend([second, read, history])
|
||||||
|
await asyncio.wait_for(started.wait(), timeout=5)
|
||||||
|
assert not second.done() and not read.done() and not history.done()
|
||||||
|
assert await store.get("first-run") is None
|
||||||
|
store.release.set()
|
||||||
|
record = await asyncio.wait_for(first, timeout=5)
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
await asyncio.wait_for(second, timeout=5)
|
||||||
|
assert await asyncio.wait_for(read, timeout=5) is record
|
||||||
|
assert await asyncio.wait_for(history, timeout=5) == [record]
|
||||||
|
assert await store.get("second-run") is None
|
||||||
|
finally:
|
||||||
|
store.release.set()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cancelled_waiter_does_not_release_an_active_admission():
|
||||||
|
store = HeldAdmissionStore()
|
||||||
|
manager = RunManager(store=store)
|
||||||
|
owner = asyncio.create_task(manager.create_or_reject("slow-thread", run_id="owner-run"))
|
||||||
|
tasks = [owner]
|
||||||
|
waiter_entered = asyncio.Event()
|
||||||
|
|
||||||
|
async def waiter():
|
||||||
|
waiter_entered.set()
|
||||||
|
return await manager.create_or_reject("slow-thread", run_id="cancelled-run")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(store.entered.wait(), timeout=5)
|
||||||
|
queued = asyncio.create_task(waiter())
|
||||||
|
tasks.append(queued)
|
||||||
|
await asyncio.wait_for(waiter_entered.wait(), timeout=5)
|
||||||
|
queued.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await queued
|
||||||
|
assert not owner.done()
|
||||||
|
store.release.set()
|
||||||
|
first = await asyncio.wait_for(owner, timeout=5)
|
||||||
|
assert await manager.get("cancelled-run") is None
|
||||||
|
assert await manager.list_by_thread("slow-thread") == [first]
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
await manager.create_or_reject("slow-thread")
|
||||||
|
finally:
|
||||||
|
store.release.set()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_same_thread_keyed_retry_reuses_the_committed_admission():
|
||||||
|
store = HeldAdmissionStore()
|
||||||
|
manager = RunManager(store=store)
|
||||||
|
first = asyncio.create_task(manager.create_or_reject("slow-thread", idempotency_key="request-key"))
|
||||||
|
tasks = [first]
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(store.entered.wait(), timeout=5)
|
||||||
|
retry = asyncio.create_task(manager.create_or_reject("slow-thread", idempotency_key="request-key"))
|
||||||
|
tasks.append(retry)
|
||||||
|
store.release.set()
|
||||||
|
original, reused = await asyncio.wait_for(asyncio.gather(first, retry), timeout=5)
|
||||||
|
assert reused.run_id == original.run_id
|
||||||
|
assert reused.idempotency_reused
|
||||||
|
assert len(await manager.list_by_thread("slow-thread")) == 1
|
||||||
|
finally:
|
||||||
|
store.release.set()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_assembly_snapshot_does_not_overwrite_an_acknowledged_renewal():
|
||||||
|
store = MemoryRunStore()
|
||||||
|
manager = RunManager(store=store, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=5, grace_seconds=0))
|
||||||
|
record = await manager.create_or_reject("thread", user_id="owner")
|
||||||
|
# Assembly is published before execution; pending runs also renew leases.
|
||||||
|
renewed = asyncio.Event()
|
||||||
|
release_ack = asyncio.Event()
|
||||||
|
assembly_started = asyncio.Event()
|
||||||
|
original_renew = store.renew_lease
|
||||||
|
renewal_targets = []
|
||||||
|
|
||||||
|
async def delayed_ack(run_id, **kwargs):
|
||||||
|
result = await original_renew(run_id, **kwargs)
|
||||||
|
renewal_targets.append(kwargs["lease_expires_at"])
|
||||||
|
renewed.set()
|
||||||
|
await release_ack.wait()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def publish_assembly():
|
||||||
|
assembly_started.set()
|
||||||
|
await manager.record_assembly_evidence(record.run_id, {"run_id": record.run_id, "thread_id": record.thread_id})
|
||||||
|
|
||||||
|
store.renew_lease = delayed_ack
|
||||||
|
publication = None
|
||||||
|
try:
|
||||||
|
await manager.start_heartbeat()
|
||||||
|
await asyncio.wait_for(renewed.wait(), timeout=5)
|
||||||
|
publication = asyncio.create_task(publish_assembly())
|
||||||
|
await asyncio.wait_for(assembly_started.wait(), timeout=5)
|
||||||
|
release_ack.set()
|
||||||
|
await manager.stop_heartbeat(timeout=5)
|
||||||
|
await asyncio.wait_for(publication, timeout=5)
|
||||||
|
stored = await store.get(record.run_id)
|
||||||
|
assert stored["lease_expires_at"] >= renewal_targets[0]
|
||||||
|
assert stored["lease_expires_at"] == record.lease_expires_at
|
||||||
|
finally:
|
||||||
|
release_ack.set()
|
||||||
|
if publication is not None:
|
||||||
|
await asyncio.gather(publication, return_exceptions=True)
|
||||||
|
await manager.cancel(record.run_id)
|
||||||
|
await manager.stop_heartbeat(timeout=3)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_renewal_cancel_does_not_hold_snapshot_gate_while_waiting_for_assembly():
|
||||||
|
store = MemoryRunStore()
|
||||||
|
manager = RunManager(store=store, run_ownership_config=RunOwnershipConfig(heartbeat_enabled=True, lease_seconds=5, grace_seconds=0))
|
||||||
|
record = await manager.create_or_reject("thread", user_id="owner")
|
||||||
|
await store.request_cancel(record.run_id, action="interrupt")
|
||||||
|
renewed, release_ack, assembly_started = asyncio.Event(), asyncio.Event(), asyncio.Event()
|
||||||
|
original_renew = store.renew_lease
|
||||||
|
|
||||||
|
async def delayed_ack(run_id, **kwargs):
|
||||||
|
result = await original_renew(run_id, **kwargs)
|
||||||
|
assert result.cancel_action == "interrupt"
|
||||||
|
renewed.set()
|
||||||
|
await release_ack.wait()
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def publish_assembly():
|
||||||
|
assembly_started.set()
|
||||||
|
await manager.record_assembly_evidence(record.run_id, {"run_id": record.run_id, "thread_id": record.thread_id})
|
||||||
|
|
||||||
|
store.renew_lease = delayed_ack
|
||||||
|
renewal = asyncio.create_task(manager._renew_leases())
|
||||||
|
publication = None
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(renewed.wait(), timeout=5)
|
||||||
|
publication = asyncio.create_task(publish_assembly())
|
||||||
|
await asyncio.wait_for(assembly_started.wait(), timeout=5)
|
||||||
|
release_ack.set()
|
||||||
|
await asyncio.wait_for(asyncio.shield(renewal), timeout=2)
|
||||||
|
await asyncio.wait_for(publication, timeout=2)
|
||||||
|
assert record.abort_event.is_set()
|
||||||
|
assert record.abort_action == "interrupt"
|
||||||
|
assert not record.ownership_lost
|
||||||
|
finally:
|
||||||
|
release_ack.set()
|
||||||
|
if publication is not None:
|
||||||
|
publication.cancel()
|
||||||
|
await asyncio.gather(publication, return_exceptions=True)
|
||||||
|
await manager.stop_heartbeat(timeout=3)
|
||||||
|
await asyncio.gather(renewal, return_exceptions=True)
|
||||||
|
await manager.cancel(record.run_id)
|
||||||
@ -162,6 +162,149 @@ def test_serialize_dispatcher_default_mode():
|
|||||||
assert result == {"key": "v1"}
|
assert result == {"key": "v1"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── non-finite float normalization ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _FakePydanticWithNonFinite:
|
||||||
|
"""Object whose model_dump output carries a nested non-finite float.
|
||||||
|
|
||||||
|
Mirrors a live LangChain message whose ``response_metadata`` holds a
|
||||||
|
provider logprob of ``-inf`` for a zero-probability token.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def model_dump(self):
|
||||||
|
return {"response_metadata": {"logprobs": {"token": float("-inf")}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_finite_floats_unchanged():
|
||||||
|
from deerflow.runtime.serialization import serialize_lc_object
|
||||||
|
|
||||||
|
assert serialize_lc_object(3.14) == 3.14
|
||||||
|
assert serialize_lc_object([1.5, {"x": -2.0}]) == [1.5, {"x": -2.0}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_non_finite_floats_normalized_to_none():
|
||||||
|
import math
|
||||||
|
|
||||||
|
from deerflow.runtime.serialization import serialize_lc_object
|
||||||
|
|
||||||
|
assert serialize_lc_object(float("nan")) is None
|
||||||
|
assert serialize_lc_object(float("inf")) is None
|
||||||
|
assert serialize_lc_object(float("-inf")) is None
|
||||||
|
result = serialize_lc_object({"a": float("inf"), "b": [float("nan"), 1.0]})
|
||||||
|
assert result == {"a": None, "b": [None, 1.0]}
|
||||||
|
assert all(not isinstance(v, float) or math.isfinite(v) for v in _iter_floats(result))
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_recurses_into_model_dump_output():
|
||||||
|
"""A non-finite float inside a model_dump result must also be normalized."""
|
||||||
|
from deerflow.runtime.serialization import serialize_lc_object
|
||||||
|
|
||||||
|
result = serialize_lc_object(_FakePydanticWithNonFinite())
|
||||||
|
assert result == {"response_metadata": {"logprobs": {"token": None}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_channel_values_json_safe_for_both_wires():
|
||||||
|
"""Serialized channel values must survive a strict (allow_nan=False) encoder."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from deerflow.runtime.serialization import serialize_channel_values_for_api
|
||||||
|
|
||||||
|
raw = {
|
||||||
|
"messages": [{"type": "ai", "content": "hi", "response_metadata": {"logprobs": {"t": float("-inf")}}}],
|
||||||
|
"some_metric": float("nan"),
|
||||||
|
}
|
||||||
|
result = serialize_channel_values_for_api(raw)
|
||||||
|
# Strict encoding mirrors Starlette's JSONResponse.render (REST path).
|
||||||
|
json.dumps(result, allow_nan=False)
|
||||||
|
# Strict decoding mirrors the JS useStream / langgraph-sdk SSE parser.
|
||||||
|
encoded = json.dumps(result)
|
||||||
|
json.loads(encoded, parse_constant=_reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_messages_mode_json_safe():
|
||||||
|
import json
|
||||||
|
|
||||||
|
from deerflow.runtime.serialization import serialize
|
||||||
|
|
||||||
|
# Both halves of the tuple must be JSON-safe: the chunk carries the
|
||||||
|
# non-finite in model_dump output, the metadata in caller-supplied run
|
||||||
|
# metadata (merged into messages-mode frames by LangGraph).
|
||||||
|
payload = serialize(
|
||||||
|
(_FakePydanticWithNonFinite(), {"node": "model", "metric": float("-inf")}),
|
||||||
|
mode="messages",
|
||||||
|
)
|
||||||
|
assert payload[1]["metric"] is None
|
||||||
|
json.dumps(payload, allow_nan=False)
|
||||||
|
json.loads(json.dumps(payload), parse_constant=_reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_messages_tuple_metadata_non_finite_floats_normalized():
|
||||||
|
"""The metadata half of a messages-mode tuple rides the same wire as the
|
||||||
|
chunk: non-finite floats nested there must be normalized too (#7669), not
|
||||||
|
emitted as bare ``NaN`` / ``Infinity`` tokens."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from deerflow.runtime.serialization import serialize_messages_tuple
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"langgraph_node": "agent",
|
||||||
|
"langgraph_step": 1,
|
||||||
|
"stats": {"score": float("inf"), "delta": float("nan")},
|
||||||
|
}
|
||||||
|
result = serialize_messages_tuple(({"content": "hi"}, metadata))
|
||||||
|
assert result[1] == {
|
||||||
|
"langgraph_node": "agent",
|
||||||
|
"langgraph_step": 1,
|
||||||
|
"stats": {"score": None, "delta": None},
|
||||||
|
}
|
||||||
|
# Strict encoding mirrors Starlette's JSONResponse; strict decoding
|
||||||
|
# mirrors the JS useStream / langgraph-sdk SSE parser.
|
||||||
|
json.dumps(result, allow_nan=False)
|
||||||
|
json.loads(json.dumps(result), parse_constant=_reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_serialize_messages_mode_json_safe_metadata():
|
||||||
|
"""At the dispatcher level metadata gets the same canonical treatment as
|
||||||
|
the chunk: tuples become lists (their JSON shape anyway) and nested
|
||||||
|
objects are recursively serialized."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from deerflow.runtime.serialization import serialize
|
||||||
|
|
||||||
|
payload = serialize(
|
||||||
|
(
|
||||||
|
{"content": "hi"},
|
||||||
|
{
|
||||||
|
"langgraph_node": "agent",
|
||||||
|
"langgraph_triggers": ("branch:to:agent",),
|
||||||
|
"nested": {"obj": _FakePydanticV2(), "floor": float("-inf")},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
mode="messages",
|
||||||
|
)
|
||||||
|
assert payload[1]["langgraph_node"] == "agent"
|
||||||
|
assert payload[1]["langgraph_triggers"] == ["branch:to:agent"]
|
||||||
|
assert payload[1]["nested"] == {"obj": {"key": "v2"}, "floor": None}
|
||||||
|
json.dumps(payload, allow_nan=False)
|
||||||
|
json.loads(json.dumps(payload), parse_constant=_reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def _reject_constant(token):
|
||||||
|
raise ValueError(f"non-finite JSON token: {token}")
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_floats(obj):
|
||||||
|
if isinstance(obj, float):
|
||||||
|
yield obj
|
||||||
|
elif isinstance(obj, dict):
|
||||||
|
for v in obj.values():
|
||||||
|
yield from _iter_floats(v)
|
||||||
|
elif isinstance(obj, (list, tuple)):
|
||||||
|
for v in obj:
|
||||||
|
yield from _iter_floats(v)
|
||||||
|
|
||||||
|
|
||||||
# ── strip_data_url_image_blocks ──────────────────────────────────────────────
|
# ── strip_data_url_image_blocks ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -24,7 +24,7 @@ def assets(tmp_path, monkeypatch):
|
|||||||
storage = UserScopedSkillStorage("owner", host_path=str(tmp_path / "skills"))
|
storage = UserScopedSkillStorage("owner", host_path=str(tmp_path / "skills"))
|
||||||
root = storage.get_custom_skill_dir("example")
|
root = storage.get_custom_skill_dir("example")
|
||||||
root.mkdir(parents=True)
|
root.mkdir(parents=True)
|
||||||
(root / "SKILL.md").write_text(CONTENT, encoding="utf-8")
|
(root / "SKILL.md").write_text(CONTENT, encoding="utf-8", newline="")
|
||||||
engine = create_engine(f"sqlite:///{tmp_path / 'mutations.db'}")
|
engine = create_engine(f"sqlite:///{tmp_path / 'mutations.db'}")
|
||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
sessions = sessionmaker(engine, expire_on_commit=False)
|
sessions = sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|||||||
@ -1080,6 +1080,8 @@ def test_subagent_executor_resolves_skill_authorization_for_chain(monkeypatch):
|
|||||||
executor = SubagentExecutor.__new__(SubagentExecutor)
|
executor = SubagentExecutor.__new__(SubagentExecutor)
|
||||||
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
|
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
|
||||||
executor.model_name = "m"
|
executor.model_name = "m"
|
||||||
|
executor.thinking_enabled = False
|
||||||
|
executor.reasoning_effort = None
|
||||||
executor.app_config = app_config
|
executor.app_config = app_config
|
||||||
executor._resolved_app_config = app_config
|
executor._resolved_app_config = app_config
|
||||||
executor._available_skill_names = None
|
executor._available_skill_names = None
|
||||||
@ -1444,6 +1446,8 @@ def test_subagent_executor_shares_one_skill_authorization_instance(monkeypatch):
|
|||||||
executor = SubagentExecutor.__new__(SubagentExecutor)
|
executor = SubagentExecutor.__new__(SubagentExecutor)
|
||||||
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
|
executor.config = SimpleNamespace(name="sub", skills=None, max_turns=25)
|
||||||
executor.model_name = "m"
|
executor.model_name = "m"
|
||||||
|
executor.thinking_enabled = False
|
||||||
|
executor.reasoning_effort = None
|
||||||
executor.app_config = app_config
|
executor.app_config = app_config
|
||||||
executor._resolved_app_config = app_config
|
executor._resolved_app_config = app_config
|
||||||
executor._available_skill_names = None
|
executor._available_skill_names = None
|
||||||
|
|||||||
@ -28,3 +28,44 @@ def test_sse_error_format():
|
|||||||
parsed = json.loads(frame.split("data: ")[1].split("\n")[0])
|
parsed = json.loads(frame.split("data: ")[1].split("\n")[0])
|
||||||
assert parsed["message"] == "boom"
|
assert parsed["message"] == "boom"
|
||||||
assert parsed["name"] == "ValueError"
|
assert parsed["name"] == "ValueError"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_frame_rejects_non_finite_floats():
|
||||||
|
"""A non-finite float must not reach the wire as a bare NaN/Infinity token.
|
||||||
|
|
||||||
|
The canonical serializer normalizes non-finite floats before they reach
|
||||||
|
``format_sse``; this guards the wire boundary directly so a regression in
|
||||||
|
either layer is caught. A strict ``parse_constant`` mirrors the JS
|
||||||
|
``useStream`` / ``langgraph-sdk`` SSE decoder, which rejects such tokens.
|
||||||
|
"""
|
||||||
|
from deerflow.runtime.serialization import serialize
|
||||||
|
|
||||||
|
payload = serialize({"messages": [], "metric": float("inf")}, mode="values")
|
||||||
|
frame = _format_sse("values", payload)
|
||||||
|
data_line = frame.split("data: ")[1].split("\n")[0]
|
||||||
|
|
||||||
|
def _reject_constant(token):
|
||||||
|
raise ValueError(f"non-finite JSON token: {token}")
|
||||||
|
|
||||||
|
json.loads(data_line, parse_constant=_reject_constant)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sse_messages_frame_rejects_non_finite_floats_in_metadata():
|
||||||
|
"""A non-finite float inside messages-mode metadata must not reach the
|
||||||
|
wire as a bare NaN/Infinity token either (#7669)."""
|
||||||
|
from deerflow.runtime.serialization import serialize
|
||||||
|
|
||||||
|
payload = serialize(
|
||||||
|
(
|
||||||
|
{"type": "AIMessageChunk", "content": "hi"},
|
||||||
|
{"langgraph_node": "model", "score": float("inf")},
|
||||||
|
),
|
||||||
|
mode="messages",
|
||||||
|
)
|
||||||
|
frame = _format_sse("messages", payload)
|
||||||
|
data_line = frame.split("data: ")[1].split("\n")[0]
|
||||||
|
|
||||||
|
def _reject_constant(token):
|
||||||
|
raise ValueError(f"non-finite JSON token: {token}")
|
||||||
|
|
||||||
|
json.loads(data_line, parse_constant=_reject_constant)
|
||||||
|
|||||||
@ -9,6 +9,7 @@ from test_terminal_store_contract import store as store
|
|||||||
|
|
||||||
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
from deerflow.config.run_ownership_config import RunOwnershipConfig
|
||||||
from deerflow.runtime import RunManager, RunStatus
|
from deerflow.runtime import RunManager, RunStatus
|
||||||
|
from deerflow.runtime.runs import manager as manager_module
|
||||||
|
|
||||||
|
|
||||||
def manager_for(run_store):
|
def manager_for(run_store):
|
||||||
@ -41,7 +42,15 @@ async def finish(manager, record, *tasks):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
@pytest.mark.anyio
|
||||||
async def test_pre_rollback_renewal_retries_after_interrupted_ack(store):
|
async def test_pre_rollback_renewal_retries_after_interrupted_ack(store, monkeypatch):
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
|
class Clock(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return now if tz is not None else now.replace(tzinfo=None)
|
||||||
|
|
||||||
|
monkeypatch.setattr(manager_module, "datetime", Clock)
|
||||||
manager = manager_for(store)
|
manager = manager_for(store)
|
||||||
record = await running_record(manager)
|
record = await running_record(manager)
|
||||||
entered, release = asyncio.Event(), asyncio.Event()
|
entered, release = asyncio.Event(), asyncio.Event()
|
||||||
@ -66,6 +75,9 @@ async def test_pre_rollback_renewal_retries_after_interrupted_ack(store):
|
|||||||
assert record.rollback_finalization_pending
|
assert record.rollback_finalization_pending
|
||||||
old_expiry = record.lease_expires_at
|
old_expiry = record.lease_expires_at
|
||||||
|
|
||||||
|
# Memory calls may finish within one Windows wall-clock tick. Pin a
|
||||||
|
# later renewal instant instead of relying on incidental test latency.
|
||||||
|
now += timedelta(seconds=1)
|
||||||
release.set()
|
release.set()
|
||||||
await asyncio.wait_for(renewal, 2)
|
await asyncio.wait_for(renewal, 2)
|
||||||
|
|
||||||
|
|||||||
@ -169,6 +169,8 @@ def _setup_executor_classes():
|
|||||||
sandbox_provider_module = importlib.import_module("deerflow.sandbox.sandbox_provider")
|
sandbox_provider_module = importlib.import_module("deerflow.sandbox.sandbox_provider")
|
||||||
sandbox_overwrite_module = importlib.import_module("deerflow.sandbox.overwrite")
|
sandbox_overwrite_module = importlib.import_module("deerflow.sandbox.overwrite")
|
||||||
sandbox_prewarm_module = importlib.import_module("deerflow.sandbox.prewarm")
|
sandbox_prewarm_module = importlib.import_module("deerflow.sandbox.prewarm")
|
||||||
|
for module_name in ("deerflow.models.factory", "deerflow.models.openai_codex_provider", "deerflow.agents.assembly_descriptor"):
|
||||||
|
importlib.import_module(module_name)
|
||||||
|
|
||||||
# Remove mocked executor if exists (from conftest.py)
|
# Remove mocked executor if exists (from conftest.py)
|
||||||
if "deerflow.subagents.executor" in sys.modules:
|
if "deerflow.subagents.executor" in sys.modules:
|
||||||
@ -395,6 +397,105 @@ def msg(classes):
|
|||||||
class TestAgentConstruction:
|
class TestAgentConstruction:
|
||||||
"""Test _create_agent() wiring before execution starts."""
|
"""Test _create_agent() wiring before execution starts."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"thinking,effort,mode,expected_thinking,expected_effort,expected_adjustment",
|
||||||
|
[
|
||||||
|
(None, None, "optional", False, "lite", None),
|
||||||
|
(True, "high", "optional", True, "deep", "effort_aliased"),
|
||||||
|
(False, None, "required", True, "lite", "thinking_forced_on"),
|
||||||
|
(True, None, "unsupported", False, None, "thinking_unsupported"),
|
||||||
|
(True, "unknown", "optional", True, "lite", "effort_unsupported_value"),
|
||||||
|
(True, "high", "legacy", True, "high", None),
|
||||||
|
(True, None, "legacy-unsupported", False, None, None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_reasoning_factory_and_observer_agree(self, classes, base_config, monkeypatch, caplog, thinking, effort, mode, expected_thinking, expected_effort, expected_adjustment):
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.model_config import ModelConfig
|
||||||
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
|
from deerflow.extensions.registry import ExtensionRegistry
|
||||||
|
from deerflow.subagents import executor as executor_module
|
||||||
|
|
||||||
|
factory = importlib.import_module("deerflow.models.factory")
|
||||||
|
profile_settings = {}
|
||||||
|
if mode in {"legacy", "legacy-unsupported"}:
|
||||||
|
profile_settings = {
|
||||||
|
"supports_thinking": mode == "legacy",
|
||||||
|
"supports_reasoning_effort": True,
|
||||||
|
"when_thinking_enabled": {"extra_body": {"thinking": {"type": "enabled"}}},
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
reasoning = {"thinking": mode, "dialect": "openai_extra_body"}
|
||||||
|
if mode != "unsupported":
|
||||||
|
reasoning["effort"] = {"values": ["lite", "deep"], "default": "lite", "aliases": {"high": "deep"}}
|
||||||
|
profile_settings["reasoning"] = reasoning
|
||||||
|
profile = ModelConfig(name="child-model", use="langchain_openai:ChatOpenAI", model="child-model", **profile_settings)
|
||||||
|
app_config = AppConfig(models=[profile], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
|
||||||
|
unchanged = app_config.model_dump()
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class RecordingModel:
|
||||||
|
def __init__(self, **kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
|
||||||
|
class Observer:
|
||||||
|
def on_agent_assembled(self, app_store, descriptor):
|
||||||
|
captured["descriptor"] = descriptor
|
||||||
|
|
||||||
|
registry = ExtensionRegistry()
|
||||||
|
with registry.attributed_to("reasoning-test"):
|
||||||
|
registry.agent_assembly_observer(Observer())
|
||||||
|
monkeypatch.setattr(factory, "resolve_class", lambda *args: RecordingModel)
|
||||||
|
monkeypatch.setattr(executor_module, "create_chat_model", factory.create_chat_model)
|
||||||
|
monkeypatch.setattr(executor_module, "create_agent", lambda **kwargs: SimpleNamespace(get_graph=lambda: SimpleNamespace(nodes={})))
|
||||||
|
monkeypatch.setitem(
|
||||||
|
sys.modules,
|
||||||
|
"deerflow.agents.middlewares.tool_error_handling_middleware",
|
||||||
|
_module("deerflow.agents.middlewares.tool_error_handling_middleware", build_subagent_runtime_middlewares=lambda **kwargs: []),
|
||||||
|
)
|
||||||
|
base_config.thinking_enabled = thinking
|
||||||
|
base_config.reasoning_effort = effort
|
||||||
|
executor = classes["SubagentExecutor"](config=base_config, tools=[], app_config=app_config, parent_model="child-model", extensions=registry.build())
|
||||||
|
if mode == "legacy-unsupported":
|
||||||
|
with pytest.raises(ValueError, match="Model child-model does not support thinking.*supports_thinking"):
|
||||||
|
asyncio.run(executor._create_agent())
|
||||||
|
assert not captured
|
||||||
|
assert app_config.model_dump() == unchanged
|
||||||
|
return
|
||||||
|
|
||||||
|
with caplog.at_level(logging.INFO, logger=factory.__name__):
|
||||||
|
asyncio.run(executor._create_agent())
|
||||||
|
if expected_adjustment is not None:
|
||||||
|
assert any(record.name == factory.__name__ and expected_adjustment in record.getMessage() for record in caplog.records)
|
||||||
|
descriptor = captured["descriptor"]
|
||||||
|
assert descriptor.thinking_enabled is expected_thinking
|
||||||
|
assert descriptor.reasoning_effort == expected_effort
|
||||||
|
assert captured.get("reasoning_effort") == expected_effort
|
||||||
|
assert captured["extra_body"]["thinking"]["type"] == ("enabled" if expected_thinking else "disabled")
|
||||||
|
assert app_config.model_dump() == unchanged
|
||||||
|
|
||||||
|
def test_required_thinking_rejection_names_model_before_creation(self, classes, base_config, monkeypatch):
|
||||||
|
from deerflow.config.app_config import AppConfig
|
||||||
|
from deerflow.config.model_config import ModelConfig
|
||||||
|
from deerflow.config.sandbox_config import SandboxConfig
|
||||||
|
from deerflow.models.reasoning import ReasoningPolicyError
|
||||||
|
from deerflow.subagents import executor as executor_module
|
||||||
|
|
||||||
|
profile = ModelConfig(name="required-worker-model", use="langchain_openai:ChatOpenAI", model="test-model", reasoning={"thinking": "required", "on_disable_request": "reject"})
|
||||||
|
app_config = AppConfig(models=[profile], sandbox=SandboxConfig(use="deerflow.sandbox.local:LocalSandboxProvider"))
|
||||||
|
model_factory = MagicMock(side_effect=AssertionError("model creation must not start"))
|
||||||
|
graph_factory = MagicMock(side_effect=AssertionError("graph creation must not start"))
|
||||||
|
monkeypatch.setattr(executor_module, "create_chat_model", model_factory)
|
||||||
|
monkeypatch.setattr(executor_module, "create_agent", graph_factory)
|
||||||
|
executor = classes["SubagentExecutor"](config=base_config, tools=[], app_config=app_config, parent_model=profile.name)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match=f"Model {profile.name}: model requires thinking") as error:
|
||||||
|
asyncio.run(executor._create_agent())
|
||||||
|
|
||||||
|
assert isinstance(error.value.__cause__, ReasoningPolicyError)
|
||||||
|
model_factory.assert_not_called()
|
||||||
|
graph_factory.assert_not_called()
|
||||||
|
|
||||||
def test_create_agent_threads_explicit_app_config_to_model_and_middlewares(
|
def test_create_agent_threads_explicit_app_config_to_model_and_middlewares(
|
||||||
self,
|
self,
|
||||||
classes,
|
classes,
|
||||||
@ -460,6 +561,7 @@ class TestAgentConstruction:
|
|||||||
assert captured["model"] == {
|
assert captured["model"] == {
|
||||||
"name": "parent-model",
|
"name": "parent-model",
|
||||||
"thinking_enabled": False,
|
"thinking_enabled": False,
|
||||||
|
"reasoning_effort": None,
|
||||||
"app_config": app_config,
|
"app_config": app_config,
|
||||||
# attach_tracing=False pairs with graph-root tracing callbacks
|
# attach_tracing=False pairs with graph-root tracing callbacks
|
||||||
# injected in _aexecute (see TestSubagentTracingWiring). Without
|
# injected in _aexecute (see TestSubagentTracingWiring). Without
|
||||||
|
|||||||
41
backend/tests/test_subagent_thinking_config.py
Normal file
41
backend/tests/test_subagent_thinking_config.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""Native registry coverage for per-worker reasoning defaults and overrides."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.config.subagents_config import CustomSubagentConfig, SubagentOverrideConfig, SubagentsAppConfig
|
||||||
|
from deerflow.persistence.managed_subagents import ManagedSubagentDefinition
|
||||||
|
from deerflow.subagents import registry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("source", ["builtin", "config", "managed"])
|
||||||
|
def test_reasoning_settings_flow_through_registry_without_mutating_definitions(monkeypatch, source):
|
||||||
|
name = "general-purpose" if source == "builtin" else "researcher"
|
||||||
|
settings = {"thinking_enabled": True, "reasoning_effort": "deep"}
|
||||||
|
app_config = SubagentsAppConfig()
|
||||||
|
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [])
|
||||||
|
if source == "builtin":
|
||||||
|
app_config.agents[name] = SubagentOverrideConfig(**settings)
|
||||||
|
original = registry.BUILTIN_SUBAGENTS[name]
|
||||||
|
elif source == "config":
|
||||||
|
original = CustomSubagentConfig(description="Research", system_prompt="Research the task.", **settings)
|
||||||
|
app_config.custom_agents[name] = original
|
||||||
|
else:
|
||||||
|
original = ManagedSubagentDefinition(name=name, description="Research", system_prompt="Research the task.", **settings)
|
||||||
|
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [original])
|
||||||
|
|
||||||
|
configured = registry.get_subagent_config(name, app_config=app_config)
|
||||||
|
assert (configured.thinking_enabled, configured.reasoning_effort) == (True, "deep")
|
||||||
|
|
||||||
|
app_config.agents[name] = SubagentOverrideConfig(thinking_enabled=False, reasoning_effort="lite")
|
||||||
|
overridden = registry.get_subagent_config(name, app_config=app_config)
|
||||||
|
assert (overridden.thinking_enabled, overridden.reasoning_effort) == (False, "lite")
|
||||||
|
assert (configured.thinking_enabled, configured.reasoning_effort) == (True, "deep")
|
||||||
|
assert original.thinking_enabled is (None if source == "builtin" else True)
|
||||||
|
assert original.reasoning_effort == (None if source == "builtin" else "deep")
|
||||||
|
|
||||||
|
|
||||||
|
def test_unconfigured_workers_keep_reasoning_defaults(monkeypatch):
|
||||||
|
monkeypatch.setattr(registry, "_managed_definitions", lambda **_: [])
|
||||||
|
configured = registry.get_subagent_config("general-purpose", app_config=SubagentsAppConfig())
|
||||||
|
assert configured.thinking_enabled is None
|
||||||
|
assert configured.reasoning_effort is None
|
||||||
@ -137,17 +137,23 @@ async def test_admin_can_create_update_and_delete_managed_subagent():
|
|||||||
name="planner",
|
name="planner",
|
||||||
description="Plans creative work",
|
description="Plans creative work",
|
||||||
system_prompt="You are a creative planner.",
|
system_prompt="You are a creative planner.",
|
||||||
|
thinking_enabled=True,
|
||||||
|
reasoning_effort="xhigh",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
assert created.source == "managed"
|
assert created.source == "managed"
|
||||||
assert created.system_prompt == "You are a creative planner."
|
assert created.system_prompt == "You are a creative planner."
|
||||||
|
assert created.thinking_enabled is True
|
||||||
|
assert created.reasoning_effort == "xhigh"
|
||||||
|
|
||||||
updated = await router.update_managed_subagent(
|
updated = await router.update_managed_subagent(
|
||||||
"planner",
|
"planner",
|
||||||
_request("admin"),
|
_request("admin"),
|
||||||
router.ManagedSubagentUpdateRequest(enabled=False),
|
router.ManagedSubagentUpdateRequest(enabled=False, thinking_enabled=False, reasoning_effort=None),
|
||||||
)
|
)
|
||||||
assert updated.enabled is False
|
assert updated.enabled is False
|
||||||
|
assert updated.thinking_enabled is False
|
||||||
|
assert updated.reasoning_effort is None
|
||||||
|
|
||||||
await router.delete_managed_subagent("planner", _request("admin"))
|
await router.delete_managed_subagent("planner", _request("admin"))
|
||||||
with pytest.raises(HTTPException) as excinfo:
|
with pytest.raises(HTTPException) as excinfo:
|
||||||
|
|||||||
256
backend/tests/test_telegram_reply_threads.py
Normal file
256
backend/tests/test_telegram_reply_threads.py
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
"""Telegram reply routing through real stores, with an offline Bot API double."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from itertools import count
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.channels.manager import ChannelManager
|
||||||
|
from app.channels.message_bus import MessageBus, OutboundMessage, ResolvedAttachment
|
||||||
|
from app.channels.store import JsonChannelStore, SqlChannelStore
|
||||||
|
from app.channels.telegram import TelegramChannel
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["json", "sqlite", "bound"])
|
||||||
|
async def routing(request, tmp_path):
|
||||||
|
repo = None
|
||||||
|
connection = None
|
||||||
|
session_factory = None
|
||||||
|
if request.param != "json":
|
||||||
|
from deerflow.persistence.channel_connections import ChannelConnectionRepository, ChannelCredentialCipher
|
||||||
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||||
|
|
||||||
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'channel.db'}", sqlite_dir=str(tmp_path))
|
||||||
|
session_factory = get_session_factory()
|
||||||
|
if request.param == "bound":
|
||||||
|
repo = ChannelConnectionRepository(session_factory, cipher=ChannelCredentialCipher.from_key("test-key"))
|
||||||
|
connection = await repo.upsert_connection(
|
||||||
|
owner_user_id="owner-a",
|
||||||
|
provider="telegram",
|
||||||
|
external_account_id="42",
|
||||||
|
workspace_id="-10042",
|
||||||
|
)
|
||||||
|
store = SqlChannelStore(session_factory) if session_factory else JsonChannelStore(tmp_path / "store.json")
|
||||||
|
bus = MessageBus()
|
||||||
|
channel = TelegramChannel(bus=bus, config={"bot_token": "test-token", "channel_store": store, "connection_repo": repo})
|
||||||
|
channel._main_loop = asyncio.get_running_loop()
|
||||||
|
message_ids = count(100)
|
||||||
|
sent_ids = []
|
||||||
|
|
||||||
|
async def send(**kwargs):
|
||||||
|
message_id = next(message_ids)
|
||||||
|
sent_ids.append(message_id)
|
||||||
|
return SimpleNamespace(message_id=message_id)
|
||||||
|
|
||||||
|
async def rich(endpoint, api_kwargs):
|
||||||
|
if endpoint == "editMessageText":
|
||||||
|
return {"message_id": api_kwargs["message_id"]}
|
||||||
|
return {"message_id": (await send()).message_id}
|
||||||
|
|
||||||
|
bot = SimpleNamespace(send_message=AsyncMock(side_effect=send), edit_message_text=AsyncMock(), do_api_request=AsyncMock(side_effect=rich), send_photo=AsyncMock(side_effect=send), send_document=AsyncMock(side_effect=send))
|
||||||
|
channel._application = SimpleNamespace(bot=bot)
|
||||||
|
thread_ids = count(1)
|
||||||
|
client = SimpleNamespace(threads=SimpleNamespace(create=AsyncMock(side_effect=lambda **kw: {"thread_id": f"thread-{next(thread_ids)}"})))
|
||||||
|
manager = ChannelManager(bus=bus, store=store, connection_repo=repo)
|
||||||
|
|
||||||
|
async def incoming(message_id, reply_id=None, *, chat_type="supergroup", user_id=42):
|
||||||
|
chat_id = 42 if chat_type == "private" else -10042
|
||||||
|
update = SimpleNamespace(
|
||||||
|
effective_chat=SimpleNamespace(id=chat_id, type=chat_type),
|
||||||
|
effective_user=SimpleNamespace(id=user_id),
|
||||||
|
message=SimpleNamespace(text="question", caption=None, photo=[], document=None, message_id=message_id, reply_to_message=SimpleNamespace(message_id=reply_id) if reply_id else None),
|
||||||
|
)
|
||||||
|
await channel._on_text(update, None)
|
||||||
|
return await asyncio.wait_for(bus.get_inbound(), timeout=5)
|
||||||
|
|
||||||
|
def outbound(inbound, thread_id, **kwargs):
|
||||||
|
return OutboundMessage(
|
||||||
|
channel_name="telegram",
|
||||||
|
chat_id=inbound.chat_id,
|
||||||
|
thread_id=thread_id,
|
||||||
|
thread_ts=inbound.thread_ts,
|
||||||
|
connection_id=inbound.connection_id,
|
||||||
|
owner_user_id=inbound.owner_user_id,
|
||||||
|
metadata=inbound.metadata,
|
||||||
|
text=kwargs.pop("text", "Answer"),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
harness = SimpleNamespace(channel=channel, bot=bot, bus=bus, store=store, repo=repo, connection=connection, manager=manager, client=client, incoming=incoming, outbound=outbound, sent_ids=sent_ids)
|
||||||
|
try:
|
||||||
|
yield harness
|
||||||
|
finally:
|
||||||
|
await channel._close_and_drain_threadsafe_futures()
|
||||||
|
if session_factory:
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("chat_type", ["private", "group", "supergroup"])
|
||||||
|
@pytest.mark.parametrize("reply_to_bot", [False, True])
|
||||||
|
async def test_reply_reuses_conversation(routing, chat_type, reply_to_bot):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10, chat_type=chat_type)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
await h.channel.send(h.outbound(first, thread_id))
|
||||||
|
response_id = h.bot.edit_message_text.await_args.kwargs["message_id"]
|
||||||
|
followup = await h.incoming(11, response_id if reply_to_bot else 10, chat_type=chat_type)
|
||||||
|
assert await h.manager._get_or_create_thread(h.client, followup) == (thread_id, False)
|
||||||
|
h.client.threads.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_stream_aliases_are_not_rewritten_and_new_keeps_its_binding(routing):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
repository = h.repo or h.store
|
||||||
|
claim = repository.claim_thread_id
|
||||||
|
repository.claim_thread_id = AsyncMock(wraps=claim)
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Partial", is_final=False))
|
||||||
|
calls = repository.claim_thread_id.await_count
|
||||||
|
assert calls == 2 # Source message and bot preview.
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Partial", is_final=False))
|
||||||
|
assert repository.claim_thread_id.await_count == calls
|
||||||
|
|
||||||
|
command = await h.incoming(11, h.sent_ids[0])
|
||||||
|
new_thread = await h.manager._create_thread(h.client, command)
|
||||||
|
assert new_thread != thread_id
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Old run finished"))
|
||||||
|
assert await h.manager._lookup_thread_id(command) == new_thread
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("delivery", ["plain", "rich", "split", "fallback"])
|
||||||
|
async def test_stream_binding_outage_logs_once_per_answer(routing, caplog, delivery):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
repository = h.repo or h.store
|
||||||
|
repository.claim_thread_id = AsyncMock(side_effect=RuntimeError("store unavailable"))
|
||||||
|
|
||||||
|
with caplog.at_level(logging.DEBUG, logger="app.channels.telegram"):
|
||||||
|
for index in range(20):
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text=f"Partial {index}", is_final=False))
|
||||||
|
if delivery == "rich":
|
||||||
|
h.channel.config["rich_messages"] = True
|
||||||
|
if delivery == "fallback":
|
||||||
|
h.bot.edit_message_text.side_effect = RuntimeError("edit unavailable")
|
||||||
|
text = "x" * 9000 if delivery == "split" else "Final answer"
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text=text))
|
||||||
|
|
||||||
|
failures = [record for record in caplog.records if record.name == "app.channels.telegram" and "thread mapping" in record.getMessage()]
|
||||||
|
assert len(failures) == repository.claim_thread_id.await_count
|
||||||
|
assert len(failures) >= 21, "every failed update must keep retrying the mapping"
|
||||||
|
assert sum(record.levelno == logging.ERROR for record in failures) == 1
|
||||||
|
assert failures[0].exc_info is not None
|
||||||
|
assert all(record.levelno == logging.DEBUG and record.exc_info is None for record in failures[1:])
|
||||||
|
assert len(h.sent_ids) == {"plain": 1, "rich": 1, "split": 3, "fallback": 2}[delivery]
|
||||||
|
|
||||||
|
# Finalization retires the per-answer log state. A subsequent answer
|
||||||
|
# must report its first outage even on the same conversation key.
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Next answer", is_final=False))
|
||||||
|
errors = [record for record in caplog.records if record.name == "app.channels.telegram" and "thread mapping" in record.getMessage() and record.levelno == logging.ERROR]
|
||||||
|
assert len(errors) == 2
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Next answer"))
|
||||||
|
|
||||||
|
|
||||||
|
async def test_binding_failure_does_not_fail_delivery_and_stream_retries(routing, caplog):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
repository = h.repo or h.store
|
||||||
|
claim = repository.claim_thread_id
|
||||||
|
repository.claim_thread_id = AsyncMock(side_effect=RuntimeError("store unavailable"))
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, is_final=False))
|
||||||
|
assert h.bot.edit_message_text.await_count == 1
|
||||||
|
assert "thread mapping" in caplog.text
|
||||||
|
if h.repo:
|
||||||
|
assert await h.store.list_entries() == []
|
||||||
|
repository.claim_thread_id = claim
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, is_final=False))
|
||||||
|
followup = await h.incoming(11, h.sent_ids[0])
|
||||||
|
assert await h.manager._get_or_create_thread(h.client, followup) == (thread_id, False)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("routing", ["bound"], indirect=True)
|
||||||
|
async def test_bound_reply_does_not_expose_thread_to_another_owner(routing):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
await h.channel.send(h.outbound(first, thread_id))
|
||||||
|
response_id = h.sent_ids[0]
|
||||||
|
assert await h.store.list_entries() == []
|
||||||
|
await h.repo.upsert_connection(owner_user_id="owner-b", provider="telegram", external_account_id="43", workspace_id="-10042")
|
||||||
|
other = await h.incoming(11, response_id, user_id=43)
|
||||||
|
assert other.owner_user_id == "owner-b"
|
||||||
|
assert await h.manager._lookup_thread_id(other) is None
|
||||||
|
unbound = await h.incoming(12, response_id, user_id=44)
|
||||||
|
assert unbound.connection_id is None
|
||||||
|
assert await h.manager._lookup_thread_id(unbound) is None
|
||||||
|
h.client.threads.create.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("delivery", ["split", "fallback", "rich", "rich-new", "rich-fallback", "plain-new", "preview", "preview-fallback"])
|
||||||
|
async def test_every_delivered_reply_keeps_thread(routing, delivery):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
placeholder_id = h.sent_ids[0]
|
||||||
|
if delivery.endswith("new"):
|
||||||
|
h.channel._stream_messages.clear()
|
||||||
|
if delivery == "fallback":
|
||||||
|
h.bot.edit_message_text.side_effect = RuntimeError("edit unavailable")
|
||||||
|
if delivery.startswith("rich"):
|
||||||
|
h.channel.config["rich_messages"] = True
|
||||||
|
if delivery == "rich-fallback":
|
||||||
|
from telegram.error import BadRequest
|
||||||
|
|
||||||
|
h.bot.do_api_request.side_effect = BadRequest("unsupported")
|
||||||
|
text = "x" * 9000 if delivery == "split" else "**Answer**"
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text=text, is_final=not delivery.startswith("preview")))
|
||||||
|
if delivery == "preview-fallback":
|
||||||
|
h.channel._stream_messages[f"{first.chat_id}:{first.thread_ts}"]["last_edit_at"] = 0.0
|
||||||
|
h.bot.edit_message_text.side_effect = RuntimeError("edit unavailable")
|
||||||
|
await h.channel.send(h.outbound(first, thread_id, text="Next chunk", is_final=False))
|
||||||
|
reply_ids = h.sent_ids[1:] if delivery.endswith("new") or delivery == "fallback" else list(h.sent_ids)
|
||||||
|
assert reply_ids
|
||||||
|
for reply_id in reply_ids:
|
||||||
|
followup = await h.incoming(200 + reply_id, reply_id)
|
||||||
|
assert await h.manager._get_or_create_thread(h.client, followup) == (thread_id, False)
|
||||||
|
if delivery == "split":
|
||||||
|
assert placeholder_id in reply_ids and len(reply_ids) == 3
|
||||||
|
|
||||||
|
|
||||||
|
async def test_followup_source_alias_and_restart(routing):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
await h.channel.send(h.outbound(first, thread_id))
|
||||||
|
followup = await h.incoming(11, h.sent_ids[0])
|
||||||
|
assert await h.manager._get_or_create_thread(h.client, followup) == (thread_id, False)
|
||||||
|
await h.channel.send(h.outbound(followup, thread_id))
|
||||||
|
store = JsonChannelStore(h.store.path) if isinstance(h.store, JsonChannelStore) else h.store
|
||||||
|
restarted = ChannelManager(bus=h.bus, store=store, connection_repo=h.repo)
|
||||||
|
for reply_id in (11, h.sent_ids[-1]):
|
||||||
|
incoming = await h.incoming(300 + reply_id, reply_id)
|
||||||
|
assert await restarted._get_or_create_thread(h.client, incoming) == (thread_id, False)
|
||||||
|
unrelated = await h.incoming(900)
|
||||||
|
assert (await restarted._get_or_create_thread(h.client, unrelated))[0] != thread_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("image", [False, True])
|
||||||
|
async def test_reply_to_attachment_keeps_thread(routing, tmp_path, image):
|
||||||
|
h = routing
|
||||||
|
first = await h.incoming(10)
|
||||||
|
thread_id, _ = await h.manager._get_or_create_thread(h.client, first)
|
||||||
|
path = tmp_path / ("chart.png" if image else "report.txt")
|
||||||
|
path.write_bytes(b"fixture")
|
||||||
|
attachment = ResolvedAttachment(virtual_path=f"/mnt/user-data/outputs/{path.name}", actual_path=path, filename=path.name, size=7, mime_type="image/png" if image else "text/plain", is_image=image)
|
||||||
|
assert await h.channel.send_file(h.outbound(first, thread_id), attachment)
|
||||||
|
followup = await h.incoming(11, h.sent_ids[-1])
|
||||||
|
assert await h.manager._get_or_create_thread(h.client, followup) == (thread_id, False)
|
||||||
241
backend/tests/test_thread_create_idempotency.py
Normal file
241
backend/tests/test_thread_create_idempotency.py
Normal file
@ -0,0 +1,241 @@
|
|||||||
|
"""Concurrent HTTP creates preserve the first admitted thread and checkpoint."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from _router_auth_helpers import make_authed_test_app
|
||||||
|
from langchain_core.messages import HumanMessage
|
||||||
|
from langgraph.checkpoint.base import empty_checkpoint
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
|
||||||
|
from app.gateway.auth.models import User
|
||||||
|
from app.gateway.routers import threads
|
||||||
|
from deerflow.persistence.thread_meta import ThreadMetaRepository
|
||||||
|
from deerflow.persistence.thread_meta.memory import MemoryThreadMetaStore
|
||||||
|
from deerflow.runtime import goal
|
||||||
|
|
||||||
|
USER = User(id=UUID("00000000-0000-0000-0000-000000000001"), email="create@example.com", password_hash="x", system_role="admin")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(params=["memory", "sqlite"])
|
||||||
|
async def thread_store(request, tmp_path):
|
||||||
|
if request.param == "memory":
|
||||||
|
yield MemoryThreadMetaStore(InMemoryStore())
|
||||||
|
return
|
||||||
|
from deerflow.persistence.engine import close_engine, get_session_factory, init_engine
|
||||||
|
|
||||||
|
try:
|
||||||
|
await init_engine("sqlite", url=f"sqlite+aiosqlite:///{tmp_path / 'threads.db'}", sqlite_dir=str(tmp_path))
|
||||||
|
yield ThreadMetaRepository(get_session_factory())
|
||||||
|
finally:
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def app_for(store, checkpointer):
|
||||||
|
# Identity is a test double; authorization, owner filtering, the router,
|
||||||
|
# metadata store and checkpoint storage retain their production paths.
|
||||||
|
app = make_authed_test_app(user_factory=lambda: USER, bind_current_user=True)
|
||||||
|
app.state.thread_store = store
|
||||||
|
app.state.checkpointer = checkpointer
|
||||||
|
app.include_router(threads.router)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("assertion", ["metadata", "status", "checkpoint"])
|
||||||
|
async def test_racing_creates_preserve_first_thread(thread_store, monkeypatch, assertion):
|
||||||
|
first_read = asyncio.Event()
|
||||||
|
second_attempt = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
original_get = thread_store.get
|
||||||
|
original_create = thread_store.create
|
||||||
|
real_lock = threads.goal_thread_lock
|
||||||
|
missing_reads = 0
|
||||||
|
lock_attempts = 0
|
||||||
|
creates = 0
|
||||||
|
|
||||||
|
async def observed_get(thread_id, **kwargs):
|
||||||
|
nonlocal missing_reads
|
||||||
|
row = await original_get(thread_id, **kwargs)
|
||||||
|
if thread_id == "same-thread" and row is None:
|
||||||
|
missing_reads += 1
|
||||||
|
if missing_reads == 1:
|
||||||
|
first_read.set()
|
||||||
|
await release.wait()
|
||||||
|
else:
|
||||||
|
# Baseline: both requests have observed no existing row.
|
||||||
|
second_attempt.set()
|
||||||
|
return row
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_lock(thread_id):
|
||||||
|
nonlocal lock_attempts
|
||||||
|
if thread_id == "same-thread":
|
||||||
|
lock_attempts += 1
|
||||||
|
if lock_attempts == 2:
|
||||||
|
# Fixed path: observe the second lock attempt before it waits.
|
||||||
|
second_attempt.set()
|
||||||
|
async with real_lock(thread_id):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def observed_create(thread_id, **kwargs):
|
||||||
|
nonlocal creates
|
||||||
|
row = await original_create(thread_id, **kwargs)
|
||||||
|
creates += 1
|
||||||
|
if creates == 1:
|
||||||
|
await thread_store.update_status(thread_id, "busy")
|
||||||
|
await thread_store.update_display_name(thread_id, "Existing conversation")
|
||||||
|
return row
|
||||||
|
|
||||||
|
class ProgressSaver(InMemorySaver):
|
||||||
|
seed_writes = 0
|
||||||
|
|
||||||
|
async def aput(self, config, checkpoint, metadata, versions):
|
||||||
|
self.seed_writes += 1
|
||||||
|
result = await super().aput(config, checkpoint, metadata, versions)
|
||||||
|
if self.seed_writes == 1:
|
||||||
|
# Interleave a real persisted checkpoint between the initial
|
||||||
|
# seed and the late create. Model/worker execution is not claimed.
|
||||||
|
progress = empty_checkpoint()
|
||||||
|
progress["channel_values"] = {"messages": [HumanMessage(content="Keep this history")]}
|
||||||
|
progress["channel_versions"] = {"messages": 1}
|
||||||
|
await super().aput(result, progress, {"source": "update", "step": 0, "writes": {}}, {"messages": 1})
|
||||||
|
return result
|
||||||
|
|
||||||
|
saver = ProgressSaver()
|
||||||
|
monkeypatch.setattr(thread_store, "get", observed_get)
|
||||||
|
monkeypatch.setattr(thread_store, "create", observed_create)
|
||||||
|
monkeypatch.setattr(threads, "goal_thread_lock", observed_lock)
|
||||||
|
app = app_for(thread_store, saver)
|
||||||
|
tasks = []
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
try:
|
||||||
|
tasks.append(asyncio.create_task(client.post("/api/threads", json={"thread_id": "same-thread", "metadata": {"request": "first"}})))
|
||||||
|
await asyncio.wait_for(first_read.wait(), 5)
|
||||||
|
tasks.append(asyncio.create_task(client.post("/api/threads", json={"thread_id": "same-thread", "metadata": {"request": "second"}})))
|
||||||
|
await asyncio.wait_for(second_attempt.wait(), 5)
|
||||||
|
release.set()
|
||||||
|
responses = await asyncio.wait_for(asyncio.gather(*tasks), 5)
|
||||||
|
assert [response.status_code for response in responses] == [200, 200]
|
||||||
|
row = await original_get("same-thread", user_id=str(USER.id))
|
||||||
|
checkpoint = await saver.aget_tuple({"configurable": {"thread_id": "same-thread", "checkpoint_ns": ""}})
|
||||||
|
if assertion == "metadata":
|
||||||
|
assert responses[0].json()["metadata"] == responses[1].json()["metadata"] == row["metadata"]
|
||||||
|
elif assertion == "status":
|
||||||
|
assert row["status"] == "busy"
|
||||||
|
assert row["display_name"] == "Existing conversation"
|
||||||
|
else:
|
||||||
|
assert checkpoint.checkpoint["channel_values"]["messages"][0].content == "Keep this history"
|
||||||
|
assert saver.seed_writes == 1
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cancelled_waiter_keeps_other_threads_available(thread_store, monkeypatch):
|
||||||
|
attempted = asyncio.Event()
|
||||||
|
real_lock = threads.goal_thread_lock
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_lock(thread_id):
|
||||||
|
if thread_id == "held-thread":
|
||||||
|
attempted.set()
|
||||||
|
async with real_lock(thread_id):
|
||||||
|
yield
|
||||||
|
|
||||||
|
monkeypatch.setattr(threads, "goal_thread_lock", observed_lock)
|
||||||
|
saver = InMemorySaver()
|
||||||
|
app = app_for(thread_store, saver)
|
||||||
|
task = None
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
try:
|
||||||
|
async with real_lock("held-thread"):
|
||||||
|
task = asyncio.create_task(client.post("/api/threads", json={"thread_id": "held-thread"}))
|
||||||
|
await asyncio.wait_for(attempted.wait(), 5)
|
||||||
|
# A wait on this id must not stop another conversation.
|
||||||
|
other = await asyncio.wait_for(client.post("/api/threads", json={"thread_id": "other-thread"}), 5)
|
||||||
|
assert other.status_code == 200
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert await thread_store.get("held-thread", user_id=str(USER.id)) is None
|
||||||
|
retry = await asyncio.wait_for(client.post("/api/threads", json={"thread_id": "held-thread"}), 5)
|
||||||
|
assert retry.status_code == 200
|
||||||
|
finally:
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cancelled_holder_allows_create_retry(thread_store, monkeypatch):
|
||||||
|
entered = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
original_get = thread_store.get
|
||||||
|
first = True
|
||||||
|
|
||||||
|
async def held_get(thread_id, **kwargs):
|
||||||
|
nonlocal first
|
||||||
|
row = await original_get(thread_id, **kwargs)
|
||||||
|
if thread_id == "cancelled-create" and row is None and first:
|
||||||
|
first = False
|
||||||
|
entered.set()
|
||||||
|
await release.wait()
|
||||||
|
return row
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_store, "get", held_get)
|
||||||
|
saver = InMemorySaver()
|
||||||
|
app = app_for(thread_store, saver)
|
||||||
|
task = None
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
try:
|
||||||
|
task = asyncio.create_task(client.post("/api/threads", json={"thread_id": "cancelled-create"}))
|
||||||
|
await asyncio.wait_for(entered.wait(), 5)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert await original_get("cancelled-create", user_id=str(USER.id)) is None
|
||||||
|
release.set()
|
||||||
|
retry = await asyncio.wait_for(client.post("/api/threads", json={"thread_id": "cancelled-create", "metadata": {"retry": True}}), 5)
|
||||||
|
assert retry.status_code == 200
|
||||||
|
assert retry.json()["metadata"] == {"retry": True}
|
||||||
|
assert await saver.aget_tuple({"configurable": {"thread_id": "cancelled-create", "checkpoint_ns": ""}}) is not None
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_checkpoint_failure_releases_creation_lock(thread_store, monkeypatch):
|
||||||
|
saver = InMemorySaver()
|
||||||
|
original_put = saver.aput
|
||||||
|
|
||||||
|
async def failed_put(*args, **kwargs):
|
||||||
|
raise RuntimeError("injected checkpoint failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(saver, "aput", failed_put)
|
||||||
|
app = app_for(thread_store, saver)
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
failed = await client.post("/api/threads", json={"thread_id": "failed-seed", "metadata": {"keep": True}})
|
||||||
|
assert failed.status_code == 500
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
monkeypatch.setattr(saver, "aput", original_put)
|
||||||
|
# Preserve existing retry semantics for a metadata row whose initial
|
||||||
|
# seed failed; this fix does not add transactional seed recovery.
|
||||||
|
retry = await asyncio.wait_for(client.post("/api/threads", json={"thread_id": "failed-seed"}), 5)
|
||||||
|
assert retry.status_code == 200
|
||||||
|
assert retry.json()["metadata"] == {"keep": True}
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
352
backend/tests/test_thread_create_run_metadata_race.py
Normal file
352
backend/tests/test_thread_create_run_metadata_race.py
Normal file
@ -0,0 +1,352 @@
|
|||||||
|
"""The detached run metadata writer participates in thread create admission."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.store.memory import InMemoryStore
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from test_gateway_services import _stub_app_config as _base_config
|
||||||
|
from test_thread_create_idempotency import USER, app_for
|
||||||
|
from test_thread_create_idempotency import thread_store as _thread_store
|
||||||
|
|
||||||
|
from app.gateway import services
|
||||||
|
from app.gateway.routers import threads
|
||||||
|
from deerflow.runtime import RunManager, goal
|
||||||
|
from deerflow.runtime.events.store.memory import MemoryRunEventStore
|
||||||
|
from deerflow.runtime.runs.store.memory import MemoryRunStore
|
||||||
|
from deerflow.runtime.user_context import reset_current_user, set_current_user
|
||||||
|
|
||||||
|
thread_store = _thread_store
|
||||||
|
_stub_app_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("first_reader", ["run", "http"])
|
||||||
|
async def test_detached_run_metadata_and_http_create_share_admission(thread_store, monkeypatch, first_reader):
|
||||||
|
thread_id = "cross-entry-create"
|
||||||
|
first_missing = asyncio.Event()
|
||||||
|
competing_attempt = asyncio.Event()
|
||||||
|
release_first = asyncio.Event()
|
||||||
|
original_get = thread_store.get
|
||||||
|
original_create = thread_store.create
|
||||||
|
real_lock = goal.goal_thread_lock
|
||||||
|
creates = []
|
||||||
|
first_task = None
|
||||||
|
|
||||||
|
async def observed_get(identifier, **kwargs):
|
||||||
|
row = await original_get(identifier, **kwargs)
|
||||||
|
if identifier == thread_id and row is None:
|
||||||
|
if asyncio.current_task() is first_task or (first_reader == "http" and first_task is None):
|
||||||
|
first_missing.set()
|
||||||
|
await release_first.wait()
|
||||||
|
else:
|
||||||
|
# Baseline reaches the second missing read; the fixed writer
|
||||||
|
# instead reaches the lock observer before waiting for it.
|
||||||
|
competing_attempt.set()
|
||||||
|
return row
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_lock(identifier):
|
||||||
|
if identifier == thread_id and first_missing.is_set():
|
||||||
|
competing_attempt.set()
|
||||||
|
async with real_lock(identifier):
|
||||||
|
yield
|
||||||
|
|
||||||
|
saver = InMemorySaver()
|
||||||
|
|
||||||
|
async def observed_create(identifier, **kwargs):
|
||||||
|
row = await original_create(identifier, **kwargs)
|
||||||
|
creates.append(row)
|
||||||
|
if len(creates) == 1:
|
||||||
|
await thread_store.update_status(identifier, "busy")
|
||||||
|
await thread_store.update_display_name(identifier, "Existing title")
|
||||||
|
return row
|
||||||
|
|
||||||
|
monkeypatch.setattr(thread_store, "get", observed_get)
|
||||||
|
monkeypatch.setattr(thread_store, "create", observed_create)
|
||||||
|
monkeypatch.setattr(threads, "goal_thread_lock", observed_lock)
|
||||||
|
monkeypatch.setattr(services, "goal_thread_lock", observed_lock)
|
||||||
|
app = app_for(thread_store, saver)
|
||||||
|
record = await RunManager().create(thread_id, metadata={"source": "run"}, kwargs={"input": {"title": "Run title"}})
|
||||||
|
tasks = []
|
||||||
|
|
||||||
|
async def ensure_from_run():
|
||||||
|
token = set_current_user(USER)
|
||||||
|
try:
|
||||||
|
# Production run_after_metadata owns this writer as a detached
|
||||||
|
# task too. No graph/model execution is simulated by this test.
|
||||||
|
return await services._ensure_thread_metadata(SimpleNamespace(thread_store=thread_store), record, owner_user_id=None)
|
||||||
|
finally:
|
||||||
|
reset_current_user(token)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
|
||||||
|
async def post():
|
||||||
|
return await client.post("/api/threads", json={"thread_id": thread_id, "metadata": {"source": "http"}})
|
||||||
|
|
||||||
|
try:
|
||||||
|
if first_reader == "run":
|
||||||
|
first_task = asyncio.create_task(ensure_from_run())
|
||||||
|
tasks.append(first_task)
|
||||||
|
await asyncio.wait_for(first_missing.wait(), 5)
|
||||||
|
tasks.append(asyncio.create_task(post()))
|
||||||
|
else:
|
||||||
|
# HTTP executes in the middleware's child task, so leave the
|
||||||
|
# first-task marker absent until its first missing read parks.
|
||||||
|
tasks.append(asyncio.create_task(post()))
|
||||||
|
await asyncio.wait_for(first_missing.wait(), 5)
|
||||||
|
first_task = tasks[0]
|
||||||
|
tasks.append(asyncio.create_task(ensure_from_run()))
|
||||||
|
await asyncio.wait_for(competing_attempt.wait(), 5)
|
||||||
|
release_first.set()
|
||||||
|
results = await asyncio.wait_for(asyncio.gather(*tasks, return_exceptions=True), 5)
|
||||||
|
# The SQL loser may raise IntegrityError; run_after_metadata logs
|
||||||
|
# that existing non-fatal failure. The winning row stays intact.
|
||||||
|
assert all(isinstance(value, (dict, httpx.Response, IntegrityError)) for value in results)
|
||||||
|
response = next(value for value in results if isinstance(value, httpx.Response))
|
||||||
|
assert response.status_code == 200
|
||||||
|
row = await original_get(thread_id, user_id=str(USER.id))
|
||||||
|
assert len(creates) == 1
|
||||||
|
assert response.json()["metadata"] == row["metadata"] == creates[0]["metadata"]
|
||||||
|
assert row["status"] == "busy"
|
||||||
|
assert row["display_name"] == "Existing title"
|
||||||
|
finally:
|
||||||
|
release_first.set()
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_start_run_detached_writer_cannot_replace_http_thread(thread_store, monkeypatch, _stub_app_config):
|
||||||
|
"""Use native admission/task attachment; only agent execution is stubbed."""
|
||||||
|
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||||
|
|
||||||
|
missing = asyncio.Event()
|
||||||
|
release = asyncio.Event()
|
||||||
|
http_attempted = asyncio.Event()
|
||||||
|
writer_locked = False
|
||||||
|
writer_task = None
|
||||||
|
original_get = thread_store.get
|
||||||
|
original_create = thread_store.create
|
||||||
|
original_ensure = services._ensure_thread_metadata
|
||||||
|
real_lock = goal.goal_thread_lock
|
||||||
|
creations = []
|
||||||
|
|
||||||
|
async def tracked_ensure(*args, **kwargs):
|
||||||
|
nonlocal writer_task
|
||||||
|
writer_task = asyncio.current_task()
|
||||||
|
return await original_ensure(*args, **kwargs)
|
||||||
|
|
||||||
|
async def held_get(identifier, **kwargs):
|
||||||
|
row = await original_get(identifier, **kwargs)
|
||||||
|
if identifier == "native-admission" and row is None and asyncio.current_task() is writer_task:
|
||||||
|
missing.set()
|
||||||
|
await release.wait()
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def counted_create(*args, **kwargs):
|
||||||
|
row = await original_create(*args, **kwargs)
|
||||||
|
creations.append(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def service_lock(identifier):
|
||||||
|
nonlocal writer_locked
|
||||||
|
if asyncio.current_task() is writer_task:
|
||||||
|
writer_locked = True
|
||||||
|
async with real_lock(identifier):
|
||||||
|
yield
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def http_lock(identifier):
|
||||||
|
http_attempted.set()
|
||||||
|
async with real_lock(identifier):
|
||||||
|
yield
|
||||||
|
|
||||||
|
async def no_agent(*args, **kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "_ensure_thread_metadata", tracked_ensure)
|
||||||
|
monkeypatch.setattr(services, "goal_thread_lock", service_lock)
|
||||||
|
monkeypatch.setattr(threads, "goal_thread_lock", http_lock)
|
||||||
|
monkeypatch.setattr(thread_store, "get", held_get)
|
||||||
|
monkeypatch.setattr(thread_store, "create", counted_create)
|
||||||
|
monkeypatch.setattr(services, "resolve_agent_factory", lambda _name: object())
|
||||||
|
monkeypatch.setattr(services, "run_agent", no_agent)
|
||||||
|
app = app_for(thread_store, InMemorySaver())
|
||||||
|
app.state.run_manager = RunManager(store=MemoryRunStore())
|
||||||
|
app.state.stream_bridge = SimpleNamespace()
|
||||||
|
app.state.store = InMemoryStore()
|
||||||
|
app.state.run_event_store = MemoryRunEventStore()
|
||||||
|
request = SimpleNamespace(headers={}, state=SimpleNamespace(user=USER), app=app)
|
||||||
|
record = None
|
||||||
|
http_task = None
|
||||||
|
token = set_current_user(USER)
|
||||||
|
try:
|
||||||
|
record = await services.start_run(RunCreateRequest(input={"messages": [{"role": "user", "content": "hello"}]}, metadata={"source": "run"}), "native-admission", request)
|
||||||
|
await asyncio.wait_for(missing.wait(), 5)
|
||||||
|
assert writer_task is not record.task
|
||||||
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as client:
|
||||||
|
http_task = asyncio.create_task(client.post("/api/threads", json={"thread_id": "native-admission", "metadata": {"source": "http"}}))
|
||||||
|
await asyncio.wait_for(http_attempted.wait(), 5)
|
||||||
|
if not writer_locked:
|
||||||
|
# Baseline permits this HTTP create to finish while the
|
||||||
|
# detached writer still retains its earlier missing read.
|
||||||
|
await asyncio.wait_for(asyncio.shield(http_task), 5)
|
||||||
|
release.set()
|
||||||
|
await asyncio.wait_for(asyncio.shield(record.task), 5)
|
||||||
|
response = await asyncio.wait_for(http_task, 5)
|
||||||
|
assert response.status_code == 200
|
||||||
|
row = await original_get("native-admission", user_id=str(USER.id))
|
||||||
|
assert len(creations) == 1
|
||||||
|
assert response.json()["metadata"] == row["metadata"]
|
||||||
|
finally:
|
||||||
|
release.set()
|
||||||
|
tasks = [task for task in [http_task, writer_task, record.task if record is not None else None] if task is not None]
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
reset_current_user(token)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cancelled_metadata_waiter_releases_its_lock_entry(thread_store, monkeypatch):
|
||||||
|
attempted = asyncio.Event()
|
||||||
|
real_lock = goal.goal_thread_lock
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_lock(identifier):
|
||||||
|
attempted.set()
|
||||||
|
async with real_lock(identifier):
|
||||||
|
yield
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "goal_thread_lock", observed_lock)
|
||||||
|
record = await RunManager().create("cancelled-metadata")
|
||||||
|
ctx = SimpleNamespace(thread_store=thread_store)
|
||||||
|
task = None
|
||||||
|
token = set_current_user(USER)
|
||||||
|
try:
|
||||||
|
async with real_lock(record.thread_id):
|
||||||
|
task = asyncio.create_task(services._ensure_thread_metadata(ctx, record, owner_user_id=None))
|
||||||
|
await asyncio.wait_for(attempted.wait(), 5)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert await thread_store.get(record.thread_id) is None
|
||||||
|
row = await asyncio.wait_for(services._ensure_thread_metadata(ctx, record, owner_user_id=None), 5)
|
||||||
|
assert row["thread_id"] == record.thread_id
|
||||||
|
finally:
|
||||||
|
if task is not None:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(task, return_exceptions=True)
|
||||||
|
reset_current_user(token)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
@pytest.mark.parametrize("strict", [False, True], ids=["fallback", "strict"])
|
||||||
|
async def test_metadata_setup_budget_includes_create_admission(thread_store, monkeypatch, _stub_app_config, caplog, strict):
|
||||||
|
from app.gateway.routers.thread_runs import RunCreateRequest
|
||||||
|
from deerflow.runtime.runs.manager import RunStartOutcome
|
||||||
|
from deerflow.runtime.runs.schemas import RunStatus
|
||||||
|
|
||||||
|
thread_id = "metadata-admission-budget"
|
||||||
|
waiting = asyncio.Event()
|
||||||
|
metadata_created = asyncio.Event()
|
||||||
|
admit_metadata = asyncio.Event()
|
||||||
|
waiter_finished = asyncio.Event()
|
||||||
|
original_ensure = services._ensure_thread_metadata
|
||||||
|
real_lock = goal.goal_thread_lock
|
||||||
|
metadata_task = None
|
||||||
|
observed_budgets = []
|
||||||
|
agent_calls = []
|
||||||
|
|
||||||
|
async def tracked_ensure(*args, **kwargs):
|
||||||
|
nonlocal metadata_task
|
||||||
|
metadata_task = asyncio.current_task()
|
||||||
|
metadata_created.set()
|
||||||
|
await admit_metadata.wait()
|
||||||
|
return await original_ensure(*args, **kwargs)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def observed_lock(identifier):
|
||||||
|
is_metadata_task = asyncio.current_task() is metadata_task
|
||||||
|
if is_metadata_task:
|
||||||
|
waiting.set()
|
||||||
|
try:
|
||||||
|
async with real_lock(identifier):
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if is_metadata_task:
|
||||||
|
waiter_finished.set()
|
||||||
|
|
||||||
|
async def expire_metadata_budget(tasks, *, timeout, return_when):
|
||||||
|
observed_budgets.append(timeout)
|
||||||
|
# Expire only after the detached writer is waiting for admission.
|
||||||
|
# No wall-clock sleep or five-second test delay is needed.
|
||||||
|
await asyncio.wait_for(waiting.wait(), 5)
|
||||||
|
done, pending = await asyncio.wait(tasks, timeout=0, return_when=return_when)
|
||||||
|
assert not done
|
||||||
|
return done, pending
|
||||||
|
|
||||||
|
async def no_agent(_bridge, manager, record, **kwargs):
|
||||||
|
agent_calls.append((await manager.try_start(record.run_id), kwargs))
|
||||||
|
|
||||||
|
monkeypatch.setattr(services, "_ensure_thread_metadata", tracked_ensure)
|
||||||
|
monkeypatch.setattr(services, "goal_thread_lock", observed_lock)
|
||||||
|
monkeypatch.setattr(services, "asyncio", SimpleNamespace(**{**vars(asyncio), "wait": expire_metadata_budget}))
|
||||||
|
monkeypatch.setattr(services, "resolve_agent_factory", lambda _name: object())
|
||||||
|
monkeypatch.setattr(services, "run_agent", no_agent)
|
||||||
|
app = app_for(thread_store, InMemorySaver())
|
||||||
|
manager = RunManager(store=MemoryRunStore())
|
||||||
|
app.state.run_manager = manager
|
||||||
|
app.state.stream_bridge = SimpleNamespace()
|
||||||
|
app.state.store = InMemoryStore()
|
||||||
|
app.state.run_event_store = MemoryRunEventStore()
|
||||||
|
request = SimpleNamespace(headers={}, state=SimpleNamespace(user=USER), app=app)
|
||||||
|
record = None
|
||||||
|
token = set_current_user(USER)
|
||||||
|
try:
|
||||||
|
row = await thread_store.create(thread_id, user_id=str(USER.id), metadata={"source": "http"})
|
||||||
|
record = await services.start_run(RunCreateRequest(input={"messages": [{"role": "user", "content": "hello"}]}), thread_id, request, require_existing_thread=strict)
|
||||||
|
await asyncio.wait_for(metadata_created.wait(), 5)
|
||||||
|
# A same-thread operation wins admission after start_run releases its
|
||||||
|
# caller-side lock and before the detached metadata writer enters it.
|
||||||
|
async with real_lock(thread_id):
|
||||||
|
admit_metadata.set()
|
||||||
|
await asyncio.wait_for(asyncio.shield(record.task), 5)
|
||||||
|
await asyncio.wait_for(waiter_finished.wait(), 5)
|
||||||
|
assert metadata_task.cancelled()
|
||||||
|
assert observed_budgets == [services._THREAD_METADATA_SETUP_TIMEOUT_SECONDS]
|
||||||
|
assert len(agent_calls) == 1
|
||||||
|
outcome, kwargs = agent_calls[0]
|
||||||
|
assert "thread_incarnation" not in kwargs
|
||||||
|
assert outcome is (RunStartOutcome.cancelled if strict else RunStartOutcome.started)
|
||||||
|
assert record.status is (RunStatus.error if strict else RunStatus.running)
|
||||||
|
assert (await manager.get(record.run_id)).status is record.status
|
||||||
|
if strict:
|
||||||
|
assert record.error == "Timed out verifying existing thread metadata"
|
||||||
|
else:
|
||||||
|
assert "MCP access will fail closed" in caplog.text
|
||||||
|
assert "Timed out ensuring thread_meta" in caplog.text
|
||||||
|
assert await thread_store.get(thread_id) == row
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
|
# The cancelled waiter neither writes late nor prevents a later setup.
|
||||||
|
assert await services._ensure_thread_metadata(SimpleNamespace(thread_store=thread_store), record, owner_user_id=None, require_existing_thread=strict) == row
|
||||||
|
finally:
|
||||||
|
tasks = [task for task in [metadata_task, record.task if record is not None else None] if task is not None and task is not asyncio.current_task()]
|
||||||
|
for task in tasks:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
reset_current_user(token)
|
||||||
|
assert not goal._goal_locks._entries_by_loop
|
||||||
@ -1126,11 +1126,26 @@ def test_fast_path_trusted_claim_does_not_take_over_owned_row() -> None:
|
|||||||
assert final_record["user_id"] == "owner-a"
|
assert final_record["user_id"] == "owner-a"
|
||||||
|
|
||||||
|
|
||||||
def test_insert_race_concurrent_trusted_claims_have_one_winner() -> None:
|
def test_insert_race_concurrent_trusted_claims_have_one_winner(monkeypatch) -> None:
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
|
||||||
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL
|
from app.gateway.auth_disabled import AUTH_SOURCE_INTERNAL
|
||||||
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
from app.gateway.internal_auth import INTERNAL_OWNER_USER_ID_HEADER_NAME, INTERNAL_SYSTEM_ROLE
|
||||||
|
from deerflow.runtime.keyed_lock import AsyncKeyedLockTable
|
||||||
|
|
||||||
|
# These INSERTs represent separate Gateways: they share SQL rows but have
|
||||||
|
# distinct process-local lifecycle locks. Same-Gateway serialization is
|
||||||
|
# covered with the real stores in test_thread_create_idempotency.py.
|
||||||
|
worker_locks = [AsyncKeyedLockTable[str](), AsyncKeyedLockTable[str]()]
|
||||||
|
lock_calls = 0
|
||||||
|
|
||||||
|
def worker_thread_lock(thread_id):
|
||||||
|
nonlocal lock_calls
|
||||||
|
lock_calls += 1
|
||||||
|
assert lock_calls <= len(worker_locks), f"expected two simulated Gateway lock acquisitions, got {lock_calls}"
|
||||||
|
return worker_locks[lock_calls - 1].hold(thread_id)
|
||||||
|
|
||||||
|
monkeypatch.setattr(threads, "goal_thread_lock", worker_thread_lock)
|
||||||
|
|
||||||
class _ConcurrentInsertRaceStore(MemoryThreadMetaStore):
|
class _ConcurrentInsertRaceStore(MemoryThreadMetaStore):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@ -1184,6 +1199,7 @@ def test_insert_race_concurrent_trusted_claims_have_one_winner() -> None:
|
|||||||
assert final_record["user_id"] in owners
|
assert final_record["user_id"] in owners
|
||||||
assert len(failures) == 1
|
assert len(failures) == 1
|
||||||
assert failures[0].status_code == 500
|
assert failures[0].status_code == 500
|
||||||
|
assert all(not table._entries_by_loop for table in worker_locks)
|
||||||
|
|
||||||
|
|
||||||
def test_create_thread_maps_memory_owner_conflict_to_404() -> None:
|
def test_create_thread_maps_memory_owner_conflict_to_404() -> None:
|
||||||
|
|||||||
120
backend/tests/test_tool_result_meta_json_depth.py
Normal file
120
backend/tests/test_tool_result_meta_json_depth.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""JSON depth limits must not replace successful native tool results."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langchain.agents import create_agent
|
||||||
|
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||||
|
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
||||||
|
|
||||||
|
from deerflow.agents.middlewares import tool_result_meta as meta
|
||||||
|
from deerflow.agents.middlewares.tool_error_handling_middleware import ToolErrorHandlingMiddleware
|
||||||
|
from deerflow.agents.middlewares.tool_receipt import TOOL_RECEIPT_KEY
|
||||||
|
from deerflow.agents.middlewares.tool_receipt_middleware import ToolReceiptMiddleware
|
||||||
|
from deerflow.agents.thread_state import ThreadState
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status", ["success", "error"])
|
||||||
|
def test_json_depth_limit_preserves_result(monkeypatch, status):
|
||||||
|
content = '{"records": [[[0]]], "user_id": 401}'
|
||||||
|
loads = Mock(side_effect=RecursionError("parser depth limit"))
|
||||||
|
monkeypatch.setattr(meta.json, "loads", loads)
|
||||||
|
message = ToolMessage(content=content, name="custom_tool", tool_call_id="depth", status=status)
|
||||||
|
|
||||||
|
result = meta.normalize_tool_message(message)
|
||||||
|
|
||||||
|
assert result is message
|
||||||
|
assert result.content == content
|
||||||
|
assert result.tool_call_id == "depth"
|
||||||
|
assert result.status == status
|
||||||
|
assert result.additional_kwargs[meta.TOOL_META_KEY] == {
|
||||||
|
"status": status,
|
||||||
|
"error_type": "unknown" if status == "error" else None,
|
||||||
|
"recoverable_by_model": True,
|
||||||
|
"recommended_next_action": "try_alternative" if status == "error" else "continue",
|
||||||
|
"source": "tool_return" if status == "error" else "content_analysis",
|
||||||
|
}
|
||||||
|
assert loads.call_count == (2 if status == "error" else 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_second_json_probe_depth_limit_is_unknown(monkeypatch):
|
||||||
|
loads = Mock(side_effect=[{}, RecursionError("parser depth limit")])
|
||||||
|
monkeypatch.setattr(meta.json, "loads", loads)
|
||||||
|
message = ToolMessage(content='{"user_id": 401}', tool_call_id="depth", status="error")
|
||||||
|
|
||||||
|
result = meta.normalize_tool_message(message)
|
||||||
|
|
||||||
|
assert loads.call_count == 2
|
||||||
|
assert result.status == "error"
|
||||||
|
assert result.additional_kwargs[meta.TOOL_META_KEY]["error_type"] == "unknown"
|
||||||
|
assert result.additional_kwargs[meta.TOOL_META_KEY]["recommended_next_action"] == "try_alternative"
|
||||||
|
|
||||||
|
|
||||||
|
def test_producer_metadata_bypasses_depth_analysis(monkeypatch):
|
||||||
|
loads = Mock(side_effect=RecursionError("parser depth limit"))
|
||||||
|
monkeypatch.setattr(meta.json, "loads", loads)
|
||||||
|
stamp = {"status": "error", "error_type": "auth", "source": "tool_return"}
|
||||||
|
message = ToolMessage(content="[0]", tool_call_id="depth", additional_kwargs={meta.TOOL_META_KEY: stamp})
|
||||||
|
|
||||||
|
assert meta.normalize_tool_message(message).additional_kwargs[meta.TOOL_META_KEY] is stamp
|
||||||
|
loads.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name,expected", [("custom_tool", "error"), ("read_file", "success")])
|
||||||
|
def test_shallow_error_envelope_classification(name, expected):
|
||||||
|
message = ToolMessage(content='{"error": {"code": 401}}', name=name, tool_call_id="shallow")
|
||||||
|
result = meta.normalize_tool_message(message)
|
||||||
|
assert result.additional_kwargs[meta.TOOL_META_KEY]["status"] == expected
|
||||||
|
assert result.additional_kwargs[meta.TOOL_META_KEY]["error_type"] == ("auth" if expected == "error" else None)
|
||||||
|
|
||||||
|
|
||||||
|
class _ScriptedModel(GenericFakeChatModel):
|
||||||
|
def bind_tools(self, tools, **kwargs):
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("mode", ["sync", "async"])
|
||||||
|
@pytest.mark.parametrize("depth", [10, 10000])
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_native_read_file_graph_preserves_deep_json(tmp_path, monkeypatch, mode, depth):
|
||||||
|
from deerflow.sandbox import tools as sandbox_tools
|
||||||
|
from deerflow.sandbox.local.local_sandbox import LocalSandbox
|
||||||
|
|
||||||
|
# String construction avoids recursive Python fixtures. Count the deep case
|
||||||
|
# as regression coverage only when the real parser rejects this fixture.
|
||||||
|
content = "[" * depth + "0" + "]" * depth
|
||||||
|
if depth > 10:
|
||||||
|
try:
|
||||||
|
json.loads(content)
|
||||||
|
except RecursionError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
pytest.skip(f"json.loads accepts {depth} nested arrays; parser depth limit is not exercised")
|
||||||
|
path = tmp_path / "nested.json"
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
sandbox = LocalSandbox("json-depth-test")
|
||||||
|
monkeypatch.setattr(sandbox_tools, "ensure_sandbox_initialized", lambda *_args, **_kwargs: sandbox)
|
||||||
|
monkeypatch.setattr(sandbox_tools, "ensure_sandbox_initialized_async", AsyncMock(return_value=sandbox))
|
||||||
|
responses = [AIMessage(content="", tool_calls=[{"name": "read_file", "args": {"path": str(path)}, "id": "read-depth"}]), AIMessage(content="Done.")]
|
||||||
|
graph = create_agent(
|
||||||
|
_ScriptedModel(messages=iter(responses)),
|
||||||
|
tools=[sandbox_tools.read_file_tool],
|
||||||
|
middleware=[ToolReceiptMiddleware(render_mode="delegation_only"), ToolErrorHandlingMiddleware()],
|
||||||
|
state_schema=ThreadState,
|
||||||
|
)
|
||||||
|
state = {
|
||||||
|
"messages": [HumanMessage(content="Read the JSON file.")],
|
||||||
|
"sandbox": {"sandbox_id": "json-depth-test"},
|
||||||
|
"thread_data": {"outputs_path": str(tmp_path)},
|
||||||
|
"thread_directories_created": True,
|
||||||
|
}
|
||||||
|
context = {"thread_id": "json-depth-test", "user_id": "default"}
|
||||||
|
result = graph.invoke(state, context=context) if mode == "sync" else await graph.ainvoke(state, context=context)
|
||||||
|
message = next(message for message in result["messages"] if isinstance(message, ToolMessage))
|
||||||
|
assert message.content == content
|
||||||
|
assert message.tool_call_id == "read-depth"
|
||||||
|
assert message.status == "success"
|
||||||
|
assert message.additional_kwargs[meta.TOOL_META_KEY]["status"] == "success"
|
||||||
|
assert message.additional_kwargs[TOOL_RECEIPT_KEY]["status"] == "success"
|
||||||
|
assert result["messages"][-1].content == "Done."
|
||||||
170
backend/tests/test_weknora_document_concurrency.py
Normal file
170
backend/tests/test_weknora_document_concurrency.py
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
"""Bounded, request-local detail checks retain WeKnora's authorization gate."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from deerflow.knowledge import KnowledgeRequestContext
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def setup_provider(monkeypatch):
|
||||||
|
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
|
||||||
|
from deerflow_extension_weknora import WeKnoraProvider
|
||||||
|
|
||||||
|
monkeypatch.setenv("WK_CONCURRENCY_KEY", "synthetic")
|
||||||
|
|
||||||
|
def create(*, gate=False, failure=None, hold=False):
|
||||||
|
state = {"active": 0, "peak": 0, "calls": Counter(), "searches": 0}
|
||||||
|
started, release = asyncio.Event(), asyncio.Event()
|
||||||
|
|
||||||
|
async def handle(request):
|
||||||
|
assert request.headers["X-API-Key"] == "synthetic"
|
||||||
|
assert request.headers["X-Tenant-ID"] == "1"
|
||||||
|
path = request.url.path
|
||||||
|
if path == "/api/v1/knowledge-bases":
|
||||||
|
data = [{"id": "kb", "name": "Synthetic"}]
|
||||||
|
elif path.endswith("hybrid-search"):
|
||||||
|
state["searches"] += 1
|
||||||
|
data = [{"knowledge_id": f"doc-{i}", "content": f"chunk {i}"} for i in range(8)]
|
||||||
|
data.insert(1, {"knowledge_id": "doc-0", "content": "second chunk"})
|
||||||
|
else:
|
||||||
|
doc = path.rsplit("/", 1)[1]
|
||||||
|
state["calls"][doc] += 1
|
||||||
|
state["active"] += 1
|
||||||
|
state["peak"] = max(state["peak"], state["active"])
|
||||||
|
started.set()
|
||||||
|
try:
|
||||||
|
if state["active"] == 4:
|
||||||
|
release.set()
|
||||||
|
if gate:
|
||||||
|
await asyncio.wait_for(release.wait(), 2)
|
||||||
|
if failure and doc == "doc-0":
|
||||||
|
return httpx.Response(failure, json={"success": False})
|
||||||
|
if hold:
|
||||||
|
await asyncio.Event().wait()
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
data = {"id": doc, "knowledge_base_id": "kb", "parse_status": "completed", "enable_status": "enabled", "title": doc}
|
||||||
|
finally:
|
||||||
|
state["active"] -= 1
|
||||||
|
return httpx.Response(200, json={"success": True, "data": data})
|
||||||
|
|
||||||
|
provider = WeKnoraProvider({"spaces": [{"base_url": "https://weknora.invalid", "tenant_id": "1", "api_key_env": "WK_CONCURRENCY_KEY", "user_ids": ["alice"]}]}, transport=httpx.MockTransport(handle))
|
||||||
|
return provider, state, started
|
||||||
|
|
||||||
|
return create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("selected", [False, True])
|
||||||
|
async def test_details_overlap_with_a_bound_and_preserve_hit_order(setup_provider, selected):
|
||||||
|
provider, state, _ = setup_provider(gate=True)
|
||||||
|
ctx = KnowledgeRequestContext("alice", "lead_agent")
|
||||||
|
if selected:
|
||||||
|
catalog = await provider.catalog(context=ctx, page=1, page_size=20, search="")
|
||||||
|
dataset = catalog.items[0].id
|
||||||
|
ctx = KnowledgeRequestContext("alice", "lead_agent", {"version": 1, "mode": "selected", "dataset_ids": [dataset], "document_filters": [{"dataset_id": dataset, "document_ids": [f"doc-{i}" for i in range(8)]}]})
|
||||||
|
result = await provider.search_scoped("q", context=ctx)
|
||||||
|
assert state["peak"] == 4
|
||||||
|
assert state["active"] == 0
|
||||||
|
assert state["calls"] == Counter({f"doc-{i}": 1 for i in range(8)})
|
||||||
|
assert [h.content for h in result.hits] == ["chunk 0", "second chunk", *[f"chunk {i}" for i in range(1, 8)]]
|
||||||
|
assert [h.source.title for h in result.hits] == ["doc-0", "doc-0", *[f"doc-{i}" for i in range(1, 8)]]
|
||||||
|
# A second call must revalidate documents rather than reuse authorization.
|
||||||
|
await provider.search_scoped("q", context=ctx)
|
||||||
|
assert all(n == 2 for n in state["calls"].values())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("failure,error", [(403, PermissionError), (404, FileNotFoundError), (500, RuntimeError)])
|
||||||
|
async def test_failure_drains_other_detail_requests_before_returning(setup_provider, failure, error):
|
||||||
|
provider, state, _ = setup_provider(gate=True, failure=failure, hold=True)
|
||||||
|
with pytest.raises(error):
|
||||||
|
await asyncio.wait_for(provider.search_scoped("q", context=KnowledgeRequestContext("alice")), 5)
|
||||||
|
assert state["peak"] == 4
|
||||||
|
assert state["active"] == 0
|
||||||
|
assert len(state["calls"]) == 4 # Later batches never start after failure.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cancellation_drains_detail_requests(setup_provider):
|
||||||
|
provider, state, started = setup_provider(hold=True)
|
||||||
|
task = asyncio.create_task(provider.search_scoped("q", context=KnowledgeRequestContext("alice")))
|
||||||
|
await asyncio.wait_for(started.wait(), 5)
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await task
|
||||||
|
assert state["active"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_selected_document_denial_prevents_search(setup_provider):
|
||||||
|
provider, state, _ = setup_provider(gate=True, failure=403, hold=True)
|
||||||
|
catalog = await provider.catalog(context=KnowledgeRequestContext("alice"), page=1, page_size=20, search="")
|
||||||
|
dataset = catalog.items[0].id
|
||||||
|
ctx = KnowledgeRequestContext("alice", "lead_agent", {"version": 1, "mode": "selected", "dataset_ids": [dataset], "document_filters": [{"dataset_id": dataset, "document_ids": [f"doc-{i}" for i in range(8)]}]})
|
||||||
|
with pytest.raises(PermissionError):
|
||||||
|
await asyncio.wait_for(provider.search_scoped("q", context=ctx), 5)
|
||||||
|
assert state["searches"] == 0
|
||||||
|
assert state["active"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_equal_native_ids_in_different_tenants_are_revalidated(monkeypatch):
|
||||||
|
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
|
||||||
|
from deerflow_extension_weknora import WeKnoraProvider
|
||||||
|
|
||||||
|
monkeypatch.setenv("WK_ONE", "key-one")
|
||||||
|
monkeypatch.setenv("WK_TWO", "key-two")
|
||||||
|
details = []
|
||||||
|
|
||||||
|
async def handle(request):
|
||||||
|
tenant = request.headers["X-Tenant-ID"]
|
||||||
|
assert request.headers["X-API-Key"] == {"1": "key-one", "2": "key-two"}[tenant]
|
||||||
|
if request.url.path == "/api/v1/knowledge-bases":
|
||||||
|
data = [{"id": "kb", "name": tenant}]
|
||||||
|
elif request.url.path.endswith("hybrid-search"):
|
||||||
|
data = [{"knowledge_id": "same-id", "content": f"evidence {tenant}"}]
|
||||||
|
else:
|
||||||
|
details.append(tenant)
|
||||||
|
data = {"id": "same-id", "knowledge_base_id": "kb", "parse_status": "completed", "enable_status": "enabled", "title": tenant}
|
||||||
|
return httpx.Response(200, json={"success": True, "data": data})
|
||||||
|
|
||||||
|
provider = WeKnoraProvider(
|
||||||
|
{"spaces": [{"base_url": "https://weknora.invalid", "tenant_id": tenant, "api_key_env": key, "user_ids": ["alice"]} for tenant, key in [("1", "WK_ONE"), ("2", "WK_TWO")]]}, transport=httpx.MockTransport(handle)
|
||||||
|
)
|
||||||
|
result = await provider.search_scoped("q", context=KnowledgeRequestContext("alice"))
|
||||||
|
assert details == ["1", "2"]
|
||||||
|
assert [hit.source.title for hit in result.hits] == ["1", "2"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_result_cap_checks_empty_hits_but_not_the_unconsumed_suffix(monkeypatch):
|
||||||
|
monkeypatch.syspath_prepend(str(Path(__file__).resolve().parents[2] / "examples/deerflow-extension-weknora"))
|
||||||
|
from deerflow_extension_weknora import WeKnoraProvider
|
||||||
|
|
||||||
|
monkeypatch.setenv("WK_CAP_KEY", "synthetic")
|
||||||
|
details = []
|
||||||
|
|
||||||
|
async def handle(request):
|
||||||
|
path = request.url.path
|
||||||
|
if path == "/api/v1/knowledge-bases":
|
||||||
|
data = [{"id": "first", "name": "First"}, {"id": "last", "name": "Last"}]
|
||||||
|
elif path.endswith("first/hybrid-search"):
|
||||||
|
data = [{"knowledge_id": "first-doc", "content": f"chunk {i}"} for i in range(99)]
|
||||||
|
elif path.endswith("last/hybrid-search"):
|
||||||
|
data = [{"knowledge_id": "empty-doc", "content": ""}, {"knowledge_id": "last-doc", "content": "last chunk"}, {"knowledge_id": "unsafe/path", "content": "unconsumed"}]
|
||||||
|
else:
|
||||||
|
doc = path.rsplit("/", 1)[1]
|
||||||
|
details.append(doc)
|
||||||
|
data = {"id": doc, "knowledge_base_id": "first" if doc == "first-doc" else "last", "parse_status": "completed", "enable_status": "enabled", "title": doc}
|
||||||
|
return httpx.Response(200, json={"success": True, "data": data})
|
||||||
|
|
||||||
|
provider = WeKnoraProvider({"spaces": [{"base_url": "https://weknora.invalid", "tenant_id": "1", "api_key_env": "WK_CAP_KEY", "user_ids": ["alice"]}]}, transport=httpx.MockTransport(handle))
|
||||||
|
result = await provider.search_scoped("q", context=KnowledgeRequestContext("alice"))
|
||||||
|
assert len(result.hits) == 100
|
||||||
|
assert result.hits[-1].content == "last chunk"
|
||||||
|
assert details == ["first-doc", "empty-doc", "last-doc"]
|
||||||
567
backend/uv.lock
generated
567
backend/uv.lock
generated
@ -1,5 +1,5 @@
|
|||||||
version = 1
|
version = 1
|
||||||
revision = 3
|
revision = 5
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||||
@ -845,8 +845,9 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deer-flow"
|
name = "deer-flow"
|
||||||
version = "2.2.0rc0"
|
version = "2.3.0.dev0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
|
default-groups = ["dev", "extensions"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bcrypt" },
|
{ name = "bcrypt" },
|
||||||
{ name = "deerflow-extension-api" },
|
{ name = "deerflow-extension-api" },
|
||||||
@ -971,7 +972,7 @@ source = { editable = "packages/extension-api" }
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deerflow-harness"
|
name = "deerflow-harness"
|
||||||
version = "2.2.0rc0"
|
version = "2.3.0.dev0"
|
||||||
source = { editable = "packages/harness" }
|
source = { editable = "packages/harness" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "agent-client-protocol" },
|
{ name = "agent-client-protocol" },
|
||||||
@ -1167,11 +1168,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dnspython"
|
name = "dnspython"
|
||||||
version = "2.8.0"
|
version = "2.9.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560, upload-time = "2026-10-09T00:07:24.352Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822, upload-time = "2026-10-09T00:07:22.622Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1563,99 +1564,99 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio"
|
name = "grpcio"
|
||||||
version = "1.80.0"
|
version = "1.81.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
|
{ url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
|
{ url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
|
{ url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio-health-checking"
|
name = "grpcio-health-checking"
|
||||||
version = "1.80.0"
|
version = "1.81.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "grpcio" },
|
{ name = "grpcio" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/a2/aa3cc47f19c03f8e5287b987317059753141a3af8f66b96d5a64b3be10b8/grpcio_health_checking-1.80.0.tar.gz", hash = "sha256:2cc5f08bc8b816b8655ab6f59c71450063ba20766d31e21a493e912e3560c8b1", size = 17117, upload-time = "2026-03-30T08:54:41.899Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/65/46/6b6678b5a922765ae7637205bb6d0618a4da8b35f2ce6116f8bcff262370/grpcio_health_checking-1.81.1.tar.gz", hash = "sha256:ecc61480e25058a4a04e11e4ab6900ad7439b32e60a8ce4ece7d9f219221c85d", size = 17107, upload-time = "2026-06-11T12:58:49.632Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/d1/d97eb30386feff6ac2a662620e2ed68be352e9a182d62e06213db694906a/grpcio_health_checking-1.80.0-py3-none-any.whl", hash = "sha256:d804d4549cbb71e90ca2c7bf0c501060135dfd220aca8e2c54f96d3e79e210e5", size = 19125, upload-time = "2026-03-30T08:53:44.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/87/174bbfb5613794862c45b5cdf567fcfb478ad5a3a7b594e11d998656b2f9/grpcio_health_checking-1.81.1-py3-none-any.whl", hash = "sha256:cbc6a4171825ec64389de2f062d296ba129a5c27eefd0dd55fa837909184bdf9", size = 19120, upload-time = "2026-06-11T12:58:38.008Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio-tools"
|
name = "grpcio-tools"
|
||||||
version = "1.80.0"
|
version = "1.81.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "grpcio" },
|
{ name = "grpcio" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
{ name = "setuptools" },
|
{ name = "setuptools" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/94/c8/1223f29c84a143ae9a56c084fc96894de0ba84b6e8d60a26241abd81d278/grpcio_tools-1.80.0.tar.gz", hash = "sha256:26052b19c6ce0dcf52d1024496aea3e2bdfa864159f06dc7b97b22d041a94b26", size = 6133212, upload-time = "2026-03-30T08:52:39.077Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/83/b3/1c5951352d6777fd7f99a0ccee04617fdfd8a5dbf2918a1f58c8b2b280b8/grpcio_tools-1.81.1.tar.gz", hash = "sha256:a22a3870180927fdd84e2b27d079ef5b7f5f8c6110181b6736afc17a463481f1", size = 6236155, upload-time = "2026-06-11T12:51:21.235Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/b9/65929df8c9614792db900a8e45d4997fadbd1734c827da3f0eb1f2fe4866/grpcio_tools-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:d19d5a8244311947b96f749c417b32d144641c6953f1164824579e1f0a51d040", size = 2550856, upload-time = "2026-03-30T08:50:57.3Z" },
|
{ url = "https://files.pythonhosted.org/packages/3b/8a/824a9ca20bcdce8a568bb8c9f98bfeb7fad62129235e6d2ae7576fd1250a/grpcio_tools-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:353b1fafcc739c31ed42271052709595b340d34f27c459beeb78a32938305bb5", size = 2585927, upload-time = "2026-06-11T12:50:05.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/17/af1557544d68d1aeca9d9ea53ed16524022d521fec6ba334ab3530e9c1a6/grpcio_tools-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fb599a3dc89ed1bb24489a2724b2f6dd4cddbbf0f7bdd69c073477bab0dc7554", size = 5710883, upload-time = "2026-03-30T08:51:00.077Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/35/e5f9f671378b1b89a896150d3e4fa2c6ec61a5e1e9e5107ce4c140ccc931/grpcio_tools-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:768f584c2423cbeb6cb6867817a39365b987ff16b8259a3adbc6546b9e303a4e", size = 5815665, upload-time = "2026-06-11T12:50:08.178Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/48/aa9b4f7519ca972bc40d315d5c28f05ca28fa08de13d4e8b69f551b798ab/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:623ee31fc2ff7df9a987b4f3d139c30af17ce46a861ae0e25fb8c112daa32dd8", size = 2598004, upload-time = "2026-03-30T08:51:02.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/02/631b628e4072e988c669bd8f1b2406ef3c9a4cfcb2625bbf2a308a07b71d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1680b35a84f4694401819ac4acac42dda6dbc7bb8fc74112fd1a60425a07adf4", size = 2635518, upload-time = "2026-06-11T12:50:10.391Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b4/b8/b01371c119924b3beca1fe3f047b1bc2cdc66b3d37f0f3acc9d10c567a43/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b46570a68378539ee2b75a5a43202561f8d753c832798b1047099e3c551cf5d6", size = 2909568, upload-time = "2026-03-30T08:51:04.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/7c/2e3537e3ea3d1c0ddd6766cf6a7c62b487d89fb005713df2781d5f21483a/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f64e665c8ec639278ecf009beb92cbdcc5994f617c1af3d58036e1f70b1423ec", size = 2958252, upload-time = "2026-06-11T12:50:12.677Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/7c/1108f7bdb58475a7e701ec89b55eb494538b6e76acd211ba0d4cc5fd28e8/grpcio_tools-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51caf99c28999e7e0f97e9cea190c1405b7681a57bb2e0631205accd92b43fa4", size = 2660938, upload-time = "2026-03-30T08:51:06.126Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/68/14013cb2942bdac354746b643b4c37dd91906da8dce00f41c616e88bf33d/grpcio_tools-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f1ae82ad199f43448995715445cc623fb20d3882382e4be61f0da8ccb3f0e", size = 2698439, upload-time = "2026-06-11T12:50:15.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/59/d1c0063d4cd3b85363c7044ff3e5159d6d5df96e2692a9a5312d9c8cb290/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:cdaa1c9aa8d3a87891a96700cadd29beec214711d6522818d207277f6452567c", size = 3113814, upload-time = "2026-03-30T08:51:08.834Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/45/000c14c0338a7ad36054b9f17ea41842deb7841c05c067dd36cc831bc0f4/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b7b6d1e986d5923751bfe2b5cca9c4cb3d5653446e4fa4aacd438033e2dc360a", size = 3152160, upload-time = "2026-06-11T12:50:17.3Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/76/21/18d34a4efe524c903cf66b0cfa5260d81f277b6ae668b647edf795df9ce5/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3399b5fd7b59bcffd59c6b9975a969d9f37a3c87f3e3d63c3a09c147907acb0d", size = 3662793, upload-time = "2026-03-30T08:51:11.094Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/97/881930ca3967d2c8a95649bea8ebc991a7cf2331bc96679fd3600450dccc/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7f208c207aca639dcb34648d3826c38d7cf3485118fb2065117e9fc4827406b3", size = 3710468, upload-time = "2026-06-11T12:50:19.479Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/40/cf2d9295a6bd593244ea703858f8fc2efd315046ca3ef7c6f9ebc5b810fa/grpcio_tools-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9c6abc08d3485b2aac99bb58afcd31dc6cd4316ce36cf263ff09cb6df15f287f", size = 3329149, upload-time = "2026-03-30T08:51:13.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/b5/67baeba7366162652cdc1dbd962289accde07241bc8f42f6f02b305efcc6/grpcio_tools-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724ecb69af63d2f6d4ccea3e6fa0ca110ed9c5824d48c2f887c631bbb03c1c3c", size = 3370797, upload-time = "2026-06-11T12:50:21.501Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/1d/fc34b32167966df20d69429b71dfca83c48434b047a5ac4fd6cd91ca4eed/grpcio_tools-1.80.0-cp312-cp312-win32.whl", hash = "sha256:18c51e07652ac7386fcdbd11866f8d55a795de073337c12447b5805575339f74", size = 997519, upload-time = "2026-03-30T08:51:14.87Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/5d/34f2dce2125ccb107e32b57f5a9c1257edcc0793b0d2fef1e8b13a6bac3c/grpcio_tools-1.81.1-cp312-cp312-win32.whl", hash = "sha256:895a6782cec86beac71ccebb4b9848259c6f04a3028b8e42fa8d40cfe5146593", size = 1008453, upload-time = "2026-06-11T12:50:23.358Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/98/6d6563cdf51085b75f8ec24605c6f2ce84197571878ca8ab4af949c6be2d/grpcio_tools-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:ac6fdd42d5bb18f0d903a067e2825be172deff70cf197164b6f65676cb506c9b", size = 1162407, upload-time = "2026-03-30T08:51:16.793Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/be/09da8256ec8d2a5ce8a1acc51cbbc4ca52a462d78ed3412778440a56502e/grpcio_tools-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:0265fd1386b7458302f79542558345880d484f8fa92ae196c0c0268242c5f23a", size = 1174857, upload-time = "2026-06-11T12:50:25.685Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/d9/f7887a4805939e9a85d03744b66fc02575dc1df3c3e8b4d9ec000ee7a33d/grpcio_tools-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e7046837859bbfd10b01786056145480155c16b222c9e209215b68d3be13060e", size = 2550319, upload-time = "2026-03-30T08:51:19.117Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/90/5faa8b26e03495e5117f93bef8293cbada4af136362745dad7d1813ef0b0/grpcio_tools-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:3d604b4fd114b79ebb9f865bf3e04fd3ae93c704e1fad96f7fd03b0865c263b7", size = 2586071, upload-time = "2026-06-11T12:50:28.4Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/5a/c8a05b32bd7203f1b9f4c0151090a2d6179d6c97692d32f2066dc29c67a6/grpcio_tools-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a447f28958a8fe84ff0d9d3d9473868feb27ee4a9c9c805e66f5b670121cec59", size = 5709681, upload-time = "2026-03-30T08:51:21.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/9a/85dc589fa6ae2439451eaa81a1578de31e29c676980d38bef7549b8a1f45/grpcio_tools-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3389e705460efa3f3758141ba5520e6743b131c9576197c944fb9cbe49048126", size = 5813299, upload-time = "2026-06-11T12:50:31.295Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/6b/794350ed645c12c310008f97068f6a6fd927150b0d0d08aad1d909e880b1/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:75f00450e08fe648ad8a1eeb25bc52219679d54cdd02f04dfdddc747309d83f6", size = 2596820, upload-time = "2026-03-30T08:51:24.323Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/fd/c53994e58a837e6eefe48f53eb3492afc04f2b8af255df4adb37d14378f8/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8a17d8ceeb6a855fadf39f5171c80a382d97c4db98d5943eca553497fdebf84b", size = 2634668, upload-time = "2026-06-11T12:50:33.938Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/b2/b39e7b79f7c878135e0784a53cd7260ee77260c8c7f2c9e46bca8e05d017/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3db830eaff1f2c2797328f2fa86c9dcdbd7d81af573a68db81e27afa2182a611", size = 2909193, upload-time = "2026-03-30T08:51:27.025Z" },
|
{ url = "https://files.pythonhosted.org/packages/34/32/de988e86688686a2117e7ce6ce9eff4f638c929bb55b0afe60d6fbd2e45c/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:43baf71dc60fd653062da2e95e95c73b35dd130be8f9fa3d544c3af3f808a290", size = 2957930, upload-time = "2026-06-11T12:50:36.726Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/f3/abe089b058f87f9910c9a458409505cbeb0b3e1c2d993a79721d02ee6a32/grpcio_tools-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7982b5fe42f012686b667dda12916884de95c4b1c65ff64371fb7232a1474b23", size = 2660197, upload-time = "2026-03-30T08:51:29.392Z" },
|
{ url = "https://files.pythonhosted.org/packages/72/97/3f18a0ea32b5f809d21961dbd0bc382b589a4c3d501e3d67c345d5456ed3/grpcio_tools-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:136e90906af0df51ad929713244ba812d0dbb1844b4f467d5d86bdb054698f90", size = 2697760, upload-time = "2026-06-11T12:50:39.108Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/c3/3f7806ad8b731d8a89fe3c6ed496473abd1ef4c9c42c9e9a8836ce96e377/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6451b3f4eb52d12c7f32d04bf8e0185f80521f3f088ad04b8d222b3a4819c71e", size = 3113144, upload-time = "2026-03-30T08:51:31.671Z" },
|
{ url = "https://files.pythonhosted.org/packages/49/c0/dbf5cbc877290ff7504a59959a8af4fdcfdaa1e84237948405ccf1aa82a6/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd6c3bf3ea6a61eb58c54368d72ada591f2a270f3a31a32e8536e773337e76d9", size = 3151456, upload-time = "2026-06-11T12:50:41.983Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/f5/415ef205e0b7e75d2a2005df6120145c4f02fda28d7b3715b55d924fe1a4/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:258bc30654a9a2236be4ca8e2ad443e2ac6db7c8cc20454d34cce60265922726", size = 3661897, upload-time = "2026-03-30T08:51:34.849Z" },
|
{ url = "https://files.pythonhosted.org/packages/de/ea/16fe2dc83140a59e5c0a0b9dc2693dd36bfaa6bd835724b4ec66a68eab7b/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c306c307f8f74cddc4056fdbb6f1da55de087a21120efbd02bd915daa5a52fd", size = 3710469, upload-time = "2026-06-11T12:50:44.596Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/d3/2ad54764c2a9547080dd8518f4a4dc7899c7e6e747a1b1de542ce6a12066/grpcio_tools-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:865a2b8e6334c838976ab02a322cbd55c863d2eaf3c1e1a0255883c63996772a", size = 3328786, upload-time = "2026-03-30T08:51:37.265Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/7d/df987d7d81e7ad2f7516d9e9d56ff29c54dbc6d8587e425688dca9a28e49/grpcio_tools-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bdbdc927be2e0ea13c32564a72ee31d712a716fb6f8c0d53d37a77d8277c272c", size = 3370488, upload-time = "2026-06-11T12:50:47.199Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/63/23ab7db01f9630ab4f3742a2fc9fbff38b0cfc30c976114f913950664a75/grpcio_tools-1.80.0-cp313-cp313-win32.whl", hash = "sha256:f760ac1722f33e774814c37b6aa0444143f612e85088ead7447a0e9cd306a1f1", size = 997087, upload-time = "2026-03-30T08:51:39.137Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/c5/5a63444d694ea47bf670138208f71830cc1759c402c8818092b28ab2dc5f/grpcio_tools-1.81.1-cp313-cp313-win32.whl", hash = "sha256:9d383724bcd67244b6def9e9164c640ee9380c0b7534ee7545a6fb0022a59afe", size = 1008229, upload-time = "2026-06-11T12:50:49.527Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9b/af/b1c1c4423fb49cb7c8e9d2c02196b038c44160b7028b425466743c6c81fa/grpcio_tools-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:7843b9ac6ff8ca508424d0dd968bd9a1a4559967e4a290f26be5bd6f04af2234", size = 1162167, upload-time = "2026-03-30T08:51:41.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/75/3945e26d5c94ae6ed9be5caef73d4d66c47dc8cfdd7b4995efaf942754e0/grpcio_tools-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:f3eb15849979ca7bb864ce81a74d68b0f225a7f111ed3fe212bfc08cf9812b10", size = 1174523, upload-time = "2026-06-11T12:50:51.755Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/44/7beeee2348f9f412804f5bf80b7d13b81d522bf926a338ae3da46b2213b7/grpcio_tools-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:12f950470449dbeec78317dbc090add7a00eb6ca812af7b0538ab7441e0a42c3", size = 2550303, upload-time = "2026-03-30T08:51:44.373Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/08/e581ad42ae517a61172285047e4d710e2ac75f2f1915f7c91f284254e6d5/grpcio_tools-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:7d168ea26390717d0462c0d0408331dc98a60fc7f7e6118afac9b73f5a66d87c", size = 2585944, upload-time = "2026-06-11T12:50:54.528Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/aa/f77dd85409a1855f8c6319ffc69d81e8c3ffe122ee3a7136653e1991d8b6/grpcio_tools-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d3f9a376a29c9adf62bb56f7ff5bc81eb4abeaf53d1e7dde5015564832901a51", size = 5709778, upload-time = "2026-03-30T08:51:47.112Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/c8/200d90ebad685af7eea5ff7e0360c504dd01ec053fe0f1f9c4abe3ea2d5a/grpcio_tools-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:43c528655b226375013036692d8db4cd59060c1f41dd62c77f4d17b69f6ce828", size = 5813492, upload-time = "2026-06-11T12:50:57.291Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/7c/ab7af4883ebdfdc228b853de89fed409703955e8d47285b321a5794856bd/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1ba1ffbf2cff71533615e2c5a138ed5569611eec9ae7f9c67b8898e127b54ac0", size = 2597928, upload-time = "2026-03-30T08:51:49.494Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/c0/60da2a1af37aa8eb47308cec24d9f7709a8976fdec3a53fd35b56b358326/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a9c6fcc68c9d5a208967bfe4fd3224d3c3be9a950c3e827e8f4b17e15c2dc555", size = 2634991, upload-time = "2026-06-11T12:50:59.767Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/e8/4381a963d472e3ab6690ba067ed2b1f1abf8518b10f402678bd2dcb79a54/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:13f60f8d9397c514c6745a967d22b5c8c698347e88deebca1ff2e1b94555e450", size = 2909333, upload-time = "2026-03-30T08:51:52.124Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/7f/dede28b579ae9bf9079ba1aa913e8088d1dc0cdbe21c85caa22f0790cad2/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:a987c85dcbe1b32066d7acd46266d1a428aecbd629331bf5b853e74c835bf876", size = 2957913, upload-time = "2026-06-11T12:51:02.31Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/cb/356b5fdf79dd99455b425fb16302fe60995554ceb721afbf3cf770a19208/grpcio_tools-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:88d77bad5dd3cd5e6f952c4ecdd0ee33e0c02ecfc2e4b0cbee3391ac19e0a431", size = 2660217, upload-time = "2026-03-30T08:51:55.066Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/38/4de2118adb58ec7ffba65ec623b5836db769665c192517cbf187db3f6145/grpcio_tools-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a882382507bb5ec6d7edc9648053dfd3bc8f9285cde56a6fa9b9a83b4bd07f1c", size = 2697709, upload-time = "2026-06-11T12:51:05.016Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/d7/1752018cc2c36b2c5612051379e2e5f59f2dbe612de23e817d2f066a9487/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:017945c3e98a4ed1c4e21399781b4137fc08dfc1f802c8ace2e64ef52d32b142", size = 3113896, upload-time = "2026-03-30T08:51:57.3Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/e1/762ced51059e4f694fd337ecae491581d42a4e61dcb0415d8c5c60e6ddcb/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7746e508d4239a02f7e93638be5bc0ebb0120ddb796f7506aaae9d47a4599d97", size = 3151884, upload-time = "2026-06-11T12:51:07.593Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/17/695bbe454f70df35c03e22b48c5314683b913d3e6ed35ec90d065418c1ab/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a33e265d4db803495007a6c623eafb0f6b9bb123ff4a0af89e44567dad809b88", size = 3661950, upload-time = "2026-03-30T08:51:59.867Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/d8/9823090dc801e7229944874e7429c3b98e741ac778d8dc373f60240e1c43/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fc3d2a41a7a4467fa03b391394fffada9291fe8feebc8679b526f6bc36942b25", size = 3710404, upload-time = "2026-06-11T12:51:10.172Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/d0/533d87629ec823c02c9169ee20228f734c264b209dcdf55268b5a14cde0a/grpcio_tools-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c129da370c5f85f569be2e545317dda786a60dd51d7deea29b03b0c05f6aac3", size = 3328755, upload-time = "2026-03-30T08:52:02.942Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/4e/4eae98d02148cb6f9f452f09942afba407afa6851e6c1fddc5ae9ec0b4ed/grpcio_tools-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:21bb3ba90e6d8df1ff663d4ee39a4e5b25a64e8ed4902476ca9ded0954d3917a", size = 3370525, upload-time = "2026-06-11T12:51:12.678Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/a1/504d7838770c73a9761e8a8ff4869dba1146b44f297ff0ac6641481942d3/grpcio_tools-1.80.0-cp314-cp314-win32.whl", hash = "sha256:25742de5958ae4325249a37e724e7c0e5120f8e302a24a977ebd1737b48a5e97", size = 1019620, upload-time = "2026-03-30T08:52:05.342Z" },
|
{ url = "https://files.pythonhosted.org/packages/e0/3e/2206e597a128da6a03a6106d2eaf2c3e72c7d80843d4be933e3a3d10d02a/grpcio_tools-1.81.1-cp314-cp314-win32.whl", hash = "sha256:3dca56016d90a710c4d9861bae793dc089c1430a90c79ce672e948ddb65fa539", size = 1030582, upload-time = "2026-06-11T12:51:14.906Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/75/8b7cd281c5cdfb4ca2c308f7e9b2799bab2be6e7a9e9212ea5a82e2aecd4/grpcio_tools-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:bbf8eeef78fda1966f732f79c1c802fadd5cfd203d845d2af4d314d18569069c", size = 1194210, upload-time = "2026-03-30T08:52:08.105Z" },
|
{ url = "https://files.pythonhosted.org/packages/cf/f2/bbeef86c687225b7bbc7c0acdfbd25c8bcaa3f5b1c941db053e5c3d9e859/grpcio_tools-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:cb08172b7b629e75cb33866928d319a3196540a725eaab628ba721007140f1af", size = 1207490, upload-time = "2026-06-11T12:51:17.598Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2063,16 +2064,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-anthropic"
|
name = "langchain-anthropic"
|
||||||
version = "1.4.1"
|
version = "1.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/19/55e0d3548a4d85ccee630fcfc0979af54ff4ea4f39ab0ed1ed3c6bf25f4e/langchain_anthropic-1.4.1.tar.gz", hash = "sha256:e17d027091438620e35ff2f06aefdfd63c8dcdf6abc606009ddfe3c764f2bc2e", size = 676043, upload-time = "2026-04-17T14:26:17.31Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/f5/cd397b94aeed5fa0e8ab9595b9fb578ac99f424d42220defe6626e6a1a7b/langchain_anthropic-1.4.6.tar.gz", hash = "sha256:78942d4458d883b7d362438a095ed501ed84f44d402622404482481fc973b9da", size = 706540, upload-time = "2026-06-12T16:54:15.352Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/0a/20625dfea38a26e8b43654e18cafbc3595e7dc223da80f23d4fa8451dc1d/langchain_anthropic-1.4.1-py3-none-any.whl", hash = "sha256:5a48afbb2b1bad9c46badaccc8e23b0dd7ae07b7583f76bac21ddb3dac831efd", size = 49020, upload-time = "2026-04-17T14:26:15.989Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/af/927dbbc5a1f5fea1a69adc2883f034cbd1430004e36f4eacd302d500393a/langchain_anthropic-1.4.6-py3-none-any.whl", hash = "sha256:dbd412a956b6b8b0716d9d8460ef71f834a6731cdbfc59e6160482a4a9fb5200", size = 51797, upload-time = "2026-06-12T16:54:14.159Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2211,7 +2212,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.9"
|
version = "1.2.14"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@ -2221,16 +2222,17 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "xxhash" },
|
{ name = "xxhash" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/41/4b/0d1130e26b41a99dcc88353bbe7162a1f255c4db746bd94024268e6af27b/langgraph-1.2.9.tar.gz", hash = "sha256:385f87bc1802c35af7e0aa479278ecba8582d103515eb48256cb2ddcd42d0bd4", size = 722869, upload-time = "2026-07-10T01:30:14.985Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/49/3f/6e60789eab2c5ceaf34ba2c626ac345df93b026bbe7385018aa4b9c30397/langgraph-1.2.14.tar.gz", hash = "sha256:5b1a72b7a7f994ac6947b7751fbbe00e949f8bb44d9ab78881604b703247ae55", size = 752793, upload-time = "2026-10-06T14:41:19.789Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/16/0b8dc48823f1326f3e0c8012a3c07a40da6f194299e2ec080df236287baf/langgraph-1.2.9-py3-none-any.whl", hash = "sha256:c2d98ad94333937922ba04148641c1da2bfe45b5b8e55d7b6dcb0bb2df809e76", size = 247473, upload-time = "2026-07-10T01:30:13.733Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/4a/f976df0d3cf7fb7986dc39f656c6b7b5655834430e9bdfaef1e1e4fffaba/langgraph-1.2.14-py3-none-any.whl", hash = "sha256:e3f5f8ff4259f9281b73ff50fc9ef5cc3b025b8936b8283ecdca7cc0c4128690", size = 255980, upload-time = "2026-10-06T14:41:18.129Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-api"
|
name = "langgraph-api"
|
||||||
version = "0.10.0"
|
version = "0.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
{ name = "cloudpickle" },
|
{ name = "cloudpickle" },
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "grpcio" },
|
{ name = "grpcio" },
|
||||||
@ -2248,8 +2250,10 @@ dependencies = [
|
|||||||
{ name = "langsmith", extra = ["otel"] },
|
{ name = "langsmith", extra = ["otel"] },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
{ name = "opentelemetry-exporter-otlp-proto-http" },
|
||||||
|
{ name = "opentelemetry-exporter-prometheus" },
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
|
{ name = "prometheus-client" },
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "sse-starlette" },
|
{ name = "sse-starlette" },
|
||||||
@ -2264,9 +2268,9 @@ dependencies = [
|
|||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
{ name = "zstandard" },
|
{ name = "zstandard" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/17/a23ecdad3de7e48209bc8d534312ba83e332126ec60da9e6487461b02597/langgraph_api-0.10.0.tar.gz", hash = "sha256:f594a160857e2cd7fb2352bba585111177a4a394625ad0cf5898814754fb7bc1", size = 713903, upload-time = "2026-06-11T14:36:50.469Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/92/1a/d9a8047905677d9740d83f4ee3dbf5880e054740b229b5d2962f1c11c034/langgraph_api-0.15.0.tar.gz", hash = "sha256:2cea18a0fdb26636382bdb6968f0a9d269f21e13fc4f741195d9cd4c17bbb759", size = 825569, upload-time = "2026-09-25T14:49:35.557Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0e/38/6b0cdcaaed9b3105102b452a5a70fb08894a9511458cf00c061c4725347c/langgraph_api-0.10.0-py3-none-any.whl", hash = "sha256:f4b545bf1936c4e90bed1cc07a6852b1be37e1667007ab4736d800cc18e9f9b3", size = 577475, upload-time = "2026-06-11T14:36:48.838Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/04/5829323d4fe69ec79ae196788ae533f830424c93018a092c67ad3eb009e1/langgraph_api-0.15.0-py3-none-any.whl", hash = "sha256:5d923b0417a1e8df817e6a308492a9a0941bb99543f0ef131965cf6c6e872172", size = 637444, upload-time = "2026-09-25T14:49:34.151Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2342,7 +2346,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-runtime-inmem"
|
name = "langgraph-runtime-inmem"
|
||||||
version = "0.30.0"
|
version = "0.35.10"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "blockbuster" },
|
{ name = "blockbuster" },
|
||||||
@ -2353,14 +2357,14 @@ dependencies = [
|
|||||||
{ name = "starlette" },
|
{ name = "starlette" },
|
||||||
{ name = "structlog" },
|
{ name = "structlog" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/98/19/5d330e22e3ee2569742d3577b4ddfbb6b8e383f2edd9724b4a4b73735717/langgraph_runtime_inmem-0.30.0.tar.gz", hash = "sha256:31954e1de6cd43c284b425243d4faeae818f68b3daca4dbe4313e651b6f5e11d", size = 129940, upload-time = "2026-06-11T14:22:46.985Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e1/b9/717000dc56ae3419807fc26fe0ec5af105bd779ffecf24091621517bfca8/langgraph_runtime_inmem-0.35.10.tar.gz", hash = "sha256:09032079d65fce5ba5ec686471e97bfaa459e5a5834b2abe627b09c2db6af286", size = 133751, upload-time = "2026-10-27T19:35:04.989Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/df/cfb15c70fa4dcab9ea48f8fda9f4ed87d1767eea6e4d2901dc704da297ba/langgraph_runtime_inmem-0.30.0-py3-none-any.whl", hash = "sha256:08a36b83b5e32fe6e30cb25062a1e671a42e2a0206e4043cc95f41198dcbbb12", size = 51762, upload-time = "2026-06-11T14:22:46.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/c5/004c79b1181589cab3f9a44178f02f14b10ad62420caf5aea02816bbe9db/langgraph_runtime_inmem-0.35.10-py3-none-any.whl", hash = "sha256:568526bbbc149438b8bb37d6472b5f81d7b84543fdf52390617c0514b17db2b2", size = 52997, upload-time = "2026-10-27T19:35:03.226Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-sdk"
|
name = "langgraph-sdk"
|
||||||
version = "0.4.4"
|
version = "0.4.10"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@ -2369,9 +2373,9 @@ dependencies = [
|
|||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0c/33/dd9cd2218197debafee7b305a975355afa1c14278200019122d11717ce96/langgraph_sdk-0.4.10.tar.gz", hash = "sha256:d8d7afd326b4df42ddc0878b7fbf53f03c3237c664c236bf5bac4ab16fc38ed9", size = 360685, upload-time = "2026-10-26T19:59:45.313Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/0c/c14a178c018fffdb818a7e098cb28d6ecead53a07e161a03c083003ff137/langgraph_sdk-0.4.10-py3-none-any.whl", hash = "sha256:306ede340814e79332812e3b5a8bda2587a09657c95e14e5c7af3370e06c22fe", size = 167854, upload-time = "2026-10-26T19:59:44.075Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2795,101 +2799,140 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "multidict"
|
name = "multidict"
|
||||||
version = "6.7.1"
|
version = "6.9.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" },
|
{ url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" },
|
{ url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" },
|
{ url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" },
|
{ url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" },
|
{ url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" },
|
{ url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" },
|
{ url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" },
|
{ url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" },
|
{ url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3076,80 +3119,63 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.45.1"
|
version = "1.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/1c/125e1c936c0873796771b7f04f6c93b9f1bf5d424cea90fda94a99f61da8/opentelemetry_api-1.42.1.tar.gz", hash = "sha256:56c63bea9f77b62856be8c47600474acad853b2924b99b1687c4cb6297166716", size = 72296, upload-time = "2026-05-21T16:32:49.335Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/ca/9520cc1f3dfbbd03ac5903bbf55833e257bc64b1cf30fa8b0d6df374d821/opentelemetry_api-1.42.1-py3-none-any.whl", hash = "sha256:51a69edacadbc03a8950ace1c4c21099cacc538820ac2c9e36277e78cebba714", size = 61311, upload-time = "2026-05-21T16:32:28.822Z" },
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opentelemetry-exporter-http-transport"
|
|
||||||
version = "0.66b1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "opentelemetry-api" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/62/0c/e3ebdb4b507f66afcc905e6885a4946969bd75b45988492643356fbbdc63/opentelemetry_exporter_http_transport-0.66b1.tar.gz", hash = "sha256:443080203bf52586ce0b2ad901e8951c61833eab1aa539ae6f1f16fe9e8e7952", size = 11693, upload-time = "2026-10-06T17:32:59.65Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/69/6af86ff66492b481c6a4c05dcfd68beb47ed8ba046440a26a2aac76b95c7/opentelemetry_exporter_http_transport-0.66b1-py3-none-any.whl", hash = "sha256:2f95404bdee7f9d2d529c7de56c7bd86d014d774d8fbf137810e0167f8a492bf", size = 12155, upload-time = "2026-10-06T17:32:35.454Z" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
requests = [
|
|
||||||
{ name = "requests" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opentelemetry-exporter-otlp-common"
|
|
||||||
version = "0.66b1"
|
|
||||||
source = { registry = "https://pypi.org/simple" }
|
|
||||||
dependencies = [
|
|
||||||
{ name = "opentelemetry-sdk" },
|
|
||||||
]
|
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/19/41de712173f43057e4532d42ece7d0c6d4210d353e5752433cb14987643f/opentelemetry_exporter_otlp_common-0.66b1.tar.gz", hash = "sha256:6b1403487a2185ac1feb45fd5546fdf8630ce71c36bcefaadf51e2130e9e23f9", size = 14325, upload-time = "2026-10-06T17:33:01.725Z" }
|
|
||||||
wheels = [
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/39/8c23d67665c762aa51840fa06f86e902e8f6f1693bc8d7e3d98cd6e2f753/opentelemetry_exporter_otlp_common-0.66b1-py3-none-any.whl", hash = "sha256:00ff8592c3a7cb729ff3fdc7ffa12372c243bdf2163e80c180994d0c7bd83ee9", size = 12385, upload-time = "2026-10-06T17:32:38.177Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-exporter-otlp-proto-common"
|
name = "opentelemetry-exporter-otlp-proto-common"
|
||||||
version = "1.45.1"
|
version = "1.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "opentelemetry-proto" },
|
{ name = "opentelemetry-proto" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/8e/65e85e5137991a3c493b11682151d198638a5bc1dd4b4c5f67e013c57d7c/opentelemetry_exporter_otlp_proto_common-1.45.1.tar.gz", hash = "sha256:2e4adcc3a67bcf57804fc49514f0ef64974ca7590aa3491da389852b4a0628f6", size = 18873, upload-time = "2026-10-06T17:33:04.471Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0e/9c/216acfeaedadf2e1937f4373929b20f73197c5c4a2546d4f584b7fa63813/opentelemetry_exporter_otlp_proto_common-1.42.1.tar.gz", hash = "sha256:04f1f01fb597c4249dfcd7f8b861c902c2102369d376d9d346ff38de4469a2ee", size = 21433, upload-time = "2026-05-21T16:32:55.526Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/aa/92f225d353904e7f70b8b3e3c1b02db0cf56f744c2e83c581dc372e78873/opentelemetry_exporter_otlp_proto_common-1.45.1-py3-none-any.whl", hash = "sha256:2f446183ae7047b036226f1d846c41a834b0e8755ad13b51a51dd38952eb466c", size = 15393, upload-time = "2026-10-06T17:32:41.911Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/43/2375e7612e1121a4518c17603b6e0b03ad94f565aafad53f464dc5be2bf6/opentelemetry_exporter_otlp_proto_common-1.42.1-py3-none-any.whl", hash = "sha256:f48d395ab815b444da118868977e9798ea354c25737d5cf39578ae894011c140", size = 17327, upload-time = "2026-05-21T16:32:33.387Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-exporter-otlp-proto-http"
|
name = "opentelemetry-exporter-otlp-proto-http"
|
||||||
version = "1.45.1"
|
version = "1.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "googleapis-common-protos" },
|
{ name = "googleapis-common-protos" },
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "opentelemetry-exporter-http-transport", extra = ["requests"] },
|
|
||||||
{ name = "opentelemetry-exporter-otlp-common" },
|
|
||||||
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
{ name = "opentelemetry-exporter-otlp-proto-common" },
|
||||||
{ name = "opentelemetry-proto" },
|
{ name = "opentelemetry-proto" },
|
||||||
{ name = "opentelemetry-sdk" },
|
{ name = "opentelemetry-sdk" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/17/26487707ea4caa97b17e6e4b5fa72133a53512ffa2f5cf7a49ef284b29cb/opentelemetry_exporter_otlp_proto_http-1.45.1.tar.gz", hash = "sha256:45c218405ce3fd879596924b1874bf9a8f6880206d61065c5a912c8e5c297fb7", size = 28839, upload-time = "2026-10-06T17:33:05.713Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/77/32/826bfa1d80ecea24f47808de03cd4a0d13c17ecc07712f45123f0f61e4ac/opentelemetry_exporter_otlp_proto_http-1.42.1.tar.gz", hash = "sha256:bf142a21035d7571ac3a09cb2e5639f49886f243972883cfe777ed3bf02b734d", size = 25406, upload-time = "2026-05-21T16:32:56.807Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/1f/517eaa0187ba106a9da97160ce2add3a371812681dc440930b267f714e42/opentelemetry_exporter_otlp_proto_http-1.45.1-py3-none-any.whl", hash = "sha256:24a97cf3753c7fb52fad44a696e452ff371686339e2acf3309e2eda3d0230700", size = 22180, upload-time = "2026-10-06T17:32:43.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/96/82cb223a1502f0787d4bbff12907f5f8d870a50731febcd5818d93ef9555/opentelemetry_exporter_otlp_proto_http-1.42.1-py3-none-any.whl", hash = "sha256:00a16da1b312a1d6c7233d600d557c91df71125af73020f3b9a7765bd699d59d", size = 21793, upload-time = "2026-05-21T16:32:35.277Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "opentelemetry-exporter-prometheus"
|
||||||
|
version = "0.63b1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "opentelemetry-api" },
|
||||||
|
{ name = "opentelemetry-sdk" },
|
||||||
|
{ name = "prometheus-client" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/2a/dfeddff262b12eff0c72f4ad9e258aab8889f48c4dc1417a0377a13bc427/opentelemetry_exporter_prometheus-0.63b1.tar.gz", hash = "sha256:31902e22c89431058a95b6dcdb644f9309f226aa4872cc755f0a780d2895e97f", size = 15234, upload-time = "2026-05-21T16:32:57.797Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/ec/d7c7435e9000fb69837cf7753b7cbbbdeb5d0585203daf1b6ebf8fa93e02/opentelemetry_exporter_prometheus-0.63b1-py3-none-any.whl", hash = "sha256:0efd00aa6b1939345ddcc6de141b83ebffa2b4401a37a68f880e54217602701d", size = 12466, upload-time = "2026-05-21T16:32:36.622Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-instrumentation"
|
name = "opentelemetry-instrumentation"
|
||||||
version = "0.66b1"
|
version = "0.63b1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
@ -3157,48 +3183,48 @@ dependencies = [
|
|||||||
{ name = "packaging" },
|
{ name = "packaging" },
|
||||||
{ name = "wrapt" },
|
{ name = "wrapt" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/03/89e47ff8d52a4f83b343e6eb9ef1698ff45357216e5b6b2b21e0da5c5c7d/opentelemetry_instrumentation-0.66b1.tar.gz", hash = "sha256:e79a510f7d87c72d95e964ddb42193a0d9a75668c027d980eab032ea1322a5ce", size = 43308, upload-time = "2026-10-06T17:36:10.703Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/da/6d/4de72d97ff54db1ed270c7a59c9b904b917c0ac7af429c086c388b824ddb/opentelemetry_instrumentation-0.63b1.tar.gz", hash = "sha256:32368d6ae52c8de20aa790a6ad86b10a76f09956092337ae37d675773990e541", size = 41081, upload-time = "2026-05-21T16:36:14.206Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/b2/d1413681ff43e13ac9860df27e1226d3199ab0b97b352ceea41abcc660a5/opentelemetry_instrumentation-0.66b1-py3-none-any.whl", hash = "sha256:4c4aa14dc9a24a02325a9d4c42c4d0208dbb1374c2b1b8fe6c9392d59f3e1008", size = 36904, upload-time = "2026-10-06T17:35:11.663Z" },
|
{ url = "https://files.pythonhosted.org/packages/35/a1/9314e621c143e4d82a5bf7a43c2ff7a745d31023506336857607c8c543cc/opentelemetry_instrumentation-0.63b1-py3-none-any.whl", hash = "sha256:f1986716d52cc316ea5f60189098726a9071d8ecc0eee96c9ed110be08bade9c", size = 35577, upload-time = "2026-05-21T16:34:56.818Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-proto"
|
name = "opentelemetry-proto"
|
||||||
version = "1.45.1"
|
version = "1.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/7f/15f014fb195da6c2dbb6c71399b8e76824878718e94de6454038488eed28/opentelemetry_proto-1.45.1.tar.gz", hash = "sha256:79e0fb95e4616691a469439238aa9224d75779b3e108e895d1aa125ab29ca77c", size = 46488, upload-time = "2026-10-06T17:33:11.49Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/55/63eac3e1089b768ba014091fdd2ae8a9a440c821ef5e2b786909c94c8836/opentelemetry_proto-1.42.1.tar.gz", hash = "sha256:c6a51e6b4f05ae63565f3a113217f3d2bfaec68f78c02d7a6c85f9010d1cfca6", size = 45839, upload-time = "2026-05-21T16:33:03.937Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ab/9a/42ec8180a769516ae757e893b69736826efceac7332553915b4528a91c6d/opentelemetry_proto-1.45.1-py3-none-any.whl", hash = "sha256:f38e2a8413053c180cd3d2637fbb279673ec2f6a6e09c995aafa2f452c52b46e", size = 72488, upload-time = "2026-10-06T17:32:53.057Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/9d/171c02c84a76940b7e601805b3bb536985aded9168fbcc9ba52f0a730fa2/opentelemetry_proto-1.42.1-py3-none-any.whl", hash = "sha256:dedb74cba2886c59c7789b227a7a670613025a07489040050aedff6e5c0fb43c", size = 71782, upload-time = "2026-05-21T16:32:44.867Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-sdk"
|
name = "opentelemetry-sdk"
|
||||||
version = "1.45.1"
|
version = "1.42.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "opentelemetry-semantic-conventions" },
|
{ name = "opentelemetry-semantic-conventions" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324, upload-time = "2026-10-06T17:33:13.26Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/f7/b390bd9bfd703bf98a68fea1f27786c6872331fd617164a54b8a59bdc008/opentelemetry_sdk-1.42.1.tar.gz", hash = "sha256:8c834e8f8c9ba4171d4ec843d0cb8a67e4c7394d3f9e9297e582cbd9456ddbf7", size = 239262, upload-time = "2026-05-21T16:33:04.641Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063, upload-time = "2026-10-06T17:32:55.04Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/6b/4287766cfbde577ae2272e8884abac325aeaac0d64f41c61d5b8cc595105/opentelemetry_sdk-1.42.1-py3-none-any.whl", hash = "sha256:083cd4bbfaa5aa7b5a9e552430d9951219967cfb27aa61feb13a77aba1fc839d", size = 170907, upload-time = "2026-05-21T16:32:45.894Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-semantic-conventions"
|
name = "opentelemetry-semantic-conventions"
|
||||||
version = "0.66b1"
|
version = "0.63b1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "opentelemetry-api" },
|
{ name = "opentelemetry-api" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250, upload-time = "2026-10-06T17:33:14.073Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/93/99/4d7dd6df64795951413ce6e815f8cf1eb191daf7196ae86574589643d5f3/opentelemetry_semantic_conventions-0.63b1.tar.gz", hash = "sha256:3daf963611334b365e98a57438183eb012d3bfb40b2d931a9af613476b8701a9", size = 148340, upload-time = "2026-05-21T16:33:05.455Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279, upload-time = "2026-10-06T17:32:56.103Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/7a/7fe66f5f3682b1dd47d88cc4e11f1c6c0966b737de2d16671146e23c39a5/opentelemetry_semantic_conventions-0.63b1-py3-none-any.whl", hash = "sha256:dfe5ef4dee82586b746f522b818ceb298d00b3d59f660042bd79404bff8d0682", size = 203713, upload-time = "2026-05-21T16:32:47.016Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -3553,6 +3579,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/67/78/882563dc554c5f710b4e47c58978586c2528703435800a7a84e81b339d90/primp-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:6513dcb55e6b511c1ae383a3a0e887929b09d8d6b2c70e2b188b425c51471a02", size = 3874087, upload-time = "2026-04-20T08:33:58.109Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/78/882563dc554c5f710b4e47c58978586c2528703435800a7a84e81b339d90/primp-1.2.3-cp314-cp314t-win_arm64.whl", hash = "sha256:6513dcb55e6b511c1ae383a3a0e887929b09d8d6b2c70e2b188b425c51471a02", size = 3874087, upload-time = "2026-04-20T08:33:58.109Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "prometheus-client"
|
||||||
|
version = "0.26.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/52/73/f1334c29c2af4cd9dba6c7817e61b611bd0215e2eb5565c6064a4de18802/prometheus_client-0.26.0.tar.gz", hash = "sha256:04a91bcf94e2cf74a44a1a874d651a2e853ed354b6e822f3b7487751465d5c2b", size = 92910, upload-time = "2026-07-24T19:36:41.893Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/a3/b69efbf4143b5b9859b977770bbbabcc2796b702fa69dc40271e45cd5a56/prometheus_client-0.26.0-py3-none-any.whl", hash = "sha256:fa93d06737aa02bacd05794768508bb97d2fbee28cb3bca04eaae92f0ca953d6", size = 64494, upload-time = "2026-07-24T19:36:40.854Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "prompt-toolkit"
|
name = "prompt-toolkit"
|
||||||
version = "3.0.52"
|
version = "3.0.52"
|
||||||
@ -4653,8 +4688,8 @@ name = "standard-aifc"
|
|||||||
version = "3.13.0"
|
version = "3.13.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
{ name = "audioop-lts" },
|
||||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
{ name = "standard-chunk" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@ -4885,24 +4920,54 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid-utils"
|
name = "uuid-utils"
|
||||||
version = "0.14.1"
|
version = "0.19.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/7b/d1/38a573f0c631c062cf42fa1f5d021d4dd3c31fb23e4376e4b56b0c9fbbed/uuid_utils-0.14.1.tar.gz", hash = "sha256:9bfc95f64af80ccf129c604fb6b8ca66c6f256451e32bc4570f760e4309c9b69", size = 22195, upload-time = "2026-02-20T22:50:38.833Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f1/0f/cd9b3e495e514441e9af3a42c5c63436aee620f61412cafcce67e259228b/uuid_utils-0.19.0.tar.gz", hash = "sha256:4b2996bb9d12da9b62f6f4c64a066b26926fa75dbe47b4f8e309e24e187abf1d", size = 43343, upload-time = "2026-10-26T10:55:23.821Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/b7/add4363039a34506a58457d96d4aa2126061df3a143eb4d042aedd6a2e76/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:93a3b5dc798a54a1feb693f2d1cb4cf08258c32ff05ae4929b5f0a2ca624a4f0", size = 604679, upload-time = "2026-02-20T22:50:27.469Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/ce/78f9d79c820b8afc9b0498b0a5f5ef87ea970a19cc382127087f36cd4839/uuid_utils-0.19.0-cp310-abi3-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:84929d8c594381c6f1444afa2a25cce9ff7edac99d35e071ebc48275bae83730", size = 574701, upload-time = "2026-10-26T10:54:06.751Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/84/d1d0bef50d9e66d31b2019997c741b42274d53dde2e001b7a83e9511c339/uuid_utils-0.14.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:ccd65a4b8e83af23eae5e56d88034b2fe7264f465d3e830845f10d1591b81741", size = 309346, upload-time = "2026-02-20T22:50:31.857Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/63/fe81dda73d651fb6bf8b0e21c4ea34209fc535ca109634c0d93f14ac21dc/uuid_utils-0.19.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:a416c78167b750186095a0ea950d95b8b4e157e3172fc6ccdc317b6ca2896580", size = 294226, upload-time = "2026-10-26T10:54:08.422Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/ed/b6d6fd52a6636d7c3eddf97d68da50910bf17cd5ac221992506fb56cf12e/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b56b0cacd81583834820588378e432b0696186683b813058b707aedc1e16c4b1", size = 344714, upload-time = "2026-02-20T22:50:42.642Z" },
|
{ url = "https://files.pythonhosted.org/packages/81/6c/118c7e11fb579b5631212bfbbb31542a1778936dfb7c55591aa9b52c7c99/uuid_utils-0.19.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82573c0e5090732c0ae2e18626e06cb35fe7cfcf6fe9534d4c1638af41d25d36", size = 327869, upload-time = "2026-10-26T10:54:10.016Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/a7/a19a1719fb626fe0b31882db36056d44fe904dc0cf15b06fdf56b2679cf7/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bb3cf14de789097320a3c56bfdfdd51b1225d11d67298afbedee7e84e3837c96", size = 350914, upload-time = "2026-02-20T22:50:36.487Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/76/80e9bad735cbf92f127183ee5c44ee0d83604e56197bddcaaaed2c66f7e6/uuid_utils-0.19.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c78d527fddf5495e80032caeca2f140731ef394298ef4cbecaaf51aa2c257e1e", size = 334737, upload-time = "2026-10-26T10:54:11.434Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/fc/f6690e667fdc3bb1a73f57951f97497771c56fe23e3d302d7404be394d4f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e0854a90d67f4b0cc6e54773deb8be618f4c9bad98d3326f081423b5d14fae", size = 482609, upload-time = "2026-02-20T22:50:37.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/9e/7b/c8eb8e45569e8681f20e381859248d18635d823b8aedc70de8a0bd5faca6/uuid_utils-0.19.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c33b5c62cf82d509945c770201b1656448c2560e12931d1a8d698a683e9125d", size = 440438, upload-time = "2026-10-26T10:54:12.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/6e/dcd3fa031320921a12ec7b4672dea3bd1dd90ddffa363a91831ba834d559/uuid_utils-0.14.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce6743ba194de3910b5feb1a62590cd2587e33a73ab6af8a01b642ceb5055862", size = 345699, upload-time = "2026-02-20T22:50:46.87Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/45/8238eac588b668e5c500cb187fd74a9f6d984c09f3f3f2fa4161d3c07eb7/uuid_utils-0.19.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8060530a8fa27f11947489c79bc36e6ebb465b2d714945364dc397283309417", size = 330342, upload-time = "2026-10-26T10:54:14.309Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/28/e5220204b58b44ac0047226a9d016a113fde039280cc8732d9e6da43b39f/uuid_utils-0.14.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:043fb58fde6cf1620a6c066382f04f87a8e74feb0f95a585e4ed46f5d44af57b", size = 372205, upload-time = "2026-02-20T22:50:28.438Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/45/8215129786e67842d479480a44066cac8a7086f345ac9707f629ed777cfe/uuid_utils-0.19.0-cp310-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96429c78eab28ac71b2121fb47c66783bfe5d06632b5bd5200004707fdc0faad", size = 354275, upload-time = "2026-10-26T10:54:15.753Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/d9/3d2eb98af94b8dfffc82b6a33b4dfc87b0a5de2c68a28f6dde0db1f8681b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c915d53f22945e55fe0d3d3b0b87fd965a57f5fd15666fd92d6593a73b1dd297", size = 521836, upload-time = "2026-02-20T22:50:23.057Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/bc/a673bfd116eb26419dd82bffa9bc2bc1e915af02bf68ac8c09598dc678ec/uuid_utils-0.19.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80e0b37b3ddf60c50c51b869e1b02df61415941aa68bc903f61562fd8df04b18", size = 504446, upload-time = "2026-10-26T10:54:17.45Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a8/15/0eb106cc6fe182f7577bc0ab6e2f0a40be247f35c5e297dbf7bbc460bd02/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0972488e3f9b449e83f006ead5a0e0a33ad4a13e4462e865b7c286ab7d7566a3", size = 625260, upload-time = "2026-02-20T22:50:25.949Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/ff/61867210f202c603d4a5ca1c5b3c4c76e8d8724eeebd7cf1307d18b63a9a/uuid_utils-0.19.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f629f2a4f95e46d99ee673536ab9706899696cdc6686e93314ea756d3b394ce4", size = 609016, upload-time = "2026-10-26T10:54:18.914Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/17/f539507091334b109e7496830af2f093d9fc8082411eafd3ece58af1f8ba/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:1c238812ae0c8ffe77d8d447a32c6dfd058ea4631246b08b5a71df586ff08531", size = 587824, upload-time = "2026-02-20T22:50:35.225Z" },
|
{ url = "https://files.pythonhosted.org/packages/42/81/6fb8a2ee8010a6a0c5cb29f5efed2604717613cc9888f9e5c999dc36b84b/uuid_utils-0.19.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ec330297c09e40b82d613326b57bb381d68535e8a341fa2e501b46791cbdfeec", size = 570293, upload-time = "2026-10-26T10:54:20.328Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2e/c2/d37a7b2e41f153519367d4db01f0526e0d4b06f1a4a87f1c5dfca5d70a8b/uuid_utils-0.14.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:bec8f8ef627af86abf8298e7ec50926627e29b34fa907fcfbedb45aaa72bca43", size = 551407, upload-time = "2026-02-20T22:50:44.915Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/2f/e1c749b65c584ebd8bc5379cf18e26c67cdff8f2bd9f8f77111a47083408/uuid_utils-0.19.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:313de97d59dbbb09f4598cece2c799cc233d8673bb075ad7eff442cd485623c2", size = 535321, upload-time = "2026-10-26T10:54:21.735Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/36/2d24b2cbe78547c6532da33fb8613debd3126eccc33a6374ab788f5e46e9/uuid_utils-0.14.1-cp39-abi3-win32.whl", hash = "sha256:b54d6aa6252d96bac1fdbc80d26ba71bad9f220b2724d692ad2f2310c22ef523", size = 183476, upload-time = "2026-02-20T22:50:32.745Z" },
|
{ url = "https://files.pythonhosted.org/packages/87/ba/f423f68fd784124e68ab1b1afe27ab4591ffd5363b39eccfcbaf8f3b0ff3/uuid_utils-0.19.0-cp310-abi3-pyemscripten_2025_0_wasm32.whl", hash = "sha256:5c47c7fc35e91a801996477f0d746d506a2e5b47a13accbf41dc29844786c40c", size = 103141, upload-time = "2026-10-26T10:54:23.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/92/2d7e90df8b1a69ec4cff33243ce02b7a62f926ef9e2f0eca5a026889cd73/uuid_utils-0.14.1-cp39-abi3-win_amd64.whl", hash = "sha256:fc27638c2ce267a0ce3e06828aff786f91367f093c80625ee21dad0208e0f5ba", size = 187147, upload-time = "2026-02-20T22:50:45.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/9a/7a0a0dad166db9f4437f3d259e47ce74d552ccc50d8aa63d05b2510e3096/uuid_utils-0.19.0-cp310-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:23868cdc4dfa71021fec6cf43fbb83797345362ac1c866b50dbf17f479587413", size = 103069, upload-time = "2026-10-26T10:54:24.709Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/26/529f4beee17e5248e37e0bc17a2761d34c0fa3b1e5729c88adb2065bae6e/uuid_utils-0.14.1-cp39-abi3-win_arm64.whl", hash = "sha256:b04cb49b42afbc4ff8dbc60cf054930afc479d6f4dd7f1ec3bbe5dbfdde06b7a", size = 188132, upload-time = "2026-02-20T22:50:41.718Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/3c/c5fef96cea027de66a035b4173951fc29c2e83ed49a5d5b3fa7b632f95b5/uuid_utils-0.19.0-cp310-abi3-win32.whl", hash = "sha256:0cff166cf6348e9a705740e6575ce7bc727554a71a6add60b9ceceaaf169002a", size = 175479, upload-time = "2026-10-26T10:54:25.949Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/16/edfb8edc8a4624cfaf1387a57bf37cd53e5d7f213ebeb8dd6fbd2f6b2c64/uuid_utils-0.19.0-cp310-abi3-win_amd64.whl", hash = "sha256:b763ae8f23334ccd95d0b81fb19ee418f38f9e80ebfb521f313a9745280bc07c", size = 182891, upload-time = "2026-10-26T10:54:27.527Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/63/17/258e5dd380bc594731b2b705082360fb4cef1e5cbaab46ef307439a6f415/uuid_utils-0.19.0-cp310-abi3-win_arm64.whl", hash = "sha256:b244ea25bfe1b34e40fabf8f079d59a9f153b22bd4095842065bb0a9dfaa477b", size = 180849, upload-time = "2026-10-26T10:54:28.86Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/64/b5554f686d5395db970bc07944b9c59c26ab0e192a27d7c8b555e5fc6332/uuid_utils-0.19.0-cp314-cp314t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:9adf8d69574f0018a25aed0cc194bffacc05fb91b922fdd8e794109c45ad80ed", size = 565592, upload-time = "2026-10-26T10:54:30.242Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/a3/d8d0b7bf4892c41dbbb0e81a356d20aad781cc28d3029669e167eb72d9a1/uuid_utils-0.19.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:ac37f30acc7fe01266c73f19a686d0ea1fbd22495d81205cc3b572e2fb2293a6", size = 292083, upload-time = "2026-10-26T10:54:32.053Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/c1/770ccb569b4c15ee81c36bc40e74f03c5e0a2ee71ecc534d3fe7cfa5696d/uuid_utils-0.19.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afc1dc05c83163ec58304350ee61d20f1f3285d0e8fb873e4d85a97988c284ad", size = 323275, upload-time = "2026-10-26T10:54:33.896Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/12/798e09469358bf59848534291aa62d16cc6fbd66767f469c5d8278d0bc4d/uuid_utils-0.19.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16e68b1a4b81301fa7ba52d500947094226d8b0c1521695746ec57ff0779ff6f", size = 328681, upload-time = "2026-10-26T10:54:35.506Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/16/71df642a7969381747de36d4e365e59683cdaa2c425da0530e9383e4de86/uuid_utils-0.19.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:833edd2b38ef08a324e9f3aed4269d2ab955d4cb66417422eeb3af28a6237427", size = 434504, upload-time = "2026-10-26T10:54:36.96Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c1/18/35c86d901bbd6d3f088217f37a6ff01aae4ce8f8cdb3f9ddb5220887a122/uuid_utils-0.19.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21356ba99b82c813c1945054a09ff7ebc1bf55b858b41a6ae084e6ed2c8bb9a9", size = 325810, upload-time = "2026-10-26T10:54:38.492Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/51/25427eee1031946d5abe0d8985123f6fd4a1931c8a5a8660b1c76368ca12/uuid_utils-0.19.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2477161656105e787a5f986adb63191b61f129c9d7399b2bce406f87a1407b4d", size = 346300, upload-time = "2026-10-26T10:54:39.864Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/4b/edbb0ebdc948549a0250363f441883c31fef42fb4e4bb607b9a083efaeba/uuid_utils-0.19.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ddf61d8443993d683c8d6fcd05cbb711c2655042952850fc0cf25af969e3a213", size = 501365, upload-time = "2026-10-26T10:54:41.268Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/e4/f5a788fae45e315d8d04d5889a0b0809543e27c8d08b9fe10b09d0766b9b/uuid_utils-0.19.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cb9ead0aeae67ad2de3f660f2e978ece77e14ad3f2a7082b768e2c75866664a2", size = 604290, upload-time = "2026-10-26T10:54:42.686Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/90/3800a0b2083d6c156a4d686e75f26c055a48fcf315b9310ba211b7e3659c/uuid_utils-0.19.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e5155902b7779ae7b692ac69e6031f41481d62fa85a530d3725c270799940a0", size = 563566, upload-time = "2026-10-26T10:54:44.068Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/37/1b58a17a041bd560a1d652afccd57ecd5d0e0c4d6280deded663b783de43/uuid_utils-0.19.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd3bcc556e249bb70b1c7fd78e659a1ebfb5572e7ff2091d096aecbac62edb1c", size = 531143, upload-time = "2026-10-26T10:54:45.599Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/ac/f6286bcfad2f321be2acd04961934a58e4a6c15ba78070a13d193cbbb9f0/uuid_utils-0.19.0-cp314-cp314t-win32.whl", hash = "sha256:9b9064d96c6fe5e43ff4486722c5c891083a7fa91ee36311286cfd833b4b5562", size = 172229, upload-time = "2026-10-26T10:54:46.925Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/45/0df876abf12875ef82bcd8f034c0c7adc1507dd721a5942232120b6267e8/uuid_utils-0.19.0-cp314-cp314t-win_amd64.whl", hash = "sha256:eeaf6b77ed2ec9815988e10d043d1533a1ae8c6535e7fb5479fd7e2e6ad25dff", size = 178436, upload-time = "2026-10-26T10:54:48.249Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/ae/c9b4aaa8ee95581b444a7be681dc0ceb57a465b50b28b2f2517f5fec1c51/uuid_utils-0.19.0-cp314-cp314t-win_arm64.whl", hash = "sha256:ebb011b9010ddbd8acd4650896e87db2715f2cfc70fb194c03ebcbf4150d3844", size = 175725, upload-time = "2026-10-26T10:54:49.562Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/41/6e/3a3fdaa9689b687f105ba885e61f6b2e8336ec4d209fd51d41923ad83479/uuid_utils-0.19.0-cp315-cp315t-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8c76f01a815a0ca8b698202f2f5e40098a4b3cd56305e1fc9ed3d959ac718ff0", size = 565325, upload-time = "2026-10-26T10:54:50.924Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5e/1a/23dacf86dfe52f4606694501a6072379156025a3c6988e16f477f627249c/uuid_utils-0.19.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:f3828e1fc0831a9d539e702992e9fcfeb400e80f737467b8e557921cdc8ae067", size = 291930, upload-time = "2026-10-26T10:54:52.529Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/f2/adf223ded569b0d75e8ac10db14be8fc253da900a63e678436bc3ff3b72a/uuid_utils-0.19.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b25ddec2775c2f500a36029ab93af09f39d5c417786df02a464464e030152014", size = 322729, upload-time = "2026-10-26T10:54:54.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/df/0f78e7f22c778d2e48ef092a3a1c44d5b8b487b7e178e1044e7a39195b17/uuid_utils-0.19.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1fc412a5ef2f1e6f96d132a6656af59bae2dba9fd04f8df2a2f6eb913fe6180a", size = 328838, upload-time = "2026-10-26T10:54:55.473Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/96/9a9d6f7bef37b3545203eaf643e7f4d574f4ea3a6364af8865c071847df5/uuid_utils-0.19.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94cbef978d789b18014c477f51c8311800803cd2a0c7e1de6d2af85377210053", size = 433992, upload-time = "2026-10-26T10:54:56.911Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/92/62f67dc40a036ab890ac3f55eaab610999c713654bb3d0b56d5676ce3eaa/uuid_utils-0.19.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4b012d2a2c5516e11ded4fb70cdd730f36e2d40b45018b9a40c6605dc5c0666", size = 326138, upload-time = "2026-10-26T10:54:58.533Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/b7/740525de1aa1a150eb7a80a667c997d54284f407b7f3b38ce8f90f948813/uuid_utils-0.19.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4062c0dfd6697ce7c91a504d8ff6eb8dcaf4e790fb6adec69c4c83fd89870d4a", size = 346714, upload-time = "2026-10-26T10:55:00.01Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/1a/c9d2be770a2061da608164d022becda3fc7ddabb34ec7a1950d29ae51ca0/uuid_utils-0.19.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:361cfd0fba0a49f1c8b6df741a35f7d948f0fae3d3914f8622ba261dc4656a5a", size = 500974, upload-time = "2026-10-26T10:55:01.392Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/2e/ee7522207a7f9477c58757f040f05c4eedb68f362623d4e40da4983bdf5b/uuid_utils-0.19.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:a15c8943e02ac59992eaad1ce69793720dc711ce66188d386be1cbf8f5b5a2e4", size = 604607, upload-time = "2026-10-26T10:55:03.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/b8/c9a1f21627238171a2021dd777177a211df20b12c0c5e45b828ee9bc3c84/uuid_utils-0.19.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:4bd00a485e973cb3bcefe06f13a663cf4ee0c54caeacb2a656a5f901d1505a03", size = 563749, upload-time = "2026-10-26T10:55:04.809Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/9e/ed7d14d4d7bdb94159909e722337a557c855e8990a7fdff55ef6b016fb38/uuid_utils-0.19.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9fe68c4b2e017fb13c5e460b76b76c5cfa717ddd653cea184c2664bc2a852011", size = 530994, upload-time = "2026-10-26T10:55:06.511Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/92/5b84a87dd4127a14337d42eb4a24a764583e4decc2675a9949d4568b16e1/uuid_utils-0.19.0-cp315-cp315t-win32.whl", hash = "sha256:5397581f40e268535cd48230e4f2f372dc5f3e70ce79e0aff9cb5ceb838163c8", size = 172512, upload-time = "2026-10-26T10:55:08.261Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d6/43/a11a33e39f64c50f17483842652933b8ef769ce7cac3399ed7d2a2b928c2/uuid_utils-0.19.0-cp315-cp315t-win_amd64.whl", hash = "sha256:225752e3c36b7c861eb1bbceb5c9197c134c806b210ed53dcacf6339a3d599e6", size = 178626, upload-time = "2026-10-26T10:55:09.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/78/4f9dc891db194ce8107c9704d7e2e51da558af457e64134da1b1a7c5db1f/uuid_utils-0.19.0-cp315-cp315t-win_arm64.whl", hash = "sha256:bfcda2352c1500230227da5475139ab177bb2516309254cd74ade19a28cb842d", size = 175582, upload-time = "2026-10-26T10:55:11.001Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@ -20,7 +20,7 @@
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Bump this number when the config schema changes.
|
# Bump this number when the config schema changes.
|
||||||
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
# Run `make config-upgrade` to merge new fields into your local config.yaml.
|
||||||
config_version: 63
|
config_version: 64
|
||||||
|
|
||||||
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
|
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
|
||||||
# lead_prompt_overlay:
|
# lead_prompt_overlay:
|
||||||
@ -2036,6 +2036,8 @@ subagent_runtime:
|
|||||||
# # token_budget: # per-agent override of the global token_budget above
|
# # token_budget: # per-agent override of the global token_budget above
|
||||||
# # max_tokens: 3000000 # raise the ceiling for deep-research tasks
|
# # max_tokens: 3000000 # raise the ceiling for deep-research tasks
|
||||||
# # model: qwen3:32b # Use a specific model (default: inherit from lead agent)
|
# # model: qwen3:32b # Use a specific model (default: inherit from lead agent)
|
||||||
|
# # thinking_enabled: true # Enable thinking/reasoning for this subagent (default: false)
|
||||||
|
# # reasoning_effort: medium # Use an effort value or alias supported by this model
|
||||||
# # skills: # Skill discovery/activation allowlist (default: all enabled)
|
# # skills: # Skill discovery/activation allowlist (default: all enabled)
|
||||||
# # - web-search
|
# # - web-search
|
||||||
# # - data-analysis
|
# # - data-analysis
|
||||||
@ -2063,11 +2065,15 @@ subagent_runtime:
|
|||||||
# # - data-analysis
|
# # - data-analysis
|
||||||
# # - visualization
|
# # - visualization
|
||||||
# # model: inherit # 'inherit' uses parent's model
|
# # model: inherit # 'inherit' uses parent's model
|
||||||
|
# # thinking_enabled: true # Enable thinking/reasoning (default: false)
|
||||||
|
# # reasoning_effort: medium # Use an effort value or alias supported by this model
|
||||||
# # max_turns: 80
|
# # max_turns: 80
|
||||||
# # timeout_seconds: 600
|
# # timeout_seconds: 600
|
||||||
#
|
#
|
||||||
# # Model override: by default, subagents inherit the lead agent's model.
|
# # Model override: by default, subagents inherit the lead agent's model.
|
||||||
# # Set `model` to use a different model (e.g., a local Ollama model for cost savings).
|
# # Set `model` to use a different model (e.g., a local Ollama model for cost savings).
|
||||||
|
# # Unset thinking requests false; the selected model's required-thinking policy
|
||||||
|
# # and effort defaults/aliases still apply. Parent thinking settings are not inherited.
|
||||||
# # The model name must match a name defined in the `models:` section above.
|
# # The model name must match a name defined in the `models:` section above.
|
||||||
|
|
||||||
# Durable native-subagent batches. Disabled by default because enabling it can
|
# Durable native-subagent batches. Disabled by default because enabling it can
|
||||||
|
|||||||
@ -2,8 +2,8 @@ apiVersion: v2
|
|||||||
name: deer-flow
|
name: deer-flow
|
||||||
description: DeerFlow — LangGraph-based AI super-agent (gateway + frontend + nginx + sandbox provisioner) deployed to Kubernetes.
|
description: DeerFlow — LangGraph-based AI super-agent (gateway + frontend + nginx + sandbox provisioner) deployed to Kubernetes.
|
||||||
type: application
|
type: application
|
||||||
version: 2.2.0-rc0
|
version: 2.3.0-dev
|
||||||
appVersion: "2.2.0-rc0"
|
appVersion: "2.3.0-dev"
|
||||||
keywords:
|
keywords:
|
||||||
- deerflow
|
- deerflow
|
||||||
- langgraph
|
- langgraph
|
||||||
|
|||||||
@ -135,7 +135,7 @@ they resolve from the `secrets` map):
|
|||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
config: |
|
config: |
|
||||||
config_version: 63
|
config_version: 64
|
||||||
models:
|
models:
|
||||||
- name: gpt-4
|
- name: gpt-4
|
||||||
use: langchain_openai:ChatOpenAI
|
use: langchain_openai:ChatOpenAI
|
||||||
|
|||||||
@ -367,7 +367,7 @@ ingress:
|
|||||||
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
|
||||||
# inline literal secret values here. The default enables provisioner sandbox.
|
# inline literal secret values here. The default enables provisioner sandbox.
|
||||||
config: |
|
config: |
|
||||||
config_version: 63
|
config_version: 64
|
||||||
log_level: info
|
log_level: info
|
||||||
recursion_limit: 100
|
recursion_limit: 100
|
||||||
max_recursion_limit: 1000
|
max_recursion_limit: 1000
|
||||||
|
|||||||
@ -28,3 +28,7 @@ Wiki lexical search retries only successful empty pages with up to four distinct
|
|||||||
whitespace-separated keywords of at least two characters. Keep retries in the
|
whitespace-separated keywords of at least two characters. Keep retries in the
|
||||||
same authorized base, validate every response, deduplicate title/excerpt pairs,
|
same authorized base, validate every response, deduplicate title/excerpt pairs,
|
||||||
and never retry provider/auth/schema errors. Nonempty phrase results stay intact.
|
and never retry provider/auth/schema errors. Nonempty phrase results stay intact.
|
||||||
|
|
||||||
|
Batch document checks at four per search; retain dataset/ID request-local keys
|
||||||
|
and hit order. Drain sibling tasks on errors/cancellation. Validate selected
|
||||||
|
documents before search, and only the consumed hit prefix before returning it.
|
||||||
|
|||||||
@ -220,3 +220,9 @@ at most four distinct whitespace-separated keywords (at least two characters),
|
|||||||
within the same authorized knowledge base, and merges duplicate evidence. This
|
within the same authorized knowledge base, and merges duplicate evidence. This
|
||||||
helps model-generated keyword combinations match the upstream lexical search;
|
helps model-generated keyword combinations match the upstream lexical search;
|
||||||
it does not perform unrestricted search or retry authorization/provider errors.
|
it does not perform unrestricted search or retry authorization/provider errors.
|
||||||
|
|
||||||
|
Document details are checked in batches of at most four concurrent requests per
|
||||||
|
search. Every selected document is validated before retrieval; retrieved evidence
|
||||||
|
is validated before returning results. Checks remain request-local and results
|
||||||
|
retain provider order. A failed or cancelled batch cancels and drains its other
|
||||||
|
requests; it never falls back to broader scope or returns partial evidence.
|
||||||
|
|||||||
@ -21,6 +21,7 @@ from deerflow.knowledge_scope import execution_scope
|
|||||||
from .config import Settings, Space, native_id
|
from .config import Settings, Space, native_id
|
||||||
|
|
||||||
_MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
_MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||||
|
_DOCUMENT_CONCURRENCY = 4
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@ -181,6 +182,23 @@ class WeKnoraProvider:
|
|||||||
async def search(self, query: str) -> RetrievalResult:
|
async def search(self, query: str) -> RetrievalResult:
|
||||||
raise PermissionError("WeKnora retrieval requires a trusted KnowledgeRequestContext")
|
raise PermissionError("WeKnora retrieval requires a trusted KnowledgeRequestContext")
|
||||||
|
|
||||||
|
async def _load_documents(self, ds: _Dataset, ids: list[str], documents: dict[tuple[str, str], dict[str, Any]]) -> None:
|
||||||
|
# Cache only within this search, keyed by authorized dataset and native ID.
|
||||||
|
missing = list(dict.fromkeys(document_id for document_id in ids if (ds.id, document_id) not in documents))
|
||||||
|
for start in range(0, len(missing), _DOCUMENT_CONCURRENCY):
|
||||||
|
batch = missing[start : start + _DOCUMENT_CONCURRENCY]
|
||||||
|
tasks = [asyncio.create_task(self._document(ds, document_id)) for document_id in batch]
|
||||||
|
try:
|
||||||
|
values = await asyncio.gather(*tasks)
|
||||||
|
except BaseException:
|
||||||
|
# gather alone leaves siblings running after an ordinary error.
|
||||||
|
# Drain them on failure/cancellation, preserving the original error.
|
||||||
|
for task in tasks:
|
||||||
|
task.cancel()
|
||||||
|
await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
raise
|
||||||
|
documents.update(((ds.id, document_id), value) for document_id, value in zip(batch, values, strict=True))
|
||||||
|
|
||||||
async def search_scoped(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
|
async def search_scoped(self, query: str, *, context: KnowledgeRequestContext) -> RetrievalResult:
|
||||||
scope = execution_scope(context.scope) if context.scope is not None else {"mode": "all"}
|
scope = execution_scope(context.scope) if context.scope is not None else {"mode": "all"}
|
||||||
if scope["mode"] == "disabled":
|
if scope["mode"] == "disabled":
|
||||||
@ -199,8 +217,7 @@ class WeKnoraProvider:
|
|||||||
# Validate every constrained resource before any search; no partial
|
# Validate every constrained resource before any search; no partial
|
||||||
# success and no unfiltered retry if any selection fails.
|
# success and no unfiltered retry if any selection fails.
|
||||||
for dataset_id, ids in filters.items():
|
for dataset_id, ids in filters.items():
|
||||||
for document_id in ids:
|
await self._load_documents(datasets[dataset_id], ids, documents)
|
||||||
documents[dataset_id, document_id] = await self._document(datasets[dataset_id], document_id)
|
|
||||||
hits = []
|
hits = []
|
||||||
for dataset_id in selected:
|
for dataset_id in selected:
|
||||||
if len(hits) >= 100:
|
if len(hits) >= 100:
|
||||||
@ -217,20 +234,25 @@ class WeKnoraProvider:
|
|||||||
results = _items(payload["data"])
|
results = _items(payload["data"])
|
||||||
if len(results) > 100:
|
if len(results) > 100:
|
||||||
raise RuntimeError("WeKnora response exceeds the supported result limit")
|
raise RuntimeError("WeKnora response exceeds the supported result limit")
|
||||||
|
candidates = []
|
||||||
|
remaining = 100 - len(hits)
|
||||||
for item in results:
|
for item in results:
|
||||||
if len(hits) >= 100:
|
if remaining == 0:
|
||||||
break
|
break
|
||||||
document_id = native_id(item.get("knowledge_id"))
|
document_id = native_id(item.get("knowledge_id"))
|
||||||
if dataset_id in filters and document_id not in filters[dataset_id]:
|
if dataset_id in filters and document_id not in filters[dataset_id]:
|
||||||
raise PermissionError("WeKnora returned evidence outside the selected documents")
|
raise PermissionError("WeKnora returned evidence outside the selected documents")
|
||||||
cache_key = (dataset_id, document_id)
|
text = ds.binding.text(item.get("content"), 100_000)
|
||||||
if cache_key not in documents:
|
candidates.append((item, document_id, text))
|
||||||
documents[cache_key] = await self._document(ds, document_id)
|
if text:
|
||||||
|
remaining -= 1
|
||||||
|
await self._load_documents(ds, [document_id for _, document_id, _ in candidates], documents)
|
||||||
|
for item, document_id, text in candidates:
|
||||||
# SearchResult has no knowledge_base_id; the authoritative detail
|
# SearchResult has no knowledge_base_id; the authoritative detail
|
||||||
# endpoint above verifies membership even for unfiltered search.
|
# endpoint above verifies membership even for unfiltered search.
|
||||||
text = ds.binding.text(item.get("content"), 100_000)
|
|
||||||
if not text:
|
if not text:
|
||||||
continue
|
continue
|
||||||
|
cache_key = (dataset_id, document_id)
|
||||||
title = ds.binding.text(item.get("knowledge_title") or item.get("knowledge_filename") or documents[cache_key].get("title") or documents[cache_key].get("file_name"), 500)
|
title = ds.binding.text(item.get("knowledge_title") or item.get("knowledge_filename") or documents[cache_key].get("title") or documents[cache_key].get("file_name"), 500)
|
||||||
source = KnowledgeSource(provider="weknora", title=title, excerpt=text, dataset_name=ds.name) if title else None
|
source = KnowledgeSource(provider="weknora", title=title, excerpt=text, dataset_name=ds.name) if title else None
|
||||||
# v0.8.3 gives offsets/chunk indexes, not a verified page or public
|
# v0.8.3 gives offsets/chunk indexes, not a verified page or public
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "deer-flow-frontend",
|
"name": "deer-flow-frontend",
|
||||||
"version": "2.2.0-rc0",
|
"version": "2.3.0-dev",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -29,7 +29,8 @@ Tests: `html-assets.dom.test.ts`, `artifact-file-preview.dom.test.tsx`,
|
|||||||
2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream.
|
2. Stream events update thread state (messages, artifacts, todos, goal). The main thread stream uses the LangGraph SDK's `throttle: true` mode so updates received in the same macrotask coalesce before React is notified; do not replace it with a numeric delay without validating the SDK's trailing-debounce behavior on a continuous stream.
|
||||||
File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates.
|
File-tool artifact auto-open work must run in an effect with timer cleanup; never schedule timers while rendering streamed `write_file` or `str_replace` updates.
|
||||||
`ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading.
|
`ThreadState.artifacts` remains the authoritative artifact list. The artifacts provider persists only thread-scoped panel UI state (`open`, selected path, and a refresh bootstrap cache) in session storage; an initial empty stream value must not overwrite that restored state before history finishes loading.
|
||||||
Formal artifact content is refreshed once when the run finishes; transient `write-file:` previews remain message-driven.
|
Refetch formal artifacts when runs finish; `write-file:` previews stay message-driven.
|
||||||
|
On save, cancel reads before cache/draft writes; pass both hooks' signals via loader to fetch.
|
||||||
UTF-8 outputs under `/mnt/user-data/outputs` are editable while idle. Root drafts survive SPA/home/showcase routes and unknown auth; confirmed account change/logout clears them. Owner/generation fences reject stale updates. Auth binds through a stable setter context, outside the typing state subscription. Drafts preview in Markdown/HTML and warn on unload off-route. SHA-256 conflicts preserve edits. Layout, draft-context and editor DOM tests cover this.
|
UTF-8 outputs under `/mnt/user-data/outputs` are editable while idle. Root drafts survive SPA/home/showcase routes and unknown auth; confirmed account change/logout clears them. Owner/generation fences reject stale updates. Auth binds through a stable setter context, outside the typing state subscription. Drafts preview in Markdown/HTML and warn on unload off-route. SHA-256 conflicts preserve edits. Layout, draft-context and editor DOM tests cover this.
|
||||||
Regular artifact text loads request at most the first 1 MiB through an HTTP
|
Regular artifact text loads request at most the first 1 MiB through an HTTP
|
||||||
byte range. A truncated preview must stay lightweight and expose an explicit
|
byte range. A truncated preview must stay lightweight and expose an explicit
|
||||||
|
|||||||
@ -330,6 +330,11 @@ export function ArtifactFileDetail({
|
|||||||
content: submittedDraftContent,
|
content: submittedDraftContent,
|
||||||
expectedSha256: submittedBaselineSha256,
|
expectedSha256: submittedBaselineSha256,
|
||||||
});
|
});
|
||||||
|
// A refetch started before or during the save can still return old bytes.
|
||||||
|
// Cancel both preview and full reads before publishing the saved revision.
|
||||||
|
await queryClient.cancelQueries({
|
||||||
|
queryKey: ["artifact", filepathFromProps, threadId, isMock],
|
||||||
|
});
|
||||||
setDrafts((current) => ({
|
setDrafts((current) => ({
|
||||||
...current,
|
...current,
|
||||||
[filepath]: (() => {
|
[filepath]: (() => {
|
||||||
|
|||||||
@ -34,12 +34,13 @@ export function useArtifactContent({
|
|||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
|
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
|
||||||
queryFn: () => {
|
queryFn: ({ signal }) => {
|
||||||
return loadArtifactContent({
|
return loadArtifactContent({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
isMock,
|
isMock,
|
||||||
full: fullContentRequested,
|
full: fullContentRequested,
|
||||||
|
signal,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled,
|
enabled,
|
||||||
@ -103,12 +104,13 @@ export function useStandaloneArtifactContent({
|
|||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
const { data, isLoading, error } = useQuery({
|
||||||
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
|
queryKey: ["artifact", filepath, threadId, isMock, fullContentRequested],
|
||||||
queryFn: () =>
|
queryFn: ({ signal }) =>
|
||||||
loadArtifactContent({
|
loadArtifactContent({
|
||||||
filepath,
|
filepath,
|
||||||
threadId,
|
threadId,
|
||||||
isMock,
|
isMock,
|
||||||
full: fullContentRequested,
|
full: fullContentRequested,
|
||||||
|
signal,
|
||||||
}),
|
}),
|
||||||
staleTime: 0,
|
staleTime: 0,
|
||||||
refetchOnWindowFocus: true,
|
refetchOnWindowFocus: true,
|
||||||
|
|||||||
@ -52,11 +52,13 @@ export async function loadArtifactContent({
|
|||||||
threadId,
|
threadId,
|
||||||
isMock,
|
isMock,
|
||||||
full = false,
|
full = false,
|
||||||
|
signal,
|
||||||
}: {
|
}: {
|
||||||
filepath: string;
|
filepath: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
isMock?: boolean;
|
isMock?: boolean;
|
||||||
full?: boolean;
|
full?: boolean;
|
||||||
|
signal?: AbortSignal;
|
||||||
}) {
|
}) {
|
||||||
let enhancedFilepath = filepath;
|
let enhancedFilepath = filepath;
|
||||||
if (filepath.endsWith(".skill")) {
|
if (filepath.endsWith(".skill")) {
|
||||||
@ -65,6 +67,7 @@ export async function loadArtifactContent({
|
|||||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal,
|
||||||
headers: full
|
headers: full
|
||||||
? undefined
|
? undefined
|
||||||
: { Range: `bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}` },
|
: { Range: `bytes=0-${ARTIFACT_PREVIEW_MAX_BYTES - 1}` },
|
||||||
|
|||||||
@ -9,6 +9,8 @@ export interface Subagent {
|
|||||||
disallowed_tools: string[] | null;
|
disallowed_tools: string[] | null;
|
||||||
skills: string[] | null;
|
skills: string[] | null;
|
||||||
model: string;
|
model: string;
|
||||||
|
thinking_enabled?: boolean | null;
|
||||||
|
reasoning_effort?: string | null;
|
||||||
max_turns: number;
|
max_turns: number;
|
||||||
timeout_seconds: number;
|
timeout_seconds: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -27,6 +29,8 @@ export interface CreateManagedSubagentRequest {
|
|||||||
disallowed_tools?: string[] | null;
|
disallowed_tools?: string[] | null;
|
||||||
skills?: string[] | null;
|
skills?: string[] | null;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
thinking_enabled?: boolean | null;
|
||||||
|
reasoning_effort?: string | null;
|
||||||
max_turns?: number;
|
max_turns?: number;
|
||||||
timeout_seconds?: number;
|
timeout_seconds?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
|||||||
@ -0,0 +1,275 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
act,
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import type { PropsWithChildren } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
rs.mock("@/components/workspace/messages/context", () => ({
|
||||||
|
useThread: () => ({
|
||||||
|
thread: { isLoading: false, messages: [] },
|
||||||
|
isMock: false,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
rs.mock("@/components/ui/sidebar", () => ({
|
||||||
|
useSidebar: () => ({ setOpen: rs.fn() }),
|
||||||
|
}));
|
||||||
|
rs.mock("next/navigation", () => ({
|
||||||
|
usePathname: () => "/workspace/chats/artifact-save-race",
|
||||||
|
}));
|
||||||
|
rs.mock("@/core/auth/AuthProvider", () => ({
|
||||||
|
useAuth: () => ({ user: null }),
|
||||||
|
}));
|
||||||
|
rs.mock("@/core/config", () => ({
|
||||||
|
getBackendBaseURL: () => "/backend",
|
||||||
|
}));
|
||||||
|
rs.mock("@/env", () => ({
|
||||||
|
env: { NEXT_PUBLIC_STATIC_WEBSITE_ONLY: "false" },
|
||||||
|
}));
|
||||||
|
rs.mock("@/core/api/fetcher", () => ({ fetch: rs.fn() }));
|
||||||
|
rs.mock("sonner", () => ({ toast: { success: rs.fn(), error: rs.fn() } }));
|
||||||
|
|
||||||
|
// Keep the production content hook, query client and draft providers. Only the
|
||||||
|
// editor surface is simplified so the test can exercise actual edit/save events.
|
||||||
|
rs.mock("@/components/workspace/code-editor", () => ({
|
||||||
|
CodeEditor: ({
|
||||||
|
value,
|
||||||
|
readonly,
|
||||||
|
disabled,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
readonly?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
}) => (
|
||||||
|
<textarea
|
||||||
|
aria-label="Artifact source"
|
||||||
|
value={value}
|
||||||
|
readOnly={readonly}
|
||||||
|
disabled={disabled}
|
||||||
|
onChange={(event) => onChange?.(event.target.value)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ArtifactFileDetail } from "@/components/workspace/artifacts/artifact-file-detail";
|
||||||
|
import { ArtifactsProvider } from "@/components/workspace/artifacts/context";
|
||||||
|
import { fetch as apiFetch } from "@/core/api/fetcher";
|
||||||
|
import { ArtifactDraftsProvider } from "@/core/artifacts/draft-context";
|
||||||
|
import { I18nProvider } from "@/core/i18n/context";
|
||||||
|
|
||||||
|
const filepath = "/mnt/user-data/outputs/report.txt";
|
||||||
|
const threadId = "artifact-save-race";
|
||||||
|
const oldRevision = "a".repeat(64);
|
||||||
|
const savedRevision = "b".repeat(64);
|
||||||
|
const nextRevision = "c".repeat(64);
|
||||||
|
const mockedFetch = rs.mocked(apiFetch);
|
||||||
|
let queryClient: QueryClient;
|
||||||
|
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
const promise = new Promise<T>((done) => {
|
||||||
|
resolve = done;
|
||||||
|
});
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(content: string, revision: string) {
|
||||||
|
return new Response(content, {
|
||||||
|
headers: { ETag: `"${revision}"` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveResponse(content: string, revision: string) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ path: filepath, sha256: revision, size: content.length }),
|
||||||
|
{ headers: { "Content-Type": "application/json" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wrapper({ children }: PropsWithChildren) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<I18nProvider initialLocale="en-US">
|
||||||
|
<ArtifactDraftsProvider>
|
||||||
|
<ArtifactsProvider>{children}</ArtifactsProvider>
|
||||||
|
</ArtifactDraftsProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function source() {
|
||||||
|
return screen.getByRole<HTMLTextAreaElement>("textbox", {
|
||||||
|
name: "Artifact source",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
queryClient.clear();
|
||||||
|
rs.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("artifact saves racing content refetches", () => {
|
||||||
|
it.each([
|
||||||
|
["before the save", false, false, false],
|
||||||
|
["during the save", true, false, false],
|
||||||
|
["during a full-file save", true, true, false],
|
||||||
|
["during a save followed by more editing", true, false, true],
|
||||||
|
])(
|
||||||
|
"keeps the saved content and revision when a read begun %s finishes late",
|
||||||
|
async (
|
||||||
|
_scenario,
|
||||||
|
refetchDuringSave,
|
||||||
|
fullContentRequested,
|
||||||
|
editWhileSaving,
|
||||||
|
) => {
|
||||||
|
const pendingRead = deferred<Response>();
|
||||||
|
const pendingSave = deferred<Response>();
|
||||||
|
const savedBodies: unknown[] = [];
|
||||||
|
let delayReads = false;
|
||||||
|
|
||||||
|
mockedFetch.mockImplementation(async (_url, options) => {
|
||||||
|
if (options?.method === "PUT") {
|
||||||
|
savedBodies.push(JSON.parse(options.body as string));
|
||||||
|
return savedBodies.length === 1
|
||||||
|
? pendingSave.promise
|
||||||
|
: saveResponse("next edit", nextRevision);
|
||||||
|
}
|
||||||
|
if (delayReads) return pendingRead.promise;
|
||||||
|
if (
|
||||||
|
fullContentRequested &&
|
||||||
|
new Headers(options?.headers).has("Range")
|
||||||
|
) {
|
||||||
|
return new Response("old", {
|
||||||
|
status: 206,
|
||||||
|
headers: {
|
||||||
|
ETag: `"${oldRevision}"`,
|
||||||
|
"Content-Range": "bytes 0-2/8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return textResponse("old file", oldRevision);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore the provider before mounting the detail, as the real async
|
||||||
|
// thread/artifact load does.
|
||||||
|
const view = render(<div />, { wrapper: Wrapper });
|
||||||
|
view.rerender(
|
||||||
|
<ArtifactFileDetail filepath={filepath} threadId={threadId} />,
|
||||||
|
);
|
||||||
|
if (fullContentRequested) {
|
||||||
|
fireEvent.click(
|
||||||
|
await screen.findByRole("button", { name: "Load full file" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await waitFor(() => expect(source().value).toBe("old file"));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
|
||||||
|
fireEvent.change(source(), { target: { value: "saved edit" } });
|
||||||
|
|
||||||
|
const queryKey = [
|
||||||
|
"artifact",
|
||||||
|
filepath,
|
||||||
|
threadId,
|
||||||
|
false,
|
||||||
|
fullContentRequested,
|
||||||
|
];
|
||||||
|
let refetch: Promise<void> | undefined;
|
||||||
|
const startRead = async () => {
|
||||||
|
delayReads = true;
|
||||||
|
await act(async () => {
|
||||||
|
refetch = queryClient.refetchQueries({ queryKey, exact: true });
|
||||||
|
});
|
||||||
|
expect(queryClient.isFetching({ queryKey, exact: true })).toBe(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!refetchDuringSave) await startRead();
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(savedBodies).toHaveLength(1));
|
||||||
|
if (refetchDuringSave) await startRead();
|
||||||
|
|
||||||
|
if (editWhileSaving) {
|
||||||
|
// Reopening the panel restores its draft while the original save is
|
||||||
|
// still pending, so subsequent typing must survive that completion.
|
||||||
|
view.rerender(<div />);
|
||||||
|
view.rerender(
|
||||||
|
<ArtifactFileDetail filepath={filepath} threadId={threadId} />,
|
||||||
|
);
|
||||||
|
expect(source().disabled).toBe(false);
|
||||||
|
fireEvent.change(source(), { target: { value: "newer draft" } });
|
||||||
|
}
|
||||||
|
const expectedContent = editWhileSaving ? "newer draft" : "saved edit";
|
||||||
|
const unrelatedRead = deferred<string>();
|
||||||
|
const unrelatedKeys = [
|
||||||
|
[
|
||||||
|
"artifact",
|
||||||
|
"/mnt/user-data/outputs/other.txt",
|
||||||
|
threadId,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
],
|
||||||
|
["artifact", filepath, "other-thread", false, false],
|
||||||
|
];
|
||||||
|
const unrelatedQueries = unrelatedKeys.map((key) =>
|
||||||
|
queryClient
|
||||||
|
.fetchQuery({ queryKey: key, queryFn: () => unrelatedRead.promise })
|
||||||
|
.catch(() => null),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pendingSave.resolve(saveResponse("saved edit", savedRevision));
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||||
|
expect(source().value).toBe(expectedContent);
|
||||||
|
for (const key of unrelatedKeys) {
|
||||||
|
expect(queryClient.isFetching({ queryKey: key, exact: true })).toBe(1);
|
||||||
|
}
|
||||||
|
unrelatedRead.resolve("unrelated content");
|
||||||
|
expect(await Promise.all(unrelatedQueries)).toEqual([
|
||||||
|
"unrelated content",
|
||||||
|
"unrelated content",
|
||||||
|
]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
pendingRead.resolve(textResponse("old file", oldRevision));
|
||||||
|
await refetch;
|
||||||
|
// Query observer notifications are batched onto the next task.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(source().value).toBe(expectedContent);
|
||||||
|
expect(screen.queryByText("Changed remotely")).toBeNull();
|
||||||
|
expect(queryClient.getQueryData(queryKey)).toMatchObject({
|
||||||
|
content: "saved edit",
|
||||||
|
sha256: savedRevision,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A later edit must compare against the committed revision, otherwise
|
||||||
|
// the Gateway rejects it as a false optimistic-concurrency conflict.
|
||||||
|
fireEvent.change(source(), { target: { value: "next edit" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||||
|
await waitFor(() => expect(savedBodies).toHaveLength(2));
|
||||||
|
expect(savedBodies[1]).toEqual({
|
||||||
|
content: "next edit",
|
||||||
|
expected_sha256: savedRevision,
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalledTimes(2));
|
||||||
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user