diff --git a/.gitattributes b/.gitattributes
index a6344aac8c09253b3b630fb776ae94478aa0275b..4d19e547058c2a856780e5904497631462a96972 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
*.zst filter=lfs diff=lfs merge=lfs -text
*tfevents* filter=lfs diff=lfs merge=lfs -text
+docs/assets/logo.png filter=lfs diff=lfs merge=lfs -text
+docs/assets/method-overview.png filter=lfs diff=lfs merge=lfs -text
+docs/assets/teaser.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..0ab1769a7dcffc21aeb8c14cb58a8ec16ed66087
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+__pycache__/
+.pytest_cache/
+*.py[cod]
+*.egg-info/
+.venv/
+venv/
+.env
+.env.*
+build/
+dist/
+
+# Local credentials and machine-specific configuration
+**/conf/config.json
+
+# Runtime artifacts
+storage/
+outputs/
+reports/
+logs/
+*.log
+llm_calls.txt
+llm_calls.jsonl
+checkpoint*.json
+prediction_cache/
+node_modules/
+.DS_Store
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..3ac2f3fd6c2e38065821376948e76c7a1e79d348
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 HCL Contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..8ef53758fc182b51386517ae3a926623fcfc3182
--- /dev/null
+++ b/README.md
@@ -0,0 +1,305 @@
+
+
+ Harness Continual Learning
+
+
+Continual Adaptation Beyond Model Parameters
+
+Official implementation of **Harness Continual Learning (HCL)**, a framework that enables frozen foundation-model agents to continually adapt their memory, routing, task interfaces, reusable capabilities, and optimization policies.
+
+
+
+
+
+## Abstract
+
+Continual learning has largely focused on model parameters, yet modern agents can also adapt through a harness of prompts, memories, tools, skills, and routing rules. We introduce **Harness Continual Learning (HCL)**, a paradigm in which this harness evolves around a frozen foundation model, and define the resulting loss of previously acquired behavior as **harness-level forgetting**. HCL comprises four execution-facing components: the Task Interface, Experience Memory, Capability Map, and Adaptive Router. To balance adaptation and retention, **guarded harness evolution** separates update generation from state commitment: a Continual Optimizer proposes candidate harnesses from execution feedback, while a Continual Evaluator commits an update only after checking current improvement, historical retention, and validity. Across open-world capability accumulation, textual reasoning, and multimodal perception, HCL supports capability growth and failure recovery, exceeds corresponding baselines by more than 10% in multiple settings, and provides a controllable stability–plasticity trade-off.
+
+## Key contributions
+
+We evaluate HCL across textual reasoning, multimodal perception, and open-world interaction, and further study retention control, harness-level distillation, and component contributions. Our main contributions are:
+
+- **Harness Continual Learning:** We propose and formalize HCL as a new continual learning paradigm, shifting the object of continual learning from model state to the harness state surrounding a frozen foundation model.
+- **Guarded harness evolution:** We identify harness-level forgetting and make current improvement, historical retention, and validity explicit conditions for committing a harness update.
+- **Capability accumulation and controlled retention:** Across reasoning, multimodal, and interactive task streams, we show that harness evolution supports capability accumulation. Component ablations and retention sweeps reveal the contributions of individual harness components, measurable forgetting, and controllable stability–plasticity trade-offs, with exploratory evidence of knowledge transfer.
+
+
+
+
+
+## Repository layout
+
+```text
+HCL/
+├── hcl/ # Core HCL implementation
+│ ├── capability/ # Reusable skills, tools, and capability services
+│ ├── memory/ # Raw, abstract, and anchor memories
+│ ├── optimizer/ # Candidate harness-update generation
+│ ├── router/ # Workflow and artifact selection prompts
+│ └── task_interface/ # Shared task structuring interface
+├── configs/ # Paper experiment and task-stream profiles
+├── data/ # Expected external dataset layout
+├── examples/ # Minimal synthetic task-stream fixtures
+├── tests/ # Offline unit and regression tests
+├── scripts/ # Utility and integration scripts
+├── experiments/
+│ ├── HCL_ALFworld/ # ALFWorld integration
+│ └── Odyssey/ # Minecraft/Odyssey integration
+├── docs/assets/ # README figures
+├── main.py # Main experiment entry point
+├── run.sh # Reference launch script
+├── requirements.txt
+└── pyproject.toml
+```
+
+The core implementation is exposed at the repository root, following the same entry-point-oriented layout as DFA-MoE. ALFWorld and Odyssey retain isolated environments because their simulator dependencies are incompatible with parts of the core stack.
+
+## Installation
+
+### Core environment
+
+Python 3.10 or later is required.
+
+```bash
+git clone https://github.com/RL-MIND/Harness-Continual-Learning.git
+cd HCL
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install --upgrade pip
+python -m pip install -r requirements.txt
+```
+
+Install optional dependencies only when needed:
+
+```bash
+# Local Transformers models
+python -m pip install -e '.[local-model]'
+
+# COCO metrics and development tests
+python -m pip install -e '.[coco-eval,test]'
+```
+
+Verify the installation without model or dataset downloads:
+
+```bash
+python -m unittest discover -s tests -v
+```
+
+## Supported task streams
+
+| Modality | Task stream | Tasks |
+| --- | --- | --- |
+| Textual reasoning | `configs/taskstream_textual_main_250_50_500.json` | MuSiQue, ProofWriter, GSM8K, HotpotQA |
+| Multimodal | `configs/taskstream_multimodal_main_250_50_500.json` | COCO detection, COCO captioning, RefCOCO grounding, VQAv2 |
+| Embodied | `experiments/HCL_ALFworld/` | ALFWorld domain-incremental sequences |
+| Open world | `experiments/Odyssey/` | Minecraft/Odyssey curriculum tasks |
+
+Datasets and model checkpoints are not redistributed. Prepare them under `data/` according to [the data layout](data/README.md), or update the project-relative paths in the selected task-stream file.
+
+## Quick start
+
+### Run a textual HCL experiment
+
+The following profile runs Stability-HCL with a Qwen3-4B main model:
+
+```bash
+python main.py \
+ --config configs/run_stability_hcl_01_4b.json \
+ --splits train,test
+```
+
+The equivalent installed command is:
+
+```bash
+hcl run \
+ --config configs/run_stability_hcl_01_4b.json \
+ --splits train,test
+```
+
+Use `--limit-per-task` for a small integration run, and use `--method` to override the method declared by a profile:
+
+```bash
+python main.py \
+ --config configs/deepseek_flash_reasoning_hcl_stability_250_50_500.json \
+ --splits train,test \
+ --limit-per-task 5 \
+ --method hcl
+```
+
+`run.sh` is a reference wrapper around the same entry point. Its first argument may be a configuration path; remaining arguments are forwarded to `main.py`.
+
+### Run the baselines
+
+Use the checked-in baseline profiles directly:
+
+```bash
+# Zero-shot
+python main.py --config configs/deepseek_flash_reasoning_raw_zero_shot_500.json --splits test
+```
+
+See [the configuration inventory](configs/README.md) for the complete reasoning, multimodal, budget-sweep, and distillation profile list.
+
+## Important configuration options
+
+HCL experiments use JSON configurations. The main options are:
+
+```json
+{
+ "method": "hcl",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/experiments/example",
+ "model": {
+ "backend": "chat_completions",
+ "name": "model-name",
+ "base_url": "http://127.0.0.1:8000",
+ "api_key_env": "MODEL_API_KEY"
+ },
+ "optimizer": {
+ "historical_loss_budget": 0
+ }
+}
+```
+
+- `model` generates final answers. `judge_model`, `selection_model`, and `memory_model` can assign optimizer, online-selection, and memory operations to different models.
+- `method` selects the HCL update mode. Supported CLI overrides are `hcl` and `hcl_memory`.
+- `optimizer.historical_loss_budget` controls accepted historical degradation; `null` represents $b=\infty$.
+- `storage_dir` contains generated state, metrics, predictions, and checkpoints and must remain under `storage/` when automatic reset is enabled.
+- `task_flow` controls the named train/test phases selected with `--splits`.
+
+For hosted OpenAI-compatible endpoints, export the credential named by `api_key_env`; do not place secrets in configuration files:
+
+```bash
+export MODEL_API_KEY="your-key"
+```
+
+## Experiments
+
+### ALFWorld
+
+```bash
+cd experiments/HCL_ALFworld
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install -e '.[llm]'
+hcl-alfworld --help
+```
+
+Use the `local` extra for a local Transformers backend. Profiles are in `configs/`, and domain-incremental sequences are in `sequences/`.
+
+### Odyssey / Minecraft
+
+```bash
+cd experiments/Odyssey/Odyssey
+python3 -m venv .venv
+source .venv/bin/activate
+python -m pip install -e .
+cp conf/config.example.json conf/config.json
+python harness_cli.py --help
+python harness_curriculum_cli.py --help
+```
+
+Odyssey additionally requires its Node/Mineflayer bridge, a reachable Minecraft server, and an OpenAI-compatible model endpoint. Local values can be supplied with `ODYSSEY_*` environment variables; `conf/config.json` is ignored by version control.
+
+## Main results
+
+The results below follow the order of the experiments in the main paper. Additional analyses reported only in the appendix are not included here.
+
+### Open-world capability accumulation
+
+#### ALFWorld
+
+We evaluate six sequential ALFWorld task categories with a frozen Qwen3.5-9B model. Plasticity-HCL achieves the highest final average success rate, while Stability-HCL retains comparable performance with substantially less forgetting.
+
+| Method | Pick | Look | Clean | Heat | Cool | Two-object | Final Avg. $\uparrow$ | Avg. Fgt. (%) $\downarrow$ |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| Static Harness | 95.80 | 66.70 | 25.80 | 26.10 | 9.50 | 58.80 | 47.12 | — |
+| RAG Baseline | 95.80 | 83.30 | 41.90 | 39.10 | 14.30 | 58.80 | 55.56 | **1.74** |
+| **Stability-HCL (Ours)** | **100.00** | 83.30 | **51.60** | 30.40 | 28.60 | 76.50 | 61.74 | 2.64 |
+| **Plasticity-HCL (Ours)** | **100.00** | 77.80 | 41.90 | 39.10 | 19.00 | **100.00** | **62.98** | 10.94 |
+
+#### Minecraft
+
+On a 50-task Minecraft curriculum, Plasticity-HCL completes the full sequence in 83 environment actions.
+
+### Controlled-stream continual learning
+
+We next evaluate HCL on textual reasoning and multimodal perception streams. Each task contains 250 adaptation examples, 50 validation examples, and 500 disjoint test examples.
+
+#### Textual reasoning
+
+The stream order is MuSiQue $\rightarrow$ ProofWriter $\rightarrow$ GSM8K $\rightarrow$ HotpotQA, using frozen DeepSeek-V4.1-Flash. Plasticity-HCL obtains the best final average performance of 76.10%.
+
+| Method | MuSiQue | ProofWriter | GSM8K | HotpotQA | Final Avg. $\uparrow$ | Avg. Fgt. (%) $\downarrow$ |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Zero-shot | 48.00 | 79.20 | 65.20 | 62.20 | 63.65 | — |
+| **Plasticity-HCL (Ours)** | **58.60** | **85.60** | **96.00** | **64.20** | **76.10** | 0.40 |
+| **Stability-HCL (Ours)** | 53.60 | 82.80 | 82.60 | 63.60 | 70.65 | 0.27 |
+
+#### Multimodal perception
+
+The stream order is COCO detection $\rightarrow$ COCO captioning $\rightarrow$ RefCOCO grounding $\rightarrow$ VQAv2, using frozen Qwen3.6-27B. Stability-HCL achieves the best final mean of 68.92 with only 0.22% average forgetting.
+
+| Method | Detection | Caption | Grounding | VQAv2 | Final Mean $\uparrow$ | Avg. Fgt. (%) $\downarrow$ |
+| --- | ---: | ---: | ---: | ---: | ---: | ---: |
+| Zero-shot | 37.52 | 21.10 | 43.00 | **85.47** | 46.77 | — |
+| **Plasticity-HCL (Ours)** | 64.14 | 37.31 | 90.60 | 79.80 | 67.96 | 0.81 |
+| **Stability-HCL (Ours)** | **65.34** | **39.41** | **91.60** | 79.33 | **68.92** | 0.22 |
+
+### Stability–plasticity control
+
+We vary the historical-loss budget over $b\in\{0,1,3,\infty\}$ while holding the current-improvement and validity criteria fixed. The strict setting $b=0$ minimizes forgetting, whereas a small tolerance of $b=1$ produces the best final average accuracy. Further relaxation degrades both final accuracy and retention.
+
+| Historical-loss budget $b$ | MuSiQue | ProofWriter | GSM8K | HotpotQA | Final Avg. $\uparrow$ | Avg. Fgt. (%) $\downarrow$ |
+| ---: | ---: | ---: | ---: | ---: | ---: | ---: |
+| $0$ | 27.83 | 73.33 | 84.33 | **59.50** | 61.25 | **0.39** |
+| $1$ | 24.83 | 77.50 | **92.33** | 59.17 | **63.46** | 1.22 |
+| $3$ | 26.83 | **79.83** | 83.00 | 58.50 | 62.04 | 2.00 |
+| $\infty$ | **28.33** | 71.00 | 82.00 | 59.17 | 60.13 | 3.45 |
+
+### External-model assistance
+
+Base-HCL uses Qwen3-4B throughout. Opt-HCL uses Qwen3.6-27B only to generate candidate harness updates, whereas Online-HCL uses the stronger model for per-example online harness operations. Opt-HCL improves final average accuracy from 35.10% to 45.75% while maintaining latency comparable to Base-HCL; Online-HCL reaches 44.80% but incurs substantially higher online latency.
+
+
+
+
+
+### Component ablation
+
+On the controlled multimodal stream with frozen Qwen3.5-4B, each ablation keeps one component available during execution but freezes its persistent updates. All four components contribute to continual adaptation, with freezing Experience Memory causing the largest reduction in final average performance and the most forgetting.
+
+| Variant | Interface | Memory | Capability | Router | Final Avg. $\uparrow$ | Avg. Fgt. (%) $\downarrow$ |
+| --- | :---: | :---: | :---: | :---: | ---: | ---: |
+| Zero-shot | — | — | — | — | 34.84 | — |
+| w/o Interface update | ✗ | ✓ | ✓ | ✓ | 62.37 | 0.11 |
+| w/o Memory update | ✓ | ✗ | ✓ | ✓ | 62.28 | 0.83 |
+| w/o Capability update | ✓ | ✓ | ✗ | ✓ | 63.12 | **0.06** |
+| w/o Router update | ✓ | ✓ | ✓ | ✗ | 62.77 | 0.14 |
+| **Plasticity-HCL** | ✓ | ✓ | ✓ | ✓ | **63.41** | 0.45 |
+
+## Notes
+
+- `main.py`, the installed `hcl` command, and `run.sh` are the recommended core entry points.
+- Generated logs, predictions, checkpoints, caches, simulator state, and credentials are intentionally excluded.
+- Some configuration filenames retain original GPU scheduling labels; the configurations themselves use portable paths and do not require that device index.
+- The ALFWorld and Odyssey environments should be installed separately from the core environment.
+
+## Citation
+
+If you find this repository useful, please cite the HCL paper:
+
+```bibtex
+@misc{kang2026harnesscontinuallearning,
+ title = {Harness Continual Learning: Continual Adaptation Beyond Model Parameters},
+ author = {Borui Kang and Jinrui Gu and Junhan Lv and Wenbin Li and Lei Wang and Yang Gao},
+ year = {2026},
+ eprint = {2608.19013},
+ archivePrefix = {arXiv},
+ primaryClass = {cs.LG},
+ url = {https://arxiv.org/abs/2608.19013}
+}
+```
+
+## License and acknowledgements
+
+The core HCL implementation is released under the [MIT License](LICENSE). The integrations under `experiments/` build on ALFWorld, Odyssey, Mineflayer, and related third-party components; consult their respective licenses before redistribution.
diff --git a/configs/README.md b/configs/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..e182a0ac0f82e83c3d743e7f0c8a7af894ffd2db
--- /dev/null
+++ b/configs/README.md
@@ -0,0 +1,62 @@
+# Experiment configuration inventory
+
+This directory contains the textual-reasoning and multimodal configurations retained for reproducibility. Interactive ALFWorld and Minecraft configurations live under the repository's `experiments/` directory. No metrics, predictions, logs, checkpoints, figures, or other prior-run outputs are included.
+
+## Path convention
+
+- `configs/taskstream_textual_main_250_50_500.json`: included portable manifest for MuSiQue, ProofWriter, GSM8K, and HotpotQA.
+- `configs/taskstream_multimodal_main_250_50_500.json`: included portable manifest for COCO detection, COCO captioning, RefCOCO grounding, and VQAv2.
+- `data/datasets/`: externally supplied dataset splits.
+- `storage/experiments//`: generated logs, caches, checkpoints, manifests, and predictions. `storage/` is ignored by Git.
+
+Relative paths are resolved from the repository root by the CLI configuration loader.
+
+## HCL reasoning experiments
+
+- `deepseek_flash_reasoning_hcl_plasticity_250_50_500.json`
+- `deepseek_flash_reasoning_hcl_stability_250_50_500.json`
+- `qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500.json`
+
+## Independent textual historical-loss-budget sweep
+
+- `deepseek_v4_flash_textual_budget_b0_300_80_80_600.json`
+- `deepseek_v4_flash_textual_budget_b1_300_80_80_600.json`
+- `deepseek_v4_flash_textual_budget_b3_300_80_80_600.json`
+- `deepseek_v4_flash_textual_budget_binf_300_80_80_600.json`
+- `taskstream_textual_budget_300_80_80_600.json`
+
+These four runnable profiles use frozen DeepSeek-V4-Flash, the paper's 300 adaptation / 80 validation / 80 historical-anchor / 600 test allocation, and ten 30-example update batches per task stage. The profiles are identical in experimental settings except for `optimizer.historical_loss_budget`, which is 0, 1, 3, or `null` ($B_n=\infty$). The optimizer generates three alternatives inside each update opportunity, matching the recovered experiment configuration; that internal multiplicity is distinct from the paper's count of ten proposal opportunities per task stage.
+
+## Distillation comparison experiments
+
+- `run_stability_hcl_01_4b.json` (`Base-HCL`): Qwen3-4B performs final answering, online structuring and selection, optimizer candidate generation, and memory operations.
+- `run_stability_hcl_02_4b_opt27b.json` (`Opt-HCL`): Qwen3.6-27B generates optimizer candidates; Qwen3-4B retains all online and memory roles.
+- `run_stability_hcl_03_4b_teacher27b.json` (`Online-HCL`): Qwen3.6-27B performs online task structuring, workflow and artifact selection, and memory operations; Qwen3-4B retains final answering and optimizer candidate generation.
+- `taskstream_textual_main_250_50_500.json`: the portable MuSiQue, ProofWriter, GSM8K, and HotpotQA task stream shared by all three runs.
+
+The three profiles share the same Stability-HCL gate, three optimizable components, candidate budget, task stream, data allocation, memory limits, and update schedule. With a batch size of 300 and 250 adaptation examples per task, each task stage has one optimizer update opportunity. The `selection_model` and `memory_model` fields make the Online-HCL role split explicit; when absent, both roles fall back to the main model.
+
+The local checkpoints are expected at `models/Qwen3-4B/` and `models/Qwen3.6-27B/`. These directories are not included. Paths can be changed to checkpoints supplied by the evaluation environment without changing the experimental method or hyperparameters.
+
+## Reasoning baselines
+
+- `deepseek_flash_reasoning_raw_zero_shot_500.json`
+
+## Multimodal experiments and baselines
+
+- `qwen36_27b_coco_gpu1_direct_raw_zero_shot_500.json`
+- `qwen36_27b_coco_gpu1_v0_harness_500.json`
+- `qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500.json`
+- `qwen36_27b_coco_gpu1_hcl_stability_250_50_500.json`
+
+## Task-stream configurations
+
+- `taskstream_textual_main_250_50_500.json`
+- `taskstream_multimodal_main_250_50_500.json`
+- `taskstream_textual_budget_300_80_80_600.json`
+
+Configuration filenames containing a GPU number preserve the original experiment-to-device scheduling label. They contain no hostname, username, institution, filesystem location, or hardware identifier unique to the authors, and do not require that same device number when reproduced.
+
+The previous component-ablation matrix is intentionally not included as a paper configuration: it used Qwen3.6-27B and disabled runtime components, whereas the paper specifies Qwen3.5-4B and freezes only persistent updates while keeping each component available. A search of the additional experiment server found no Qwen3.5-4B four-way persistent-update-freeze configuration, so this remains unresolved rather than being approximated.
+
+The stronger-model comparison is the Stability-HCL series above. Earlier `run_paper_*` profiles represented a separate four-run study and are intentionally excluded to avoid conflating that study with the paper's Base/Opt/Online comparison.
diff --git a/configs/coco_stability_125_50_250_local.json b/configs/coco_stability_125_50_250_local.json
new file mode 100644
index 0000000000000000000000000000000000000000..c702e83fd2ec4328b0b565186fd1c0e455b8d2e0
--- /dev/null
+++ b/configs/coco_stability_125_50_250_local.json
@@ -0,0 +1,137 @@
+{
+ "project_name": "Qwen3.6-27B COCO HCL stability 125-50-250 local validation",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_path": "storage/experiments/coco_stability_125_50_250_local/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_hcl_stability",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": 0,
+ "max_forget_count": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/coco_stability_125_50_250_local",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/coco_stability_125_50_250_local/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/coco_stability_125_50_250_local/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 125,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 250
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 250,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_flash_reasoning_hcl_plasticity_250_50_500.json b/configs/deepseek_flash_reasoning_hcl_plasticity_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..7b1677b7c5636276a5652395e5c3823d79aede04
--- /dev/null
+++ b/configs/deepseek_flash_reasoning_hcl_plasticity_250_50_500.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4.1-Flash reasoning HCL plasticity 250-50-500",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-flash",
+ "paper_model_name": "DeepSeek-V4.1-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 1024,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {
+ "type": "disabled"
+ },
+ "cleanup_output": true,
+ "json_object_for_structured_phases": false,
+ "system_prompt": "You are a concise reasoning assistant. Follow the requested output schema and return only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 10,
+ "log_path": "storage/experiments/deepseek_flash_reasoning_hcl_plasticity_250_50_500/llm_calls.txt",
+ "log_prompt_chars": 12000
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 768,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 768,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 8192
+ },
+ "router": {
+ "route_name": "deepseek_flash_reasoning_hcl_plasticity",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": false,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 0,
+ "workflow_selector_max_new_tokens": 512,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled",
+ "capability_service_url": null,
+ "semantic_search_enabled": false,
+ "cross_modal_match_enabled": false
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": null,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 256,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/deepseek_flash_reasoning_hcl_plasticity_250_50_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/deepseek_flash_reasoning_hcl_plasticity_250_50_500/prediction_cache/prediction_cache.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/deepseek_flash_reasoning_hcl_plasticity_250_50_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_flash_reasoning_hcl_stability_250_50_500.json b/configs/deepseek_flash_reasoning_hcl_stability_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..56ccf90c39a063ada27f05b2a58a2f9187cccf74
--- /dev/null
+++ b/configs/deepseek_flash_reasoning_hcl_stability_250_50_500.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4.1-Flash reasoning HCL stability 250-50-500",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-flash",
+ "paper_model_name": "DeepSeek-V4.1-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 1024,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {
+ "type": "disabled"
+ },
+ "cleanup_output": true,
+ "json_object_for_structured_phases": false,
+ "system_prompt": "You are a concise reasoning assistant. Follow the requested output schema and return only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 10,
+ "log_path": "storage/experiments/deepseek_flash_reasoning_hcl_stability_250_50_500/llm_calls.txt",
+ "log_prompt_chars": 12000
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 768,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 768,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 8192
+ },
+ "router": {
+ "route_name": "deepseek_flash_reasoning_hcl_stability",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": false,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 0,
+ "workflow_selector_max_new_tokens": 512,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled",
+ "capability_service_url": null,
+ "semantic_search_enabled": false,
+ "cross_modal_match_enabled": false
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 256,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/deepseek_flash_reasoning_hcl_stability_250_50_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/deepseek_flash_reasoning_hcl_stability_250_50_500/prediction_cache/prediction_cache.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/deepseek_flash_reasoning_hcl_stability_250_50_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_flash_reasoning_raw_zero_shot_500.json b/configs/deepseek_flash_reasoning_raw_zero_shot_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..6325af46d4918b0a5d2b4f0ee7e4258ecd24d50d
--- /dev/null
+++ b/configs/deepseek_flash_reasoning_raw_zero_shot_500.json
@@ -0,0 +1,29 @@
+{
+ "project_name": "DeepSeek Flash reasoning raw zero-shot 4-task 500-test",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "split": "test",
+ "limit_per_task": 500,
+ "workers": 4,
+ "resume": true,
+ "output_dir": "storage/experiments/deepseek_flash_reasoning_raw_zero_shot_500",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-flash",
+ "paper_model_name": "DeepSeek-V4.1-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {
+ "type": "disabled"
+ },
+ "cleanup_output": true,
+ "enable_image_input": false,
+ "system_prompt": "You are a concise reasoning assistant. Follow the requested output format and return only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 10,
+ "log_path": "storage/experiments/deepseek_flash_reasoning_raw_zero_shot_500/llm_calls.txt",
+ "log_prompt_chars": 12000
+ }
+}
diff --git a/configs/deepseek_v4_flash_textual_budget_b0_300_80_80_600.json b/configs/deepseek_v4_flash_textual_budget_b0_300_80_80_600.json
new file mode 100644
index 0000000000000000000000000000000000000000..7ff3b6275e5f8f1a0e0cf8beb6785dc467592f06
--- /dev/null
+++ b/configs/deepseek_v4_flash_textual_budget_b0_300_80_80_600.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4-Flash textual budget sweep b=0 300-80-80-600",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-v4-flash",
+ "paper_model_name": "DeepSeek-V4-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {"type": "disabled"},
+ "reasoning_effort": null,
+ "system_prompt": "You are a concise reasoning assistant. Reason internally and return only the final answer in the requested format.",
+ "timeout_seconds": 300,
+ "max_retries": 3,
+ "cleanup_output": true
+ },
+ "execution": {
+ "read_only_workers": 8,
+ "run_train_final": false
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 2048,
+ "invalid_output_retries": 2,
+ "retry_max_new_tokens": 2048,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 5000,
+ "visible_context_max_chars": 12000,
+ "visible_context_max_items": 8
+ },
+ "router": {
+ "route_name": "direct",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "tool_auto_select": true,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 2,
+ "tool_strict_schema": false,
+ "tool_js_timeout_seconds": 8.0,
+ "skill_selector_max_new_tokens": 512,
+ "skill_selector_invalid_output_retries": 2,
+ "skill_selector_retry_max_new_tokens": 512,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_max_new_tokens": 512,
+ "tool_selector_invalid_output_retries": 2,
+ "tool_selector_retry_max_new_tokens": 512,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_max_new_tokens": 512,
+ "memory_selector_invalid_output_retries": 2,
+ "memory_selector_retry_max_new_tokens": 512,
+ "memory_selector_fallback_to_empty_on_invalid": true
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "paper_historical_loss_budget",
+ "historical_loss_budget": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 2,
+ "min_format_compliance_rate": 0.9,
+ "selection_mode": "argmax",
+ "min_historical_anchor_count": 80,
+ "min_historical_anchors_per_task": 80,
+ "require_all_historical_tasks": true,
+ "min_anchor_evaluation_coverage": 1.0,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 15,
+ "abstract_capacity_per_task": 2,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_capacity_per_task": 80,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 8,
+ "anchor_candidate_limit": 80,
+ "anchor_selection_max_new_tokens": 256,
+ "retrieval_top_k": 2,
+ "retrieval_candidate_limit": 12,
+ "reset_on_start": true,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "skill_max_count": 6,
+ "skill_min_source_tasks": 2
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 80,
+ "target_correct_ratio": 0.8,
+ "seed": 20260713,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_budget_300_80_80_600.json",
+ "reset_run_on_start": true,
+ "storage_dir": "storage/textual_budget_sweep/b0",
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b0/checkpoint.json",
+ "resume": true
+ },
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b0/prediction_cache/read_only_predictions.jsonl"
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 30,
+ "train_limit_per_task": 300,
+ "validation_limit_per_task": 80,
+ "test_limit_per_task": 600
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 600,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_v4_flash_textual_budget_b1_300_80_80_600.json b/configs/deepseek_v4_flash_textual_budget_b1_300_80_80_600.json
new file mode 100644
index 0000000000000000000000000000000000000000..ad581dea5f4bbee0f099c77388859325f50b62f9
--- /dev/null
+++ b/configs/deepseek_v4_flash_textual_budget_b1_300_80_80_600.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4-Flash textual budget sweep b=1 300-80-80-600",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-v4-flash",
+ "paper_model_name": "DeepSeek-V4-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {"type": "disabled"},
+ "reasoning_effort": null,
+ "system_prompt": "You are a concise reasoning assistant. Reason internally and return only the final answer in the requested format.",
+ "timeout_seconds": 300,
+ "max_retries": 3,
+ "cleanup_output": true
+ },
+ "execution": {
+ "read_only_workers": 8,
+ "run_train_final": false
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 2048,
+ "invalid_output_retries": 2,
+ "retry_max_new_tokens": 2048,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 5000,
+ "visible_context_max_chars": 12000,
+ "visible_context_max_items": 8
+ },
+ "router": {
+ "route_name": "direct",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "tool_auto_select": true,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 2,
+ "tool_strict_schema": false,
+ "tool_js_timeout_seconds": 8.0,
+ "skill_selector_max_new_tokens": 512,
+ "skill_selector_invalid_output_retries": 2,
+ "skill_selector_retry_max_new_tokens": 512,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_max_new_tokens": 512,
+ "tool_selector_invalid_output_retries": 2,
+ "tool_selector_retry_max_new_tokens": 512,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_max_new_tokens": 512,
+ "memory_selector_invalid_output_retries": 2,
+ "memory_selector_retry_max_new_tokens": 512,
+ "memory_selector_fallback_to_empty_on_invalid": true
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "paper_historical_loss_budget",
+ "historical_loss_budget": 1,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 2,
+ "min_format_compliance_rate": 0.9,
+ "selection_mode": "argmax",
+ "min_historical_anchor_count": 80,
+ "min_historical_anchors_per_task": 80,
+ "require_all_historical_tasks": true,
+ "min_anchor_evaluation_coverage": 1.0,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 15,
+ "abstract_capacity_per_task": 2,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_capacity_per_task": 80,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 8,
+ "anchor_candidate_limit": 80,
+ "anchor_selection_max_new_tokens": 256,
+ "retrieval_top_k": 2,
+ "retrieval_candidate_limit": 12,
+ "reset_on_start": true,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "skill_max_count": 6,
+ "skill_min_source_tasks": 2
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 80,
+ "target_correct_ratio": 0.8,
+ "seed": 20260713,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_budget_300_80_80_600.json",
+ "reset_run_on_start": true,
+ "storage_dir": "storage/textual_budget_sweep/b1",
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b1/checkpoint.json",
+ "resume": true
+ },
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b1/prediction_cache/read_only_predictions.jsonl"
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 30,
+ "train_limit_per_task": 300,
+ "validation_limit_per_task": 80,
+ "test_limit_per_task": 600
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 600,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_v4_flash_textual_budget_b3_300_80_80_600.json b/configs/deepseek_v4_flash_textual_budget_b3_300_80_80_600.json
new file mode 100644
index 0000000000000000000000000000000000000000..892a30e649af3d4df7c50d2e0370da3db0680c6b
--- /dev/null
+++ b/configs/deepseek_v4_flash_textual_budget_b3_300_80_80_600.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4-Flash textual budget sweep b=3 300-80-80-600",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-v4-flash",
+ "paper_model_name": "DeepSeek-V4-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {"type": "disabled"},
+ "reasoning_effort": null,
+ "system_prompt": "You are a concise reasoning assistant. Reason internally and return only the final answer in the requested format.",
+ "timeout_seconds": 300,
+ "max_retries": 3,
+ "cleanup_output": true
+ },
+ "execution": {
+ "read_only_workers": 8,
+ "run_train_final": false
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 2048,
+ "invalid_output_retries": 2,
+ "retry_max_new_tokens": 2048,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 5000,
+ "visible_context_max_chars": 12000,
+ "visible_context_max_items": 8
+ },
+ "router": {
+ "route_name": "direct",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "tool_auto_select": true,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 2,
+ "tool_strict_schema": false,
+ "tool_js_timeout_seconds": 8.0,
+ "skill_selector_max_new_tokens": 512,
+ "skill_selector_invalid_output_retries": 2,
+ "skill_selector_retry_max_new_tokens": 512,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_max_new_tokens": 512,
+ "tool_selector_invalid_output_retries": 2,
+ "tool_selector_retry_max_new_tokens": 512,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_max_new_tokens": 512,
+ "memory_selector_invalid_output_retries": 2,
+ "memory_selector_retry_max_new_tokens": 512,
+ "memory_selector_fallback_to_empty_on_invalid": true
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "paper_historical_loss_budget",
+ "historical_loss_budget": 3,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 2,
+ "min_format_compliance_rate": 0.9,
+ "selection_mode": "argmax",
+ "min_historical_anchor_count": 80,
+ "min_historical_anchors_per_task": 80,
+ "require_all_historical_tasks": true,
+ "min_anchor_evaluation_coverage": 1.0,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 15,
+ "abstract_capacity_per_task": 2,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_capacity_per_task": 80,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 8,
+ "anchor_candidate_limit": 80,
+ "anchor_selection_max_new_tokens": 256,
+ "retrieval_top_k": 2,
+ "retrieval_candidate_limit": 12,
+ "reset_on_start": true,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "skill_max_count": 6,
+ "skill_min_source_tasks": 2
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 80,
+ "target_correct_ratio": 0.8,
+ "seed": 20260713,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_budget_300_80_80_600.json",
+ "reset_run_on_start": true,
+ "storage_dir": "storage/textual_budget_sweep/b3",
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b3/checkpoint.json",
+ "resume": true
+ },
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/b3/prediction_cache/read_only_predictions.jsonl"
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 30,
+ "train_limit_per_task": 300,
+ "validation_limit_per_task": 80,
+ "test_limit_per_task": 600
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 600,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/deepseek_v4_flash_textual_budget_binf_300_80_80_600.json b/configs/deepseek_v4_flash_textual_budget_binf_300_80_80_600.json
new file mode 100644
index 0000000000000000000000000000000000000000..88616331cefbff7ed5186075b86c747f82e7f7f0
--- /dev/null
+++ b/configs/deepseek_v4_flash_textual_budget_binf_300_80_80_600.json
@@ -0,0 +1,142 @@
+{
+ "project_name": "DeepSeek-V4-Flash textual budget sweep b=inf 300-80-80-600",
+ "model": {
+ "backend": "chat_completions",
+ "name": "deepseek-v4-flash",
+ "paper_model_name": "DeepSeek-V4-Flash",
+ "base_url": "https://api.deepseek.com",
+ "api_key_env": "DEEPSEEK_API_KEY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "thinking": {"type": "disabled"},
+ "reasoning_effort": null,
+ "system_prompt": "You are a concise reasoning assistant. Reason internally and return only the final answer in the requested format.",
+ "timeout_seconds": 300,
+ "max_retries": 3,
+ "cleanup_output": true
+ },
+ "execution": {
+ "read_only_workers": 8,
+ "run_train_final": false
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 2048,
+ "invalid_output_retries": 2,
+ "retry_max_new_tokens": 2048,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 5000,
+ "visible_context_max_chars": 12000,
+ "visible_context_max_items": 8
+ },
+ "router": {
+ "route_name": "direct",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "tool_auto_select": true,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 2,
+ "tool_strict_schema": false,
+ "tool_js_timeout_seconds": 8.0,
+ "skill_selector_max_new_tokens": 512,
+ "skill_selector_invalid_output_retries": 2,
+ "skill_selector_retry_max_new_tokens": 512,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_max_new_tokens": 512,
+ "tool_selector_invalid_output_retries": 2,
+ "tool_selector_retry_max_new_tokens": 512,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_max_new_tokens": 512,
+ "memory_selector_invalid_output_retries": 2,
+ "memory_selector_retry_max_new_tokens": 512,
+ "memory_selector_fallback_to_empty_on_invalid": true
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "paper_historical_loss_budget",
+ "historical_loss_budget": null,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 2,
+ "min_format_compliance_rate": 0.9,
+ "selection_mode": "argmax",
+ "min_historical_anchor_count": 80,
+ "min_historical_anchors_per_task": 80,
+ "require_all_historical_tasks": true,
+ "min_anchor_evaluation_coverage": 1.0,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 15,
+ "abstract_capacity_per_task": 2,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_capacity_per_task": 80,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 8,
+ "anchor_candidate_limit": 80,
+ "anchor_selection_max_new_tokens": 256,
+ "retrieval_top_k": 2,
+ "retrieval_candidate_limit": 12,
+ "reset_on_start": true,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "skill_max_count": 6,
+ "skill_min_source_tasks": 2
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 80,
+ "target_correct_ratio": 0.8,
+ "seed": 20260713,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_budget_300_80_80_600.json",
+ "reset_run_on_start": true,
+ "storage_dir": "storage/textual_budget_sweep/binf",
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/binf/checkpoint.json",
+ "resume": true
+ },
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/textual_budget_sweep/binf/prediction_cache/read_only_predictions.jsonl"
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 30,
+ "train_limit_per_task": 300,
+ "validation_limit_per_task": 80,
+ "test_limit_per_task": 600
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 600,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/full_coco_stability_250_50_500_local.json b/configs/full_coco_stability_250_50_500_local.json
new file mode 100644
index 0000000000000000000000000000000000000000..eaee1511c61e864f7e29eae2d4b31b140450a2d2
--- /dev/null
+++ b/configs/full_coco_stability_250_50_500_local.json
@@ -0,0 +1,137 @@
+{
+ "project_name": "Qwen3.6-27B COCO HCL stability full 250-50-500 local validation",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_path": "storage/experiments/full_coco_stability_250_50_500_local/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_hcl_stability",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": 0,
+ "max_forget_count": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/full_coco_stability_250_50_500_local",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/full_coco_stability_250_50_500_local/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/full_coco_stability_250_50_500_local/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/qwen36_27b_coco_gpu1_direct_raw_zero_shot_500.json b/configs/qwen36_27b_coco_gpu1_direct_raw_zero_shot_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..a34c285dc8a8ea4a522b5ceb4e55e7d2a28f627b
--- /dev/null
+++ b/configs/qwen36_27b_coco_gpu1_direct_raw_zero_shot_500.json
@@ -0,0 +1,27 @@
+{
+ "project_name": "Qwen3.6-27B COCO corrected multimodal raw zero-shot 500-test",
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "split": "test",
+ "limit_per_task": 500,
+ "resume": true,
+ "output_dir": "storage/experiments/qwen36_27b_coco_gpu1_direct_raw_zero_shot_500",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8007",
+ "api_key": "EMPTY",
+ "max_new_tokens": 1024,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly and output only the final answer in the requested format.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_prompt_chars": 8000
+ }
+}
diff --git a/configs/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500.json b/configs/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..2e6a299dfdd5789c8a914f8523b31af6da26e1ec
--- /dev/null
+++ b/configs/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500.json
@@ -0,0 +1,137 @@
+{
+ "project_name": "Qwen3.6-27B COCO HCL plasticity 250-50-500 migrated",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8007",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_hcl_plasticity",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": null,
+ "max_forget_count": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_plasticity_250_50_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/qwen36_27b_coco_gpu1_hcl_stability_250_50_500.json b/configs/qwen36_27b_coco_gpu1_hcl_stability_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..7d2d0fc354e480c5e4d7848905ef2551abd29d42
--- /dev/null
+++ b/configs/qwen36_27b_coco_gpu1_hcl_stability_250_50_500.json
@@ -0,0 +1,137 @@
+{
+ "project_name": "Qwen3.6-27B COCO HCL stability 250-50-500 migrated",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8007",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_stability_250_50_500/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_hcl_stability",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": 0,
+ "max_forget_count": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/qwen36_27b_coco_gpu1_hcl_stability_250_50_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_stability_250_50_500/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_hcl_stability_250_50_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/qwen36_27b_coco_gpu1_v0_harness_500.json b/configs/qwen36_27b_coco_gpu1_v0_harness_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..33ca692f4e19c19b978a31a0c9bf5de9ea3e2f19
--- /dev/null
+++ b/configs/qwen36_27b_coco_gpu1_v0_harness_500.json
@@ -0,0 +1,99 @@
+{
+ "project_name": "Qwen3.6-27B COCO fixed v0 harness 500-test migrated",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8007",
+ "api_key": "EMPTY",
+ "max_new_tokens": 1024,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "log_path": "storage/experiments/qwen36_27b_coco_gpu1_v0_harness_500/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": false
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_v0_harness",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 0,
+ "components": []
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm"
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/qwen36_27b_coco_gpu1_v0_harness_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_v0_harness_500/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_coco_gpu1_v0_harness_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500.json b/configs/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..ba17196db3b2a4234f83cdc2cde496d6e1890730
--- /dev/null
+++ b/configs/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500.json
@@ -0,0 +1,153 @@
+{
+ "project_name": "Qwen3.6-27B Stability-HCL with router-selected external capabilities reasoning 250-50-500",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-capabilities-gpu0",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8006",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "thinking": null,
+ "reasoning_effort": null,
+ "system_prompt": "You are a concise reasoning assistant. Reason internally, use relevant tool evidence when available, follow the requested output schema exactly, and return only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 20,
+ "cleanup_output": true,
+ "log_path": "storage/experiments/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "llm",
+ "max_new_tokens": 1024,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true,
+ "long_context_rule_threshold_chars": 8192
+ },
+ "router": {
+ "route_name": "qwen36_27b_reasoning_hcl_stability_capabilities_router_fixed",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": false,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "tool_auto_select": true,
+ "use_chat_tool_calls": false,
+ "tool_max_rounds": 0,
+ "tool_strict_schema": false,
+ "tool_js_timeout_seconds": 8.0,
+ "tool_selector_max_new_tokens": 512,
+ "tool_selector_invalid_output_retries": 1,
+ "tool_selector_retry_max_new_tokens": 512,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_max_new_tokens": 512,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 512,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_max_new_tokens": 512,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 512,
+ "capability_enabled": true,
+ "capability_execution_mode": "router_managed",
+ "capability_service_url": "http://127.0.0.1:8091",
+ "capability_timeout_seconds": 30,
+ "capability_allowed_corpora": [
+ "reasoning_train_corpus"
+ ],
+ "capability_default_corpus_id": "reasoning_train_corpus",
+ "capability_default_top_k": 5,
+ "capability_continue_on_error": true,
+ "capability_force_semantic_search": false,
+ "semantic_search_enabled": true,
+ "cross_modal_match_enabled": true
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "stability",
+ "commit_policy": "strict_no_forgetting",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 0,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.85,
+ "recover_reward": 1.0,
+ "forget_penalty": 2.0,
+ "min_anchor_score": 0.0,
+ "min_anchor_net_score": 0.0,
+ "max_anchor_forget_rate": 0.0,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "tool_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 256,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "reset_run_on_start": false,
+ "storage_dir": "storage/experiments/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/experiments/qwen36_27b_reasoning_hcl_stability_capabilities_250_50_500/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/run_stability_hcl_01_4b.json b/configs/run_stability_hcl_01_4b.json
new file mode 100644
index 0000000000000000000000000000000000000000..5a811f5c5148f2478c4c00abc593388173c6384e
--- /dev/null
+++ b/configs/run_stability_hcl_01_4b.json
@@ -0,0 +1,96 @@
+{
+ "project_name": "hcl_stability_01_4b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/experiments/stability_hcl_01_4b",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_01_4b/llm_calls.txt"
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500
+ }
+ ]
+}
diff --git a/configs/run_stability_hcl_02_4b_opt27b.json b/configs/run_stability_hcl_02_4b_opt27b.json
new file mode 100644
index 0000000000000000000000000000000000000000..4dc9b48eeba974b80bafa8e1bb7d9ca15f049bd6
--- /dev/null
+++ b/configs/run_stability_hcl_02_4b_opt27b.json
@@ -0,0 +1,108 @@
+{
+ "project_name": "hcl_stability_02_4b_opt27b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/experiments/stability_hcl_02_4b_opt27b",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_02_4b_opt27b/llm_calls.txt"
+ },
+ "judge_model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.6-27B",
+ "path": "models/Qwen3.6-27B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_02_4b_opt27b/judge_llm_calls.txt"
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500
+ }
+ ]
+}
diff --git a/configs/run_stability_hcl_03_4b_teacher27b.json b/configs/run_stability_hcl_03_4b_teacher27b.json
new file mode 100644
index 0000000000000000000000000000000000000000..9da365c8ec14ee322f1e6ba8c391c3c8b7c00d9d
--- /dev/null
+++ b/configs/run_stability_hcl_03_4b_teacher27b.json
@@ -0,0 +1,120 @@
+{
+ "project_name": "hcl_stability_03_4b_teacher27b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/experiments/stability_hcl_03_4b_teacher27b",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_03_4b_teacher27b/llm_calls.txt"
+ },
+ "selection_model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.6-27B",
+ "path": "models/Qwen3.6-27B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_03_4b_teacher27b/teacher_llm_calls.txt"
+ },
+ "memory_model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.6-27B",
+ "path": "models/Qwen3.6-27B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/experiments/stability_hcl_03_4b_teacher27b/teacher_llm_calls.txt"
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 250,
+ "validation_limit_per_task": 50,
+ "test_limit_per_task": 500
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 500
+ }
+ ]
+}
diff --git a/configs/taskstream_multimodal_main_250_50_500.json b/configs/taskstream_multimodal_main_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..9c741cefdb4f18e07b4d5dd73375b340786e9826
--- /dev/null
+++ b/configs/taskstream_multimodal_main_250_50_500.json
@@ -0,0 +1,50 @@
+{
+ "description": "Paper-aligned multimodal stream manifest. Images and the exact sampled JSONL records are external data and are not redistributed.",
+ "paper_order": ["coco_detection", "coco_caption", "refcoco_grounding", "vqav2"],
+ "tasks": [
+ {
+ "task_name": "coco_detection",
+ "task_type": "coco_detection",
+ "modality": ["text", "image"],
+ "splits": {
+ "train": "data/datasets/coco_detection/train.jsonl",
+ "validation": "data/datasets/coco_detection/validation.jsonl",
+ "anchor": "data/datasets/coco_detection/validation.jsonl",
+ "test": "data/datasets/coco_detection/test.jsonl"
+ }
+ },
+ {
+ "task_name": "coco_caption",
+ "task_type": "coco_caption",
+ "modality": ["text", "image"],
+ "splits": {
+ "train": "data/datasets/coco_caption/train.jsonl",
+ "validation": "data/datasets/coco_caption/validation.jsonl",
+ "anchor": "data/datasets/coco_caption/validation.jsonl",
+ "test": "data/datasets/coco_caption/test.jsonl"
+ }
+ },
+ {
+ "task_name": "refcoco_grounding",
+ "task_type": "refcoco_grounding",
+ "modality": ["text", "image"],
+ "splits": {
+ "train": "data/datasets/refcoco_grounding/train.jsonl",
+ "validation": "data/datasets/refcoco_grounding/validation.jsonl",
+ "anchor": "data/datasets/refcoco_grounding/validation.jsonl",
+ "test": "data/datasets/refcoco_grounding/test.jsonl"
+ }
+ },
+ {
+ "task_name": "vqav2",
+ "task_type": "vqa",
+ "modality": ["text", "image"],
+ "splits": {
+ "train": "data/datasets/vqav2/train.jsonl",
+ "validation": "data/datasets/vqav2/validation.jsonl",
+ "anchor": "data/datasets/vqav2/validation.jsonl",
+ "test": "data/datasets/vqav2/test.jsonl"
+ }
+ }
+ ]
+}
diff --git a/configs/taskstream_multimodal_recovered_500_100_500.json b/configs/taskstream_multimodal_recovered_500_100_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..ac1c51c7ac0a05ca316a390adc9512ffda31fcd2
--- /dev/null
+++ b/configs/taskstream_multimodal_recovered_500_100_500.json
@@ -0,0 +1,55 @@
+{
+ "stream_name": "multimodal_hcl_stream",
+ "description": "Recovered four-task multimodal 500/100/500 split manifest. Dataset records and images are external.",
+ "paper_order": ["coco_detection", "coco_caption", "refcoco_grounding", "vqav2"],
+ "tasks": [
+ {
+ "task_name": "coco_detection",
+ "task_type": "coco_detection",
+ "modality": ["image", "text"],
+ "interactions": 1,
+ "splits": {
+ "train": "data/datasets_multimodal_500_100_500/coco_detection/train.json",
+ "validation": "data/datasets_multimodal_500_100_500/coco_detection/validation.json",
+ "anchor": "data/datasets_multimodal_500_100_500/coco_detection/validation.json",
+ "test": "data/datasets_multimodal_500_100_500/coco_detection/test.json"
+ }
+ },
+ {
+ "task_name": "coco_caption",
+ "task_type": "coco_caption",
+ "modality": ["image", "text"],
+ "interactions": 1,
+ "splits": {
+ "train": "data/datasets_multimodal_500_100_500/coco_caption/train.json",
+ "validation": "data/datasets_multimodal_500_100_500/coco_caption/validation.json",
+ "anchor": "data/datasets_multimodal_500_100_500/coco_caption/validation.json",
+ "test": "data/datasets_multimodal_500_100_500/coco_caption/test.json"
+ }
+ },
+ {
+ "task_name": "refcoco_grounding",
+ "task_type": "refcoco_grounding",
+ "modality": ["image", "text"],
+ "interactions": 1,
+ "splits": {
+ "train": "data/datasets_multimodal_500_100_500/refcoco_grounding/train.json",
+ "validation": "data/datasets_multimodal_500_100_500/refcoco_grounding/validation.json",
+ "anchor": "data/datasets_multimodal_500_100_500/refcoco_grounding/validation.json",
+ "test": "data/datasets_multimodal_500_100_500/refcoco_grounding/test.json"
+ }
+ },
+ {
+ "task_name": "vqav2",
+ "task_type": "vqa",
+ "modality": ["image", "text"],
+ "interactions": 1,
+ "splits": {
+ "train": "data/datasets_multimodal_500_100_500/vqav2/train.json",
+ "validation": "data/datasets_multimodal_500_100_500/vqav2/validation.json",
+ "anchor": "data/datasets_multimodal_500_100_500/vqav2/validation.json",
+ "test": "data/datasets_multimodal_500_100_500/vqav2/test.json"
+ }
+ }
+ ]
+}
diff --git a/configs/taskstream_textual_budget_300_80_80_600.json b/configs/taskstream_textual_budget_300_80_80_600.json
new file mode 100644
index 0000000000000000000000000000000000000000..2f36c8bf6868bd7df46f2f92d82504175467b092
--- /dev/null
+++ b/configs/taskstream_textual_budget_300_80_80_600.json
@@ -0,0 +1,50 @@
+{
+ "description": "Paper-aligned independent textual budget-sweep stream. Exact split files are external; their provenance and fingerprints are documented in data/README.md.",
+ "paper_order": ["musique", "proofwriter", "gsm8k", "hotpotqa"],
+ "tasks": [
+ {
+ "task_name": "musique",
+ "task_type": "compositional_multi_hop_qa",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets_budget_300_80_80_600/musique/train.jsonl",
+ "validation": "data/datasets_budget_300_80_80_600/musique/validation.jsonl",
+ "anchor": "data/datasets_budget_300_80_80_600/musique/anchor.jsonl",
+ "test": "data/datasets_budget_300_80_80_600/musique/test.jsonl"
+ }
+ },
+ {
+ "task_name": "proofwriter",
+ "task_type": "logical_reasoning",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets_budget_300_80_80_600/proofwriter/train.jsonl",
+ "validation": "data/datasets_budget_300_80_80_600/proofwriter/validation.jsonl",
+ "anchor": "data/datasets_budget_300_80_80_600/proofwriter/anchor.jsonl",
+ "test": "data/datasets_budget_300_80_80_600/proofwriter/test.jsonl"
+ }
+ },
+ {
+ "task_name": "gsm8k",
+ "task_type": "math_word_problem",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets_budget_300_80_80_600/gsm8k/train.jsonl",
+ "validation": "data/datasets_budget_300_80_80_600/gsm8k/validation.jsonl",
+ "anchor": "data/datasets_budget_300_80_80_600/gsm8k/anchor.jsonl",
+ "test": "data/datasets_budget_300_80_80_600/gsm8k/test.jsonl"
+ }
+ },
+ {
+ "task_name": "hotpotqa",
+ "task_type": "knowledge_intensive_multi_hop_qa",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets_budget_300_80_80_600/hotpotqa/train.jsonl",
+ "validation": "data/datasets_budget_300_80_80_600/hotpotqa/validation.jsonl",
+ "anchor": "data/datasets_budget_300_80_80_600/hotpotqa/anchor.jsonl",
+ "test": "data/datasets_budget_300_80_80_600/hotpotqa/test.jsonl"
+ }
+ }
+ ]
+}
diff --git a/configs/taskstream_textual_main_250_50_500.json b/configs/taskstream_textual_main_250_50_500.json
new file mode 100644
index 0000000000000000000000000000000000000000..d7b2815ccce66e5ea5fb483ecc0e243d411f699a
--- /dev/null
+++ b/configs/taskstream_textual_main_250_50_500.json
@@ -0,0 +1,50 @@
+{
+ "description": "Paper-aligned textual stream manifest. The exact sampled JSONL records are external data and are not redistributed.",
+ "paper_order": ["musique", "proofwriter", "gsm8k", "hotpotqa"],
+ "tasks": [
+ {
+ "task_name": "musique",
+ "task_type": "compositional_multi_hop_qa",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets/musique/train.jsonl",
+ "validation": "data/datasets/musique/validation.jsonl",
+ "anchor": "data/datasets/musique/anchor.jsonl",
+ "test": "data/datasets/musique/test.jsonl"
+ }
+ },
+ {
+ "task_name": "proofwriter",
+ "task_type": "logical_reasoning",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets/proofwriter/train.jsonl",
+ "validation": "data/datasets/proofwriter/validation.jsonl",
+ "anchor": "data/datasets/proofwriter/anchor.jsonl",
+ "test": "data/datasets/proofwriter/test.jsonl"
+ }
+ },
+ {
+ "task_name": "gsm8k",
+ "task_type": "math_word_problem",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets/gsm8k/train.jsonl",
+ "validation": "data/datasets/gsm8k/validation.jsonl",
+ "anchor": "data/datasets/gsm8k/anchor.jsonl",
+ "test": "data/datasets/gsm8k/test.jsonl"
+ }
+ },
+ {
+ "task_name": "hotpotqa",
+ "task_type": "knowledge_intensive_multi_hop_qa",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/datasets/hotpotqa/train.jsonl",
+ "validation": "data/datasets/hotpotqa/validation.jsonl",
+ "anchor": "data/datasets/hotpotqa/anchor.jsonl",
+ "test": "data/datasets/hotpotqa/test.jsonl"
+ }
+ }
+ ]
+}
diff --git a/configs/validation_coco_distill_01_limit1.json b/configs/validation_coco_distill_01_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..f7951906baa913547ef50f49eb75a071351a9c29
--- /dev/null
+++ b/configs/validation_coco_distill_01_limit1.json
@@ -0,0 +1,100 @@
+{
+ "project_name": "coco_distill_01_smoke",
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "storage_dir": "storage/validation/coco_distill_01_limit1",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.5-4B",
+ "path": "models/Qwen3.5-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_01_limit1/student_calls.txt"
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12,
+ "max_new_tokens": 640,
+ "retry_max_new_tokens": 1024,
+ "invalid_output_retries": 1,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 1,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/configs/validation_coco_distill_02_limit1.json b/configs/validation_coco_distill_02_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..2ff623bb366af5442605e3758d3cb0e820746254
--- /dev/null
+++ b/configs/validation_coco_distill_02_limit1.json
@@ -0,0 +1,116 @@
+{
+ "project_name": "coco_distill_02_smoke",
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "storage_dir": "storage/validation/coco_distill_02_limit1",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.5-4B",
+ "path": "models/Qwen3.5-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_02_limit1/student_calls.txt"
+ },
+ "judge_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_02_limit1/judge_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "timeout_seconds": 600,
+ "max_retries": 1
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12,
+ "max_new_tokens": 640,
+ "retry_max_new_tokens": 1024,
+ "invalid_output_retries": 1,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 1,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/configs/validation_coco_distill_03_limit1.json b/configs/validation_coco_distill_03_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..bace9b3dd39f2b70da25873ed2e14a14ed4b5d3d
--- /dev/null
+++ b/configs/validation_coco_distill_03_limit1.json
@@ -0,0 +1,132 @@
+{
+ "project_name": "coco_distill_03_smoke",
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "storage_dir": "storage/validation/coco_distill_03_limit1",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3.5-4B",
+ "path": "models/Qwen3.5-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_03_limit1/student_calls.txt"
+ },
+ "selection_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_03_limit1/selection_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "timeout_seconds": 600,
+ "max_retries": 1
+ },
+ "memory_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 512,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/coco_distill_03_limit1/memory_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "timeout_seconds": 600,
+ "max_retries": 1
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12,
+ "max_new_tokens": 640,
+ "retry_max_new_tokens": 1024,
+ "invalid_output_retries": 1,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 1,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/configs/validation_coco_stability_limit1.json b/configs/validation_coco_stability_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..1280f062e80717050b4df0c0c2841f63c0dcda59
--- /dev/null
+++ b/configs/validation_coco_stability_limit1.json
@@ -0,0 +1,137 @@
+{
+ "project_name": "Qwen3.6-27B COCO HCL stability 250-50-500 migrated",
+ "model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "paper_model_name": "Qwen3.6-27B",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 1024,
+ "temperature": 0.0,
+ "cleanup_output": true,
+ "enable_image_input": true,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ },
+ "json_object_for_structured_phases": true,
+ "system_prompt": "You are a concise multimodal reasoning assistant. Inspect the supplied image directly, follow the requested output schema exactly, and output only the final answer.",
+ "timeout_seconds": 600,
+ "max_retries": 1,
+ "log_path": "storage/validation/coco_stability_limit1/llm_calls.txt",
+ "log_prompt_chars": 8000
+ },
+ "execution": {
+ "read_only_workers": 2,
+ "run_train_final": true
+ },
+ "task_interface": {
+ "mode": "auto",
+ "max_new_tokens": 640,
+ "invalid_output_retries": 1,
+ "retry_max_new_tokens": 1024,
+ "fallback_to_rules_on_invalid": true
+ },
+ "router": {
+ "route_name": "qwen36_27b_coco_gpu3_hcl_stability",
+ "strip_output": true,
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "memory_always_on": true,
+ "use_skill_selector": true,
+ "use_tool_selector": false,
+ "tool_auto_select": false,
+ "use_chat_tool_calls": false,
+ "memory_selector_max_new_tokens": 256,
+ "memory_selector_invalid_output_retries": 1,
+ "memory_selector_retry_max_new_tokens": 256,
+ "skill_selector_max_new_tokens": 256,
+ "skill_selector_invalid_output_retries": 1,
+ "skill_selector_retry_max_new_tokens": 256,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "capability_enabled": false,
+ "capability_execution_mode": "disabled"
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "selection_objective": "plasticity",
+ "commit_policy": "plasticity_first",
+ "historical_loss_budget": 0,
+ "max_forget_count": 0,
+ "min_primary_score_delta": 1e-09,
+ "min_correct_gain": 0,
+ "min_format_compliance_rate": 1.0,
+ "recover_reward": 1.0,
+ "forget_penalty": 1.5,
+ "min_anchor_score": -5.0,
+ "min_anchor_net_score": -0.1,
+ "max_anchor_forget_rate": 0.2,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "memory_selector",
+ "router_context"
+ ]
+ },
+ "memory": {
+ "enabled": true,
+ "raw_capacity_per_task": 10,
+ "abstract_capacity_per_task": 2,
+ "anchor_capacity_per_task": 25,
+ "retrieval_top_k": 2,
+ "reset_on_start": false,
+ "abstract_mode": "llm",
+ "abstract_max_new_tokens": 512,
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 2,
+ "anchor_candidate_limit": 10,
+ "anchor_selection_max_new_tokens": 128,
+ "skill_enabled": true,
+ "skill_mode": "llm",
+ "abstract_prompt_max_chars": 12000
+ },
+ "forgetting_probe": {
+ "enabled": true,
+ "interval_batches": 2,
+ "samples_per_task": 100,
+ "target_correct_ratio": 0.8,
+ "seed": 20260726,
+ "split": "test",
+ "historical_tasks_only": true,
+ "reuse_probe_set": true,
+ "record_predictions": false
+ },
+ "task_stream_path": "configs/taskstream_multimodal_main_250_50_500.json",
+ "reset_run_on_start": true,
+ "storage_dir": "storage/validation/coco_stability_limit1",
+ "prediction_cache": {
+ "enabled": true,
+ "path": "storage/validation/coco_stability_limit1/prediction_cache/read_only_predictions.jsonl"
+ },
+ "checkpoint": {
+ "enabled": true,
+ "path": "storage/validation/coco_stability_limit1/checkpoint.json",
+ "resume": true
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 25,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1,
+ "group_by_task": true
+ }
+ ]
+}
diff --git a/configs/validation_distill_01_limit1.json b/configs/validation_distill_01_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe180f21fc8f817dde09cd5a47623eea9805d2a6
--- /dev/null
+++ b/configs/validation_distill_01_limit1.json
@@ -0,0 +1,96 @@
+{
+ "project_name": "hcl_stability_01_4b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/validation/distill_01_limit1_full_output",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_01_limit1_full_output/model_calls.txt"
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/configs/validation_distill_02_limit1.json b/configs/validation_distill_02_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..f7ef427d72543da16c7ad20b72e2a9afbfd82402
--- /dev/null
+++ b/configs/validation_distill_02_limit1.json
@@ -0,0 +1,112 @@
+{
+ "project_name": "hcl_stability_02_4b_opt27b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/validation/distill_02_limit1",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_02_limit1/model_calls.txt"
+ },
+ "judge_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_02_limit1/judge_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "timeout_seconds": 600,
+ "max_retries": 1,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ }
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/configs/validation_distill_03_limit1.json b/configs/validation_distill_03_limit1.json
new file mode 100644
index 0000000000000000000000000000000000000000..3be0ce87c26c1927eebb654a08c3ca0610dd4240
--- /dev/null
+++ b/configs/validation_distill_03_limit1.json
@@ -0,0 +1,128 @@
+{
+ "project_name": "hcl_stability_03_4b_teacher27b",
+ "task_stream_path": "configs/taskstream_textual_main_250_50_500.json",
+ "storage_dir": "storage/validation/distill_03_limit1",
+ "reset_run_on_start": true,
+ "batchsize": 300,
+ "model": {
+ "backend": "local_transformers",
+ "name": "Qwen3-4B",
+ "path": "models/Qwen3-4B",
+ "device_map": "cuda:0",
+ "torch_dtype": "bfloat16",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_03_limit1/model_calls.txt"
+ },
+ "selection_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_03_limit1/selection_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "timeout_seconds": 600,
+ "max_retries": 1,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ }
+ },
+ "memory_model": {
+ "backend": "chat_completions",
+ "name": "qwen3.6-27b-coco-ablation-gpu3",
+ "max_new_tokens": 2048,
+ "answer_max_new_tokens": 200,
+ "temperature": 0.0,
+ "enable_thinking": false,
+ "log_path": "storage/validation/distill_03_limit1/memory_model_calls.txt",
+ "base_url": "http://127.0.0.1:8017",
+ "api_key": "EMPTY",
+ "timeout_seconds": 600,
+ "max_retries": 1,
+ "chat_template_kwargs": {
+ "enable_thinking": false
+ }
+ },
+ "task_interface": {
+ "mode": "auto",
+ "long_context_rule_threshold_chars": 24000,
+ "visible_context_max_chars": 24000,
+ "visible_context_max_items": 12
+ },
+ "router": {
+ "use_llm_workflow_selector": true,
+ "use_memory_selector": true,
+ "use_skill_selector": true,
+ "use_tool_selector": true,
+ "capability_enabled": false,
+ "memory_selector_fallback_to_empty_on_invalid": true,
+ "skill_selector_fallback_to_empty_on_invalid": true,
+ "tool_selector_fallback_to_empty_on_invalid": true,
+ "memory_selector_invalid_output_retries": 2,
+ "skill_selector_invalid_output_retries": 2,
+ "tool_selector_invalid_output_retries": 2
+ },
+ "optimizer": {
+ "candidate_count": 3,
+ "components": [
+ "task_interface_structuring",
+ "router_workflow",
+ "router_context"
+ ],
+ "selection_objective": "stability",
+ "min_accuracy_delta": 0.0,
+ "min_correct_gain": 1,
+ "max_forget_count": 0,
+ "min_format_compliance_rate": 0.0,
+ "min_historical_anchor_count": 1,
+ "min_historical_anchors_per_task": 1
+ },
+ "memory": {
+ "enabled": true,
+ "reset_on_start": true,
+ "abstract_mode": "llm",
+ "anchor_selection_mode": "llm",
+ "anchor_count_per_batch": 5,
+ "anchor_capacity_per_task": 15,
+ "skill_mode": "llm"
+ },
+ "prediction_cache": {
+ "enabled": true
+ },
+ "checkpoint": {
+ "enabled": true,
+ "resume": true
+ },
+ "execution": {
+ "read_only_workers": 1,
+ "run_train_final": false
+ },
+ "forgetting_probe": {
+ "enabled": false
+ },
+ "task_flow": [
+ {
+ "name": "train",
+ "phase": "train",
+ "train_split": "train",
+ "val_split": "validation",
+ "test_split": "test",
+ "continual": true,
+ "batchsize": 300,
+ "train_limit_per_task": 1,
+ "validation_limit_per_task": 1,
+ "test_limit_per_task": 1
+ },
+ {
+ "name": "test",
+ "phase": "test",
+ "test_split": "test",
+ "test_limit_per_task": 1
+ }
+ ]
+}
diff --git a/data/README.md b/data/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..3257045f72db48a71b7fcb794fb25f7414381348
--- /dev/null
+++ b/data/README.md
@@ -0,0 +1,69 @@
+# External data layout
+
+Datasets are not redistributed in this repository. After obtaining each dataset according to its license and the paper's preprocessing protocol, place the generated task-stream files and splits under this directory.
+
+The task-stream manifests are included under `configs/`. Supply the external splits in this layout:
+
+```text
+data/
+└── datasets/
+ ├── musique/{train,validation,anchor,test}.jsonl
+ ├── proofwriter/{train,validation,anchor,test}.jsonl
+ ├── gsm8k/{train,validation,anchor,test}.jsonl
+ ├── hotpotqa/{train,validation,anchor,test}.jsonl
+ ├── coco_detection/{train,validation,test}.jsonl
+ ├── coco_caption/{train,validation,test}.jsonl
+ ├── refcoco_grounding/{train,validation,test}.jsonl
+ └── vqav2/{train,validation,test}.jsonl
+```
+
+Prepare disjoint splits according to the paper protocol. The main run configurations apply per-task limits of 250 adaptation examples, 50 validation examples, and 500 test examples.
+
+## Reproducible split builders
+
+The recovered textual-reasoning stream builder is integrated as
+`scripts/build_textual_stream.py`. It preserves the experiment seed and the
+task-specific stratified sampling protocol. The default output is `data/datasets/`:
+
+```bash
+python scripts/build_textual_stream.py \
+ --source-root /path/to/existing/gsm8k-proofwriter-hotpotqa/splits \
+ --musique-root /path/to/musique/data
+```
+
+For a task-stream manifest whose train files have not yet been split, use the
+generic deterministic JSON/JSONL splitter. It resolves dataset paths the same
+way as the runtime loader and does not overwrite files unless requested:
+
+```bash
+python scripts/split_task_stream.py \
+ --config configs/my_taskstream.json \
+ --output-config configs/my_taskstream_split.json \
+ --val-ratio 0.2
+```
+
+The recovered multimodal 500/100/500 manifest is available at
+`configs/taskstream_multimodal_recovered_500_100_500.json`. Place its JSON split files under
+`data/datasets_multimodal_500_100_500/`. Validation is explicitly reused as the
+anchor split, matching the recovered experiment directory. Image paths inside
+the external records may need relocation for the local COCO installation.
+
+The exact sampled record files for the independent textual budget sweep were recovered from the experiment server. They are not redistributed here because benchmark data remain subject to their original licenses. Place them under `data/datasets_budget_300_80_80_600/` using the layout in `configs/taskstream_textual_budget_300_80_80_600.json`.
+
+The recovered budget-sweep counts are 300 train, 80 validation, 80 anchor, and 600 test records for every task. For GSM8K, ProofWriter, and HotpotQA, the recovered anchor file is byte-identical to the validation file; MuSiQue uses separate validation and anchor samples. This is recorded explicitly rather than silently claiming all evaluator subsets are mutually disjoint. Test records remain separate.
+
+The additional server also contains larger ordered source pools for the main textual and multimodal runs. Because no dedicated 250/50/500 manifest was present, the exact main-run sampled record IDs remain unconfirmed in this release instead of being inferred from those larger pools.
+
+The included single-task textual stream expects:
+
+```text
+data/
+└── datasets/
+ └── musique/
+ ├── train.jsonl
+ ├── validation.jsonl
+ ├── test.jsonl
+ └── anchor.jsonl
+```
+
+Task-stream JSON files can point to any project-relative layout. Avoid adding personal absolute paths to committed configurations.
diff --git a/docs/assets/capability-aware-results.png b/docs/assets/capability-aware-results.png
new file mode 100644
index 0000000000000000000000000000000000000000..778ca0a417fc183f9677132ef3d1f14bf57ab42f
Binary files /dev/null and b/docs/assets/capability-aware-results.png differ
diff --git a/docs/assets/controlled-forgetting.png b/docs/assets/controlled-forgetting.png
new file mode 100644
index 0000000000000000000000000000000000000000..f4bd008c279bbdfbea8814c2db4d9428902137fa
Binary files /dev/null and b/docs/assets/controlled-forgetting.png differ
diff --git a/docs/assets/distillation-results.png b/docs/assets/distillation-results.png
new file mode 100644
index 0000000000000000000000000000000000000000..a6a48fe3e90e1e42fd64446ba73260877fb65a4c
Binary files /dev/null and b/docs/assets/distillation-results.png differ
diff --git a/docs/assets/logo.png b/docs/assets/logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..52e7db21ae8a483876959bd373dabd57acad437e
--- /dev/null
+++ b/docs/assets/logo.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:b7fc6ab88e09594e6d2415cd81400b4ee4886ea037fc1f39fd00a455839444bd
+size 218389
diff --git a/docs/assets/method-overview.png b/docs/assets/method-overview.png
new file mode 100644
index 0000000000000000000000000000000000000000..9d25e105fc8f5c8cd2ed55f2497fb1c35509d50c
--- /dev/null
+++ b/docs/assets/method-overview.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7097ad4def36cc817e6f68d068b959d8b259f717ddfa0ad9861591720f693998
+size 662796
diff --git a/docs/assets/teaser.png b/docs/assets/teaser.png
new file mode 100644
index 0000000000000000000000000000000000000000..d2748d1e75448a67855b98ef518c2c6acc955a25
--- /dev/null
+++ b/docs/assets/teaser.png
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:7d3f0f3b7d087bdbf10256991b6645d6e7b20431f9c1abb073c76b3f959f6f58
+size 398133
diff --git a/examples/data/test.jsonl b/examples/data/test.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..10fadde5f1670cd2bcffc587b6ab691a36b01850
--- /dev/null
+++ b/examples/data/test.jsonl
@@ -0,0 +1 @@
+{"id":"test-1","question":"What is 2+2?","answer":"4"}
diff --git a/examples/data/train.jsonl b/examples/data/train.jsonl
new file mode 100644
index 0000000000000000000000000000000000000000..499c46947a7f878fad93f475b54e42665da2f495
--- /dev/null
+++ b/examples/data/train.jsonl
@@ -0,0 +1 @@
+{"id":"train-1","question":"What is 2+2?","answer":"4"}
diff --git a/examples/task_stream.json b/examples/task_stream.json
new file mode 100644
index 0000000000000000000000000000000000000000..5f8f5c350bacecf6bffa91763cd583fb49e19451
--- /dev/null
+++ b/examples/task_stream.json
@@ -0,0 +1,13 @@
+{
+ "tasks": [
+ {
+ "task_name": "arithmetic_smoke",
+ "task_type": "question_answering",
+ "modality": ["text"],
+ "splits": {
+ "train": "data/train.jsonl",
+ "test": "data/test.jsonl"
+ }
+ }
+ ]
+}
diff --git a/experiments/HCL_ALFworld/README.md b/experiments/HCL_ALFworld/README.md
new file mode 100644
index 0000000000000000000000000000000000000000..768c5c5fc525166ae5433cdc2cc368332ab3c0d1
--- /dev/null
+++ b/experiments/HCL_ALFworld/README.md
@@ -0,0 +1,25 @@
+# HCL ALFWorld experiments
+
+This module contains the ALFWorld experiment implementation, configurations, task sequences, and command-line entry point used with the unified HCL submission.
+
+## Installation
+
+Python 3.9 through 3.12 is supported by the package metadata.
+
+```bash
+python -m venv .venv
+source .venv/bin/activate
+python -m pip install -e '.[llm]'
+```
+
+Use the `local` extra instead of `llm` when running a local Transformers model, or install both extras when both backends are needed.
+
+## Usage
+
+```bash
+hcl-alfworld --help
+```
+
+Experiment definitions are under `configs/`, and the supplied domain-incremental task sequences are under `sequences/`. External ALFWorld assets and model credentials are intentionally not bundled. API credentials should be supplied through the environment variable named by the selected configuration's `api_key_env` field.
+
+The `scripts/bootstrap.sh` helper documents the environment bootstrap expected by this module.
diff --git a/experiments/HCL_ALFworld/configs/ablations/no_capabilities.yaml b/experiments/HCL_ALFworld/configs/ablations/no_capabilities.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f3ab1ff043b61c605e523e43eb17cc8c148ac7d3
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/ablations/no_capabilities.yaml
@@ -0,0 +1,5 @@
+extends: ../hcl.yaml
+output_dir: outputs/ablation_no_capabilities_seed42
+harness:
+ capabilities_enabled: false
+
diff --git a/experiments/HCL_ALFworld/configs/ablations/no_gate.yaml b/experiments/HCL_ALFworld/configs/ablations/no_gate.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..72231dce442ad61ab7bac9969b720378bb47c6f3
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/ablations/no_gate.yaml
@@ -0,0 +1,5 @@
+extends: ../hcl.yaml
+output_dir: outputs/ablation_no_gate_seed42
+harness:
+ gate_enabled: false
+
diff --git a/experiments/HCL_ALFworld/configs/ablations/no_memory.yaml b/experiments/HCL_ALFworld/configs/ablations/no_memory.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..44329ee86da16a22399f635b0676c72f9a3b5cc2
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/ablations/no_memory.yaml
@@ -0,0 +1,5 @@
+extends: ../hcl.yaml
+output_dir: outputs/ablation_no_memory_seed42
+harness:
+ memory_enabled: false
+
diff --git a/experiments/HCL_ALFworld/configs/ablations/stability_gate.yaml b/experiments/HCL_ALFworld/configs/ablations/stability_gate.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..672d54c4864b52dd1381ec5c06c101e21fb8cc1e
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/ablations/stability_gate.yaml
@@ -0,0 +1,6 @@
+extends: ../hcl_abstract_only.yaml
+output_dir: outputs/hcl_abstract_only_stability_llm_seed42
+
+harness:
+ # Reproduce the original whole-candidate gate used by completed historical runs.
+ gate_mode: stability
diff --git a/experiments/HCL_ALFworld/configs/ablations/static.yaml b/experiments/HCL_ALFworld/configs/ablations/static.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..d062eacda2ad443778dc9cc58d894de0c7d1431f
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/ablations/static.yaml
@@ -0,0 +1,16 @@
+extends: ../hcl.yaml
+
+# Frozen pass@1 baseline on the complete official valid_unseen split.
+run_mode: standard_evaluation
+output_dir: outputs/ablation_static_llm_seed42
+
+standard_evaluation:
+ game_source: full_split
+ split: valid_unseen
+ expected_game_count: 134
+
+harness:
+ updates_enabled: false
+ # The frozen baseline evaluates the base action model without learned or
+ # environment-grounded capability descriptions in its prompt.
+ capabilities_enabled: false
diff --git a/experiments/HCL_ALFworld/configs/baselines/alfworld_standard.yaml b/experiments/HCL_ALFworld/configs/baselines/alfworld_standard.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..f5e188db7682e68c22533540afa876edaa3b5ad6
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/baselines/alfworld_standard.yaml
@@ -0,0 +1,13 @@
+extends: ../hcl.yaml
+
+# Frozen pass@1 evaluation on the complete official ALFWorld valid_unseen split.
+run_mode: standard_evaluation
+output_dir: outputs/baseline_alfworld_standard_seed42
+
+standard_evaluation:
+ game_source: full_split
+ split: valid_unseen
+ expected_game_count: 134
+
+harness:
+ updates_enabled: false
diff --git a/experiments/HCL_ALFworld/configs/hcl.yaml b/experiments/HCL_ALFworld/configs/hcl.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..46e343cffd1f48cc27b371d8869f2ec7faa23d2f
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/hcl.yaml
@@ -0,0 +1,82 @@
+seed: 42
+data_dir: data/alfworld
+sequence_manifest: sequences/domain_incremental_full_eval_seed42.json
+output_dir: outputs/hcl_llm_seed42
+
+sequence:
+ task_order: [1, 2, 3, 4, 5, 6]
+ train_episodes_per_task: 10
+ # Use every game of each learned task type; evaluation rows are lower triangular.
+ eval_episodes_per_task: all
+ evaluation_scope: learned_tasks
+ train_split: train
+ eval_split: valid_unseen
+
+environment:
+ max_steps: 50
+ domain_randomization: false
+
+execution:
+ # Training remains serial; only read-only evaluation episodes run concurrently.
+ evaluation_workers: 4
+
+agent:
+ # Both continual-learning and evaluation episodes are controlled by the LLM.
+ policy: openai
+ learning_policy: openai
+ # Shared baseline/main protocol: LLM progress state plus indexed legal actions.
+ interface_protocol_version: deterministic_state_semantic_subgoal_v2
+ episode_progress_enabled: true
+ # Grounded facts come from ALFWorld responses; the LLM only judges subgoals.
+ hard_loop_protection_enabled: true
+ # Block the third stalled attempt or repeated transition without new progress.
+ hard_loop_repeat_limit: 2
+ # Cycle evidence expires outside this recent non-progress transition window.
+ hard_loop_window: 8
+ model: deepseek-v4-flash
+ base_url: https://api.deepseek.com
+ api_key_env: DEEPSEEK_API_KEY
+ json_mode: true
+ # DeepSeek V4 supports high/max. Thinking mode ignores temperature.
+ thinking_enabled: true
+ reasoning_effort: high
+ # JSON parsing retries and invalid-action retries are tracked separately.
+ max_retries: 2
+ action_retries: 2
+ # The current episode has at most 50 steps, so this exposes its full trajectory.
+ episode_history_max_steps: 50
+ # Bound stalled network requests separately from structured JSON retries.
+ request_timeout_seconds: 90
+ # Retry transient 408/409/429/5xx, timeout, and connection failures.
+ transport_max_retries: 8
+ transport_retry_base_seconds: 1
+ transport_retry_max_seconds: 60
+ transport_retry_jitter: 0.25
+ # Optional per-component overrides: task_interface/router/action_policy/optimizer/evaluator.
+ component_models: {}
+ component_thinking_enabled: {}
+ component_reasoning_efforts: {}
+
+harness:
+ updates_enabled: true
+ top_k_memory: 5
+ top_k_strategies: 3
+ candidate_pool_size: 20
+ raw_memory_capacity: 500
+ anchors_per_task: 5
+ min_current_score: 1.0
+ max_anchor_regression: 0.0
+ gate_enabled: true
+ # plasticity: retain grounded evidence and gate semantic operations independently.
+ # stability: preserve the original whole-candidate gate behavior.
+ gate_mode: plasticity
+ memory_enabled: true
+ # Retain raw trajectories for learning and audit, but route only abstract strategies.
+ raw_memory_retrieval_enabled: false
+ strategy_retrieval_enabled: true
+ capabilities_enabled: true
+ capability_learning_enabled: true
+ max_capability_operations: 3
+ provisional_min_positive_evidence: 2
+ validated_min_positive_evidence: 3
+ validated_min_success_rate: 0.67
diff --git a/experiments/HCL_ALFworld/configs/hcl_local.yaml b/experiments/HCL_ALFworld/configs/hcl_local.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..aab832f4aa41a736feecc39735a34f48ceabd9b2
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/hcl_local.yaml
@@ -0,0 +1,25 @@
+extends: hcl.yaml
+output_dir: outputs/hcl_local_seed42
+
+execution:
+ evaluation_workers: 1
+
+agent:
+ provider: transformers
+ model: Qwen3.5-4B
+ model_path:
+ device_map: auto
+ dtype: bfloat16
+ base_url: null
+ api_key_env: null
+ thinking_enabled: false
+ reasoning_effort: null
+ temperature: 0.0
+ max_tokens: 2048
+ component_max_tokens:
+ task_interface: 1024
+ router: 1024
+ action_policy: 768
+ optimizer: 4096
+ evaluator: 4096
+
diff --git a/experiments/HCL_ALFworld/configs/rag.yaml b/experiments/HCL_ALFworld/configs/rag.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..6c05ddde22d4c0bc25c39940e01c9e15924ebce5
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/rag.yaml
@@ -0,0 +1,27 @@
+extends: hcl.yaml
+
+# Fair-comparison standard RAG baseline. The shared Task Interface, episode
+# dialogue, Action Policy, legal-action selection, ALFWorld stream, and
+# evaluation protocol are unchanged; only external experience memory changes.
+continual_method: rag
+output_dir: outputs/rag_llm_seed42
+
+# Embeddings support semantic retrieval.
+embedding:
+ provider: openai
+ model: Qwen3-Embedding-4B
+ base_url: http://127.0.0.1:8007/v1
+ api_key: EMPTY
+ # Native output dimension validation only; not sent to the endpoint.
+ dimension: 2560
+ max_text_length: 4096
+ request_timeout_seconds: 90
+ transport_max_retries: 3
+
+rag:
+ updates_enabled: true
+ top_k: 3
+ # Standard RAG always returns the available top-k experiences. Set a numeric
+ # value only if an explicit minimum-similarity filter is required.
+ similarity_threshold: null
+ memory_capacity: 500
diff --git a/experiments/HCL_ALFworld/configs/rag_local.yaml b/experiments/HCL_ALFworld/configs/rag_local.yaml
new file mode 100644
index 0000000000000000000000000000000000000000..e6cb3d8d460863bfe480df21592d0d2150438090
--- /dev/null
+++ b/experiments/HCL_ALFworld/configs/rag_local.yaml
@@ -0,0 +1,36 @@
+extends: rag.yaml
+output_dir: outputs/rag_local_seed42
+
+execution:
+ evaluation_workers: 1
+
+agent:
+ provider: transformers
+ model: Qwen3.5-4B
+ model_path:
+ device_map: auto
+ dtype: bfloat16
+ base_url: null
+ api_key_env: null
+ thinking_enabled: false
+ reasoning_effort: null
+ temperature: 0.0
+ max_tokens: 2048
+ component_max_tokens:
+ task_interface: 1024
+ router: 1024
+ action_policy: 768
+
+embedding:
+ provider: transformers
+ model: Qwen3-Embedding-4B
+ model_path:
+ device_map: auto
+ dtype: bfloat16
+ base_url: null
+ api_key: null
+ dimension: 2560
+ pooling: last_token
+ padding_side: left
+ normalize: true
+ max_tokens: 8192
diff --git a/experiments/HCL_ALFworld/pyproject.toml b/experiments/HCL_ALFworld/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..a4cdd5bfe4f9b0138dd70838b11acdc4ef7ca9e3
--- /dev/null
+++ b/experiments/HCL_ALFworld/pyproject.toml
@@ -0,0 +1,40 @@
+[build-system]
+requires = ["setuptools>=64", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "hcl-alfworld"
+version = "0.1.0"
+description = "LLM-driven Harness Continual Learning experiments on ALFWorld"
+readme = "README.md"
+requires-python = ">=3.9,<3.13"
+dependencies = [
+ "alfworld==0.4.2",
+ "numpy>=1.24,<2.0",
+ "PyYAML>=6.0",
+ "requests>=2.31",
+ "spacy>=3.7,<3.8",
+ "tqdm>=4.66",
+]
+
+[project.optional-dependencies]
+llm = [
+ "openai>=1.60,<2",
+ "httpx[socks]>=0.27,<1",
+]
+local = [
+ "torch>=2.1",
+ "transformers>=4.57",
+ "accelerate>=0.30",
+]
+dev = [
+ "pytest>=8,<9",
+ "pytest-cov>=5,<7",
+ "ruff>=0.8,<1",
+]
+
+[project.scripts]
+hcl-alfworld = "hcl_alfworld.cli:main"
+
+[tool.setuptools.packages.find]
+where = ["src"]
diff --git a/experiments/HCL_ALFworld/scripts/bootstrap.sh b/experiments/HCL_ALFworld/scripts/bootstrap.sh
new file mode 100644
index 0000000000000000000000000000000000000000..a4b9fb96a13e38f145f38fe55655e5d44b1e8156
--- /dev/null
+++ b/experiments/HCL_ALFworld/scripts/bootstrap.sh
@@ -0,0 +1,24 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+ENV_DIR="${PROJECT_DIR}/.venv"
+PKG_CACHE_DIR="${PROJECT_DIR}/.conda-pkgs"
+DATA_DIR="${PROJECT_DIR}/data/alfworld"
+
+if [[ ! -x "${ENV_DIR}/bin/python" ]]; then
+ env CONDA_PKGS_DIRS="${PKG_CACHE_DIR}" XDG_CACHE_HOME="${PROJECT_DIR}/.cache" \
+ conda create --prefix "${ENV_DIR}" python=3.9 pip -y
+fi
+
+env ALFWORLD_DATA="${DATA_DIR}" "${ENV_DIR}/bin/python" -m pip install --upgrade pip
+env ALFWORLD_DATA="${DATA_DIR}" "${ENV_DIR}/bin/python" -m pip install -e "${PROJECT_DIR}[dev,llm]"
+if [[ ! -f "${DATA_DIR}/logic/alfred.pddl" ]] || \
+ [[ ! -d "${DATA_DIR}/json_2.1.1/train" ]] || \
+ [[ ! -f "${DATA_DIR}/detectors/mrcnn_alfred_objects_sep13_004.pth" ]]; then
+ env ALFWORLD_DATA="${DATA_DIR}" "${ENV_DIR}/bin/alfworld-download" --data-dir "${DATA_DIR}"
+fi
+env ALFWORLD_DATA="${DATA_DIR}" "${ENV_DIR}/bin/hcl-alfworld" doctor --config "${PROJECT_DIR}/configs/hcl.yaml"
+env ALFWORLD_DATA="${DATA_DIR}" "${ENV_DIR}/bin/hcl-alfworld" build-sequence --config "${PROJECT_DIR}/configs/hcl.yaml"
+
+echo "Bootstrap complete. Activate with: conda activate ${ENV_DIR}"
diff --git a/experiments/HCL_ALFworld/sequences/domain_incremental_full_eval_seed42.json b/experiments/HCL_ALFworld/sequences/domain_incremental_full_eval_seed42.json
new file mode 100644
index 0000000000000000000000000000000000000000..fad100871e129148d6f46e7f13a9b080d636b4a5
--- /dev/null
+++ b/experiments/HCL_ALFworld/sequences/domain_incremental_full_eval_seed42.json
@@ -0,0 +1,275 @@
+{
+ "data_validation": {
+ "game_counts": {
+ "train": 3553,
+ "valid_seen": 140,
+ "valid_unseen": 134
+ },
+ "missing": [],
+ "valid": true
+ },
+ "eval_episodes_per_task": "all",
+ "eval_split": "valid_unseen",
+ "game_paths_relative_to": "data_dir",
+ "phases": [
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190906_184021_215264/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Drawer-10/trial_T20190909_021650_880235/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Mug-None-Desk-308/trial_T20190909_210238_431966/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SoapBottle-None-Toilet-424/trial_T20190907_004351_281384/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074643_810052/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Mug-None-Desk-308/trial_T20190909_203041_433487/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190918_154424_844749/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074556_124850/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Cabinet-10/trial_T20190906_191429_743650/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Cabinet-10/trial_T20190906_191501_563086/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Vase-None-Safe-219/trial_T20190908_205246_776817/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_122024_052056/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SoapBottle-None-Toilet-424/trial_T20190907_004404_604165/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_121952_610012/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190918_154326_823501/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_122154_042763/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074524_006355/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SoapBottle-None-Toilet-424/trial_T20190907_004321_405868/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Mug-None-Desk-308/trial_T20190908_125200_737896/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Cabinet-10/trial_T20190906_191445_723170/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Drawer-10/trial_T20190909_021613_077537/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Vase-None-Safe-219/trial_T20190908_205221_748352/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Vase-None-Safe-219/trial_T20190908_205204_244321/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Drawer-10/trial_T20190909_021728_339782/game.tw-pddl"
+ ],
+ "phase": 0,
+ "task_id": 1,
+ "task_type": "pick_and_place_simple",
+ "train_games": [
+ "json_2.1.1/train/pick_and_place_simple-RemoteControl-None-CoffeeTable-209/trial_T20190909_062307_151182/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SoapBottle-None-Cabinet-414/trial_T20190908_110224_056978/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Statue-None-CoffeeTable-230/trial_T20190909_145138_877153/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Box-None-Sofa-205/trial_T20190907_214830_497445/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SoapBar-None-Cart-401/trial_T20190907_054853_603987/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SprayBottle-None-Toilet-403/trial_T20190908_042148_900573/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Candle-None-Toilet-413/trial_T20190909_104025_525772/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Pillow-None-Ottoman-208/trial_T20190906_172059_337617/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SprayBottle-None-CounterTop-427/trial_T20190919_014945_471194/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Candle-None-Drawer-411/trial_T20190909_093518_959671/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_142046_281296/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_141958_463362/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220604_010430/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133919_856963/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_020029_636862/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220545_153480/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Mug-None-DeskLamp-308/trial_T20190908_201421_021646/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Mug-None-DeskLamp-308/trial_T20190908_201444_037645/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222933_607649/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_144951_587345/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_141942_810052/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133953_562557/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222951_616606/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222917_366542/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133935_066606/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_020048_814402/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220656_510400/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Mug-None-DeskLamp-308/trial_T20190908_161733_213242/game.tw-pddl"
+ ],
+ "phase": 1,
+ "task_id": 2,
+ "task_type": "look_at_obj_in_light",
+ "train_games": [
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-316/trial_T20190908_232409_225369/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-319/trial_T20190907_224211_927258/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-CD-None-DeskLamp-316/trial_T20190909_114536_775689/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-AlarmClock-None-DeskLamp-301/trial_T20190907_174157_650656/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Book-None-DeskLamp-303/trial_T20190909_085355_202971/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Mug-None-DeskLamp-301/trial_T20190908_045036_103114/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-320/trial_T20190906_225908_972390/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Watch-None-DeskLamp-301/trial_T20190909_151141_967451/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-319/trial_T20190907_224200_196003/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Book-None-DeskLamp-305/trial_T20190908_215726_055077/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Bowl-None-Cabinet-10/trial_T20190909_061232_368489/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Bowl-None-Cabinet-10/trial_T20190909_061130_844814/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-Cabinet-424/trial_T20190908_022321_380927/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-CounterTop-424/trial_T20190907_074124_966890/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Knife-None-CounterTop-10/trial_T20190909_110531_148235/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080800_275989/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080825_222432/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Egg-None-Microwave-10/trial_T20190909_120632_691361/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_032434_013084/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-Cabinet-424/trial_T20190908_022436_073995/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080730_211959/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_032543_712058/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-CounterTop-424/trial_T20190907_074106_050405/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Knife-None-CounterTop-10/trial_T20190909_110347_624008/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-CounterTop-424/trial_T20190908_100632_546757/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-CounterTop-424/trial_T20190908_114340_674467/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_221355_558505/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-Cabinet-424/trial_T20190908_214926_337906/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Plate-None-CounterTop-10/trial_T20190908_213420_728917/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_032518_891433/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-CounterTop-424/trial_T20190907_074045_109439/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-Cabinet-424/trial_T20190908_215019_162873/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Egg-None-Microwave-10/trial_T20190909_120712_273910/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Plate-None-CounterTop-10/trial_T20190908_213356_017769/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_221300_362511/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Egg-None-Microwave-10/trial_T20190909_120554_888709/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-Cabinet-424/trial_T20190908_214946_567644/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Plate-None-CounterTop-10/trial_T20190908_213533_897289/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Knife-None-CounterTop-10/trial_T20190909_110445_675754/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_221208_560499/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Bowl-None-Cabinet-10/trial_T20190909_061158_110530/game.tw-pddl"
+ ],
+ "phase": 2,
+ "task_id": 3,
+ "task_type": "pick_clean_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Lettuce-None-Fridge-13/trial_T20190908_203315_375450/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-ButterKnife-None-DiningTable-27/trial_T20190907_062827_740259/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cup-None-Shelf-1/trial_T20190909_152013_462926/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cloth-None-CounterTop-409/trial_T20190908_150707_250921/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-SoapBar-None-Toilet-408/trial_T20190908_122506_504898/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Kettle-None-DiningTable-17/trial_T20190909_023408_359277/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cup-None-Microwave-6/trial_T20190909_033518_213036/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-SoapBar-None-Toilet-408/trial_T20190908_122434_336174/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-ButterKnife-None-DiningTable-27/trial_T20190912_063708_012838/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Kettle-None-Cabinet-3/trial_T20190909_151546_297502/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Potato-None-GarbageCan-10/trial_T20190907_161853_945788/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113523_123938/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113432_673307/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-Fridge-10/trial_T20190906_182259_116320/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Cup-None-Cabinet-10/trial_T20190907_083429_887065/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_021200_669381/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_171806_406231/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225046_020282/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225453_272533/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113610_425142/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Potato-None-GarbageCan-10/trial_T20190907_161745_664033/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225359_617900/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145356_918528/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Cup-None-Cabinet-10/trial_T20190907_083346_800823/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-Fridge-10/trial_T20190906_182353_418140/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145143_820541/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145050_918567/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Cup-None-Cabinet-10/trial_T20190907_083507_594820/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_171933_349922/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_021247_306737/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_171850_960211/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_021100_341887/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-Fridge-10/trial_T20190906_182435_622538/game.tw-pddl"
+ ],
+ "phase": 3,
+ "task_id": 4,
+ "task_type": "pick_heat_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Potato-None-GarbageCan-3/trial_T20190908_121849_346541/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Egg-None-GarbageCan-2/trial_T20190909_101236_908258/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Apple-None-Fridge-22/trial_T20190908_210321_417277/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Cup-None-Cabinet-1/trial_T20190909_090951_740727/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Plate-None-CounterTop-5/trial_T20190908_202750_896176/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Potato-None-GarbageCan-14/trial_T20190909_114325_950642/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Tomato-None-Fridge-19/trial_T20190909_152219_111684/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Tomato-None-Fridge-18/trial_T20190907_192826_972343/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-3/trial_T20190906_233939_167170/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Cup-None-CounterTop-13/trial_T20190907_013610_766116/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033228_194678/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183807_477267/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114545_244903/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Lettuce-None-CounterTop-10/trial_T20190909_174840_771703/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Tomato-None-Microwave-10/trial_T20190909_102644_926781/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Tomato-None-Microwave-10/trial_T20190909_102710_795182/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114656_768805/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Lettuce-None-CounterTop-10/trial_T20190909_123133_763972/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Bread-None-CounterTop-10/trial_T20190908_091835_825830/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033306_962974/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_121559_082363/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183853_958104/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114622_738670/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183715_299073/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033157_424297/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_121710_650938/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Bread-None-CounterTop-10/trial_T20190908_091747_866951/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Lettuce-None-CounterTop-10/trial_T20190909_174807_646433/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_121635_622676/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Tomato-None-Microwave-10/trial_T20190909_102608_318800/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Bread-None-CounterTop-10/trial_T20190908_091811_414150/game.tw-pddl"
+ ],
+ "phase": 4,
+ "task_id": 5,
+ "task_type": "pick_cool_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-Cabinet-5/trial_T20190906_235356_747730/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Bowl-None-Cabinet-2/trial_T20190907_021058_144729/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Plate-None-Cabinet-7/trial_T20190907_231129_582705/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Cup-None-Shelf-7/trial_T20190906_222334_320065/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pan-None-CounterTop-22/trial_T20190907_003325_111452/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Tomato-None-DiningTable-11/trial_T20190908_220012_692282/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-DiningTable-4/trial_T20190909_025030_569726/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pan-None-CounterTop-7/trial_T20190909_072619_689367/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Apple-None-CounterTop-14/trial_T20190909_045026_110157/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-Shelf-1/trial_T20190906_234856_560153/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-KeyChain-None-Safe-219/trial_T20190909_011803_423115/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190912_221016_460197/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_051013_060265/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163408_914117/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-ToiletPaper-None-Cabinet-424/trial_T20190906_202926_527010/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064221_368939/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-Cabinet-424/trial_T20190909_081720_491733/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163327_486300/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064053_839817/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_051056_585414/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-KeyChain-None-Safe-219/trial_T20190909_012027_782483/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-Cabinet-424/trial_T20190909_081746_857594/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163240_345855/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064309_357168/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190912_221141_608117/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190908_010306_215435/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_050942_897916/game.tw-pddl"
+ ],
+ "phase": 5,
+ "task_id": 6,
+ "task_type": "pick_two_obj_and_place",
+ "train_games": [
+ "json_2.1.1/train/pick_two_obj_and_place-Watch-None-Dresser-301/trial_T20190906_180154_425245/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-Shelf-301/trial_T20190909_011401_611613/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-CellPhone-None-Bed-314/trial_T20190909_013318_115630/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-AlarmClock-None-Desk-313/trial_T20190909_054639_360008/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-CoffeeTable-229/trial_T20190910_224430_843186/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-Desk-313/trial_T20190906_201014_115564/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-SaltShaker-None-Shelf-20/trial_T20190906_200701_399969/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Book-None-Bed-316/trial_T20190909_035341_047789/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Candle-None-Drawer-411/trial_T20190909_055913_544549/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-SprayBottle-None-GarbageCan-418/trial_T20190909_051110_474114/game.tw-pddl"
+ ]
+ }
+ ],
+ "schema_version": 1,
+ "seed": 42,
+ "task_order": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6
+ ],
+ "train_split": "train"
+}
diff --git a/experiments/HCL_ALFworld/sequences/domain_incremental_seed42.json b/experiments/HCL_ALFworld/sequences/domain_incremental_seed42.json
new file mode 100644
index 0000000000000000000000000000000000000000..7471eb83eeae285898982836aacbdcb2c082ac0d
--- /dev/null
+++ b/experiments/HCL_ALFworld/sequences/domain_incremental_seed42.json
@@ -0,0 +1,242 @@
+{
+ "data_validation": {
+ "game_counts": {
+ "train": 3553,
+ "valid_seen": 140,
+ "valid_unseen": 134
+ },
+ "missing": [],
+ "valid": true
+ },
+ "eval_split": "valid_unseen",
+ "game_paths_relative_to": "data_dir",
+ "phases": [
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190906_184021_215264/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Drawer-10/trial_T20190909_021650_880235/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Mug-None-Desk-308/trial_T20190909_210238_431966/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SoapBottle-None-Toilet-424/trial_T20190907_004351_281384/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074643_810052/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Mug-None-Desk-308/trial_T20190909_203041_433487/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190918_154424_844749/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074556_124850/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Cabinet-10/trial_T20190906_191429_743650/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SaltShaker-None-Cabinet-10/trial_T20190906_191501_563086/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Vase-None-Safe-219/trial_T20190908_205246_776817/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_122024_052056/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-SoapBottle-None-Toilet-424/trial_T20190907_004404_604165/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_121952_610012/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-PepperShaker-None-Drawer-10/trial_T20190918_154326_823501/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Pencil-None-Shelf-308/trial_T20190908_122154_042763/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_and_place_simple-Watch-None-Safe-219/trial_T20190907_074524_006355/game.tw-pddl"
+ ],
+ "phase": 0,
+ "task_id": 1,
+ "task_type": "pick_and_place_simple",
+ "train_games": [
+ "json_2.1.1/train/pick_and_place_simple-RemoteControl-None-CoffeeTable-209/trial_T20190909_062307_151182/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SoapBottle-None-Cabinet-414/trial_T20190908_110224_056978/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Statue-None-CoffeeTable-230/trial_T20190909_145138_877153/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Box-None-Sofa-205/trial_T20190907_214830_497445/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SoapBar-None-Cart-401/trial_T20190907_054853_603987/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SprayBottle-None-Toilet-403/trial_T20190908_042148_900573/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Candle-None-Toilet-413/trial_T20190909_104025_525772/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Pillow-None-Ottoman-208/trial_T20190906_172059_337617/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-SprayBottle-None-CounterTop-427/trial_T20190919_014945_471194/game.tw-pddl",
+ "json_2.1.1/train/pick_and_place_simple-Candle-None-Drawer-411/trial_T20190909_093518_959671/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_142046_281296/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_141958_463362/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220604_010430/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133919_856963/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_020029_636862/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220545_153480/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Mug-None-DeskLamp-308/trial_T20190908_201421_021646/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Mug-None-DeskLamp-308/trial_T20190908_201444_037645/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222933_607649/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_144951_587345/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-CD-None-DeskLamp-308/trial_T20190908_141942_810052/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133953_562557/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222951_616606/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-AlarmClock-None-DeskLamp-308/trial_T20190908_222917_366542/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Bowl-None-DeskLamp-308/trial_T20190907_133935_066606/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Book-None-DeskLamp-308/trial_T20190908_020048_814402/game.tw-pddl",
+ "json_2.1.1/valid_unseen/look_at_obj_in_light-Pencil-None-DeskLamp-308/trial_T20190908_220656_510400/game.tw-pddl"
+ ],
+ "phase": 1,
+ "task_id": 2,
+ "task_type": "look_at_obj_in_light",
+ "train_games": [
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-316/trial_T20190908_232409_225369/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-319/trial_T20190907_224211_927258/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-CD-None-DeskLamp-316/trial_T20190909_114536_775689/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-AlarmClock-None-DeskLamp-301/trial_T20190907_174157_650656/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Book-None-DeskLamp-303/trial_T20190909_085355_202971/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Mug-None-DeskLamp-301/trial_T20190908_045036_103114/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-320/trial_T20190906_225908_972390/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Watch-None-DeskLamp-301/trial_T20190909_151141_967451/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Pillow-None-DeskLamp-319/trial_T20190907_224200_196003/game.tw-pddl",
+ "json_2.1.1/train/look_at_obj_in_light-Book-None-DeskLamp-305/trial_T20190908_215726_055077/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Bowl-None-Cabinet-10/trial_T20190909_061232_368489/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Bowl-None-Cabinet-10/trial_T20190909_061130_844814/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-Cabinet-424/trial_T20190908_022321_380927/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-CounterTop-424/trial_T20190907_074124_966890/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Knife-None-CounterTop-10/trial_T20190909_110531_148235/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080800_275989/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080825_222432/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Egg-None-Microwave-10/trial_T20190909_120632_691361/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_032434_013084/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-Cabinet-424/trial_T20190908_022436_073995/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Spatula-None-Drawer-10/trial_T20190907_080730_211959/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_032543_712058/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-SoapBar-None-CounterTop-424/trial_T20190907_074106_050405/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Knife-None-CounterTop-10/trial_T20190909_110347_624008/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-CounterTop-424/trial_T20190908_100632_546757/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Cloth-None-CounterTop-424/trial_T20190908_114340_674467/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_clean_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_221355_558505/game.tw-pddl"
+ ],
+ "phase": 2,
+ "task_id": 3,
+ "task_type": "pick_clean_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Lettuce-None-Fridge-13/trial_T20190908_203315_375450/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-ButterKnife-None-DiningTable-27/trial_T20190907_062827_740259/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cup-None-Shelf-1/trial_T20190909_152013_462926/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cloth-None-CounterTop-409/trial_T20190908_150707_250921/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-SoapBar-None-Toilet-408/trial_T20190908_122506_504898/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Kettle-None-DiningTable-17/trial_T20190909_023408_359277/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Cup-None-Microwave-6/trial_T20190909_033518_213036/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-SoapBar-None-Toilet-408/trial_T20190908_122434_336174/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-ButterKnife-None-DiningTable-27/trial_T20190912_063708_012838/game.tw-pddl",
+ "json_2.1.1/train/pick_clean_then_place_in_recep-Kettle-None-Cabinet-3/trial_T20190909_151546_297502/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Potato-None-GarbageCan-10/trial_T20190907_161853_945788/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113523_123938/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113432_673307/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-Fridge-10/trial_T20190906_182259_116320/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Cup-None-Cabinet-10/trial_T20190907_083429_887065/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_021200_669381/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_171806_406231/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225046_020282/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225453_272533/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Egg-None-GarbageCan-10/trial_T20190908_113610_425142/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Potato-None-GarbageCan-10/trial_T20190907_161745_664033/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Tomato-None-GarbageCan-10/trial_T20190908_225359_617900/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145356_918528/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Cup-None-Cabinet-10/trial_T20190907_083346_800823/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-Fridge-10/trial_T20190906_182353_418140/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145143_820541/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_heat_then_place_in_recep-Apple-None-GarbageCan-10/trial_T20190908_145050_918567/game.tw-pddl"
+ ],
+ "phase": 3,
+ "task_id": 4,
+ "task_type": "pick_heat_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Potato-None-GarbageCan-3/trial_T20190908_121849_346541/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Egg-None-GarbageCan-2/trial_T20190909_101236_908258/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Apple-None-Fridge-22/trial_T20190908_210321_417277/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Cup-None-Cabinet-1/trial_T20190909_090951_740727/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Plate-None-CounterTop-5/trial_T20190908_202750_896176/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Potato-None-GarbageCan-14/trial_T20190909_114325_950642/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Tomato-None-Fridge-19/trial_T20190909_152219_111684/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Tomato-None-Fridge-18/trial_T20190907_192826_972343/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Mug-None-CoffeeMachine-3/trial_T20190906_233939_167170/game.tw-pddl",
+ "json_2.1.1/train/pick_heat_then_place_in_recep-Cup-None-CounterTop-13/trial_T20190907_013610_766116/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033228_194678/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183807_477267/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114545_244903/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Lettuce-None-CounterTop-10/trial_T20190909_174840_771703/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Tomato-None-Microwave-10/trial_T20190909_102644_926781/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Tomato-None-Microwave-10/trial_T20190909_102710_795182/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114656_768805/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Lettuce-None-CounterTop-10/trial_T20190909_123133_763972/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Bread-None-CounterTop-10/trial_T20190908_091835_825830/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033306_962974/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_121559_082363/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183853_958104/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Pan-None-CounterTop-10/trial_T20190908_114622_738670/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-CoffeeMachine-10/trial_T20190907_183715_299073/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Potato-None-Microwave-10/trial_T20190907_033157_424297/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Mug-None-Cabinet-10/trial_T20190909_121710_650938/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_cool_then_place_in_recep-Bread-None-CounterTop-10/trial_T20190908_091747_866951/game.tw-pddl"
+ ],
+ "phase": 4,
+ "task_id": 5,
+ "task_type": "pick_cool_then_place_in_recep",
+ "train_games": [
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-Cabinet-5/trial_T20190906_235356_747730/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Bowl-None-Cabinet-2/trial_T20190907_021058_144729/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Plate-None-Cabinet-7/trial_T20190907_231129_582705/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Cup-None-Shelf-7/trial_T20190906_222334_320065/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pan-None-CounterTop-22/trial_T20190907_003325_111452/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Tomato-None-DiningTable-11/trial_T20190908_220012_692282/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-DiningTable-4/trial_T20190909_025030_569726/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pan-None-CounterTop-7/trial_T20190909_072619_689367/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Apple-None-CounterTop-14/trial_T20190909_045026_110157/game.tw-pddl",
+ "json_2.1.1/train/pick_cool_then_place_in_recep-Pot-None-Shelf-1/trial_T20190906_234856_560153/game.tw-pddl"
+ ]
+ },
+ {
+ "eval_games": [
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-KeyChain-None-Safe-219/trial_T20190909_011803_423115/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190912_221016_460197/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_051013_060265/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163408_914117/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-ToiletPaper-None-Cabinet-424/trial_T20190906_202926_527010/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064221_368939/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-Cabinet-424/trial_T20190909_081720_491733/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163327_486300/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064053_839817/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_051056_585414/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-KeyChain-None-Safe-219/trial_T20190909_012027_782483/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-Cabinet-424/trial_T20190909_081746_857594/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-Pillow-None-Sofa-219/trial_T20190907_163240_345855/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-SoapBar-None-GarbageCan-424/trial_T20190909_064309_357168/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190912_221141_608117/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-PepperShaker-None-Drawer-10/trial_T20190908_010306_215435/game.tw-pddl",
+ "json_2.1.1/valid_unseen/pick_two_obj_and_place-CD-None-Safe-308/trial_T20190907_050942_897916/game.tw-pddl"
+ ],
+ "phase": 5,
+ "task_id": 6,
+ "task_type": "pick_two_obj_and_place",
+ "train_games": [
+ "json_2.1.1/train/pick_two_obj_and_place-Watch-None-Dresser-301/trial_T20190906_180154_425245/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-Shelf-301/trial_T20190909_011401_611613/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-CellPhone-None-Bed-314/trial_T20190909_013318_115630/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-AlarmClock-None-Desk-313/trial_T20190909_054639_360008/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-CoffeeTable-229/trial_T20190910_224430_843186/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Pencil-None-Desk-313/trial_T20190906_201014_115564/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-SaltShaker-None-Shelf-20/trial_T20190906_200701_399969/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Book-None-Bed-316/trial_T20190909_035341_047789/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-Candle-None-Drawer-411/trial_T20190909_055913_544549/game.tw-pddl",
+ "json_2.1.1/train/pick_two_obj_and_place-SprayBottle-None-GarbageCan-418/trial_T20190909_051110_474114/game.tw-pddl"
+ ]
+ }
+ ],
+ "schema_version": 1,
+ "seed": 42,
+ "task_order": [
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6
+ ],
+ "train_split": "train"
+}
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/__init__.py b/experiments/HCL_ALFworld/src/hcl_alfworld/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f91dc7fc150c6b26eb3fc0c781acf430306c5945
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/__init__.py
@@ -0,0 +1,3 @@
+"""Harness Continual Learning on ALFWorld."""
+
+__version__ = "0.1.0"
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/capability.py b/experiments/HCL_ALFworld/src/hcl_alfworld/capability.py
new file mode 100644
index 0000000000000000000000000000000000000000..391cdd118292a08696f3cb850c2d3265b98d3dc1
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/capability.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from typing import List
+
+from .schemas import CapabilityUnit
+
+
+def default_capabilities() -> List[CapabilityUnit]:
+ specifications = [
+ (
+ "navigation",
+ "Navigate",
+ ["go"],
+ "move to a receptacle or room",
+ ["destination is listed"],
+ ["unknown destination"],
+ ),
+ (
+ "inspection",
+ "Inspect",
+ ["look", "examine"],
+ "observe a location or object",
+ ["target is visible"],
+ ["target is hidden"],
+ ),
+ (
+ "open_close",
+ "Open/close",
+ ["open", "close"],
+ "change a receptacle state",
+ ["receptacle supports opening"],
+ ["already open or closed"],
+ ),
+ (
+ "pickup",
+ "Pick up",
+ ["take"],
+ "put an accessible object in inventory",
+ ["object is visible", "inventory has capacity"],
+ ["object is enclosed or already held"],
+ ),
+ (
+ "placement",
+ "Place",
+ ["put", "move"],
+ "place an inventory object in/on a receptacle",
+ ["object is held", "destination is accessible"],
+ ["object is not held"],
+ ),
+ (
+ "cleaning",
+ "Clean",
+ ["clean"],
+ "clean an object at a sink",
+ ["object is held", "sink is accessible"],
+ ["wrong appliance"],
+ ),
+ (
+ "heating",
+ "Heat",
+ ["heat"],
+ "heat an object with a microwave",
+ ["object is held", "microwave is accessible"],
+ ["wrong appliance"],
+ ),
+ (
+ "cooling",
+ "Cool",
+ ["cool"],
+ "cool an object with a refrigerator",
+ ["object is held", "refrigerator is accessible"],
+ ["wrong appliance"],
+ ),
+ (
+ "illumination",
+ "Illuminate",
+ ["use"],
+ "use a lamp to illuminate an object",
+ ["lamp is accessible"],
+ ["wrong device"],
+ ),
+ ("inventory", "Inventory", ["inventory"], "inspect currently held objects", [], []),
+ ]
+ return [
+ CapabilityUnit(
+ capability_id=identifier,
+ name=name,
+ kind="atomic_external",
+ function=function,
+ action_prefixes=prefixes,
+ input_output="ALFWorld text command -> environment observation",
+ preconditions=preconditions,
+ failure_modes=failures,
+ version="alfworld-0.4.2",
+ description=function,
+ inputs=["current ALFWorld observation", "admissible commands"],
+ outputs=["environment state transition and next observation"],
+ success_conditions=["The environment accepts the grounded action."],
+ confidence=1.0,
+ status="validated",
+ provider_component="alfworld_environment",
+ model="environment-defined",
+ prompt_version="not-applicable",
+ )
+ for identifier, name, prefixes, function, preconditions, failures in specifications
+ ]
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/capability_evolution.py b/experiments/HCL_ALFworld/src/hcl_alfworld/capability_evolution.py
new file mode 100644
index 0000000000000000000000000000000000000000..75e5a76804480c2a8e1c1e68f34ef515aa031e2b
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/capability_evolution.py
@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+import re
+import uuid
+from typing import Any, Dict, List, Set
+
+from .schemas import CapabilityUnit, EpisodeRecord, HarnessState
+from .text import unique
+
+
+CAPABILITY_KINDS = {"atomic_external", "composite_external", "internal"}
+CAPABILITY_STATUSES = {"hypothesized", "provisional", "validated", "deprecated"}
+INTERNAL_PROVIDERS = {
+ "task_interface",
+ "router",
+ "action_policy",
+ "optimizer",
+ "evaluator",
+}
+
+
+class CapabilityEvolution:
+ """Apply LLM-proposed capability evolution under evidence and graph invariants."""
+
+ def __init__(
+ self,
+ max_operations: int = 3,
+ provisional_min_positive_evidence: int = 2,
+ validated_min_positive_evidence: int = 3,
+ validated_min_success_rate: float = 0.67,
+ ):
+ self.max_operations = max_operations
+ self.provisional_min_positive_evidence = provisional_min_positive_evidence
+ self.validated_min_positive_evidence = validated_min_positive_evidence
+ self.validated_min_success_rate = validated_min_success_rate
+
+ @staticmethod
+ def _strings(value: Any, limit: int = 30) -> List[str]:
+ if not isinstance(value, list):
+ return []
+ return unique(
+ str(item).strip()[:500]
+ for item in value[:limit]
+ if isinstance(item, str) and item.strip()
+ )
+
+ @staticmethod
+ def _normalized_name(value: str) -> str:
+ return re.sub(r"[^a-z0-9]+", " ", value.lower()).strip()
+
+ @staticmethod
+ def _known_episode_ids(state: HarnessState) -> Set[str]:
+ return {
+ str(item.get("episode_id"))
+ for item in state.raw_memory
+ if item.get("episode_id")
+ }
+
+ @staticmethod
+ def _episode_selected_capabilities(episode: EpisodeRecord) -> Set[str]:
+ selected: Set[str] = set()
+ for trace in episode.harness_traces:
+ router = trace.get("router", {}) if isinstance(trace, dict) else {}
+ for capability_id in router.get("selected_capability_ids", []):
+ if isinstance(capability_id, str):
+ selected.add(capability_id)
+ return selected
+
+ @staticmethod
+ def _episode_internal_components(episode: EpisodeRecord) -> Set[str]:
+ used: Set[str] = set()
+ for trace in episode.harness_traces:
+ if not isinstance(trace, dict):
+ continue
+ for component in INTERNAL_PROVIDERS:
+ if trace.get(component):
+ used.add(component)
+ return used
+
+ @staticmethod
+ def _raw_episode_supports_provider(
+ state: HarnessState, episode_id: str, provider: str
+ ) -> bool:
+ raw = next(
+ (
+ item
+ for item in state.raw_memory
+ if str(item.get("episode_id", "")) == episode_id
+ ),
+ None,
+ )
+ if raw is None:
+ return False
+ traces = raw.get("harness_traces", [])
+ return any(isinstance(trace, dict) and trace.get(provider) for trace in traces)
+
+ def record_observed_outcomes(
+ self, state: HarnessState, episode: EpisodeRecord
+ ) -> List[Dict[str, Any]]:
+ selected = self._episode_selected_capabilities(episode)
+ internal_components = self._episode_internal_components(episode)
+ operations: List[Dict[str, Any]] = []
+ for capability in state.capabilities:
+ was_used = capability.capability_id in selected or (
+ capability.kind == "internal"
+ and capability.provider_component in internal_components
+ )
+ if not was_used:
+ continue
+ capability.usage_count += 1
+ if episode.success:
+ capability.success_count += 1
+ capability.positive_evidence_episode_ids = unique(
+ capability.positive_evidence_episode_ids + [episode.episode_id]
+ )
+ else:
+ capability.failure_count += 1
+ capability.negative_evidence_episode_ids = unique(
+ capability.negative_evidence_episode_ids + [episode.episode_id]
+ )
+ capability.updated_at_version = state.version
+ operations.append(
+ {
+ "kind": "record_capability_outcome",
+ "capability_id": capability.capability_id,
+ "success": episode.success,
+ "episode_id": episode.episode_id,
+ }
+ )
+ return operations
+
+ def recalibrate_lifecycle(self, state: HarnessState) -> None:
+ """Promote or demote learned capabilities from complete outcome evidence."""
+ for capability in state.capabilities:
+ if capability.kind == "atomic_external" or capability.status == "deprecated":
+ continue
+ capability.status = self._max_evidence_status(capability)
+ positive = len(set(capability.positive_evidence_episode_ids))
+ negative = len(set(capability.negative_evidence_episode_ids))
+ # A small beta prior avoids assigning certainty from only a few
+ # episodes while still allowing evidence-driven lifecycle changes.
+ evidence_confidence = (positive + 1) / (positive + negative + 2)
+ capability.confidence = self._bounded_confidence(
+ capability.status, evidence_confidence
+ )
+
+ def _max_evidence_status(self, capability: CapabilityUnit) -> str:
+ positive = len(set(capability.positive_evidence_episode_ids))
+ negative = len(set(capability.negative_evidence_episode_ids))
+ rate = positive / max(1, positive + negative)
+ if (
+ positive >= self.validated_min_positive_evidence
+ and rate >= self.validated_min_success_rate
+ ):
+ return "validated"
+ if positive >= self.provisional_min_positive_evidence:
+ return "provisional"
+ return "hypothesized"
+
+ def _bounded_status(self, capability: CapabilityUnit, requested: str) -> str:
+ order = {"hypothesized": 0, "provisional": 1, "validated": 2}
+ evidence_max = self._max_evidence_status(capability)
+ if requested not in order:
+ requested = capability.status if capability.status in order else "hypothesized"
+ return min((requested, evidence_max), key=lambda item: order[item])
+
+ @staticmethod
+ def _bounded_confidence(status: str, value: Any) -> float:
+ try:
+ confidence = max(0.0, min(1.0, float(value)))
+ except (TypeError, ValueError):
+ confidence = 0.0
+ ceiling = {"hypothesized": 0.49, "provisional": 0.79}.get(status, 1.0)
+ return min(confidence, ceiling)
+
+ def _create(
+ self,
+ state: HarnessState,
+ proposal: Dict[str, Any],
+ model: str,
+ known_episode_ids: Set[str],
+ ) -> Dict[str, Any] | None:
+ kind = str(proposal.get("kind", ""))
+ if kind not in {"composite_external", "internal"}:
+ return None
+ name = str(proposal.get("name", "")).strip()[:200]
+ if not name:
+ return None
+ normalized_names = {
+ self._normalized_name(item.name) for item in state.capabilities
+ if item.status != "deprecated"
+ }
+ if self._normalized_name(name) in normalized_names:
+ return None
+ by_id = {item.capability_id: item for item in state.capabilities}
+ dependencies = [
+ item for item in self._strings(proposal.get("dependencies"), 20) if item in by_id
+ ]
+ provider = str(proposal.get("provider_component", "")).strip()
+ if kind == "composite_external" and not dependencies:
+ return None
+ if kind == "internal" and provider not in INTERNAL_PROVIDERS:
+ return None
+ procedure = self._strings(proposal.get("procedure"), 30)
+ inputs = self._strings(proposal.get("inputs"), 20)
+ outputs = self._strings(proposal.get("outputs"), 20)
+ success_conditions = self._strings(proposal.get("success_conditions"), 20)
+ if not procedure or not inputs or not outputs or not success_conditions:
+ return None
+ positive = [
+ item
+ for item in self._strings(proposal.get("positive_evidence_episode_ids"), 50)
+ if item in known_episode_ids
+ ]
+ negative = [
+ item
+ for item in self._strings(proposal.get("negative_evidence_episode_ids"), 50)
+ if item in known_episode_ids
+ ]
+ if not positive:
+ return None
+ if kind == "internal":
+ positive = [
+ episode_id
+ for episode_id in positive
+ if self._raw_episode_supports_provider(state, episode_id, provider)
+ ]
+ if not positive:
+ return None
+ atomic_prefixes = {
+ prefix
+ for item in state.capabilities
+ if item.kind == "atomic_external"
+ for prefix in item.action_prefixes
+ }
+ capability = CapabilityUnit(
+ capability_id=f"capability-{uuid.uuid4().hex[:12]}",
+ name=name,
+ kind=kind,
+ function=str(proposal.get("function", proposal.get("description", "")))[:500],
+ action_prefixes=[
+ prefix
+ for prefix in self._strings(proposal.get("action_prefixes"), 20)
+ if prefix in atomic_prefixes
+ ],
+ input_output="LLM-mediated structured capability",
+ preconditions=self._strings(proposal.get("preconditions"), 30),
+ failure_modes=self._strings(proposal.get("failure_modes"), 30),
+ version=f"harness-{state.version}",
+ description=str(proposal.get("description", ""))[:1000],
+ dependencies=dependencies,
+ procedure=procedure,
+ inputs=inputs,
+ outputs=outputs,
+ success_conditions=success_conditions,
+ positive_evidence_episode_ids=positive,
+ negative_evidence_episode_ids=negative,
+ usage_count=len(set(positive + negative)),
+ success_count=len(set(positive)),
+ failure_count=len(set(negative)),
+ confidence=self._bounded_confidence("hypothesized", proposal.get("confidence", 0.0)),
+ status="hypothesized",
+ provider_component=provider,
+ model=model,
+ prompt_version="capability-induction-v1",
+ created_at_version=state.version,
+ updated_at_version=state.version,
+ )
+ state.capabilities.append(capability)
+ return {"kind": "create_capability", "capability_id": capability.capability_id}
+
+ def _refine(
+ self,
+ state: HarnessState,
+ proposal: Dict[str, Any],
+ known_episode_ids: Set[str],
+ ) -> Dict[str, Any] | None:
+ capability_id = str(proposal.get("capability_id", ""))
+ target = next(
+ (item for item in state.capabilities if item.capability_id == capability_id), None
+ )
+ if target is None or target.kind == "atomic_external" or target.status == "deprecated":
+ return None
+ dependencies = self._strings(proposal.get("dependencies"), 20)
+ known_capabilities = {item.capability_id for item in state.capabilities}
+ if dependencies:
+ target.dependencies = [
+ item
+ for item in dependencies
+ if item in known_capabilities and item != capability_id
+ ]
+ for field_name in (
+ "procedure",
+ "inputs",
+ "outputs",
+ "preconditions",
+ "failure_modes",
+ "success_conditions",
+ ):
+ value = self._strings(proposal.get(field_name), 30)
+ if value:
+ setattr(target, field_name, value)
+ for field_name in ("name", "description", "function"):
+ value = str(proposal.get(field_name, "")).strip()
+ if value:
+ setattr(target, field_name, value[:1000])
+ positive = [
+ item
+ for item in self._strings(proposal.get("positive_evidence_episode_ids"), 50)
+ if item in known_episode_ids
+ ]
+ negative = [
+ item
+ for item in self._strings(proposal.get("negative_evidence_episode_ids"), 50)
+ if item in known_episode_ids
+ ]
+ target.positive_evidence_episode_ids = unique(
+ target.positive_evidence_episode_ids + positive
+ )
+ target.negative_evidence_episode_ids = unique(
+ target.negative_evidence_episode_ids + negative
+ )
+ target.success_count = len(set(target.positive_evidence_episode_ids))
+ target.failure_count = len(set(target.negative_evidence_episode_ids))
+ target.usage_count = len(
+ set(target.positive_evidence_episode_ids + target.negative_evidence_episode_ids)
+ )
+ requested_status = str(proposal.get("requested_status", target.status))
+ target.status = self._bounded_status(target, requested_status)
+ target.confidence = self._bounded_confidence(
+ target.status, proposal.get("confidence", target.confidence)
+ )
+ target.updated_at_version = state.version
+ return {
+ "kind": "refine_capability",
+ "capability_id": target.capability_id,
+ "status": target.status,
+ }
+
+ def _deprecate(
+ self,
+ state: HarnessState,
+ proposal: Dict[str, Any],
+ known_episode_ids: Set[str],
+ ) -> Dict[str, Any] | None:
+ capability_id = str(proposal.get("capability_id", ""))
+ target = next(
+ (item for item in state.capabilities if item.capability_id == capability_id), None
+ )
+ rationale = str(proposal.get("rationale", "")).strip()
+ negative = [
+ item
+ for item in self._strings(proposal.get("negative_evidence_episode_ids"), 50)
+ if item in known_episode_ids
+ ]
+ if (
+ target is None
+ or target.kind == "atomic_external"
+ or target.status == "deprecated"
+ or not rationale
+ ):
+ return None
+ target.negative_evidence_episode_ids = unique(
+ target.negative_evidence_episode_ids + negative
+ )
+ if not target.negative_evidence_episode_ids:
+ return None
+ target.status = "deprecated"
+ target.availability = "unavailable"
+ target.confidence = 0.0
+ target.failure_modes = unique(target.failure_modes + [f"Deprecated: {rationale[:500]}"])
+ target.updated_at_version = state.version
+ return {"kind": "deprecate_capability", "capability_id": target.capability_id}
+
+ def apply_proposals(
+ self,
+ state: HarnessState,
+ reflection: Dict[str, Any],
+ model: str,
+ ) -> List[Dict[str, Any]]:
+ proposals = reflection.get("capability_operations", [])
+ if not isinstance(proposals, list):
+ return []
+ known_episode_ids = self._known_episode_ids(state)
+ operations: List[Dict[str, Any]] = []
+ for proposal in proposals[: self.max_operations]:
+ if not isinstance(proposal, dict):
+ continue
+ operation_kind = str(proposal.get("operation", ""))
+ if operation_kind == "create":
+ applied = self._create(state, proposal, model, known_episode_ids)
+ elif operation_kind == "refine":
+ applied = self._refine(state, proposal, known_episode_ids)
+ elif operation_kind == "deprecate":
+ applied = self._deprecate(state, proposal, known_episode_ids)
+ else:
+ applied = None
+ if applied is not None:
+ operations.append(applied)
+ return operations
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/cli.py b/experiments/HCL_ALFworld/src/hcl_alfworld/cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..2b91bfdb20b19931e1a94971bd07e495388d8a6c
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/cli.py
@@ -0,0 +1,209 @@
+from __future__ import annotations
+
+import argparse
+import importlib.metadata
+import json
+import os
+import platform
+import sys
+from pathlib import Path
+from typing import Any, Dict
+
+from .config import load_config, resolve_project_path
+from .environment import validate_data_dir
+from .runner import ExperimentRunner
+from .sequence import build_sequence
+
+
+def _print(data: Dict[str, Any]) -> None:
+ print(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=True))
+
+
+def _local_model_report(model_config: Dict[str, Any], project_root: str) -> Dict[str, Any]:
+ configured_path = str(model_config.get("model_path") or "")
+ path = Path(configured_path).expanduser()
+ if not path.is_absolute():
+ path = Path(project_root) / path
+ path = path.resolve()
+ config_present = (path / "config.json").is_file()
+ tokenizer_present = (path / "tokenizer_config.json").is_file()
+ weights_present = any(path.glob("*.safetensors")) or any(path.glob("*.bin"))
+ return {
+ "path": str(path),
+ "config_present": config_present,
+ "tokenizer_present": tokenizer_present,
+ "weights_present": weights_present,
+ "valid": bool(configured_path and config_present and tokenizer_present and weights_present),
+ }
+
+
+def command_doctor(args: argparse.Namespace) -> int:
+ config = load_config(args.config)
+ data = validate_data_dir(resolve_project_path(config, "data_dir"))
+ agent_config = dict(config.get("agent", {}))
+ agent_provider = str(agent_config.get("provider", "openai")).lower()
+ report = {
+ "python": sys.version,
+ "python_executable": sys.executable,
+ "platform": platform.platform(),
+ "config": config["_config_path"],
+ "data": data,
+ "agent_provider": agent_provider,
+ }
+ models_valid = True
+ local_provider_used = agent_provider == "transformers"
+ if agent_provider == "transformers":
+ report["agent_model"] = _local_model_report(
+ agent_config, config["_project_root"]
+ )
+ models_valid = report["agent_model"]["valid"]
+ else:
+ api_key_env = str(agent_config.get("api_key_env") or "OPENAI_API_KEY")
+ report["api_key_env"] = api_key_env
+ report["api_key_present"] = bool(os.environ.get(api_key_env))
+ if str(config.get("continual_method", "hcl")).lower() in {"rag"}:
+ embedding_config = dict(config.get("embedding", {}))
+ embedding_provider = str(embedding_config.get("provider", "openai")).lower()
+ report["embedding_provider"] = embedding_provider
+ if embedding_provider == "transformers":
+ local_provider_used = True
+ report["embedding_model"] = _local_model_report(
+ embedding_config, config["_project_root"]
+ )
+ models_valid = models_valid and report["embedding_model"]["valid"]
+ try:
+ import textworld
+
+ report["alfworld"] = importlib.metadata.version("alfworld")
+ report["textworld"] = getattr(textworld, "__version__", "installed")
+ except ImportError as exc:
+ report["dependency_error"] = str(exc)
+ if local_provider_used:
+ local_dependencies = {}
+ missing_dependencies = []
+ for name in ("torch", "transformers", "accelerate"):
+ try:
+ local_dependencies[name] = importlib.metadata.version(name)
+ except importlib.metadata.PackageNotFoundError:
+ missing_dependencies.append(name)
+ report["local_dependencies"] = local_dependencies
+ if missing_dependencies:
+ report["dependency_error"] = (
+ "Missing local inference dependencies: " + ", ".join(missing_dependencies)
+ )
+ _print(report)
+ return 0 if data["valid"] and models_valid and "dependency_error" not in report else 1
+
+
+def command_build_sequence(args: argparse.Namespace) -> int:
+ config = load_config(args.config)
+ sequence = config["sequence"]
+ manifest = build_sequence(
+ data_dir=resolve_project_path(config, "data_dir"),
+ output_path=resolve_project_path(config, "sequence_manifest"),
+ task_order=sequence["task_order"],
+ train_episodes_per_task=int(sequence["train_episodes_per_task"]),
+ eval_episodes_per_task=sequence["eval_episodes_per_task"],
+ train_split=str(sequence["train_split"]),
+ eval_split=str(sequence["eval_split"]),
+ seed=int(config.get("seed", 42)),
+ )
+ _print(
+ {
+ "manifest": str(resolve_project_path(config, "sequence_manifest")),
+ "phase_count": len(manifest["phases"]),
+ "task_order": manifest["task_order"],
+ }
+ )
+ return 0
+
+
+def command_run(args: argparse.Namespace) -> int:
+ config = load_config(args.config)
+ if args.transport_max_retries is not None:
+ if args.transport_max_retries < 0:
+ raise ValueError("--transport-max-retries must be non-negative.")
+ config.setdefault("agent", {})["transport_max_retries"] = int(
+ args.transport_max_retries
+ )
+ if args.resume_disable_raw_memory_retrieval and not args.resume:
+ raise ValueError(
+ "--resume-disable-raw-memory-retrieval requires --resume; "
+ "configs/hcl.yaml already disables raw-memory retrieval for a new run."
+ )
+ manifest = resolve_project_path(config, "sequence_manifest")
+ if config.get("run_mode") != "standard_evaluation" and not manifest.exists():
+ sequence = config["sequence"]
+ build_sequence(
+ resolve_project_path(config, "data_dir"),
+ manifest,
+ sequence["task_order"],
+ int(sequence["train_episodes_per_task"]),
+ sequence["eval_episodes_per_task"],
+ str(sequence["train_split"]),
+ str(sequence["eval_split"]),
+ int(config.get("seed", 42)),
+ )
+ runner = ExperimentRunner(
+ config,
+ resume_dir=args.resume,
+ raw_memory_retrieval_override=(
+ False if args.resume_disable_raw_memory_retrieval else None
+ ),
+ )
+ results = runner.run()
+ _print(
+ {
+ "output_dir": str(runner.output_dir),
+ "metrics": results["metrics"],
+ "harness_version": results["harness_version"],
+ "elapsed_seconds": results["elapsed_seconds"],
+ }
+ )
+ return 0
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(description="Harness Continual Learning on ALFWorld")
+ subparsers = parser.add_subparsers(dest="command", required=True)
+ for name, handler, help_text in (
+ ("doctor", command_doctor, "verify Python, dependencies, config, and ALFWorld data"),
+ ("build-sequence", command_build_sequence, "build a deterministic continual task stream"),
+ ("run", command_run, "run learning and seen-task evaluation after every phase"),
+ ):
+ child = subparsers.add_parser(name, help=help_text)
+ child.add_argument("--config", default="configs/hcl.yaml")
+ if name == "run":
+ child.add_argument(
+ "--resume",
+ default=None,
+ help="resume a timestamped run directory from its last episode checkpoint",
+ )
+ child.add_argument(
+ "--resume-disable-raw-memory-retrieval",
+ action="store_true",
+ help=(
+ "resume with raw trajectories stored but excluded from online routing; "
+ "records an intentional mid-run protocol change"
+ ),
+ )
+ child.add_argument(
+ "--transport-max-retries",
+ type=int,
+ default=None,
+ help=(
+ "override transient transport retries; safe to change between "
+ "resume attempts"
+ ),
+ )
+ child.set_defaults(handler=handler)
+ return parser
+
+
+def main() -> int:
+ args = build_parser().parse_args()
+ return int(args.handler(args))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/config.py b/experiments/HCL_ALFworld/src/hcl_alfworld/config.py
new file mode 100644
index 0000000000000000000000000000000000000000..ce8a35ef665a5b7907a595e991ba8b465a565eb7
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/config.py
@@ -0,0 +1,42 @@
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from typing import Any, Dict
+
+import yaml
+
+
+def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
+ result = copy.deepcopy(base)
+ for key, value in override.items():
+ if key in result and isinstance(result[key], dict) and isinstance(value, dict):
+ result[key] = _deep_merge(result[key], value)
+ else:
+ result[key] = copy.deepcopy(value)
+ return result
+
+
+def load_config(path: str | Path) -> Dict[str, Any]:
+ path = Path(path).resolve()
+ with path.open("r", encoding="utf-8") as handle:
+ config = yaml.safe_load(handle) or {}
+ parent = config.pop("extends", None)
+ if parent:
+ parent_config = load_config(path.parent / parent)
+ config = _deep_merge(parent_config, config)
+ root = path.parent
+ for candidate in (path.parent, *path.parents):
+ if (candidate / "pyproject.toml").exists():
+ root = candidate
+ break
+ config["_config_path"] = str(path)
+ config["_project_root"] = str(root.resolve())
+ return config
+
+
+def resolve_project_path(config: Dict[str, Any], key: str) -> Path:
+ value = Path(str(config[key]))
+ if value.is_absolute():
+ return value
+ return Path(config["_project_root"]) / value
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/embedding.py b/experiments/HCL_ALFworld/src/hcl_alfworld/embedding.py
new file mode 100644
index 0000000000000000000000000000000000000000..59022a26b8c22e72b7ce1316007e3b224c99698e
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/embedding.py
@@ -0,0 +1,246 @@
+from __future__ import annotations
+
+import math
+import os
+import threading
+from collections import defaultdict
+from pathlib import Path
+from typing import Any, Dict, List, Protocol, Sequence
+
+
+class EmbedderProtocol(Protocol):
+ def embed(self, texts: Sequence[str]) -> List[List[float]]: ...
+
+ def usage_snapshot(self) -> Dict[str, Any]: ...
+
+
+def cosine_similarity(left: Sequence[float], right: Sequence[float]) -> float:
+ if len(left) != len(right) or not left:
+ return 0.0
+ numerator = sum(a * b for a, b in zip(left, right))
+ left_norm = math.sqrt(sum(value * value for value in left))
+ right_norm = math.sqrt(sum(value * value for value in right))
+ if left_norm <= 1e-12 or right_norm <= 1e-12:
+ return 0.0
+ return numerator / (left_norm * right_norm)
+
+
+class OpenAICompatibleEmbedder:
+ """Small OpenAI-compatible embedding client for semantic-memory baselines."""
+
+ def __init__(self, config: Dict[str, Any]):
+ try:
+ from openai import OpenAI
+ except ImportError as exc:
+ raise RuntimeError("Install the LLM extra with `pip install -e '.[llm]'`.") from exc
+
+ api_key_env = str(config.get("api_key_env", "OPENAI_API_KEY"))
+ configured_api_key = config.get("api_key")
+ api_key = (
+ str(configured_api_key)
+ if configured_api_key is not None
+ else os.environ.get(api_key_env)
+ )
+ if not api_key:
+ raise RuntimeError(
+ "Embedding retrieval requires embedding.api_key or "
+ f"the {api_key_env} environment variable."
+ )
+ options: Dict[str, Any] = {
+ "api_key": api_key,
+ "timeout": float(config.get("request_timeout_seconds", 90.0)),
+ "max_retries": int(config.get("transport_max_retries", 3)),
+ }
+ if config.get("base_url"):
+ options["base_url"] = str(config["base_url"])
+ self.client = OpenAI(**options)
+ self.model = str(config["model"])
+ self.expected_dimension = (
+ int(config["dimension"]) if config.get("dimension") is not None else None
+ )
+ self.request_dimensions = (
+ int(config["request_dimensions"])
+ if config.get("request_dimensions") is not None
+ else None
+ )
+ self.max_text_length = int(config.get("max_text_length", 4096))
+ self.usage: Dict[str, Any] = {
+ "requests": 0,
+ "input_tokens": 0,
+ "by_model": defaultdict(lambda: {"requests": 0, "input_tokens": 0}),
+ }
+
+ def embed(self, texts: Sequence[str]) -> List[List[float]]:
+ normalized = [str(text)[: self.max_text_length] for text in texts]
+ if not normalized:
+ return []
+ request: Dict[str, Any] = {"model": self.model, "input": normalized}
+ # Fixed-dimension local models such as Qwen3-Embedding-4B reject
+ # OpenAI's optional Matryoshka `dimensions` argument. `dimension`
+ # therefore validates output only; an API parameter is sent solely
+ # when the separate `request_dimensions` option is explicit.
+ if self.request_dimensions is not None:
+ request["dimensions"] = self.request_dimensions
+ response = self.client.embeddings.create(**request)
+ ordered = sorted(response.data, key=lambda item: int(item.index))
+ vectors = [[float(value) for value in item.embedding] for item in ordered]
+ if len(vectors) != len(normalized):
+ raise RuntimeError(
+ f"Embedding endpoint returned {len(vectors)} vectors for {len(normalized)} inputs."
+ )
+ if self.expected_dimension is not None:
+ invalid_dimensions = [
+ len(vector) for vector in vectors if len(vector) != self.expected_dimension
+ ]
+ if invalid_dimensions:
+ raise RuntimeError(
+ "Embedding endpoint returned an unexpected vector dimension: "
+ f"expected {self.expected_dimension}, got {invalid_dimensions[0]}."
+ )
+ tokens = int(getattr(getattr(response, "usage", None), "prompt_tokens", 0) or 0)
+ self.usage["requests"] += 1
+ self.usage["input_tokens"] += tokens
+ by_model = self.usage["by_model"][self.model]
+ by_model["requests"] += 1
+ by_model["input_tokens"] += tokens
+ return vectors
+
+ def usage_snapshot(self) -> Dict[str, Any]:
+ return {
+ "requests": int(self.usage["requests"]),
+ "input_tokens": int(self.usage["input_tokens"]),
+ "by_model": {model: dict(values) for model, values in self.usage["by_model"].items()},
+ }
+
+
+_LOCAL_EMBED_CACHE: Dict[tuple[str, str, str, str], tuple[Any, Any, threading.Lock]] = {}
+_LOCAL_EMBED_CACHE_LOCK = threading.Lock()
+
+
+class LocalTransformersEmbedder:
+ """Produce dense vectors directly from a local encoder model."""
+
+ def __init__(self, config: Dict[str, Any]):
+ try:
+ import torch
+ from transformers import AutoModel, AutoTokenizer
+ except ImportError as exc:
+ raise RuntimeError("Install the local extra with `pip install -e '.[local]'.") from exc
+
+ model_path = Path(str(config.get("model_path", ""))).expanduser().resolve()
+ if not (model_path / "config.json").is_file():
+ raise FileNotFoundError(f"Local embedding model directory is incomplete: {model_path}")
+ if config.get("request_dimensions") is not None:
+ raise ValueError("Local embeddings do not support request_dimensions.")
+ dtype_name = str(config.get("dtype", "float32"))
+ if dtype_name not in {"bfloat16", "float16", "float32"}:
+ raise ValueError(f"Unsupported local embedding dtype: {dtype_name}")
+ device_map = str(config.get("device_map", "auto"))
+ padding_side = config.get("padding_side")
+ if padding_side is not None and padding_side not in {"left", "right"}:
+ raise ValueError(f"Unsupported local embedding padding_side: {padding_side}")
+ cache_key = (str(model_path), dtype_name, device_map, str(padding_side or ""))
+ with _LOCAL_EMBED_CACHE_LOCK:
+ if cache_key not in _LOCAL_EMBED_CACHE:
+ tokenizer_options: Dict[str, Any] = {"local_files_only": True}
+ if padding_side is not None:
+ tokenizer_options["padding_side"] = padding_side
+ tokenizer = AutoTokenizer.from_pretrained(model_path, **tokenizer_options)
+ model = AutoModel.from_pretrained(
+ model_path,
+ local_files_only=True,
+ dtype=getattr(torch, dtype_name),
+ device_map=device_map,
+ ).eval()
+ _LOCAL_EMBED_CACHE[cache_key] = (tokenizer, model, threading.Lock())
+ self.tokenizer, self.model, self._model_lock = _LOCAL_EMBED_CACHE[cache_key]
+
+ self.model_name = str(config.get("model") or model_path.name)
+ self.expected_dimension = (
+ int(config["dimension"]) if config.get("dimension") is not None else None
+ )
+ self.max_text_length = int(config.get("max_text_length", 4096))
+ self.max_tokens = int(config.get("max_tokens", 8192))
+ self.batch_size = int(config.get("batch_size", 8))
+ if self.max_tokens <= 0 or self.batch_size <= 0:
+ raise ValueError("Local embedding max_tokens and batch_size must be positive.")
+ self.pooling = str(config.get("pooling", "cls")).lower()
+ if self.pooling not in {"cls", "last_token", "mean"}:
+ raise ValueError(f"Unknown local embedding pooling: {self.pooling}")
+ self.normalize = bool(config.get("normalize", True))
+ self.usage: Dict[str, Any] = {
+ "requests": 0,
+ "input_tokens": 0,
+ "by_model": defaultdict(lambda: {"requests": 0, "input_tokens": 0}),
+ }
+
+ def embed(self, texts: Sequence[str]) -> List[List[float]]:
+ import torch
+ import torch.nn.functional as F
+
+ normalized = [str(text)[: self.max_text_length] for text in texts]
+ if not normalized:
+ return []
+ result: List[List[float]] = []
+ token_count = 0
+ with self._model_lock, torch.inference_mode():
+ for start in range(0, len(normalized), self.batch_size):
+ batch = normalized[start : start + self.batch_size]
+ encoded = self.tokenizer(
+ batch,
+ padding=True,
+ truncation=True,
+ max_length=self.max_tokens,
+ return_tensors="pt",
+ )
+ token_count += int(encoded["attention_mask"].sum().item())
+ encoded = {
+ key: value.to(next(self.model.parameters()).device)
+ for key, value in encoded.items()
+ }
+ hidden = self.model(**encoded).last_hidden_state
+ mask = encoded["attention_mask"]
+ if self.pooling == "cls":
+ vectors = hidden[:, 0]
+ elif self.pooling == "last_token":
+ last_indices = mask.shape[1] - 1 - mask.flip(1).int().argmax(1)
+ vectors = hidden[
+ torch.arange(hidden.shape[0], device=hidden.device), last_indices
+ ]
+ else:
+ weights = mask.unsqueeze(-1).to(hidden.dtype)
+ vectors = (hidden * weights).sum(1) / weights.sum(1).clamp(min=1)
+ vectors = vectors.float()
+ if self.normalize:
+ vectors = F.normalize(vectors, p=2, dim=-1)
+ result.extend(vectors.cpu().tolist())
+
+ if self.expected_dimension is not None and any(
+ len(vector) != self.expected_dimension for vector in result
+ ):
+ raise RuntimeError(
+ "Local embedding model returned an unexpected vector dimension: "
+ f"expected {self.expected_dimension}, got {len(result[0])}."
+ )
+ self.usage["requests"] += 1
+ self.usage["input_tokens"] += token_count
+ by_model = self.usage["by_model"][self.model_name]
+ by_model["requests"] += 1
+ by_model["input_tokens"] += token_count
+ return result
+
+ def usage_snapshot(self) -> Dict[str, Any]:
+ return {
+ "requests": int(self.usage["requests"]),
+ "input_tokens": int(self.usage["input_tokens"]),
+ "by_model": {model: dict(values) for model, values in self.usage["by_model"].items()},
+ }
+
+
+def make_embedder(config: Dict[str, Any]) -> EmbedderProtocol:
+ provider = str(config.get("provider", "openai")).lower()
+ if provider == "openai":
+ return OpenAICompatibleEmbedder(config)
+ if provider == "transformers":
+ return LocalTransformersEmbedder(config)
+ raise ValueError(f"Unknown embedding provider: {provider}")
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/environment.py b/experiments/HCL_ALFworld/src/hcl_alfworld/environment.py
new file mode 100644
index 0000000000000000000000000000000000000000..cc515254446b4fa788a8d4858b4e26d2686e8056
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/environment.py
@@ -0,0 +1,113 @@
+from __future__ import annotations
+
+import json
+import os
+import threading
+from pathlib import Path
+from typing import Any, Dict, List
+
+import textworld
+import textworld.gym
+
+
+# TextWorld uses both a process-global Gym registry and a process-global TatSu
+# PDDL/text-generation parsers. Parsing happens both during env.reset() and in
+# every env.step() while TextWorld derives command text in _gather_infos(). All
+# TextWorld calls that can enter TatSu must therefore share one lock. LLM calls,
+# which dominate runtime, remain fully concurrent between episodes.
+_ENV_INITIALIZATION_LOCK = threading.Lock()
+
+
+class ALFWorldEpisodeEnv:
+ """A deterministic one-game ALFWorld TextWorld adapter."""
+
+ def __init__(
+ self,
+ game_file: str,
+ data_dir: str | Path,
+ max_steps: int = 50,
+ domain_randomization: bool = False,
+ ):
+ self.game_file = str(Path(game_file).resolve())
+ self.data_dir = Path(data_dir).resolve()
+ self.max_steps = max_steps
+ # ALFWorld creates its default cache at import time. Point it at the
+ # configured project data before the lazy import, so even restricted
+ # machines never try to write ~/.cache/alfworld.
+ os.environ["ALFWORLD_DATA"] = str(self.data_dir)
+ from alfworld.agents.environment.alfred_tw_env import (
+ AlfredDemangler,
+ AlfredInfos,
+ )
+
+ request_infos = textworld.EnvInfos(won=True, admissible_commands=True, extras=["gamefile"])
+ wrappers = [AlfredDemangler(shuffle=domain_randomization), AlfredInfos]
+ # Gym's registry is process-global. Only registration/creation needs to be
+ # serialized; completed environments can still execute concurrently.
+ with _ENV_INITIALIZATION_LOCK:
+ env_id = textworld.gym.register_games(
+ [self.game_file],
+ request_infos,
+ batch_size=1,
+ asynchronous=False,
+ max_episode_steps=max_steps,
+ wrappers=wrappers,
+ )
+ self.env = textworld.gym.make(env_id)
+
+ def reset(self):
+ with _ENV_INITIALIZATION_LOCK:
+ return self.env.reset()
+
+ def step(self, action: str):
+ with _ENV_INITIALIZATION_LOCK:
+ return self.env.step([action])
+
+ def close(self) -> None:
+ self.env.close()
+
+
+def validate_data_dir(data_dir: str | Path) -> Dict[str, Any]:
+ data_dir = Path(data_dir).resolve()
+ required = [
+ data_dir / "json_2.1.1" / "train",
+ data_dir / "json_2.1.1" / "valid_seen",
+ data_dir / "json_2.1.1" / "valid_unseen",
+ data_dir / "logic" / "alfred.pddl",
+ data_dir / "logic" / "alfred.twl2",
+ data_dir / "detectors" / "mrcnn_alfred_objects_sep13_004.pth",
+ ]
+ missing = [str(path) for path in required if not path.exists()]
+ counts = {}
+ for split in ("train", "valid_seen", "valid_unseen"):
+ root = data_dir / "json_2.1.1" / split
+ counts[split] = len(list(root.rglob("game.tw-pddl"))) if root.exists() else 0
+ return {
+ "data_dir": str(data_dir),
+ "missing": missing,
+ "game_counts": counts,
+ "valid": not missing,
+ }
+
+
+def read_task_type(game_file: str | Path) -> str:
+ with Path(game_file).with_name("traj_data.json").open("r", encoding="utf-8") as handle:
+ return str(json.load(handle)["task_type"])
+
+
+def list_split_games(data_dir: str | Path, split: str) -> List[Dict[str, str]]:
+ """List every game in an official ALFWorld split without balancing or sampling."""
+
+ data_dir = Path(data_dir).resolve()
+ split_dir = data_dir / "json_2.1.1" / split
+ if not split_dir.exists():
+ raise FileNotFoundError(f"ALFWorld split does not exist: {split_dir}")
+ games = []
+ for game_file in sorted(split_dir.rglob("game.tw-pddl")):
+ games.append(
+ {
+ "game_file": str(game_file.resolve().relative_to(data_dir)),
+ "task_type": read_task_type(game_file),
+ }
+ )
+ return games
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/evaluator.py b/experiments/HCL_ALFworld/src/hcl_alfworld/evaluator.py
new file mode 100644
index 0000000000000000000000000000000000000000..a6f26d8a606ff90a4ede75a811fad07dcf2d832b
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/evaluator.py
@@ -0,0 +1,787 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List
+
+from .capability_evolution import CAPABILITY_KINDS, CAPABILITY_STATUSES, INTERNAL_PROVIDERS
+from .llm import StructuredLLMProtocol
+from .schemas import CandidateUpdate, EpisodeRecord, EvaluationResult, HarnessState
+
+
+class ContinualEvaluator:
+ """LLM semantic review constrained by non-negotiable state-integrity checks."""
+
+ def __init__(
+ self,
+ llm: StructuredLLMProtocol,
+ min_current_score: float = 1.0,
+ max_anchor_regression: float = 0.0,
+ provisional_min_positive_evidence: int = 2,
+ validated_min_positive_evidence: int = 3,
+ validated_min_success_rate: float = 0.67,
+ gate_mode: str = "stability",
+ ):
+ self.llm = llm
+ self.min_current_score = min_current_score
+ self.max_anchor_regression = max_anchor_regression
+ self.provisional_min_positive_evidence = provisional_min_positive_evidence
+ self.validated_min_positive_evidence = validated_min_positive_evidence
+ self.validated_min_success_rate = validated_min_success_rate
+ self.gate_mode = gate_mode
+
+ @staticmethod
+ def _clean_string_list(value: Any) -> List[str]:
+ """Normalize a JSON string or string array without splitting scalars."""
+ if isinstance(value, str):
+ value = [value]
+ if not isinstance(value, list):
+ return []
+ cleaned: List[str] = []
+ for item in value:
+ if not isinstance(item, str):
+ continue
+ text = item.strip()
+ if text and text not in cleaned:
+ cleaned.append(text)
+ return cleaned
+
+ def _validate_structure(
+ self, stable: HarnessState, candidate: CandidateUpdate
+ ) -> List[str]:
+ problems: List[str] = []
+ proposed = candidate.proposed_state
+ if proposed.version != candidate.base_version + 1:
+ problems.append("candidate version is not exactly base_version + 1")
+ for label, identifiers in (
+ ("strategy", [item.artifact_id for item in proposed.abstract_memory]),
+ ("capability", [item.capability_id for item in proposed.capabilities]),
+ ("anchor", [item.anchor_id for item in proposed.anchors]),
+ ):
+ if len(identifiers) != len(set(identifiers)):
+ problems.append(f"duplicate {label} identifiers")
+ stable_capabilities = {item.capability_id: item for item in stable.capabilities}
+ proposed_capabilities = {item.capability_id: item for item in proposed.capabilities}
+ missing_existing = set(stable_capabilities) - set(proposed_capabilities)
+ if missing_existing:
+ problems.append(f"candidate deleted capabilities: {sorted(missing_existing)}")
+
+ raw_episode_ids = {
+ str(item.get("episode_id"))
+ for item in proposed.raw_memory
+ if item.get("episode_id")
+ }
+ raw_by_id = {
+ str(item.get("episode_id")): item
+ for item in proposed.raw_memory
+ if item.get("episode_id")
+ }
+ normalized_names: Dict[str, str] = {}
+ atomic_prefixes = {
+ prefix
+ for item in proposed.capabilities
+ if item.kind == "atomic_external"
+ for prefix in item.action_prefixes
+ }
+ for capability in proposed.capabilities:
+ if capability.kind not in CAPABILITY_KINDS:
+ problems.append(
+ f"{capability.capability_id}: unsupported kind {capability.kind}"
+ )
+ if capability.status not in CAPABILITY_STATUSES:
+ problems.append(
+ f"{capability.capability_id}: unsupported status {capability.status}"
+ )
+ if not 0.0 <= capability.confidence <= 1.0:
+ problems.append(f"{capability.capability_id}: confidence outside [0, 1]")
+ normalized_name = " ".join(capability.name.lower().split())
+ if normalized_name in normalized_names and capability.status != "deprecated":
+ problems.append(
+ f"duplicate capability names: {normalized_names[normalized_name]} and "
+ f"{capability.capability_id}"
+ )
+ normalized_names[normalized_name] = capability.capability_id
+ unknown_dependencies = set(capability.dependencies) - set(proposed_capabilities)
+ if unknown_dependencies:
+ problems.append(
+ f"{capability.capability_id}: unknown dependencies "
+ f"{sorted(unknown_dependencies)}"
+ )
+ if capability.capability_id in capability.dependencies:
+ problems.append(f"{capability.capability_id}: self dependency")
+ unknown_evidence = (
+ set(capability.positive_evidence_episode_ids)
+ | set(capability.negative_evidence_episode_ids)
+ ) - raw_episode_ids
+ if unknown_evidence:
+ problems.append(
+ f"{capability.capability_id}: unknown evidence episodes "
+ f"{sorted(unknown_evidence)}"
+ )
+ if capability.kind != "atomic_external":
+ invented_prefixes = set(capability.action_prefixes) - atomic_prefixes
+ if invented_prefixes:
+ problems.append(
+ f"{capability.capability_id}: invented action prefixes "
+ f"{sorted(invented_prefixes)}"
+ )
+ if not capability.procedure:
+ problems.append(f"{capability.capability_id}: missing procedure")
+ if not capability.inputs or not capability.outputs:
+ problems.append(f"{capability.capability_id}: missing inputs or outputs")
+ if not capability.success_conditions:
+ problems.append(f"{capability.capability_id}: missing success conditions")
+ positive = len(set(capability.positive_evidence_episode_ids))
+ negative = len(set(capability.negative_evidence_episode_ids))
+ rate = positive / max(1, positive + negative)
+ if (
+ capability.status in {"provisional", "validated"}
+ and positive < self.provisional_min_positive_evidence
+ ):
+ problems.append(
+ f"{capability.capability_id}: insufficient evidence for provisional"
+ )
+ if capability.status == "validated" and (
+ positive < self.validated_min_positive_evidence
+ or rate < self.validated_min_success_rate
+ ):
+ problems.append(
+ f"{capability.capability_id}: insufficient evidence for validated"
+ )
+ if capability.kind == "internal":
+ if capability.provider_component not in INTERNAL_PROVIDERS:
+ problems.append(
+ f"{capability.capability_id}: unsupported internal provider"
+ )
+ unsupported = []
+ for episode_id in capability.positive_evidence_episode_ids:
+ traces = raw_by_id.get(episode_id, {}).get("harness_traces", [])
+ if not any(
+ isinstance(trace, dict)
+ and trace.get(capability.provider_component)
+ for trace in traces
+ ):
+ unsupported.append(episode_id)
+ if unsupported:
+ problems.append(
+ f"{capability.capability_id}: internal evidence lacks provider trace "
+ f"{unsupported}"
+ )
+ previous = stable_capabilities.get(capability.capability_id)
+ if previous is not None and previous.kind == "atomic_external":
+ immutable_before = (
+ previous.name,
+ previous.kind,
+ previous.function,
+ previous.action_prefixes,
+ previous.preconditions,
+ previous.failure_modes,
+ previous.status,
+ previous.availability,
+ )
+ immutable_after = (
+ capability.name,
+ capability.kind,
+ capability.function,
+ capability.action_prefixes,
+ capability.preconditions,
+ capability.failure_modes,
+ capability.status,
+ capability.availability,
+ )
+ if immutable_before != immutable_after:
+ problems.append(
+ f"{capability.capability_id}: atomic capability semantics are immutable"
+ )
+
+ graph = {
+ item.capability_id: list(item.dependencies)
+ for item in proposed.capabilities
+ if item.status != "deprecated"
+ }
+ visiting: set[str] = set()
+ visited: set[str] = set()
+
+ def visit(node: str) -> bool:
+ if node in visiting:
+ return True
+ if node in visited:
+ return False
+ visiting.add(node)
+ if any(visit(dependency) for dependency in graph.get(node, []) if dependency in graph):
+ return True
+ visiting.remove(node)
+ visited.add(node)
+ return False
+
+ if any(visit(node) for node in graph if node not in visited):
+ problems.append("capability dependency graph contains a cycle")
+ for capability in proposed.capabilities:
+ if capability.status == "deprecated":
+ continue
+ deprecated_dependencies = [
+ dependency
+ for dependency in capability.dependencies
+ if proposed_capabilities[dependency].status == "deprecated"
+ ]
+ if deprecated_dependencies:
+ problems.append(
+ f"{capability.capability_id}: depends on deprecated capabilities "
+ f"{deprecated_dependencies}"
+ )
+ return problems
+
+ @staticmethod
+ def _hard_anchor_failures(
+ stable: HarnessState, candidate: CandidateUpdate
+ ) -> Dict[str, str]:
+ capability_ids = {item.capability_id for item in candidate.proposed_state.capabilities}
+ strategy_ids = {item.artifact_id for item in candidate.proposed_state.abstract_memory}
+ failures: Dict[str, str] = {}
+ for anchor in stable.anchors:
+ missing_capabilities = set(anchor.expected_capability_ids) - capability_ids
+ missing_strategies = set(anchor.expected_strategy_ids) - strategy_ids
+ if missing_capabilities or missing_strategies:
+ failures[anchor.anchor_id] = (
+ f"missing_capabilities={sorted(missing_capabilities)}, "
+ f"missing_strategies={sorted(missing_strategies)}"
+ )
+ return failures
+
+ @staticmethod
+ def _candidate_summary(candidate: CandidateUpdate) -> Dict[str, Any]:
+ state = candidate.proposed_state
+ return {
+ "candidate_id": candidate.candidate_id,
+ "base_version": candidate.base_version,
+ "proposed_version": state.version,
+ "operations": candidate.operations,
+ "rationale": candidate.rationale,
+ "strategies": [
+ {
+ "artifact_id": item.artifact_id,
+ "task_type": item.task_type,
+ "title": item.title,
+ "procedure": item.procedure,
+ "applicability": item.applicability,
+ "failure_boundaries": item.failure_boundaries,
+ "evidence_episode_ids": item.evidence_episode_ids,
+ }
+ for item in state.abstract_memory
+ ],
+ "capabilities": [
+ {
+ "capability_id": item.capability_id,
+ "name": item.name,
+ "kind": item.kind,
+ "description": item.description,
+ "dependencies": item.dependencies,
+ "procedure": item.procedure,
+ "inputs": item.inputs,
+ "outputs": item.outputs,
+ "preconditions": item.preconditions,
+ "success_conditions": item.success_conditions,
+ "failure_modes": item.failure_modes,
+ "positive_evidence_episode_ids": item.positive_evidence_episode_ids,
+ "negative_evidence_episode_ids": item.negative_evidence_episode_ids,
+ "status": item.status,
+ "confidence": item.confidence,
+ "provider_component": item.provider_component,
+ "validation_evidence": item.validation_evidence[-10:],
+ }
+ for item in state.capabilities
+ ],
+ }
+
+ @staticmethod
+ def _clean_index_list(value: Any, upper_bound: int) -> List[int]:
+ if not isinstance(value, list):
+ return []
+ cleaned: List[int] = []
+ for item in value:
+ if isinstance(item, bool):
+ continue
+ try:
+ index = int(item)
+ except (TypeError, ValueError):
+ continue
+ if 0 <= index < upper_bound and index not in cleaned:
+ cleaned.append(index)
+ return cleaned
+
+ @classmethod
+ def _candidate_delta(
+ cls, stable: HarnessState, candidate: CandidateUpdate
+ ) -> Dict[str, Any]:
+ proposed = candidate.proposed_state
+ strategies = {item.artifact_id: item for item in proposed.abstract_memory}
+ capabilities = {item.capability_id: item for item in proposed.capabilities}
+ operations: List[Dict[str, Any]] = []
+ for index, operation in enumerate(candidate.operations):
+ entry: Dict[str, Any] = {"operation_index": index, **operation}
+ strategy = strategies.get(str(operation.get("artifact_id", "")))
+ capability = capabilities.get(str(operation.get("capability_id", "")))
+ if strategy is not None:
+ entry["strategy"] = {
+ "artifact_id": strategy.artifact_id,
+ "task_type": strategy.task_type,
+ "title": strategy.title,
+ "procedure": strategy.procedure,
+ "applicability": strategy.applicability,
+ "failure_boundaries": strategy.failure_boundaries,
+ "evidence_episode_ids": strategy.evidence_episode_ids,
+ }
+ if (
+ capability is not None
+ and operation.get("kind") != "record_capability_outcome"
+ ):
+ entry["capability"] = next(
+ item
+ for item in cls._candidate_summary(candidate)["capabilities"]
+ if item["capability_id"] == capability.capability_id
+ )
+ operations.append(entry)
+ return {
+ "candidate_id": candidate.candidate_id,
+ "base_version": candidate.base_version,
+ "proposed_version": proposed.version,
+ "operations": operations,
+ "rationale": candidate.rationale,
+ "stable_counts": {
+ "raw_memory": len(stable.raw_memory),
+ "strategies": len(stable.abstract_memory),
+ "capabilities": len(stable.capabilities),
+ "anchors": len(stable.anchors),
+ },
+ }
+
+ @staticmethod
+ def _affected_anchor_ids(
+ stable: HarnessState, candidate: CandidateUpdate
+ ) -> set[str]:
+ affected_strategy_ids = {
+ str(operation.get("artifact_id"))
+ for operation in candidate.operations
+ if operation.get("kind") == "refine_strategy"
+ }
+ affected_capability_ids = {
+ str(operation.get("capability_id"))
+ for operation in candidate.operations
+ if operation.get("kind") in {"refine_capability", "deprecate_capability"}
+ }
+ return {
+ anchor.anchor_id
+ for anchor in stable.anchors
+ if set(anchor.expected_strategy_ids) & affected_strategy_ids
+ or set(anchor.expected_capability_ids) & affected_capability_ids
+ }
+
+ def _evaluate_stability(
+ self,
+ stable: HarnessState,
+ candidate: CandidateUpdate,
+ episode: EpisodeRecord,
+ ) -> EvaluationResult:
+ structural_diagnostics = self._validate_structure(stable, candidate)
+ if structural_diagnostics:
+ return EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="rollback",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=structural_diagnostics,
+ partial_operation_kinds=[
+ "add_raw_memory",
+ "add_strategy",
+ "refine_strategy",
+ "record_capability_outcome",
+ ],
+ )
+
+ hard_failures = self._hard_anchor_failures(stable, candidate)
+ review = self.llm.complete_json(
+ "evaluator",
+ (
+ "You are the Continual Evaluator for an ALFWorld harness. Judge whether the "
+ "candidate contains grounded, reusable knowledge and preserves every historical "
+ "anchor semantically. A successful episode does not justify overgeneralized or "
+ "unsupported rules. A failed episode may contribute raw evidence and bounded "
+ "failure lessons but never a success strategy. Review every create/refine/"
+ "deprecate capability operation: require grounded episode evidence, useful and "
+ "non-duplicative semantics, correct dependencies, calibrated confidence and a "
+ "falsifiable success condition. Internal capability claims must be supported by "
+ "observable component traces and downstream behavior, not model self-description. "
+ "Capabilities whose kind is atomic_external are immutable contracts supplied by "
+ "the ALFWorld environment: their validated status and confidence 1.0 do not require "
+ "positive episode evidence, and an unused atomic_external capability is neither a "
+ "regression nor a reason to reject, demote or deprecate a candidate. Apply evidence "
+ "calibration requirements only to composite_external and internal capabilities. "
+ "Return JSON with decision "
+ "(commit, partial_merge, or reject), retained_anchor_ids, regressions, diagnostics, "
+ "rationale. An anchor is retained only if the candidate still has semantically "
+ "appropriate capabilities and strategy coverage for its goal. Use only supplied "
+ "anchor IDs. retained_anchor_ids, regressions, and diagnostics must be JSON arrays "
+ "of plain strings, even when they contain zero or one item."
+ ),
+ {
+ "episode": episode.to_dict(),
+ "candidate": self._candidate_summary(candidate),
+ "historical_anchors": [item.__dict__ for item in stable.anchors],
+ "hard_anchor_failures": hard_failures,
+ "thresholds": {
+ "min_current_score": self.min_current_score,
+ "max_anchor_regression": self.max_anchor_regression,
+ "provisional_min_positive_evidence": (
+ self.provisional_min_positive_evidence
+ ),
+ "validated_min_positive_evidence": self.validated_min_positive_evidence,
+ "validated_min_success_rate": self.validated_min_success_rate,
+ },
+ },
+ )
+ known_anchor_ids = {item.anchor_id for item in stable.anchors}
+ retained_response = self._clean_string_list(review.get("retained_anchor_ids", []))
+ retained_ids = {
+ item
+ for item in retained_response
+ if isinstance(item, str) and item in known_anchor_ids and item not in hard_failures
+ }
+ if stable.anchors:
+ retention = len(retained_ids) / len(stable.anchors)
+ else:
+ retention = 1.0
+ regression_messages = self._clean_string_list(review.get("regressions", []))
+ regression_messages.extend(
+ f"{anchor_id}: {reason}" for anchor_id, reason in hard_failures.items()
+ )
+ diagnostics = self._clean_string_list(review.get("diagnostics", []))
+ if review.get("rationale"):
+ diagnostics.append(f"LLM rationale: {review['rationale']}")
+ episode.harness_traces.append(
+ {
+ "evaluator": {
+ "component": "llm_evaluator",
+ "proposed_decision": str(review.get("decision", "reject")),
+ "rationale": str(review.get("rationale", "")),
+ "retained_anchor_ids": list(retained_ids),
+ }
+ }
+ )
+ for raw_episode in candidate.proposed_state.raw_memory:
+ if raw_episode.get("episode_id") == episode.episode_id:
+ raw_episode["harness_traces"] = list(episode.harness_traces)
+
+ proposed_decision = str(review.get("decision", "reject"))
+ if proposed_decision not in {"commit", "partial_merge", "reject"}:
+ proposed_decision = "reject"
+ diagnostics.append("Evaluator LLM returned an unsupported decision.")
+ regression = 1.0 - retention
+ if regression > self.max_anchor_regression:
+ decision = "partial_merge"
+ diagnostics.append("Candidate exceeded allowed historical-anchor regression.")
+ elif episode.score < self.min_current_score and episode.success:
+ decision = "reject"
+ diagnostics.append("Current-task score did not meet the hard commit threshold.")
+ elif not episode.success and proposed_decision == "commit":
+ decision = "partial_merge"
+ diagnostics.append("A failed episode cannot be fully committed.")
+ else:
+ decision = proposed_decision
+
+ return EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision=decision,
+ current_score=float(episode.score),
+ anchor_retention=retention,
+ regressions=regression_messages,
+ diagnostics=diagnostics,
+ partial_operation_kinds=[
+ "add_raw_memory",
+ "add_strategy",
+ "refine_strategy",
+ "record_capability_outcome",
+ ],
+ )
+
+ def _evaluate_plasticity(
+ self,
+ stable: HarnessState,
+ candidate: CandidateUpdate,
+ episode: EpisodeRecord,
+ ) -> EvaluationResult:
+ structural_diagnostics = self._validate_structure(stable, candidate)
+ if structural_diagnostics:
+ return EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="rollback",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=structural_diagnostics,
+ )
+
+ operations = candidate.operations
+ evidence_kinds = {"add_raw_memory", "record_capability_outcome"}
+ evidence_indices = {
+ index
+ for index, operation in enumerate(operations)
+ if operation.get("kind") in evidence_kinds
+ }
+ semantic_indices = set(range(len(operations))) - evidence_indices
+ affected_anchor_ids = self._affected_anchor_ids(stable, candidate)
+ affected_anchors = [
+ anchor.__dict__
+ for anchor in stable.anchors
+ if anchor.anchor_id in affected_anchor_ids
+ ]
+ hard_failures = self._hard_anchor_failures(stable, candidate)
+ review = self.llm.complete_json(
+ "evaluator",
+ (
+ "You are the operation-level Continual Evaluator for an ALFWorld harness in "
+ "plasticity mode. The evidence operations add_raw_memory and "
+ "record_capability_outcome are already accepted and must not be judged. Judge "
+ "only the supplied semantic delta, never reject an operation merely because an "
+ "unchanged capability or strategy elsewhere in the stable state is hypothesized. "
+ "Accept a successful strategy when its ordered reusable procedure is grounded in "
+ "the successful trajectory. Accept a failure_boundary when it is a bounded, "
+ "grounded lesson and does not claim success. A newly created hypothesized "
+ "capability may be stored with one positive grounded episode; it does not need to "
+ "meet provisional or validated thresholds yet. Judge capability creation, "
+ "refinement and deprecation independently from strategies. Accept a staged anchor "
+ "only when its associated successful strategy is accepted. Only anchors listed "
+ "under affected_historical_anchors can regress; additive operations cannot regress "
+ "an old anchor. The current episode failing is not by itself evidence that an "
+ "unchanged historical strategy regressed. Return JSON with decision, "
+ "accepted_operation_indices, rejected_operation_indices, regressed_anchor_ids, "
+ "regressions, diagnostics, and rationale. Indices must refer to candidate_delta "
+ "operations. Lists must be JSON arrays."
+ ),
+ {
+ "episode": episode.to_dict(),
+ "candidate_delta": self._candidate_delta(stable, candidate),
+ "preaccepted_evidence_operation_indices": sorted(evidence_indices),
+ "semantic_operation_indices": sorted(semantic_indices),
+ "affected_historical_anchors": affected_anchors,
+ "staged_new_anchors": [
+ item.__dict__
+ for item in candidate.proposed_state.anchors
+ if item.anchor_id not in {anchor.anchor_id for anchor in stable.anchors}
+ ],
+ "hard_anchor_failures": hard_failures,
+ "thresholds": {
+ "min_current_score": self.min_current_score,
+ "max_anchor_regression": self.max_anchor_regression,
+ "provisional_min_positive_evidence": (
+ self.provisional_min_positive_evidence
+ ),
+ "validated_min_positive_evidence": self.validated_min_positive_evidence,
+ "validated_min_success_rate": self.validated_min_success_rate,
+ },
+ },
+ )
+
+ proposed_decision = str(review.get("decision", "reject"))
+ requested_accepts = set(
+ self._clean_index_list(
+ review.get("accepted_operation_indices", []), len(operations)
+ )
+ ) & semantic_indices
+ if "accepted_operation_indices" not in review and proposed_decision == "commit":
+ requested_accepts = set(semantic_indices)
+ requested_rejects = set(
+ self._clean_index_list(
+ review.get("rejected_operation_indices", []), len(operations)
+ )
+ ) & semantic_indices
+ accepted = evidence_indices | (requested_accepts - requested_rejects)
+
+ # A successful flag with a sub-threshold score is still evidence, but it
+ # cannot promote semantic knowledge or an anchor.
+ diagnostics = self._clean_string_list(review.get("diagnostics", []))
+ if episode.success and episode.score < self.min_current_score:
+ accepted &= evidence_indices
+ diagnostics.append(
+ "Current-task score did not meet the semantic promotion threshold; "
+ "evidence was retained."
+ )
+
+ known_anchor_ids = {item.anchor_id for item in stable.anchors}
+ reported_regressions = {
+ item
+ for item in self._clean_string_list(review.get("regressed_anchor_ids", []))
+ if item in affected_anchor_ids
+ }
+ regressed_anchor_ids = reported_regressions | set(hard_failures)
+ regression_messages = self._clean_string_list(review.get("regressions", []))
+ regression_messages.extend(
+ f"{anchor_id}: {reason}" for anchor_id, reason in hard_failures.items()
+ )
+ regression = len(regressed_anchor_ids) / max(1, len(known_anchor_ids))
+ if regression > self.max_anchor_regression:
+ for index, operation in enumerate(operations):
+ if index not in accepted:
+ continue
+ kind = operation.get("kind")
+ target = str(
+ operation.get("artifact_id")
+ or operation.get("capability_id")
+ or ""
+ )
+ if kind == "refine_strategy" and any(
+ anchor.anchor_id in regressed_anchor_ids
+ and target in anchor.expected_strategy_ids
+ for anchor in stable.anchors
+ ):
+ accepted.discard(index)
+ if kind in {"refine_capability", "deprecate_capability"} and any(
+ anchor.anchor_id in regressed_anchor_ids
+ and target in anchor.expected_capability_ids
+ for anchor in stable.anchors
+ ):
+ accepted.discard(index)
+ diagnostics.append(
+ "Regressing semantic operations were rejected; independent evidence "
+ "and additive operations remained eligible."
+ )
+
+ # Keep the accepted capability subgraph closed when the evaluator
+ # accepts a capability that depends on another newly proposed one.
+ stable_capability_ids = {
+ item.capability_id
+ for item in stable.capabilities
+ if item.status != "deprecated"
+ }
+ capabilities_by_id = {
+ item.capability_id: item
+ for item in candidate.proposed_state.capabilities
+ }
+ changed = True
+ while changed:
+ changed = False
+ accepted_created_ids = {
+ str(operation.get("capability_id"))
+ for index, operation in enumerate(operations)
+ if index in accepted and operation.get("kind") == "create_capability"
+ }
+ available_capability_ids = stable_capability_ids | accepted_created_ids
+ for index, operation in enumerate(operations):
+ if index not in accepted or operation.get("kind") not in {
+ "create_capability",
+ "refine_capability",
+ }:
+ continue
+ capability = capabilities_by_id.get(
+ str(operation.get("capability_id", ""))
+ )
+ if capability is not None and not set(capability.dependencies) <= (
+ available_capability_ids
+ ):
+ accepted.discard(index)
+ diagnostics.append(
+ f"Capability operation {index} was rejected because an "
+ "accepted dependency was unavailable."
+ )
+ changed = True
+
+ # An anchor cannot be accepted unless its expected semantic entities
+ # already exist in stable state or are produced by an accepted operation.
+ stable_strategy_ids = {item.artifact_id for item in stable.abstract_memory}
+ stable_capability_ids = {item.capability_id for item in stable.capabilities}
+ accepted_strategy_ids = {
+ str(operation.get("artifact_id"))
+ for index, operation in enumerate(operations)
+ if index in accepted
+ and operation.get("kind") in {"add_strategy", "refine_strategy"}
+ }
+ accepted_capability_ids = {
+ str(operation.get("capability_id"))
+ for index, operation in enumerate(operations)
+ if index in accepted
+ and operation.get("kind")
+ in {"create_capability", "refine_capability", "deprecate_capability"}
+ }
+ anchors_by_id = {
+ item.anchor_id: item for item in candidate.proposed_state.anchors
+ }
+ strategy_operation_indices: Dict[str, set[int]] = {}
+ for index, operation in enumerate(operations):
+ if operation.get("kind") not in {"add_strategy", "refine_strategy"}:
+ continue
+ strategy_operation_indices.setdefault(
+ str(operation.get("artifact_id", "")), set()
+ ).add(index)
+ for index, operation in enumerate(operations):
+ if index not in accepted or operation.get("kind") != "add_anchor":
+ continue
+ anchor = anchors_by_id.get(str(operation.get("anchor_id", "")))
+ associated_strategy_accepted = anchor is not None and all(
+ not strategy_operation_indices.get(strategy_id)
+ or bool(strategy_operation_indices[strategy_id] & accepted)
+ for strategy_id in anchor.expected_strategy_ids
+ )
+ if (
+ anchor is None
+ or not associated_strategy_accepted
+ or not set(anchor.expected_strategy_ids)
+ <= (stable_strategy_ids | accepted_strategy_ids)
+ or not set(anchor.expected_capability_ids)
+ <= (stable_capability_ids | accepted_capability_ids)
+ ):
+ accepted.discard(index)
+ diagnostics.append(
+ f"Anchor operation {index} was rejected because its semantic "
+ "dependencies were not accepted."
+ )
+
+ if review.get("rationale"):
+ diagnostics.append(f"LLM rationale: {review['rationale']}")
+ rejected = set(range(len(operations))) - accepted
+ decision = (
+ "commit"
+ if len(accepted) == len(operations)
+ else "partial_merge"
+ if accepted
+ else "reject"
+ )
+ retention = (
+ 1.0
+ if not known_anchor_ids
+ else 1.0 - len(regressed_anchor_ids) / len(known_anchor_ids)
+ )
+ episode.harness_traces.append(
+ {
+ "evaluator": {
+ "component": "llm_evaluator",
+ "gate_mode": "plasticity",
+ "proposed_decision": proposed_decision,
+ "rationale": str(review.get("rationale", "")),
+ "accepted_operation_indices": sorted(accepted),
+ "rejected_operation_indices": sorted(rejected),
+ "regressed_anchor_ids": sorted(regressed_anchor_ids),
+ }
+ }
+ )
+ for raw_episode in candidate.proposed_state.raw_memory:
+ if raw_episode.get("episode_id") == episode.episode_id:
+ raw_episode["harness_traces"] = list(episode.harness_traces)
+ return EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision=decision,
+ current_score=float(episode.score),
+ anchor_retention=retention,
+ regressions=regression_messages,
+ diagnostics=diagnostics,
+ partial_operation_kinds=sorted(evidence_kinds),
+ accepted_operation_indices=sorted(accepted),
+ rejected_operation_indices=sorted(rejected),
+ )
+
+ def evaluate(
+ self,
+ stable: HarnessState,
+ candidate: CandidateUpdate,
+ episode: EpisodeRecord,
+ ) -> EvaluationResult:
+ if self.gate_mode == "plasticity":
+ return self._evaluate_plasticity(stable, candidate, episode)
+ return self._evaluate_stability(stable, candidate, episode)
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/harness.py b/experiments/HCL_ALFworld/src/hcl_alfworld/harness.py
new file mode 100644
index 0000000000000000000000000000000000000000..8e3b2895738978e8568f9acd14bc999b04e060d5
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/harness.py
@@ -0,0 +1,469 @@
+from __future__ import annotations
+
+import copy
+from pathlib import Path
+from typing import Any, Dict
+
+from .capability import default_capabilities
+from .evaluator import ContinualEvaluator
+from .llm import StructuredLLMProtocol
+from .memory import ExperienceMemory, HarnessStore
+from .optimizer import ContinualOptimizer
+from .router import AdaptiveRouter
+from .schemas import CandidateUpdate, EpisodeRecord, EvaluationResult, HarnessState, RoutingContext
+
+
+class HCLHarness:
+ """Closed HCL loop implementing stable/candidate state separation."""
+
+ def __init__(
+ self,
+ store_dir: str | Path,
+ config: Dict[str, Any],
+ llm: StructuredLLMProtocol,
+ ):
+ self.config = config
+ self.gate_mode = str(config.get("gate_mode", "stability")).strip().lower()
+ if self.gate_mode not in {"plasticity", "stability"}:
+ raise ValueError(
+ "harness.gate_mode must be either 'plasticity' or 'stability'"
+ )
+ self.store = HarnessStore(store_dir)
+ memory = ExperienceMemory(
+ top_k_memory=int(config.get("top_k_memory", 5)),
+ top_k_strategies=int(config.get("top_k_strategies", 3)),
+ )
+ self.router = AdaptiveRouter(
+ llm,
+ memory,
+ memory_enabled=bool(config.get("memory_enabled", True)),
+ raw_memory_retrieval_enabled=bool(
+ config.get("raw_memory_retrieval_enabled", True)
+ ),
+ strategy_retrieval_enabled=bool(
+ config.get("strategy_retrieval_enabled", True)
+ ),
+ capabilities_enabled=bool(config.get("capabilities_enabled", True)),
+ candidate_pool_size=int(config.get("candidate_pool_size", 20)),
+ gate_mode=self.gate_mode,
+ )
+ self.optimizer = ContinualOptimizer(
+ llm,
+ raw_memory_capacity=int(config.get("raw_memory_capacity", 500)),
+ anchors_per_task=int(config.get("anchors_per_task", 5)),
+ capability_learning_enabled=bool(
+ config.get("capability_learning_enabled", True)
+ and config.get("capabilities_enabled", True)
+ ),
+ max_capability_operations=int(config.get("max_capability_operations", 3)),
+ provisional_min_positive_evidence=int(
+ config.get("provisional_min_positive_evidence", 2)
+ ),
+ validated_min_positive_evidence=int(
+ config.get("validated_min_positive_evidence", 3)
+ ),
+ validated_min_success_rate=float(
+ config.get("validated_min_success_rate", 0.67)
+ ),
+ gate_mode=self.gate_mode,
+ )
+ self.evaluator = ContinualEvaluator(
+ llm,
+ min_current_score=float(config.get("min_current_score", 1.0)),
+ max_anchor_regression=float(config.get("max_anchor_regression", 0.0)),
+ provisional_min_positive_evidence=int(
+ config.get("provisional_min_positive_evidence", 2)
+ ),
+ validated_min_positive_evidence=int(
+ config.get("validated_min_positive_evidence", 3)
+ ),
+ validated_min_success_rate=float(
+ config.get("validated_min_success_rate", 0.67)
+ ),
+ gate_mode=self.gate_mode,
+ )
+ initial = HarnessState(
+ capabilities=default_capabilities(),
+ routing_policy={
+ "gate_mode": self.gate_mode,
+ "top_k_memory": int(config.get("top_k_memory", 5)),
+ "top_k_strategies": int(config.get("top_k_strategies", 3)),
+ "raw_memory_retrieval_enabled": bool(
+ config.get("raw_memory_retrieval_enabled", True)
+ ),
+ "strategy_retrieval_enabled": bool(
+ config.get("strategy_retrieval_enabled", True)
+ ),
+ },
+ evaluator_rules={
+ "gate_mode": self.gate_mode,
+ "min_current_score": float(config.get("min_current_score", 1.0)),
+ "max_anchor_regression": float(config.get("max_anchor_regression", 0.0)),
+ },
+ )
+ self.state = self.store.load(initial)
+
+ def route(self, task, trigger_reasons=None) -> RoutingContext:
+ return self.router.route(task, self.state, trigger_reasons)
+
+ def reuse_route(
+ self, task, previous_context: RoutingContext
+ ) -> RoutingContext:
+ return self.router.reuse(task, self.state, previous_context)
+
+ def _partial_merge(
+ self, candidate: CandidateUpdate, evaluation: EvaluationResult
+ ) -> HarnessState:
+ merged = copy.deepcopy(self.state)
+ allowed = set(evaluation.partial_operation_kinds)
+ candidate_state = candidate.proposed_state
+ if "add_raw_memory" in allowed:
+ merged.raw_memory = copy.deepcopy(candidate_state.raw_memory)
+ if "add_strategy" in allowed or "refine_strategy" in allowed:
+ by_id = {item.artifact_id: item for item in merged.abstract_memory}
+ for strategy in candidate_state.abstract_memory:
+ if not strategy.title.startswith("Failure boundary"):
+ continue
+ if strategy.artifact_id in by_id:
+ existing = by_id[strategy.artifact_id]
+ existing.title = strategy.title
+ existing.procedure = list(strategy.procedure)
+ existing.applicability = list(strategy.applicability)
+ existing.failure_boundaries = list(strategy.failure_boundaries)
+ existing.evidence_episode_ids = list(strategy.evidence_episode_ids)
+ else:
+ merged.abstract_memory.append(copy.deepcopy(strategy))
+ if "record_capability_outcome" in allowed:
+ outcome_ids = {
+ operation.get("capability_id")
+ for operation in candidate.operations
+ if operation.get("kind") == "record_capability_outcome"
+ }
+ candidate_by_id = {
+ item.capability_id: item for item in candidate_state.capabilities
+ }
+ for capability in merged.capabilities:
+ if capability.capability_id not in outcome_ids:
+ continue
+ candidate_capability = candidate_by_id[capability.capability_id]
+ capability.usage_count = candidate_capability.usage_count
+ capability.success_count = candidate_capability.success_count
+ capability.failure_count = candidate_capability.failure_count
+ capability.positive_evidence_episode_ids = list(
+ candidate_capability.positive_evidence_episode_ids
+ )
+ capability.negative_evidence_episode_ids = list(
+ candidate_capability.negative_evidence_episode_ids
+ )
+ capability.updated_at_version = candidate_capability.updated_at_version
+ merged.version += 1
+ return merged
+
+ def _plasticity_merge(
+ self, candidate: CandidateUpdate, evaluation: EvaluationResult
+ ) -> HarnessState:
+ """Apply only accepted delta operations while retaining monotonic evidence."""
+ merged = copy.deepcopy(self.state)
+ accepted = set(evaluation.accepted_operation_indices)
+ candidate_state = candidate.proposed_state
+ candidate_strategies = {
+ item.artifact_id: item for item in candidate_state.abstract_memory
+ }
+ candidate_capabilities = {
+ item.capability_id: item for item in candidate_state.capabilities
+ }
+ candidate_anchors = {item.anchor_id: item for item in candidate_state.anchors}
+
+ if any(
+ index in accepted and operation.get("kind") == "add_raw_memory"
+ for index, operation in enumerate(candidate.operations)
+ ):
+ merged.raw_memory = copy.deepcopy(candidate_state.raw_memory)
+
+ for index, operation in enumerate(candidate.operations):
+ if index not in accepted:
+ continue
+ kind = str(operation.get("kind", ""))
+ if kind in {"add_raw_memory", "record_capability_outcome"}:
+ continue
+ if kind in {"add_strategy", "refine_strategy"}:
+ artifact_id = str(operation.get("artifact_id", ""))
+ proposed = candidate_strategies.get(artifact_id)
+ if proposed is None:
+ continue
+ by_id = {item.artifact_id: pos for pos, item in enumerate(merged.abstract_memory)}
+ if artifact_id in by_id:
+ merged.abstract_memory[by_id[artifact_id]] = copy.deepcopy(proposed)
+ else:
+ merged.abstract_memory.append(copy.deepcopy(proposed))
+ elif kind in {
+ "create_capability",
+ "refine_capability",
+ "deprecate_capability",
+ }:
+ capability_id = str(operation.get("capability_id", ""))
+ proposed = candidate_capabilities.get(capability_id)
+ if proposed is None:
+ continue
+ by_id = {item.capability_id: pos for pos, item in enumerate(merged.capabilities)}
+ if capability_id in by_id:
+ merged.capabilities[by_id[capability_id]] = copy.deepcopy(proposed)
+ else:
+ merged.capabilities.append(copy.deepcopy(proposed))
+ elif kind == "update_capability_evidence":
+ capability_id = str(operation.get("capability_id", ""))
+ proposed = candidate_capabilities.get(capability_id)
+ target = next(
+ (
+ item
+ for item in merged.capabilities
+ if item.capability_id == capability_id
+ ),
+ None,
+ )
+ if proposed is not None and target is not None:
+ target.validation_evidence = list(proposed.validation_evidence)
+ target.updated_at_version = proposed.updated_at_version
+ elif kind == "add_anchor":
+ anchor_id = str(operation.get("anchor_id", ""))
+ proposed = candidate_anchors.get(anchor_id)
+ if proposed is not None and all(
+ item.anchor_id != anchor_id for item in merged.anchors
+ ):
+ merged.anchors.append(copy.deepcopy(proposed))
+
+ outcome_ids = {
+ str(operation.get("capability_id"))
+ for index, operation in enumerate(candidate.operations)
+ if index in accepted
+ and operation.get("kind") == "record_capability_outcome"
+ }
+ for capability in merged.capabilities:
+ if capability.capability_id not in outcome_ids:
+ continue
+ proposed = candidate_capabilities.get(capability.capability_id)
+ if proposed is None:
+ continue
+ capability.usage_count = proposed.usage_count
+ capability.success_count = proposed.success_count
+ capability.failure_count = proposed.failure_count
+ capability.positive_evidence_episode_ids = list(
+ proposed.positive_evidence_episode_ids
+ )
+ capability.negative_evidence_episode_ids = list(
+ proposed.negative_evidence_episode_ids
+ )
+ capability.updated_at_version = proposed.updated_at_version
+
+ self.optimizer.capability_evolution.recalibrate_lifecycle(merged)
+ merged.version += 1
+ return merged
+
+ def learn(
+ self, episode: EpisodeRecord, final_context: RoutingContext
+ ) -> tuple[CandidateUpdate, EvaluationResult]:
+ try:
+ candidate = self.optimizer.propose(self.state, episode, final_context)
+ except RuntimeError as exc:
+ message = str(exc)
+ if not message.startswith("optimizer did not return valid JSON:"):
+ raise
+ if self.gate_mode == "plasticity":
+ episode.harness_traces.append(
+ {
+ "optimizer": {
+ "component": "llm_optimizer",
+ "mode": "json_fallback_evidence_only",
+ "fallback": True,
+ "error": message,
+ "knowledge_type": "none",
+ "rationale": (
+ "Malformed optimizer reflection; grounded evidence "
+ "was retained without semantic operations."
+ ),
+ "capability_operations": [],
+ "strategy_diagnostic": (
+ "Skipped strategy: optimizer returned invalid JSON."
+ ),
+ }
+ }
+ )
+ candidate = self.optimizer.propose_evidence_only(
+ self.state,
+ episode,
+ "Optimizer JSON fallback: retained grounded evidence only.",
+ )
+ accepted = list(range(len(candidate.operations)))
+ evaluation = EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="partial_merge",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=[
+ "Optimizer exhausted JSON retries; retained deterministic "
+ f"episode evidence. {message}"
+ ],
+ partial_operation_kinds=[
+ "add_raw_memory",
+ "record_capability_outcome",
+ ],
+ accepted_operation_indices=accepted,
+ rejected_operation_indices=[],
+ )
+ self.state = self._plasticity_merge(candidate, evaluation)
+ self.store.save_candidate(candidate.to_dict())
+ self.store.save_stable(self.state)
+ self.store.append_interaction(episode.to_dict())
+ self.store.append_decision(evaluation.to_dict())
+ return candidate, evaluation
+ # A malformed reflection must not abort a resumable experiment or
+ # mutate the stable harness. The grounded episode remains in the
+ # interaction audit and the no-op candidate makes the skipped
+ # update explicit in episode/update artifacts.
+ candidate = CandidateUpdate(
+ candidate_id=(
+ f"candidate-v{self.state.version}-optimizer-fallback-"
+ f"{episode.episode_id}"
+ ),
+ base_version=self.state.version,
+ proposed_state=copy.deepcopy(self.state),
+ operations=[],
+ rationale=[
+ "Optimizer JSON fallback: stable Harness was left unchanged."
+ ],
+ )
+ evaluation = EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="reject",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=[
+ "Optimizer exhausted JSON retries; skipped this episode's "
+ f"Harness update. {message}"
+ ],
+ partial_operation_kinds=[],
+ )
+ episode.harness_traces.append(
+ {
+ "optimizer": {
+ "component": "llm_optimizer",
+ "mode": "json_fallback_noop",
+ "fallback": True,
+ "error": message,
+ "knowledge_type": "none",
+ "rationale": (
+ "Malformed optimizer reflection; stable Harness was "
+ "preserved without applying candidate operations."
+ ),
+ "capability_operations": [],
+ "strategy_diagnostic": (
+ "Skipped strategy: optimizer returned invalid JSON."
+ ),
+ }
+ }
+ )
+ self.store.save_candidate(candidate.to_dict())
+ self.store.append_interaction(episode.to_dict())
+ self.store.append_decision(evaluation.to_dict())
+ return candidate, evaluation
+ try:
+ evaluation = self.evaluator.evaluate(self.state, candidate, episode)
+ except RuntimeError as exc:
+ message = str(exc)
+ if (
+ self.gate_mode != "plasticity"
+ or not message.startswith("evaluator did not return valid JSON:")
+ ):
+ raise
+ accepted = [
+ index
+ for index, operation in enumerate(candidate.operations)
+ if operation.get("kind")
+ in {"add_raw_memory", "record_capability_outcome"}
+ ]
+ rejected = [
+ index
+ for index in range(len(candidate.operations))
+ if index not in accepted
+ ]
+ evaluation = EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="partial_merge" if accepted else "reject",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=[
+ "Evaluator exhausted JSON retries; retained deterministic "
+ f"evidence only. {message}"
+ ],
+ partial_operation_kinds=[
+ "add_raw_memory",
+ "record_capability_outcome",
+ ],
+ accepted_operation_indices=accepted,
+ rejected_operation_indices=rejected,
+ )
+ episode.harness_traces.append(
+ {
+ "evaluator": {
+ "component": "llm_evaluator",
+ "gate_mode": "plasticity",
+ "mode": "json_fallback_evidence_only",
+ "fallback": True,
+ "error": message,
+ "accepted_operation_indices": accepted,
+ "rejected_operation_indices": rejected,
+ }
+ }
+ )
+ self.store.save_candidate(candidate.to_dict())
+
+ if not bool(self.config.get("gate_enabled", True)) and evaluation.decision != "rollback":
+ evaluation.decision = "commit"
+ evaluation.diagnostics.append(
+ "Semantic gate disabled by ablation; structural rollback remains mandatory."
+ )
+
+ if (
+ self.gate_mode == "plasticity"
+ and bool(self.config.get("gate_enabled", True))
+ and evaluation.decision in {"commit", "partial_merge"}
+ ):
+ self.state = self._plasticity_merge(candidate, evaluation)
+ self.store.save_stable(self.state)
+ elif evaluation.decision == "commit":
+ self.state = candidate.proposed_state
+ self.store.save_stable(self.state)
+ elif evaluation.decision == "partial_merge":
+ self.state = self._partial_merge(candidate, evaluation)
+ self.store.save_stable(self.state)
+ self.store.append_interaction(episode.to_dict())
+ self.store.append_decision(evaluation.to_dict())
+ return candidate, evaluation
+
+
+def make_continual_harness(
+ method: str,
+ store_dir: str | Path,
+ config: Dict[str, Any],
+ llm: StructuredLLMProtocol,
+ *,
+ embedder: Any = None,
+ seed: int = 42,
+) -> Any:
+ """Construct one continual method behind the runner's common harness boundary."""
+ normalized = method.strip().lower()
+ if normalized == "hcl":
+ return HCLHarness(store_dir, config, llm)
+ if normalized == "rag":
+ if embedder is None:
+ raise ValueError("The RAG baseline requires an embedding provider.")
+ from .rag_baseline import RAGHarness
+
+ return RAGHarness(store_dir, config, llm, embedder)
+ raise ValueError(
+ f"Unknown continual_method: {method}. "
+ "Expected 'hcl' or 'rag'."
+ )
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/llm.py b/experiments/HCL_ALFworld/src/hcl_alfworld/llm.py
new file mode 100644
index 0000000000000000000000000000000000000000..55c9be6ba751317082647689d3d574c5cfe6801d
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/llm.py
@@ -0,0 +1,495 @@
+from __future__ import annotations
+
+import json
+import os
+import random
+import re
+import sys
+import threading
+import time
+from collections import defaultdict
+from email.utils import parsedate_to_datetime
+from pathlib import Path
+from typing import Any, Dict, Optional, Protocol
+
+
+class StructuredLLMProtocol(Protocol):
+ usage: Dict[str, Any]
+
+ def complete_json(
+ self,
+ component: str,
+ system_prompt: str,
+ payload: Dict[str, Any],
+ ) -> Dict[str, Any]: ...
+
+ def usage_snapshot(self) -> Dict[str, Any]: ...
+
+
+class OpenAICompatibleLLM:
+ """Shared structured-reasoning client used by every semantic HCL component."""
+
+ def __init__(self, config: Dict[str, Any]):
+ try:
+ from openai import OpenAI
+ except ImportError as exc:
+ raise RuntimeError("Install the LLM extra with `pip install -e '.[llm]'`.") from exc
+
+ api_key_env = str(config.get("api_key_env", "OPENAI_API_KEY"))
+ api_key = os.environ.get(api_key_env)
+ if not api_key:
+ raise RuntimeError(f"{api_key_env} is required for the LLM-driven harness.")
+ options: Dict[str, Any] = {"api_key": api_key}
+ if config.get("base_url"):
+ options["base_url"] = config["base_url"]
+ options["timeout"] = float(config.get("request_timeout_seconds", 90.0))
+ # Application-level retries below provide consistent behavior and logging
+ # across OpenAI-compatible providers, so disable the SDK's hidden retries.
+ options["max_retries"] = 0
+ self.client = OpenAI(**options)
+ self.default_model = str(config.get("model", "gpt-4.1-mini"))
+ self.component_models = dict(config.get("component_models", {}))
+ self.temperature = float(config.get("temperature", 0.0))
+ self.component_temperatures = dict(config.get("component_temperatures", {}))
+ self.thinking_enabled = config.get("thinking_enabled")
+ self.component_thinking_enabled = dict(
+ config.get("component_thinking_enabled", {})
+ )
+ self.reasoning_effort = config.get("reasoning_effort")
+ self.component_reasoning_efforts = dict(
+ config.get("component_reasoning_efforts", {})
+ )
+ raw_max_tokens = config.get("max_tokens")
+ self.max_tokens = None if raw_max_tokens is None else int(raw_max_tokens)
+ self.component_max_tokens = {
+ str(component): int(value)
+ for component, value in dict(config.get("component_max_tokens", {})).items()
+ }
+ if self.max_tokens is not None and self.max_tokens <= 0:
+ raise ValueError("max_tokens must be a positive integer.")
+ if any(value <= 0 for value in self.component_max_tokens.values()):
+ raise ValueError("component_max_tokens values must be positive integers.")
+ self.max_retries = int(config.get("max_retries", 2))
+ self.transport_max_retries = max(
+ 0, int(config.get("transport_max_retries", 8))
+ )
+ self.transport_retry_base_seconds = max(
+ 0.0, float(config.get("transport_retry_base_seconds", 1.0))
+ )
+ self.transport_retry_max_seconds = max(
+ self.transport_retry_base_seconds,
+ float(config.get("transport_retry_max_seconds", 60.0)),
+ )
+ self.transport_retry_jitter = max(
+ 0.0, float(config.get("transport_retry_jitter", 0.25))
+ )
+ self.json_mode = bool(config.get("json_mode", True))
+ self.usage: Dict[str, Any] = {
+ "requests": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "by_component": defaultdict(
+ lambda: {"requests": 0, "prompt_tokens": 0, "completion_tokens": 0}
+ ),
+ }
+
+ @staticmethod
+ def _repair_missing_commas(content: str, max_repairs: int = 8) -> Dict[str, Any]:
+ """Conservatively repair separators omitted between JSON values.
+
+ Small local instruction-tuned models occasionally omit a comma between
+ two otherwise valid object members. Only repair the exact decoder error
+ and only when the surrounding characters form an unambiguous value/key
+ boundary. Other malformed output is left for a structured retry.
+ """
+ candidate = content
+ for _ in range(max_repairs):
+ try:
+ parsed = json.loads(candidate)
+ if not isinstance(parsed, dict):
+ raise TypeError("LLM response must be a JSON object.")
+ return parsed
+ except json.JSONDecodeError as exc:
+ if exc.msg != "Expecting ',' delimiter":
+ raise
+ position = exc.pos
+ previous = position - 1
+ while previous >= 0 and candidate[previous].isspace():
+ previous -= 1
+ following = position
+ while following < len(candidate) and candidate[following].isspace():
+ following += 1
+ if (
+ previous < 0
+ or following >= len(candidate)
+ or candidate[previous] not in {'"', "}", "]"}
+ or candidate[following] != '"'
+ ):
+ raise
+ candidate = candidate[:following] + "," + candidate[following:]
+ return json.loads(candidate)
+
+ @classmethod
+ def _parse_json(cls, content: str) -> Dict[str, Any]:
+ try:
+ parsed = json.loads(content)
+ except json.JSONDecodeError:
+ match = re.search(r"\{.*\}", content, re.S)
+ if not match:
+ raise
+ parsed = cls._repair_missing_commas(match.group(0))
+ if not isinstance(parsed, dict):
+ raise TypeError("LLM response must be a JSON object.")
+ return parsed
+
+ def complete_json(
+ self,
+ component: str,
+ system_prompt: str,
+ payload: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ base_messages = [
+ {"role": "system", "content": system_prompt},
+ {
+ "role": "user",
+ "content": json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ },
+ ]
+ messages = list(base_messages)
+ last_error: Optional[Exception] = None
+ for attempt in range(self.max_retries + 1):
+ thinking_enabled = self.component_thinking_enabled.get(
+ component, self.thinking_enabled
+ )
+ reasoning_effort = self.component_reasoning_efforts.get(
+ component, self.reasoning_effort
+ )
+ max_tokens = self.component_max_tokens.get(component, self.max_tokens)
+ request: Dict[str, Any] = {
+ "model": str(self.component_models.get(component, self.default_model)),
+ "messages": messages,
+ }
+ if max_tokens is not None:
+ request["max_tokens"] = max_tokens
+ # DeepSeek thinking mode ignores sampling parameters, so omit temperature
+ # when thinking is explicitly enabled. Keeping it for other modes preserves
+ # the behavior of generic OpenAI-compatible backends.
+ if thinking_enabled is not True:
+ request["temperature"] = float(
+ self.component_temperatures.get(component, self.temperature)
+ )
+ if thinking_enabled is not None:
+ request["extra_body"] = {
+ "thinking": {
+ "type": "enabled" if bool(thinking_enabled) else "disabled"
+ }
+ }
+ if reasoning_effort is not None and thinking_enabled is not False:
+ request["reasoning_effort"] = str(reasoning_effort)
+ if self.json_mode:
+ request["response_format"] = {"type": "json_object"}
+ response = self._create_with_transport_retry(component, request)
+ self._record_usage(component, response.usage)
+ content = response.choices[0].message.content or "{}"
+ try:
+ return self._parse_json(content)
+ except (json.JSONDecodeError, TypeError) as exc:
+ last_error = exc
+ # Do not echo the entire malformed response back to the model:
+ # it grows the context and encourages the same syntax pattern.
+ messages = list(base_messages)
+ messages.append(
+ {
+ "role": "user",
+ "content": (
+ "The previous response was rejected by the JSON parser with: "
+ f"{exc}. Regenerate the object from scratch. Use double-quoted "
+ "keys and strings, put a comma between every object member and "
+ "array item, and return one concise JSON object only. "
+ f"This is structured retry {attempt + 1}."
+ ),
+ }
+ )
+ raise RuntimeError(f"{component} did not return valid JSON: {last_error}")
+
+ @staticmethod
+ def _status_code(exc: Exception) -> Optional[int]:
+ status = getattr(exc, "status_code", None)
+ if status is None:
+ status = getattr(getattr(exc, "response", None), "status_code", None)
+ try:
+ return int(status) if status is not None else None
+ except (TypeError, ValueError):
+ return None
+
+ @classmethod
+ def _is_retryable_transport_error(cls, exc: Exception) -> bool:
+ status = cls._status_code(exc)
+ if status is not None:
+ return status in {408, 409, 429} or 500 <= status <= 599
+ if isinstance(exc, (ConnectionError, TimeoutError)):
+ return True
+ # The SDK wraps httpx errors in these classes without a status code.
+ return exc.__class__.__name__ in {
+ "APIConnectionError",
+ "APITimeoutError",
+ "ConnectError",
+ "ConnectTimeout",
+ "ReadError",
+ "ReadTimeout",
+ "RemoteProtocolError",
+ "WriteError",
+ "WriteTimeout",
+ }
+
+ @staticmethod
+ def _response_headers(exc: Exception) -> Any:
+ response = getattr(exc, "response", None)
+ return getattr(response, "headers", {}) if response is not None else {}
+
+ def _retry_after_seconds(self, exc: Exception) -> Optional[float]:
+ headers = self._response_headers(exc)
+ retry_after_ms = headers.get("retry-after-ms") if headers else None
+ if retry_after_ms is not None:
+ try:
+ return max(0.0, float(retry_after_ms) / 1000.0)
+ except (TypeError, ValueError):
+ pass
+ retry_after = headers.get("retry-after") if headers else None
+ if retry_after is None:
+ return None
+ try:
+ return max(0.0, float(retry_after))
+ except (TypeError, ValueError):
+ try:
+ delay = parsedate_to_datetime(str(retry_after)).timestamp() - time.time()
+ return max(0.0, delay)
+ except (TypeError, ValueError, OverflowError):
+ return None
+
+ def _retry_delay(self, exc: Exception, retry_index: int) -> float:
+ retry_after = self._retry_after_seconds(exc)
+ if retry_after is not None:
+ base_delay = retry_after
+ else:
+ base_delay = self.transport_retry_base_seconds * (2**retry_index)
+ base_delay = min(base_delay, self.transport_retry_max_seconds)
+ if base_delay == 0.0 or self.transport_retry_jitter == 0.0:
+ return base_delay
+ spread = base_delay * self.transport_retry_jitter
+ return max(0.0, base_delay + random.uniform(-spread, spread))
+
+ def _create_with_transport_retry(
+ self, component: str, request: Dict[str, Any]
+ ) -> Any:
+ for retry_index in range(self.transport_max_retries + 1):
+ try:
+ return self.client.chat.completions.create(**request)
+ except Exception as exc:
+ if (
+ not self._is_retryable_transport_error(exc)
+ or retry_index >= self.transport_max_retries
+ ):
+ raise
+ delay = self._retry_delay(exc, retry_index)
+ status = self._status_code(exc)
+ reason = f"HTTP {status}" if status is not None else exc.__class__.__name__
+ print(
+ f"[{component}] transient LLM error ({reason}); retry "
+ f"{retry_index + 1}/{self.transport_max_retries} in {delay:.1f}s",
+ file=sys.stderr,
+ flush=True,
+ )
+ time.sleep(delay)
+ raise AssertionError("unreachable")
+
+ def _record_usage(self, component: str, usage: Any) -> None:
+ self.usage["requests"] += 1
+ component_usage = self.usage["by_component"][component]
+ component_usage["requests"] += 1
+ if usage is None:
+ return
+ prompt_tokens = int(usage.prompt_tokens or 0)
+ completion_tokens = int(usage.completion_tokens or 0)
+ self.usage["prompt_tokens"] += prompt_tokens
+ self.usage["completion_tokens"] += completion_tokens
+ component_usage["prompt_tokens"] += prompt_tokens
+ component_usage["completion_tokens"] += completion_tokens
+
+ def usage_snapshot(self) -> Dict[str, Any]:
+ return {
+ "requests": self.usage["requests"],
+ "prompt_tokens": self.usage["prompt_tokens"],
+ "completion_tokens": self.usage["completion_tokens"],
+ "by_component": {
+ key: dict(value) for key, value in self.usage["by_component"].items()
+ },
+ }
+
+
+_LOCAL_MODEL_CACHE: Dict[tuple[str, str, str], tuple[Any, Any, threading.Lock]] = {}
+_LOCAL_MODEL_CACHE_LOCK = threading.Lock()
+
+
+class LocalTransformersLLM:
+ """Run a text-only chat model from local weights without an HTTP server."""
+
+ def __init__(self, config: Dict[str, Any]):
+ try:
+ import torch
+ from transformers import AutoModelForImageTextToText, AutoTokenizer
+ except ImportError as exc:
+ raise RuntimeError("Install the local extra with `pip install -e '.[local]'.") from exc
+
+ model_path = Path(str(config.get("model_path", ""))).expanduser().resolve()
+ if not (model_path / "config.json").is_file():
+ raise FileNotFoundError(f"Local LLM model directory is incomplete: {model_path}")
+ dtype_name = str(config.get("dtype", "bfloat16"))
+ if dtype_name not in {"bfloat16", "float16", "float32"}:
+ raise ValueError(f"Unsupported local LLM dtype: {dtype_name}")
+ device_map = str(config.get("device_map", "auto"))
+ cache_key = (str(model_path), dtype_name, device_map)
+ with _LOCAL_MODEL_CACHE_LOCK:
+ if cache_key not in _LOCAL_MODEL_CACHE:
+ tokenizer = AutoTokenizer.from_pretrained(
+ model_path, local_files_only=True
+ )
+ model = AutoModelForImageTextToText.from_pretrained(
+ model_path,
+ local_files_only=True,
+ dtype=getattr(torch, dtype_name),
+ device_map=device_map,
+ ).eval()
+ _LOCAL_MODEL_CACHE[cache_key] = (tokenizer, model, threading.Lock())
+ self.tokenizer, self.model, self._generation_lock = _LOCAL_MODEL_CACHE[cache_key]
+
+ self.model_name = str(config.get("model") or model_path.name)
+ self.max_retries = int(config.get("max_retries", 2))
+ self.max_tokens = int(config.get("max_tokens", 2048))
+ self.component_max_tokens = {
+ str(key): int(value)
+ for key, value in dict(config.get("component_max_tokens", {})).items()
+ }
+ if self.max_tokens <= 0 or any(
+ value <= 0 for value in self.component_max_tokens.values()
+ ):
+ raise ValueError("Local max_tokens values must be positive integers.")
+ self.thinking_enabled = bool(config.get("thinking_enabled", False))
+ self.component_thinking_enabled = dict(
+ config.get("component_thinking_enabled", {})
+ )
+ self.temperature = float(config.get("temperature", 0.0))
+ self.component_temperatures = dict(
+ config.get("component_temperatures", {})
+ )
+ self.top_p = float(config.get("top_p", 0.95))
+ self.usage: Dict[str, Any] = {
+ "requests": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "by_component": defaultdict(
+ lambda: {"requests": 0, "prompt_tokens": 0, "completion_tokens": 0}
+ ),
+ }
+
+ def complete_json(
+ self,
+ component: str,
+ system_prompt: str,
+ payload: Dict[str, Any],
+ ) -> Dict[str, Any]:
+ import torch
+
+ base_messages = [
+ {
+ "role": "system",
+ "content": system_prompt
+ + "\nReturn exactly one JSON object without Markdown or explanation.",
+ },
+ {
+ "role": "user",
+ "content": json.dumps(payload, ensure_ascii=False, sort_keys=True),
+ },
+ ]
+ messages = list(base_messages)
+ last_error: Optional[Exception] = None
+ for attempt in range(self.max_retries + 1):
+ thinking_enabled = bool(
+ self.component_thinking_enabled.get(component, self.thinking_enabled)
+ )
+ encoded = self.tokenizer.apply_chat_template(
+ messages,
+ tokenize=True,
+ add_generation_prompt=True,
+ enable_thinking=thinking_enabled,
+ return_tensors="pt",
+ return_dict=True,
+ )
+ prompt_tokens = int(encoded["input_ids"].shape[-1])
+ encoded = {
+ key: value.to(next(self.model.parameters()).device)
+ for key, value in encoded.items()
+ }
+ temperature = float(
+ self.component_temperatures.get(component, self.temperature)
+ )
+ generation: Dict[str, Any] = {
+ "max_new_tokens": self.component_max_tokens.get(
+ component, self.max_tokens
+ ),
+ "do_sample": temperature > 0,
+ }
+ if temperature > 0:
+ generation["temperature"] = temperature
+ generation["top_p"] = self.top_p
+ with self._generation_lock, torch.inference_mode():
+ output = self.model.generate(**encoded, **generation)
+ completion = output[0][prompt_tokens:]
+ completion_tokens = int(completion.shape[-1])
+ content = self.tokenizer.decode(
+ completion, skip_special_tokens=True
+ ).strip()
+ if "" in content:
+ content = content.rsplit("", 1)[-1].strip()
+ self.usage["requests"] += 1
+ self.usage["prompt_tokens"] += prompt_tokens
+ self.usage["completion_tokens"] += completion_tokens
+ component_usage = self.usage["by_component"][component]
+ component_usage["requests"] += 1
+ component_usage["prompt_tokens"] += prompt_tokens
+ component_usage["completion_tokens"] += completion_tokens
+ try:
+ return OpenAICompatibleLLM._parse_json(content)
+ except (json.JSONDecodeError, TypeError) as exc:
+ last_error = exc
+ messages = list(base_messages)
+ messages.append(
+ {
+ "role": "user",
+ "content": (
+ "The previous response was rejected by the JSON parser with: "
+ f"{exc}. Regenerate one concise JSON object from scratch. "
+ f"This is structured retry {attempt + 1}."
+ ),
+ }
+ )
+ raise RuntimeError(f"{component} did not return valid JSON: {last_error}")
+
+ def usage_snapshot(self) -> Dict[str, Any]:
+ return {
+ "requests": self.usage["requests"],
+ "prompt_tokens": self.usage["prompt_tokens"],
+ "completion_tokens": self.usage["completion_tokens"],
+ "by_component": {
+ key: dict(value) for key, value in self.usage["by_component"].items()
+ },
+ }
+
+
+def make_llm(config: Dict[str, Any]) -> StructuredLLMProtocol:
+ provider = str(config.get("provider", "openai")).lower()
+ if provider == "openai":
+ return OpenAICompatibleLLM(config)
+ if provider == "transformers":
+ return LocalTransformersLLM(config)
+ raise ValueError(f"Unknown LLM provider: {provider}")
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/memory.py b/experiments/HCL_ALFworld/src/hcl_alfworld/memory.py
new file mode 100644
index 0000000000000000000000000000000000000000..7dce1df9243b0ed6b7d0cda60864dd533fb141ef
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/memory.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+from typing import Any, Dict, List, Tuple
+
+from .schemas import HarnessState, StrategyArtifact, TaskEvidence
+from .text import overlap_score
+
+
+class ExperienceMemory:
+ """Paper Eq. (3): traceable raw records, abstractions, and anchors."""
+
+ def __init__(self, top_k_memory: int = 5, top_k_strategies: int = 3):
+ self.top_k_memory = top_k_memory
+ self.top_k_strategies = top_k_strategies
+
+ @staticmethod
+ def _memory_text(item: Dict[str, Any]) -> str:
+ steps = item.get("steps", [])
+ action_text = " ".join(step.get("action", "") for step in steps)
+ observation_text = " ".join(step.get("observation", "") for step in steps[-3:])
+ return (
+ f"{item.get('goal', '')} {item.get('task_type', '')} {action_text} {observation_text}"
+ )
+
+ def raw_candidates(
+ self, task: TaskEvidence, state: HarnessState, pool_size: int = 20
+ ) -> List[Dict[str, Any]]:
+ """Bound the prompt size; the LLM router makes the actual relevance decision."""
+ query = f"{task.task_type} {task.goal} {task.evidence}"
+ ranked: List[Tuple[float, Dict[str, Any]]] = []
+ for item in state.raw_memory:
+ score = overlap_score(query, self._memory_text(item))
+ if item.get("task_type") == task.task_type:
+ score += 1.0
+ if item.get("success"):
+ score += 0.15
+ ranked.append((score, item))
+ ranked.sort(key=lambda pair: (pair[0], pair[1].get("episode_id", "")), reverse=True)
+ return [item for _, item in ranked[:pool_size]]
+
+ def strategy_candidates(
+ self, task: TaskEvidence, state: HarnessState, pool_size: int = 20
+ ) -> List[StrategyArtifact]:
+ query = f"{task.task_type} {task.goal} {task.evidence}"
+ ranked: List[Tuple[float, StrategyArtifact]] = []
+ for strategy in state.abstract_memory:
+ text = " ".join(
+ [strategy.task_type, strategy.title]
+ + strategy.procedure
+ + strategy.applicability
+ + strategy.failure_boundaries
+ )
+ score = overlap_score(query, text)
+ if strategy.task_type == task.task_type:
+ score += 1.0
+ score += min(strategy.validation_count, 10) * 0.01
+ ranked.append((score, strategy))
+ ranked.sort(key=lambda pair: (pair[0], pair[1].artifact_id), reverse=True)
+ return [item for _, item in ranked[:pool_size]]
+
+
+class HarnessStore:
+ """Atomic persistence for stable state, candidates, decisions, and interaction audit."""
+
+ def __init__(self, directory: str | Path):
+ self.directory = Path(directory)
+ self.candidates_dir = self.directory / "candidates"
+ self.directory.mkdir(parents=True, exist_ok=True)
+ self.candidates_dir.mkdir(parents=True, exist_ok=True)
+ self.stable_path = self.directory / "stable.json"
+
+ @staticmethod
+ def _write_json(path: Path, data: Dict[str, Any]) -> None:
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, ensure_ascii=False, sort_keys=True)
+ handle.write("\n")
+ temporary.replace(path)
+
+ @staticmethod
+ def _append_jsonl(path: Path, data: Dict[str, Any]) -> None:
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n")
+
+ def load(self, initial: HarnessState) -> HarnessState:
+ if not self.stable_path.exists():
+ self.save_stable(initial)
+ return initial
+ with self.stable_path.open("r", encoding="utf-8") as handle:
+ return HarnessState.from_dict(json.load(handle))
+
+ def save_stable(self, state: HarnessState) -> None:
+ self._write_json(self.stable_path, state.to_dict())
+
+ def save_candidate(self, candidate: Dict[str, Any]) -> Path:
+ path = self.candidates_dir / f"{candidate['candidate_id']}.json"
+ self._write_json(path, candidate)
+ return path
+
+ def append_decision(self, decision: Dict[str, Any]) -> None:
+ self._append_jsonl(self.directory / "decisions.jsonl", decision)
+
+ def append_interaction(self, episode: Dict[str, Any]) -> None:
+ self._append_jsonl(self.directory / "interactions.jsonl", episode)
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/metrics.py b/experiments/HCL_ALFworld/src/hcl_alfworld/metrics.py
new file mode 100644
index 0000000000000000000000000000000000000000..b1a83b29fd1fea46eac4cc5f018b05a4e0b1e203
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/metrics.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import Dict, List, Optional
+
+
+def continual_metrics(
+ matrix: List[List[float]], random_baseline: float = 0.0
+) -> Dict[str, Optional[float]]:
+ """Compute task-IL ACC/BWT/forgetting/FWT from a phase-by-task matrix."""
+
+ if not matrix:
+ return {
+ "average_accuracy": 0.0,
+ "backward_transfer": 0.0,
+ "forgetting": 0.0,
+ "forward_transfer": None,
+ }
+ final = matrix[-1]
+ # Rows arrive one phase at a time, while each row can already contain
+ # scores for future tasks. Retention metrics cover only learned tasks.
+ learned_count = min(len(matrix), len(final))
+ average_accuracy = sum(final[:learned_count]) / max(learned_count, 1)
+ if learned_count <= 1:
+ backward_transfer = 0.0
+ forgetting = 0.0
+ else:
+ backward_transfer = sum(final[j] - matrix[j][j] for j in range(learned_count - 1)) / (
+ learned_count - 1
+ )
+ forgetting = sum(
+ max(matrix[i][j] for i in range(j, learned_count)) - final[j]
+ for j in range(learned_count - 1)
+ ) / (learned_count - 1)
+ forward_entries = [
+ matrix[i][i + 1] - random_baseline
+ for i in range(learned_count)
+ if i + 1 < len(matrix[i])
+ ]
+ forward_transfer = (
+ sum(forward_entries) / len(forward_entries) if forward_entries else None
+ )
+ return {
+ "average_accuracy": average_accuracy,
+ "backward_transfer": backward_transfer,
+ "forgetting": forgetting,
+ "forward_transfer": forward_transfer,
+ }
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/optimizer.py b/experiments/HCL_ALFworld/src/hcl_alfworld/optimizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..53c39b9227d569b7ca7f486cc46052e2924ec5d4
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/optimizer.py
@@ -0,0 +1,375 @@
+from __future__ import annotations
+
+import copy
+import uuid
+from typing import Any, Dict, List
+
+from .capability_evolution import CapabilityEvolution
+from .llm import StructuredLLMProtocol
+from .schemas import (
+ AnchorCase,
+ CandidateUpdate,
+ EpisodeRecord,
+ HarnessState,
+ RoutingContext,
+ StrategyArtifact,
+)
+from .text import unique
+
+
+class ContinualOptimizer:
+ """Use LLM reflection to propose bounded edits without mutating stable state."""
+
+ def __init__(
+ self,
+ llm: StructuredLLMProtocol,
+ raw_memory_capacity: int = 500,
+ anchors_per_task: int = 5,
+ capability_learning_enabled: bool = True,
+ max_capability_operations: int = 3,
+ provisional_min_positive_evidence: int = 2,
+ validated_min_positive_evidence: int = 3,
+ validated_min_success_rate: float = 0.67,
+ gate_mode: str = "stability",
+ ):
+ self.llm = llm
+ self.raw_memory_capacity = raw_memory_capacity
+ self.anchors_per_task = anchors_per_task
+ self.capability_learning_enabled = capability_learning_enabled
+ self.gate_mode = gate_mode
+ self.capability_evolution = CapabilityEvolution(
+ max_operations=max_capability_operations,
+ provisional_min_positive_evidence=provisional_min_positive_evidence,
+ validated_min_positive_evidence=validated_min_positive_evidence,
+ validated_min_success_rate=validated_min_success_rate,
+ )
+
+ @staticmethod
+ def _strategy_summary(item: StrategyArtifact) -> Dict[str, Any]:
+ return {
+ "artifact_id": item.artifact_id,
+ "task_type": item.task_type,
+ "title": item.title,
+ "procedure": item.procedure,
+ "applicability": item.applicability,
+ "failure_boundaries": item.failure_boundaries,
+ "validation_count": item.validation_count,
+ "success_count": item.success_count,
+ }
+
+ @staticmethod
+ def _clean_string_list(value: Any, limit: int = 30) -> List[str]:
+ # OpenAI-compatible backends occasionally collapse a one-item JSON array
+ # to a scalar string. Treat that as a recoverable shape variation.
+ if isinstance(value, str):
+ value = [value]
+ if not isinstance(value, list):
+ return []
+ return unique(
+ str(item).strip()[:500]
+ for item in value[:limit]
+ if isinstance(item, str) and item.strip()
+ )
+
+ @classmethod
+ def _clean_procedure(cls, value: Any, limit: int = 30) -> List[str]:
+ """Normalize common structured-step variants to procedure strings."""
+ if isinstance(value, dict):
+ value = value.get("steps", value.get("procedure", []))
+ if isinstance(value, list):
+ normalized: List[Any] = []
+ for item in value[:limit]:
+ if isinstance(item, str):
+ normalized.append(item)
+ continue
+ if not isinstance(item, dict):
+ continue
+ for key in ("instruction", "action", "description", "step"):
+ text = item.get(key)
+ if isinstance(text, str) and text.strip():
+ normalized.append(text)
+ break
+ value = normalized
+ return cls._clean_string_list(value, limit)
+
+ def _apply_strategy_reflection(
+ self,
+ proposed: HarnessState,
+ episode: EpisodeRecord,
+ reflection: Dict[str, Any],
+ ) -> Dict[str, Any] | None:
+ knowledge_type = str(reflection.get("knowledge_type", "none"))
+ if episode.success and knowledge_type != "success_strategy":
+ return None
+ if not episode.success and knowledge_type != "failure_boundary":
+ return None
+
+ procedure = self._clean_procedure(reflection.get("procedure"))
+ applicability = self._clean_string_list(reflection.get("applicability"), 15)
+ boundaries = self._clean_string_list(reflection.get("failure_boundaries"), 20)
+ if episode.success and not procedure:
+ # A malformed semantic response must not abort a resumable experiment.
+ # Skipping the strategy also prevents an empty artifact and its anchor
+ # from contaminating stable state. Raw evidence and independently valid
+ # capability proposals can still be reviewed by the evaluator.
+ return None
+ if not episode.success and not boundaries:
+ boundaries = [episode.failure_reason or "The episode ended without task success."]
+
+ # A failed rollout is evidence about a boundary, not permission to
+ # overwrite a previously successful strategy. The stability mode keeps
+ # the historical behavior for reproducible comparisons.
+ requested_target_id = str(reflection.get("target_strategy_id", ""))
+ if self.gate_mode == "plasticity" and not episode.success:
+ requested_target = next(
+ (
+ item
+ for item in proposed.abstract_memory
+ if item.artifact_id == requested_target_id
+ and item.task_type == episode.task_type
+ ),
+ None,
+ )
+ target_id = (
+ requested_target_id
+ if requested_target is not None
+ and requested_target.title.startswith("Failure boundary")
+ else ""
+ )
+ else:
+ target_id = requested_target_id
+ target = next(
+ (
+ item
+ for item in proposed.abstract_memory
+ if item.artifact_id == target_id and item.task_type == episode.task_type
+ ),
+ None,
+ )
+ if target is not None:
+ target.title = str(reflection.get("title") or target.title)[:200]
+ target.procedure = procedure or target.procedure
+ target.applicability = unique(target.applicability + applicability)
+ target.failure_boundaries = unique(target.failure_boundaries + boundaries)
+ target.evidence_episode_ids = unique(
+ target.evidence_episode_ids + [episode.episode_id]
+ )
+ target.validation_count += int(episode.success)
+ target.success_count += int(episode.success)
+ return {"kind": "refine_strategy", "artifact_id": target.artifact_id}
+
+ title = str(reflection.get("title", "")).strip()[:200]
+ if not title:
+ title = (
+ f"Reusable strategy for {episode.task_type}"
+ if episode.success
+ else f"Failure boundary for {episode.task_type}"
+ )
+ if not episode.success and not title.startswith("Failure boundary"):
+ title = f"Failure boundary: {title}"
+ strategy = StrategyArtifact(
+ artifact_id=f"strategy-{uuid.uuid4().hex[:12]}",
+ task_type=episode.task_type,
+ title=title,
+ procedure=procedure,
+ applicability=applicability,
+ failure_boundaries=boundaries,
+ evidence_episode_ids=[episode.episode_id],
+ validation_count=int(episode.success),
+ success_count=int(episode.success),
+ created_at_version=proposed.version,
+ )
+ proposed.abstract_memory.append(strategy)
+ return {"kind": "add_strategy", "artifact_id": strategy.artifact_id}
+
+ def propose_evidence_only(
+ self,
+ stable: HarnessState,
+ episode: EpisodeRecord,
+ rationale: str,
+ ) -> CandidateUpdate:
+ """Stage deterministic evidence when semantic reflection is unavailable."""
+ proposed = copy.deepcopy(stable)
+ proposed.version = stable.version + 1
+ proposed.raw_memory.append(episode.to_dict())
+ proposed.raw_memory = proposed.raw_memory[-self.raw_memory_capacity :]
+ operations: List[Dict[str, Any]] = [
+ {"kind": "add_raw_memory", "episode_id": episode.episode_id}
+ ]
+ if self.capability_learning_enabled:
+ operations.extend(
+ self.capability_evolution.record_observed_outcomes(proposed, episode)
+ )
+ return CandidateUpdate(
+ candidate_id=f"candidate-v{proposed.version}-evidence-{uuid.uuid4().hex[:8]}",
+ base_version=stable.version,
+ proposed_state=proposed,
+ operations=operations,
+ rationale=[rationale],
+ )
+
+ @staticmethod
+ def _apply_capability_evidence(
+ proposed: HarnessState, reflection: Dict[str, Any], episode_id: str
+ ) -> List[Dict[str, Any]]:
+ updates = reflection.get("capability_evidence", [])
+ if not isinstance(updates, list):
+ return []
+ by_id = {item.capability_id: item for item in proposed.capabilities}
+ operations: List[Dict[str, Any]] = []
+ for update in updates[:20]:
+ if not isinstance(update, dict):
+ continue
+ capability_id = str(update.get("capability_id", ""))
+ evidence = str(update.get("evidence", "")).strip()[:500]
+ if capability_id not in by_id or not evidence:
+ continue
+ entry = f"{episode_id}: {evidence}"
+ by_id[capability_id].validation_evidence = unique(
+ by_id[capability_id].validation_evidence + [entry]
+ )[-50:]
+ operations.append(
+ {"kind": "update_capability_evidence", "capability_id": capability_id}
+ )
+ return operations
+
+ def propose(
+ self,
+ stable: HarnessState,
+ episode: EpisodeRecord,
+ final_context: RoutingContext,
+ ) -> CandidateUpdate:
+ proposed = copy.deepcopy(stable)
+ proposed.version = stable.version + 1
+ proposed.raw_memory.append(episode.to_dict())
+ proposed.raw_memory = proposed.raw_memory[-self.raw_memory_capacity :]
+ operations: List[Dict[str, Any]] = [
+ {"kind": "add_raw_memory", "episode_id": episode.episode_id}
+ ]
+ if self.capability_learning_enabled:
+ operations.extend(
+ self.capability_evolution.record_observed_outcomes(proposed, episode)
+ )
+
+ reflection = self.llm.complete_json(
+ "optimizer",
+ (
+ "You are the Continual Optimizer of an ALFWorld harness. Reflect on the entire "
+ "grounded trajectory and propose reusable knowledge, not a transcript. For a "
+ "successful episode, knowledge_type must be success_strategy and procedure must "
+ "describe a generalized, ordered, conditional plan without scene-specific object "
+ "IDs. For a failed episode, knowledge_type must be failure_boundary and you must "
+ "not claim a successful procedure. Distinguish environmental evidence from "
+ "speculation. You may refine one supplied strategy using target_strategy_id or "
+ "create a new one. Also discover capabilities from the grounded environment "
+ "trajectory and internal Task Interface, Router, Action Policy, Optimizer and "
+ "Evaluator traces. The atomic_external capabilities are environment-defined and "
+ "must never be recreated, renamed or deprecated. You may propose create/refine/"
+ "deprecate operations for composite_external and internal capabilities. Create a "
+ "capability only when positive raw-episode evidence supports a reusable function; "
+ "do not equate your own self-description with evidence. A composite capability "
+ "must cite existing dependency IDs. An internal capability must identify one of "
+ "task_interface, router, action_policy, optimizer, evaluator as provider_component. "
+ "New capabilities begin hypothesized; refinements may request provisional or "
+ "validated status but evidence thresholds are enforced outside the model. "
+ "Capability evidence may update only supplied IDs. "
+ "Return JSON with knowledge_type, target_strategy_id, title, procedure, "
+ "applicability, failure_boundaries, capability_evidence, capability_operations, "
+ "rationale. Each capability operation must have operation and, for "
+ "creation, kind, name, description, function, dependencies, procedure, inputs, "
+ "outputs, preconditions, success_conditions, failure_modes, provider_component, "
+ "positive_evidence_episode_ids, negative_evidence_episode_ids and confidence. "
+ "procedure, applicability, and failure_boundaries must each be JSON arrays of "
+ "plain strings; for a successful episode procedure must contain at least one "
+ "ordered reusable step."
+ ),
+ {
+ "episode": episode.to_dict(),
+ "routed_context": final_context.to_dict(),
+ "existing_strategies_for_task": [
+ self._strategy_summary(item)
+ for item in stable.abstract_memory
+ if item.task_type == episode.task_type
+ ],
+ "capabilities": [item.__dict__ for item in proposed.capabilities],
+ "capability_lifecycle": {
+ "statuses": [
+ "hypothesized",
+ "provisional",
+ "validated",
+ "deprecated",
+ ],
+ "new_capability_status": "hypothesized",
+ "max_operations_this_episode": self.capability_evolution.max_operations,
+ },
+ },
+ )
+ strategy_operation = self._apply_strategy_reflection(proposed, episode, reflection)
+ strategy_diagnostic = ""
+ if episode.success and strategy_operation is None:
+ if str(reflection.get("knowledge_type", "none")) != "success_strategy":
+ strategy_diagnostic = "Skipped strategy: knowledge_type was not success_strategy."
+ else:
+ strategy_diagnostic = "Skipped strategy: response had no reusable procedure."
+ episode.harness_traces.append(
+ {
+ "optimizer": {
+ "component": "llm_optimizer",
+ "knowledge_type": str(reflection.get("knowledge_type", "none")),
+ "rationale": str(reflection.get("rationale", "")),
+ "capability_operations": reflection.get("capability_operations", []),
+ "strategy_diagnostic": strategy_diagnostic,
+ }
+ }
+ )
+ for raw_episode in proposed.raw_memory:
+ if raw_episode.get("episode_id") == episode.episode_id:
+ raw_episode["harness_traces"] = copy.deepcopy(episode.harness_traces)
+ if strategy_operation is not None:
+ operations.append(strategy_operation)
+ operations.extend(
+ self._apply_capability_evidence(proposed, reflection, episode.episode_id)
+ )
+ if self.capability_learning_enabled:
+ operations.extend(
+ self.capability_evolution.apply_proposals(
+ proposed,
+ reflection,
+ model=str(getattr(self.llm, "default_model", "configured-llm")),
+ )
+ )
+
+ # Anchor retention is a harness invariant, not an LLM preference. Stage one
+ # anchor for every grounded success until the per-task capacity is reached.
+ # If the evaluator rejects the candidate, the staged anchor is rejected with
+ # it and stable state remains unchanged.
+ if episode.success:
+ per_task = [item for item in proposed.anchors if item.task_type == episode.task_type]
+ strategy_id = strategy_operation.get("artifact_id") if strategy_operation else ""
+ if len(per_task) < self.anchors_per_task and strategy_id:
+ anchor = AnchorCase(
+ anchor_id=f"anchor-{uuid.uuid4().hex[:12]}",
+ task_type=episode.task_type,
+ goal=episode.goal,
+ evidence=(
+ episode.steps[-1].next_observation if episode.steps else episode.goal
+ ),
+ admissible_commands=(
+ episode.steps[-1].admissible_commands if episode.steps else []
+ ),
+ expected_capability_ids=[
+ item.capability_id for item in final_context.capabilities
+ ],
+ expected_strategy_ids=[strategy_id],
+ source_episode_id=episode.episode_id,
+ )
+ proposed.anchors.append(anchor)
+ operations.append({"kind": "add_anchor", "anchor_id": anchor.anchor_id})
+
+ return CandidateUpdate(
+ candidate_id=f"candidate-v{proposed.version}-{uuid.uuid4().hex[:8]}",
+ base_version=stable.version,
+ proposed_state=proposed,
+ operations=operations,
+ rationale=[str(reflection.get("rationale", "LLM trajectory reflection."))],
+ )
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/policies.py b/experiments/HCL_ALFworld/src/hcl_alfworld/policies.py
new file mode 100644
index 0000000000000000000000000000000000000000..f7dbba4b5234b780af5dd4d33ea8f3277ebc1685
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/policies.py
@@ -0,0 +1,257 @@
+from __future__ import annotations
+
+from abc import ABC, abstractmethod
+from typing import Any, Dict, Optional
+
+from .llm import StructuredLLMProtocol, make_llm
+from .schemas import RoutingContext
+
+
+ACTION_DIALOGUE_HISTORY_MAX_STEPS = 15
+
+
+class Policy(ABC):
+ @abstractmethod
+ def select_action(self, context: RoutingContext, info: Dict[str, Any]) -> str:
+ raise NotImplementedError
+
+
+class OpenAICompatiblePolicy(Policy):
+ """LLM action executor grounded by the routed harness context."""
+
+ def __init__(self, llm: StructuredLLMProtocol, action_retries: int = 2):
+ self.llm = llm
+ self.action_retries = action_retries
+ self.last_trace: Dict[str, Any] = {}
+
+ @staticmethod
+ def _episode_dialogue(context: RoutingContext) -> list[Dict[str, str]]:
+ """Render the recent grounded transitions as a coherent ReAct conversation."""
+ task = context.task
+ opening = task.initial_observation.strip() or (
+ f"Task goal: {task.goal}\nInitial evidence: {task.evidence}"
+ )
+ history = task.episode_history[-ACTION_DIALOGUE_HISTORY_MAX_STEPS:]
+ omitted_steps = len(task.episode_history) - len(history)
+ if omitted_steps:
+ opening += (
+ f"\n\nContext note: {omitted_steps} earlier transitions are omitted from "
+ "this action prompt. current_step.progress preserves the accumulated task "
+ "state; the dialogue below contains the most recent transitions."
+ )
+ dialogue = [
+ {
+ "role": "user",
+ "content": f"Initial environment and task:\n{opening}",
+ }
+ ]
+ for item in history:
+ assessment = str(item.get("state_assessment", "")).strip()
+ plan = str(item.get("next_step_plan", "")).strip()
+ rationale = str(item.get("action_rationale", "")).strip()
+ assistant_parts = []
+ if assessment:
+ assistant_parts.append(f"State assessment: {assessment}")
+ if plan:
+ assistant_parts.append(f"Next-step plan: {plan}")
+ elif rationale:
+ assistant_parts.append(f"Decision rationale: {rationale}")
+ assistant_parts.append(f"Action: {item.get('action', '')}")
+ dialogue.append(
+ {"role": "assistant", "content": "\n".join(assistant_parts)}
+ )
+ feedback = f"Observation: {item.get('next_observation', '')}"
+ if item.get("reward") is not None:
+ feedback += f"\nEnvironment reward: {item.get('reward')}"
+ dialogue.append({"role": "user", "content": feedback})
+ return dialogue
+
+ def select_action(self, context: RoutingContext, info: Dict[str, Any]) -> str:
+ del info
+ payload: Dict[str, Any] = {
+ "task": {
+ "task_type": context.task.task_type,
+ "goal": context.task.goal,
+ "constraints": context.task.constraints,
+ },
+ "episode_dialogue": self._episode_dialogue(context),
+ "current_step": {
+ "step": context.task.step,
+ "observation": context.task.evidence,
+ "progress": context.task.progress,
+ },
+ "indexed_admissible_commands": [
+ {"index": index, "action": action}
+ for index, action in enumerate(context.task.admissible_commands)
+ ],
+ "strategies": [
+ {
+ "title": item.title,
+ "procedure": item.procedure,
+ "applicability": item.applicability,
+ "failure_boundaries": item.failure_boundaries,
+ "validation_count": item.validation_count,
+ }
+ for item in context.strategies
+ ],
+ "memories": [
+ {
+ "goal": item.get("goal"),
+ "success": item.get("success"),
+ "failure_reason": item.get("failure_reason"),
+ "steps": [
+ {
+ "observation": step.get("observation"),
+ "action": step.get("action"),
+ "next_observation": step.get("next_observation"),
+ }
+ for step in item.get("steps", [])
+ ],
+ **(
+ {
+ "memory_id": item.get("memory_id"),
+ "memory_type": item.get("memory_type"),
+ "similarity": item.get("similarity"),
+ }
+ if item.get("memory_type")
+ else {}
+ ),
+ **(
+ {"script": item.get("script")}
+ if item.get("script")
+ else {}
+ ),
+ **(
+ {"reflection": item.get("reflection")}
+ if item.get("reflection")
+ else {}
+ ),
+ **(
+ {"confidence": item.get("confidence")}
+ if "confidence" in item
+ else {}
+ ),
+ }
+ for item in context.memories
+ ],
+ "capabilities": [
+ {
+ "name": item.name,
+ "kind": item.kind,
+ "description": item.description,
+ "function": item.function,
+ "dependencies": item.dependencies,
+ "procedure": item.procedure,
+ "preconditions": item.preconditions,
+ "success_conditions": item.success_conditions,
+ "failure_modes": item.failure_modes,
+ "status": item.status,
+ "confidence": item.confidence,
+ "provider_component": item.provider_component,
+ "validation_evidence": item.validation_evidence[-5:],
+ }
+ for item in context.capabilities
+ ],
+ "router_rationale": context.trace.get("rationale", ""),
+ }
+ attempts = []
+ for attempt in range(self.action_retries + 1):
+ try:
+ result = self.llm.complete_json(
+ "action_policy",
+ (
+ "You control an ALFWorld text agent. Reason about task progress, current "
+ "evidence, the structured episode progress, the chronological "
+ "recent episode_dialogue, retrieved experience, failure "
+ "boundaries and capability preconditions. Use prior steps to avoid loops and "
+ "do not repeat a failed action unless the state has materially changed. "
+ "First give a concise grounded state_assessment, then a concise "
+ "next_step_plan, and finally choose the single action that executes that "
+ "plan. Do not invent hidden state or provide a long chain of thought. "
+ "Choose exactly one entry from indexed_admissible_commands. Return its "
+ "integer index, never generate or copy an action string. Memories are "
+ "advisory and may be stale. Return JSON with state_assessment, "
+ "next_step_plan, action_index, and rationale."
+ ),
+ payload,
+ )
+ except RuntimeError as exc:
+ message = str(exc)
+ if not message.startswith("action_policy did not return valid JSON:"):
+ raise
+ attempts.append(
+ {
+ "attempt": attempt + 1,
+ "proposed_action_index": None,
+ "valid": False,
+ "error": message,
+ }
+ )
+ # complete_json already exhausted its own structured retries. Do not
+ # abort a parallel evaluation batch over one malformed model response.
+ break
+ proposed_index = result.get("action_index")
+ valid_index = (
+ isinstance(proposed_index, int)
+ and not isinstance(proposed_index, bool)
+ and 0 <= proposed_index < len(context.task.admissible_commands)
+ )
+ attempt_trace = {
+ "attempt": attempt + 1,
+ "proposed_action_index": proposed_index,
+ "valid": valid_index,
+ "state_assessment": str(result.get("state_assessment", "")),
+ "next_step_plan": str(result.get("next_step_plan", "")),
+ "rationale": str(result.get("rationale", "")),
+ }
+ attempts.append(attempt_trace)
+ if valid_index:
+ action = context.task.admissible_commands[proposed_index]
+ self.last_trace = {
+ "component": "llm_action_policy",
+ "action": action,
+ "action_index": proposed_index,
+ "state_assessment": str(result.get("state_assessment", "")),
+ "next_step_plan": str(result.get("next_step_plan", "")),
+ "rationale": str(result.get("rationale", "")),
+ "attempts": attempts,
+ }
+ return action
+ payload["invalid_previous_action_index"] = proposed_index
+ payload["valid_action_index_range"] = [
+ 0,
+ max(0, len(context.task.admissible_commands) - 1),
+ ]
+ payload["correction"] = (
+ "Return one integer action_index from the supplied valid range."
+ )
+ if "look" in context.task.admissible_commands:
+ self.last_trace = {
+ "component": "hard_action_fallback",
+ "action": "look",
+ "attempts": attempts,
+ }
+ return "look"
+ fallback = context.task.admissible_commands[0] if context.task.admissible_commands else "look"
+ self.last_trace = {
+ "component": "hard_action_fallback",
+ "action": fallback,
+ "attempts": attempts,
+ }
+ return fallback
+
+
+def make_policy(
+ name: str,
+ config: Dict[str, Any],
+ seed: int,
+ llm: Optional[StructuredLLMProtocol] = None,
+) -> Policy:
+ del seed
+ if name.lower() != "openai":
+ raise ValueError(f"Unknown policy: {name}. This project supports only policy=openai.")
+ shared_llm = llm or make_llm(config)
+ return OpenAICompatiblePolicy(
+ shared_llm,
+ action_retries=int(config.get("action_retries", config.get("max_retries", 2))),
+ )
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/rag_baseline.py b/experiments/HCL_ALFworld/src/hcl_alfworld/rag_baseline.py
new file mode 100644
index 0000000000000000000000000000000000000000..a57d6947becbf79c5b0f39c93e7c36a503557148
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/rag_baseline.py
@@ -0,0 +1,277 @@
+from __future__ import annotations
+
+import copy
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+from .embedding import EmbedderProtocol, cosine_similarity
+from .llm import StructuredLLMProtocol
+from .memory import HarnessStore
+from .schemas import (
+ CandidateUpdate,
+ EpisodeRecord,
+ EvaluationResult,
+ HarnessState,
+ RoutingContext,
+ StepRecord,
+ TaskEvidence,
+)
+
+
+def _now_iso() -> str:
+ return datetime.now().astimezone().isoformat()
+
+
+class RAGRouter:
+ """Retrieve raw past episodes using cosine similarity only."""
+
+ def __init__(self, embedder: EmbedderProtocol, config: Dict[str, Any]):
+ self.embedder = embedder
+ self.top_k = int(config.get("top_k", 3))
+ configured_threshold = config.get("similarity_threshold")
+ self.similarity_threshold = (
+ float(configured_threshold) if configured_threshold is not None else None
+ )
+
+ @staticmethod
+ def reroute_reasons(
+ task: TaskEvidence,
+ previous_task: Optional[TaskEvidence],
+ steps: Sequence[StepRecord],
+ ) -> List[str]:
+ del task, steps
+ return ["episode_start"] if previous_task is None else []
+
+ @staticmethod
+ def should_refresh_semantic_context(
+ reasons: Sequence[str],
+ current_step: int,
+ last_semantic_route_step: int,
+ ) -> bool:
+ del current_step, last_semantic_route_step
+ return "episode_start" in reasons
+
+ @staticmethod
+ def _retrieval_query(task: TaskEvidence) -> str:
+ return task.goal.strip() or task.initial_observation.strip() or task.evidence.strip()
+
+ def _rank(
+ self,
+ query_vector: Sequence[float],
+ memories: Sequence[Dict[str, Any]],
+ ) -> List[Dict[str, Any]]:
+ ranked: List[Dict[str, Any]] = []
+ for memory in memories:
+ similarity = cosine_similarity(
+ query_vector,
+ memory.get("retrieval_embedding", []),
+ )
+ if (
+ self.similarity_threshold is not None
+ and similarity < self.similarity_threshold
+ ):
+ continue
+ item = copy.deepcopy(memory)
+ item["similarity"] = float(similarity)
+ item["retrieval_score"] = float(similarity)
+ ranked.append(item)
+ ranked.sort(
+ key=lambda item: (
+ float(item["similarity"]),
+ str(item.get("memory_id", "")),
+ ),
+ reverse=True,
+ )
+ return ranked[: self.top_k]
+
+ def route(
+ self,
+ task: TaskEvidence,
+ state: HarnessState,
+ trigger_reasons: Optional[Sequence[str]] = None,
+ ) -> RoutingContext:
+ query = self._retrieval_query(task)
+ query_vector = self.embedder.embed([query])[0] if state.raw_memory else []
+ selected = self._rank(query_vector, state.raw_memory) if query_vector else []
+ trace = {
+ "component": "rag_router",
+ "mode": "cosine_top_k",
+ "llm_called": False,
+ "trigger_reasons": list(trigger_reasons or ["episode_start"]),
+ "retrieval_query": query,
+ "similarity_threshold": self.similarity_threshold,
+ "selected_memory_ids": [
+ str(item.get("memory_id", "")) for item in selected
+ ],
+ "selected_similarities": [
+ float(item.get("similarity", 0.0)) for item in selected
+ ],
+ "selected_strategy_ids": [],
+ "selected_capability_ids": [],
+ "semantic_context_source_step": task.step,
+ "rationale": (
+ "RAG retrieved the top-k raw past episodes using embedding cosine "
+ "similarity only."
+ ),
+ }
+ return RoutingContext(task, selected, [], [], trace)
+
+ @staticmethod
+ def reuse(
+ task: TaskEvidence,
+ state: HarnessState,
+ previous_context: RoutingContext,
+ ) -> RoutingContext:
+ del state
+ trace = copy.deepcopy(previous_context.trace)
+ trace.update(
+ {
+ "mode": "episode_route_reuse",
+ "llm_called": False,
+ "trigger_reasons": [],
+ "reused_from_step": trace.get("semantic_context_source_step", 0),
+ }
+ )
+ return RoutingContext(
+ task=task,
+ memories=copy.deepcopy(previous_context.memories),
+ strategies=[],
+ capabilities=[],
+ trace=trace,
+ )
+
+
+class RAGHarness:
+ """Standard RAG over raw episode experiences behind the shared runner API."""
+
+ def __init__(
+ self,
+ store_dir: str | Path,
+ config: Dict[str, Any],
+ llm: StructuredLLMProtocol,
+ embedder: EmbedderProtocol,
+ ):
+ del llm
+ self.config = config
+ self.embedder = embedder
+ self.store = HarnessStore(store_dir)
+ self.router = RAGRouter(embedder, config)
+ self.capacity = int(config.get("memory_capacity", 500))
+ initial = HarnessState(
+ routing_policy={
+ "method": "rag",
+ "retrieval": "cosine_top_k",
+ "top_k": self.router.top_k,
+ "similarity_threshold": self.router.similarity_threshold,
+ },
+ evaluator_rules={"method": "rag", "updates": "append_only"},
+ )
+ self.state = self.store.load(initial)
+
+ def route(
+ self,
+ task: TaskEvidence,
+ trigger_reasons: Optional[Sequence[str]] = None,
+ ) -> RoutingContext:
+ return self.router.route(task, self.state, trigger_reasons)
+
+ def reuse_route(
+ self,
+ task: TaskEvidence,
+ previous_context: RoutingContext,
+ ) -> RoutingContext:
+ return self.router.reuse(task, self.state, previous_context)
+
+ @staticmethod
+ def _trajectory(episode: EpisodeRecord) -> List[Dict[str, Any]]:
+ return [
+ {
+ "step": index,
+ "observation": step.observation,
+ "action": step.action,
+ "next_observation": step.next_observation,
+ "reward": step.reward,
+ }
+ for index, step in enumerate(episode.steps)
+ ]
+
+ def learn(
+ self,
+ episode: EpisodeRecord,
+ final_context: RoutingContext,
+ ) -> Tuple[CandidateUpdate, EvaluationResult]:
+ proposed = copy.deepcopy(self.state)
+ goal = episode.goal.strip()
+ embedding = self.embedder.embed([goal])[0]
+ memory_id = f"rag-{uuid.uuid4().hex[:12]}"
+ memory = {
+ "memory_id": memory_id,
+ "episode_id": episode.episode_id,
+ "task_type": episode.task_type,
+ "goal": goal,
+ "success": episode.success,
+ "failure_reason": episode.failure_reason,
+ "memory_type": "rag_episode",
+ "steps": self._trajectory(episode),
+ "retrieval_key": goal,
+ "retrieval_embedding": list(embedding),
+ "created_at": _now_iso(),
+ }
+ proposed.raw_memory.append(memory)
+ evicted_memory_ids: List[str] = []
+ if self.capacity > 0 and len(proposed.raw_memory) > self.capacity:
+ overflow = len(proposed.raw_memory) - self.capacity
+ evicted_memory_ids = [
+ str(item.get("memory_id", ""))
+ for item in proposed.raw_memory[:overflow]
+ ]
+ proposed.raw_memory = proposed.raw_memory[overflow:]
+ proposed.version = self.state.version + 1
+ operation = {
+ "kind": "add_rag_experience",
+ "memory_id": memory_id,
+ "success": episode.success,
+ "evicted_memory_ids": evicted_memory_ids,
+ }
+ candidate = CandidateUpdate(
+ candidate_id=f"rag-candidate-v{proposed.version}-{episode.episode_id}",
+ base_version=self.state.version,
+ proposed_state=proposed,
+ operations=[operation],
+ rationale=[
+ "Stored the complete episode as an append-only RAG experience.",
+ "No reflection, proceduralization, Q update, or semantic merge was applied.",
+ ],
+ )
+ evaluation = EvaluationResult(
+ candidate_id=candidate.candidate_id,
+ decision="commit",
+ current_score=float(episode.score),
+ anchor_retention=1.0,
+ regressions=[],
+ diagnostics=[
+ "RAG appends training experiences directly without HCL gating or RL."
+ ],
+ accepted_operation_indices=[0],
+ rejected_operation_indices=[],
+ )
+ self.state = proposed
+ self.store.save_candidate(candidate.to_dict())
+ self.store.save_stable(self.state)
+ self.store.append_interaction(episode.to_dict())
+ self.store.append_decision(evaluation.to_dict())
+ episode.harness_traces.append(
+ {
+ "rag_learning": {
+ "new_memory_id": memory_id,
+ "selected_memory_ids": [
+ str(item.get("memory_id", ""))
+ for item in final_context.memories
+ ],
+ "evicted_memory_ids": evicted_memory_ids,
+ }
+ }
+ )
+ return candidate, evaluation
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/router.py b/experiments/HCL_ALFworld/src/hcl_alfworld/router.py
new file mode 100644
index 0000000000000000000000000000000000000000..575810de2ce3291d62d005fee7a35188d41fa433
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/router.py
@@ -0,0 +1,420 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional, Sequence
+
+from .llm import StructuredLLMProtocol
+from .memory import ExperienceMemory
+from .schemas import (
+ CapabilityUnit,
+ HarnessState,
+ RoutingContext,
+ StepRecord,
+ StrategyArtifact,
+ TaskEvidence,
+)
+
+
+class AdaptiveRouter:
+ """LLM semantic routing over bounded, auditable candidate pools."""
+
+ def __init__(
+ self,
+ llm: StructuredLLMProtocol,
+ memory: ExperienceMemory,
+ memory_enabled: bool = True,
+ raw_memory_retrieval_enabled: bool = True,
+ strategy_retrieval_enabled: bool = True,
+ capabilities_enabled: bool = True,
+ candidate_pool_size: int = 20,
+ gate_mode: str = "stability",
+ ):
+ self.llm = llm
+ self.memory = memory
+ self.memory_enabled = memory_enabled
+ self.raw_memory_retrieval_enabled = raw_memory_retrieval_enabled
+ self.strategy_retrieval_enabled = strategy_retrieval_enabled
+ self.capabilities_enabled = capabilities_enabled
+ self.candidate_pool_size = candidate_pool_size
+ self.gate_mode = gate_mode
+
+ @staticmethod
+ def _raw_summary(item: Dict[str, Any]) -> Dict[str, Any]:
+ steps = item.get("steps", [])
+ return {
+ "episode_id": item.get("episode_id"),
+ "task_type": item.get("task_type"),
+ "goal": item.get("goal"),
+ "success": item.get("success"),
+ "failure_reason": item.get("failure_reason"),
+ "actions": [step.get("action") for step in steps],
+ }
+
+ @staticmethod
+ def _strategy_summary(item: StrategyArtifact) -> Dict[str, Any]:
+ return {
+ "artifact_id": item.artifact_id,
+ "task_type": item.task_type,
+ "title": item.title,
+ "procedure": item.procedure,
+ "applicability": item.applicability,
+ "failure_boundaries": item.failure_boundaries,
+ "validation_count": item.validation_count,
+ "success_count": item.success_count,
+ }
+
+ @staticmethod
+ def _capability_summary(item: CapabilityUnit) -> Dict[str, Any]:
+ return {
+ "capability_id": item.capability_id,
+ "name": item.name,
+ "kind": item.kind,
+ "description": item.description,
+ "function": item.function,
+ "action_prefixes": item.action_prefixes,
+ "dependencies": item.dependencies,
+ "procedure": item.procedure,
+ "preconditions": item.preconditions,
+ "success_conditions": item.success_conditions,
+ "failure_modes": item.failure_modes,
+ "positive_evidence_episode_ids": item.positive_evidence_episode_ids,
+ "negative_evidence_episode_ids": item.negative_evidence_episode_ids,
+ "status": item.status,
+ "confidence": item.confidence,
+ "provider_component": item.provider_component,
+ "validation_evidence": item.validation_evidence[-5:],
+ }
+
+ @staticmethod
+ def _select_by_id(items: List[Any], selected: Any, attribute: str, limit: int) -> List[Any]:
+ requested = selected if isinstance(selected, list) else []
+ by_id = {str(getattr(item, attribute)): item for item in items}
+ return [by_id[item_id] for item_id in requested if item_id in by_id][:limit]
+
+ @staticmethod
+ def _command_matches_prefix(command: str, prefix: str) -> bool:
+ normalized_command = " ".join(command.lower().split())
+ normalized_prefix = " ".join(prefix.lower().split())
+ return bool(normalized_prefix) and (
+ normalized_command == normalized_prefix
+ or normalized_command.startswith(f"{normalized_prefix} ")
+ )
+
+ def _grounded_atomic_capabilities(
+ self, task: TaskEvidence, state: HarnessState
+ ) -> List[CapabilityUnit]:
+ if not self.capabilities_enabled:
+ return []
+ return [
+ item
+ for item in state.capabilities
+ if item.kind == "atomic_external"
+ and item.availability == "available"
+ and item.status != "deprecated"
+ and any(
+ self._command_matches_prefix(command, prefix)
+ for command in task.admissible_commands
+ for prefix in item.action_prefixes
+ )
+ ]
+
+ def _semantic_capability_pool(self, state: HarnessState) -> List[CapabilityUnit]:
+ if not self.capabilities_enabled:
+ return []
+ return [
+ item
+ for item in state.capabilities
+ if item.kind != "atomic_external"
+ and item.availability == "available"
+ and item.status != "deprecated"
+ and (
+ self.gate_mode == "stability"
+ or item.status in {"provisional", "validated"}
+ )
+ ]
+
+ @staticmethod
+ def _unique_capabilities(items: Sequence[CapabilityUnit]) -> List[CapabilityUnit]:
+ unique: List[CapabilityUnit] = []
+ seen = set()
+ for item in items:
+ if item.capability_id in seen:
+ continue
+ seen.add(item.capability_id)
+ unique.append(item)
+ return unique
+
+ @staticmethod
+ def reroute_reasons(
+ task: TaskEvidence,
+ previous_task: Optional[TaskEvidence],
+ steps: Sequence[StepRecord],
+ ) -> List[str]:
+ """Return grounded events that justify refreshing semantic episode context."""
+ if previous_task is None:
+ return ["episode_start"]
+
+ reasons: List[str] = []
+ current = task.progress if isinstance(task.progress, dict) else {}
+ previous = (
+ previous_task.progress if isinstance(previous_task.progress, dict) else {}
+ )
+
+ def normalized_items(progress: Dict[str, Any], name: str) -> set[str]:
+ value = progress.get(name, [])
+ if not isinstance(value, list):
+ return set()
+ return {
+ str(item).strip() for item in value if isinstance(item, str) and item.strip()
+ }
+
+ if bool(current.get("loop_risk")) and not bool(previous.get("loop_risk")):
+ reasons.append("loop_risk_detected")
+
+ current_failures = normalized_items(current, "failed_or_unproductive_actions")
+ previous_failures = normalized_items(previous, "failed_or_unproductive_actions")
+ if current_failures - previous_failures:
+ reasons.append("new_failure_evidence")
+
+ current_completed = normalized_items(current, "completed_subgoals")
+ previous_completed = normalized_items(previous, "completed_subgoals")
+ if current_completed - previous_completed:
+ reasons.append("subgoal_completed")
+
+ if normalized_items(current, "inventory") != normalized_items(
+ previous, "inventory"
+ ):
+ reasons.append("inventory_changed")
+
+ current_count = current.get("completed_count")
+ previous_count = previous.get("completed_count")
+ if (
+ isinstance(current_count, int)
+ and not isinstance(current_count, bool)
+ and isinstance(previous_count, int)
+ and not isinstance(previous_count, bool)
+ and current_count != previous_count
+ ):
+ reasons.append("completion_count_changed")
+
+ if steps:
+ latest = steps[-1]
+ latest_signature = (
+ " ".join(latest.action.lower().split()),
+ " ".join(latest.next_observation.lower().split()),
+ )
+ prior_signatures = {
+ (
+ " ".join(item.action.lower().split()),
+ " ".join(item.next_observation.lower().split()),
+ )
+ for item in steps[:-1]
+ }
+ if latest_signature in prior_signatures:
+ reasons.append("repeated_transition")
+
+ if len(steps) >= 2:
+ recent = steps[-2:]
+ no_progress = all(
+ item.reward <= 0.0
+ and " ".join(item.observation.lower().split())
+ == " ".join(item.next_observation.lower().split())
+ for item in recent
+ )
+ if no_progress:
+ reasons.append("consecutive_no_progress")
+
+ return list(dict.fromkeys(reasons))
+
+ @staticmethod
+ def should_refresh_semantic_context(
+ reasons: Sequence[str],
+ current_step: int,
+ last_semantic_route_step: int,
+ ) -> bool:
+ """Immediately honor new evidence; lightly debounce repeated-stall recovery."""
+ if not reasons:
+ return False
+ recovery_reasons = {"repeated_transition", "consecutive_no_progress"}
+ if not set(reasons).issubset(recovery_reasons):
+ return True
+ return current_step - last_semantic_route_step >= 2
+
+ def route(
+ self,
+ task: TaskEvidence,
+ state: HarnessState,
+ trigger_reasons: Optional[Sequence[str]] = None,
+ ) -> RoutingContext:
+ raw_pool = (
+ self.memory.raw_candidates(task, state, self.candidate_pool_size)
+ if self.memory_enabled and self.raw_memory_retrieval_enabled
+ else []
+ )
+ strategy_pool = (
+ self.memory.strategy_candidates(task, state, self.candidate_pool_size)
+ if self.memory_enabled and self.strategy_retrieval_enabled
+ else []
+ )
+ capability_pool = self._semantic_capability_pool(state)
+ atomic_capabilities = self._grounded_atomic_capabilities(task, state)
+ reasons = list(trigger_reasons or ["explicit_route"])
+ if not raw_pool and not strategy_pool and not capability_pool:
+ trace = {
+ "component": "adaptive_router",
+ "mode": "deterministic_only",
+ "llm_called": False,
+ "trigger_reasons": reasons,
+ "candidate_memory_ids": [],
+ "candidate_strategy_ids": [],
+ "candidate_capability_ids": [],
+ "selected_memory_ids": [],
+ "selected_strategy_ids": [],
+ "selected_capability_ids": [
+ item.capability_id for item in atomic_capabilities
+ ],
+ "grounded_atomic_capability_ids": [
+ item.capability_id for item in atomic_capabilities
+ ],
+ "semantic_context_source_step": task.step,
+ "rationale": (
+ "No semantic memory, strategy, or learned capability candidates were "
+ "available; atomic capabilities were grounded from admissible commands."
+ ),
+ }
+ return RoutingContext(task, [], [], atomic_capabilities, trace)
+
+ result = self.llm.complete_json(
+ "router",
+ (
+ "You are the Adaptive Router of an ALFWorld continual-learning harness. "
+ "Semantically select only context that can help the agent's next decision. "
+ "Prefer evidence matching the current goal, progress state, objects and required "
+ "operations; use the current episode_history to avoid loops and reject "
+ "superficially similar but stale or contradictory memories. "
+ "Select learned capabilities that are relevant to the episode or immediately "
+ "needed for planning. Atomic environment capabilities have already been grounded "
+ "deterministically from admissible commands and must not be selected here. Treat "
+ "hypothesized and provisional model-generated capabilities as uncertain advice; "
+ "prefer validated capabilities when equally applicable, and respect dependencies "
+ "and failure boundaries. Internal capabilities describe reasoning support, while "
+ "composite external capabilities describe multi-step skills. Return JSON with "
+ "memory_ids, strategy_ids, capability_ids, rationale. Use only supplied IDs."
+ ),
+ {
+ "task": task.to_dict(),
+ "raw_memory_candidates": [self._raw_summary(item) for item in raw_pool],
+ "strategy_candidates": [self._strategy_summary(item) for item in strategy_pool],
+ "capability_candidates": [
+ self._capability_summary(item) for item in capability_pool
+ ],
+ "grounded_atomic_capabilities": [
+ self._capability_summary(item) for item in atomic_capabilities
+ ],
+ "selection_limits": {
+ "raw_memory": (
+ self.memory.top_k_memory if self.raw_memory_retrieval_enabled else 0
+ ),
+ "strategies": (
+ self.memory.top_k_strategies if self.strategy_retrieval_enabled else 0
+ ),
+ },
+ },
+ )
+ raw_by_id = {str(item.get("episode_id")): item for item in raw_pool}
+ memory_ids = result.get("memory_ids", [])
+ memories = [
+ raw_by_id[item_id]
+ for item_id in memory_ids if isinstance(item_id, str) and item_id in raw_by_id
+ ][: self.memory.top_k_memory]
+ strategies = self._select_by_id(
+ strategy_pool,
+ result.get("strategy_ids", []),
+ "artifact_id",
+ self.memory.top_k_strategies,
+ )
+ capabilities = self._select_by_id(
+ capability_pool,
+ result.get("capability_ids", []),
+ "capability_id",
+ len(capability_pool),
+ )
+ capabilities = self._unique_capabilities(capabilities + atomic_capabilities)
+ trace = {
+ "component": "adaptive_router",
+ "mode": "semantic_route",
+ "llm_called": True,
+ "trigger_reasons": reasons,
+ "candidate_memory_ids": [item.get("episode_id") for item in raw_pool],
+ "candidate_strategy_ids": [item.artifact_id for item in strategy_pool],
+ "candidate_capability_ids": [
+ item.capability_id for item in capability_pool
+ ],
+ "selected_memory_ids": [item.get("episode_id") for item in memories],
+ "selected_strategy_ids": [item.artifact_id for item in strategies],
+ "selected_capability_ids": [item.capability_id for item in capabilities],
+ "grounded_atomic_capability_ids": [
+ item.capability_id for item in atomic_capabilities
+ ],
+ "semantic_context_source_step": task.step,
+ "rationale": str(result.get("rationale", "")),
+ }
+ return RoutingContext(task, memories, strategies, capabilities, trace)
+
+ def reuse(
+ self,
+ task: TaskEvidence,
+ state: HarnessState,
+ previous: RoutingContext,
+ ) -> RoutingContext:
+ """Reuse episode-level semantic context while refreshing grounded atomics."""
+ available = {
+ item.capability_id: item
+ for item in state.capabilities
+ if item.availability == "available" and item.status != "deprecated"
+ and (
+ item.kind == "atomic_external"
+ or self.gate_mode == "stability"
+ or item.status in {"provisional", "validated"}
+ )
+ }
+ semantic_capabilities = [
+ available[item.capability_id]
+ for item in previous.capabilities
+ if item.kind != "atomic_external" and item.capability_id in available
+ ]
+ atomic_capabilities = self._grounded_atomic_capabilities(task, state)
+ capabilities = self._unique_capabilities(
+ semantic_capabilities + atomic_capabilities
+ )
+ trace = {
+ "component": "adaptive_router",
+ "mode": "cached_context",
+ "llm_called": False,
+ "trigger_reasons": [],
+ "candidate_memory_ids": [],
+ "candidate_strategy_ids": [],
+ "candidate_capability_ids": [],
+ "selected_memory_ids": [
+ item.get("episode_id") for item in previous.memories
+ ],
+ "selected_strategy_ids": [
+ item.artifact_id for item in previous.strategies
+ ],
+ "selected_capability_ids": [
+ item.capability_id for item in capabilities
+ ],
+ "grounded_atomic_capability_ids": [
+ item.capability_id for item in atomic_capabilities
+ ],
+ "semantic_context_source_step": previous.trace.get(
+ "semantic_context_source_step", previous.task.step
+ ),
+ "rationale": "Reused episode-level semantic context and refreshed atomic capabilities.",
+ }
+ return RoutingContext(
+ task,
+ list(previous.memories),
+ list(previous.strategies),
+ capabilities,
+ trace,
+ )
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/run_artifacts.py b/experiments/HCL_ALFworld/src/hcl_alfworld/run_artifacts.py
new file mode 100644
index 0000000000000000000000000000000000000000..a938c75a2ccc459382d62370f19f33e896b796d8
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/run_artifacts.py
@@ -0,0 +1,226 @@
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import threading
+from datetime import datetime
+from pathlib import Path
+from typing import Any, Dict, Optional
+
+import yaml
+
+
+def _now() -> datetime:
+ return datetime.now().astimezone()
+
+
+def _json_fingerprint(config: Dict[str, Any]) -> str:
+ ignored = {"_config_path", "_project_root", "output_dir"}
+ comparable = copy.deepcopy(
+ {key: value for key, value in config.items() if key not in ignored}
+ )
+ # Transport controls may be safely tuned when resuming; they do not change
+ # the model, prompts, games, Harness state, or evaluation semantics.
+ agent = comparable.get("agent", {})
+ agent.pop("request_timeout_seconds", None)
+ agent.pop("transport_max_retries", None)
+ agent.pop("transport_retry_base_seconds", None)
+ agent.pop("transport_retry_max_seconds", None)
+ agent.pop("transport_retry_jitter", None)
+ # Progress was enabled by default before the explicit switch was added.
+ # Treat an omitted switch and explicit true as the same resume protocol;
+ # explicit false remains fingerprint-significant.
+ if agent.get("episode_progress_enabled") is True:
+ agent.pop("episode_progress_enabled", None)
+ # Evaluation concurrency changes throughput only. It is safe to tune between
+ # resume attempts because committed results retain their original order and
+ # every worker reads the same frozen Harness snapshot.
+ execution = comparable.get("execution", {})
+ execution.pop("evaluation_workers", None)
+ if not execution:
+ comparable.pop("execution", None)
+ encoded = json.dumps(
+ comparable, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")
+ return hashlib.sha256(encoded).hexdigest()
+
+
+class RunArtifacts:
+ """Own one timestamped run directory and its resumable audit artifacts."""
+
+ def __init__(
+ self,
+ base_dir: str | Path,
+ config: Dict[str, Any],
+ resume_dir: Optional[str | Path] = None,
+ ):
+ self.config = config
+ self._write_lock = threading.RLock()
+ self.base_dir = Path(base_dir).resolve()
+ self.fingerprint = _json_fingerprint(config)
+ if resume_dir is None:
+ self.run_dir = self._new_run_dir()
+ self.attempt = 1
+ self.metadata: Dict[str, Any] = {
+ "schema_version": 1,
+ "run_id": self.run_dir.name,
+ "status": "created",
+ "created_at": _now().isoformat(),
+ "updated_at": _now().isoformat(),
+ "completed_at": None,
+ "attempts": self.attempt,
+ "config_path": config.get("_config_path"),
+ "config_fingerprint": self.fingerprint,
+ "checkpoint_granularity": "episode",
+ }
+ self._prepare_directories()
+ self._write_config_snapshot()
+ self._write_json_atomic(self.run_dir / "run.json", self.metadata)
+ else:
+ candidate = Path(resume_dir)
+ if not candidate.is_absolute():
+ candidate = Path(config["_project_root"]) / candidate
+ self.run_dir = candidate.resolve()
+ metadata_path = self.run_dir / "run.json"
+ checkpoint_path = self.run_dir / "checkpoint.json"
+ if not metadata_path.exists() or not checkpoint_path.exists():
+ raise FileNotFoundError(
+ f"Resume directory lacks run.json or checkpoint.json: {self.run_dir}"
+ )
+ self.metadata = self._read_json(metadata_path)
+ if self.metadata.get("config_fingerprint") != self.fingerprint:
+ raise ValueError(
+ "Resume config does not match the config that created this run."
+ )
+ self.attempt = int(self.metadata.get("attempts", 1)) + 1
+ self.metadata["attempts"] = self.attempt
+ self.metadata["status"] = "resuming"
+ self.metadata["checkpoint_granularity"] = "episode"
+ self.metadata["updated_at"] = _now().isoformat()
+ self._prepare_directories()
+ self._write_json_atomic(metadata_path, self.metadata)
+
+ self.episode_sequence = self._next_sequence(self.run_dir / "episodes", "*.json")
+ self.update_sequence = self._next_sequence(self.run_dir / "updates", "*.json")
+
+ def _new_run_dir(self) -> Path:
+ self.base_dir.mkdir(parents=True, exist_ok=True)
+ timestamp = _now().strftime("%Y%m%d_%H%M%S_%f")
+ candidate = self.base_dir / timestamp
+ suffix = 1
+ while candidate.exists():
+ candidate = self.base_dir / f"{timestamp}_{suffix}"
+ suffix += 1
+ return candidate
+
+ def _prepare_directories(self) -> None:
+ self.run_dir.mkdir(parents=True, exist_ok=True)
+ (self.run_dir / "episodes").mkdir(exist_ok=True)
+ (self.run_dir / "updates").mkdir(exist_ok=True)
+
+ def _write_config_snapshot(self) -> None:
+ snapshot = {
+ key: value
+ for key, value in self.config.items()
+ if key not in {"_config_path", "_project_root"}
+ }
+ path = self.run_dir / "config_snapshot.yaml"
+ temporary = path.with_suffix(".yaml.tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ yaml.safe_dump(snapshot, handle, allow_unicode=True, sort_keys=False)
+ temporary.replace(path)
+
+ @staticmethod
+ def _next_sequence(directory: Path, pattern: str) -> int:
+ return len(list(directory.glob(pattern))) + 1
+
+ @staticmethod
+ def _read_json(path: Path) -> Dict[str, Any]:
+ with path.open("r", encoding="utf-8") as handle:
+ value = json.load(handle)
+ if not isinstance(value, dict):
+ raise TypeError(f"Expected a JSON object in {path}")
+ return value
+
+ @staticmethod
+ def _write_json_atomic(path: Path, data: Dict[str, Any]) -> None:
+ temporary = path.with_suffix(path.suffix + ".tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ json.dump(data, handle, indent=2, ensure_ascii=False, sort_keys=True)
+ handle.write("\n")
+ temporary.replace(path)
+
+ @staticmethod
+ def _append_jsonl(path: Path, data: Dict[str, Any]) -> None:
+ with path.open("a", encoding="utf-8") as handle:
+ handle.write(json.dumps(data, ensure_ascii=False, sort_keys=True) + "\n")
+ handle.flush()
+
+ def event(self, kind: str, message: str, **details: Any) -> Dict[str, Any]:
+ payload = {
+ "timestamp": _now().isoformat(),
+ "attempt": self.attempt,
+ "kind": kind,
+ "message": message,
+ "details": details,
+ }
+ with self._write_lock:
+ self._append_jsonl(self.run_dir / "events.jsonl", payload)
+ detail_text = json.dumps(details, ensure_ascii=False, sort_keys=True)
+ with (self.run_dir / "run.log").open("a", encoding="utf-8") as handle:
+ handle.write(
+ f"[{payload['timestamp']}] attempt={self.attempt} {kind}: "
+ f"{message} {detail_text}\n"
+ )
+ handle.flush()
+ return payload
+
+ def write_episode(self, payload: Dict[str, Any]) -> Path:
+ enriched = {
+ "logged_at": _now().isoformat(),
+ "attempt": self.attempt,
+ **payload,
+ }
+ with self._write_lock:
+ self._append_jsonl(self.run_dir / "episodes.jsonl", enriched)
+ episode_id = str(payload["record"]["episode_id"])
+ path = self.run_dir / "episodes" / f"{self.episode_sequence:06d}_{episode_id}.json"
+ self.episode_sequence += 1
+ self._write_json_atomic(path, enriched)
+ return path
+
+ def write_update(self, payload: Dict[str, Any]) -> Path:
+ enriched = {
+ "logged_at": _now().isoformat(),
+ "attempt": self.attempt,
+ **payload,
+ }
+ with self._write_lock:
+ self._append_jsonl(self.run_dir / "updates.jsonl", enriched)
+ episode_id = str(payload["episode_id"])
+ path = self.run_dir / "updates" / f"{self.update_sequence:06d}_{episode_id}.json"
+ self.update_sequence += 1
+ self._write_json_atomic(path, enriched)
+ return path
+
+ def save_checkpoint(self, checkpoint: Dict[str, Any]) -> None:
+ payload = {
+ "schema_version": 1,
+ "saved_at": _now().isoformat(),
+ "attempt": self.attempt,
+ **checkpoint,
+ }
+ self._write_json_atomic(self.run_dir / "checkpoint.json", payload)
+
+ def load_checkpoint(self) -> Dict[str, Any]:
+ return self._read_json(self.run_dir / "checkpoint.json")
+
+ def set_status(self, status: str, **details: Any) -> None:
+ self.metadata["status"] = status
+ self.metadata["updated_at"] = _now().isoformat()
+ if status == "completed":
+ self.metadata["completed_at"] = _now().isoformat()
+ if details:
+ self.metadata.setdefault("status_details", {}).update(details)
+ self._write_json_atomic(self.run_dir / "run.json", self.metadata)
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/runner.py b/experiments/HCL_ALFworld/src/hcl_alfworld/runner.py
new file mode 100644
index 0000000000000000000000000000000000000000..85293fabf471cf9740594fd945adc271371c8719
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/runner.py
@@ -0,0 +1,1292 @@
+from __future__ import annotations
+
+import csv
+import copy
+import threading
+import time
+import uuid
+from concurrent.futures import ThreadPoolExecutor
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, Iterator, List, Optional
+
+from .config import resolve_project_path
+from .embedding import make_embedder
+from .environment import ALFWorldEpisodeEnv, list_split_games, read_task_type
+from .harness import make_continual_harness
+from .llm import make_llm
+from .metrics import continual_metrics
+from .policies import Policy, make_policy
+from .run_artifacts import RunArtifacts
+from .schemas import EpisodeRecord, EpisodeResult, HarnessState, StepRecord
+from .sequence import load_sequence
+from .task_interface import TaskInterface
+
+
+@dataclass
+class _ReadOnlyRuntime:
+ llm: Any
+ interface: TaskInterface
+ policy: Policy
+ harness: Any
+ harness_version: int
+
+
+@dataclass
+class _ReadOnlyOutcome:
+ result: EpisodeResult
+ state_before: Dict[str, Any]
+ llm_usage: Dict[str, Any]
+ requires_runtime_record: bool = True
+ requires_usage_merge: bool = True
+
+
+class ExperimentRunner:
+ def __init__(
+ self,
+ config: Dict[str, Any],
+ resume_dir: Optional[str | Path] = None,
+ raw_memory_retrieval_override: Optional[bool] = None,
+ ):
+ if raw_memory_retrieval_override is not None and resume_dir is None:
+ raise ValueError("Raw-memory retrieval overrides are only supported when resuming.")
+ configured_method = str(config.get("continual_method", "hcl")).lower()
+ if raw_memory_retrieval_override is not None and configured_method != "hcl":
+ raise ValueError("Raw-memory retrieval overrides apply only to the HCL method.")
+ self.config = copy.deepcopy(config)
+ if raw_memory_retrieval_override is not None:
+ self.config["harness"]["raw_memory_retrieval_enabled"] = bool(
+ raw_memory_retrieval_override
+ )
+ self.raw_memory_retrieval_override = raw_memory_retrieval_override
+ self.seed = int(config.get("seed", 42))
+ self.data_dir = resolve_project_path(config, "data_dir")
+ self.base_output_dir = resolve_project_path(config, "output_dir")
+ self.artifacts = RunArtifacts(self.base_output_dir, config, resume_dir=resume_dir)
+ self.output_dir = self.artifacts.run_dir
+ self.agent_config = config["agent"]
+ self.continual_method = configured_method
+ self.llm = make_llm(self.agent_config)
+ self.interface = TaskInterface(
+ self.llm,
+ episode_progress_enabled=bool(
+ self.agent_config.get("episode_progress_enabled", True)
+ ),
+ )
+ self.eval_policy = make_policy(
+ self.agent_config["policy"], self.agent_config, self.seed, self.llm
+ )
+ learning_name = self.agent_config.get("learning_policy", self.agent_config["policy"])
+ self.learning_policy = make_policy(learning_name, self.agent_config, self.seed, self.llm)
+ self.embedding_config = dict(self.config.get("embedding", {}))
+ self.embedder = (
+ make_embedder(self.embedding_config)
+ if self.continual_method in {"rag"}
+ else None
+ )
+ method_config = (
+ self.config[self.continual_method]
+ if self.continual_method in {"rag"}
+ else self.config["harness"]
+ )
+ self.harness = make_continual_harness(
+ self.continual_method,
+ self.output_dir / "harness",
+ method_config,
+ self.llm,
+ embedder=self.embedder,
+ seed=self.seed,
+ )
+ self.runtime_stats: Dict[str, Any] = {
+ "episodes": 0,
+ "steps": 0,
+ "learning_episodes": 0,
+ "evaluation_episodes": 0,
+ "decisions": {"commit": 0, "partial_merge": 0, "reject": 0, "rollback": 0},
+ "anchor_retention_sum": 0.0,
+ "anchor_evaluations": 0,
+ }
+ self.prior_llm_usage: Dict[str, Any] = {}
+ self.parallel_llm_usage: Dict[str, Any] = {}
+ self._worker_local = threading.local()
+ self.elapsed_before = 0.0
+ self.resume_requested = resume_dir is not None
+ self.artifacts.event(
+ "run_initialized",
+ "Run directory initialized.",
+ run_dir=str(self.output_dir),
+ resume=self.resume_requested,
+ )
+ if raw_memory_retrieval_override is not None:
+ self.artifacts.event(
+ "resume_protocol_override",
+ "Resume intentionally changed online raw-memory retrieval.",
+ raw_memory_retrieval_enabled=bool(raw_memory_retrieval_override),
+ )
+
+ def run_episode(
+ self,
+ game_file: str,
+ split: str,
+ policy: Policy,
+ learn: bool,
+ training_episode: bool = False,
+ runtime: Optional[_ReadOnlyRuntime] = None,
+ record_runtime: bool = True,
+ ) -> EpisodeResult:
+ active_interface = runtime.interface if runtime is not None else self.interface
+ active_harness = runtime.harness if runtime is not None else self.harness
+ game_path = Path(game_file)
+ if not game_path.is_absolute():
+ game_path = self.data_dir / game_path
+ game_file = str(game_path.resolve())
+ episode_id = f"episode-{uuid.uuid4().hex[:12]}"
+ env_config = self.config["environment"]
+ max_steps = int(env_config.get("max_steps", 50))
+ task_type_hint = read_task_type(game_file)
+ self.artifacts.event(
+ "episode_started",
+ "Episode environment initialization started.",
+ episode_id=episode_id,
+ split=split,
+ game_file=game_file,
+ training_episode=training_episode,
+ learn=learn,
+ harness_version=active_harness.state.version,
+ )
+ env = ALFWorldEpisodeEnv(
+ game_file,
+ self.data_dir,
+ max_steps=max_steps,
+ domain_randomization=bool(env_config.get("domain_randomization", False)),
+ )
+ steps: List[StepRecord] = []
+ routing_traces: List[Dict[str, Any]] = []
+ success = False
+ score = 0.0
+ final_context = None
+ episode_context = None
+ last_semantic_route_step = -2
+ goal = ""
+ task_type = ""
+ try:
+ observations, info = env.reset()
+ initial_observation = observations[0]
+ self.artifacts.event(
+ "environment_ready",
+ "Episode environment reset completed.",
+ episode_id=episode_id,
+ task_type_hint=task_type_hint,
+ max_steps=max_steps,
+ )
+ for step_index in range(max_steps):
+ admissible = list(info.get("admissible_commands", [[]])[0])
+ history_limit = int(
+ self.agent_config.get("episode_history_max_steps", max_steps)
+ )
+ visible_steps = steps[-history_limit:] if history_limit > 0 else []
+ visible_start = len(steps) - len(visible_steps)
+ episode_history = [
+ {
+ "step": visible_start + history_index,
+ "observation": history_step.observation,
+ "action": history_step.action,
+ "next_observation": history_step.next_observation,
+ "reward": history_step.reward,
+ "state_assessment": str(
+ routing_traces[visible_start + history_index]
+ .get("action_policy", {})
+ .get("state_assessment", "")
+ ),
+ "next_step_plan": str(
+ routing_traces[visible_start + history_index]
+ .get("action_policy", {})
+ .get("next_step_plan", "")
+ ),
+ "action_rationale": str(
+ routing_traces[visible_start + history_index]
+ .get("action_policy", {})
+ .get("rationale", "")
+ ),
+ }
+ for history_index, history_step in enumerate(visible_steps)
+ ]
+ self.artifacts.event(
+ "step_started",
+ "LLM step processing started.",
+ episode_id=episode_id,
+ step=step_index,
+ admissible_command_count=len(admissible),
+ )
+ task = active_interface.structure(
+ interaction_id=episode_id,
+ observation=observations[0],
+ initial_observation=initial_observation,
+ admissible_commands=admissible,
+ game_file=game_file,
+ step=step_index,
+ max_steps=max_steps,
+ known_goal=goal or None,
+ known_task_type=task_type or None,
+ known_progress=(
+ episode_context.task.progress
+ if episode_context is not None
+ else None
+ ),
+ task_type_hint=task_type_hint,
+ episode_history=episode_history,
+ )
+ goal = task.goal
+ task_type = task.task_type
+ if task.interface_trace.get("fallback"):
+ self.artifacts.event(
+ "component_fallback",
+ "Task Interface exhausted JSON retries; deterministic episode "
+ "state fallback was used.",
+ episode_id=episode_id,
+ step=step_index,
+ component="task_interface",
+ error=task.interface_trace.get("fallback_error", ""),
+ goal_source=task.interface_trace.get("goal_source"),
+ )
+ self.artifacts.event(
+ "component_completed",
+ "Task Interface completed.",
+ episode_id=episode_id,
+ step=step_index,
+ component="task_interface",
+ task_type=task_type,
+ )
+ reroute_reasons = active_harness.router.reroute_reasons(
+ task,
+ episode_context.task if episode_context is not None else None,
+ steps,
+ )
+ reroute_allowed = (
+ episode_context is None
+ or active_harness.router.should_refresh_semantic_context(
+ reroute_reasons,
+ step_index,
+ last_semantic_route_step,
+ )
+ )
+ if reroute_allowed:
+ context = active_harness.route(task, reroute_reasons)
+ episode_context = context
+ if context.trace.get("llm_called"):
+ last_semantic_route_step = step_index
+ else:
+ context = active_harness.reuse_route(task, episode_context)
+ episode_context = context
+ self.artifacts.event(
+ "component_completed",
+ "Router completed.",
+ episode_id=episode_id,
+ step=step_index,
+ component="router",
+ selected_memory_count=len(context.memories),
+ selected_strategy_count=len(context.strategies),
+ selected_capability_count=len(context.capabilities),
+ routing_mode=context.trace.get("mode"),
+ llm_called=bool(context.trace.get("llm_called")),
+ trigger_reasons=context.trace.get("trigger_reasons", []),
+ )
+ action = policy.select_action(context, info)
+ self.artifacts.event(
+ "action_selected",
+ "Action Policy selected an admissible action.",
+ episode_id=episode_id,
+ step=step_index,
+ action=action,
+ )
+ routing_traces.append(
+ {
+ "step": step_index,
+ "task_interface": task.interface_trace,
+ "router": context.trace,
+ "action_policy": dict(getattr(policy, "last_trace", {})),
+ }
+ )
+ next_observations, rewards, dones, next_info = env.step(action)
+ reward = float(rewards[0])
+ steps.append(
+ StepRecord(
+ observation=observations[0],
+ action=action,
+ next_observation=next_observations[0],
+ admissible_commands=admissible,
+ reward=reward,
+ )
+ )
+ observations, info = next_observations, next_info
+ score = max(score, reward)
+ success = bool(info.get("won", [False])[0])
+ final_context = context
+ if bool(dones[0]) or success:
+ break
+ finally:
+ env.close()
+
+ record = EpisodeRecord(
+ episode_id=episode_id,
+ task_type=task_type,
+ goal=goal,
+ game_file=game_file,
+ split=split,
+ steps=steps,
+ success=success,
+ score=1.0 if success else score,
+ failure_reason="" if success else f"No success within {len(steps)} steps.",
+ harness_traces=routing_traces,
+ )
+ result = EpisodeResult(record=record, routing_traces=routing_traces)
+ if learn and final_context is not None:
+ result.candidate, result.evaluation = active_harness.learn(record, final_context)
+ optimizer_fallback = next(
+ (
+ trace["optimizer"]
+ for trace in reversed(record.harness_traces)
+ if isinstance(trace.get("optimizer"), dict)
+ and trace["optimizer"].get("fallback") is True
+ ),
+ None,
+ )
+ if optimizer_fallback is not None:
+ self.artifacts.event(
+ "component_fallback",
+ "Optimizer exhausted JSON retries; Harness update was safely skipped.",
+ episode_id=episode_id,
+ component="optimizer",
+ error=optimizer_fallback.get("error", ""),
+ fallback_mode=optimizer_fallback.get("mode"),
+ harness_version=active_harness.state.version,
+ )
+ decision = result.evaluation.decision
+ self.runtime_stats["decisions"][decision] += 1
+ self.runtime_stats["anchor_retention_sum"] += result.evaluation.anchor_retention
+ self.runtime_stats["anchor_evaluations"] += 1
+ if record_runtime:
+ self._record_episode_runtime(result, training_episode)
+ return result
+
+ def _record_episode_runtime(
+ self, result: EpisodeResult, training_episode: bool = False
+ ) -> None:
+ self.runtime_stats["episodes"] += 1
+ self.runtime_stats["steps"] += len(result.record.steps)
+ category = "learning_episodes" if training_episode else "evaluation_episodes"
+ self.runtime_stats[category] += 1
+
+ def _evaluation_workers(self, remaining: int) -> int:
+ configured = int(self.config.get("execution", {}).get("evaluation_workers", 1))
+ return min(max(configured, 1), max(remaining, 1))
+
+ def _read_only_runtime(self, state_snapshot: Dict[str, Any]) -> _ReadOnlyRuntime:
+ version = int(state_snapshot.get("version", 0))
+ runtime = getattr(self._worker_local, "runtime", None)
+ if runtime is None:
+ llm = make_llm(self.agent_config)
+ embedder = (
+ make_embedder(self.embedding_config)
+ if self.continual_method in {"rag"}
+ else None
+ )
+ method_config = (
+ self.config[self.continual_method]
+ if self.continual_method in {"rag"}
+ else self.config["harness"]
+ )
+ runtime = _ReadOnlyRuntime(
+ llm=llm,
+ interface=TaskInterface(
+ llm,
+ episode_progress_enabled=bool(
+ self.agent_config.get("episode_progress_enabled", True)
+ ),
+ ),
+ policy=make_policy(
+ self.agent_config["policy"], self.agent_config, self.seed, llm
+ ),
+ harness=make_continual_harness(
+ self.continual_method,
+ self.output_dir / "harness",
+ method_config,
+ llm,
+ embedder=embedder,
+ seed=self.seed,
+ ),
+ harness_version=-1,
+ )
+ self._worker_local.runtime = runtime
+ if runtime.harness_version != version:
+ runtime.harness.state = HarnessState.from_dict(state_snapshot)
+ runtime.harness_version = version
+ return runtime
+
+ def _run_read_only_episode(
+ self, game_file: str, split: str, state_snapshot: Dict[str, Any]
+ ) -> _ReadOnlyOutcome:
+ print(f"[eval_worker start] {game_file}", flush=True)
+ runtime = self._read_only_runtime(state_snapshot)
+ usage_before = runtime.llm.usage_snapshot()
+ result = self.run_episode(
+ game_file,
+ split,
+ runtime.policy,
+ learn=False,
+ training_episode=False,
+ runtime=runtime,
+ record_runtime=False,
+ )
+ print(
+ f"[eval_worker done] success={result.record.success} "
+ f"steps={len(result.record.steps)} {game_file}",
+ flush=True,
+ )
+ return _ReadOnlyOutcome(
+ result=result,
+ state_before=state_snapshot,
+ llm_usage=self._usage_delta(usage_before, runtime.llm.usage_snapshot()),
+ )
+
+ def _ordered_read_only_outcomes(
+ self, game_files: List[str], split: str
+ ) -> Iterator[_ReadOnlyOutcome]:
+ state_snapshot = self.harness.state.to_dict()
+ workers = self._evaluation_workers(len(game_files))
+ if workers == 1:
+ for game_file in game_files:
+ usage_before = self.llm.usage_snapshot()
+ result = self.run_episode(
+ game_file,
+ split,
+ self.eval_policy,
+ learn=False,
+ training_episode=False,
+ )
+ yield _ReadOnlyOutcome(
+ result=result,
+ state_before=state_snapshot,
+ llm_usage=self._usage_delta(
+ usage_before, self.llm.usage_snapshot()
+ ),
+ requires_runtime_record=False,
+ requires_usage_merge=False,
+ )
+ return
+ self.artifacts.event(
+ "parallel_evaluation_started",
+ "Read-only evaluation episodes started in parallel.",
+ workers=workers,
+ episodes=len(game_files),
+ harness_version=state_snapshot.get("version", 0),
+ )
+ print(
+ f"[parallel_eval] workers={workers} episodes={len(game_files)} "
+ f"harness_version={state_snapshot.get('version', 0)}",
+ flush=True,
+ )
+ with ThreadPoolExecutor(
+ max_workers=workers, thread_name_prefix="hcl-eval"
+ ) as executor:
+ yield from executor.map(
+ lambda game_file: self._run_read_only_episode(
+ game_file, split, state_snapshot
+ ),
+ game_files,
+ )
+ self.artifacts.event(
+ "parallel_evaluation_completed",
+ "Read-only parallel evaluation batch completed.",
+ workers=workers,
+ episodes=len(game_files),
+ harness_version=state_snapshot.get("version", 0),
+ )
+
+ @staticmethod
+ def _state_summary(state: Dict[str, Any]) -> Dict[str, Any]:
+ return {
+ "version": int(state.get("version", 0)),
+ "raw_memory_count": len(state.get("raw_memory", [])),
+ "abstract_memory_count": len(state.get("abstract_memory", [])),
+ "capability_count": len(state.get("capabilities", [])),
+ "anchor_count": len(state.get("anchors", [])),
+ }
+
+ @staticmethod
+ def _entity_changes(
+ before: List[Dict[str, Any]], after: List[Dict[str, Any]], identifier: str
+ ) -> Dict[str, Any]:
+ before_by_id = {str(item[identifier]): item for item in before}
+ after_by_id = {str(item[identifier]): item for item in after}
+ added_ids = sorted(set(after_by_id) - set(before_by_id))
+ removed_ids = sorted(set(before_by_id) - set(after_by_id))
+ changed_ids = sorted(
+ item_id
+ for item_id in set(before_by_id) & set(after_by_id)
+ if before_by_id[item_id] != after_by_id[item_id]
+ )
+ return {
+ "added": [after_by_id[item_id] for item_id in added_ids],
+ "changed": [
+ {
+ "id": item_id,
+ "before": before_by_id[item_id],
+ "after": after_by_id[item_id],
+ }
+ for item_id in changed_ids
+ ],
+ "removed": [before_by_id[item_id] for item_id in removed_ids],
+ }
+
+ @classmethod
+ def _state_diff(cls, before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
+ before_raw = {
+ str(item.get("episode_id"))
+ for item in before.get("raw_memory", [])
+ if item.get("episode_id")
+ }
+ after_raw = {
+ str(item.get("episode_id"))
+ for item in after.get("raw_memory", [])
+ if item.get("episode_id")
+ }
+ return {
+ "before": cls._state_summary(before),
+ "after": cls._state_summary(after),
+ "raw_memory_added_episode_ids": sorted(after_raw - before_raw),
+ "raw_memory_removed_episode_ids": sorted(before_raw - after_raw),
+ "strategies": cls._entity_changes(
+ before.get("abstract_memory", []),
+ after.get("abstract_memory", []),
+ "artifact_id",
+ ),
+ "capabilities": cls._entity_changes(
+ before.get("capabilities", []),
+ after.get("capabilities", []),
+ "capability_id",
+ ),
+ "anchors": cls._entity_changes(
+ before.get("anchors", []), after.get("anchors", []), "anchor_id"
+ ),
+ }
+
+ def _write_episode_log(
+ self,
+ phase: int,
+ mode: str,
+ result: EpisodeResult,
+ state_before: Dict[str, Any],
+ usage_before: Dict[str, Any],
+ usage_delta: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ state_after = self.harness.state.to_dict()
+ candidate_summary = None
+ if result.candidate is not None:
+ candidate_summary = {
+ "candidate_id": result.candidate.candidate_id,
+ "base_version": result.candidate.base_version,
+ "proposed_version": result.candidate.proposed_state.version,
+ "operations": result.candidate.operations,
+ "rationale": result.candidate.rationale,
+ "candidate_file": (
+ f"harness/candidates/{result.candidate.candidate_id}.json"
+ ),
+ }
+ payload = {
+ "phase": phase,
+ "mode": mode,
+ "harness_version": self.harness.state.version,
+ "record": result.record.to_dict(),
+ "candidate": candidate_summary,
+ "evaluation": result.evaluation.to_dict() if result.evaluation else None,
+ "state_change": self._state_diff(state_before, state_after),
+ "llm_usage": usage_delta
+ if usage_delta is not None
+ else self._usage_delta(usage_before, self.llm.usage_snapshot()),
+ }
+ episode_path = self.artifacts.write_episode(payload)
+ if result.candidate is not None:
+ update_path = self.artifacts.write_update(
+ {
+ "phase": phase,
+ "mode": mode,
+ "episode_id": result.record.episode_id,
+ "task_type": result.record.task_type,
+ "success": result.record.success,
+ "candidate": candidate_summary,
+ "evaluation": payload["evaluation"],
+ "applied_state_change": payload["state_change"],
+ }
+ )
+ else:
+ update_path = None
+ self.artifacts.event(
+ "episode_completed",
+ "Episode completed and logged.",
+ phase=phase,
+ mode=mode,
+ episode_id=result.record.episode_id,
+ success=result.record.success,
+ steps=len(result.record.steps),
+ harness_version=self.harness.state.version,
+ episode_file=str(episode_path.relative_to(self.output_dir)),
+ update_file=(
+ str(update_path.relative_to(self.output_dir)) if update_path else None
+ ),
+ )
+
+ @staticmethod
+ def _merge_usage(previous: Dict[str, Any], current: Dict[str, Any]) -> Dict[str, Any]:
+ merged = {
+ "requests": int(previous.get("requests", 0)) + int(current.get("requests", 0)),
+ "prompt_tokens": int(previous.get("prompt_tokens", 0))
+ + int(current.get("prompt_tokens", 0)),
+ "completion_tokens": int(previous.get("completion_tokens", 0))
+ + int(current.get("completion_tokens", 0)),
+ "by_component": {},
+ }
+ component_names = set(previous.get("by_component", {})) | set(
+ current.get("by_component", {})
+ )
+ for component in sorted(component_names):
+ old = previous.get("by_component", {}).get(component, {})
+ new = current.get("by_component", {}).get(component, {})
+ merged["by_component"][component] = {
+ "requests": int(old.get("requests", 0)) + int(new.get("requests", 0)),
+ "prompt_tokens": int(old.get("prompt_tokens", 0))
+ + int(new.get("prompt_tokens", 0)),
+ "completion_tokens": int(old.get("completion_tokens", 0))
+ + int(new.get("completion_tokens", 0)),
+ }
+ return merged
+
+ @staticmethod
+ def _usage_delta(before: Dict[str, Any], after: Dict[str, Any]) -> Dict[str, Any]:
+ delta = {
+ "requests": int(after.get("requests", 0)) - int(before.get("requests", 0)),
+ "prompt_tokens": int(after.get("prompt_tokens", 0))
+ - int(before.get("prompt_tokens", 0)),
+ "completion_tokens": int(after.get("completion_tokens", 0))
+ - int(before.get("completion_tokens", 0)),
+ "by_component": {},
+ }
+ component_names = set(before.get("by_component", {})) | set(
+ after.get("by_component", {})
+ )
+ for component in sorted(component_names):
+ old = before.get("by_component", {}).get(component, {})
+ new = after.get("by_component", {}).get(component, {})
+ component_delta = {
+ "requests": int(new.get("requests", 0)) - int(old.get("requests", 0)),
+ "prompt_tokens": int(new.get("prompt_tokens", 0))
+ - int(old.get("prompt_tokens", 0)),
+ "completion_tokens": int(new.get("completion_tokens", 0))
+ - int(old.get("completion_tokens", 0)),
+ }
+ if any(component_delta.values()):
+ delta["by_component"][component] = component_delta
+ return delta
+
+ def _add_parallel_usage(self, usage: Dict[str, Any]) -> None:
+ self.parallel_llm_usage = self._merge_usage(self.parallel_llm_usage, usage)
+
+ def _live_llm_usage(self) -> Dict[str, Any]:
+ return self._merge_usage(self.llm.usage_snapshot(), self.parallel_llm_usage)
+
+ def _total_llm_usage(self) -> Dict[str, Any]:
+ return self._merge_usage(self.prior_llm_usage, self._live_llm_usage())
+
+ def _checkpoint(
+ self,
+ cursor: Dict[str, Any],
+ matrix: List[List[float]],
+ phase_summaries: List[Dict[str, Any]],
+ elapsed_seconds: float,
+ ) -> None:
+ self.artifacts.save_checkpoint(
+ {
+ # next_phase remains for compatibility with earlier phase checkpoints.
+ "next_phase": int(cursor["phase_index"]),
+ "cursor": cursor,
+ "matrix": matrix,
+ "phase_summaries": phase_summaries,
+ "runtime_stats": self.runtime_stats,
+ "llm_usage": self._total_llm_usage(),
+ "elapsed_seconds": elapsed_seconds,
+ "harness_state": self.harness.state.to_dict(),
+ "sequence_manifest": str(
+ resolve_project_path(self.config, "sequence_manifest")
+ ),
+ }
+ )
+
+ @staticmethod
+ def _initial_cursor(phase_index: int, task_count: int) -> Dict[str, Any]:
+ return {
+ "phase_index": phase_index,
+ "stage": "train",
+ "train_game_index": 0,
+ "train_successes": 0,
+ "eval_task_index": 0,
+ "eval_game_index": 0,
+ "eval_successes": [0 for _ in range(task_count)],
+ "eval_success_rates": [0.0 for _ in range(task_count)],
+ }
+
+ def run(self) -> Dict[str, Any]:
+ if self.config.get("run_mode") == "standard_evaluation":
+ return self.run_standard_evaluation()
+ manifest_path = resolve_project_path(self.config, "sequence_manifest")
+ manifest = load_sequence(manifest_path)
+ phases = manifest["phases"]
+ matrix: List[List[float]] = []
+ phase_summaries: List[Dict[str, Any]] = []
+ cursor = self._initial_cursor(0, len(phases))
+ started = time.time()
+ if self.resume_requested:
+ checkpoint = self.artifacts.load_checkpoint()
+ if checkpoint.get("sequence_manifest") != str(manifest_path):
+ raise ValueError("Resume checkpoint uses a different sequence manifest.")
+ matrix = list(checkpoint.get("matrix", []))
+ phase_summaries = list(checkpoint.get("phase_summaries", []))
+ cursor = dict(
+ checkpoint.get(
+ "cursor",
+ self._initial_cursor(
+ int(checkpoint.get("next_phase", len(matrix))), len(phases)
+ ),
+ )
+ )
+ self.runtime_stats = dict(checkpoint.get("runtime_stats", self.runtime_stats))
+ self.prior_llm_usage = dict(checkpoint.get("llm_usage", {}))
+ self.elapsed_before = float(checkpoint.get("elapsed_seconds", 0.0))
+ self.harness.state = HarnessState.from_dict(checkpoint["harness_state"])
+ if self.raw_memory_retrieval_override is not None:
+ self.harness.state.routing_policy["raw_memory_retrieval_enabled"] = bool(
+ self.raw_memory_retrieval_override
+ )
+ self.harness.store.save_stable(self.harness.state)
+ self.artifacts.event(
+ "checkpoint_restored",
+ "Restored the last completed episode checkpoint.",
+ cursor=cursor,
+ harness_version=self.harness.state.version,
+ )
+ else:
+ self._checkpoint(cursor, matrix, phase_summaries, 0.0)
+
+ self.artifacts.set_status("running", cursor=cursor)
+ # Ensure every run has a result snapshot even before its first phase completes.
+ self._save_results(matrix, phase_summaries, started)
+ try:
+ while int(cursor["phase_index"]) < len(phases):
+ phase_index = int(cursor["phase_index"])
+ phase = phases[phase_index]
+ evaluation_scope = str(
+ self.config.get("sequence", {}).get(
+ "evaluation_scope", "all_tasks"
+ )
+ )
+ if evaluation_scope == "learned_tasks":
+ evaluation_task_count = phase_index + 1
+ elif evaluation_scope == "all_tasks":
+ evaluation_task_count = len(phases)
+ else:
+ raise ValueError(
+ f"Unsupported sequence.evaluation_scope: {evaluation_scope}"
+ )
+ self.artifacts.event(
+ "phase_started",
+ "Phase execution started or resumed.",
+ phase=phase_index,
+ task_type=phase["task_type"],
+ cursor=cursor,
+ )
+ if cursor["stage"] == "train":
+ train_games = phase["train_games"]
+ for train_index in range(
+ int(cursor["train_game_index"]), len(train_games)
+ ):
+ game_file = train_games[train_index]
+ state_before = self.harness.state.to_dict()
+ usage_before = self.llm.usage_snapshot()
+ result = self.run_episode(
+ game_file,
+ manifest["train_split"],
+ self.learning_policy,
+ learn=bool(
+ (
+ self.config[self.continual_method]
+ if self.continual_method in {"rag"}
+ else self.config["harness"]
+ ).get("updates_enabled", True)
+ ),
+ training_episode=True,
+ )
+ self._write_episode_log(
+ phase_index,
+ "learn",
+ result,
+ state_before,
+ usage_before,
+ )
+ cursor["train_successes"] = int(cursor["train_successes"]) + int(
+ result.record.success
+ )
+ cursor["train_game_index"] = train_index + 1
+ elapsed = self.elapsed_before + time.time() - started
+ self._checkpoint(cursor, matrix, phase_summaries, elapsed)
+ cursor["stage"] = "eval"
+ cursor["eval_task_index"] = 0
+ cursor["eval_game_index"] = 0
+ elapsed = self.elapsed_before + time.time() - started
+ self._checkpoint(cursor, matrix, phase_summaries, elapsed)
+
+ for task_index in range(
+ int(cursor["eval_task_index"]), evaluation_task_count
+ ):
+ eval_phase = phases[task_index]
+ eval_games = eval_phase["eval_games"]
+ start_game = (
+ int(cursor["eval_game_index"])
+ if task_index == int(cursor["eval_task_index"])
+ else 0
+ )
+ remaining_games = list(eval_games[start_game:])
+ outcomes = self._ordered_read_only_outcomes(
+ remaining_games, manifest["eval_split"]
+ )
+ for offset, outcome in enumerate(outcomes):
+ eval_game_index = start_game + offset
+ result = outcome.result
+ if outcome.requires_runtime_record:
+ self._record_episode_runtime(result)
+ if outcome.requires_usage_merge:
+ self._add_parallel_usage(outcome.llm_usage)
+ self._write_episode_log(
+ phase_index,
+ f"eval_task_{task_index}",
+ result,
+ outcome.state_before,
+ {},
+ usage_delta=outcome.llm_usage,
+ )
+ cursor["eval_successes"][task_index] = int(
+ cursor["eval_successes"][task_index]
+ ) + int(result.record.success)
+ cursor["eval_task_index"] = task_index
+ cursor["eval_game_index"] = eval_game_index + 1
+ elapsed = self.elapsed_before + time.time() - started
+ self._checkpoint(cursor, matrix, phase_summaries, elapsed)
+ cursor["eval_success_rates"][task_index] = int(
+ cursor["eval_successes"][task_index]
+ ) / max(1, len(eval_games))
+ cursor["eval_task_index"] = task_index + 1
+ cursor["eval_game_index"] = 0
+ elapsed = self.elapsed_before + time.time() - started
+ self._checkpoint(cursor, matrix, phase_summaries, elapsed)
+
+ row = list(cursor["eval_success_rates"][:evaluation_task_count])
+ matrix.append(row)
+ phase_summaries.append(
+ {
+ "phase": phase_index,
+ "task_type": phase["task_type"],
+ "train_success_rate": int(cursor["train_successes"])
+ / max(1, len(phase["train_games"])),
+ "eval_success_rates": row,
+ "eval_success_counts": list(
+ cursor["eval_successes"][:evaluation_task_count]
+ ),
+ "eval_game_counts": [
+ len(phases[index]["eval_games"])
+ for index in range(evaluation_task_count)
+ ],
+ "harness_version": self.harness.state.version,
+ }
+ )
+ cursor = self._initial_cursor(phase_index + 1, len(phases))
+ elapsed = self.elapsed_before + time.time() - started
+ self._checkpoint(cursor, matrix, phase_summaries, elapsed)
+ self._save_results(matrix, phase_summaries, started)
+ self.artifacts.event(
+ "phase_completed",
+ "Phase checkpoint and results saved.",
+ phase=phase_index,
+ task_type=phase["task_type"],
+ train_success_rate=phase_summaries[-1]["train_success_rate"],
+ eval_success_rates=row,
+ harness_version=self.harness.state.version,
+ )
+ results = self._save_results(matrix, phase_summaries, started)
+ final_elapsed = float(results["elapsed_seconds"])
+ self._checkpoint(cursor, matrix, phase_summaries, final_elapsed)
+ self.artifacts.set_status(
+ "completed", next_phase=len(phases), harness_version=self.harness.state.version
+ )
+ self.artifacts.event(
+ "run_completed",
+ "All phases completed.",
+ metrics=results["metrics"],
+ elapsed_seconds=final_elapsed,
+ )
+ return results
+ except BaseException as exc:
+ self.artifacts.set_status(
+ "interrupted",
+ cursor=cursor,
+ error_type=type(exc).__name__,
+ error=str(exc),
+ )
+ self.artifacts.event(
+ "run_interrupted",
+ "Run stopped; resume will restart only the incomplete episode.",
+ cursor=cursor,
+ error_type=type(exc).__name__,
+ error=str(exc),
+ )
+ raise
+
+ def _standard_checkpoint(
+ self,
+ games: List[Dict[str, str]],
+ split: str,
+ next_game_index: int,
+ successes: int,
+ per_task_type: Dict[str, Dict[str, int]],
+ elapsed_seconds: float,
+ ) -> None:
+ self.artifacts.save_checkpoint(
+ {
+ "run_mode": "standard_evaluation",
+ "split": split,
+ "games": games,
+ "next_game_index": next_game_index,
+ "successes": successes,
+ "per_task_type": per_task_type,
+ "runtime_stats": self.runtime_stats,
+ "llm_usage": self._total_llm_usage(),
+ "elapsed_seconds": elapsed_seconds,
+ "harness_state": self.harness.state.to_dict(),
+ }
+ )
+
+ def run_standard_evaluation(self) -> Dict[str, Any]:
+ evaluation = self.config.get("standard_evaluation", {})
+ split = str(evaluation.get("split", "valid_unseen"))
+ game_source = str(evaluation.get("game_source", "full_split"))
+ if game_source == "full_split":
+ games = list_split_games(self.data_dir, split)
+ elif game_source == "sequence_manifest":
+ manifest = load_sequence(
+ resolve_project_path(self.config, "sequence_manifest")
+ )
+ if manifest.get("eval_split") != split:
+ raise ValueError(
+ "Sequence manifest eval_split does not match standard_evaluation.split."
+ )
+ games = [
+ {"game_file": game_file, "task_type": str(phase["task_type"])}
+ for phase in manifest["phases"]
+ for game_file in phase["eval_games"]
+ ]
+ game_paths = [item["game_file"] for item in games]
+ if len(game_paths) != len(set(game_paths)):
+ raise ValueError("Sequence manifest contains duplicate evaluation games.")
+ else:
+ raise ValueError(f"Unsupported standard_evaluation.game_source: {game_source}")
+ expected_count = int(evaluation.get("expected_game_count", 134))
+ if len(games) != expected_count:
+ raise ValueError(
+ f"Expected {expected_count} games in {split}, found {len(games)}."
+ )
+ if bool(self.config["harness"].get("updates_enabled", True)):
+ raise ValueError("standard_evaluation requires harness.updates_enabled: false")
+
+ started = time.time()
+ next_game_index = 0
+ successes = 0
+ per_task_type = {
+ task_type: {"successes": 0, "total": 0}
+ for task_type in sorted({item["task_type"] for item in games})
+ }
+ if self.resume_requested:
+ checkpoint = self.artifacts.load_checkpoint()
+ if checkpoint.get("run_mode") != "standard_evaluation":
+ raise ValueError("Resume checkpoint is not a standard evaluation run.")
+ if checkpoint.get("split") != split or checkpoint.get("games") != games:
+ raise ValueError("The standard evaluation game list changed since checkpoint.")
+ next_game_index = int(checkpoint.get("next_game_index", 0))
+ successes = int(checkpoint.get("successes", 0))
+ per_task_type = {
+ key: {"successes": int(value["successes"]), "total": int(value["total"])}
+ for key, value in checkpoint.get("per_task_type", {}).items()
+ }
+ self.runtime_stats = dict(checkpoint.get("runtime_stats", self.runtime_stats))
+ self.prior_llm_usage = dict(checkpoint.get("llm_usage", {}))
+ self.elapsed_before = float(checkpoint.get("elapsed_seconds", 0.0))
+ self.harness.state = HarnessState.from_dict(checkpoint["harness_state"])
+ self.harness.store.save_stable(self.harness.state)
+ self.artifacts.event(
+ "checkpoint_restored",
+ "Restored the last completed standard-evaluation episode.",
+ next_game_index=next_game_index,
+ completed_games=next_game_index,
+ total_games=len(games),
+ )
+ else:
+ self._standard_checkpoint(
+ games, split, next_game_index, successes, per_task_type, 0.0
+ )
+
+ self.artifacts.set_status(
+ "running",
+ run_mode="standard_evaluation",
+ next_game_index=next_game_index,
+ total_games=len(games),
+ )
+ results = self._save_standard_results(
+ games, split, next_game_index, successes, per_task_type, started
+ )
+ try:
+ batch_start_index = next_game_index
+ remaining_games = games[batch_start_index:]
+ outcomes = self._ordered_read_only_outcomes(
+ [game["game_file"] for game in remaining_games], split
+ )
+ for offset, outcome in enumerate(outcomes):
+ game_index = batch_start_index + offset
+ game = games[game_index]
+ result = outcome.result
+ if outcome.requires_runtime_record:
+ self._record_episode_runtime(result)
+ if outcome.requires_usage_merge:
+ self._add_parallel_usage(outcome.llm_usage)
+ self._write_episode_log(
+ -1,
+ "standard_eval",
+ result,
+ outcome.state_before,
+ {},
+ usage_delta=outcome.llm_usage,
+ )
+ succeeded = int(result.record.success)
+ successes += succeeded
+ task_counts = per_task_type[game["task_type"]]
+ task_counts["successes"] += succeeded
+ task_counts["total"] += 1
+ completed_index = game_index + 1
+ elapsed = self.elapsed_before + time.time() - started
+ self._standard_checkpoint(
+ games, split, completed_index, successes, per_task_type, elapsed
+ )
+ results = self._save_standard_results(
+ games, split, completed_index, successes, per_task_type, started
+ )
+ print(
+ f"[standard_eval {completed_index}/{len(games)}] "
+ f"success={bool(succeeded)} cumulative_sr="
+ f"{results['metrics']['success_rate']:.4f}",
+ flush=True,
+ )
+ next_game_index = completed_index
+
+ self.artifacts.set_status(
+ "completed",
+ run_mode="standard_evaluation",
+ completed_games=next_game_index,
+ total_games=len(games),
+ )
+ self.artifacts.event(
+ "run_completed",
+ "Standard ALFWorld evaluation completed.",
+ success_rate=results["metrics"]["success_rate"],
+ successes=successes,
+ total_games=len(games),
+ )
+ return results
+ except BaseException as exc:
+ self.artifacts.set_status(
+ "interrupted",
+ run_mode="standard_evaluation",
+ next_game_index=next_game_index,
+ error_type=type(exc).__name__,
+ error=str(exc),
+ )
+ self.artifacts.event(
+ "run_interrupted",
+ "Standard evaluation stopped; resume will retry the incomplete episode.",
+ next_game_index=next_game_index,
+ error_type=type(exc).__name__,
+ error=str(exc),
+ )
+ raise
+
+ def _save_standard_results(
+ self,
+ games: List[Dict[str, str]],
+ split: str,
+ completed_games: int,
+ successes: int,
+ per_task_type: Dict[str, Dict[str, int]],
+ started: float,
+ ) -> Dict[str, Any]:
+ elapsed_seconds = self.elapsed_before + time.time() - started
+ game_source = str(
+ self.config.get("standard_evaluation", {}).get(
+ "game_source", "full_split"
+ )
+ )
+ per_task_results = {
+ task_type: {
+ **counts,
+ "success_rate": counts["successes"] / max(1, counts["total"]),
+ }
+ for task_type, counts in sorted(per_task_type.items())
+ }
+ results = {
+ "config": self.config,
+ "run_dir": str(self.output_dir),
+ "run_mode": "standard_evaluation",
+ "game_source": game_source,
+ "split": split,
+ "expected_games": len(games),
+ "completed_games": completed_games,
+ "successes": successes,
+ "metrics": {
+ "success_rate": successes / max(1, completed_games),
+ },
+ "per_task_type": per_task_results,
+ "harness_version": self.harness.state.version,
+ "elapsed_seconds": elapsed_seconds,
+ "runtime": {
+ **self.runtime_stats,
+ "average_steps": self.runtime_stats["steps"]
+ / max(1, self.runtime_stats["episodes"]),
+ "llm_usage": self._total_llm_usage(),
+ },
+ }
+ RunArtifacts._write_json_atomic(self.output_dir / "results.json", results)
+ csv_path = self.output_dir / "task_type_results.csv"
+ temporary_csv = csv_path.with_suffix(".csv.tmp")
+ with temporary_csv.open("w", encoding="utf-8", newline="") as handle:
+ writer = csv.writer(handle)
+ writer.writerow(["task_type", "successes", "total", "success_rate"])
+ for task_type, counts in per_task_results.items():
+ writer.writerow(
+ [
+ task_type,
+ counts["successes"],
+ counts["total"],
+ counts["success_rate"],
+ ]
+ )
+ temporary_csv.replace(csv_path)
+ status = "complete" if completed_games == len(games) else "in progress"
+ lines = [
+ "# Standard ALFWorld evaluation",
+ "",
+ f"- Status: {status}",
+ f"- Split: {split}",
+ f"- Completed games: {completed_games}/{len(games)}",
+ f"- Successes: {successes}",
+ f"- Success rate: {results['metrics']['success_rate']:.6f}",
+ f"- Harness version: {self.harness.state.version}",
+ f"- LLM requests: {results['runtime']['llm_usage']['requests']}",
+ "",
+ "## Per task type",
+ "",
+ ]
+ for task_type, counts in per_task_results.items():
+ lines.append(
+ f"- {task_type}: {counts['successes']}/{counts['total']} "
+ f"({counts['success_rate']:.6f})"
+ )
+ summary_path = self.output_dir / "results_summary.md"
+ temporary_summary = summary_path.with_suffix(".md.tmp")
+ with temporary_summary.open("w", encoding="utf-8") as handle:
+ handle.write("\n".join(lines) + "\n")
+ temporary_summary.replace(summary_path)
+ return results
+
+ def _save_results(
+ self,
+ matrix: List[List[float]],
+ phase_summaries: List[Dict[str, Any]],
+ started: float,
+ ) -> Dict[str, Any]:
+ elapsed_seconds = self.elapsed_before + time.time() - started
+ llm_usage = self._total_llm_usage()
+ metrics = continual_metrics(matrix)
+ if phase_summaries:
+ final_summary = phase_summaries[-1]
+ final_successes = sum(final_summary.get("eval_success_counts", []))
+ final_games = sum(final_summary.get("eval_game_counts", []))
+ metrics["final_micro_success_rate"] = final_successes / max(1, final_games)
+ else:
+ metrics["final_micro_success_rate"] = 0.0
+ results = {
+ "config": self.config,
+ "run_dir": str(self.output_dir),
+ "matrix": matrix,
+ "metrics": metrics,
+ "phases": phase_summaries,
+ "harness_version": self.harness.state.version,
+ "elapsed_seconds": elapsed_seconds,
+ "runtime": {
+ **self.runtime_stats,
+ "average_steps": self.runtime_stats["steps"]
+ / max(1, self.runtime_stats["episodes"]),
+ "average_anchor_retention": self.runtime_stats["anchor_retention_sum"]
+ / max(1, self.runtime_stats["anchor_evaluations"]),
+ "llm_usage": llm_usage,
+ },
+ }
+ RunArtifacts._write_json_atomic(self.output_dir / "results.json", results)
+ matrix_path = self.output_dir / "success_matrix.csv"
+ temporary_matrix = matrix_path.with_suffix(".csv.tmp")
+ with temporary_matrix.open(
+ "w", encoding="utf-8", newline=""
+ ) as handle:
+ writer = csv.writer(handle)
+ width = max((len(row) for row in matrix), default=0)
+ writer.writerow(["after_phase"] + [f"task_{index}" for index in range(width)])
+ for index, row in enumerate(matrix):
+ writer.writerow([index] + row)
+ temporary_matrix.replace(matrix_path)
+ self._write_result_summary(results)
+ return results
+
+ def _write_result_summary(self, results: Dict[str, Any]) -> None:
+ metrics = results["metrics"]
+ forward_transfer = metrics["forward_transfer"]
+ forward_transfer_text = (
+ f"{forward_transfer:.6f}" if forward_transfer is not None else "N/A"
+ )
+ expected_phases = len(self.config.get("sequence", {}).get("task_order", []))
+ is_complete = expected_phases > 0 and len(results["matrix"]) == expected_phases
+ lines = [
+ "# Run summary",
+ "",
+ f"- Status: {'complete' if is_complete else 'in progress'}",
+ f"- Completed phases: {len(results['matrix'])}",
+ f"- Harness version: {results['harness_version']}",
+ f"- Episodes: {results['runtime']['episodes']}",
+ f"- LLM requests: {results['runtime']['llm_usage']['requests']}",
+ f"- Elapsed seconds: {results['elapsed_seconds']:.3f}",
+ "",
+ "## Continual-learning metrics",
+ "",
+ f"- Average accuracy: {metrics['average_accuracy']:.6f}",
+ f"- Backward transfer: {metrics['backward_transfer']:.6f}",
+ f"- Forgetting: {metrics['forgetting']:.6f}",
+ f"- Forward transfer: {forward_transfer_text}",
+ f"- Final micro success rate: {metrics['final_micro_success_rate']:.6f}",
+ "",
+ ]
+ path = self.output_dir / "results_summary.md"
+ temporary = path.with_suffix(".md.tmp")
+ with temporary.open("w", encoding="utf-8") as handle:
+ handle.write("\n".join(lines))
+ temporary.replace(path)
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/schemas.py b/experiments/HCL_ALFworld/src/hcl_alfworld/schemas.py
new file mode 100644
index 0000000000000000000000000000000000000000..db7abd13c5fe5e7c18363ee02abadc69ac2c7171
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/schemas.py
@@ -0,0 +1,194 @@
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from typing import Any, Dict, List, Optional
+
+
+@dataclass
+class TaskEvidence:
+ """Paper Eq. (2): evidence, recognizable goal, and constraints."""
+
+ interaction_id: str
+ task_type: str
+ evidence: str
+ goal: str
+ constraints: List[str]
+ admissible_commands: List[str]
+ game_file: str = ""
+ initial_observation: str = ""
+ step: int = 0
+ episode_history: List[Dict[str, Any]] = field(default_factory=list)
+ progress: Dict[str, Any] = field(default_factory=dict)
+ interface_trace: Dict[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class StepRecord:
+ observation: str
+ action: str
+ next_observation: str
+ admissible_commands: List[str]
+ reward: float = 0.0
+
+
+@dataclass
+class EpisodeRecord:
+ episode_id: str
+ task_type: str
+ goal: str
+ game_file: str
+ split: str
+ steps: List[StepRecord]
+ success: bool
+ score: float
+ failure_reason: str = ""
+ harness_traces: List[Dict[str, Any]] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class StrategyArtifact:
+ artifact_id: str
+ task_type: str
+ title: str
+ procedure: List[str]
+ applicability: List[str]
+ failure_boundaries: List[str]
+ evidence_episode_ids: List[str]
+ validation_count: int = 0
+ success_count: int = 0
+ created_at_version: int = 0
+
+
+@dataclass
+class CapabilityUnit:
+ capability_id: str
+ name: str
+ kind: str
+ function: str
+ action_prefixes: List[str]
+ input_output: str
+ preconditions: List[str]
+ failure_modes: List[str]
+ availability: str = "available"
+ version: str = "1"
+ validation_evidence: List[str] = field(default_factory=list)
+ description: str = ""
+ dependencies: List[str] = field(default_factory=list)
+ procedure: List[str] = field(default_factory=list)
+ inputs: List[str] = field(default_factory=list)
+ outputs: List[str] = field(default_factory=list)
+ success_conditions: List[str] = field(default_factory=list)
+ positive_evidence_episode_ids: List[str] = field(default_factory=list)
+ negative_evidence_episode_ids: List[str] = field(default_factory=list)
+ usage_count: int = 0
+ success_count: int = 0
+ failure_count: int = 0
+ confidence: float = 0.0
+ status: str = "hypothesized"
+ provider_component: str = ""
+ model: str = ""
+ prompt_version: str = ""
+ created_at_version: int = 0
+ updated_at_version: int = 0
+
+
+@dataclass
+class AnchorCase:
+ anchor_id: str
+ task_type: str
+ goal: str
+ evidence: str
+ admissible_commands: List[str]
+ expected_capability_ids: List[str]
+ expected_strategy_ids: List[str]
+ source_episode_id: str
+
+
+@dataclass
+class HarnessState:
+ """Paper Eq. (1), serialized stable harness state H_t."""
+
+ version: int = 0
+ raw_memory: List[Dict[str, Any]] = field(default_factory=list)
+ abstract_memory: List[StrategyArtifact] = field(default_factory=list)
+ capabilities: List[CapabilityUnit] = field(default_factory=list)
+ anchors: List[AnchorCase] = field(default_factory=list)
+ routing_policy: Dict[str, Any] = field(default_factory=dict)
+ evaluator_rules: Dict[str, Any] = field(default_factory=dict)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: Dict[str, Any]) -> "HarnessState":
+ capabilities = []
+ for raw in data.get("capabilities", []):
+ item = dict(raw)
+ if item.get("kind") == "external":
+ item["kind"] = "atomic_external"
+ item.setdefault("status", "validated")
+ item.setdefault("confidence", 1.0)
+ capabilities.append(CapabilityUnit(**item))
+ return cls(
+ version=int(data.get("version", 0)),
+ raw_memory=list(data.get("raw_memory", [])),
+ abstract_memory=[StrategyArtifact(**x) for x in data.get("abstract_memory", [])],
+ capabilities=capabilities,
+ anchors=[AnchorCase(**x) for x in data.get("anchors", [])],
+ routing_policy=dict(data.get("routing_policy", {})),
+ evaluator_rules=dict(data.get("evaluator_rules", {})),
+ )
+
+
+@dataclass
+class RoutingContext:
+ task: TaskEvidence
+ memories: List[Dict[str, Any]]
+ strategies: List[StrategyArtifact]
+ capabilities: List[CapabilityUnit]
+ trace: Dict[str, Any]
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class CandidateUpdate:
+ candidate_id: str
+ base_version: int
+ proposed_state: HarnessState
+ operations: List[Dict[str, Any]]
+ rationale: List[str]
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class EvaluationResult:
+ candidate_id: str
+ decision: str
+ current_score: float
+ anchor_retention: float
+ regressions: List[str]
+ diagnostics: List[str]
+ partial_operation_kinds: List[str] = field(default_factory=list)
+ accepted_operation_indices: List[int] = field(default_factory=list)
+ rejected_operation_indices: List[int] = field(default_factory=list)
+
+ def to_dict(self) -> Dict[str, Any]:
+ return asdict(self)
+
+
+@dataclass
+class EpisodeResult:
+ record: EpisodeRecord
+ routing_traces: List[Dict[str, Any]]
+ candidate: Optional[CandidateUpdate] = None
+ evaluation: Optional[EvaluationResult] = None
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/sequence.py b/experiments/HCL_ALFworld/src/hcl_alfworld/sequence.py
new file mode 100644
index 0000000000000000000000000000000000000000..9a465b372b296ed941641f03ec7ce0ca9c216e7c
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/sequence.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import json
+import random
+from collections import defaultdict
+from pathlib import Path
+from typing import Any, Dict, Iterable, List, Union
+
+from .environment import read_task_type, validate_data_dir
+from .task_interface import TASK_TYPE_NAMES
+
+
+def _index_split(data_dir: Path, split: str) -> Dict[str, List[str]]:
+ result: Dict[str, List[str]] = defaultdict(list)
+ split_dir = data_dir / "json_2.1.1" / split
+ for game_file in sorted(split_dir.rglob("game.tw-pddl")):
+ if "movable" in str(game_file).lower() or "sliced" in str(game_file).lower():
+ continue
+ try:
+ with game_file.open("r", encoding="utf-8") as handle:
+ game_data = json.load(handle)
+ if not game_data.get("solvable", False):
+ continue
+ relative_game = game_file.resolve().relative_to(data_dir.resolve())
+ result[read_task_type(game_file)].append(str(relative_game))
+ except (OSError, json.JSONDecodeError, KeyError):
+ continue
+ return dict(result)
+
+
+def build_sequence(
+ data_dir: str | Path,
+ output_path: str | Path,
+ task_order: Iterable[int],
+ train_episodes_per_task: int,
+ eval_episodes_per_task: Union[int, str],
+ train_split: str,
+ eval_split: str,
+ seed: int,
+) -> Dict[str, Any]:
+ data_dir = Path(data_dir).resolve()
+ validation = validate_data_dir(data_dir)
+ if not validation["valid"]:
+ raise FileNotFoundError("Incomplete ALFWorld data: " + ", ".join(validation["missing"]))
+ rng = random.Random(seed)
+ train_index = _index_split(data_dir, train_split)
+ eval_index = _index_split(data_dir, eval_split)
+ use_all_eval_games = str(eval_episodes_per_task).lower() == "all"
+ eval_limit = None if use_all_eval_games else int(eval_episodes_per_task)
+ phases: List[Dict[str, Any]] = []
+ for phase_index, task_id in enumerate(task_order):
+ task_type = TASK_TYPE_NAMES[int(task_id)]
+ train_games = list(train_index.get(task_type, []))
+ eval_games = list(eval_index.get(task_type, []))
+ rng.shuffle(train_games)
+ rng.shuffle(eval_games)
+ if len(train_games) < train_episodes_per_task:
+ raise ValueError(f"Only {len(train_games)} train games available for {task_type}.")
+ if eval_limit is not None and len(eval_games) < eval_limit:
+ raise ValueError(f"Only {len(eval_games)} eval games available for {task_type}.")
+ phases.append(
+ {
+ "phase": phase_index,
+ "task_id": int(task_id),
+ "task_type": task_type,
+ "train_games": train_games[:train_episodes_per_task],
+ "eval_games": eval_games if eval_limit is None else eval_games[:eval_limit],
+ }
+ )
+ manifest = {
+ "schema_version": 1,
+ "seed": seed,
+ "game_paths_relative_to": "data_dir",
+ "train_split": train_split,
+ "eval_split": eval_split,
+ "task_order": [int(item) for item in task_order],
+ "eval_episodes_per_task": "all" if use_all_eval_games else eval_limit,
+ "phases": phases,
+ "data_validation": {key: value for key, value in validation.items() if key != "data_dir"},
+ }
+ output_path = Path(output_path)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ with output_path.open("w", encoding="utf-8") as handle:
+ json.dump(manifest, handle, indent=2, ensure_ascii=False, sort_keys=True)
+ handle.write("\n")
+ return manifest
+
+
+def load_sequence(path: str | Path) -> Dict[str, Any]:
+ with Path(path).open("r", encoding="utf-8") as handle:
+ return json.load(handle)
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/task_interface.py b/experiments/HCL_ALFworld/src/hcl_alfworld/task_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..0628a94e313a57bc77c6e827885a8a92f218edcc
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/task_interface.py
@@ -0,0 +1,301 @@
+from __future__ import annotations
+
+import re
+from typing import Any, Dict, List, Optional
+
+from .llm import StructuredLLMProtocol
+from .schemas import TaskEvidence
+
+
+TASK_TYPE_NAMES = {
+ 1: "pick_and_place_simple",
+ 2: "look_at_obj_in_light",
+ 3: "pick_clean_then_place_in_recep",
+ 4: "pick_heat_then_place_in_recep",
+ 5: "pick_cool_then_place_in_recep",
+ 6: "pick_two_obj_and_place",
+}
+TASK_TYPE_IDS = {value: key for key, value in TASK_TYPE_NAMES.items()}
+TASK_INTERFACE_HISTORY_MAX_STEPS = 12
+
+
+class TaskInterface:
+ """LLM-driven task interpretation with deterministic validity boundaries."""
+
+ def __init__(
+ self,
+ llm: StructuredLLMProtocol,
+ episode_progress_enabled: bool = True,
+ ):
+ self.llm = llm
+ self.episode_progress_enabled = episode_progress_enabled
+
+ @staticmethod
+ def _string_list(value: Any, limit: int = 30) -> List[str]:
+ if not isinstance(value, list):
+ return []
+ return [
+ str(item).strip()
+ for item in value[:limit]
+ if isinstance(item, str) and str(item).strip()
+ ]
+
+ @classmethod
+ def _normalize_progress(cls, value: Any) -> Dict[str, Any]:
+ raw = value if isinstance(value, dict) else {}
+
+ def optional_count(name: str) -> Optional[int]:
+ count = raw.get(name)
+ if isinstance(count, bool) or not isinstance(count, int) or count < 0:
+ return None
+ return count
+
+ return {
+ "inventory": cls._string_list(raw.get("inventory")),
+ "completed_subgoals": cls._string_list(raw.get("completed_subgoals")),
+ "pending_subgoals": cls._string_list(raw.get("pending_subgoals")),
+ "current_subgoal": raw.get("current_subgoal", "").strip()
+ if isinstance(raw.get("current_subgoal", ""), str)
+ else "",
+ "visited_locations": cls._string_list(raw.get("visited_locations")),
+ "failed_or_unproductive_actions": cls._string_list(
+ raw.get("failed_or_unproductive_actions")
+ ),
+ "required_count": optional_count("required_count"),
+ "completed_count": optional_count("completed_count"),
+ "loop_risk": raw.get("loop_risk")
+ if isinstance(raw.get("loop_risk"), bool)
+ else False,
+ "uncertainties": cls._string_list(raw.get("uncertainties")),
+ }
+
+ @staticmethod
+ def _goal_from_initial_observation(initial_observation: str) -> str:
+ match = re.search(
+ r"your\s+task\s+is\s+to\s*:\s*(.+?)(?:\r?\n|$)",
+ initial_observation,
+ flags=re.IGNORECASE,
+ )
+ if not match:
+ return ""
+ return " ".join(match.group(1).strip().split())
+
+ @staticmethod
+ def _task_type_from_goal(goal: str) -> str:
+ """Infer an ALFWorld task type from goal text without dataset metadata."""
+ normalized = " ".join(goal.lower().split())
+ if not normalized:
+ return "unknown"
+ if re.search(r"\b(?:look at|examine)\b.*\b(?:light|\w*lamp)\b", normalized):
+ return "look_at_obj_in_light"
+ if re.search(r"\b(?:clean|wash)\b", normalized):
+ return "pick_clean_then_place_in_recep"
+ if re.search(r"\b(?:heat|warm)\b", normalized):
+ return "pick_heat_then_place_in_recep"
+ if re.search(r"\b(?:cool|chill)\b", normalized):
+ return "pick_cool_then_place_in_recep"
+ if re.search(
+ r"\b(?:two|2)\b.*\b(?:put|place|move|bring)\b"
+ r"|\b(?:put|place|move|bring)\b.*\b(?:two|2)\b",
+ normalized,
+ ):
+ return "pick_two_obj_and_place"
+ if re.search(r"\b(?:put|place|move|bring)\b", normalized):
+ return "pick_and_place_simple"
+ return "unknown"
+
+ @staticmethod
+ def _grounded_recent_history(
+ episode_history: List[Dict[str, Any]],
+ ) -> List[Dict[str, Any]]:
+ """Keep only recent environment-grounded transitions for state tracking."""
+ return [
+ {
+ key: item.get(key)
+ for key in (
+ "step",
+ "observation",
+ "action",
+ "next_observation",
+ "reward",
+ )
+ if key in item
+ }
+ for item in episode_history[-TASK_INTERFACE_HISTORY_MAX_STEPS:]
+ ]
+
+ def structure(
+ self,
+ interaction_id: str,
+ observation: str,
+ initial_observation: str,
+ admissible_commands: List[str],
+ game_file: str,
+ step: int,
+ max_steps: int,
+ known_goal: Optional[str] = None,
+ known_task_type: Optional[str] = None,
+ task_type_hint: Optional[str] = None,
+ episode_history: Optional[List[Dict[str, Any]]] = None,
+ known_progress: Optional[Dict[str, Any]] = None,
+ ) -> TaskEvidence:
+ commands = sorted(set(admissible_commands))
+ history = list(episode_history or [])
+ progress_history = self._grounded_recent_history(history)
+ continuing_episode = known_goal is not None
+ if self.episode_progress_enabled:
+ progress_instruction = (
+ "Return progress as a JSON object with inventory, completed_subgoals, "
+ "pending_subgoals, current_subgoal, visited_locations, "
+ "failed_or_unproductive_actions, required_count, completed_count, "
+ "loop_risk, and uncertainties. Infer progress only from grounded episode "
+ "evidence; use empty lists/strings or null counts when unknown. "
+ )
+ progress_key = ", progress"
+ else:
+ progress_instruction = "Do not generate an episode progress object. "
+ progress_key = ""
+ if continuing_episode:
+ system_prompt = (
+ "You are the Task Interface state tracker of a continual-learning "
+ "ALFWorld harness. The task was parsed at the beginning of this episode. "
+ "Do not reinterpret, replace, or restate the established goal or task type. "
+ "Update only the grounded current evidence, task-specific constraints, and "
+ "episode progress using the latest observation and the chronological "
+ "recent_episode_history. Evidence must distinguish observed facts from "
+ "uncertainty. The supplied prior_progress summarizes older transitions. "
+ "Use prior actions and their resulting observations to identify completed "
+ "subgoals, invalid actions, and loops. "
+ f"{progress_instruction}Keep every string and list concise. Return exactly one "
+ f"JSON object with evidence, constraints{progress_key}, rationale only."
+ )
+ llm_payload = {
+ "established_task": {
+ "goal": known_goal,
+ "task_type": known_task_type,
+ },
+ "current_observation": observation,
+ "admissible_commands": commands,
+ "step": step,
+ "max_steps": max_steps,
+ "prior_progress": known_progress or {},
+ "omitted_history_steps": max(0, len(history) - len(progress_history)),
+ "recent_episode_history": progress_history,
+ }
+ interface_mode = "progress_update"
+ else:
+ system_prompt = (
+ "You are the Task Interface of a continual-learning ALFWorld harness. "
+ "Parse the task once from the initial environment-and-goal description. "
+ "Return JSON with task_type, goal, evidence, constraints"
+ f"{progress_key}, rationale. task_type must be one of "
+ f"{list(TASK_TYPE_IDS)}. Evidence must summarize only facts supported by "
+ "the initial/current observation and useful history; do not claim hidden "
+ "facts. Constraints must be concise and task-specific. "
+ f"{progress_instruction}Use episode_history only as grounded interaction "
+ "evidence. Do not use task-type-specific canned plans. The metadata hint is "
+ "supporting evidence, not a substitute for interpreting the task. Return "
+ "one concise JSON object only."
+ )
+ llm_payload = {
+ "initial_observation": initial_observation,
+ "current_observation": observation,
+ "admissible_commands": commands,
+ "step": step,
+ "max_steps": max_steps,
+ "task_type_metadata_hint": task_type_hint,
+ "episode_history": progress_history,
+ }
+ interface_mode = "initial_parse"
+ fallback_error = ""
+ try:
+ result = self.llm.complete_json(
+ "task_interface",
+ system_prompt,
+ llm_payload,
+ )
+ except RuntimeError as exc:
+ message = str(exc)
+ if not message.startswith("task_interface did not return valid JSON:"):
+ raise
+ result = {}
+ fallback_error = message
+ proposed_goal = " ".join(str(result.get("goal", "")).split())
+ extracted_goal = self._goal_from_initial_observation(initial_observation)
+ proposed_type = str(result.get("task_type", ""))
+ if fallback_error:
+ task_type = (
+ known_task_type
+ or self._task_type_from_goal(extracted_goal)
+ or "unknown"
+ )
+ else:
+ task_type = (
+ known_task_type
+ or (proposed_type if proposed_type in TASK_TYPE_IDS else None)
+ or (task_type_hint if task_type_hint in TASK_TYPE_IDS else None)
+ or "unknown"
+ )
+ goal = (
+ known_goal
+ or proposed_goal
+ or extracted_goal
+ or "Complete the task described in the initial environment observation."
+ )
+ evidence = str(result.get("evidence", "")).strip()
+ if not evidence:
+ evidence = f"Goal: {goal}\nCurrent observation: {observation}"
+
+ model_constraints = result.get("constraints", [])
+ constraints = [
+ str(item).strip()
+ for item in model_constraints
+ if isinstance(item, str) and str(item).strip()
+ ]
+ hard_constraints = [
+ f"Choose exactly one of the {len(commands)} admissible commands.",
+ f"Finish within {max_steps} environment steps.",
+ "Never invent an action or object identifier absent from admissible_commands.",
+ ]
+ constraints = list(dict.fromkeys(constraints + hard_constraints))
+ if self.episode_progress_enabled:
+ progress_source = (
+ known_progress
+ if fallback_error and isinstance(known_progress, dict)
+ else result.get("progress")
+ )
+ progress = self._normalize_progress(progress_source)
+ else:
+ progress = {}
+ if fallback_error:
+ interface_mode = f"{interface_mode}_json_fallback"
+ return TaskEvidence(
+ interaction_id=interaction_id,
+ task_type=task_type,
+ evidence=evidence,
+ goal=goal,
+ constraints=constraints,
+ admissible_commands=commands,
+ game_file=game_file,
+ initial_observation=initial_observation,
+ step=step,
+ episode_history=history,
+ progress=progress,
+ interface_trace={
+ "component": "llm_task_interface",
+ "mode": interface_mode,
+ "rationale": str(result.get("rationale", "")),
+ "proposed_task_type": proposed_type,
+ "fallback": bool(fallback_error),
+ "fallback_error": fallback_error,
+ "goal_source": (
+ "established"
+ if known_goal
+ else "model"
+ if proposed_goal
+ else "initial_observation"
+ if extracted_goal
+ else "generic_fallback"
+ ),
+ },
+ )
diff --git a/experiments/HCL_ALFworld/src/hcl_alfworld/text.py b/experiments/HCL_ALFworld/src/hcl_alfworld/text.py
new file mode 100644
index 0000000000000000000000000000000000000000..231a3574b63f43bebcc4e4c4cf4592aac92a9eff
--- /dev/null
+++ b/experiments/HCL_ALFworld/src/hcl_alfworld/text.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+import re
+from collections import Counter
+from typing import Iterable, List
+
+
+STOPWORDS = {
+ "a",
+ "an",
+ "and",
+ "are",
+ "at",
+ "be",
+ "in",
+ "is",
+ "it",
+ "of",
+ "on",
+ "the",
+ "then",
+ "to",
+ "with",
+ "you",
+ "your",
+ "this",
+ "that",
+ "there",
+ "from",
+ "into",
+}
+
+
+def tokenize(text: str) -> List[str]:
+ return [x for x in re.findall(r"[a-z0-9]+", text.lower()) if x not in STOPWORDS]
+
+
+def overlap_score(query: str, document: str) -> float:
+ q, d = Counter(tokenize(query)), Counter(tokenize(document))
+ if not q or not d:
+ return 0.0
+ common = sum(min(q[token], d[token]) for token in q)
+ return common / max(1.0, (sum(q.values()) * sum(d.values())) ** 0.5)
+
+
+def unique(items: Iterable[str]) -> List[str]:
+ return list(dict.fromkeys(items))
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/func.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/func.json
new file mode 100644
index 0000000000000000000000000000000000000000..4792d60f6083e96361dd54cbd0edd975befb4b1e
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/func.json
@@ -0,0 +1,790 @@
+{
+ "acacia_boat": "craft",
+ "acacia_button": "craft",
+ "acacia_chest_boat": "craft",
+ "acacia_door": "craft",
+ "acacia_fence": "craft",
+ "acacia_fence_gate": "craft",
+ "acacia_planks": "craft",
+ "acacia_pressure_plate": "craft",
+ "acacia_sign": "craft",
+ "acacia_slab": "craft",
+ "acacia_trapdoor": "craft",
+ "acacia_wood": "craft",
+ "activator_rail": "craft",
+ "amethyst_block": "craft",
+ "andesite_slab": "craft",
+ "andesite_wall": "craft",
+ "anvil": "craft",
+ "armor_stand": "craft",
+ "arrow": "craft",
+ "barrel": "craft",
+ "beacon": "craft",
+ "beehive": "craft",
+ "beetroot_soup": "craft",
+ "birch_boat": "craft",
+ "birch_button": "craft",
+ "birch_chest_boat": "craft",
+ "birch_door": "craft",
+ "birch_fence": "craft",
+ "birch_fence_gate": "craft",
+ "birch_planks": "craft",
+ "birch_pressure_plate": "craft",
+ "birch_sign": "craft",
+ "birch_slab": "craft",
+ "birch_trapdoor": "craft",
+ "birch_wood": "craft",
+ "black_banner": "craft",
+ "black_bed": "craft",
+ "black_candle": "craft",
+ "black_carpet": "craft",
+ "black_concrete_powder": "craft",
+ "black_dye": "craft",
+ "black_stained_glass": "craft",
+ "black_stained_glass_pane": "craft",
+ "black_terracotta": "craft",
+ "black_wool": "craft",
+ "blackstone_slab": "craft",
+ "blackstone_stairs": "craft",
+ "blackstone_wall": "craft",
+ "blast_furnace": "craft",
+ "blaze_powder": "craft",
+ "blue_banner": "craft",
+ "blue_bed": "craft",
+ "blue_candle": "craft",
+ "blue_carpet": "craft",
+ "blue_concrete_powder": "craft",
+ "blue_dye": "craft",
+ "blue_ice": "craft",
+ "blue_stained_glass": "craft",
+ "blue_stained_glass_pane": "craft",
+ "blue_terracotta": "craft",
+ "blue_wool": "craft",
+ "bone_block": "craft",
+ "bone_meal": "craft",
+ "book": "craft",
+ "bookshelf": "craft",
+ "bow": "craft",
+ "bowl": "craft",
+ "bread": "craft",
+ "brewing_stand": "craft",
+ "brick_slab": "craft",
+ "brick_stairs": "craft",
+ "brick_wall": "craft",
+ "bricks": "craft",
+ "brown_banner": "craft",
+ "brown_bed": "craft",
+ "brown_candle": "craft",
+ "brown_carpet": "craft",
+ "brown_concrete_powder": "craft",
+ "brown_dye": "craft",
+ "brown_stained_glass": "craft",
+ "brown_stained_glass_pane": "craft",
+ "brown_terracotta": "craft",
+ "brown_wool": "craft",
+ "bucket": "craft",
+ "cake": "craft",
+ "campfire": "craft",
+ "candle": "craft",
+ "carrot_on_a_stick": "craft",
+ "cartography_table": "craft",
+ "cauldron": "craft",
+ "chain": "craft",
+ "chest": "craft",
+ "chest_minecart": "craft",
+ "chiseled_deepslate": "craft",
+ "chiseled_nether_bricks": "craft",
+ "chiseled_polished_blackstone": "craft",
+ "chiseled_quartz_block": "craft",
+ "chiseled_red_sandstone": "craft",
+ "chiseled_sandstone": "craft",
+ "chiseled_stone_bricks": "craft",
+ "clay": "craft",
+ "clock": "craft",
+ "coal_block": "craft",
+ "coarse_dirt": "craft",
+ "cobbled_deepslate_slab": "craft",
+ "cobbled_deepslate_stairs": "craft",
+ "cobbled_deepslate_wall": "craft",
+ "cobblestone_slab": "craft",
+ "cobblestone_stairs": "craft",
+ "cobblestone_wall": "craft",
+ "comparator": "craft",
+ "compass": "craft",
+ "composter": "craft",
+ "conduit": "craft",
+ "cookie": "craft",
+ "copper_block": "craft",
+ "crafting_table": "craft",
+ "crimson_button": "craft",
+ "crimson_door": "craft",
+ "crimson_fence": "craft",
+ "crimson_fence_gate": "craft",
+ "crimson_hyphae": "craft",
+ "crimson_planks": "craft",
+ "crimson_pressure_plate": "craft",
+ "crimson_sign": "craft",
+ "crimson_slab": "craft",
+ "crimson_trapdoor": "craft",
+ "crossbow": "craft",
+ "cut_copper": "craft",
+ "cut_copper_slab": "craft",
+ "cut_copper_stairs": "craft",
+ "cut_red_sandstone": "craft",
+ "cut_red_sandstone_slab": "craft",
+ "cut_sandstone": "craft",
+ "cut_sandstone_slab": "craft",
+ "cyan_banner": "craft",
+ "cyan_bed": "craft",
+ "cyan_candle": "craft",
+ "cyan_carpet": "craft",
+ "cyan_concrete_powder": "craft",
+ "cyan_dye": "craft",
+ "cyan_stained_glass": "craft",
+ "cyan_stained_glass_pane": "craft",
+ "cyan_terracotta": "craft",
+ "cyan_wool": "craft",
+ "dark_oak_boat": "craft",
+ "dark_oak_button": "craft",
+ "dark_oak_chest_boat": "craft",
+ "dark_oak_door": "craft",
+ "dark_oak_fence": "craft",
+ "dark_oak_fence_gate": "craft",
+ "dark_oak_planks": "craft",
+ "dark_oak_pressure_plate": "craft",
+ "dark_oak_sign": "craft",
+ "dark_oak_slab": "craft",
+ "dark_oak_trapdoor": "craft",
+ "dark_oak_wood": "craft",
+ "dark_prismarine": "craft",
+ "dark_prismarine_slab": "craft",
+ "dark_prismarine_stairs": "craft",
+ "daylight_detector": "craft",
+ "deepslate_brick_slab": "craft",
+ "deepslate_brick_stairs": "craft",
+ "deepslate_brick_wall": "craft",
+ "deepslate_bricks": "craft",
+ "deepslate_tile_slab": "craft",
+ "deepslate_tile_stairs": "craft",
+ "deepslate_tile_wall": "craft",
+ "deepslate_tiles": "craft",
+ "detector_rail": "craft",
+ "diamond_axe": "craft",
+ "diamond_block": "craft",
+ "diamond_boots": "craft",
+ "diamond_chestplate": "craft",
+ "diamond_helmet": "craft",
+ "diamond_hoe": "craft",
+ "diamond_leggings": "craft",
+ "diamond_pickaxe": "craft",
+ "diamond_shovel": "craft",
+ "diamond_sword": "craft",
+ "diorite_slab": "craft",
+ "diorite_stairs": "craft",
+ "diorite_wall": "craft",
+ "dispenser": "craft",
+ "dripstone_block": "craft",
+ "dropper": "craft",
+ "emerald_block": "craft",
+ "enchanting_table": "craft",
+ "end_crystal": "craft",
+ "end_rod": "craft",
+ "end_stone_brick_slab": "craft",
+ "end_stone_brick_stairs": "craft",
+ "end_stone_brick_wall": "craft",
+ "end_stone_bricks": "craft",
+ "ender_chest": "craft",
+ "ender_eye": "craft",
+ "exposed_cut_copper": "craft",
+ "exposed_cut_copper_slab": "craft",
+ "exposed_cut_copper_stairs": "craft",
+ "fermented_spider_eye": "craft",
+ "fire_charge": "craft",
+ "firework_rocket": "craft",
+ "fishing_rod": "craft",
+ "fletching_table": "craft",
+ "flint_and_steel": "craft",
+ "flower_pot": "craft",
+ "furnace": "craft",
+ "furnace_minecart": "craft",
+ "glass_bottle": "craft",
+ "glass_pane": "craft",
+ "glistering_melon_slice": "craft",
+ "glow_item_frame": "craft",
+ "gold_block": "craft",
+ "gold_nugget": "craft",
+ "golden_apple": "craft",
+ "golden_axe": "craft",
+ "golden_boots": "craft",
+ "golden_carrot": "craft",
+ "golden_chestplate": "craft",
+ "golden_helmet": "craft",
+ "golden_hoe": "craft",
+ "golden_leggings": "craft",
+ "golden_pickaxe": "craft",
+ "golden_shovel": "craft",
+ "golden_sword": "craft",
+ "granite_slab": "craft",
+ "granite_stairs": "craft",
+ "granite_wall": "craft",
+ "gray_banner": "craft",
+ "gray_bed": "craft",
+ "gray_candle": "craft",
+ "gray_carpet": "craft",
+ "gray_concrete_powder": "craft",
+ "gray_dye": "craft",
+ "gray_stained_glass": "craft",
+ "gray_stained_glass_pane": "craft",
+ "gray_terracotta": "craft",
+ "gray_wool": "craft",
+ "green_banner": "craft",
+ "green_bed": "craft",
+ "green_candle": "craft",
+ "green_carpet": "craft",
+ "green_concrete_powder": "craft",
+ "green_stained_glass": "craft",
+ "green_stained_glass_pane": "craft",
+ "green_terracotta": "craft",
+ "green_wool": "craft",
+ "grindstone": "craft",
+ "hay_block": "craft",
+ "heavy_weighted_pressure_plate": "craft",
+ "honey_block": "craft",
+ "honey_bottle": "craft",
+ "honeycomb_block": "craft",
+ "hopper": "craft",
+ "hopper_minecart": "craft",
+ "iron_axe": "craft",
+ "iron_bars": "craft",
+ "iron_block": "craft",
+ "iron_boots": "craft",
+ "iron_chestplate": "craft",
+ "iron_door": "craft",
+ "iron_helmet": "craft",
+ "iron_hoe": "craft",
+ "iron_leggings": "craft",
+ "iron_nugget": "craft",
+ "iron_pickaxe": "craft",
+ "iron_shovel": "craft",
+ "iron_sword": "craft",
+ "iron_trapdoor": "craft",
+ "item_frame": "craft",
+ "jack_o_lantern": "craft",
+ "jukebox": "craft",
+ "jungle_boat": "craft",
+ "jungle_button": "craft",
+ "jungle_chest_boat": "craft",
+ "jungle_door": "craft",
+ "jungle_fence": "craft",
+ "jungle_fence_gate": "craft",
+ "jungle_planks": "craft",
+ "jungle_pressure_plate": "craft",
+ "jungle_sign": "craft",
+ "jungle_slab": "craft",
+ "jungle_trapdoor": "craft",
+ "jungle_wood": "craft",
+ "ladder": "craft",
+ "lantern": "craft",
+ "lapis_block": "craft",
+ "lead": "craft",
+ "leather_boots": "craft",
+ "leather_chestplate": "craft",
+ "leather_helmet": "craft",
+ "leather_horse_armor": "craft",
+ "leather_leggings": "craft",
+ "lectern": "craft",
+ "lever": "craft",
+ "light_blue_banner": "craft",
+ "light_blue_bed": "craft",
+ "light_blue_candle": "craft",
+ "light_blue_carpet": "craft",
+ "light_blue_concrete_powder": "craft",
+ "light_blue_dye": "craft",
+ "light_blue_stained_glass": "craft",
+ "light_blue_stained_glass_pane": "craft",
+ "light_blue_terracotta": "craft",
+ "light_blue_wool": "craft",
+ "light_gray_banner": "craft",
+ "light_gray_bed": "craft",
+ "light_gray_candle": "craft",
+ "light_gray_carpet": "craft",
+ "light_gray_concrete_powder": "craft",
+ "light_gray_dye": "craft",
+ "light_gray_stained_glass": "craft",
+ "light_gray_stained_glass_pane": "craft",
+ "light_gray_terracotta": "craft",
+ "light_gray_wool": "craft",
+ "light_weighted_pressure_plate": "craft",
+ "lightning_rod": "craft",
+ "lime_banner": "craft",
+ "lime_bed": "craft",
+ "lime_candle": "craft",
+ "lime_carpet": "craft",
+ "lime_concrete_powder": "craft",
+ "lime_dye": "craft",
+ "lime_stained_glass": "craft",
+ "lime_stained_glass_pane": "craft",
+ "lime_terracotta": "craft",
+ "lime_wool": "craft",
+ "lodestone": "craft",
+ "loom": "craft",
+ "magenta_banner": "craft",
+ "magenta_bed": "craft",
+ "magenta_candle": "craft",
+ "magenta_carpet": "craft",
+ "magenta_concrete_powder": "craft",
+ "magenta_dye": "craft",
+ "magenta_stained_glass": "craft",
+ "magenta_stained_glass_pane": "craft",
+ "magenta_terracotta": "craft",
+ "magenta_wool": "craft",
+ "magma_block": "craft",
+ "magma_cream": "craft",
+ "mangrove_boat": "craft",
+ "mangrove_button": "craft",
+ "mangrove_chest_boat": "craft",
+ "mangrove_door": "craft",
+ "mangrove_fence": "craft",
+ "mangrove_fence_gate": "craft",
+ "mangrove_planks": "craft",
+ "mangrove_pressure_plate": "craft",
+ "mangrove_sign": "craft",
+ "mangrove_slab": "craft",
+ "mangrove_trapdoor": "craft",
+ "mangrove_wood": "craft",
+ "map": "craft",
+ "melon": "craft",
+ "melon_seeds": "craft",
+ "minecart": "craft",
+ "mossy_cobblestone": "craft",
+ "mossy_cobblestone_slab": "craft",
+ "mossy_cobblestone_stairs": "craft",
+ "mossy_cobblestone_wall": "craft",
+ "mossy_stone_brick_slab": "craft",
+ "mossy_stone_brick_stairs": "craft",
+ "mossy_stone_bricks": "craft",
+ "mud_brick_slab": "craft",
+ "mud_brick_stairs": "craft",
+ "mud_brick_wall": "craft",
+ "mud_bricks": "craft",
+ "muddy_mangrove_roots": "craft",
+ "mushroom_stew": "craft",
+ "nether_brick_fence": "craft",
+ "nether_brick_slab": "craft",
+ "nether_brick_stairs": "craft",
+ "nether_brick_wall": "craft",
+ "nether_bricks": "craft",
+ "nether_wart_block": "craft",
+ "netherite_block": "craft",
+ "note_block": "craft",
+ "oak_boat": "craft",
+ "oak_button": "craft",
+ "oak_chest_boat": "craft",
+ "oak_door": "craft",
+ "oak_fence": "craft",
+ "oak_fence_gate": "craft",
+ "oak_planks": "craft",
+ "oak_pressure_plate": "craft",
+ "oak_sign": "craft",
+ "oak_slab": "craft",
+ "oak_trapdoor": "craft",
+ "oak_wood": "craft",
+ "observer": "craft",
+ "orange_banner": "craft",
+ "orange_bed": "craft",
+ "orange_candle": "craft",
+ "orange_carpet": "craft",
+ "orange_concrete_powder": "craft",
+ "orange_dye": "craft",
+ "orange_stained_glass": "craft",
+ "orange_stained_glass_pane": "craft",
+ "orange_terracotta": "craft",
+ "orange_wool": "craft",
+ "oxidized_cut_copper": "craft",
+ "oxidized_cut_copper_slab": "craft",
+ "oxidized_cut_copper_stairs": "craft",
+ "packed_ice": "craft",
+ "packed_mud": "craft",
+ "painting": "craft",
+ "pink_banner": "craft",
+ "pink_bed": "craft",
+ "pink_candle": "craft",
+ "pink_carpet": "craft",
+ "pink_concrete_powder": "craft",
+ "pink_dye": "craft",
+ "pink_stained_glass": "craft",
+ "pink_stained_glass_pane": "craft",
+ "pink_terracotta": "craft",
+ "pink_wool": "craft",
+ "piston": "craft",
+ "plank": "craft",
+ "polished_andesite": "craft",
+ "polished_andesite_slab": "craft",
+ "polished_andesite_stairs": "craft",
+ "polished_basalt": "craft",
+ "polished_blackstone": "craft",
+ "polished_blackstone_brick_slab": "craft",
+ "polished_blackstone_brick_stairs": "craft",
+ "polished_blackstone_brick_wall": "craft",
+ "polished_blackstone_bricks": "craft",
+ "polished_blackstone_button": "craft",
+ "polished_blackstone_pressure_plate": "craft",
+ "polished_blackstone_slab": "craft",
+ "polished_blackstone_wall": "craft",
+ "polished_deepslate": "craft",
+ "polished_deepslate_slab": "craft",
+ "polished_deepslate_stairs": "craft",
+ "polished_deepslate_wall": "craft",
+ "polished_diorite": "craft",
+ "polished_diorite_slab": "craft",
+ "polished_diorite_stairs": "craft",
+ "polished_granite": "craft",
+ "polished_granite_slab": "craft",
+ "polished_granite_stairs": "craft",
+ "powered_rail": "craft",
+ "prismarine_brick_slab": "craft",
+ "prismarine_brick_stairs": "craft",
+ "prismarine_bricks": "craft",
+ "prismarine_slab": "craft",
+ "prismarine_stairs": "craft",
+ "prismarine_wall": "craft",
+ "pumpkin_pie": "craft",
+ "pumpkin_seeds": "craft",
+ "purple_banner": "craft",
+ "purple_bed": "craft",
+ "purple_candle": "craft",
+ "purple_carpet": "craft",
+ "purple_concrete_powder": "craft",
+ "purple_dye": "craft",
+ "purple_stained_glass": "craft",
+ "purple_stained_glass_pane": "craft",
+ "purple_terracotta": "craft",
+ "purple_wool": "craft",
+ "purpur_block": "craft",
+ "purpur_pillar": "craft",
+ "purpur_slab": "craft",
+ "quartz_block": "craft",
+ "quartz_pillar": "craft",
+ "quartz_slab": "craft",
+ "quartz_stairs": "craft",
+ "rabbit_stew": "craft",
+ "rail": "craft",
+ "raw_copper_block": "craft",
+ "raw_gold_block": "craft",
+ "raw_iron_block": "craft",
+ "recovery_compass": "craft",
+ "red_banner": "craft",
+ "red_bed": "craft",
+ "red_candle": "craft",
+ "red_carpet": "craft",
+ "red_concrete_powder": "craft",
+ "red_dye": "craft",
+ "red_nether_brick_slab": "craft",
+ "red_nether_brick_stairs": "craft",
+ "red_nether_brick_wall": "craft",
+ "red_nether_bricks": "craft",
+ "red_sandstone_slab": "craft",
+ "red_sandstone_stairs": "craft",
+ "red_sandstone_wall": "craft",
+ "red_stained_glass": "craft",
+ "red_stained_glass_pane": "craft",
+ "red_terracotta": "craft",
+ "red_wool": "craft",
+ "redstone_block": "craft",
+ "redstone_lamp": "craft",
+ "redstone_torch": "craft",
+ "repeater": "craft",
+ "respawn_anchor": "craft",
+ "sandstone_slab": "craft",
+ "sandstone_stairs": "craft",
+ "sandstone_wall": "craft",
+ "scaffolding": "craft",
+ "sea_lantern": "craft",
+ "shears": "craft",
+ "shield": "craft",
+ "shulker_box": "craft",
+ "slime_block": "craft",
+ "smithing_table": "craft",
+ "smoker": "craft",
+ "smooth_quartz_slab": "craft",
+ "smooth_quartz_stairs": "craft",
+ "smooth_red_sandstone_slab": "craft",
+ "smooth_red_sandstone_stairs": "craft",
+ "smooth_sandstone_slab": "craft",
+ "smooth_sandstone_stairs": "craft",
+ "smooth_stone_slab": "craft",
+ "snow": "craft",
+ "snow_block": "craft",
+ "soul_campfire": "craft",
+ "soul_lantern": "craft",
+ "soul_torch": "craft",
+ "spectral_arrow": "craft",
+ "spruce_boat": "craft",
+ "spruce_button": "craft",
+ "spruce_chest_boat": "craft",
+ "spruce_door": "craft",
+ "spruce_fence": "craft",
+ "spruce_fence_gate": "craft",
+ "spruce_planks": "craft",
+ "spruce_pressure_plate": "craft",
+ "spruce_sign": "craft",
+ "spruce_slab": "craft",
+ "spruce_trapdoor": "craft",
+ "spruce_wood": "craft",
+ "spyglass": "craft",
+ "stick": "craft",
+ "sticky_piston": "craft",
+ "stone_axe": "craft",
+ "stone_brick_slab": "craft",
+ "stone_brick_stairs": "craft",
+ "stone_brick_wall": "craft",
+ "stone_bricks": "craft",
+ "stone_hoe": "craft",
+ "stone_pickaxe": "craft",
+ "stone_pressure_plate": "craft",
+ "stone_shovel": "craft",
+ "stone_slab": "craft",
+ "stone_stairs": "craft",
+ "stone_sword": "craft",
+ "stonecutter": "craft",
+ "stripped_acacia_wood": "craft",
+ "stripped_birch_wood": "craft",
+ "stripped_crimson_hyphae": "craft",
+ "stripped_dark_oak_wood": "craft",
+ "stripped_jungle_wood": "craft",
+ "stripped_mangrove_wood": "craft",
+ "stripped_oak_wood": "craft",
+ "stripped_spruce_wood": "craft",
+ "stripped_warped_hyphae": "craft",
+ "stripped_wood": "craft",
+ "sugar": "craft",
+ "target": "craft",
+ "tinted_glass": "craft",
+ "tnt": "craft",
+ "tnt_minecart": "craft",
+ "torch": "craft",
+ "trapped_chest": "craft",
+ "tripwire_hook": "craft",
+ "turtle_helmet": "craft",
+ "warped_button": "craft",
+ "warped_door": "craft",
+ "warped_fence": "craft",
+ "warped_fence_gate": "craft",
+ "warped_fungus_on_a_stick": "craft",
+ "warped_hyphae": "craft",
+ "warped_planks": "craft",
+ "warped_pressure_plate": "craft",
+ "warped_sign": "craft",
+ "warped_slab": "craft",
+ "warped_trapdoor": "craft",
+ "waxed_copper_block": "craft",
+ "waxed_cut_copper": "craft",
+ "waxed_cut_copper_slab": "craft",
+ "waxed_cut_copper_stairs": "craft",
+ "waxed_exposed_cut_copper": "craft",
+ "waxed_exposed_cut_copper_slab": "craft",
+ "waxed_exposed_cut_copper_stairs": "craft",
+ "waxed_oxidized_copper": "craft",
+ "waxed_oxidized_cut_copper": "craft",
+ "waxed_oxidized_cut_copper_slab": "craft",
+ "waxed_oxidized_cut_copper_stairs": "craft",
+ "waxed_weathered_copper": "craft",
+ "waxed_weathered_cut_copper": "craft",
+ "waxed_weathered_cut_copper_slab": "craft",
+ "waxed_weathered_cut_copper_stairs": "craft",
+ "weathered_cut_copper": "craft",
+ "weathered_cut_copper_slab": "craft",
+ "weathered_cut_copper_stairs": "craft",
+ "white_banner": "craft",
+ "white_bed": "craft",
+ "white_candle": "craft",
+ "white_carpet": "craft",
+ "white_concrete_powder": "craft",
+ "white_dye": "craft",
+ "white_stained_glass": "craft",
+ "white_stained_glass_pane": "craft",
+ "white_terracotta": "craft",
+ "white_wool": "craft",
+ "wood": "craft",
+ "wooden_axe": "craft",
+ "wooden_hoe": "craft",
+ "wooden_pickaxe": "craft",
+ "wooden_shovel": "craft",
+ "wooden_sword": "craft",
+ "writable_book": "craft",
+ "yellow_banner": "craft",
+ "yellow_bed": "craft",
+ "yellow_candle": "craft",
+ "yellow_carpet": "craft",
+ "yellow_concrete_powder": "craft",
+ "yellow_dye": "craft",
+ "yellow_stained_glass": "craft",
+ "yellow_stained_glass_pane": "craft",
+ "yellow_terracotta": "craft",
+ "yellow_wool": "craft",
+ "cod": "kill",
+ "salmon": "kill",
+ "beef": "kill",
+ "blaze_rod": "kill",
+ "bone": "kill",
+ "chicken": "kill",
+ "ender_pearl": "kill",
+ "feather": "kill",
+ "glow_ink_sac": "kill",
+ "gunpowder": "kill",
+ "ink_sac": "kill",
+ "leather": "kill",
+ "mutton": "kill",
+ "nether_star": "kill",
+ "phantom_membrane": "kill",
+ "porkchop": "kill",
+ "rabbit": "kill",
+ "rabbit_hide": "kill",
+ "rotten_flesh": "kill",
+ "scute": "kill",
+ "shulker_shell": "kill",
+ "slime_ball": "kill",
+ "spider_eye": "kill",
+ "string": "kill",
+ "acacia_leaves": "mine",
+ "acacia_log": "mine",
+ "allium": "mine",
+ "ancient_debris": "mine",
+ "andesite": "mine",
+ "azalea_leaves": "mine",
+ "azure_bluet": "mine",
+ "bamboo": "mine",
+ "basalt": "mine",
+ "birch_leaves": "mine",
+ "birch_log": "mine",
+ "blackstone": "mine",
+ "blue_orchid": "mine",
+ "brown_mushroom": "mine",
+ "cactus": "mine",
+ "calcite": "mine",
+ "chorus_fruit": "mine",
+ "clay_ball": "mine",
+ "coal": "mine",
+ "cobblestone": "mine",
+ "cocoa": "mine",
+ "cornflower": "mine",
+ "crimson_fungus": "mine",
+ "crimson_stem": "mine",
+ "crying_obsidian": "mine",
+ "dandelion": "mine",
+ "dark_oak_leaves": "mine",
+ "dark_oak_log": "mine",
+ "diamond": "mine",
+ "diorite": "mine",
+ "dirt": "mine",
+ "emerald": "mine",
+ "end_stone": "mine",
+ "fern": "mine",
+ "flint": "mine",
+ "flowering_azalea_leaves": "mine",
+ "glowstone": "mine",
+ "granite": "mine",
+ "grass": "mine",
+ "gravel": "mine",
+ "jungle_leaves": "mine",
+ "jungle_log": "mine",
+ "kelp": "mine",
+ "lapis_lazuli": "mine",
+ "lilac": "mine",
+ "lily_of_the_valley": "mine",
+ "log": "mine",
+ "mangrove_leaves": "mine",
+ "mangrove_log": "mine",
+ "moss_block": "mine",
+ "mud": "mine",
+ "nether_quartz_ore": "mine",
+ "netherrack": "mine",
+ "oak_leaves": "mine",
+ "oak_log": "mine",
+ "obsidian": "mine",
+ "orange_tulip": "mine",
+ "oxeye_daisy": "mine",
+ "peony": "mine",
+ "pink_tulip": "mine",
+ "pointed_dripstone": "mine",
+ "poppy": "mine",
+ "prismarine": "mine",
+ "pumpkin": "mine",
+ "raw_copper": "mine",
+ "raw_gold": "mine",
+ "raw_iron": "mine",
+ "red_mushroom": "mine",
+ "red_sand": "mine",
+ "red_sandstone": "mine",
+ "red_tulip": "mine",
+ "redstone": "mine",
+ "rose_bush": "mine",
+ "sand": "mine",
+ "sandstone": "mine",
+ "shroomlight": "mine",
+ "snowball": "mine",
+ "soul_sand": "mine",
+ "soul_soil": "mine",
+ "spore_blossom": "mine",
+ "spruce_leaves": "mine",
+ "spruce_log": "mine",
+ "sugar_cane": "mine",
+ "sunflower": "mine",
+ "tuff": "mine",
+ "vine": "mine",
+ "warped_fungus": "mine",
+ "warped_stem": "mine",
+ "wheat_seeds": "mine",
+ "white_tulip": "mine",
+ "wither_rose": "mine",
+ "baked_potato": "smelt",
+ "black_glazed_terracotta": "smelt",
+ "blue_glazed_terracotta": "smelt",
+ "brick": "smelt",
+ "brown_glazed_terracotta": "smelt",
+ "charcoal": "smelt",
+ "cooked_beef": "smelt",
+ "cooked_chicken": "smelt",
+ "cooked_cod": "smelt",
+ "cooked_mutton": "smelt",
+ "cooked_porkchop": "smelt",
+ "cooked_rabbit": "smelt",
+ "cooked_salmon": "smelt",
+ "copper_ingot": "smelt",
+ "cracked_deepslate_bricks": "smelt",
+ "cracked_deepslate_tiles": "smelt",
+ "cracked_nether_bricks": "smelt",
+ "cracked_polished_blackstone_bricks": "smelt",
+ "cracked_stone_bricks": "smelt",
+ "cyan_glazed_terracotta": "smelt",
+ "deepslate": "smelt",
+ "dried_kelp": "smelt",
+ "glass": "smelt",
+ "gold_ingot": "smelt",
+ "gray_glazed_terracotta": "smelt",
+ "green_dye": "smelt",
+ "green_glazed_terracotta": "smelt",
+ "iron_ingot": "smelt",
+ "light_blue_glazed_terracotta": "smelt",
+ "light_gray_glazed_terracotta": "smelt",
+ "lime_glazed_terracotta": "smelt",
+ "magenta_glazed_terracotta": "smelt",
+ "nether_brick": "smelt",
+ "netherite_scrap": "smelt",
+ "orange_glazed_terracotta": "smelt",
+ "pink_glazed_terracotta": "smelt",
+ "popped_chorus_fruit": "smelt",
+ "purple_glazed_terracotta": "smelt",
+ "quartz": "smelt",
+ "red_glazed_terracotta": "smelt",
+ "smooth_basalt": "smelt",
+ "smooth_quartz": "smelt",
+ "smooth_red_sandstone": "smelt",
+ "smooth_sandstone": "smelt",
+ "smooth_stone": "smelt",
+ "sponge": "smelt",
+ "stone": "smelt",
+ "terracotta": "smelt",
+ "white_glazed_terracotta": "smelt",
+ "yellow_glazed_terracotta": "smelt"
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/map_name.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/map_name.json
new file mode 100644
index 0000000000000000000000000000000000000000..d6ee8d85ab23292d41548be5f0ca158577cd5fe4
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/map_name.json
@@ -0,0 +1,25 @@
+{
+ "emerald":["emerald_ore", "deepslate_emerald_ore"],
+ "diamond":["diamond_ore", "deepslate_diamond_ore"],
+ "redstone":["redstone_ore", "deepslate_redstone_ore"],
+ "raw_gold":["gold_ore", "deepslate_gold_ore"],
+ "raw_iron":["iron_ore", "deepslate_iron_ore"],
+ "raw_copper":["copper_ore", "deepslate_copper_ore"],
+ "lapis_lazuli":["lapis_lazuli_ore", "deepslate_lapis_lazuli_ore"],
+ "coal":["coal_ore", "deepslate_coal_ore"],
+ "quartz":["nether_quartz_ore"],
+ "cobblestone":["stone"],
+ "cobbled_deepslate":["deepslate"],
+ "dirt":["dirt", "grass_block"],
+ "melon_slice":["melon"],
+ "snowball": ["snow_block"],
+ "clay_ball": ["clay"],
+ "wool": ["white_wool", "orange_wool", "magenta_wool", "light_blue_wool", "yellow_wool", "lime_wool", "pink_wool", "gray_wool",
+ "light_gray_wool", "cyan_wool", "purple_wool", "blue_wool", "brown_wool", "green_wool", "red_wool", "black_wool"],
+ "wood": ["oak_wood", "birch_wood", "spruce_wood", "jungle_wood", "acacia_wood", "dark_oak_wood", "mangrove_wood", "crimson_hyphae", "warped_hyphae"],
+ "stripped_log": ["stripped_oak_log", "stripped_birch_log", "stripped_spruce_log", "stripped_jungle_log", "stripped_acacia_log", "stripped_dark_oak_log", "stripped_mangrove_log", "stripped_crimson_stem", "stripped_warped_stem"],
+ "stripped_wood": ["stripped_oak_wood", "stripped_birch_wood", "stripped_spruce_wood", "stripped_jungle_wood", "stripped_acacia_wood", "stripped_dark_oak_wood", "stripped_mangrove_wood", "stripped_crimson_hyphae", "stripped_warped_hyphae"],
+ "plank":["oak_planks", "birch_planks", "spruce_planks", "jungle_planks", "acacia_planks", "dark_oak_planks", "mangrove_planks", "crimson_planks", "warped_planks"],
+ "plank_slab":["oak_slab", "birch_slab", "spruce_slab", "jungle_slab", "acacia_slab", "dark_oak_slab", "mangrove_slab", "crimson_slab", "warped_slab"],
+ "log":["oak_log", "birch_log", "spruce_log", "jungle_log", "acacia_log", "dark_oak_log", "mangrove_log", "crimson_stem", "warped_stem"]
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_collect.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_collect.json
new file mode 100644
index 0000000000000000000000000000000000000000..54da098a9ed0854ee558f0d4f9b5ab5152d9c489
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_collect.json
@@ -0,0 +1,28 @@
+{
+ "flint": ["gravel"],
+ "wheat_seeds": ["grass"],
+ "bone":["skeleton"],
+ "string":["spider", "cave_spider"],
+ "feather":["chicken"],
+ "leather":["cow"],
+ "rabbit_hide":["rabbit"],
+ "ink_sac":["squid"],
+ "glow_ink_sac":["glow_squid"],
+ "slime_ball":["slime"],
+ "blaze_rod":["blaze"],
+ "nether_star":["wither"],
+ "ender_pearl":["enderman"],
+ "shulker_shell":["shulker"],
+ "gunpowder":["creeper"],
+ "spider_eye":["spider", "cave_spider"],
+ "magma_cream":["magma_cube"],
+ "phantom_membrane":["phantom"],
+ "beef":["cow"],
+ "porkchop":["pig"],
+ "mutton":["sheep"],
+ "chicken":["chicken"],
+ "rabbit":["rabbit"],
+ "rotten_flesh":["zombie"],
+ "cod":["cod"],
+ "salmon":["salmon"]
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_item.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_item.json
new file mode 100644
index 0000000000000000000000000000000000000000..e1b577baae6693e7ab975763c3dda513e950d31e
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_item.json
@@ -0,0 +1,632 @@
+{
+ "furnace": [["8 cobblestone"],1,true],
+ "stick": [["2 plank"],4,false],
+ "crafting_table": [["4 plank"],1,false],
+ "oak_planks": [["1 oak_log"],4,false],
+ "dark_oak_planks": [["1 dark_oak_log"],4,false],
+ "birch_planks": [["1 birch_log"],4,false],
+ "spruce_planks": [["1 spruce_log"],4,false],
+ "jungle_planks": [["1 jungle_log"],4,false],
+ "acacia_planks": [["1 acacia_log"],4,false],
+ "mangrove_planks": [["1 mangrove_log"],4,false],
+ "crimson_planks": [["1 crimson_stem"],4,false],
+ "warped_planks": [["1 warped_stem"],4,false],
+ "plank": [["1 log"],4,false],
+ "oak_wood": [["4 oak_log"], 3, false],
+ "dark_oak_wood": [["4 dark_oak_log"], 3, false],
+ "birch_wood": [["4 birch_log"], 3, false],
+ "spruce_wood": [["4 spruce_log"], 3, false],
+ "jungle_wood": [["4 jungle_log"], 3, false],
+ "acacia_wood": [["4 acacia_log"], 3, false],
+ "mangrove_wood": [["4 mangrove_log"], 3, false],
+ "crimson_hyphae": [["4 crimson_stem"], 3, false],
+ "warped_hyphae": [["4 warped_stem"], 3, false],
+ "wood": [["4 log"], 3, false],
+ "stripped_oak_wood": [["4 stripped_oak_log"], 3, false],
+ "stripped_dark_oak_wood": [["4 stripped_dark_oak_log"], 3, false],
+ "stripped_birch_wood": [["4 stripped_birch_log"], 3, false],
+ "stripped_spruce_wood": [["4 stripped_spruce_log"], 3, false],
+ "stripped_jungle_wood": [["4 stripped_jungle_log"], 3, false],
+ "stripped_acacia_wood": [["4 stripped_acacia_log"], 3, false],
+ "stripped_mangrove_wood": [["4 stripped_mangrove_log"], 3, false],
+ "stripped_crimson_hyphae": [["4 stripped_crimson_stem"], 3, false],
+ "stripped_warped_hyphae": [["4 stripped_warped_stem"], 3, false],
+ "stripped_wood": [["4 stripped_log"], 3, false],
+ "diamond_chestplate": [["8 diamond"],1,true],
+ "diamond_leggings": [["7 diamond"],1,true],
+ "diamond_helmet": [["5 diamond"],1,true],
+ "diamond_boots": [["4 diamond"],1,true],
+ "diamond_sword": [["2 diamond","1 stick"],1,true],
+ "diamond_axe": [["3 diamond","2 stick"],1,true],
+ "diamond_pickaxe": [["3 diamond","2 stick"],1,true],
+ "diamond_shovel": [["1 diamond","2 stick"],1,true],
+ "diamond_hoe": [["2 diamond","2 stick"],1,true],
+ "iron_chestplate": [["8 iron_ingot"],1,true],
+ "iron_leggings": [["7 iron_ingot"],1,true],
+ "iron_helmet": [["5 iron_ingot"],1,true],
+ "iron_boots": [["4 iron_ingot"],1,true],
+ "iron_sword": [["2 iron_ingot","1 stick"],1,true],
+ "iron_axe": [["3 iron_ingot","2 stick"],1,true],
+ "iron_pickaxe": [["3 iron_ingot","2 stick"],1,true],
+ "iron_shovel": [["1 iron_ingot","2 stick"],1,true],
+ "iron_hoe": [["2 iron_ingot","2 stick"],1,true],
+ "golden_chestplate": [["8 gold_ingot"],1,true],
+ "golden_leggings": [["7 gold_ingot"],1,true],
+ "golden_helmet": [["5 gold_ingot"],1,true],
+ "golden_boots": [["4 gold_ingot"],1,true],
+ "golden_sword": [["2 gold_ingot","1 stick"],1,true],
+ "golden_axe": [["3 gold_ingot","2 stick"],1,true],
+ "golden_pickaxe": [["3 gold_ingot","2 stick"],1,true],
+ "golden_shovel": [["1 gold_ingot","2 stick"],1,true],
+ "golden_hoe": [["2 gold_ingot","2 stick"],1,true],
+ "stone_sword": [["2 cobblestone","1 stick"],1,true],
+ "stone_axe": [["3 cobblestone","2 stick"],1,true],
+ "stone_pickaxe": [["3 cobblestone","2 stick"],1,true],
+ "stone_shovel": [["1 cobblestone","2 stick"],1,true],
+ "stone_hoe": [["2 cobblestone","2 stick"],1,true],
+ "wooden_sword": [["2 plank","1 stick"],1,true],
+ "wooden_axe": [["3 plank","2 stick"],1,true],
+ "wooden_pickaxe": [["3 plank","2 stick"],1,true],
+ "wooden_shovel": [["1 plank","2 stick"],1,true],
+ "wooden_hoe": [["2 plank","2 stick"],1,true],
+ "leather_chestplate": [["8 leather"],1,true],
+ "leather_leggings": [["7 leather"],1,true],
+ "leather_helmet": [["5 leather"],1,true],
+ "leather_boots": [["4 leather"],1,true],
+ "lead": [["4 string","1 slime_ball"],2,true],
+ "clock": [["4 gold_ingot","1 redstone"],1,true],
+ "arrow": [["1 flint","1 stick","1 feather"],4,true],
+ "turtle_helmet": [["5 scute"],1,true],
+ "compass": [["4 iron_ingot","1 redstone"],1,true],
+ "flint_and_steel": [["1 iron_ingot","1 flint"],1,true],
+ "fishing_rod": [["3 stick","2 string"],1,true],
+ "crossbow": [["3 stick","2 string","1 iron_ingot","1 tripwire_hook"],1,true],
+ "bow": [["3 stick","3 string"],1,true],
+ "shears": [["2 iron_ingots"],1,false],
+ "spectral_arrow": [["4 glowstone_dust","1 arrow"],2,true],
+ "shield": [["1 iron_ingot","6 plank"],1,true],
+ "spyglass": [["2 copper_ingot","1 amethyst_shard"],1,true],
+ "recovery_compass": [["1 compass","8 echo_shard"],1,true],
+ "oak_door": [["6 oak_planks"],3,true],
+ "dark_oak_door": [["6 dark_oak_planks"],3,true],
+ "birch_door": [["6 birch_planks"],3,true],
+ "spruce_door": [["6 spruce_planks"],3,true],
+ "jungle_door": [["6 jungle_planks"],3,true],
+ "acacia_door": [["6 acacia_planks"],3,true],
+ "mangrove_door": [["6 mangrove_planks"],3,true],
+ "crimson_door": [["6 crimson_planks"],3,true],
+ "warped_door": [["6 warped_planks"],3,true],
+ "oak_button": [["1 oak_planks"],1,false],
+ "dark_oak_button": [["1 dark_oak_planks"],1,false],
+ "birch_button": [["1 birch_planks"],1,false],
+ "spruce_button": [["1 spruce_planks"],1,false],
+ "jungle_button": [["1 jungle_planks"],1,false],
+ "acacia_button": [["1 acacia_planks"],1,false],
+ "mangrove_button": [["1 mangrove_planks"],1,false],
+ "crimson_button": [["1 crimson_planks"],1,false],
+ "warped_button": [["1 warped_planks"],1,false],
+ "oak_fence_gate": [["2 oak_planks","4 stick"],1,true],
+ "dark_oak_fence_gate": [["2 dark_oak_planks","4 stick"],1,true],
+ "birch_fence_gate": [["2 birch_planks","4 stick"],1,true],
+ "spruce_fence_gate": [["2 spruce_planks","4 stick"],1,true],
+ "jungle_fence_gate": [["2 jungle_planks","4 stick"],1,true],
+ "acacia_fence_gate": [["2 acacia_planks","4 stick"],1,true],
+ "mangrove_fence_gate": [["2 mangrove_planks","4 stick"],1,true],
+ "crimson_fence_gate": [["2 crimson_planks","4 stick"],1,true],
+ "warped_fence_gate": [["2 warped_planks","4 stick"],1,true],
+ "oak_pressure_plate": [["2 oak_planks"],1,false],
+ "dark_oak_pressure_plate": [["2 dark_oak_planks"],1,false],
+ "birch_pressure_plate": [["2 birch_planks"],1,false],
+ "spruce_pressure_plate": [["2 spruce_planks"],1,false],
+ "jungle_pressure_plate": [["2 jungle_planks"],1,false],
+ "acacia_pressure_plate": [["2 acacia_planks"],1,false],
+ "mangrove_pressure_plate": [["2 mangrove_planks"],1,false],
+ "crimson_pressure_plate": [["2 crimson_planks"],1,false],
+ "warped_pressure_plate": [["2 warped_planks"],1,false],
+ "oak_slab": [["3 oak_planks"],6,true],
+ "dark_oak_slab": [["3 dark_oak_planks"],6,true],
+ "birch_slab": [["3 birch_planks"],6,true],
+ "spruce_slab": [["3 spruce_planks"],6,true],
+ "jungle_slab": [["3 jungle_planks"],6,true],
+ "acacia_slab": [["3 acacia_planks"],6,true],
+ "mangrove_slab": [["3 mangrove_planks"],6,true],
+ "crimson_slab": [["3 crimson_planks"],6,true],
+ "warped_slab": [["3 warped_planks"],6,true],
+ "oak_sign": [["6 oak_planks"],3,true],
+ "dark_oak_sign": [["6 dark_oak_planks"],3,true],
+ "birch_sign": [["6 birch_planks"],3,true],
+ "spruce_sign": [["6 spruce_planks"],3,true],
+ "jungle_sign": [["6 jungle_planks"],3,true],
+ "acacia_sign": [["6 acacia_planks"],3,true],
+ "mangrove_sign": [["6 mangrove_planks"],3,true],
+ "crimson_sign": [["6 crimson_planks"],3,true],
+ "warped_sign": [["6 warped_planks"],3,true],
+ "oak_fence": [["4 oak_planks","2 stick"],3,true],
+ "dark_oak_fence": [["4 dark_oak_planks","2 stick"],3,true],
+ "birch_fence": [["4 birch_planks","2 stick"],3,true],
+ "spruce_fence": [["4 spruce_planks","2 stick"],3,true],
+ "jungle_fence": [["4 jungle_planks","2 stick"],3,true],
+ "acacia_fence": [["4 acacia_planks","2 stick"],3,true],
+ "mangrove_fence": [["4 mangrove_planks","2 stick"],3,true],
+ "crimson_fence": [["4 crimson_planks","2 stick"],3,true],
+ "warped_fence": [["4 warped_planks","2 stick"],3,true],
+ "oak_boat": [["5 oak_planks"],1,true],
+ "dark_oak_boat": [["5 dark_oak_planks"],1,true],
+ "birch_boat": [["5 birch_planks"],1,true],
+ "spruce_boat": [["5 spruce_planks"],1,true],
+ "jungle_boat": [["5 jungle_planks"],1,true],
+ "acacia_boat": [["5 acacia_planks"],1,true],
+ "mangrove_boat": [["5 mangrove_planks"],1,true],
+ "oak_trapdoor": [["6 oak_planks"],2,true],
+ "dark_oak_trapdoor": [["6 dark_oak_planks"],2,true],
+ "birch_trapdoor": [["6 birch_planks"],2,true],
+ "spruce_trapdoor": [["6 spruce_planks"],2,true],
+ "jungle_trapdoor": [["6 jungle_planks"],2,true],
+ "acacia_trapdoor": [["6 acacia_planks"],2,true],
+ "mangrove_trapdoor": [["6 mangrove_planks"],2,true],
+ "crimson_trapdoor": [["6 crimson_planks"],2,true],
+ "warped_trapdoor": [["6 warped_planks"],2,true],
+ "oak_chest_boat": [["1 oak_boat", "1 chest"], 1, true],
+ "dark_oak_chest_boat": [["1 dark_oak_boat", "1 chest"], 1, true],
+ "birch_chest_boat": [["1 birch_boat", "1 chest"], 1, true],
+ "spruce_chest_boat": [["1 spruce_boat", "1 chest"], 1, true],
+ "jungle_chest_boat": [["1 jungle_boat", "1 chest"], 1, true],
+ "acacia_chest_boat": [["1 acacia_boat", "1 chest"], 1, true],
+ "mangrove_chest_boat": [["1 mangrove_boat", "1 chest"], 1, true],
+ "redstone_lamp": [["1 glowstone","4 redstone"],1,true],
+ "dispenser": [["1 redstone","1 bow","7 cobblestone"],1,true],
+ "stone_pressure_plate": [["2 stone"],1,false],
+ "heavy_weighted_pressure_plate": [["2 iron_ingot"],1,false],
+ "light_weighted_pressure_plate": [["2 gold_ingot"],1,false],
+ "polished_blackstone_pressure_plate": [["2 polished_blackstone"],1,false],
+ "polished_blackstone_button": [["1 polished_blackstone"],1,false],
+ "trapped_chest": [["1 chest","1 tripwire_hook"],1,false],
+ "redstone_torch": [["1 stick","1 redstone"],1,false],
+ "lever": [["1 stick","1 cobblestone"],1,false],
+ "iron_door": [["6 iron_ingot"],3,true],
+ "honey_block": [["4 honey_bottle"],1,false],
+ "slime_block": [["9 slime_ball"],1,false],
+ "comparator": [["3 stone","3 redstone_torch","1 quartz"],1,true],
+ "observer": [["1 quartz","2 redstone","6 cobblestone"],1,true],
+ "dropper": [["1 redstone","7 cobblestone"],1,true],
+ "piston": [["1 redstone","1 iron_ingot","4 cobblestone","3 plank"],1,true],
+ "tnt": [["5 gunpowder","4 sand"],1,true],
+ "hopper": [["5 iron_ingot","1 chest"],1,true],
+ "target": [["4 redstone","1 hay_block"],1,true],
+ "daylight_detector": [["3 glass","3 quartz","3 plank_slab"],1,true],
+ "tripwire_hook": [["1 iron_ingot","1 stick","1 plank"],1,true],
+ "lightning_rod": [["3 copper_ingot"],1,true],
+ "sticky_piston": [["1 piston","1 slime_ball"],1,false],
+ "iron_trapdoor": [["4 iron_ingot"],1,false],
+ "repeater": [["2 redstone_torch","1 redstone","3 stone"],1,true],
+ "lectern": [["1 bookshelf","4 plank_slab"],1,true],
+ "note_block": [["1 redstone","4 plank"],1,true],
+ "redstone_block": [["9 redstone"],1,true],
+ "white_carpet": [["3 white_wool"], 2, true],
+ "orange_carpet": [["3 orange_wool"], 2, true],
+ "magenta_carpet": [["3 magenta_wool"], 2, true],
+ "light_blue_carpet": [["3 light_blue_wool"], 2, true],
+ "yellow_carpet": [["3 yellow_wool"], 2, true],
+ "lime_carpet": [["3 lime_wool"], 2, true],
+ "pink_carpet": [["3 pink_wool"], 2, true],
+ "gray_carpet": [["3 gray_wool"], 2, true],
+ "light_gray_carpet": [["3 light_gray_wool"], 2, true],
+ "cyan_carpet": [["3 cyan_wool"], 2, true],
+ "purple_carpet": [["3 purple_wool"], 2, true],
+ "blue_carpet": [["3 blue_wool"], 2, true],
+ "brown_carpet": [["3 brown_wool"], 2, true],
+ "green_carpet": [["3 green_wool"], 2, true],
+ "red_carpet": [["3 red_wool"], 2, true],
+ "black_carpet": [["3 black_wool"], 2, true],
+ "white_bed": [["3 white_wool", "3 plank"], 1, true],
+ "orange_bed": [["3 orange_wool", "3 plank"], 1, true],
+ "magenta_bed": [["3 magenta_wool", "3 plank"], 1, true],
+ "light_blue_bed": [["3 light_blue_wool", "3 plank"], 1, true],
+ "yellow_bed": [["3 yellow_wool", "3 plank"], 1, true],
+ "lime_bed": [["3 lime_wool", "3 plank"], 1, true],
+ "pink_bed": [["3 pink_wool", "3 plank"], 1, true],
+ "gray_bed": [["3 gray_wool", "3 plank"], 1, true],
+ "light_gray_bed": [["3 light_gray_wool", "3 plank"], 1, true],
+ "cyan_bed": [["3 cyan_wool", "3 plank"], 1, true],
+ "purple_bed": [["3 purple_wool", "3 plank"], 1, true],
+ "blue_bed": [["3 blue_wool", "3 plank"], 1, true],
+ "brown_bed": [["3 brown_wool", "3 plank"], 1, true],
+ "green_bed": [["3 green_wool", "3 plank"], 1, true],
+ "red_bed": [["3 red_wool", "3 plank"], 1, true],
+ "black_bed": [["3 black_wool", "3 plank"], 1, true],
+ "snow":[["3 snow_block"], 6, true],
+ "white_candle": [["1 candle", "1 white_dye"], 1, false],
+ "orange_candle": [["1 candle", "1 orange_dye"], 1, false],
+ "magenta_candle": [["1 candle", "1 magenta_dye"], 1, false],
+ "light_blue_candle": [["1 candle", "1 light_blue_dye"], 1, false],
+ "yellow_candle": [["1 candle", "1 yellow_dye"], 1, false],
+ "lime_candle": [["1 candle", "1 lime_dye"], 1, false],
+ "pink_candle": [["1 candle", "1 pink_dye"], 1, false],
+ "gray_candle": [["1 candle", "1 gray_dye"], 1, false],
+ "light_gray_candle": [["1 candle", "1 light_gray_dye"], 1, false],
+ "cyan_candle": [["1 candle", "1 cyan_dye"], 1, false],
+ "purple_candle": [["1 candle", "1 purple_dye"], 1, false],
+ "blue_candle": [["1 candle", "1 blue_dye"], 1, false],
+ "brown_candle": [["1 candle", "1 brown_dye"], 1, false],
+ "green_candle": [["1 candle", "1 green_dye"], 1, false],
+ "red_candle": [["1 candle", "1 red_dye"], 1, false],
+ "black_candle": [["1 candle", "1 black_dye"], 1, false],
+ "respawn_anchor": [["3 glowstone", "6 crying_obsidian"], 1, true],
+ "rabbit_stew": [["1 baked_potato", "1 cooked_rabbit", "1 bowl", "1 carrot", "1 red_mushroom"], 1, true],
+ "magma_cream": [["1 blaze_powder", "1 slime_ball"], 1, false],
+ "carrot_on_a_stick":[["1 fishing_rod", "1 carrot"], 1, false],
+ "warped_fungus_on_a_stick": [["1 fishing_rod", "1 warped_fungus"], 1, false],
+ "smoker": [["1 furnace", "4 log"], 1, true],
+ "white_stained_glass_pane": [["8 glass_pane", "1 white_dye"], 8, true],
+ "orange_stained_glass_pane": [["8 glass_pane", "1 orange_dye"], 8, true],
+ "magenta_stained_glass_pane": [["8 glass_pane", "1 magenta_dye"], 8, true],
+ "light_blue_stained_glass_pane": [["8 glass_pane", "1 light_blue_dye"], 8, true],
+ "yellow_stained_glass_pane": [["8 glass_pane", "1 yellow_dye"], 8, true],
+ "lime_stained_glass_pane": [["8 glass_pane", "1 lime_dye"], 8, true],
+ "pink_stained_glass_pane": [["8 glass_pane", "1 pink_dye"], 8, true],
+ "gray_stained_glass_pane": [["8 glass_pane", "1 gray_dye"], 8, true],
+ "light_gray_stained_glass_pane": [["8 glass_pane", "1 light_gray_dye"], 8, true],
+ "cyan_stained_glass_pane": [["8 glass_pane", "1 cyan_dye"], 8, true],
+ "purple_stained_glass_pane": [["8 glass_pane", "1 purple_dye"], 8, true],
+ "blue_stained_glass_pane": [["8 glass_pane", "1 blue_dye"], 8, true],
+ "brown_stained_glass_pane": [["8 glass_pane", "1 brown_dye"], 8, true],
+ "green_stained_glass_pane": [["8 glass_pane", "1 green_dye"], 8, true],
+ "red_stained_glass_pane": [["8 glass_pane", "1 red_dye"], 8, true],
+ "black_stained_glass_pane": [["8 glass_pane", "1 black_dye"], 8, true],
+ "white_banner": [["6 white_wool", "1 stick"], 1, true],
+ "orange_banner": [["6 orange_wool", "1 stick"], 1, true],
+ "magenta_banner": [["6 magenta_wool", "1 stick"], 1, true],
+ "light_blue_banner": [["6 light_blue_wool", "1 stick"], 1, true],
+ "yellow_banner": [["6 yellow_wool", "1 stick"], 1, true],
+ "lime_banner": [["6 lime_wool", "1 stick"], 1, true],
+ "pink_banner": [["6 pink_wool", "1 stick"], 1, true],
+ "gray_banner": [["6 gray_wool", "1 stick"], 1, true],
+ "light_gray_banner": [["6 light_gray_wool", "1 stick"], 1, true],
+ "cyan_banner": [["6 cyan_wool", "1 stick"], 1, true],
+ "purple_banner": [["6 purple_wool", "1 stick"], 1, true],
+ "blue_banner": [["6 blue_wool", "1 stick"], 1, true],
+ "brown_banner": [["6 brown_wool", "1 stick"], 1, true],
+ "green_banner": [["6 green_wool", "1 stick"], 1, true],
+ "red_banner": [["6 red_wool", "1 stick"], 1, true],
+ "black_banner": [["6 black_wool", "1 stick"], 1, true],
+ "rail": [["6 iron_ingot", "1 stick"], 16, true],
+ "detector_rail": [["1 stone_pressure_plate", "1 redstone", "6 iron_ingot"], 6, true],
+ "activator_rail": [["6 iron_ingot", "1 redstone_torch", "2 stick"], 6, true],
+ "powered_rail": [["6 gold_ingot", "1 redstone", "2 stick"], 6, true],
+ "fire_charge": [["1 gunpowder", "1 coal", "1 blaze_powder"], 3, true],
+ "melon_seeds": [["1 melon_slice"], 1, false],
+ "pumpkin_seeds": [["1 pumpkin"], 4, false],
+ "white_dye": [["1 lily_of_the_valley"], 1, false],
+ "black_dye": [["1 wither_rose"], 1, false],
+ "red_dye":[["1 rose_bush"], 1, true],
+ "yellow_dye": [["1 dandelion"], 1, false],
+ "light_gray_dye": [["1 oxeye_daisy"], 1, false],
+ "magenta_dye": [["1 allium"], 1, false],
+ "orange_dye": [["1 orange_tulip"], 1, false],
+ "purple_dye":[["1 blue_dye", "1 red_dye"], 2, false],
+ "pink_dye": [["1 white_dye", "1 red_dye"], 2, false],
+ "gray_dye": [["1 white_dye", "1 black_dye"], 2, false],
+ "cyan_dye": [["1 blue_dye", "1 green_dye"], 2, false],
+ "lime_dye": [["1 white_dye", "1 green_dye"], 2, false],
+ "light_blue_dye": [["1 blue_orchid"], 1, false],
+ "blue_dye": [["1 lapis_lazuli"], 1, false],
+ "brown_dye": [["1 cocoa_beans"], 1, false],
+ "soul_campfire": [["3 stick", "3 log", "1 soul_soil"], 1, true],
+ "brewing_stand": [["3 cobblestone", "1 blaze_rod"], 1, true],
+ "lodestone": [["8 chiseled_stone_bricks", "1 netherite_ingot"], 1, true],
+ "ladder": [["7 stick"], 3, true],
+ "polished_blackstone_brick_wall": [["6 polished_blackstone_bricks"], 6, true],
+ "red_sandstone_wall": [["6 red_sandstone"], 6, true],
+ "granite_wall": [["6 granite"], 6, true],
+ "brick_wall": [["6 bricks"], 6, true],
+ "stone_brick_wall": [["6 stone_bricks"], 6, true],
+ "red_nether_brick_wall": [["6 red_nether_bricks"], 6, true],
+ "deepslate_brick_wall": [["6 deepslate_bricks"], 6, true],
+ "deepslate_tile_wall": [["6 deepslate_tiles"], 6, true],
+ "blackstone_wall": [["6 blackstone"], 6, true],
+ "cobblestone_wall": [["6 cobblestone"], 6, true],
+ "polished_deepslate_wall": [["6 polished_deepslate"], 6, true],
+ "polished_blackstone_wall": [["6 polished_blackstone"], 6, true],
+ "mossy_cobblestone_wall": [["6 mossy_cobblestone"], 6, true],
+ "prismarine_wall": [["6 prismarine"], 6, true],
+ "sandstone_wall": [["6 sandstone"], 6, true],
+ "diorite_wall": [["6 diorite"], 6, true],
+ "nether_brick_wall": [["6 nether_bricks"], 6, true],
+ "end_stone_brick_wall": [["6 end_stone_bricks"], 6, true],
+ "cobbled_deepslate_wall": [["6 cobbled_deepslate"], 6, true],
+ "andesite_wall": [["6 andesite"], 6, true],
+ "mud_brick_wall": [["6 mud_bricks"], 6, true],
+ "bucket": [["3 iron_ingot"], 1, true],
+ "iron_bars": [["6 iron_ingot"], 16, true],
+ "painting": [["8 stick", "1 wool"], 1, true],
+ "glistering_melon_slice": [["8 gold nugget", "1 melon_slice"], 1, true],
+ "conduit": [["8 nautilus_shell", "1 heart_of_the_sea"], 1, true],
+ "mushroom_stew": [["1 bowl", "1 red_mushroom", "1 brown_mushroom"], 1, true],
+ "leather": [["4 rabbit_hide"], 1, false],
+ "iron_nugget": [["1 iron_ingot"], 9, false],
+ "gold_nugget": [["1 gold_ingot"], 9, false],
+ "candle": [["1 string", "1 honeycomb"], 1, false],
+ "grindstone": [["1 stone_slab", "2 stick", "2 plank"], 1, true],
+ "leather_horse_armor": [["7 leather"], 1, true],
+ "enchanting_table":[["4 obsidian", "2 diamond", "1 book"], 1, true],
+ "end_crystal": [["7 glass", "1 ghast_tear", "1 ender_eye"], 1, true],
+ "minecart": [["5 iron_ingot"], 1, true],
+ "chest_minecart": [["1 minecart", "1 chest"], 1, false],
+ "furnace_minecart": [["1 minecart", "1 furnace"], 1, false],
+ "hopper_minecart": [["1 minecart", "1 hopper"], 1, false],
+ "tnt_minecart": [["1 minecart", "1 tnt"], 1, false],
+ "beacon": [["5 glass", "3 obsidian", "1 nether_star"], 1, true],
+ "item_frame": [["1 leather", "8 stick"], 1, true],
+ "glow_item_frame": [["1 item_frame", "1 glow_ink_sac"], 1 ,false],
+ "cartography_table": [["2 paper", "4 plank"], 1, true],
+ "torch": [["1 coal", "1 stick"], 4, true],
+ "flower_pot": [["3 brick"], 1, true],
+ "map": [["8 paper", "1 compass"], 1, true],
+ "stonecutter": [["3 stone", "1 iron_ingot"], 1, true],
+ "honeycomb_block": [["4 honeycomb"], 1, false],
+ "barrel": [["6 plank", "2 plank_slab"], 1, true],
+ "scaffolding": [["6 bamboo", "1 string"], 1, true],
+ "armor_stand": [["1 smooth_stone_slab", "6 stick"], 1, true],
+ "book": [["1 leather", "3 paper"], 1, true],
+ "writable_book": [["1 book", "1 feather", "1 ink_sac"], 1, true],
+ "firework_rocket": [["1 gunpowder", "1 paper"], 3, false],
+ "blaze_powder": [["1 blaze_rod"], 2, false],
+ "glass_bottle": [["3 glass"], 3, true],
+ "lantern": [["1 torch", "8 iron_nugget"], 1, true],
+ "ender_eye": [["1 ender_pearl", "1 blaze_powder"], 1, false],
+ "sugar": [["1 sugar_cane"], 1, false],
+ "fermented_spider_eye": [["1 spider_eye", "1 brown_mushroom", "1 sugar"], 1, true],
+ "chain": [["1 iron_ingot", "2 iron_nugget"], 1, true],
+ "bread": [["3 wheat"], 1, true],
+ "beehive": [["3 honeycomb", "6 plank"], 1, true],
+ "fletching_table": [["2 flint", "4 plank"], 1, true],
+ "cake": [["3 milk_bucket", "3 wheat", "2 sugar", "1 egg"], 1, true],
+ "golden_apple": [["1 apple", "8 gold_ingot"], 1, true],
+ "anvil": [["3 iron_block", "4 iron_ingot"], 1, true],
+ "composter": [["7 plank_slab"], 1, true],
+ "smithing_table": [["2 iron_ingot", "4 plank"], 1, true],
+ "pumpkin_pie": [["1 pumpkin", "1 sugar", "1 egg"], 1, true],
+ "bone_meal": [["1 bone"], 3, false],
+ "soul_torch": [["1 coal", "1 stick", "1 soul_soil"], 4, true],
+ "soul_lantern": [["8 iron_nugget", "1 soul_torch"], 1, true],
+ "beetroot_soup": [["1 bowl", "6 beetroot"], 1, true],
+ "jukebox": [["1 diamond", "8 plank"], 1, true],
+ "cauldron": [["7 iron_ingot"], 1, true],
+ "shulker_box": [["2 shulker_shell", "1 chest"], 1, true],
+ "loom": [["2 string", "2 plank"], 1, false],
+ "campfire": [["1 coal", "3 stick", "3 log"], 1, true],
+ "chest": [["8 plank"], 1, true],
+ "glass_pane": [["6 glass"], 16, true],
+ "ender_chest": [["8 obsidian", "1 ender_pearl"], 1, true],
+ "nether_brick_fence": [["4 nether_bricks", "2 nether_brick"], 6, true],
+ "honey_bottle": [["1 honey_block", "4 glass_bottle"], 4, true],
+ "blast_furnace": [["5 iron_ingot", "3 smooth_stone", "1 furnace"], 1, true],
+ "bowl": [["3 plank"], 4, true],
+ "end_rod": [["1 blaze_rod", "1 popped_chorus_fruit"], 4, false],
+ "golden_carrot": [["8 gold_nugget", "1 carrot"], 1, true],
+ "cookie": [["1 cocoa_beans", "2 wheaat"], 1, true],
+ "netherite_block": [["9 netherite_ingot"], 1, true],
+ "emerald_block": [["9 emerald"], 1, true],
+ "diamond_block": [["9 diamond"], 1, true],
+ "gold_block": [["9 gold_ingot"], 1, true],
+ "iron_block": [["9 iron_ingot"], 1, true],
+ "copper_block": [["9 copper_ingot"], 1, true],
+ "lapis_block": [["9 lapis_lazuli"], 1, true],
+ "coal_block": [["9 coal"], 1, true],
+ "raw_gold_block": [["9 raw_gold"], 1, true],
+ "raw_iron_block": [["9 raw_iron"], 1, true],
+ "raw_copper_block": [["9 raw_copper"], 1, true],
+ "quartz_block": [["4 quartz"], 1, false],
+ "amethyst_block": [["4 amethyst_shard"], 1, false],
+ "snow_block": [["4 snowball"], 1, false],
+ "purpur_block": [["4 popped_chorus_fruit"], 4, false],
+ "bone_block": [["9 bone_meal"], 1, true],
+ "magma_block": [["4 magma_cream"], 1, false],
+ "nether_wart_block": [["9 nether_wart"], 1, false],
+ "dripstone_block": [["4 pointed_dripstone"], 1, false],
+ "mossy_cobblestone": [["1 vine", "1 cobblestone"], 1, false],
+ "exposed_cut_copper_slab": [["3 exposed_cut_copper"], 6, true],
+ "polished_andesite_slab": [["3 polished_andesite"], 6, true],
+ "cut_sandstone_slab": [["3 cut_sandstone"], 6, true],
+ "andesite_slab": [["3 andesite"], 6, true],
+ "waxed_cut_copper_slab": [["3 waxed_cut_copper"], 6, true],
+ "polished_diorite_slab": [["3 polished_diorite"], 6, true],
+ "prismarine_slab": [["3 prismarine"], 6, true],
+ "cut_red_sandstone_slab": [["3 cut_red_sandstone"], 6, true],
+ "weathered_cut_copper_slab": [["3 weathered_cut_copper"], 6, true],
+ "cobblestone_slab": [["3 cobblestone"], 6, true],
+ "nether_brick_slab": [["3 nether_bricks"], 6, true],
+ "granite_slab": [["3 granite"], 6, true],
+ "red_nether_brick_slab": [["3 red_nether_bricks"], 6, true],
+ "waxed_exposed_cut_copper_slab": [["3 waxed_exposed_cut_copper"], 6, true],
+ "deepslate_tile_slab": [["3 deepslate_tiles"], 6, true],
+ "stone_slab": [["3 stone"], 6, true],
+ "sandstone_slab": [["3 sandstone"], 6, true],
+ "prismarine_brick_slab": [["3 prismarine_bricks"], 6, true],
+ "purpur_slab": [["3 purpur_block"], 6, true],
+ "waxed_weathered_cut_copper_slab": [["3 waxed_weathered_cut_copper"], 6, true],
+ "waxed_oxidized_cut_copper_slab": [["3 waxed_oxidized_cut_copper"], 6, true],
+ "diorite_slab": [["3 diorite"], 6, true],
+ "smooth_quartz_slab": [["3 smooth_quartz"], 6, true],
+ "red_sandstone_slab": [["3 red_sandstone"], 6, true],
+ "stone_brick_slab": [["3 stone_bricks"], 6, true],
+ "polished_blackstone_brick_slab": [["3 polished_blackstone_bricks"], 6, true],
+ "polished_deepslate_slab": [["3 polished_deepslate"], 6, true],
+ "cut_copper_slab": [["3 cut_copper"], 6, true],
+ "deepslate_brick_slab": [["3 deepslate_bricks"], 6, true],
+ "smooth_red_sandstone_slab": [["3 smooth_red_sandstone"], 6, true],
+ "brick_slab": [["3 bricks"], 6, true],
+ "polished_granite_slab": [["3 polished_granite"], 6, true],
+ "smooth_sandstone_slab": [["3 smooth_sandstone"], 6, true],
+ "oxidized_cut_copper_slab": [["3 oxidized_cut_copper"], 6, true],
+ "smooth_stone_slab": [["3 smooth_stone"], 6, true],
+ "quartz_slab": [["3 quartz_block"], 6, true],
+ "mud_brick_slab": [["3 mud_bricks"], 6, true],
+ "mossy_stone_brick_slab": [["3 mossy_stone_bricks"], 6, true],
+ "blackstone_slab": [["3 blackstone"], 6, true],
+ "polished_blackstone_slab": [["3 blackstone"], 6, true],
+ "dark_prismarine_slab": [["3 dark_prismarine"], 6, true],
+ "cobbled_deepslate_slab": [["3 cobbled_deepslate"], 6, true],
+ "mossy_cobblestone_slab": [["3 mossy_cobblestone"], 6, true],
+ "end_stone_brick_slab": [["3 end_stone_bricks"], 6, true],
+ "black_wool": [["1 white_wool", "1 black_dye"], 1, false],
+ "red_wool": [["1 white_wool", "1 red_dye"], 1, false],
+ "green_wool": [["1 white_wool", "1 green_dye"], 1, false],
+ "brown_wool": [["1 white_wool", "1 brown_dye"], 1, false],
+ "blue_wool": [["1 white_wool", "1 blue_dye"], 1, false],
+ "purple_wool": [["1 white_wool", "1 purple_dye"], 1, false],
+ "cyan_wool": [["1 white_wool", "1 cyan_dye"], 1, false],
+ "light_gray_wool": [["1 white_wool", "1 light_gray_dye"], 1, false],
+ "gray_wool": [["1 white_wool", "1 gray_dye"], 1, false],
+ "pink_wool": [["1 white_wool", "1 pink_dye"], 1, false],
+ "lime_wool": [["1 white_wool", "1 lime_dye"], 1, false],
+ "yellow_wool": [["1 white_wool", "1 yellow_dye"], 1, false],
+ "light_blue_wool": [["1 white_wool", "1 light_blue_dye"], 1, false],
+ "magenta_wool": [["1 white_wool", "1 magenta_dye"], 1, false],
+ "orange_wool": [["1 white_wool", "1 orange_dye"], 1, false],
+ "white_stained_glass": [["8 glass", "1 white_dye"], 8, true],
+ "orange_stained_glass": [["8 glass", "1 orange_dye"], 8, true],
+ "magenta_stained_glass": [["8 glass", "1 magenta_dye"], 8, true],
+ "light_blue_stained_glass": [["8 glass", "1 light_blue_dye"], 8, true],
+ "yellow_stained_glass": [["8 glass", "1 yellow_dye"], 8, true],
+ "lime_stained_glass": [["8 glass", "1 lime_dye"], 8, true],
+ "pink_stained_glass": [["8 glass", "1 pink_dye"], 8, true],
+ "gray_stained_glass": [["8 glass", "1 gray_dye"], 8, true],
+ "light_gray_stained_glass": [["8 glass", "1 light_gray_dye"], 8, true],
+ "cyan_stained_glass": [["8 glass", "1 cyan_dye"], 8, true],
+ "purple_stained_glass": [["8 glass", "1 purple_dye"], 8, true],
+ "blue_stained_glass": [["8 glass", "1 blue_dye"], 8, true],
+ "brown_stained_glass": [["8 glass", "1 brown_dye"], 8, true],
+ "green_stained_glass": [["8 glass", "1 green_dye"], 8, true],
+ "red_stained_glass": [["8 glass", "1 red_dye"], 8, true],
+ "black_stained_glass": [["8 glass", "1 black_dye"], 8, true],
+ "white_terracotta": [["8 terracotta", "1 white_dye"], 8, true],
+ "orange_terracotta": [["8 terracotta", "1 orange_dye"], 8, true],
+ "magenta_terracotta": [["8 terracotta", "1 magenta_dye"], 8, true],
+ "light_blue_terracotta": [["8 terracotta", "1 light_blue_dye"], 8, true],
+ "yellow_terracotta": [["8 terracotta", "1 yellow_dye"], 8, true],
+ "lime_terracotta": [["8 terracotta", "1 lime_dye"], 8, true],
+ "pink_terracotta": [["8 terracotta", "1 pink_dye"], 8, true],
+ "gray_terracotta": [["8 terracotta", "1 gray_dye"], 8, true],
+ "light_gray_terracotta": [["8 terracotta", "1 light_gray_dye"], 8, true],
+ "cyan_terracotta": [["8 terracotta", "1 cyan_dye"], 8, true],
+ "purple_terracotta": [["8 terracotta", "1 purple_dye"], 8, true],
+ "blue_terracotta": [["8 terracotta", "1 blue_dye"], 8, true],
+ "brown_terracotta": [["8 terracotta", "1 brown_dye"], 8, true],
+ "green_terracotta": [["8 terracotta", "1 green_dye"], 8, true],
+ "red_terracotta": [["8 terracotta", "1 red_dye"], 8, true],
+ "black_terracotta": [["8 terracotta", "1 black_dye"], 8, true],
+ "white_concrete_powder": [["4 sand", "4 gravel", "1 white_dye"], 8, true],
+ "orange_concrete_powder": [["4 sand", "4 gravel", "1 orange_dye"], 8, true],
+ "magenta_concrete_powder": [["4 sand", "4 gravel", "1 magenta_dye"], 8, true],
+ "light_blue_concrete_powder": [["4 sand", "4 gravel", "1 light_blue_dye"], 8, true],
+ "yellow_concrete_powder": [["4 sand", "4 gravel", "1 yellow_dye"], 8, true],
+ "lime_concrete_powder": [["4 sand", "4 gravel", "1 lime_dye"], 8, true],
+ "pink_concrete_powder": [["4 sand", "4 gravel", "1 pink_dye"], 8, true],
+ "gray_concrete_powder": [["4 sand", "4 gravel", "1 gray_dye"], 8, true],
+ "light_gray_concrete_powder": [["4 sand", "4 gravel", "1 light_gray_dye"], 8, true],
+ "cyan_concrete_powder": [["4 sand", "4 gravel", "1 cyan_dye"], 8, true],
+ "purple_concrete_powder": [["4 sand", "4 gravel", "1 purple_dye"], 8, true],
+ "blue_concrete_powder": [["4 sand", "4 gravel", "1 blue_dye"], 8, true],
+ "brown_concrete_powder": [["4 sand", "4 gravel", "1 brown_dye"], 8, true],
+ "green_concrete_powder": [["4 sand", "4 gravel", "1 green_dye"], 8, true],
+ "red_concrete_powder": [["4 sand", "4 gravel", "1 red_dye"], 8, true],
+ "black_concrete_powder": [["4 sand", "4 gravel", "1 black_dye"], 8, true],
+ "stone_brick_stairs": [["6 stone_bricks"], 4, true],
+ "waxed_cut_copper_stairs": [["6 waxed_cut_copper"], 4, true],
+ "deepslate_brick_stairs": [["6 deepslate_bricks"], 4, true],
+ "smooth_sandstone_stairs": [["6 smooth_sandstone"], 4, true],
+ "diorite_stairs": [["6 diorite"], 4, true],
+ "weathered_cut_copper_stairs": [["6 weathered_cut_copper"], 4, true],
+ "sandstone_stairs": [["6 sandstone"], 4, true],
+ "prismarine_brick_stairs": [["6 prismarine_bricks"], 4, true],
+ "polished_granite_stairs": [["6 polished_granite"], 4, true],
+ "waxed_oxidized_cut_copper_stairs": [["6 waxed_oxidized_cut_copper"], 4, true],
+ "mossy_cobblestone_stairs": [["6 mossy_cobblestone"], 4, true],
+ "end_stone_brick_stairs": [["6 end_stone_bricks"], 4, true],
+ "quartz_stairs": [["6 quartz_block"], 4, true],
+ "brick_stairs": [["6 bricks"], 4, true],
+ "mossy_stone_brick_stairs": [["6 mossy_stone_bricks"], 4, true],
+ "waxed_exposed_cut_copper_stairs": [["6 waxed_exposed_cut_copper"], 4, true],
+ "mud_brick_stairs": [["6 mud_bricks"], 4, true],
+ "waxed_weathered_cut_copper_stairs": [["6 waxed_weathered_cut_copper"], 4, true],
+ "cut_copper_stairs": [["6 cut_copper"], 4, true],
+ "red_nether_brick_stairs": [["6 red_nether_bricks"], 4, true],
+ "stone_stairs": [["6 stone"], 4, true],
+ "nether_brick_stairs": [["6 nether_bricks"], 4, true],
+ "cobbled_deepslate_stairs": [["6 cobbled_deepslate"], 4, true],
+ "exposed_cut_copper_stairs": [["6 exposed_cut_copper"], 4, true],
+ "prismarine_stairs": [["6 prismarine"], 4, true],
+ "polished_andesite_stairs": [["6 polished_andesite"], 4, true],
+ "dark_prismarine_stairs": [["6 dark_prismarine"], 4, true],
+ "polished_deepslate_stairs": [["6 polished_deepslate"], 4, true],
+ "polished_diorite_stairs": [["6 polished_diorite"], 4, true],
+ "red_sandstone_stairs": [["6 red_sandstone"], 4, true],
+ "blackstone_stairs": [["6 blackstone"], 4, true],
+ "deepslate_tile_stairs": [["6 deepslate_tiles"], 4, true],
+ "polished_blackstone_brick_stairs": [["6 polished_blackstone_bricks"], 4, true],
+ "smooth_quartz_stairs": [["6 smooth_quartz"], 4, true],
+ "cobblestone_stairs": [["6 cobblestone"], 4, true],
+ "granite_stairs": [["6 granite"], 4, true],
+ "smooth_red_sandstone_stairs": [["6 smooth_red_sandstone"], 4, true],
+ "oxidized_cut_copper_stairs": [["6 oxidized_cut_copper"], 4, true],
+ "deepslate_bricks": [["4 polished_deepslate"], 1, false],
+ "granite": [["1 diorite", "1 quartz"], 1, false],
+ "purpur_pillar": [["2 purpur_slab"], 1, false],
+ "chiseled_sandstone": [["2 sandstone_slab"], 1, false],
+ "chiseled_stone_bricks": [["2 stone_brick_slab"], 1, false],
+ "chiseled_polished_blackstone": [["2 polished_blackstone_slab"], 1, false],
+ "chiseled_nether_bricks": [["2 nether_brick_slab"], 1, false],
+ "chiseled_deepslate": [["2 cobbled_deepslate_slab"], 1, false],
+ "chiseled_quartz_block": [["2 quartz_slab"], 1, false],
+ "chiseled_red_sandstone": [["2 red_sandstone_slab"], 1, false],
+ "waxed_weathered_cut_copper": [["4 waxed_weathered_copper"], 4, false],
+ "waxed_oxidized_cut_copper":[["4 waxed_oxidized_opper"], 4, false],
+ "waxed_exposed_cut_copper": [["4 waxed_exposed_copper"], 4, false],
+ "waxed_cut_copper": [["4 waxed_copper_block"], 4, false],
+ "waxed_weathered_copper": [["1 weathered_copper", "1 honeycomb"], 1, false],
+ "waxed_oxidized_copper": [["1 oxidized_copper", "1 honeycomb"], 1, false],
+ "waxed_copper_block": [["1 copper_block", "1 honeycomb"], 1, false],
+ "weathered_cut_copper": [["4 weathered_copper"], 4, false],
+ "oxidized_cut_copper": [["4 oxidized_copper"], 4, false],
+ "exposed_cut_copper": [["4 exposed_copper"], 4, false],
+ "cut_copper": [["4 copper_block"], 4, false],
+ "cut_sandstone": [["4 sandstone"], 4, false],
+ "cut_red_sandstone": [["4 red_sandstone"], 4, false],
+ "deepslate_tiles": [["4 deepslate bricks"], 4, false],
+ "end_stone_bricks": [["4 end_stone"], 4, false],
+ "polished_deepslate": [["4 cobbled_deepslate"], 4, false],
+ "polished_blackstone": [["4 blackstone"], 4, false],
+ "polished_basalt": [["4 basalt"], 4, false],
+ "polished_diorite": [["4 diorite"], 4, false],
+ "polished_granite": [["4 granite"], 4, false],
+ "polished_andesite": [["4 andesite"], 4, false],
+ "bricks": [["4 brick"], 1, false],
+ "nether_bricks": [["4 nether_brick"], 1, false],
+ "stone_bricks": [["4 stone"], 4, false],
+ "mud_bricks": [["4 packed_mud"], 4, false],
+ "mossy_stone_bricks": [["1 stone_bricks", "1 vine"], 1, false],
+ "red_nether_bricks": [["2 nether_brick, 2 nether_wart"], 1, false],
+ "polished_blackstone_bricks": [["4 polished_blackstone"], 4, false],
+ "prismarine_bricks": [["9 prismarine_shard"], 1, true],
+ "prismarine": [["4 prismarine_shard"], 1, false],
+ "dark_prismarine": [["8 prismarine_shard", "1 black_dye"], 1, true],
+ "bookshelf": [["6 plank", "3 book"], 1, true],
+ "clay": [["4 clay_ball"], 1, false],
+ "sandstone": [["4 sand"], 1, false],
+ "red_sandstone": [["4 red_sand"], 1, false],
+ "tinted_glass": [["1 glass", "4 amethyst_shard"], 2, true],
+ "white_wool": [["4 string"], 1, false],
+ "coarse_dirt": [["2 dirt", "2 gravel"], 4, false],
+ "sea_lantern": [["4 prismarine_shard", "5 prismarine_crystals"], 1, true],
+ "diorite": [["2 cobblestone", "2 quartz"], 2, false],
+ "andesite": [["1 diorite", "1 cobblestone"], 2, false],
+ "muddy_mangrove_roots": [["1 mud", "1 mangrove_roots"], 1, false],
+ "jack_o_lantern": [["1 carved_pumpkin", "1 torch"], 1, false],
+ "packed_ice": [["9 ice"], 1, true],
+ "blue_ice": [["9 paced_ice"], 1, true],
+ "melon": [["9 melon_slice"], 1, true],
+ "hay_block": [["9 wheat"], 1, true],
+ "quartz_pillar": [["2 quartz_block"], 1, false],
+ "packed_mud": [["1 mud", "1 wheat"], 1, false]
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_smelt.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_smelt.json
new file mode 100644
index 0000000000000000000000000000000000000000..13e21c471cb120921fcb76f93c5731bec7dd3744
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_smelt.json
@@ -0,0 +1,53 @@
+{
+ "cooked_cod":"cod",
+ "cooked_salmon":"salmon",
+ "cooked_beef":"beef",
+ "cooked_rabbit":"rabbit",
+ "cooked_chicken":"chicken",
+ "cooked_porkchop":"porkchop",
+ "dried_kelp":"kelp",
+ "cooked_mutton":"mutton",
+ "baked_potato":"potato",
+ "sponge":"wet_sponge",
+ "terracotta":"clay",
+ "red_glazed_terracotta":"red_terracotta",
+ "orange_glazed_terracotta":"orange_terracotta",
+ "black_glazed_terracotta":"black_terracotta",
+ "brown_glazed_terracotta":"brown_terracotta",
+ "light_blue_glazed_terracotta":"light_blue_terracotta",
+ "lime_glazed_terracotta":"lime_terracotta",
+ "green_glazed_terracotta":"green_terracotta",
+ "blue_glazed_terracotta":"blue_terracotta",
+ "pink_glazed_terracotta":"pink_terracotta",
+ "white_glazed_terracotta":"white_terracotta",
+ "light_gray_glazed_terracotta":"light_gray_terracotta",
+ "magenta_glazed_terracotta":"magenta_terracotta",
+ "cyan_glazed_terracotta":"cyan_terracotta",
+ "purple_glazed_terracotta":"purple_terracotta",
+ "yellow_glazed_terracotta":"yellow_terracotta",
+ "gray_glazed_terracotta":"gray_terracotta",
+ "cracked_polished_blackstone_bricks":"polished_blackstone_bricks",
+ "cracked_nether_bricks":"nether_bricks",
+ "cracked_stone_bricks":"stone_bricks",
+ "cracked_deepslate_bricks":"deepslate_bricks",
+ "cracked_deepslate_tiles":"deepslate_tiles",
+ "smooth_sandstone":"sandstone",
+ "smooth_red_sandstone":"red_sandstone",
+ "smooth_quartz":"quartz_block",
+ "smooth_basalt":"basalt",
+ "smooth_stone":"stone",
+ "stone":"cobblestone",
+ "deepslate":"cobbled_deepslate",
+ "netherite_scrap":"ancient debris",
+ "glass":"sand",
+ "gold_ingot":"raw_gold",
+ "iron_ingot":"raw_iron",
+ "copper_ingot":"raw_copper",
+ "quartz":"nether_quartz_ore",
+ "nether_brick":"netherrack",
+ "brick":"clay_ball",
+ "green_dye":"cactus",
+ "lime_dye":"sea_pickle",
+ "popped_chorus_fruit":"chorus_fruit",
+ "charcoal":"log"
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_tool.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_tool.json
new file mode 100644
index 0000000000000000000000000000000000000000..5623db51ad4905e50f16d56f9c97269dbbe2a5b4
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/pre_tool.json
@@ -0,0 +1,94 @@
+{
+ "ancient_debris":"diamond_pickaxe",
+ "obsidian":"diamond_pickaxe",
+ "crying_obsidian":"diamond_pickaxe",
+ "emerald":"iron_pickaxe",
+ "diamond":"iron_pickaxe",
+ "redstone":"iron_pickaxe",
+ "raw_gold":"iron_pickaxe",
+ "raw_iron":"stone_pickaxe",
+ "raw_copper":"stone_pickaxe",
+ "lapis_lazuli":"stone_pickaxe",
+ "coal":"wooden_pickaxe",
+ "cobblestone":"wooden_pickaxe",
+ "granite":"wooden_pickaxe",
+ "diorite":"wooden_pickaxe",
+ "andesite":"wooden_pickaxe",
+ "calcite":"wooden_pickaxe",
+ "tuff":"wooden_pickaxe",
+ "dripstone_block":"wooden_pickaxe",
+ "pointed_dripstone":"wooden_pickaxe",
+ "magma_block":"wooden_pickaxe",
+ "netherrack":"wooden_pickaxe",
+ "basalt":"wooden_pickaxe",
+ "blackstone":"wooden_pickaxe",
+ "end_stone":"wooden_pickaxe",
+ "amethyst_block":"wooden_pickaxe",
+ "prismarine":"wooden_pickaxe",
+ "nether_quartz_ore":"wooden_pickaxe",
+ "sandstone":"wooden_pickaxe",
+ "red_sandstone":"wooden_pickaxe",
+ "glowstone":"pickaxe",
+ "snowball": "shovel",
+ "clay_ball": "shovel",
+ "dirt":"shovel",
+ "sand":"shovel",
+ "red_sand":"shovel",
+ "gravel":"shovel",
+ "mud":"shovel",
+ "moss_block":"shovel",
+ "soul_sand":"shovel",
+ "soul_soil":"shovel",
+ "oak_leaves":"shears",
+ "dark_oak_leaves":"shears",
+ "birch_leaves":"shears",
+ "spruce_leaves":"shears",
+ "jungle_leaves":"shears",
+ "acacia_leaves":"shears",
+ "mangrove_leaves":"shears",
+ "azalea_leaves":"shears",
+ "flowering_azalea_leaves":"shears",
+ "grass":"shears",
+ "fern":"shears",
+ "vine":"shears",
+ "shroomlight":"hoe",
+ "bamboo":"sword",
+ "melon_slice":"axe",
+ "pumpkin":"axe",
+ "oak_log":"axe",
+ "dark_oak_log":"axe",
+ "birch_log":"axe",
+ "spruce_log":"axe",
+ "jungle_log":"axe",
+ "acacia_log":"axe",
+ "mangrove_log":"axe",
+ "crimson_stem":"axe",
+ "warped_stem":"axe",
+ "log":"axe",
+ "brown_mushroom":"none",
+ "red_mushroom":"none",
+ "crimson_fungus":"none",
+ "warped_fungus":"none",
+ "dandelion":"none",
+ "poppy":"none",
+ "blue_orchid":"none",
+ "allium":"none",
+ "azure_bluet":"none",
+ "red_tulip":"none",
+ "orange_tulip":"none",
+ "white_tulip":"none",
+ "pink_tulip":"none",
+ "oxeye_daisy":"none",
+ "cornflower":"none",
+ "spore_blossom":"none",
+ "lily_of_the_valley":"none",
+ "wither_rose":"none",
+ "sunflower":"none",
+ "lilac":"none",
+ "rose_bush":"none",
+ "peony":"none",
+ "cocoa":"none",
+ "kelp":"none",
+ "sugar_cane":"none",
+ "cactus":"none"
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/process_json.py b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/process_json.py
new file mode 100644
index 0000000000000000000000000000000000000000..473fce3aa244546837c4ce18e475ce105e9c87f1
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/json/process_json.py
@@ -0,0 +1,74 @@
+import json
+
+# 文件路径
+pre_item_path = 'MCskill/json/pre_item.json'
+pre_tool_path = 'MCskill/json/pre_tool.json'
+pre_smelt_path = 'MCskill/json/pre_smelt.json'
+func_path = 'MCskill/json/func.json'
+
+# 读取JSON文件
+def read_json(file_path):
+ with open(file_path, 'r') as file:
+ return json.load(file)
+
+# 写入JSON文件
+def write_json(data, file_path):
+ with open(file_path, 'w') as file:
+ json.dump(data, file, indent=4)
+
+# 排序函数
+def sort_func_json_data(data):
+ # 将字典按值排序,值相同的按键排序
+ sorted_data = dict(sorted(data.items(), key=lambda item: (item[1], item[0])))
+ return sorted_data
+
+# 更新func.json并记录冲突
+def update_func_json():
+ # 读取所有JSON文件
+ pre_item_data = read_json(pre_item_path)
+ pre_tool_data = read_json(pre_tool_path)
+ pre_smelt_data = read_json(pre_smelt_path)
+ func_data = read_json(func_path)
+
+ # 记录更新过程中的所有键及其出现次数
+ key_counts = {}
+
+ # 更新函数,记录键出现次数
+ def update_keys(data, value):
+ for key in data.keys():
+ if key not in key_counts:
+ key_counts[key] = 0
+ key_counts[key] += 1
+ if key_counts[key] == 1:
+ func_data[key] = value
+
+ # 更新func.json
+ update_keys(pre_item_data, 'craft')
+ update_keys(pre_tool_data, 'mine')
+ update_keys(pre_smelt_data, 'smelt')
+
+ # 记录冲突的键
+ conflicts = [key for key, count in key_counts.items() if count > 1]
+
+ # 排序func.json
+ sorted_func_data = sort_func_json_data(func_data)
+
+ # 写回func.json
+ write_json(sorted_func_data, func_path)
+
+ # 输出冲突的键
+ if conflicts:
+ print("Conflicts detected for the following keys:")
+ for conflict in conflicts:
+ print(conflict)
+
+# 排序函数
+def sort_func_json(file_path):
+ data = read_json(file_path)
+ # 将字典按值排序,值相同的按键排序
+ sorted_data = dict(sorted(data.items(), key=lambda item: (item[1], item[0])))
+ write_json(sorted_data, file_path)
+
+# 运行排序函数
+sort_func_json(func_path)
+
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/.prettierrc.json b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..44f18dc6104c5f5c681ae0213ebf810c35250edd
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "tabWidth": 4
+}
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/chestAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/chestAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..57d8795fd118a87278367460ecdadfeb0e9ecf49
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/chestAPIs.js
@@ -0,0 +1,133 @@
+async function getItemFromChest(bot, chestPosition, itemsToGet) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ bot.chat("chestPosition for getItemFromChest must be a Vec3");
+ return;
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToGet) {
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ bot.chat(`No item named ${name}`);
+ continue;
+ }
+
+ const item = chest.findContainerItem(itemByName.id);
+ if (!item) {
+ bot.chat(`I don't see ${name} in this chest`);
+ continue;
+ }
+ try {
+ await chest.withdraw(item.type, null, itemsToGet[name]);
+ } catch (err) {
+ bot.chat(`Not enough ${name} in chest.`);
+ }
+ }
+ await closeChest(bot, chestBlock);
+}
+
+async function depositItemIntoChest(bot, chestPosition, itemsToDeposit) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToDeposit) {
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ bot.chat(`No item named ${name}`);
+ continue;
+ }
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ if (!item) {
+ bot.chat(`No ${name} in inventory`);
+ continue;
+ }
+ try {
+ await chest.deposit(item.type, null, itemsToDeposit[name]);
+ } catch (err) {
+ bot.chat(`Not enough ${name} in inventory.`);
+ }
+ }
+ await closeChest(bot, chestBlock);
+}
+
+async function checkItemInsideChest(bot, chestPosition) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ await bot.openContainer(chestBlock);
+ await closeChest(bot, chestBlock);
+}
+
+async function moveToChest(bot, chestPosition) {
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ if (chestPosition.distanceTo(bot.entity.position) > 32) {
+ bot.chat(
+ `/tp ${chestPosition.x} ${chestPosition.y} ${chestPosition.z}`
+ );
+ await bot.waitForTicks(20);
+ }
+ const chestBlock = bot.blockAt(chestPosition);
+ if (chestBlock.name !== "chest") {
+ bot.emit("removeChest", chestPosition);
+ throw new Error(
+ `No chest at ${chestPosition}, it is ${chestBlock.name}`
+ );
+ }
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(chestBlock.position, bot.world, {})
+ );
+ return chestBlock;
+}
+
+async function listItemsInChest(bot, chestBlock) {
+ const chest = await bot.openContainer(chestBlock);
+ const items = chest.containerItems();
+ if (items.length > 0) {
+ const itemNames = items.reduce((acc, obj) => {
+ if (acc[obj.name]) {
+ acc[obj.name] += obj.count;
+ } else {
+ acc[obj.name] = obj.count;
+ }
+ return acc;
+ }, {});
+ bot.emit("closeChest", itemNames, chestBlock.position);
+ } else {
+ bot.emit("closeChest", {}, chestBlock.position);
+ }
+ return chest;
+}
+
+async function closeChest(bot, chestBlock) {
+ try {
+ const chest = await listItemsInChest(bot, chestBlock);
+ await chest.close();
+ } catch (err) {
+ await bot.closeWindow(chestBlock);
+ }
+}
+
+function itemByName(items, name) {
+ for (let i = 0; i < items.length; ++i) {
+ const item = items[i];
+ if (item && item.name === name) return item;
+ }
+ return null;
+}
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/collectItem.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/collectItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..35906f3c1f4204cde084f8bee38e107a5a84a93c
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/collectItem.js
@@ -0,0 +1,33 @@
+async function collectItem(bot, count = 1, type, func, tool, recursive = true) {
+ let original_count = await getTotalCount(bot, type);
+ let inv_count = original_count;
+ bot.chat(`Recursively executing collectItem(bot, ${count}, ${type}, ${func}, ${tool})...`);
+ while (inv_count - original_count < count) {
+ await equipBestTool(bot, tool);
+ if (func == "kill") {
+ let mob_list = await preCollect(type);
+ bot.chat(`${JSON.stringify(mob_list)}`);
+ // find entity first
+ const mob = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const mob = bot.nearestEntity(entity => {
+ return mob_list.includes(entity.name) && entity.position.distanceTo(bot.entity.position) < 32;
+ });
+ return mob;
+ });
+ if (!mob) {
+ bot.chat("Could not find a mob.");
+ return false;
+ }
+ // kill the mob using the sword
+ await equipBestTool(bot, tool);
+ await killMob(bot, mob.name, 300);
+ // collect the dropped items
+ await bot.pathfinder.goto(new GoalBlock(mob.position.x, mob.position.y, mob.position.z));
+ bot.chat("Collected dropped items.");
+ } else if (func == "collect_mine") {
+ // find block first
+ }
+ inv_count = await getTotalCount(bot, type);
+ }
+ return true;
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/combatAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/combatAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..e9254075385649cef5f37f1040287e0099266cec
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/combatAPIs.js
@@ -0,0 +1,172 @@
+async function killMob(bot, mobName, timeout = 300) {
+ // return if mobName is not string
+ if (typeof mobName !== "string") {
+ throw new Error(`mobName for killMob must be a string`);
+ }
+ // return if timeout is not number
+ if (typeof timeout !== "number") {
+ throw new Error(`timeout for killMob must be a number`);
+ }
+
+ const weaponsForShooting = [
+ "bow",
+ "crossbow",
+ "snowball",
+ "ender_pearl",
+ "egg",
+ "splash_potion",
+ "trident",
+ ];
+ const mainHandItem = bot.inventory.slots[bot.getEquipmentDestSlot("hand")];
+
+ const entity = bot.nearestEntity(
+ (entity) =>
+ entity.name === mobName &&
+ // kill mob distance should be slightly bigger than explore distance
+ entity.position.distanceTo(bot.entity.position) < 48
+ );
+ if (!entity) {
+ bot.chat(`No ${mobName} nearby, please explore first`);
+ _killMobFailCount++;
+ if (_killMobFailCount > 10) {
+ throw new Error(
+ `killMob failed too many times, make sure you explore before calling killMob`
+ );
+ }
+ return;
+ }
+
+ let droppedItem;
+ if (mainHandItem && weaponsForShooting.includes(mainHandItem.name)) {
+ bot.hawkEye.autoAttack(entity, mainHandItem.name);
+ droppedItem = await waitForMobShot(bot, entity, timeout);
+ } else {
+ await bot.pvp.attack(entity);
+ droppedItem = await waitForMobRemoved(bot, entity, timeout);
+ }
+ if (droppedItem) {
+ await bot.collectBlock.collect(droppedItem, { ignoreNoPath: true });
+ }
+ bot.save(`${mobName}_killed`);
+}
+
+function waitForMobRemoved(bot, entity, timeout = 300) {
+ return new Promise((resolve, reject) => {
+ let success = false;
+ let droppedItem = null;
+ // Set up timeout
+ const timeoutId = setTimeout(() => {
+ success = false;
+ bot.pvp.stop();
+ }, timeout * 1000);
+
+ // Function to handle entityRemoved event
+ function onEntityGone(e) {
+ if (e === entity) {
+ success = true;
+ clearTimeout(timeoutId);
+ bot.chat(`Killed ${entity.name}!`);
+ bot.pvp.stop();
+ }
+ }
+
+ function onItemDrop(item) {
+ if (entity.position.distanceTo(item.position) <= 1) {
+ droppedItem = item;
+ }
+ }
+
+ function onStoppedAttacking() {
+ clearTimeout(timeoutId);
+ bot.removeListener("entityGone", onEntityGone);
+ bot.removeListener("stoppedAttacking", onStoppedAttacking);
+ bot.removeListener("itemDrop", onItemDrop);
+ if (!success) reject(new Error(`Failed to kill ${entity.name}.`));
+ else resolve(droppedItem);
+ }
+
+ // Listen for entityRemoved event
+ bot.on("entityGone", onEntityGone);
+ bot.on("stoppedAttacking", onStoppedAttacking);
+ bot.on("itemDrop", onItemDrop);
+ });
+}
+
+
+function waitForMobShot(bot, entity, timeout = 300) {
+ return new Promise((resolve, reject) => {
+ let success = false;
+ let droppedItem = null;
+ // Set up timeout
+ const timeoutId = setTimeout(() => {
+ success = false;
+ bot.hawkEye.stop();
+ }, timeout * 1000);
+
+ // Function to handle entityRemoved event
+ function onEntityGone(e) {
+ if (e === entity) {
+ success = true;
+ clearTimeout(timeoutId);
+ bot.chat(`Shot ${entity.name}!`);
+ bot.hawkEye.stop();
+ }
+ }
+
+ function onItemDrop(item) {
+ if (entity.position.distanceTo(item.position) <= 1) {
+ droppedItem = item;
+ }
+ }
+
+ function onAutoShotStopped() {
+ clearTimeout(timeoutId);
+ bot.removeListener("entityGone", onEntityGone);
+ bot.removeListener("auto_shot_stopped", onAutoShotStopped);
+ bot.removeListener("itemDrop", onItemDrop);
+ if (!success) reject(new Error(`Failed to shoot ${entity.name}.`));
+ else resolve(droppedItem);
+ }
+
+ // Listen for entityRemoved event
+ bot.on("entityGone", onEntityGone);
+ bot.on("auto_shot_stopped", onAutoShotStopped);
+ bot.on("itemDrop", onItemDrop);
+ });
+}
+
+// shoot 1 pig with a bow: shoot(bot, "bow", "pig");
+async function shoot(bot, weapon, target) {
+ const validWeapons = [
+ "bow",
+ "crossbow",
+ "snowball",
+ "ender_pearl",
+ "egg",
+ "splash_potion",
+ "trident",
+ ];
+ if (!validWeapons.includes(weapon)) {
+ bot.chat(`${weapon} is not a valid weapon for shooting`);
+ return;
+ }
+
+ const weaponItem = mcData.itemsByName[weapon];
+ if (!bot.inventory.findInventoryItem(weaponItem.id, null)) {
+ bot.chat(`No ${weapon} in inventory for shooting`);
+ return;
+ }
+
+ const targetEntity = bot.nearestEntity(
+ (entity) =>
+ entity.name === target
+ );
+ if (!targetEntity) {
+ bot.chat(`No ${target} nearby`);
+ return;
+ }
+ bot.hawkEye.autoAttack(targetEntity, "bow");
+ bot.on('auto_shot_stopped', (target) => {
+ })
+}
+
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/equipAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/equipAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..3e5fd73cad87ec207c6ba52514deb9507fc9107b
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/equipAPIs.js
@@ -0,0 +1,194 @@
+async function equipBestTool(bot, type) {
+ // used to equip the highest level of tools in inventory
+ if (type == "sword") {
+ // find the best sword in the bot's inventory
+ const Sword = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_sword.id)||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_sword.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.stone_sword.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_sword.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.wooden_sword.id);
+ // equip the best sword
+ if (Sword) {
+ await bot.equip(Sword, "hand");
+ bot.chat("Sword equipped.");
+ return true;
+ } else {
+ bot.chat("No sword in inventory.");
+ return false;
+ }
+ } else if (type == "pickaxe") {
+ // find the best pickaxe in the bot's inventory
+ const pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_pickaxe.id)||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_pickaxe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.stone_pickaxe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_pickaxe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.wooden_pickaxe.id);
+ // equip the best pickaxe
+ if (pickaxe) {
+ await bot.equip(pickaxe, "hand");
+ bot.chat("Pickaxe equipped.");
+ return true;
+ } else {
+ bot.chat("No pickaxe in inventory.");
+ return false;
+ }
+ } else if (type == "axe") {
+ // find the best axe in the bot's inventory
+ const axe = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_axe.id)||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_axe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.stone_axe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_axe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.wooden_axe.id);
+ // equip the best axe
+ if (axe) {
+ await bot.equip(axe, "hand");
+ bot.chat("Axe equipped.");
+ return true;
+ } else {
+ bot.chat("No axe in inventory.");
+ return false;
+ }
+ } else if (type == "hoe") {
+ // find the best hoe in the bot's inventory
+ const hoe = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_hoe.id)||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_hoe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.stone_hoe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_hoe.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.wooden_hoe.id);
+ // equip the best hoe
+ if (hoe) {
+ await bot.equip(hoe, "hand");
+ bot.chat("Hoe equipped.");
+ return true;
+ } else {
+ bot.chat("No hoe in inventory.");
+ return false;
+ }
+ } else if (type == "shovel") {
+ // find the best shovel in the bot's inventory
+ const shovel = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_shovel.id)||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_shovel.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.stone_shovel.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_shovel.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.wooden_shovel.id);
+ // equip the best shovel
+ if (shovel) {
+ await bot.equip(shovel, "hand");
+ bot.chat("Shovel equipped.");
+ return true;
+ } else {
+ bot.chat("No shovel in inventory.");
+ return false;
+ }
+ } else if (type == "helmet") {
+ // find the best helmet in the bot's inventory
+ const Helmet = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_helmet.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_helmet.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_helmet.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.chainmail_helmet.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.leather_helmet.id);
+ if (Helmet) {
+ await bot.equip(Helmet, "head");
+ return true;
+ } else {
+ await bot.chat("No helmet found in inventory.");
+ return false;
+ }
+ } else if (type == "chestplate") {
+ // find the best chestplate in the bot's inventory
+ const Chestplate = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_chestplate.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_chestplate.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_chestplate.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.chainmail_chestplate.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.leather_chestplate.id);
+ if (Chestplate) {
+ await bot.equip(Chestplate, "torso");
+ return true;
+ } else {
+ await bot.chat("No chestplate found in inventory.");
+ return false;
+ }
+ } else if (type == "leggings") {
+ // find the best leggings in the bot's inventory
+ const Leggings = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_leggings.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_leggings.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_leggings.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.chainmail_leggings.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.leather_leggings.id);
+ if (Leggings) {
+ await bot.equip(Leggings, "legs");
+ return true;
+ } else {
+ await bot.chat("No leggings found in inventory.");
+ return false;
+ }
+ } else if (type == "boots") {
+ // find the best boots in the bot's inventory
+ const Boots = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_boots.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.iron_boots.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.golden_boots.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.chainmail_boots.id) ||
+ bot.inventory.findInventoryItem(mcData.itemsByName.leather_boots.id);
+ if (Boots) {
+ await bot.equip(Boots, "feet");
+ return true;
+ } else {
+ await bot.chat("No boots found in inventory.");
+ return false;
+ }
+ } else if (type == "shears") {
+ const Shears = bot.inventory.findInventoryItem(mcData.itemsByName.shears.id)
+ if (Shears) {
+ await bot.equip(Shears, "hand");
+ return true;
+ } else {
+ await bot.chat("No shears found in inventory.");
+ return false;
+ }
+ } else if (type == "bucket") {
+ const Bucket = bot.inventory.findInventoryItem(mcData.itemsByName.bucket.id)
+ if (Bucket) {
+ await bot.equip(Bucket, "hand");
+ return true;
+ } else {
+ await bot.chat("No bucket found in inventory.");
+ return false;
+ }
+ } else if (type == "fishing_rod") {
+ const Fishing_rod = bot.inventory.findInventoryItem(mcData.itemsByName.fishing_rod.id)
+ if (Fishing_rod) {
+ await bot.equip(Fishing_rod, "hand");
+ return true;
+ } else {
+ await bot.chat("No fishing_rod found in inventory.");
+ return false;
+ }
+ }
+ else {
+ bot.chat("Invalid type of equipment.");
+ return false;
+ }
+}
+
+async function equipPickaxeOfLevel(bot, level) {
+ // used for equipping a pickaxe of at least 'level'
+ const diamond_pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.diamond_pickaxe.id);
+ const iron_pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.iron_pickaxe.id);
+ const stone_pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.stone_pickaxe.id);
+ const golden_pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.golden_pickaxe.id);
+ const wooden_pickaxe = bot.inventory.findInventoryItem(mcData.itemsByName.wooden_pickaxe.id);
+ const pickaxe_list = [diamond_pickaxe, iron_pickaxe, stone_pickaxe, golden_pickaxe, wooden_pickaxe];
+ const index = ["diamond", "iron", "stone", "golden", "wooden"].indexOf(level);
+ if (index === -1) {
+ bot.chat("Invalid level");
+ return false;
+ }
+ for (let i = 0; i <= index; i++) {
+ let pickaxe = pickaxe_list[i];
+ if (pickaxe) {
+ await bot.equip(pickaxe, "hand");
+ return true;
+ }
+ }
+ return false;
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/jsonAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/jsonAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..e958a0017efaf949545a44d5dc0478c11688be7d
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/jsonAPIs.js
@@ -0,0 +1,73 @@
+const fs = require('fs').promises;
+const path = require('path');
+
+const cur_dir = path.dirname(path.resolve(__filename));
+// cur_dir here is actually mineflayer dir
+const preToolPath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/pre_tool.json');
+const preItemPath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/pre_item.json');
+const preSmeltPath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/pre_smelt.json');
+const preCollectPath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/pre_collect.json')
+const funcPath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/func.json');
+const mapNamePath = path.join(cur_dir, '../../../../MC-Comprehensive-Skill-Library/json/map_name.json');
+
+async function preTool(item) {
+ const data = await fs.readFile(preToolPath, 'utf-8');
+ const json = JSON.parse(data);
+ return json[item];
+}
+
+async function preItem(item) {
+ const data = await fs.readFile(preItemPath, 'utf-8');
+ const json = JSON.parse(data);
+ return json[item];
+}
+
+async function preSmelt(item) {
+ const data = await fs.readFile(preSmeltPath, 'utf-8');
+ const json = JSON.parse(data);
+ return json[item];
+}
+
+async function preCollect(item) {
+ const data = await fs.readFile(preCollectPath, 'utf-8');
+ const json = JSON.parse(data);
+ return json[item];
+}
+
+async function getFunc(item) {
+ const data = await fs.readFile(funcPath, 'utf-8');
+ const json = JSON.parse(data);
+ return json[item];
+}
+
+async function mapName(item) {
+ const data = await fs.readFile(mapNamePath, 'utf-8');
+ const json = JSON.parse(data);
+ if (json[item]) {
+ return json[item];
+ } else {
+ return [item];
+ }
+}
+
+async function getTotalCount(bot, type) {
+ // todo: refactor this
+ let nameList = [];
+ let totalCount = 0;
+ if (type == "plank" || type == "log" || type == "wood") {
+ nameList = await mapName(type);
+ } else {
+ nameList = [type];
+ }
+ for (let itemType of nameList) {
+ if (mcData.itemsByName[itemType]) {
+ let itemId = mcData.itemsByName[itemType].id;
+ let count = bot.inventory.count(itemId);
+ totalCount += count;
+ } else {
+ console.warn(`Item type ${itemType} not found in mcData.itemsByName`);
+ }
+ }
+
+ return totalCount;
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/obtainItem.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/obtainItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..bc67cb54ff7faa5c661f2d0122cda19fa2acc6bc
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/obtainItem.js
@@ -0,0 +1,20 @@
+async function obtainItem(bot, count = 1, type) {
+ let res = false;
+ bot.chat(`Recursively executing obtainItem(bot, ${count}, ${type})...`);
+ while (!res) {
+ let func_name = await getFunc(type);
+ if (func_name == "craft") {
+ res =await craftItem(bot, count, type);
+ } else if (func_name == "mine") {
+ // specify mine or collect: mine_until?
+ res = await mineItem(bot, count, type);
+ } else if (func_name == "smelt") {
+ res = await smeltItem(bot, count, type);
+ } else if (func_name == "kill") {
+ res =await collectItem(bot, count, type, "kill", "sword");
+ } else if (func_name == "collect_mine") {
+ res =await collectItem(bot, count, type, "collect_mine");
+ }
+ }
+ return res;
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/primitiveAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/primitiveAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..cbd564e6fbc5562bff1f5cbe5fce6d44f7a69486
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/primitiveAPIs.js
@@ -0,0 +1,503 @@
+async function craftItem(bot, count = 1, type, need_crafting_table = true, recursive = true) {
+ // return if type is not string
+ if (typeof type !== "string") {
+ throw new Error("type for craftItem must be a string");
+ }
+ // return if count is not number
+ if (typeof count !== "number") {
+ throw new Error("count for craftItem must be a number");
+ }
+ let original_count = await getTotalCount(bot, type);
+ let output_count = 1; // Output count from the recipe
+ if (recursive) {
+ bot.chat(`Recursively executing craftItem(bot, ${count}, ${type})...`);
+ // check pre material
+ let pre_item_info = await preItem(type);
+ // bot.chat(`pre item info of ${type}: ${JSON.stringify(pre_item_info)}`);
+ let [item_array, num, bool] = pre_item_info;
+ output_count = num;
+ need_crafting_table = bool;
+ for (const item of item_array) {
+ let [need_count_str, need_type] = item.split(' ');
+ let need_count = Math.ceil(count / output_count * parseFloat(need_count_str));
+ let inv_count = await getTotalCount(bot, need_type);
+ // if not enough
+ if (inv_count < need_count) {
+ await obtainItem(bot, need_count - inv_count, need_type);
+ }
+ }
+ }
+ let craft_times = Math.ceil(count / output_count);
+ // specify plank type
+ if (type == "plank") {
+ const log_names = ["oak_log", "birch_log", "spruce_log", "jungle_log",
+ "acacia_log", "dark_oak_log", "mangrove_log", "crimson_stem", "warped_stem"];
+ const plank_names = ["oak_planks", "birch_planks", "spruce_planks", "jungle_planks",
+ "acacia_planks", "dark_oak_planks", "mangrove_planks", "crimson_planks", "warped_planks"];
+ let log_in_inventory = log_names.find(logName => bot.inventory.count(mcData.itemsByName[logName].id) > 0);
+ const log_index = log_names.indexOf(log_in_inventory);
+ const plank_name = plank_names[log_index];
+ type = plank_name;
+ }
+ // craft
+ let item_by_name = mcData.itemsByName[type];
+ if (!item_by_name) {
+ throw new Error(`No item named ${type}`);
+ }
+ if (!need_crafting_table) {
+ const recipe = bot.recipesFor(item_by_name.id, null, 1, null)[0];
+ // bot.chat(`recipe: ${JSON.stringify(recipe)}`);
+ try {
+ await bot.craft(recipe, craft_times, null);
+ bot.chat(`I did the recipe for ${type} ${craft_times} times`);
+ } catch (err) {
+ bot.chat(`I cannot do the recipe for ${type} ${craft_times} times`);
+ }
+ } else { // need crafting_table
+ let craftingTableBlock = bot.findBlock({matching: mcData.blocksByName.crafting_table.id, maxDistance: 32,});
+ if (!craftingTableBlock) {
+ // no crafting_table nearby, craft one
+ // check crafting_table in inventory
+ const craftingTable = bot.inventory.findInventoryItem(mcData.itemsByName.crafting_table.id);
+ if (!craftingTable) {
+ if (recursive) {
+ await craftItem(bot, 1, "crafting_table", false, true);
+ } else {
+ bot.chat("Craft without a crafting table");
+ }
+ }
+ // place crafting_table
+ const craftingTablePosition = await findSuitablePosition(bot);
+ await placeItem(bot, "crafting_table", craftingTablePosition);
+ craftingTableBlock = bot.findBlock({matching: mcData.blocksByName.crafting_table.id, maxDistance: 32,});
+ }
+ await bot.pathfinder.goto(new GoalLookAtBlock(craftingTableBlock.position, bot.world));
+ // craft item
+ const recipe = bot.recipesFor(item_by_name.id, null, 1, craftingTableBlock)[0];
+ if (recipe) {
+ try {
+ await bot.craft(recipe, craft_times, craftingTableBlock);
+ bot.chat(`I did the recipe for ${type} ${craft_times} times`);
+ } catch (err) {
+ bot.chat(`I cannot do the recipe for ${type} ${craft_times} times`);
+ }
+ } else {
+ failedCraftFeedback(bot, type, item_by_name, craftingTableBlock);
+ _craftItemFailCount++;
+ if (_craftItemFailCount > 10) {
+ throw new Error(
+ "craftItem failed too many times, check chat log to see what happened"
+ );
+ }
+ }
+ }
+ let after_count = await getTotalCount(bot, type);
+ let craft_count = after_count - original_count;
+ if (craft_count >= count) {
+ await bot.chat(`SUCCESS: ${craft_count} ${type} crafted.`);
+ return true;
+ } else {
+ await bot.chat(`FAILED: only ${craft_count} ${type} crafted.`);
+ return false;
+ }
+}
+
+async function mineItem(bot, count = 1, type, explore_direction = new Vec3(1, 0, 1), explore_time = 300, recursive = true) {
+ if (typeof type !== "string") {
+ throw new Error(`type for mineBlock must be a string`);
+ }
+ if (typeof count !== "number") {
+ throw new Error(`count for mineBlock must be a number`);
+ }
+ // let original_count = await getTotalCount(bot, type);
+ if (recursive) {
+ bot.chat(`Recursively executing mineItem(bot, ${count}, ${type})...`);
+ // get tool_item from pre_tool.json
+ let tool_item = await preTool(type);
+ let check_tool = false;
+ if (tool_item != "none") {
+ if (tool_item.endsWith('_pickaxe')) {
+ let level = tool_item.slice(0, -8); // extract the part before '_pickaxe'
+ res = await equipPickaxeOfLevel(bot, level);
+ check_tool = !res;
+ } else if (tool_item === "axe" || tool_item === "pickaxe" || tool_item === "sword" || tool_item === "shovel" || tool_item === "hoe") {
+ await equipBestTool(bot, tool_item);
+ } else if (tool_item === "bucket" || tool_item === "shears") {
+ check_tool = true;
+ }
+ }
+ if (check_tool) {
+ let tool = bot.inventory.findInventoryItem(mcData.itemsByName[tool_item].id);
+ while (!tool) {
+ await craftItem(bot, 1, tool_item);
+ tool = bot.inventory.findInventoryItem(mcData.itemsByName[tool_item].id);
+ }
+ await bot.equip(tool, "hand");
+ }
+ }
+ // todo: specify explore direction for all items
+ if (type == "diamond") explore_direction = new Vec3(0, -1, 0); // dig down for diamond
+ // find the block
+ let name_list = await mapName(type);
+ const blocks = await exploreUntil(bot, explore_direction, explore_time, () => {
+ const blocks = bot.findBlocks({
+ matching: block => name_list.includes(block.name),
+ maxDistance: 32,
+ count: count
+ });
+ return blocks.length >= 1 ? blocks : null;
+ });
+ if (!blocks) {
+ bot.chat(`Could not find ${type}.`);
+ return;
+ }
+ // todo: many-to-one like flint, one-to-many like redstone, not count-to-count
+ const targets = [];
+ for (let i = 0; i < blocks.length; i++) {
+ targets.push(bot.blockAt(blocks[i]));
+ }
+ await bot.collectBlock.collect(targets, {
+ ignoreNoPath: true,
+ count: count,
+ });
+
+ let inv_count = await getTotalCount(bot, type);
+ if (inv_count >= count) {
+ await bot.chat(`SUCCESS: ${inv_count} ${type} in inventory.`);
+ return true;
+ } else {
+ await bot.chat(`FAILED: only ${inv_count} ${type} in inventory.`);
+ return false;
+ }
+}
+
+async function smeltItem(bot, count = 1, type, fuel = "coal", recursive = true) {
+ // todo: add fuel dynamically; other fuel: wooden material...
+ if (typeof type !== "string" || typeof fuel !== "string") {
+ throw new Error("type(item_name) or fuel_name for smeltItem must be a string");
+ }
+ if (typeof count !== "number") {
+ throw new Error("count for smeltItem must be a number");
+ }
+ const type_in_mc = mcData.itemsByName[type];
+ const fuel_in_mc = mcData.itemsByName[fuel];
+ if (!type_in_mc) throw new Error(`No item named ${type}`);
+ if (!fuel_in_mc) throw new Error(`No item named ${fuel}`);
+
+ let original_count = await getTotalCount(bot, type);
+ let smelt_item_str = await preSmelt(type);
+ if (recursive) {
+ bot.chat(`Recursively executing smeltItem(bot, ${count}, ${type})...`);
+ // check fuel
+ let fuel_item = bot.inventory.findInventoryItem(mcData.itemsByName[fuel].id);
+ if (!fuel_item) {
+ await obtainItem(bot, 1, fuel);
+ fuel_item = bot.inventory.findInventoryItem(mcData.itemsByName[fuel].id);
+ }
+ // check pre_item
+ let inv_count = await getTotalCount(bot, smelt_item_str);
+ // bot.chat(`${smelt_item_str} inv_count=${inv_count}`);
+ // if not enough
+ if (inv_count < count) {
+ await obtainItem(bot, count - inv_count, smelt_item_str);
+ }
+ }
+ let furnaceBlock = bot.findBlock({matching: mcData.blocksByName.furnace.id, maxDistance: 32});
+ if (!furnaceBlock) {
+ if (recursive) {
+ // check furnace
+ let furnace_item = bot.inventory.findInventoryItem(mcData.itemsByName.furnace.id);
+ if (!furnace_item) {
+ await craftItem(bot, 1, "furnace");
+ }
+ } else {
+ throw new Error("No furnace nearby");
+ }
+ }
+ // place the furnace near the bot
+ const furnacePosition = await findSuitablePosition(bot);
+ await placeItem(bot, "furnace", furnacePosition);
+ furnaceBlock = bot.findBlock({matching: mcData.blocksByName.furnace.id, maxDistance: 4});
+ // bot.chat(`smelt_item=${smelt_item_str}, fuel_item=${fuel}`);
+
+ const furnace = await bot.openFurnace(furnaceBlock);
+ let item = mcData.itemsByName[smelt_item_str];
+ let success_count = 0;
+ for (let i = 0; i < count; i++) {
+ if (!bot.inventory.findInventoryItem(item.id, null)) {
+ bot.chat(`No ${type} to smelt in inventory`);
+ break;
+ }
+ if (furnace.fuelSeconds < 15 && furnace.fuelItem()?.name !== fuel) {
+ if (!bot.inventory.findInventoryItem(fuel_in_mc.id, null)) {
+ bot.chat(`No ${fuel} as fuel in inventory`);
+ break;
+ }
+ await furnace.putFuel(fuel_in_mc.id, null, 1);
+ await bot.waitForTicks(20);
+ if (!furnace.fuel && furnace.fuelItem()?.name !== fuel) {
+ throw new Error(`${fuel} is not a valid fuel`);
+ }
+ }
+ await furnace.putInput(item.id, null, 1);
+ await bot.waitForTicks(12 * 20);
+ if (!furnace.outputItem()) {
+ throw new Error(`${type} is not a valid input`);
+ }
+ await furnace.takeOutput();
+ success_count++;
+ }
+ furnace.close();
+ if (success_count > 0) bot.chat(`Smelted ${success_count} ${type}.`);
+ else {
+ bot.chat(
+ `Failed to smelt ${type}, please check the fuel and input.`
+ );
+ _smeltItemFailCount++;
+ if (_smeltItemFailCount > 10) {
+ throw new Error(
+ `smeltItem failed too many times, please check the fuel and input.`
+ );
+ }
+ }
+
+ let after_count = await getTotalCount(bot, type);
+ let smelt_count = after_count - original_count;
+ if (smelt_count >= count) {
+ await bot.chat(`SUCCESS: ${count} ${smelt_item_str} smelted into ${type}.`);
+ return true;
+ } else {
+ await bot.chat(`FAILED: only ${count} ${smelt_item_str} smelted into ${type}.`);
+ return false;
+ }
+}
+
+async function placeItem(bot, name, position) {
+ // return if name is not string
+ if (typeof name !== "string") {
+ throw new Error(`name for placeItem must be a string`);
+ }
+ // return if position is not Vec3
+ if (!(position instanceof Vec3)) {
+ throw new Error(`position for placeItem must be a Vec3`);
+ }
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ throw new Error(`No item named ${name}`);
+ }
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ if (!item) {
+ bot.chat(`No ${name} in inventory`);
+ return;
+ }
+ const item_count = item.count;
+ // find a reference block
+ const faceVectors = [
+ new Vec3(0, 1, 0),
+ new Vec3(0, -1, 0),
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ ];
+ let referenceBlock = null;
+ let faceVector = null;
+ for (const vector of faceVectors) {
+ const block = bot.blockAt(position.minus(vector));
+ if (block?.name !== "air") {
+ referenceBlock = block;
+ faceVector = vector;
+ bot.chat(`Placing ${name} on ${block.name} at ${block.position}`);
+ break;
+ }
+ }
+ if (!referenceBlock) {
+ bot.chat(
+ `No block to place ${name} on. You cannot place a floating block.`
+ );
+ _placeItemFailCount++;
+ if (_placeItemFailCount > 10) {
+ throw new Error(
+ `placeItem failed too many times. You cannot place a floating block.`
+ );
+ }
+ return;
+ }
+
+ // You must use try catch to placeBlock
+ try {
+ // You must first go to the block position you want to place
+ await bot.pathfinder.goto(new GoalPlaceBlock(position, bot.world, {}));
+ // You must equip the item right before calling placeBlock
+ await bot.equip(item, "hand");
+ await bot.placeBlock(referenceBlock, faceVector);
+ bot.chat(`Placed ${name}`);
+ bot.save(`${name}_placed`);
+ } catch (err) {
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ if (item?.count === item_count) {
+ bot.chat(
+ `Error placing ${name}: ${err.message}, please find another position to place`
+ );
+ _placeItemFailCount++;
+ if (_placeItemFailCount > 10) {
+ throw new Error(
+ `placeItem failed too many times, please find another position to place.`
+ );
+ }
+ } else {
+ bot.chat(`Placed ${name}`);
+ bot.save(`${name}_placed`);
+ }
+ }
+}
+
+// Explore downward for 60 seconds: exploreUntil(bot, new Vec3(0, -1, 0), 60);
+async function exploreUntil(
+ bot,
+ direction,
+ maxTime = 60,
+ callback = () => {
+ return false;
+ }
+) {
+ if (typeof maxTime !== "number") {
+ throw new Error("maxTime must be a number");
+ }
+ if (typeof callback !== "function") {
+ throw new Error("callback must be a function");
+ }
+ const test = callback();
+ if (test) {
+ bot.chat("Explore success.");
+ return Promise.resolve(test);
+ }
+ if (direction.x === 0 && direction.y === 0 && direction.z === 0) {
+ throw new Error("direction cannot be 0, 0, 0");
+ }
+ if (
+ !(
+ (direction.x === 0 || direction.x === 1 || direction.x === -1) &&
+ (direction.y === 0 || direction.y === 1 || direction.y === -1) &&
+ (direction.z === 0 || direction.z === 1 || direction.z === -1)
+ )
+ ) {
+ throw new Error(
+ "direction must be a Vec3 only with value of -1, 0 or 1"
+ );
+ }
+ maxTime = Math.min(maxTime, 1200);
+ return new Promise((resolve, reject) => {
+ const dx = direction.x;
+ const dy = direction.y;
+ const dz = direction.z;
+
+ let explorationInterval;
+ let maxTimeTimeout;
+
+ const cleanUp = () => {
+ clearInterval(explorationInterval);
+ clearTimeout(maxTimeTimeout);
+ bot.pathfinder.setGoal(null);
+ };
+
+ const explore = () => {
+ const x =
+ bot.entity.position.x +
+ Math.floor(Math.random() * 20 + 10) * dx;
+ const y =
+ bot.entity.position.y +
+ Math.floor(Math.random() * 20 + 10) * dy;
+ const z =
+ bot.entity.position.z +
+ Math.floor(Math.random() * 20 + 10) * dz;
+ let goal = new GoalNear(x, y, z);
+ if (dy === 0) {
+ goal = new GoalNearXZ(x, z);
+ }
+ bot.pathfinder.setGoal(goal);
+
+ try {
+ const result = callback();
+ if (result) {
+ cleanUp();
+ bot.chat("Explore success.");
+ resolve(result);
+ }
+ } catch (err) {
+ cleanUp();
+ reject(err);
+ }
+ };
+
+ explorationInterval = setInterval(explore, 2000);
+
+ maxTimeTimeout = setTimeout(() => {
+ cleanUp();
+ bot.chat("Max exploration time reached");
+ resolve(null);
+ }, maxTime * 1000);
+ });
+}
+
+function failedCraftFeedback(bot, name, item, craftingTable) {
+ const recipes = bot.recipesAll(item.id, null, craftingTable);
+ if (!recipes.length) {
+ throw new Error(`No crafting table nearby`);
+ } else {
+ const recipes = bot.recipesAll(
+ item.id,
+ null,
+ mcData.blocksByName.crafting_table.id
+ );
+ // find the recipe with the fewest missing ingredients
+ var min = 999;
+ var min_recipe = null;
+ for (const recipe of recipes) {
+ const delta = recipe.delta;
+ var missing = 0;
+ for (const delta_item of delta) {
+ if (delta_item.count < 0) {
+ const inventory_item = bot.inventory.findInventoryItem(
+ mcData.items[delta_item.id].name,
+ null
+ );
+ if (!inventory_item) {
+ missing += -delta_item.count;
+ } else {
+ missing += Math.max(
+ -delta_item.count - inventory_item.count,
+ 0
+ );
+ }
+ }
+ }
+ if (missing < min) {
+ min = missing;
+ min_recipe = recipe;
+ }
+ }
+ const delta = min_recipe.delta;
+ let message = "";
+ for (const delta_item of delta) {
+ if (delta_item.count < 0) {
+ const inventory_item = bot.inventory.findInventoryItem(
+ mcData.items[delta_item.id].name,
+ null
+ );
+ if (!inventory_item) {
+ message += ` ${-delta_item.count} more ${
+ mcData.items[delta_item.id].name
+ }, `;
+ } else {
+ if (inventory_item.count < -delta_item.count) {
+ message += `${
+ -delta_item.count - inventory_item.count
+ } more ${mcData.items[delta_item.id].name}`;
+ }
+ }
+ }
+ }
+ bot.chat(`I cannot make ${name} because I need: ${message}`);
+ }
+}
diff --git a/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/spatialAPIs.js b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/spatialAPIs.js
new file mode 100644
index 0000000000000000000000000000000000000000..74386e3481dff1771416ffa94ee0710bccbc0d0f
--- /dev/null
+++ b/experiments/Odyssey/MC-Comprehensive-Skill-Library/skill/spatialAPIs.js
@@ -0,0 +1,141 @@
+async function findSuitablePosition(bot) {
+ const offsets = [
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ new Vec3(1, 0, 1),
+ new Vec3(-1, 0, 1),
+ new Vec3(-1, 0, 1),
+ new Vec3(-1, 0, -1),
+ new Vec3(1, 1, 0),
+ new Vec3(-1, 1, 0),
+ new Vec3(0, 1, 1),
+ new Vec3(0, 1, -1),
+ new Vec3(1, 1, 1),
+ new Vec3(-1, 1, 1),
+ new Vec3(-1, 1, 1),
+ new Vec3(-1, 1, -1),
+ new Vec3(1, -1, 0),
+ new Vec3(-1, -1, 0),
+ new Vec3(0, -1, 1),
+ new Vec3(0, -1, -1),
+ new Vec3(1, -1, 1),
+ new Vec3(-1, -1, 1),
+ new Vec3(-1, -1, 1),
+ new Vec3(-1, -1, -1),
+ new Vec3(0, 2, 0)
+ ]
+ for (const offset of offsets) {
+ const pos = bot.entity.position.offset(offset.x, offset.y, offset.z);
+ const block = bot.blockAt(pos);
+ const ref = await checkNoAdjacentBlock(bot, ["air", "water"], pos.x, pos.y, pos.z);
+ // bot.chat(`offset: ${offset}; block name: ${block.name}; ref block: ${ref}`);
+ if ((block.name == "air" || block.name == "water") && ref) {
+ return pos;
+ }
+ }
+ return null;
+}
+
+async function checkNoAdjacentBlock(bot, types, x, y, z) {
+ // check adjacent blocks of block at (x, y, z)
+ // return true if not all adjacent blocks are within the specified type
+ const adjacentVec = [
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ new Vec3(0, 1, 0),
+ new Vec3(0, -1, 0)
+ ]
+ for (const offset of adjacentVec) {
+ const pos = new Vec3(x + offset.x, y + offset.y, z + offset.z);
+ const block = bot.blockAt(pos);
+ if (!types.includes(block.name)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function checkAdjacentBlock(bot, types, x, y, z) {
+ // check blocks adjacent to the block at (x, y, z)
+ // return true if any of the adjacent blocks match the specified types
+ const adjacentVec = [
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ new Vec3(0, 1, 0),
+ new Vec3(0, -1, 0)
+ ]
+ for (const offset of adjacentVec) {
+ const pos = new Vec3(x + offset.x, y + offset.y, z + offset.z);
+ const block = bot.blockAt(pos);
+ if (types.includes(block.name)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function checkBlockAbove(bot, type, position) {
+ // check block above the block at position
+ // return true if the above block match the specified type
+ // return if position is not Vec3
+ if (!(position instanceof Vec3)) {
+ throw new Error(`position must be a Vec3`);
+ }
+ const pos = position.plus(new Vec3(0, 1, 0));
+ const block = bot.blockAt(pos);
+ if (type == block.name) {
+ // bot.chat(`block at ${pos} is ${type}.`);
+ return true;
+
+ }
+ return false;
+}
+
+async function checkBlocksAround(bot, type, position) {
+ // check blocks around the block at position
+ // return true if any of the around blocks match the specified type
+ // return if position is not Vec3
+ if (!(position instanceof Vec3)) {
+ throw new Error(`position must be a Vec3`);
+ }
+ const adjacentVec = [
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1)
+ ]
+ for (const offset of adjacentVec) {
+ const pos = position.plus(offset);
+ const block = bot.blockAt(pos);
+ if (type == block.name) {
+ // bot.chat(`block at ${position} is arounded by ${type}.`);
+ return true;
+ }
+ }
+ return false;
+}
+
+async function checkNearbyBlock(bot, types, x, y, z, r) {
+ // check blocks in a radius around the block at (x, y, z)
+ // return true if any block within the radius matches the specified types
+ const minX = x - r;
+ const maxX = x + r;
+ const minZ = z - r;
+ const maxZ = z + r;
+ for (let i = minX; i <= maxX; i++) {
+ for (let j = minZ; j <= maxZ; j++) {
+ const pos = new Vec3(i, y, j);
+ const block = bot.blockAt(pos);
+ if (types.includes(block.name)) {
+ return true;
+ }
+ }
+ }
+ return false;
+}
diff --git a/experiments/Odyssey/Odyssey/conf/config.example.json b/experiments/Odyssey/Odyssey/conf/config.example.json
new file mode 100644
index 0000000000000000000000000000000000000000..a98690c9a668bd9e35386ee71f08452fb33060bc
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/conf/config.example.json
@@ -0,0 +1,25 @@
+{
+ "openai_base_url": "http://127.0.0.1:8000/v1",
+ "openai_api_key": "",
+ "openai_model": "your-model-name",
+ "llm_request_timeout": 240,
+ "llm_thinking_enabled": false,
+ "llm_thinking_effort": "",
+ "llm_thinking_budget_tokens": 0,
+ "llm_thinking_style": "none",
+ "llm_extra_body": {},
+ "NODE_SERVER_PORT": 3000,
+ "SENTENT_EMBEDDING_DIR": "",
+ "EMBEDDING": {
+ "provider": "openai_compatible",
+ "base_url": "http://127.0.0.1:8001/v1",
+ "api_key": "",
+ "api_key_env": "ODYSSEY_EMBEDDING_API_KEY",
+ "model": "your-embedding-model",
+ "timeout": 60,
+ "batch_size": 32,
+ "query_instruction": "Given a Minecraft task instruction, retrieve reusable skills whose behavior and preconditions best satisfy the task."
+ },
+ "MC_SERVER_HOST": "127.0.0.1",
+ "MC_SERVER_PORT": "25565"
+}
diff --git a/experiments/Odyssey/Odyssey/conf/retry_baseline.json b/experiments/Odyssey/Odyssey/conf/retry_baseline.json
new file mode 100644
index 0000000000000000000000000000000000000000..8d25d5233e046bac5904b9b9feb0c29170a3af7c
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/conf/retry_baseline.json
@@ -0,0 +1,15 @@
+{
+ "backend": "retry",
+ "updates": {
+ "memory": false,
+ "non_memory": false,
+ "skills": false,
+ "prompts": false,
+ "skill_health": false
+ },
+ "execution": {
+ "retry_suppression": false,
+ "condition_on_failure_feedback": false,
+ "use_harness_learned_skills": false
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/curriculum/harness_survival_curriculum.txt b/experiments/Odyssey/Odyssey/curriculum/harness_survival_curriculum.txt
new file mode 100644
index 0000000000000000000000000000000000000000..beafeae49fdd3b49e4b93805b8246e0d4bebe948
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/curriculum/harness_survival_curriculum.txt
@@ -0,0 +1,54 @@
+# Harness continual-learning survival curriculum.
+# One non-comment line is one transactional instruction sent to Harness CLI.
+# Tasks use persistent-world-asset terminology without task-specific execution rules.
+
+砍伐一块原木。保留所有工具和有用材料,不要丢弃任何工具。
+再砍伐三块原木。保留所有工具和有用材料,不要丢弃任何工具。
+把已有原木合成为木板。保留所有工具和有用材料。
+用已有木板合成木棍。保留所有工具和有用材料。
+用已有材料合成一个工作台,并将其保留在背包中。不要丢弃工具。
+将背包中的工作台作为可长期复用的世界资产放置,并保留所有工具。不要在之后随意重复放置它。
+在野外再砍伐两块原木,并保留木板、木棍和所有已有工具。
+使用已注册且可复用的工作台和已有材料合成一把木镐,并保留在背包中。
+使用木镐挖掘一块圆石,并保留木镐。
+使用木镐挖掘三块圆石,并保留木镐和圆石。
+使用已放置的工作台、木棍和圆石合成一把石镐,并保留木镐和石镐。
+使用石镐挖掘八块圆石,并保留木镐和石镐。
+用已有材料合成一个熔炉,并保留所有工具。
+将熔炉作为可长期复用的世界资产放置。保留所有工具,不要在其他地点重复放置熔炉。
+用已有材料合成一把石斧,并保留石斧、石镐和木镐。
+使用石斧再砍伐三块原木,并保留所有工具。
+把新获得的原木合成为木板,并保留所有工具。
+用已有材料合成一个箱子,并保留所有工具。
+将箱子作为可长期复用的世界资产放置。保留所有工具,不要在其他地点重复放置箱子。
+诊断当前背包和已注册世界资产,确认工作台、熔炉、箱子、木镐、石镐和石斧仍然可用。不要丢弃工具。
+使用石镐寻找并挖掘一块煤矿石,保留煤和所有工具。
+使用石镐寻找并挖掘三块煤矿石,保留煤和所有工具。
+使用石镐寻找并挖掘一块铁矿石,或获得一份粗铁。保留所有工具。
+使用石镐寻找并获得三份粗铁,保留所有工具。
+使用已放置的熔炉和可用煤,把一份粗铁烧成铁锭,并保留所有工具。
+使用已放置的熔炉和煤,把三份粗铁烧成铁锭,并保留所有工具。
+使用已放置的工作台、木棍和铁锭合成一把铁镐,并保留木镐、石镐和铁镐。
+使用铁镐挖掘一块圆石,并保留所有工具。
+使用铁镐再挖掘四块圆石,并保留所有工具。
+使用石斧砍伐两块原木,并保留所有工具。
+诊断已注册世界资产的坐标和状态。若任何已注册资产缺失,只能在其已注册坐标修复,不要新建无关资产。
+砍伐四块原木,并保留所有工具。
+把新获得的原木合成为木板,并按需合成木棍。保留所有工具。
+使用已放置的工作台合成一把石锹,并保留所有工具。
+使用石锹挖掘三块泥土,并保留所有工具。
+使用铁镐挖掘四块圆石,并保留所有工具。
+使用已放置的工作台合成一把石剑,并保留木镐、石镐、石斧、石锹和石剑。
+使用铁镐再获得两份煤,并保留煤和所有工具。
+使用铁镐再获得两份粗铁,并保留所有工具。
+使用已放置的熔炉将两份粗铁烧成铁锭,并保留所有工具。
+使用已放置的工作台、木棍和铁锭合成一把铁斧,并保留所有工具。
+使用铁斧砍伐四块原木,并保留所有工具。
+将新获得的原木加工为木板,并按需补充木棍。保留所有工具和已注册世界资产。
+使用已放置的工作台合成一把铁锹,并保留所有工具。
+使用铁镐获得四份煤,并保留所有工具。
+使用铁镐获得三份粗铁,并保留所有工具。
+使用已放置的熔炉和可用燃料将三份粗铁烧成铁锭,并保留所有工具。
+使用铁镐挖掘两块圆石,并保留所有工具。
+使用铁斧砍伐三块原木,并保留所有工具。
+使用铁锹挖掘三块泥土,并保留所有工具。
diff --git a/experiments/Odyssey/Odyssey/harness_cli.py b/experiments/Odyssey/Odyssey/harness_cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..5a9b5afdb47000f06d4e0316ab4833ae58a61459
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/harness_cli.py
@@ -0,0 +1,68 @@
+import argparse
+import traceback
+
+from main import build_odyssey
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run the interactive Odyssey Harness.")
+ parser.add_argument(
+ "--memory-config",
+ default=None,
+ help="Optional Harness backend JSON, e.g. conf/retry_baseline.json.",
+ )
+ args = parser.parse_args()
+ agent = build_odyssey(
+ environment="harness",
+ username="harness_bot",
+ env_wait_ticks=40,
+ max_iterations=32,
+ memory_config_path=args.memory_config,
+ )
+ print("Harness interactive mode. Type an instruction, or type exit to quit.")
+ print("Minecraft must already be running and reachable by the configured Mineflayer port.")
+ try:
+ agent.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": agent.env_wait_ticks,
+ "username": agent.username,
+ }
+ )
+ agent.run_raw_skill("odyssey/test_env/respawnAndClear.js")
+ agent.last_events = agent.env.step("")
+ reset_next = False
+ while True:
+ instruction = input("mc> ").strip()
+ if not instruction:
+ continue
+ if instruction.lower() in {"exit", "quit", "q"}:
+ break
+ try:
+ result = agent.harness_run_instruction(
+ instruction,
+ reset_env=reset_next,
+ reset_mode="hard",
+ max_steps=3,
+ )
+ reset_next = False
+ print(f"success: {result.get('success')}")
+ route = result.get("route") or {}
+ if route:
+ print(f"route: {route.get('decision')} {route.get('skill_name', '')}")
+ if route.get("reasoning"):
+ print(f"reason: {route.get('reasoning')}")
+ evaluation = result.get("evaluation") or {}
+ if evaluation.get("critique"):
+ print(f"critique: {evaluation.get('critique')}")
+ if result.get("needs_user"):
+ print(result.get("message", "Harness needs more information."))
+ except Exception as exc: # noqa: BLE001
+ print(f"error: {exc}")
+ traceback.print_exc()
+ finally:
+ agent.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experiments/Odyssey/Odyssey/harness_curriculum_cli.py b/experiments/Odyssey/Odyssey/harness_curriculum_cli.py
new file mode 100644
index 0000000000000000000000000000000000000000..ede701dd84d756ae4520f96b8bc7be4979741d9f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/harness_curriculum_cli.py
@@ -0,0 +1,516 @@
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import re
+import traceback
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+
+DEFAULT_TASKS = Path("curriculum/harness_survival_curriculum.txt")
+DEFAULT_USERNAME = "hcurrbot"
+MINECRAFT_USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_]{1,16}$")
+
+
+def load_tasks(path: Path) -> list[str]:
+ tasks = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ stripped = line.strip()
+ if not stripped or stripped.startswith("#"):
+ continue
+ tasks.append(stripped)
+ return tasks
+
+
+def result_summary(result: dict) -> dict:
+ route = result.get("route") or {}
+ evaluation = result.get("evaluation") or {}
+ return {
+ "success": result.get("success"),
+ "needs_user": result.get("needs_user", False),
+ "route_decision": route.get("decision", ""),
+ "skill_name": route.get("skill_name", ""),
+ "reasoning": route.get("reasoning", ""),
+ "critique": evaluation.get("critique", ""),
+ "subgoals": result.get("subgoals", []),
+ "completed_subgoals": result.get("completed_subgoals", []),
+ "replan_count": result.get("replan_count", 0),
+ "inventory": result.get("inventory", {}),
+ }
+
+
+def append_jsonl(path: Path, record: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("a", encoding="utf-8") as fp:
+ fp.write(json.dumps(record, ensure_ascii=False) + "\n")
+
+
+def write_json(path: Path, record: dict) -> None:
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary_path = path.with_suffix(path.suffix + ".tmp")
+ temporary_path.write_text(
+ json.dumps(record, ensure_ascii=False, indent=2),
+ encoding="utf-8",
+ )
+ temporary_path.replace(path)
+
+
+def load_json(path: Path) -> dict[str, Any] | None:
+ if not path.exists():
+ return None
+ try:
+ value = json.loads(path.read_text(encoding="utf-8"))
+ except (OSError, json.JSONDecodeError):
+ return None
+ return value if isinstance(value, dict) else None
+
+
+def read_task_records(path: Path) -> list[dict[str, Any]]:
+ if not path.exists():
+ return []
+ records = []
+ for line in path.read_text(encoding="utf-8").splitlines():
+ try:
+ value = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if isinstance(value, dict):
+ records.append(value)
+ return records
+
+
+def resolve_run_dir(value: str) -> Path:
+ candidate = Path(value)
+ if candidate.is_dir():
+ return candidate
+ candidate = Path("ckpt/harness/curriculum_runs") / value
+ if candidate.is_dir():
+ return candidate
+ raise ValueError(f"Resume run not found: {value}")
+
+
+def latest_observation(events: list[tuple[str, dict]] | None) -> dict:
+ for event_type, event in reversed(events or []):
+ if event_type == "observe" and isinstance(event, dict):
+ return event
+ for _, event in reversed(events or []):
+ if isinstance(event, dict) and "inventory" in event:
+ return event
+ return {}
+
+
+def infer_resume_state(run_dir: Path, task_count: int, default_start: int) -> dict[str, Any]:
+ """Recover legacy runs that predate resume_state.json.
+
+ A task is committed only after its summary says success. If a process was
+ killed while task N was running, task N is deliberately selected again;
+ the Harness re-observes the persistent Minecraft world and finishes the
+ original task contract rather than resetting the world.
+ """
+ state_path = run_dir / "resume_state.json"
+ state = load_json(state_path)
+ if state:
+ return state
+ successful = {
+ int(record["task_index"])
+ for record in read_task_records(run_dir / "tasks.jsonl")
+ if isinstance(record.get("task_index"), int)
+ and isinstance(record.get("summary"), dict)
+ and record["summary"].get("success") is True
+ }
+ # Legacy runs did not retain an active-task write-ahead record. They may
+ # also contain an error entry followed by later successful tasks because
+ # the old CLI continued after errors. The best durable recovery point is
+ # therefore the task immediately after the highest committed success.
+ next_task = max(successful, default=default_start - 1) + 1
+ return {
+ "version": 1,
+ "status": "legacy_recovered",
+ "committed_task_indices": sorted(successful),
+ "next_task_index": next_task,
+ "active_task": None,
+ "updated_at": datetime.now().isoformat(timespec="seconds"),
+ }
+
+
+def save_resume_state(path: Path, state: dict[str, Any]) -> None:
+ state["updated_at"] = datetime.now().isoformat(timespec="seconds")
+ write_json(path, state)
+
+
+def validate_username(username: str) -> None:
+ if MINECRAFT_USERNAME_PATTERN.fullmatch(username):
+ return
+ raise ValueError(
+ "Minecraft bot username must be 1-16 characters and contain only letters, numbers, or underscores. "
+ f"Got {username!r} with length {len(username)}."
+ )
+
+
+def validate_spawn_coordinates(values: dict[str, float | None]) -> None:
+ for name, value in values.items():
+ if value is not None and not math.isfinite(value):
+ raise ValueError(f"--spawn-{name} must be a finite number, got {value!r}.")
+
+
+def spawn_skill_parameters(spawn: dict[str, float | None]) -> list[float]:
+ """Build positional JS arguments without serializing Python None."""
+ values = [
+ float(spawn["x"]),
+ float(spawn["z"]),
+ float(spawn.get("yaw") or 0),
+ float(spawn.get("pitch") or 0),
+ ]
+ if spawn.get("y") is not None:
+ values.append(float(spawn["y"]))
+ return values
+
+
+def transaction_baseline(
+ previous_active: dict[str, Any],
+ observed_inventory: dict[str, Any],
+) -> dict[str, Any]:
+ """Return the write-ahead baseline, never a later resume observation."""
+ baseline = previous_active.get("baseline_inventory")
+ return dict(baseline) if isinstance(baseline, dict) else dict(observed_inventory)
+
+
+def contract_uses_baseline(contract: Any, baseline_inventory: dict[str, Any]) -> bool:
+ if not isinstance(contract, dict):
+ return False
+ saved = contract.get("baseline_inventory")
+ if not isinstance(saved, dict):
+ return False
+ try:
+ return {str(key): int(value) for key, value in saved.items()} == {
+ str(key): int(value) for key, value in baseline_inventory.items()
+ }
+ except (TypeError, ValueError):
+ return False
+
+
+def contract_uses_semantic_compiler(contract: Any) -> bool:
+ """Migrate older semantic interpretations using their original transaction baseline."""
+ return (
+ isinstance(contract, dict)
+ and contract.get("semantic_contract_version") == 2
+ and contract.get("semantic_source") in {
+ "llm_validated",
+ "narrow_rule_fallback",
+ }
+ )
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Run a sequential Harness continual-learning curriculum.")
+ parser.add_argument("--tasks", type=Path, default=DEFAULT_TASKS, help="Path to a text file with one task per line.")
+ parser.add_argument("--start", type=int, default=1, help="1-based task index to start from.")
+ parser.add_argument("--limit", type=int, default=None, help="Maximum number of tasks to run.")
+ parser.add_argument("--max-steps", type=int, default=3, help="Harness attempts per subgoal.")
+ parser.add_argument("--max-replans", type=int, default=2, help="Replans after subgoal failure.")
+ parser.add_argument("--stop-on-failure", action="store_true", help="Stop the curriculum when one task fails.")
+ parser.add_argument("--pause", action="store_true", help="Wait for Enter before each task.")
+ parser.add_argument("--username", default=DEFAULT_USERNAME, help="Minecraft bot username, max 16 characters.")
+ parser.add_argument("--env-wait-ticks", type=int, default=40, help="Mineflayer wait ticks after reset/step.")
+ parser.add_argument(
+ "--memory-config",
+ type=Path,
+ default=None,
+ help="Optional Harness backend JSON, e.g. conf/retry_baseline.json.",
+ )
+ parser.add_argument("--spawn-x", type=float, default=0.5, help="Fixed initial X coordinate for a new run.")
+ parser.add_argument("--spawn-z", type=float, default=0.5, help="Fixed initial Z coordinate for a new run.")
+ parser.add_argument(
+ "--spawn-y",
+ type=float,
+ default=None,
+ help="Optional fixed initial Y. By default use the terrain surface at spawn X/Z.",
+ )
+ parser.add_argument("--spawn-yaw", type=float, default=0.0, help="Fixed initial yaw.")
+ parser.add_argument("--spawn-pitch", type=float, default=0.0, help="Fixed initial pitch.")
+ parser.add_argument(
+ "--resume-run",
+ "--resume",
+ dest="resume_run",
+ help="Existing run id or run directory to resume without resetting the Minecraft world.",
+ )
+ args = parser.parse_args()
+ validate_username(args.username)
+ requested_spawn = {
+ "x": args.spawn_x,
+ "y": args.spawn_y,
+ "z": args.spawn_z,
+ "yaw": args.spawn_yaw,
+ "pitch": args.spawn_pitch,
+ }
+ validate_spawn_coordinates(requested_spawn)
+ spawn_parameters = spawn_skill_parameters(requested_spawn)
+
+ tasks = load_tasks(args.tasks)
+ if args.start < 1 or args.start > len(tasks):
+ raise ValueError(f"--start must be between 1 and {len(tasks)}")
+ if args.resume_run:
+ run_dir = resolve_run_dir(args.resume_run)
+ run_metadata = load_json(run_dir / "run.json") or {}
+ saved_tasks_file = run_metadata.get("tasks_file")
+ if saved_tasks_file and Path(saved_tasks_file) != args.tasks:
+ raise ValueError(
+ f"Resume run was created with tasks file {saved_tasks_file!r}; got {str(args.tasks)!r}."
+ )
+ saved_spawn = run_metadata.get("initial_spawn")
+ if saved_spawn and saved_spawn.get("requested") != requested_spawn:
+ print(
+ "Resume mode ignores the newly supplied spawn coordinates and preserves "
+ "the bot's live position from the existing transaction."
+ )
+ resume_state = infer_resume_state(run_dir, len(tasks), args.start)
+ active_task = resume_state.get("active_task") or {}
+ start_index = int(active_task.get("task_index") or resume_state.get("next_task_index") or args.start)
+ if start_index > len(tasks):
+ print(f"Run {run_dir.name} is already complete.")
+ return
+ run_id = run_dir.name
+ resumed = True
+ else:
+ run_id = datetime.now().strftime("%Y%m%d_%H%M%S")
+ run_dir = Path("ckpt/harness/curriculum_runs") / run_id
+ start_index = args.start
+ resumed = False
+ resume_state = {
+ "version": 1,
+ "status": "ready",
+ "committed_task_indices": [],
+ "next_task_index": start_index,
+ "active_task": None,
+ }
+
+ selected = tasks[start_index - 1 :]
+ if args.limit is not None:
+ selected = selected[: args.limit]
+
+ summary_path = run_dir / "tasks.jsonl"
+ steps_path = run_dir / "steps.jsonl"
+ state_path = run_dir / "resume_state.json"
+ run_dir.mkdir(parents=True, exist_ok=True)
+ if not resumed:
+ write_json(
+ run_dir / "run.json",
+ {
+ "run_id": run_id,
+ "started_at": datetime.now().isoformat(timespec="seconds"),
+ "tasks_file": str(args.tasks),
+ "start": args.start,
+ "limit": args.limit,
+ "max_steps": args.max_steps,
+ "max_replans": args.max_replans,
+ "username": args.username,
+ "memory_config": str(args.memory_config) if args.memory_config else None,
+ "initial_spawn": {
+ "requested": requested_spawn,
+ "resolved": None,
+ },
+ },
+ )
+ save_resume_state(state_path, resume_state)
+
+ from main import build_odyssey
+
+ agent = build_odyssey(
+ environment="harness_curriculum",
+ username=args.username,
+ env_wait_ticks=args.env_wait_ticks,
+ max_iterations=64,
+ memory_config_path=str(args.memory_config) if args.memory_config else None,
+ # Resume only Harness transaction/memory state. The original Odyssey
+ # Action/Curriculum checkpoints are independent and need not exist.
+ harness_resume=resumed,
+ )
+ from odyssey.harness.completion_contract import build_completion_contract
+
+ print(f"Loaded {len(tasks)} curriculum tasks from {args.tasks}")
+ print(f"Running {len(selected)} tasks starting at index {start_index}")
+ print(f"Results will be written to {run_dir}")
+ if resumed:
+ print("Resume mode: preserving the existing Minecraft world, bot inventory, and position.")
+ else:
+ y_description = requested_spawn["y"] if requested_spawn["y"] is not None else "terrain surface"
+ print(
+ "Deterministic initial spawn: "
+ f"x={requested_spawn['x']}, y={y_description}, z={requested_spawn['z']}"
+ )
+ print("Minecraft must already be running through PCL and opened to LAN.")
+
+ try:
+ if resumed:
+ # Bridge.step requires a per-process reset handshake. A soft reset
+ # only reconnects Mineflayer and observes the existing player; it
+ # does not invoke respawnAndClear or clear inventory/world state.
+ agent.last_events = agent.env.reset(
+ options={
+ "mode": "soft",
+ "wait_ticks": agent.env_wait_ticks,
+ "username": agent.username,
+ }
+ )
+ if not agent.last_events:
+ agent.last_events = agent.env.step("")
+ observation = latest_observation(agent.last_events)
+ resume_state["resume_observation"] = {
+ "time": datetime.now().isoformat(timespec="seconds"),
+ "inventory": observation.get("inventory", {}),
+ "position": (observation.get("status") or {}).get("position", {}),
+ }
+ resume_state["status"] = "running"
+ save_resume_state(state_path, resume_state)
+ else:
+ agent.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": agent.env_wait_ticks,
+ "username": agent.username,
+ }
+ )
+ agent.run_raw_skill("odyssey/test_env/respawnAndClear.js", spawn_parameters)
+ agent.last_events = agent.env.step("")
+ initial_observation = latest_observation(agent.last_events)
+ run_metadata = load_json(run_dir / "run.json") or {}
+ initial_spawn = run_metadata.get("initial_spawn") or {"requested": requested_spawn}
+ initial_spawn["resolved"] = (initial_observation.get("status") or {}).get("position", {})
+ run_metadata["initial_spawn"] = initial_spawn
+ write_json(run_dir / "run.json", run_metadata)
+
+ for offset, task in enumerate(selected, start=start_index):
+ # A curriculum file may be improved while a run is paused. The
+ # active transaction must nevertheless execute its originally
+ # persisted instruction, otherwise its write-ahead contract and
+ # the instruction being evaluated could silently diverge.
+ persisted_active = resume_state.get("active_task") or {}
+ if persisted_active.get("task_index") == offset and isinstance(persisted_active.get("instruction"), str):
+ task = persisted_active["instruction"]
+ print("\n" + "=" * 80)
+ print(f"Task {offset}/{len(tasks)}")
+ print(task)
+ if args.pause:
+ input("Press Enter to run this task...")
+
+ # Write-ahead task record: after a forced stop this remains the
+ # active task and resume will re-observe the world before retrying
+ # this exact original instruction.
+ previous_active = resume_state.get("active_task") or {}
+ observed_inventory = latest_observation(agent.last_events).get("inventory", {})
+ baseline_inventory = transaction_baseline(previous_active, observed_inventory)
+ original_contract = previous_active.get("completion_contract")
+ # A contract is part of the transaction. On resume it must be
+ # anchored to the write-ahead inventory snapshot from when this
+ # task first started, not to the partially completed live state.
+ # The baseline check also migrates transactions produced by the
+ # earlier resume implementation that used the later observation.
+ if (
+ not contract_uses_baseline(original_contract, baseline_inventory)
+ or not contract_uses_semantic_compiler(original_contract)
+ ):
+ original_contract = build_completion_contract(
+ task,
+ baseline_inventory,
+ model_name=agent.action_agent_model_name,
+ )
+ resume_state["status"] = "running"
+ resume_state["next_task_index"] = offset
+ resume_state["active_task"] = {
+ "task_index": offset,
+ "instruction": task,
+ "started_at": datetime.now().isoformat(timespec="seconds"),
+ "baseline_inventory": baseline_inventory,
+ "completion_contract": original_contract,
+ }
+ save_resume_state(state_path, resume_state)
+
+ try:
+ result = agent.harness_run_instruction(
+ task,
+ reset_env=False,
+ reset_mode="hard",
+ max_steps=args.max_steps,
+ max_replans=args.max_replans,
+ completion_contract=original_contract,
+ )
+ summary = result_summary(result)
+ print(f"success: {summary['success']}")
+ print(f"route: {summary['route_decision']} {summary['skill_name']}")
+ if summary["reasoning"]:
+ print(f"reason: {summary['reasoning']}")
+ if summary["critique"]:
+ print(f"critique: {summary['critique']}")
+ task_record = {
+ "time": datetime.now().isoformat(timespec="seconds"),
+ "task_index": offset,
+ "task": task,
+ "summary": summary,
+ }
+ append_jsonl(summary_path, task_record)
+ write_json(
+ run_dir / f"task_{offset:03d}.json",
+ {
+ **task_record,
+ "steps": result.get("trajectory", []),
+ },
+ )
+ for entry in result.get("trajectory", []):
+ append_jsonl(
+ steps_path,
+ {
+ "task_index": offset,
+ "task": task,
+ **entry,
+ },
+ )
+ if summary["success"]:
+ committed = set(resume_state.get("committed_task_indices", []))
+ committed.add(offset)
+ resume_state["committed_task_indices"] = sorted(committed)
+ resume_state["next_task_index"] = offset + 1
+ resume_state["active_task"] = None
+ resume_state["status"] = "ready"
+ else:
+ # A failed task is deliberately not committed. If the
+ # process stops, resume returns here and evaluates the
+ # original contract against the live world state.
+ resume_state["status"] = "retryable_failure"
+ save_resume_state(state_path, resume_state)
+ if not summary["success"]:
+ print("Task was not committed; stopping so resume can retry this task transactionally.")
+ break
+ except Exception as exc: # noqa: BLE001
+ print(f"error: {exc}")
+ traceback.print_exc()
+ error_record = {
+ "time": datetime.now().isoformat(timespec="seconds"),
+ "task_index": offset,
+ "task": task,
+ "error": str(exc),
+ "traceback": traceback.format_exc(),
+ }
+ append_jsonl(summary_path, error_record)
+ write_json(run_dir / f"task_{offset:03d}.json", error_record)
+ resume_state["status"] = "interrupted"
+ resume_state["next_task_index"] = offset
+ # Keep active_task intact so resume retries this task.
+ save_resume_state(state_path, resume_state)
+ print("Task raised an exception; stopping so resume can retry this task transactionally.")
+ break
+ finally:
+ if resume_state.get("next_task_index", 1) > len(tasks):
+ resume_state["status"] = "completed"
+ resume_state["active_task"] = None
+ save_resume_state(state_path, resume_state)
+ if resume_state.get("status") == "running":
+ resume_state["status"] = "interrupted"
+ save_resume_state(state_path, resume_state)
+ agent.close()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/experiments/Odyssey/Odyssey/main.py b/experiments/Odyssey/Odyssey/main.py
new file mode 100644
index 0000000000000000000000000000000000000000..8472978921e0c4fcfbbe49c18c34a33be1f54abb
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/main.py
@@ -0,0 +1,169 @@
+import traceback
+
+from odyssey.odyssey import Odyssey
+from odyssey.utils import config
+from odyssey.utils.logger import get_logger
+
+
+logger = get_logger("main")
+mc_port = config.get("MC_SERVER_PORT")
+mc_host = config.get("MC_SERVER_HOST")
+node_port = config.get("NODE_SERVER_PORT")
+embedding_config = config.get("EMBEDDING") or config.get("SENTENT_EMBEDDING_DIR")
+env_wait_ticks = 100
+
+
+def build_odyssey(environment=None, username="bot", **kwargs):
+ wait_ticks = kwargs.pop("env_wait_ticks", env_wait_ticks)
+ return Odyssey(
+ mc_port=mc_port,
+ mc_host=mc_host,
+ env_wait_ticks=wait_ticks,
+ skill_library_dir="./skill_library",
+ reload=True,
+ embedding_dir=embedding_config,
+ environment=environment,
+ # The ordinary Odyssey agents start fresh for curriculum harness runs.
+ # Harness transaction recovery is controlled separately by
+ # ``harness_resume`` so it never attempts to load ckpt/action or
+ # ckpt/curriculum.
+ resume=False,
+ server_port=node_port,
+ username=username,
+ **kwargs,
+ )
+
+
+def debug_lightweight():
+ """Run a small LLM-driven task to craft a stone pickaxe and drop it."""
+ odyssey_debug = build_odyssey(
+ environment="subgoal",
+ username="debug_bot",
+ env_wait_ticks=40,
+ max_iterations=8,
+ action_agent_task_max_retries=2,
+ )
+ try:
+ odyssey_debug.inference_sub_goal(
+ task="debug_craft_and_drop_stone_pickaxe",
+ sub_goals=["craft stone pickaxe", "drop stone pickaxe"],
+ reset_mode="soft",
+ reset_env=False,
+ )
+ finally:
+ odyssey_debug.close()
+
+
+def test_subgoal():
+ odyssey_agent = build_odyssey(environment="subgoal")
+ test_sub_goals = [
+ "craft crafting table",
+ "craft wooden pickaxe",
+ "craft stone pickaxe",
+ "craft iron pickaxe",
+ "mine diamond",
+ ]
+ while True:
+ try:
+ odyssey_agent.inference_sub_goal(
+ task="subgoal_openai",
+ sub_goals=test_sub_goals,
+ )
+ except Exception as e:
+ logger.critical(e)
+ traceback.print_exc()
+
+
+def test_combat():
+ odyssey_agent = build_odyssey(environment="combat")
+ combat_benchmark = [
+ "1 skeleton",
+ "1 spider",
+ "1 zombified_piglin",
+ "1 zombie",
+ "1 zombie, 1 skeleton",
+ "1 zombie, 1 spider",
+ "1 zombie, 1 skeleton, 1 spider",
+ "3 zombie",
+ ]
+ multi_round_tasks = ["1 zombie", "1 skeleton", "1 spider"]
+ max_retry = 3
+
+ while True:
+ _run_combat_tasks(odyssey_agent, combat_benchmark, feedback_rounds=1, max_retry=max_retry)
+ _run_combat_tasks(odyssey_agent, multi_round_tasks, feedback_rounds=3, max_retry=max_retry)
+
+
+def _run_combat_tasks(odyssey_agent, tasks, feedback_rounds, max_retry):
+ retry = max_retry
+ i = 0
+ while i < len(tasks):
+ task = tasks[i]
+ try:
+ odyssey_agent.inference(task=task, reset_env=False, feedback_rounds=feedback_rounds)
+ i += 1
+ retry = max_retry
+ except Exception as e:
+ logger.critical(f"{task} failed. retry...")
+ logger.critical(e)
+ traceback.print_exc()
+ if retry > 0:
+ retry -= 1
+ continue
+ i += 1
+ retry = max_retry
+
+
+def explore():
+ odyssey_agent = build_odyssey(environment="explore")
+ odyssey_agent.learn()
+
+
+def test_farming():
+ odyssey_agent = build_odyssey(environment="farming")
+ farming_benchmark = [
+ "collect 1 seed (wheat or melon or pumpkin)",
+ "hoe a farmland",
+ "collect 1 wool by shearing 1 sheep",
+ "collect 1 bucket of milk",
+ "cook 1 meat (beef or mutton or pork or chicken)",
+ "obtain 1 leather",
+ "make 1 sugar",
+ "collect 1 bucket of water",
+ ]
+ while True:
+ for task in farming_benchmark:
+ try:
+ odyssey_agent.learn(goals=task, reset_env=False)
+ except Exception as e:
+ logger.critical(f"{task} failed. retry...")
+ logger.critical(e)
+ traceback.print_exc()
+
+
+def test_skill(skill_name):
+ odyssey_skill = build_odyssey()
+ odyssey_skill.run_raw_skill(f"./skill_library/skill/compositional/{skill_name}", skill_lib="old", reset=True)
+ while True:
+ odyssey_skill.run_raw_skill(f"./skill_library/skill/compositional/{skill_name}", reset=False)
+
+
+def test_mc_skill(skill_name, para_list):
+ odyssey_mc_skill = build_odyssey(username="bot")
+ odyssey_mc_skill.run_raw_skill(
+ f"../MC-Comprehensive-Skill-Library/skill/{skill_name}",
+ para_list,
+ skill_lib="new",
+ reset=True,
+ )
+ while True:
+ odyssey_mc_skill.run_raw_skill(
+ f"../MC-Comprehensive-Skill-Library/skill/{skill_name}",
+ para_list,
+ skill_lib="new",
+ reset=False,
+ )
+
+
+if __name__ == "__main__":
+ debug_lightweight()
diff --git a/experiments/Odyssey/Odyssey/odyssey/__init__.py b/experiments/Odyssey/Odyssey/odyssey/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0398484036cfdfc916bcc8802c7e5ced4f81f71
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/__init__.py
@@ -0,0 +1,9 @@
+def __getattr__(name):
+ if name == "Odyssey":
+ from .odyssey import Odyssey
+
+ return Odyssey
+ raise AttributeError(name)
+
+
+__all__ = ["Odyssey"]
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/__init__.py b/experiments/Odyssey/Odyssey/odyssey/agents/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa8683e49ca2727e146f836d860b46ffbee67cd1
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/__init__.py
@@ -0,0 +1,6 @@
+from .actor import ActionAgent
+from .critic import CriticAgent
+from .comment import CommentAgent
+from .planner import PlannerAgent
+from .skill import SkillManager
+from .llama import call_with_messages
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/actor.py b/experiments/Odyssey/Odyssey/odyssey/agents/actor.py
new file mode 100644
index 0000000000000000000000000000000000000000..df9bd7b63f1d64971b5be080b79dcbc9c680ec99
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/actor.py
@@ -0,0 +1,233 @@
+import re
+import time
+
+import odyssey.utils as U
+from odyssey.utils.json_utils import fix_and_parse_json
+from javascript import require
+from langchain.schema import AIMessage, HumanMessage, SystemMessage
+
+from odyssey.prompts import load_prompt
+from odyssey.control_primitives_context import load_control_primitives_context
+from odyssey.utils.logger import get_logger
+
+class ActionAgent:
+ def __init__(
+ self,
+ request_timout=120,
+ ckpt_dir="ckpt",
+ resume=False,
+ chat_log=True,
+ execution_error=True,
+ ):
+ self.ckpt_dir = ckpt_dir
+ self.chat_log = chat_log
+ self.execution_error = execution_error
+ self.logger = get_logger("ActionAgent")
+ U.f_mkdir(f"{ckpt_dir}/action")
+ if resume:
+ self.logger.info(f"Loading Action Agent from {ckpt_dir}/action")
+ self.chest_memory = U.load_json(f"{ckpt_dir}/action/chest_memory.json")
+ else:
+ self.chest_memory = {}
+
+ def update_chest_memory(self, chests):
+ U.f_mkdir(f"{self.ckpt_dir}/action")
+ for position, chest in chests.items():
+ if position in self.chest_memory:
+ if isinstance(chest, dict):
+ self.chest_memory[position] = chest
+ if chest == "Invalid":
+ self.logger.debug(f"Removing chest {position}: {chest}")
+ self.chest_memory.pop(position)
+ else:
+ if chest != "Invalid":
+ self.logger.debug(f"Saving chest {position}: {chest}")
+ self.chest_memory[position] = chest
+ U.dump_json(self.chest_memory, f"{self.ckpt_dir}/action/chest_memory.json")
+
+ def render_chest_observation(self):
+ chests = []
+ for chest_position, chest in self.chest_memory.items():
+ if isinstance(chest, dict) and len(chest) > 0:
+ chests.append(f"{chest_position}: {chest}")
+ for chest_position, chest in self.chest_memory.items():
+ if isinstance(chest, dict) and len(chest) == 0:
+ chests.append(f"{chest_position}: Empty")
+ for chest_position, chest in self.chest_memory.items():
+ if isinstance(chest, str):
+ assert chest == "Unknown"
+ chests.append(f"{chest_position}: Unknown items inside")
+ assert len(chests) == len(self.chest_memory)
+ if chests:
+ chests = "\n".join(chests)
+ return f"Chests:\n{chests}\n\n"
+ else:
+ return f"Chests: None\n\n"
+
+ def render_system_message(self):
+ system_message = SystemMessage(content=load_prompt("action_template"))
+ # FIXME: Hardcoded control_primitives
+ # base_skills = [
+ # "exploreUntil",
+ # "mineBlock",
+ # "craftItem",
+ # "placeItem",
+ # "smeltItem",
+ # "killMob",
+ # ]
+ # if not self.llm.model_name == "gpt-3.5-turbo":
+ # base_skills += [
+ # "useChest",
+ # "mineflayer",
+ # ]
+ # programs = "\n\n".join(load_control_primitives_context(base_skills) + skills)
+ # response_format = load_prompt("action_response_format")
+ # system_message_prompt = SystemMessagePromptTemplate.from_template(
+ # system_template
+ # )
+ # system_message = system_message_prompt.format()
+ assert isinstance(system_message, SystemMessage)
+ return system_message
+
+ def render_human_message(
+ self, *, events, code="", task="", context="", critique="", skills=""
+ ):
+ chat_messages = []
+ error_messages = []
+ # FIXME: damage_messages is not used
+ damage_messages = []
+ assert events[-1][0] == "observe", "Last event must be observe"
+ for i, (event_type, event) in enumerate(events):
+ if event_type == "onChat":
+ chat_messages.append(event["onChat"])
+ elif event_type == "onError":
+ error_messages.append(event["onError"])
+ elif event_type == "onDamage":
+ damage_messages.append(event["onDamage"])
+ elif event_type == "observe":
+ biome = event["status"]["biome"]
+ time_of_day = event["status"]["timeOfDay"]
+ voxels = event["voxels"]
+ entities = event["status"]["entities"]
+ health = event["status"]["health"]
+ hunger = event["status"]["food"]
+ position = event["status"]["position"]
+ equipment = event["status"]["equipment"]
+ inventory_used = event["status"]["inventoryUsed"]
+ inventory = event["inventory"]
+ assert i == len(events) - 1, "observe must be the last event"
+
+ observation = ""
+
+ if code:
+ observation += f"Program used in the last round:\n{code}\n\n"
+ else:
+ observation += f"Program used in the last round: None\n\n"
+
+ programs = "".join(skills)
+ observation += f"Task: {task}\n\n"
+ observation += f"Programs: {programs}"
+
+ if critique:
+ observation += f"Critique: {critique}\n\n"
+ else:
+ observation += f"Critique: None\n\n"
+
+ return HumanMessage(content=observation)
+
+ def process_ai_message(self, message, skills):
+ assert isinstance(message, AIMessage)
+
+ retry = 3
+ error = None
+ while retry > 0:
+ try:
+ babel = require("@babel/core")
+ generator_module = require("@babel/generator")
+ try:
+ babel_generator = generator_module.default
+ except Exception: # CommonJS versions export the callable directly.
+ babel_generator = generator_module
+ if babel_generator is None:
+ babel_generator = generator_module
+
+ code_pattern = re.compile(r"{(.*?)}", re.DOTALL)
+ code_name = "".join(code_pattern.findall(message.content)[0])
+ action = "{" + code_name + "}"
+ action_resp = fix_and_parse_json(action)
+ assert "program" in action_resp
+ code_name = action_resp['program']
+ self.logger.debug(f'code name: {code_name}')
+
+ code = [c for c in skills if code_name in c]
+ if len(code) == 0:
+ code = [skills[0]]
+ parsed = babel.parseSync(code[0])
+ functions = []
+ assert len(list(parsed.program.body)) > 0, "No functions found"
+ for i, node in enumerate(parsed.program.body):
+ if node.type != "FunctionDeclaration":
+ continue
+ node_type = (
+ "AsyncFunctionDeclaration"
+ if node["async"]
+ else "FunctionDeclaration"
+ )
+ functions.append(
+ {
+ "name": node.id.name,
+ "type": node_type,
+ "body": babel_generator(node).code,
+ "params": list(node["params"]),
+ }
+ )
+ # find the last async function
+ main_function = None
+ for function in reversed(functions):
+ if function["type"] == "AsyncFunctionDeclaration":
+ main_function = function
+ break
+ assert (
+ main_function is not None
+ ), "No async function found. Your main function must be async."
+ assert (
+ len(main_function["params"]) == 1
+ and main_function["params"][0].name == "bot"
+ ), f"Main function {main_function['name']} must take a single argument named 'bot'"
+ program_code = "\n\n".join(function["body"] for function in functions)
+ exec_code = f"await {main_function['name']}(bot);"
+ return {
+ "program_code": program_code,
+ "program_name": main_function["name"],
+ "exec_code": exec_code,
+ }
+ except Exception as e:
+ retry -= 1
+ error = e
+ self.logger.warning(e)
+ time.sleep(1)
+ return f"Error parsing action response (before program execution): {error}"
+
+ def summarize_chatlog(self, events):
+ def filter_item(message: str):
+ craft_pattern = r"I cannot make \w+ because I need: (.*)"
+ craft_pattern2 = (
+ r"I cannot make \w+ because there is no crafting table nearby"
+ )
+ mine_pattern = r"I need at least a (.*) to mine \w+!"
+ if re.match(craft_pattern, message):
+ return re.match(craft_pattern, message).groups()[0]
+ elif re.match(craft_pattern2, message):
+ return "a nearby crafting table"
+ elif re.match(mine_pattern, message):
+ return re.match(mine_pattern, message).groups()[0]
+ else:
+ return ""
+
+ chatlog = set()
+ for event_type, event in events:
+ if event_type == "onChat":
+ item = filter_item(event["onChat"])
+ if item:
+ chatlog.add(item)
+ return "I also need " + ", ".join(chatlog) + "." if chatlog else ""
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/comment.py b/experiments/Odyssey/Odyssey/odyssey/agents/comment.py
new file mode 100644
index 0000000000000000000000000000000000000000..fa090c435346d57326f5b0ef122fb979570b1550
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/comment.py
@@ -0,0 +1,128 @@
+import re
+from odyssey.prompts import load_prompt
+from odyssey.utils.json_utils import fix_and_parse_json
+from langchain.schema import HumanMessage, SystemMessage
+from odyssey.agents.llama import call_with_messages, ModelType
+from odyssey.utils.logger import get_logger
+from typing import Union
+env_prompt = {
+ 'combat': 'combat_critic_prompt'
+}
+
+class CommentAgent:
+ def __init__(
+ self,
+ environment,
+ model_name=ModelType.LLAMA3_8B_V3,
+ mode="auto",
+ ):
+ assert mode in ["auto", "manual"]
+ self.logger = get_logger('CommentAgent')
+ self.env = environment
+ self.mode = mode
+ self.model_name = model_name
+
+ def render_system_message(self):
+ system_message = SystemMessage(content=load_prompt(env_prompt[self.env]))
+ return system_message
+
+ def render_human_message(self, events, task_list, time_ticks, iteration)->Union[None, tuple[HumanMessage, str]]:
+ assert events[-1][0] == "observe", "Last event must be observe"
+ health = events[-1][1]["status"]["health"]
+
+ for i, (event_type, event) in enumerate(events):
+ if event_type == "onError":
+ self.logger.warning(f"Critic Agent: Error occurs {event['onError']}")
+ return None
+
+ observation = ""
+ observation += f"Used Time: {time_ticks} ticks"
+ observation += f"Toal iteration: {iteration}"
+ observation += f"Task: {task_list}\n\n"
+ result = 'failed'
+ for event in reversed(events):
+ if event[0] == 'onChat':
+ result = event[1]['onChat']
+ break
+ observation += f"Result: {result}"
+ observation += f"Health: {health}"
+
+ # self.logger.debug(f"****Critic Agent human message****\n{observation}")
+ return HumanMessage(content=observation), result
+
+ def human_check_task_success(self):
+ confirmed = False
+ critique = ""
+ while not confirmed:
+ reason = input("Reason:")
+ critique = input("Enter your critique:")
+ self.logger.debug(f"Reason: {reason}\nCritique: {critique}")
+ confirmed = input("Confirm? (y/n)") in ["y", ""]
+ return reason, critique
+
+ def ai_check_task_success(self, messages, max_retries=5):
+ if max_retries == 0:
+ self.logger.warning("Failed to parse Critic Agent response. Consider updating your prompt.")
+ return "", ""
+
+ if messages[1] is None:
+ return "", ""
+
+ # modify
+ critic = call_with_messages(messages, self.model_name).content
+ self.logger.debug(f"****Comment Agent ai message****\n{critic}")
+ code_pattern = re.compile(r"{(.*?)}", re.DOTALL)
+ code_name = "".join(code_pattern.findall(critic))
+ critic = "{" + code_name + "}"
+ try:
+ response = fix_and_parse_json(critic)
+ # assert response["success"] in [True, False]
+ if "reason" not in response:
+ response["reason"] = ""
+ if "critique" not in response:
+ response["critique"] = ""
+ return response["reason"], response["critique"]
+ except Exception as e:
+ self.logger.warning(f"Error parsing critic response: {e} Trying again!")
+ return self.ai_check_task_success(
+ messages=messages,
+ max_retries=max_retries - 1,
+ )
+
+ def check_task_success(
+ self, *, events, task, time, iter, max_retries=5
+ ):
+ res = self.render_human_message(
+ events=events,
+ task_list=task,
+ time_ticks=time,
+ iteration=iter
+ )
+ if res is None:
+ human_message = None
+ result = 'failed'
+ else:
+ human_message, result = res
+
+ messages = [
+ self.render_system_message(),
+ human_message,
+ ]
+
+ if self.mode == "manual":
+ return self.human_check_task_success()
+ elif self.mode == "auto":
+ # reason, critique = self.ai_check_task_success(
+ # messages=messages, max_retries=max_retries
+ # )
+ if "won" in result:
+ health = events[-1][1]["status"]["health"]
+ critique = "You should streamline the task plan. For example, **reduce the quantity or quality** of crafting equipment in last task list to reduce time of collecting items."
+ else:
+ # elif "lost" in result:
+ health = 0
+ critique = "You need to improve the task plan. For example, **improve the quantity or quality** of crafting equipment in last task list to win the combat."
+ return health, critique, result, events[-1][1]["status"]["equipment"]
+ else:
+ raise ValueError(f"Invalid comment agent mode: {self.mode}")
+
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/critic.py b/experiments/Odyssey/Odyssey/odyssey/agents/critic.py
new file mode 100644
index 0000000000000000000000000000000000000000..08a4ba57b8838a90fd58a585d27ea2d68465362e
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/critic.py
@@ -0,0 +1,317 @@
+import re
+from odyssey.prompts import load_prompt
+from odyssey.utils.json_utils import fix_and_parse_json
+from langchain.schema import HumanMessage, SystemMessage
+from odyssey.agents.llama import call_with_messages, ModelType
+from odyssey.utils.logger import get_logger, Timer
+class CriticAgent:
+ def __init__(
+ self,
+ model_name=ModelType.LLAMA2_70B,
+ request_timout=120,
+ mode="auto",
+ ):
+ assert mode in ["auto", "manual"]
+ self.logger = get_logger('CriticAgent')
+ self.mode = mode
+ self.last_inventory = "Empty"
+ self.last_inventory_used = 0
+ self.model_name = model_name
+
+ def render_system_message(self):
+ system_message = SystemMessage(content=load_prompt("critic"))
+ return system_message
+
+ def render_human_message(self, *, events, task, context, chest_observation):
+ assert events[-1][0] == "observe", "Last event must be observe"
+ biome = events[-1][1]["status"]["biome"]
+ time_of_day = events[-1][1]["status"]["timeOfDay"]
+ voxels = events[-1][1]["voxels"]
+ health = events[-1][1]["status"]["health"]
+ hunger = events[-1][1]["status"]["food"]
+ position = events[-1][1]["status"]["position"]
+ equipment = events[-1][1]["status"]["equipment"]
+ inventory_used = events[-1][1]["status"]["inventoryUsed"]
+ inventory = events[-1][1]["inventory"]
+
+ for i, (event_type, event) in enumerate(events):
+ if event_type == "onError":
+ self.logger.warning(f"Critic Agent: Error occurs {event['onError']}")
+ return None
+
+ observation = ""
+
+ # observation += f"Biome: {biome}\n\n"
+
+ # observation += f"Time: {time_of_day}\n\n"
+
+ if voxels:
+ observation += f"Nearby blocks: {', '.join(voxels)}\n\n"
+ else:
+ observation += f"Nearby blocks: None\n\n"
+
+ # observation += f"Health: {health:.1f}/20\n\n"
+ # observation += f"Hunger: {hunger:.1f}/20\n\n"
+
+ # observation += f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n"
+ observation += f"Task: {task}\n\n"
+ observation += chest_observation
+
+ observation += f"Equipment: {equipment}\n\n"
+
+ if inventory:
+ observation += f"Current Inventory ({inventory_used}/36): {inventory}\n\n"
+ else:
+ observation += f"Current Inventory ({inventory_used}/36): Empty\n\n"
+
+ observation += f"Last inventory ({self.last_inventory_used}/36): {self.last_inventory}\n"
+
+ self.last_inventory_used = inventory_used
+ self.last_inventory = inventory
+
+ chatlog = None
+ for event_type, event in events:
+ if event_type == 'onChat':
+ chatlog = event['onChat']
+
+ if chatlog:
+ observation += f"Chat log: {chatlog}"
+
+ # if context:
+ # observation += f"Context: {context}\n\n"
+ # else:
+ # observation += f"Context: None\n\n"
+
+ # self.logger.debug(f"****Critic Agent human message****\n{observation}")
+ return HumanMessage(content=observation)
+
+ def human_check_task_success(self):
+ confirmed = False
+ success = False
+ critique = ""
+ while not confirmed:
+ success = input("Success? (y/n)")
+ success = success.lower() == "y"
+ critique = input("Enter your critique:")
+ self.logger.debug(f"Success: {success}\nCritique: {critique}")
+ confirmed = input("Confirm? (y/n)") in ["y", ""]
+ return success, critique
+
+ def ai_check_task_success(self, messages, max_retries=5):
+ if max_retries == 0:
+ self.logger.warning("Failed to parse Critic Agent response. Consider updating your prompt.")
+ return False, ""
+
+ if messages[1] is None:
+ return False, ""
+
+ # critic = self.llm(messages).content
+ # modify
+ critic = call_with_messages(messages, self.model_name).content
+ code_pattern = re.compile(r"{(.*?)}", re.DOTALL)
+ code_name = "".join(code_pattern.findall(critic))
+ critic = "{" + code_name + "}"
+ try:
+ response = fix_and_parse_json(critic)
+ assert response["success"] in [True, False]
+ if "critique" not in response:
+ response["critique"] = ""
+ return response["success"], response["critique"]
+ except Exception as e:
+ self.logger.warning(f"Error parsing critic response: {e} Trying again!")
+ return self.ai_check_task_success(
+ messages=messages,
+ max_retries=max_retries - 1,
+ )
+
+ def check_task_success(
+ self, *, events, task, context, chest_observation, max_retries=5
+ ):
+
+ with Timer('Check Task Success render_human_message'):
+ human_message = self.render_human_message(
+ events=events,
+ task=task,
+ context=context,
+ chest_observation=chest_observation,
+ )
+ self.logger.debug(f'human message: {human_message}')
+
+ messages = [
+ self.render_system_message(),
+ human_message,
+ ]
+
+ if self.mode == "manual":
+ return self.human_check_task_success()
+ elif self.mode == "auto":
+ return self.ai_check_task_success(
+ messages=messages, max_retries=max_retries
+ )
+ # elif self.mode == 'pragram':
+ # return self.program_check_task_success(
+ # events=events, task=task
+ # )
+ else:
+ raise ValueError(f"Invalid critic agent mode: {self.mode}")
+
+ def check_subgoal_success(self, events, task)->bool:
+ inventory = self.get_inventory(events=events)
+ if task == 'craft crafting table':
+ return 'crafting_table' in inventory
+ if task == 'craft wooden pickaxe':
+ return 'wooden_pickaxe' in inventory
+ if task == 'craft stone pickaxe':
+ return 'stone_pickaxe' in inventory
+ if task == 'craft iron pickaxe':
+ return 'iron_pickaxe' in inventory
+ if task == 'mine diamond':
+ return 'diamond' in inventory
+ if task == 'drop stone pickaxe':
+ dropped = any(
+ event_type == "onChat"
+ and event.get("onChat") == "Dropped a stone pickaxe."
+ for event_type, event in events
+ )
+ return dropped and 'stone_pickaxe' not in inventory
+
+
+ def program_check_task_success(self, events, task)->bool:
+ inventory = self.get_inventory(events=events)
+ return task in inventory
+
+ def ai_check_goal_success(self, messages, max_retries=5):
+ if max_retries == 0:
+ self.logger.warning("Failed to parse Critic Agent response. Consider updating your prompt.")
+ return False, ""
+
+ if messages[1] is None:
+ return False, ""
+
+ # critic = self.llm(messages).content
+ # modify
+ critic = call_with_messages(messages).content
+ self.logger.debug(f"****Goal Agent ai message****\n{critic}")
+ code_pattern = re.compile(r"{(.*?)}", re.DOTALL)
+ code_name = "".join(code_pattern.findall(critic))
+ critic = "{" + code_name + "}"
+ try:
+ response = fix_and_parse_json(critic)
+ assert response["success"] in [True, False]
+ if "reasoning" not in response:
+ response["reasoning"] = ""
+ return response["reasoning"], response["success"]
+ except Exception as e:
+ self.logger.warning(f"Error parsing goal response: {e} Trying again!")
+ return self.ai_check_task_success(
+ messages=messages,
+ max_retries=max_retries - 1,
+ )
+
+ def get_inventory(self, events)->dict:
+ assert events[-1][0] == "observe", "Last event must be observe"
+ event = events[-1][1]
+ inventory = event["inventory"]
+ return inventory
+
+ def render_observation(self, *, events, completed_tasks, failed_tasks):
+ assert events[-1][0] == "observe", "Last event must be observe"
+ event = events[-1][1]
+ biome = event["status"]["biome"]
+ time_of_day = event["status"]["timeOfDay"]
+ voxels = event["voxels"]
+ block_records = event["blockRecords"]
+ entities = event["status"]["entities"]
+ health = event["status"]["health"]
+ hunger = event["status"]["food"]
+ position = event["status"]["position"]
+ equipment = event["status"]["equipment"]
+ inventory_used = event["status"]["inventoryUsed"]
+ inventory = event["inventory"]
+
+ if not any(
+ "dirt" in block
+ or "log" in block
+ or "grass" in block
+ or "sand" in block
+ or "snow" in block
+ for block in voxels
+ ):
+ biome = "underground"
+
+ other_blocks = ", ".join(
+ list(
+ set(block_records).difference(set(voxels).union(set(inventory.keys())))
+ )
+ )
+
+ other_blocks = other_blocks if other_blocks else "None"
+
+ nearby_entities = (
+ ", ".join([k for k, v in sorted(entities.items(), key=lambda x: x[1])])
+ if entities
+ else "None"
+ )
+
+ completed_tasks = (
+ ", ".join(completed_tasks) if completed_tasks else "None"
+ )
+ failed_tasks = ", ".join(failed_tasks) if failed_tasks else "None"
+
+ observation = {
+ "context": "",
+ "biome": f"Biome: {biome}\n\n",
+ "time": f"Time: {time_of_day}\n\n",
+ "nearby_blocks": f"Nearby blocks: {', '.join(voxels) if voxels else 'None'}\n\n",
+ "other_blocks": f"Other blocks that are recently seen: {other_blocks}\n\n",
+ "nearby_entities": f"Nearby entities: {nearby_entities}\n\n",
+ "health": f"Health: {health:.1f}/20\n\n",
+ "hunger": f"Hunger: {hunger:.1f}/20\n\n",
+ "position": f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n",
+ "equipment": f"Equipment: {equipment}\n\n",
+ "inventory": f"Inventory ({inventory_used}/36): {inventory if inventory else 'Empty'}\n\n",
+ "completed_tasks": f"Completed tasks so far: {completed_tasks}\n\n",
+ "failed_tasks": f"Failed tasks that are too hard: {failed_tasks}\n\n",
+ }
+ return observation
+
+ def check_goal_success(self, events, completed_task, failed_task, goals, mode):
+ observations = self.render_observation(events=events, completed_tasks=completed_task, failed_tasks=failed_task)
+ content = ''
+ for key in observations:
+ content += observations[key]
+ content += f"My ultimate goals: {goals}"
+ if mode == "auto":
+ messages = [
+ SystemMessage(content=load_prompt("goals")),
+ HumanMessage(content=content)
+ ]
+ return self.ai_check_goal_success(messages=messages)
+ # program mode is used for checking farming benchmark tasks success
+ elif mode == "program":
+ print("observations = " + str(observations))
+ inventory = self.get_inventory(events=events)
+ wool_in_inventory = 'white_wool' in inventory or 'black_wool' in inventory or 'gray_wool' in inventory or 'light_gray_wool' in inventory
+ milk_in_inventory = 'milk_bucket' in inventory
+ meat_in_inventory = 'cooked_porkchop' in inventory or 'cooked_mutton' in inventory or 'cooked_beef' in inventory or 'cooked_chicken' in inventory
+ seed_in_inventory = 'wheat_seeds' in inventory or 'melon_seeds' in inventory or 'pumpkin_seeds' in inventory
+ leather_in_inventory = 'leather' in inventory
+ sugar_in_inventory = 'sugar' in inventory
+ water_bucket_in_inventory = 'water_bucket' in inventory
+ farmland_nearby = 'farmland' in observations["nearby_blocks"]
+ if goals == 'hoe a farmland':
+ return farmland_nearby
+ if goals == "collect 1 wool by shearing 1 sheep":
+ return wool_in_inventory
+ if goals == "collect 1 bucket of milk":
+ return milk_in_inventory
+ if goals == "cook 1 meat (beef or mutton or pork or chicken)":
+ return meat_in_inventory
+ if goals == "collect 1 seed (wheat or melon or pumpkin)":
+ return seed_in_inventory
+ if goals == 'make 1 sugar':
+ return sugar_in_inventory
+ if goals == 'obtain 1 leather':
+ return leather_in_inventory
+ if goals == 'collect 1 bucket of water':
+ return water_bucket_in_inventory
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/llama.py b/experiments/Odyssey/Odyssey/odyssey/agents/llama.py
new file mode 100644
index 0000000000000000000000000000000000000000..320b77cc6102b847b094e99ff3f632a68f15f0dd
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/llama.py
@@ -0,0 +1,135 @@
+import json
+
+import requests
+from langchain.schema import AIMessage
+
+from odyssey.utils import config
+
+
+class ModelType:
+ """Legacy model aliases kept for existing experiment code.
+
+ The actual model sent to the API is configured by `openai_model`.
+ """
+
+ LLAMA2_70B = 'llama2_70b'
+ LLAMA3_8B_V3 = 'llama3_8b_v3'
+ LLAMA3_8B = 'llama3_8b'
+ LLAMA3_70B_V1 = 'llama3_70b_v1'
+ QWEN2_72B = 'qwen2-72b'
+ QWEN2_7B = 'qwen2-7b'
+ BAICHUAN2_7B = 'baichuan2-7b'
+
+
+def _get_bool_config(key, default=False):
+ value = config.get(key)
+ if value == "":
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, (int, float)):
+ return bool(value)
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
+ return default
+
+
+def _get_int_config(key):
+ value = config.get(key)
+ if value == "":
+ return None
+ try:
+ value = int(value)
+ except (TypeError, ValueError):
+ return None
+ return value if value > 0 else None
+
+
+def _get_request_timeout():
+ timeout = config.get("llm_request_timeout")
+ if not timeout:
+ return None
+ try:
+ return float(timeout)
+ except (TypeError, ValueError):
+ return None
+
+
+def _build_extra_body():
+ """Build optional OpenAI-compatible vendor extensions from config.json."""
+ extra_body = config.get("llm_extra_body")
+ if not isinstance(extra_body, dict):
+ extra_body = {}
+ else:
+ extra_body = dict(extra_body)
+
+ thinking_enabled = _get_bool_config("llm_thinking_enabled", default=False)
+ thinking_style = config.get("llm_thinking_style") or "deepseek"
+ thinking_effort = config.get("llm_thinking_effort") or ""
+ thinking_budget = _get_int_config("llm_thinking_budget_tokens")
+
+ if thinking_style == "none":
+ return extra_body
+
+ if thinking_style == "reasoning_effort":
+ if thinking_enabled and thinking_effort:
+ extra_body["reasoning_effort"] = thinking_effort
+ return extra_body
+
+ if thinking_style == "qwen":
+ extra_body["enable_thinking"] = thinking_enabled
+ if thinking_enabled and thinking_budget:
+ extra_body["thinking_budget"] = thinking_budget
+ return extra_body
+
+ thinking = {"type": "enabled" if thinking_enabled else "disabled"}
+ if thinking_enabled and thinking_effort:
+ thinking["effort"] = thinking_effort
+ if thinking_enabled and thinking_budget:
+ thinking["budget_tokens"] = thinking_budget
+ extra_body["thinking"] = thinking
+ return extra_body
+
+
+def call_with_messages(msgs, model_name=ModelType.LLAMA3_8B_V3):
+ base_url = config.get("openai_base_url")
+ if not base_url:
+ raise RuntimeError("`openai_base_url` must be configured.")
+
+ base_url = base_url.rstrip("/")
+ model = config.get("openai_model") or model_name
+ api_key = config.get("openai_api_key")
+ url = f"{base_url}/chat/completions"
+ headers = {"Content-Type": "application/json"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ payload = {
+ "model": model,
+ "messages": [
+ {"role": "system", "content": msgs[0].content},
+ {"role": "user", "content": msgs[1].content},
+ ],
+ }
+ payload.update(_build_extra_body())
+ result = requests.post(
+ url,
+ json=payload,
+ headers=headers,
+ timeout=_get_request_timeout(),
+ )
+ result.raise_for_status()
+ json_result = result.json()
+ choices = json_result.get("choices") or []
+ if not choices:
+ preview = json.dumps(json_result, ensure_ascii=False)[:1000]
+ raise RuntimeError(f"OpenAI-compatible API returned no choices: {preview}")
+
+ choice = choices[0] or {}
+ message = choice.get("message") or {}
+ content = message.get("content") if isinstance(message, dict) else None
+ if content is None:
+ content = choice.get("text")
+ if content is None:
+ preview = json.dumps(json_result, ensure_ascii=False)[:1000]
+ raise RuntimeError(f"OpenAI-compatible API returned no text content: {preview}")
+ return AIMessage(content=content)
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/planner.py b/experiments/Odyssey/Odyssey/odyssey/agents/planner.py
new file mode 100644
index 0000000000000000000000000000000000000000..4a8485d35faa71f1fa34a7b935099efa763829c1
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/planner.py
@@ -0,0 +1,526 @@
+from __future__ import annotations
+
+import random
+import re
+
+import odyssey.utils as U
+from odyssey.prompts import load_prompt
+from odyssey.utils.json_utils import fix_and_parse_list, fix_and_parse_json
+# from langchain.embeddings.huggingface import HuggingFaceEmbeddings
+# from langchain.schema import HumanMessage, SystemMessage
+# from langchain.vectorstores import Chroma
+
+from langchain.schema import HumanMessage, SystemMessage
+from langchain_community.vectorstores import Chroma
+
+from odyssey.embeddings import build_embeddings
+from odyssey.utils.logger import get_logger
+
+# llama
+from odyssey.agents.llama import call_with_messages, ModelType
+
+env_prompt = {
+ 'combat': 'combat_sys_prompt',
+ 'farming': 'farming_sys_prompt',
+ 'explore': 'explore_sys_prompt'
+}
+
+
+class PlannerAgent:
+ def __init__(
+ self,
+ model_name=ModelType.LLAMA2_70B,
+ qa_model_name=ModelType.LLAMA3_8B_V3,
+ request_timout=120,
+ ckpt_dir="ckpt",
+ resume=False,
+ mode="auto",
+ warm_up=None,
+ core_inventory_items: str | None = None,
+ embedding_model = "",
+ ):
+ assert mode in [
+ "auto",
+ "manual",
+ ], f"mode {mode} not supported"
+ self.logger = get_logger("CurriculumAgent")
+ self.mode = mode
+ self.ckpt_dir = ckpt_dir
+ if not resume:
+ U.f_remove(f"{ckpt_dir}/curriculum/vectordb")
+ U.f_mkdir(f"{ckpt_dir}/curriculum/vectordb")
+ if resume:
+ self.logger.info(f"Loading Curriculum Agent from {ckpt_dir}/curriculum")
+ self.completed_tasks = U.load_json(
+ f"{ckpt_dir}/curriculum/completed_tasks.json"
+ )
+ self.failed_tasks = U.load_json(f"{ckpt_dir}/curriculum/failed_tasks.json")
+ self.qa_cache = U.load_json(f"{ckpt_dir}/curriculum/qa_cache.json")
+ else:
+ self.completed_tasks = []
+ self.failed_tasks = []
+ self.qa_cache = {}
+ # vectordb for qa cache
+ self.qa_cache_questions_vectordb = Chroma(
+ collection_name="qa_cache_questions_vectordb",
+ embedding_function=build_embeddings(embedding_model),
+ persist_directory=f"{ckpt_dir}/curriculum/vectordb",
+ )
+ assert self.qa_cache_questions_vectordb._collection.count() == len(
+ self.qa_cache
+ ), (
+ f"Curriculum Agent's qa cache question vectordb is not synced with qa_cache.json.\n"
+ f"There are {self.qa_cache_questions_vectordb._collection.count()} questions in vectordb "
+ f"but {len(self.qa_cache)} questions in qa_cache.json.\n"
+ f"Did you set resume=False when initializing the agent?\n"
+ f"You may need to manually delete the qa cache question vectordb directory for running from scratch.\n"
+ )
+ # if warm up not defined, initialize it as a dict, else, initialize all the missing value as a default value
+ if not warm_up:
+ warm_up = self.default_warmup
+ self.warm_up = {}
+ if "optional_inventory_items" in warm_up:
+ assert core_inventory_items is not None
+ self._core_inv_items_regex = re.compile(core_inventory_items)
+ self.warm_up["optional_inventory_items"] = warm_up[
+ "optional_inventory_items"
+ ]
+ else:
+ self.warm_up["optional_inventory_items"] = 0
+ for key in self.curriculum_observations:
+ self.warm_up[key] = warm_up.get(key, self.default_warmup[key])
+ self.warm_up["nearby_blocks"] = 0
+ self.warm_up["inventory"] = 0
+ self.warm_up["completed_tasks"] = 0
+ self.warm_up["failed_tasks"] = 0
+ self.qa_model_name = qa_model_name
+ self.model_name = model_name
+
+ @property
+ def default_warmup(self):
+ return {
+ "context": 15,
+ "biome": 10,
+ "time": 15,
+ "nearby_blocks": 0,
+ "other_blocks": 10,
+ "nearby_entities": 5,
+ "health": 15,
+ "hunger": 15,
+ "position": 0,
+ "equipment": 0,
+ "inventory": 0,
+ "optional_inventory_items": 7,
+ "chests": 0,
+ "completed_tasks": 0,
+ "failed_tasks": 0,
+ }
+
+ @property
+ def curriculum_observations(self):
+ return [
+ # "context",
+ "biome",
+ # "time",
+ "nearby_blocks",
+ "other_blocks",
+ "nearby_entities",
+ "health",
+ "hunger",
+ # "position",
+ # "equipment",
+ "inventory",
+ # "chests",
+ "completed_tasks",
+ "failed_tasks",
+ ]
+
+ @property
+ def progress(self):
+ return len(self.completed_tasks)+len(self.failed_tasks)
+
+ def render_system_message(self, environment, goals):
+ prompts = load_prompt(env_prompt[environment])
+ if goals != None:
+ prompts = prompts.replace("```goals```", goals)
+
+ system_message = SystemMessage(content=prompts)
+ assert isinstance(system_message, SystemMessage)
+ return system_message
+
+ def render_observation(self, *, events, chest_observation):
+ assert events[-1][0] == "observe", "Last event must be observe"
+ event = events[-1][1]
+ biome = event["status"]["biome"]
+ time_of_day = event["status"]["timeOfDay"]
+ voxels = event["voxels"]
+ block_records = event["blockRecords"]
+ entities = event["status"]["entities"]
+ health = event["status"]["health"]
+ hunger = event["status"]["food"]
+ position = event["status"]["position"]
+ equipment = event["status"]["equipment"]
+ inventory_used = event["status"]["inventoryUsed"]
+ inventory = event["inventory"]
+
+ if not any(
+ "dirt" in block
+ or "log" in block
+ or "grass" in block
+ or "sand" in block
+ or "snow" in block
+ for block in voxels
+ ):
+ biome = "underground"
+
+ other_blocks = ", ".join(
+ list(
+ set(block_records).difference(set(voxels).union(set(inventory.keys())))
+ )
+ )
+
+ other_blocks = other_blocks if other_blocks else "None"
+
+ nearby_entities = (
+ ", ".join([k for k, v in sorted(entities.items(), key=lambda x: x[1])])
+ if entities
+ else "None"
+ )
+
+ completed_tasks = (
+ ", ".join(self.completed_tasks) if self.completed_tasks else "None"
+ )
+ failed_tasks = ", ".join(self.failed_tasks) if self.failed_tasks else "None"
+
+ # filter out optional inventory items if required
+ if self.progress < self.warm_up["optional_inventory_items"]:
+ inventory = {
+ k: v
+ for k, v in inventory.items()
+ if self._core_inv_items_regex.search(k) is not None
+ }
+
+ observation = {
+ "context": "",
+ "biome": f"Biome: {biome}\n\n",
+ "time": f"Time: {time_of_day}\n\n",
+ "nearby_blocks": f"Nearby blocks: {', '.join(voxels) if voxels else 'None'}\n\n",
+ "other_blocks": f"Other blocks that are recently seen: {other_blocks}\n\n",
+ "nearby_entities": f"Nearby entities: {nearby_entities}\n\n",
+ "health": f"Health: {health:.1f}/20\n\n",
+ "hunger": f"Hunger: {hunger:.1f}/20\n\n",
+ "position": f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n",
+ "equipment": f"Equipment: {equipment}\n\n",
+ "inventory": f"Inventory ({inventory_used}/36): {inventory if inventory else 'Empty'}\n\n",
+ "chests": chest_observation,
+ "completed_tasks": f"Completed tasks so far: {completed_tasks}\n\n",
+ "failed_tasks": f"Failed tasks that are too hard: {failed_tasks}\n\n",
+ }
+ return observation
+
+ def render_human_message(self, *, events, chest_observation, goals=None):
+ content = ""
+ observation = self.render_observation(
+ events=events, chest_observation=chest_observation
+ )
+ # if self.progress >= self.warm_up["context"]:
+ # questions, answers = self.run_qa(
+ # events=events, chest_observation=chest_observation
+ # )
+ # i = 1
+ # for question, answer in zip(questions, answers):
+ # if "Answer: Unknown" in answer or "language model" in answer:
+ # continue
+ # observation["context"] += f"Question {i}: {question}\n"
+ # observation["context"] += f"{answer}\n\n"
+ # i += 1
+ # if i > 5:
+ # break
+ if goals:
+ content += "Ultimate goal: " + goals +"\n"
+ content += "Note that your proposed tasks should directly related to the goals!\n"
+ for key in self.curriculum_observations:
+ content += observation[key]
+ # if self.progress >= self.warm_up[key]:
+ # if self.warm_up[key] != 0:
+ # should_include = random.random() < 0.8
+ # else:
+ # should_include = True
+ # if should_include:
+ # content += observation[key]
+ result = None
+ for event in reversed(events):
+ if event[0] == 'onChat':
+ result = event[1]['onChat']
+ break
+ content += f"Logs: {result}"
+ self.logger.info(f"****Curriculum Agent human message****\n{content}")
+ return HumanMessage(content=content)
+
+ def propose_next_task(self, environment, events, chest_observation, goals=None, max_retries=5):
+ # if self.progress == 0 and self.mode == "auto":
+ # task = "Mine 1 wood log"
+ # context = "You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs."
+ # return task, context
+
+ # hard code task when inventory is almost full
+ inventoryUsed = events[-1][1]["status"]["inventoryUsed"]
+ if inventoryUsed > 32:
+ task = "deposit items into chest"
+ context = "You should deposits non-iron and non-diamond items from the your inventory into a chest at a specified position, using a placed chest as a deposit location."
+ return task, context
+
+ messages = [
+ self.render_system_message(environment, goals),
+ self.render_human_message(
+ events=events, chest_observation=chest_observation, goals=goals
+ ),
+ ]
+
+ if self.mode == "auto":
+ return self.propose_next_ai_task(messages=messages, max_retries=max_retries)
+ elif self.mode == "manual":
+ return self.propose_next_manual_task()
+ else:
+ raise ValueError(f"Invalid curriculum agent mode: {self.mode}")
+
+ def propose_next_ai_task(self, *, messages, max_retries=5):
+ if max_retries == 0:
+ raise RuntimeError("Max retries reached, failed to propose ai task.")
+ curriculum = call_with_messages(messages, self.model_name).content
+ self.logger.info(f"****Curriculum Agent ai message****\n{curriculum}")
+ code_pattern = re.compile(r"{(.*?)}", re.DOTALL)
+ code_name = "".join(code_pattern.findall(curriculum))
+ curriculum = "{" + code_name + "}"
+ try:
+ response = fix_and_parse_json(curriculum)
+ assert "task" in response
+ if "reasoning" not in response:
+ response["reasoning"] = ""
+ context = self.get_task_context(response["task"])
+ return response["task"], context
+ except Exception as e:
+ self.logger.warning(f"Error parsing curriculum response: {e}. Trying again!")
+ return self.propose_next_ai_task(
+ messages=messages,
+ max_retries=max_retries - 1,
+ )
+
+ def parse_ai_message(self, message):
+ task = ""
+ for line in message.split("\n"):
+ if line.startswith("Task:"):
+ task = line[5:].replace(".", "").strip()
+ assert task, "Task not found in Curriculum Agent response"
+ return {"next_task": task}
+
+ def propose_next_manual_task(self):
+ confirmed = False
+ task, context = "", ""
+ while not confirmed:
+ task = input("Enter task: ")
+ context = input("Enter context: ")
+ self.logger.info(f"Task: {task}\nContext: {context}")
+ confirmed = input("Confirm? (y/n)").lower() in ["y", ""]
+ return task, context
+
+ def update_exploration_progress(self, info):
+ task = info["task"]
+ if task.startswith("Deposit useless items into the chest at"):
+ # No need to record the deposit task
+ return
+ if info["success"]:
+ self.logger.success(f"Completed task {task}.")
+ self.completed_tasks.append(task)
+ else:
+ self.logger.failed(f"Failed to complete task {task}. Skipping to next task.")
+ self.failed_tasks.append(task)
+
+ # clean up tasks and dump to disk
+ self.clean_up_tasks()
+
+ def clean_up_tasks(self):
+ updated_completed_tasks = []
+ # record repeated failed tasks
+ updated_failed_tasks = self.failed_tasks
+ # dedup but keep order
+ for task in self.completed_tasks:
+ if task not in updated_completed_tasks:
+ updated_completed_tasks.append(task)
+
+ # remove completed tasks from failed tasks
+ for task in updated_completed_tasks:
+ while task in updated_failed_tasks:
+ updated_failed_tasks.remove(task)
+
+ self.completed_tasks = updated_completed_tasks
+ self.failed_tasks = updated_failed_tasks
+
+ # dump to json
+ U.dump_json(
+ self.completed_tasks, f"{self.ckpt_dir}/curriculum/completed_tasks.json"
+ )
+ U.dump_json(self.failed_tasks, f"{self.ckpt_dir}/curriculum/failed_tasks.json")
+
+ def decompose_task(self, environment, monster, last_tasklist, critique, health):
+ # for different test env, modify prompt here
+ messages = [
+ SystemMessage(
+ content=load_prompt(env_prompt[environment]),
+ ),
+ # self.render_human_message(events=events, chest_observation=""),
+ HumanMessage(content=f"Equipment obtained in last round: {last_tasklist};\n Health after last combat:{health};\n Critique: {critique};\n Monster: {monster}.\n"),
+ ]
+ # print(f"\033[31m****Curriculum Agent task decomposition****\nFinal task: {task}\033[0m")
+ response = call_with_messages(messages, self.qa_model_name).content
+ return fix_and_parse_list(response)
+
+ def rerank_monster(self, task):
+ messages = [
+ SystemMessage(
+ content=load_prompt('combat_template'),
+ ),
+ HumanMessage(content=task),
+ ]
+
+ monster_origin = task.split(',')
+ monster_order = []
+ retry = 3
+ while retry > 0:
+ try:
+ response = call_with_messages(messages, self.qa_model_name).content
+ self.logger.debug(f"****Curriculum Agent monster rerank****\n{response}")
+ monster_order = fix_and_parse_list(response)
+ break
+ except Exception as e:
+ retry -= 1
+
+ for item in monster_order:
+ if len(item.split()) != 2:
+ for monster in monster_origin:
+ if item in monster.strip():
+ item = monster.strip()
+
+ if len(monster_origin) != len(monster_order):
+ for monster in monster_origin:
+ if monster not in monster_order:
+ monster_order.append(monster.strip())
+
+ return monster_order
+
+ def run_qa(self, *, events, chest_observation):
+ questions_new, _ = self.run_qa_step1_ask_questions(
+ events=events, chest_observation=chest_observation
+ )
+ questions = []
+ answers = []
+ for question in questions_new:
+ if self.qa_cache_questions_vectordb._collection.count() > 0:
+ docs_and_scores = (
+ self.qa_cache_questions_vectordb.similarity_search_with_score(
+ question, k=1
+ )
+ )
+ if docs_and_scores and docs_and_scores[0][1] < 0.05:
+ question_cached = docs_and_scores[0][0].page_content
+ assert question_cached in self.qa_cache
+ answer_cached = self.qa_cache[question_cached]
+ questions.append(question_cached)
+ answers.append(answer_cached)
+ continue
+ answer = self.run_qa_step2_answer_questions(question=question)
+ assert question not in self.qa_cache
+ self.qa_cache[question] = answer
+ self.qa_cache_questions_vectordb.add_texts(
+ texts=[question],
+ )
+ U.dump_json(self.qa_cache, f"{self.ckpt_dir}/curriculum/qa_cache.json")
+ self.qa_cache_questions_vectordb.persist()
+ questions.append(question)
+ answers.append(answer)
+ assert len(questions_new) == len(questions) == len(answers)
+ return questions, answers
+
+ def get_task_context(self, task):
+ # if include ore in question, gpt will try to use tool with skill touch enhancement to mine
+ question = (
+ f"How to {task.replace('_', ' ').replace(' ore', '').replace(' ores', '').replace('.', '').strip().lower()}"
+ f" in Minecraft?"
+ )
+ if question in self.qa_cache:
+ answer = self.qa_cache[question]
+ else:
+ answer = self.run_qa_step2_answer_questions(question=question)
+ self.qa_cache[question] = answer
+ self.qa_cache_questions_vectordb.add_texts(
+ texts=[question],
+ )
+ U.dump_json(self.qa_cache, f"{self.ckpt_dir}/curriculum/qa_cache.json")
+ self.qa_cache_questions_vectordb.persist()
+ context = f"Question: {question}\n{answer}"
+ return context
+
+ def render_system_message_qa_step1_ask_questions(self):
+ return SystemMessage(content=load_prompt("curriculum_qa_step1_ask_questions"))
+
+ def render_human_message_qa_step1_ask_questions(self, *, events, chest_observation):
+ observation = self.render_observation(
+ events=events, chest_observation=chest_observation
+ )
+ content = ""
+ for key in self.curriculum_observations:
+ content += observation[key]
+ return HumanMessage(content=content)
+
+ def run_qa_step1_ask_questions(self, *, events, chest_observation):
+ biome = events[-1][1]["status"]["biome"].replace("_", " ")
+ questions = [
+ f"What are the blocks that I can find in the {biome} in Minecraft?",
+ f"What are the items that I can find in the {biome} in Minecraft?",
+ f"What are the mobs that I can find in the {biome} in Minecraft?",
+ ]
+ concepts = [biome, biome, biome]
+ messages = [
+ self.render_system_message_qa_step1_ask_questions(),
+ self.render_human_message_qa_step1_ask_questions(
+ events=events, chest_observation=chest_observation
+ ),
+ ]
+ qa_response = call_with_messages(messages, self.qa_model_name).content
+ try:
+ # Regex pattern to extract question and concept pairs
+ pattern = r"Question \d+: (.+)\nConcept \d+: (.+)"
+ # Extracting all question and concept pairs from the text
+ pairs = re.findall(pattern, qa_response)
+ # Storing each question and concept in separate lists
+ questions_new = [pair[0] for pair in pairs]
+ concepts_new = [pair[1] for pair in pairs]
+ assert len(questions_new) == len(concepts_new)
+ questions.extend(questions_new)
+ concepts.extend(concepts_new)
+ except Exception as e:
+ self.logger.warning(
+ f"Error parsing curriculum response for QA step 1 ask questions: {e}."
+ )
+ return questions, concepts
+
+ def render_system_message_qa_step2_answer_questions(self):
+ return SystemMessage(
+ content=load_prompt("curriculum_qa_step2_answer_questions")
+ )
+
+ def render_human_message_qa_step2_answer_questions(self, question):
+ content = f"Question: {question}"
+ return HumanMessage(content=content)
+
+ def run_qa_step2_answer_questions(self, question):
+ messages = [
+ self.render_system_message_qa_step2_answer_questions(),
+ self.render_human_message_qa_step2_answer_questions(question=question),
+ ]
+ # self.logger.debug(f"Curriculum Agent Question: {question}")
+ # qa_answer = self.qa_llm(messages).content
+ # ï¿????æ¹è°ï¿????
+ qa_answer = call_with_messages(messages, model_name=self.qa_model_name).content
+ # self.logger.debug(f"Curriculum Agent Answer: {qa_answer}")
+ return qa_answer
diff --git a/experiments/Odyssey/Odyssey/odyssey/agents/skill.py b/experiments/Odyssey/Odyssey/odyssey/agents/skill.py
new file mode 100644
index 0000000000000000000000000000000000000000..60fad137e10ee803c997198245e74931be604274
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/agents/skill.py
@@ -0,0 +1,189 @@
+import os
+
+import odyssey.utils as U
+from langchain.schema import HumanMessage, SystemMessage
+from langchain_community.vectorstores import Chroma
+
+from odyssey.embeddings import build_embeddings
+from odyssey.prompts import load_prompt
+from odyssey.control_primitives import load_control_primitives
+from odyssey.utils.logger import get_logger
+from odyssey.agents.llama import call_with_messages
+
+class SkillManager:
+ def __init__(
+ self,
+ retrieval_top_k=5,
+ request_timout=120,
+ ckpt_dir="ckpt",
+ resume=False,
+ reload=False,
+ embedding_model="",
+ ):
+ U.f_mkdir(f"{ckpt_dir}/skill/compositional")
+ U.f_mkdir(f"{ckpt_dir}/skill/description")
+ U.f_mkdir(f"{ckpt_dir}/skill/vectordb")
+ if reload:
+ U.f_remove(f"{ckpt_dir}/skill/vectordb")
+ # programs for env execution
+ self.control_primitives = load_control_primitives()
+ self.skill_primitives = self.load_skill_primitives()
+ self.mc_skill_primitives = self.load_mc_skill_primitives()
+ self.skill_lib = "old"
+ self.logger = get_logger("SkillManager")
+ if resume:
+ self.skills = U.load_json(f"{ckpt_dir}/skill/skills.json")
+ self.logger.info(f"Loading {len(self.skills)} skills from {ckpt_dir}/skill")
+ else:
+ self.skills = {}
+ self.retrieval_top_k = retrieval_top_k
+ self.ckpt_dir = ckpt_dir
+ self.embedding_model = build_embeddings(embedding_model)
+ self.vectordb = Chroma(
+ collection_name="skill_vectordb",
+ embedding_function=self.embedding_model,
+ persist_directory=f"{ckpt_dir}/skill/vectordb",
+ )
+ if reload:
+ for key, value in self.skills.items():
+ self.vectordb.add_texts(
+ texts=[value['description']],
+ ids=[key],
+ metadatas=[{"name": key}],
+ )
+
+ self.vectordb.persist()
+
+ assert self.vectordb._collection.count() == len(self.skills), (
+ f"Skill Manager's vectordb is not synced with skills.json.\n"
+ f"There are {self.vectordb._collection.count()} skills in vectordb but {len(self.skills)} skills in skills.json.\n"
+ f"Did you set resume=False when initializing the manager?\n"
+ f"You may need to manually delete the vectordb directory for running from scratch."
+ )
+
+ @property
+ def programs(self):
+ programs = ""
+ if (self.skill_lib == "old"):
+ for skill_name, entry in self.skills.items():
+ programs += f"{entry['code']}\n\n"
+ for primitives in self.control_primitives:
+ programs += f"{primitives}\n\n"
+ for skill_primitive in self.skill_primitives:
+ programs += f"{skill_primitive}\n\n"
+ elif (self.skill_lib == "new"):
+ for mc_skill_primitive in self.mc_skill_primitives:
+ programs += f"{mc_skill_primitive}\n\n"
+ return programs
+
+ @programs.setter
+ def programs(self, value):
+ self.skill_lib = value
+
+ def add_new_skill(self, info):
+ if info["task"].startswith("Deposit useless items into the chest at"):
+ # No need to reuse the deposit skill
+ return
+ program_name = info["program_name"]
+ program_code = info["program_code"]
+ skill_description = self.generate_skill_description(program_name, program_code)
+ self.logger.info(f"Skill Manager generated description for {program_name}:\n{skill_description}")
+ if program_name in self.skills:
+ self.logger.warning(f"Skill {program_name} already exists. Rewriting!")
+ self.vectordb._collection.delete(ids=[program_name])
+ i = 2
+ while f"{program_name}V{i}.js" in os.listdir(f"{self.ckpt_dir}/skill/compositional"):
+ i += 1
+ dumped_program_name = f"{program_name}V{i}"
+ else:
+ dumped_program_name = program_name
+ self.vectordb.add_texts(
+ texts=[skill_description],
+ ids=[program_name],
+ metadatas=[{"name": program_name}],
+ )
+ self.skills[program_name] = {
+ "code": program_code,
+ "description": skill_description,
+ }
+ assert self.vectordb._collection.count() == len(
+ self.skills
+ ), "vectordb is not synced with skills.json"
+ U.dump_text(
+ program_code, f"{self.ckpt_dir}/skill/compositional/{dumped_program_name}.js"
+ )
+ U.dump_text(
+ skill_description,
+ f"{self.ckpt_dir}/skill/description/{dumped_program_name}.txt",
+ )
+ U.dump_json(self.skills, f"{self.ckpt_dir}/skill/skills.json")
+ self.vectordb.persist()
+
+ def generate_skill_description(self, program_name, program_code):
+ messages = [
+ SystemMessage(content=load_prompt("skill")),
+ HumanMessage(
+ content=program_code
+ + "\n\n"
+ + f"The main function is `{program_name}`."
+ ),
+ ]
+ skill_description = f" // {call_with_messages(messages).content}"
+ return f"async function {program_name}(bot) {{\n{skill_description}\n}}"
+
+ def retrieve_skills(self, query):
+ k = min(self.vectordb._collection.count(), self.retrieval_top_k)
+ if k == 0:
+ return []
+ self.logger.info(f"Skill Manager retrieving for {k} skills")
+ docs_and_scores = self.vectordb.similarity_search_with_score(query, k=k)
+ self.logger.debug(
+ f"Skill Manager retrieved skills: "
+ f"{', '.join([doc.metadata['name'] for doc, _ in docs_and_scores])}"
+ )
+ self.logger.debug(
+ f"Skill Manager retrieved skills Scores: "
+ f"{[score for _, score in docs_and_scores]}"
+ )
+
+ code = []
+ description = []
+ for doc, _ in docs_and_scores:
+ code.append(self.skills[doc.metadata["name"]]["code"])
+ description.append(self.skills[doc.metadata["name"]]["description"])
+
+ skills = [code, description]
+ # print(skills)
+ return skills
+
+ def load_skill_primitives(self, primitive_names=None):
+ current_dir = os.getcwd()
+ # print(f"current dir: {current_dir}")
+ package_path = f"{current_dir}/skill_library/skill"
+ if primitive_names is None:
+ primitive_names = [
+ primitives[:-3]
+ for primitives in os.listdir(f"{package_path}/primitive")
+ if primitives.endswith(".js")
+ ]
+ primitives = [
+ U.load_text(f"{package_path}/primitive/{primitive_name}.js")
+ for primitive_name in primitive_names
+ ]
+ return primitives
+
+ def load_mc_skill_primitives(self, primitive_names=None):
+ current_dir = os.getcwd()
+ # print(f"current dir: {current_dir}")
+ package_path = f"{current_dir}/../MC-Comprehensive-Skill-Library/skill"
+ if primitive_names is None:
+ primitive_names = [
+ primitives[:-3]
+ for primitives in os.listdir(f"{package_path}")
+ if primitives.endswith(".js")
+ ]
+ primitives = [
+ U.load_text(f"{package_path}/{primitive_name}.js")
+ for primitive_name in primitive_names
+ ]
+ return primitives
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/.prettierrc.json b/experiments/Odyssey/Odyssey/odyssey/control_primitives/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a02bcefdab2e1654666e9d5effedc14501e98db
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "tabWidth": 4
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/__init__.py b/experiments/Odyssey/Odyssey/odyssey/control_primitives/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb019fff545932b254d5affe3768f409185bea5d
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/__init__.py
@@ -0,0 +1,18 @@
+import pkg_resources
+import os
+import odyssey.utils as U
+
+
+def load_control_primitives(primitive_names=None):
+ package_path = pkg_resources.resource_filename("odyssey", "")
+ if primitive_names is None:
+ primitive_names = [
+ primitives[:-3]
+ for primitives in os.listdir(f"{package_path}/control_primitives")
+ if primitives.endswith(".js")
+ ]
+ primitives = [
+ U.load_text(f"{package_path}/control_primitives/{primitive_name}.js")
+ for primitive_name in primitive_names
+ ]
+ return primitives
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftHelper.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftHelper.js
new file mode 100644
index 0000000000000000000000000000000000000000..41ae1f091ebc6ca2e490f49a10f71cd957585b0f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftHelper.js
@@ -0,0 +1,61 @@
+function failedCraftFeedback(bot, name, item, craftingTable) {
+ const recipes = bot.recipesAll(item.id, null, craftingTable);
+ if (!recipes.length) {
+ throw new Error(`No crafting table nearby`);
+ } else {
+ const recipes = bot.recipesAll(
+ item.id,
+ null,
+ mcData.blocksByName.crafting_table.id
+ );
+ // find the recipe with the fewest missing ingredients
+ var min = 999;
+ var min_recipe = null;
+ for (const recipe of recipes) {
+ const delta = recipe.delta;
+ var missing = 0;
+ for (const delta_item of delta) {
+ if (delta_item.count < 0) {
+ const inventory_item = bot.inventory.findInventoryItem(
+ mcData.items[delta_item.id].name,
+ null
+ );
+ if (!inventory_item) {
+ missing += -delta_item.count;
+ } else {
+ missing += Math.max(
+ -delta_item.count - inventory_item.count,
+ 0
+ );
+ }
+ }
+ }
+ if (missing < min) {
+ min = missing;
+ min_recipe = recipe;
+ }
+ }
+ const delta = min_recipe.delta;
+ let message = "";
+ for (const delta_item of delta) {
+ if (delta_item.count < 0) {
+ const inventory_item = bot.inventory.findInventoryItem(
+ mcData.items[delta_item.id].name,
+ null
+ );
+ if (!inventory_item) {
+ message += ` ${-delta_item.count} more ${
+ mcData.items[delta_item.id].name
+ }, `;
+ } else {
+ if (inventory_item.count < -delta_item.count) {
+ message += `${
+ -delta_item.count - inventory_item.count
+ } more ${mcData.items[delta_item.id].name}`;
+ }
+ }
+ }
+ }
+ bot.chat(`I cannot make ${name} because I need: ${message}`);
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..a58c738e0e728f097e373198342de01ece56bc66
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/craftItem.js
@@ -0,0 +1,113 @@
+function findCraftableRecipe(bot, recipes, executions = 1) {
+ const available = new Map();
+ for (const item of bot.inventory.items()) {
+ available.set(item.type, (available.get(item.type) || 0) + item.count);
+ }
+ for (const recipe of recipes || []) {
+ const required = new Map();
+ for (const delta of recipe.delta || []) {
+ if (delta.count >= 0) continue;
+ required.set(delta.id, (required.get(delta.id) || 0) + (-delta.count * executions));
+ }
+ if ([...required.entries()].every(([id, needed]) => (available.get(id) || 0) >= needed)) {
+ return recipe;
+ }
+ }
+ return null;
+}
+
+function craftRecipeCapacity(bot, recipe) {
+ const available = new Map();
+ for (const item of bot.inventory.items()) {
+ available.set(item.type, (available.get(item.type) || 0) + item.count);
+ }
+ let capacity = Infinity;
+ for (const delta of recipe.delta || []) {
+ if (delta.count >= 0) continue;
+ capacity = Math.min(capacity, Math.floor((available.get(delta.id) || 0) / -delta.count));
+ }
+ return Number.isFinite(capacity) ? capacity : 0;
+}
+
+function craftCountError(bot, name, recipes, requested) {
+ let bestRecipe = null;
+ let maximum = 0;
+ for (const recipe of recipes || []) {
+ const capacity = craftRecipeCapacity(bot, recipe);
+ if (capacity > maximum) {
+ maximum = capacity;
+ bestRecipe = recipe;
+ }
+ }
+ const requirements = [];
+ for (const delta of (bestRecipe && bestRecipe.delta) || []) {
+ if (delta.count >= 0) continue;
+ const item = mcData.items[delta.id];
+ requirements.push(`${item ? item.name : delta.id}=${-delta.count * requested}`);
+ }
+ return new Error(
+ `Cannot execute ${name} recipe ${requested} times; maximum executable count is ${maximum}`
+ + `${requirements.length ? `; requested ingredients: ${requirements.join(", ")}` : ""}. `
+ + "craftItem count means recipe executions, not desired output items."
+ );
+}
+
+async function craftItem(bot, name, count = 1) {
+ // return if name is not string
+ if (typeof name !== "string") {
+ throw new Error("name for craftItem must be a string");
+ }
+ // return if count is not number
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 1) {
+ throw new Error("count for craftItem must be a positive integer number of recipe executions");
+ }
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ throw new Error(`No item named ${name}`);
+ }
+ const craftingTable = bot.findBlock({
+ matching: mcData.blocksByName.crafting_table.id,
+ maxDistance: 32,
+ });
+ const noCraftingTableList = [
+ "crafting_table",
+ "melon_seeds", "pumpkin_seeds", "sugar",
+ "iron_nugget", "iron_trapdoor", "heavy_weighted_pressure_plate", "light_weighted_pressure_plate",
+ "stick", "torch", "flint_and_steel", "lever",
+ "oak_planks", "birch_planks", "spruce_planks", "jungle_planks", "acacia_planks", "dark_oak_planks", "mangrove_planks",
+ "cut_sandstone"];
+ if (noCraftingTableList.includes(name)) {
+ const recipes = bot.recipesFor(itemByName.id, null, 1, null);
+ const recipe = findCraftableRecipe(bot, recipes, count);
+ if (!recipe) {
+ throw craftCountError(bot, name, recipes, count);
+ }
+ try {
+ await bot.craft(recipe, count, null);
+ bot.chat(`I did the recipe for ${name} ${count} times`);
+ return;
+ } catch (err) {
+ bot.chat(`I cannot do the recipe for ${name} ${count} times`);
+ }
+ }
+ if (!craftingTable) {
+ bot.chat("Craft without a crafting table");
+ } else {
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(craftingTable.position, bot.world)
+ );
+ }
+ const recipes = bot.recipesFor(itemByName.id, null, 1, craftingTable);
+ const recipe = findCraftableRecipe(bot, recipes, count);
+ if (recipe) {
+ bot.chat(`I can make ${name}`);
+ try {
+ await bot.craft(recipe, count, craftingTable);
+ bot.chat(`I did the recipe for ${name} ${count} times`);
+ } catch (err) {
+ bot.chat(`I cannot do the recipe for ${name} ${count} times`);
+ }
+ } else {
+ throw craftCountError(bot, name, recipes, count);
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/equipItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/equipItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..429e8ea64609ae4aea9218e0a9700aef12c8ad94
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/equipItem.js
@@ -0,0 +1,30 @@
+async function equipItem(bot, itemName) {
+ // This primitive deliberately has one responsibility: select an item that
+ // already exists in the inventory and put it in the main hand. Do not add
+ // recovery behavior here (crafting, placing, mining, dropping, etc.);
+ // callers can observe a failed precondition and plan that work explicitly.
+ if (typeof itemName !== "string" || itemName.length === 0) {
+ throw new Error("itemName for equipItem must be a non-empty string");
+ }
+ if (!mcData.itemsByName[itemName]) {
+ throw new Error(`No item named ${itemName}`);
+ }
+
+ const item = bot.inventory.items().find((candidate) => candidate.name === itemName);
+ if (!item) {
+ const result = { ok: false, itemName, reason: "item_not_in_inventory" };
+ bot.chat(`Cannot equip ${itemName}: it is not in inventory.`);
+ return result;
+ }
+
+ const before = bot.heldItem ? bot.heldItem.name : null;
+ await bot.equip(item, "hand");
+ const after = bot.heldItem ? bot.heldItem.name : null;
+ if (after !== itemName) {
+ throw new Error(`equipItem verification failed: expected ${itemName}, found ${after || "empty hand"}`);
+ }
+
+ const result = { ok: true, itemName, before, after, evidence: "main_hand" };
+ bot.chat(`Equipped ${itemName}.`);
+ return result;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/exploreUntil.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/exploreUntil.js
new file mode 100644
index 0000000000000000000000000000000000000000..c73dcf3a9d77633d9e3feef22f75ec10968bf8bb
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/exploreUntil.js
@@ -0,0 +1,87 @@
+// Explore downward for 60 seconds: exploreUntil(bot, new Vec3(0, -1, 0), 60);
+async function exploreUntil(
+ bot,
+ direction,
+ maxTime = 60,
+ callback = () => {
+ return false;
+ }
+) {
+ if (typeof maxTime !== "number") {
+ throw new Error("maxTime must be a number");
+ }
+ if (typeof callback !== "function") {
+ throw new Error("callback must be a function");
+ }
+ const test = callback();
+ if (test) {
+ bot.chat("Explore success.");
+ return Promise.resolve(test);
+ }
+ if (direction.x === 0 && direction.y === 0 && direction.z === 0) {
+ throw new Error("direction cannot be 0, 0, 0");
+ }
+ if (
+ !(
+ (direction.x === 0 || direction.x === 1 || direction.x === -1) &&
+ (direction.y === 0 || direction.y === 1 || direction.y === -1) &&
+ (direction.z === 0 || direction.z === 1 || direction.z === -1)
+ )
+ ) {
+ throw new Error(
+ "direction must be a Vec3 only with value of -1, 0 or 1"
+ );
+ }
+ maxTime = Math.min(maxTime, 1200);
+ return new Promise((resolve, reject) => {
+ const dx = direction.x;
+ const dy = direction.y;
+ const dz = direction.z;
+
+ let explorationInterval;
+ let maxTimeTimeout;
+
+ const cleanUp = () => {
+ clearInterval(explorationInterval);
+ clearTimeout(maxTimeTimeout);
+ bot.pathfinder.setGoal(null);
+ };
+
+ const explore = () => {
+ const x =
+ bot.entity.position.x +
+ Math.floor(Math.random() * 20 + 10) * dx;
+ const y =
+ bot.entity.position.y +
+ Math.floor(Math.random() * 20 + 10) * dy;
+ const z =
+ bot.entity.position.z +
+ Math.floor(Math.random() * 20 + 10) * dz;
+ let goal = new GoalNear(x, y, z);
+ if (dy === 0) {
+ goal = new GoalNearXZ(x, z);
+ }
+ bot.pathfinder.setGoal(goal);
+
+ try {
+ const result = callback();
+ if (result) {
+ cleanUp();
+ bot.chat("Explore success.");
+ resolve(result);
+ }
+ } catch (err) {
+ cleanUp();
+ reject(err);
+ }
+ };
+
+ explorationInterval = setInterval(explore, 2000);
+
+ maxTimeTimeout = setTimeout(() => {
+ cleanUp();
+ bot.chat("Max exploration time reached");
+ resolve(null);
+ }, maxTime * 1000);
+ });
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/givePlacedItemBack.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/givePlacedItemBack.js
new file mode 100644
index 0000000000000000000000000000000000000000..57d3537f4682b55f546c3759dd53968f3db7121c
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/givePlacedItemBack.js
@@ -0,0 +1,38 @@
+async function givePlacedItemBack(bot, name, position) {
+ await bot.chat("/gamerule doTileDrops false");
+ // iterate name and position
+ const history = [];
+ for (let i = 0; i < name.length; i++) {
+ await givePlacedItemBackSingle(bot, name[i], position[i]);
+ }
+ await bot.chat("/gamerule doTileDrops true");
+
+ async function givePlacedItemBackSingle(bot, name, position) {
+ bot.chat(`/give bot ${name} 1`);
+ const x = Math.floor(position.x);
+ const y = Math.floor(position.y);
+ const z = Math.floor(position.z);
+ // loop through 125 blocks around the block
+ const size = 3;
+ for (let dx = -size; dx <= size; dx++) {
+ for (let dy = -size; dy <= size; dy++) {
+ for (let dz = -size; dz <= size; dz++) {
+ const block = bot.blockAt(new Vec3(x + dx, y + dy, z + dz));
+ if (
+ block?.name === name &&
+ !history.includes(block.position)
+ ) {
+ await bot.chat(
+ `/setblock ${x + dx} ${y + dy} ${
+ z + dz
+ } air destroy`
+ );
+ history.push(block.position);
+ await bot.waitForTicks(20);
+ return;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/killMob.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/killMob.js
new file mode 100644
index 0000000000000000000000000000000000000000..3466077b22fbef97fd3ccbc765e788e4f98f648a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/killMob.js
@@ -0,0 +1,51 @@
+async function killMob(bot, mobName, timeout = 300) {
+ // return if mobName is not string
+ if (typeof mobName !== "string") {
+ throw new Error(`mobName for killMob must be a string`);
+ }
+ // return if timeout is not number
+ if (typeof timeout !== "number") {
+ throw new Error(`timeout for killMob must be a number`);
+ }
+
+ const weaponsForShooting = [
+ "bow",
+ "crossbow",
+ "snowball",
+ "ender_pearl",
+ "egg",
+ "splash_potion",
+ "trident",
+ ];
+ const mainHandItem = bot.inventory.slots[bot.getEquipmentDestSlot("hand")];
+
+ const entity = bot.nearestEntity(
+ (entity) =>
+ entity.name === mobName &&
+ // kill mob distance should be slightly bigger than explore distance
+ entity.position.distanceTo(bot.entity.position) < 48
+ );
+ if (!entity) {
+ bot.chat(`No ${mobName} nearby, please explore first`);
+ _killMobFailCount++;
+ if (_killMobFailCount > 10) {
+ throw new Error(
+ `killMob failed too many times, make sure you explore before calling killMob`
+ );
+ }
+ return;
+ }
+
+ let droppedItem;
+ if (mainHandItem && weaponsForShooting.includes(mainHandItem.name)) {
+ bot.hawkEye.autoAttack(entity, mainHandItem.name);
+ droppedItem = await waitForMobShot(bot, entity, timeout);
+ } else {
+ await bot.pvp.attack(entity);
+ droppedItem = await waitForMobRemoved(bot, entity, timeout);
+ }
+ if (droppedItem) {
+ await bot.collectBlock.collect(droppedItem, { ignoreNoPath: true });
+ }
+ bot.save(`${mobName}_killed`);
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/maintainBase.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/maintainBase.js
new file mode 100644
index 0000000000000000000000000000000000000000..460a21727904830f63d01a96fab26ae8db642e63
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/maintainBase.js
@@ -0,0 +1,161 @@
+// Bind a model-designated durable block to a verified world coordinate.
+async function maintainRegisteredAsset(bot, savedAnchor, assetName, savedPosition, savedRejectedPositions = []) {
+ if (typeof assetName !== "string" || !/^[a-z0-9_]+$/.test(assetName)) {
+ throw new Error("assetName must be a concrete Minecraft block name");
+ }
+ let position = normalizedPosition(savedPosition);
+ let anchor = normalizedPosition(savedAnchor);
+ if (!position && anchor) {
+ // A prior placement may have reached the server while its inventory
+ // update arrived too late for placeItem's strict verification. Inspect
+ // the real world before selecting another coordinate so retries reuse
+ // the existing asset instead of creating a duplicate.
+ await useRegisteredAsset(bot, assetName, anchor, true);
+ const existingPosition = findNearbyRegisteredAsset(bot, assetName, anchor);
+ if (existingPosition) {
+ bot.save(`harness_asset:${JSON.stringify({
+ asset: assetName,
+ anchor,
+ position: existingPosition,
+ status: "present",
+ detail: "Recovered an existing nearby asset from world state."
+ })}`);
+ return;
+ }
+ }
+ if (!position) {
+ position = findRegisteredAssetPosition(bot, anchor, savedRejectedPositions);
+ if (!position) {
+ bot.save(`harness_asset:${JSON.stringify({ asset: assetName, anchor: null, position: null, status: "no_flat_position" })}`);
+ return;
+ }
+ }
+ if (!anchor) anchor = { x: position.x, y: position.y, z: position.z };
+ await useRegisteredAsset(bot, assetName, position, true);
+ const block = bot.blockAt(new Vec3(position.x, position.y, position.z));
+ let status = "unknown";
+ if (block?.name === assetName) {
+ status = "present";
+ } else {
+ // The durable coordinate can become stale when an older skill moves
+ // or destroys an asset. Prefer a world-observed asset of the exact
+ // same type near the base anchor over repeatedly repairing an empty
+ // coordinate. The report remains authoritative: Python only rebinds
+ // after this concrete block observation.
+ const nearbyPosition = findNearbyRegisteredAsset(bot, assetName, anchor);
+ if (
+ nearbyPosition &&
+ (nearbyPosition.x !== position.x ||
+ nearbyPosition.y !== position.y ||
+ nearbyPosition.z !== position.z)
+ ) {
+ bot.save(`harness_asset:${JSON.stringify({
+ asset: assetName,
+ anchor,
+ position: nearbyPosition,
+ status: "present",
+ detail: `Rebound stale registered coordinate from (${position.x},${position.y},${position.z}).`
+ })}`);
+ return;
+ }
+ if (block && block.name !== "air") {
+ status = `conflict:${block.name}`;
+ bot.save(`harness_asset:${JSON.stringify({ asset: assetName, anchor, position, status })}`);
+ return;
+ }
+ const item = mcData.itemsByName[assetName];
+ if (!item || !bot.inventory.findInventoryItem(item.id)) {
+ status = "missing_inventory_item";
+ } else {
+ try {
+ await placeItem(bot, assetName, new Vec3(position.x, position.y, position.z));
+ status = bot.blockAt(new Vec3(position.x, position.y, position.z))?.name === assetName ? "placed" : "placement_unverified";
+ } catch (err) {
+ const detail = err?.message || String(err);
+ // World state is authoritative for a persistent asset. If the
+ // block exists after a placement/inventory desynchronization,
+ // register it and prohibit a second placement elsewhere.
+ if (bot.blockAt(new Vec3(position.x, position.y, position.z))?.name === assetName) {
+ status = "present";
+ bot.save(`harness_asset:${JSON.stringify({ asset: assetName, anchor, position, status, detail })}`);
+ return;
+ }
+ status = "placement_failed";
+ bot.save(`harness_asset:${JSON.stringify({ asset: assetName, anchor, position, status, detail })}`);
+ return;
+ }
+ }
+ }
+ bot.save(`harness_asset:${JSON.stringify({ asset: assetName, anchor, position, status })}`);
+}
+
+async function useRegisteredAsset(bot, assetName, position, allowEmpty = false) {
+ const target = normalizedPosition(position);
+ if (!target) throw new Error("registered asset position is invalid");
+ const targetVec = new Vec3(target.x, target.y, target.z);
+ const block = bot.blockAt(targetVec);
+ if (!allowEmpty && block?.name !== assetName) {
+ throw new Error(`registered_asset_missing: expected=${assetName} actual=${block?.name}`);
+ }
+ if (targetVec.distanceTo(bot.entity.position) > 8) {
+ await bot.pathfinder.goto(new GoalNear(target.x, target.y, target.z, 3));
+ }
+}
+
+function normalizedPosition(value) {
+ if (!value || !Number.isInteger(value.x) || !Number.isInteger(value.y) || !Number.isInteger(value.z)) return null;
+ return { x: value.x, y: value.y, z: value.z };
+}
+
+function findNearbyRegisteredAsset(bot, assetName, savedAnchor, maxRadius = 12) {
+ const anchor = normalizedPosition(savedAnchor);
+ if (!anchor) return null;
+ const verticalOffsets = [0, -1, 1, -2, 2, -3, 3, -4, 4, -5, 5, -6, 6];
+ for (let radius = 0; radius <= maxRadius; radius++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ for (let dz = -radius; dz <= radius; dz++) {
+ if (radius > 0 && Math.max(Math.abs(dx), Math.abs(dz)) !== radius) continue;
+ for (const dy of verticalOffsets) {
+ const position = {
+ x: anchor.x + dx,
+ y: anchor.y + dy,
+ z: anchor.z + dz
+ };
+ if (bot.blockAt(new Vec3(position.x, position.y, position.z))?.name === assetName) {
+ return position;
+ }
+ }
+ }
+ }
+ }
+ return null;
+}
+
+function findRegisteredAssetPosition(bot, savedAnchor = null, savedRejectedPositions = []) {
+ const origin = normalizedPosition(savedAnchor) || bot.entity.position.floored();
+ const rejected = new Set(
+ (Array.isArray(savedRejectedPositions) ? savedRejectedPositions : [])
+ .map(normalizedPosition)
+ .filter(Boolean)
+ .map((position) => `${position.x},${position.y},${position.z}`)
+ );
+ for (let radius = 1; radius <= 12; radius++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ for (let dz = -radius; dz <= radius; dz++) {
+ if (Math.max(Math.abs(dx), Math.abs(dz)) !== radius) continue;
+ for (const dy of [0, -1, 1]) {
+ const position = { x: origin.x + dx, y: origin.y + dy, z: origin.z + dz };
+ if (rejected.has(`${position.x},${position.y},${position.z}`)) continue;
+ const target = new Vec3(position.x, position.y, position.z);
+ const ground = bot.blockAt(target.offset(0, -1, 0));
+ const head = bot.blockAt(target.offset(0, 1, 0));
+ const block = bot.blockAt(target);
+ if (ground && ground.name !== "air" && ground.name !== "water" && ground.name !== "lava" && block?.name === "air" && head?.name === "air") {
+ return position;
+ }
+ }
+ }
+ }
+ }
+ return null;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/mineBlock.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/mineBlock.js
new file mode 100644
index 0000000000000000000000000000000000000000..5bc1b768308ceec44a86ec264f4df9ee692ff698
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/mineBlock.js
@@ -0,0 +1,38 @@
+async function mineBlock(bot, name, count = 1) {
+ // return if name is not string
+ if (typeof name !== "string") {
+ throw new Error(`name for mineBlock must be a string`);
+ }
+ if (typeof count !== "number") {
+ throw new Error(`count for mineBlock must be a number`);
+ }
+ const blockByName = mcData.blocksByName[name];
+ if (!blockByName) {
+ throw new Error(`No block named ${name}`);
+ }
+ const blocks = bot.findBlocks({
+ matching: [blockByName.id],
+ maxDistance: 32,
+ count: 1024,
+ });
+ if (blocks.length === 0) {
+ bot.chat(`No ${name} nearby, please explore first`);
+ _mineBlockFailCount++;
+ if (_mineBlockFailCount > 10) {
+ throw new Error(
+ "mineBlock failed too many times, make sure you explore before calling mineBlock"
+ );
+ }
+ return;
+ }
+ const targets = [];
+ for (let i = 0; i < blocks.length; i++) {
+ targets.push(bot.blockAt(blocks[i]));
+ }
+ await bot.collectBlock.collect(targets, {
+ ignoreNoPath: true,
+ count: count,
+ });
+ console.log('Mine Block, targets:', targets, 'count:', count, 'name:', name);
+ bot.save(`${name}_mined`);
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/placeItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/placeItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..8d6d52f3998eeb9257fa1c6900261a5ec870d63e
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/placeItem.js
@@ -0,0 +1,113 @@
+async function placeItem(bot, name, position) {
+ // return if name is not string
+ if (typeof name !== "string") {
+ throw new Error(`name for placeItem must be a string`);
+ }
+ // return if position is not Vec3
+ if (!(position instanceof Vec3)) {
+ throw new Error(`position for placeItem must be a Vec3`);
+ }
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ throw new Error(`No item named ${name}`);
+ }
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ if (!item) {
+ bot.chat(`No ${name} in inventory`);
+ return;
+ }
+ // Count all stacks, rather than only the stack selected for equip. This is
+ // the inventory invariant used to verify a consumptive placement.
+ const countBefore = bot.inventory.count(itemByName.id);
+ // find a reference block
+ const faceVectors = [
+ new Vec3(0, 1, 0),
+ new Vec3(0, -1, 0),
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ ];
+ let referenceBlock = null;
+ let faceVector = null;
+ for (const vector of faceVectors) {
+ const block = bot.blockAt(position.minus(vector));
+ if (block?.name !== "air") {
+ referenceBlock = block;
+ faceVector = vector;
+ bot.chat(`Placing ${name} on ${block.name} at ${block.position}`);
+ break;
+ }
+ }
+ if (!referenceBlock) {
+ bot.chat(
+ `No block to place ${name} on. You cannot place a floating block.`
+ );
+ _placeItemFailCount++;
+ if (_placeItemFailCount > 10) {
+ throw new Error(
+ `placeItem failed too many times. You cannot place a floating block.`
+ );
+ }
+ return;
+ }
+
+ const placementVerified = () => {
+ const placedBlock = bot.blockAt(position);
+ const countAfter = bot.inventory.count(itemByName.id);
+ return {
+ blockPlaced: placedBlock?.name === name,
+ countAfter,
+ inventoryConsumed: countAfter === countBefore - 1,
+ };
+ };
+ const waitForPlacementSync = async (maxTicks = 40, pollTicks = 2) => {
+ let verification = placementVerified();
+ let waitedTicks = 0;
+ while (
+ (!verification.blockPlaced || !verification.inventoryConsumed) &&
+ waitedTicks < maxTicks &&
+ typeof bot.waitForTicks === "function"
+ ) {
+ await bot.waitForTicks(pollTicks);
+ waitedTicks += pollTicks;
+ verification = placementVerified();
+ }
+ return verification;
+ };
+
+ // A chat message is not proof of success. Verify both the world state and
+ // inventory state so callers can distinguish an actual placement from a
+ // server/observation desynchronization.
+ try {
+ // You must first go to the block position you want to place
+ await bot.pathfinder.goto(new GoalPlaceBlock(position, bot.world, {}));
+ // You must equip the item right before calling placeBlock
+ await bot.equip(item, "hand");
+ await bot.placeBlock(referenceBlock, faceVector);
+ const verification = await waitForPlacementSync();
+ if (!verification.blockPlaced || !verification.inventoryConsumed) {
+ throw new Error(
+ `placement_inventory_desync: item=${name} before=${countBefore} after=${verification.countAfter} blockPlaced=${verification.blockPlaced}`
+ );
+ }
+ bot.chat(`Placed ${name}`);
+ bot.save(`${name}_placed`);
+ } catch (err) {
+ // Mineflayer can occasionally reject after the server already applied
+ // the placement. Treat that as success only after the same two-part
+ // verification, never based on a changed hand slot or chat message.
+ const verification = await waitForPlacementSync();
+ if (verification.blockPlaced && verification.inventoryConsumed) {
+ bot.chat(`Placed ${name}`);
+ bot.save(`${name}_placed`);
+ return;
+ }
+ _placeItemFailCount++;
+ const detail = err?.message || String(err);
+ bot.chat(`Error placing ${name}: ${detail}`);
+ throw new Error(
+ `placement_failed: item=${name} before=${countBefore} after=${verification.countAfter} blockPlaced=${verification.blockPlaced} cause=${detail}`
+ );
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/protectRegisteredAssets.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/protectRegisteredAssets.js
new file mode 100644
index 0000000000000000000000000000000000000000..9b8291859ea3ec4200efc9bb7cfcccccfc1a72c7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/protectRegisteredAssets.js
@@ -0,0 +1,73 @@
+// Prevent model-designated persistent assets from being destroyed as
+// collateral damage by pathfinding or collection skills.
+function protectRegisteredAssets(bot, assets = []) {
+ const normalizedAssets = (Array.isArray(assets) ? assets : [])
+ .map((asset) => {
+ const position = asset?.position;
+ if (
+ !position ||
+ !Number.isInteger(position.x) ||
+ !Number.isInteger(position.y) ||
+ !Number.isInteger(position.z)
+ ) {
+ return null;
+ }
+ return {
+ block_name: typeof asset.block_name === "string" ? asset.block_name : "world_asset",
+ position: { x: position.x, y: position.y, z: position.z },
+ };
+ })
+ .filter(Boolean);
+
+ const protectedByPosition = new Map(
+ normalizedAssets.map((asset) => [
+ `${asset.position.x},${asset.position.y},${asset.position.z}`,
+ asset,
+ ])
+ );
+ // The dig guard is installed once, while this map is replaced before
+ // every Harness action so newly registered or relocated assets take
+ // effect without stacking wrappers.
+ bot._harnessProtectedAssets = protectedByPosition;
+
+ const positionKey = (position) =>
+ position && Number.isFinite(position.x) && Number.isFinite(position.y) && Number.isFinite(position.z)
+ ? `${Math.floor(position.x)},${Math.floor(position.y)},${Math.floor(position.z)}`
+ : null;
+
+ if (!bot._harnessOriginalDig && typeof bot.dig === "function") {
+ bot._harnessOriginalDig = bot.dig.bind(bot);
+ bot.dig = async function guardedDig(block, ...args) {
+ const key = positionKey(block?.position);
+ const protectedAsset = key ? bot._harnessProtectedAssets?.get(key) : null;
+ if (protectedAsset) {
+ throw new Error(
+ `protected_world_asset: refusing to dig ${protectedAsset.block_name} at ${key}`
+ );
+ }
+ return bot._harnessOriginalDig(block, ...args);
+ };
+ }
+
+ const installPathProtection = (movements) => {
+ if (!movements || !Array.isArray(movements.exclusionAreasBreak)) return;
+ movements.exclusionAreasBreak = movements.exclusionAreasBreak.filter(
+ (callback) => !callback?._harnessProtectedAssetGuard
+ );
+ const guard = (block) => {
+ const key = positionKey(block?.position);
+ return key && bot._harnessProtectedAssets?.has(key) ? 100 : 0;
+ };
+ guard._harnessProtectedAssetGuard = true;
+ movements.exclusionAreasBreak.push(guard);
+ };
+
+ // collectBlock owns a separate Movements instance and replaces the
+ // pathfinder's active instance whenever collection starts, so both must
+ // carry the same coordinate guard.
+ installPathProtection(bot.pathfinder?.movements);
+ installPathProtection(bot.collectBlock?.movements);
+ if (bot.pathfinder?.movements && typeof bot.pathfinder.setMovements === "function") {
+ bot.pathfinder.setMovements(bot.pathfinder.movements);
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/shoot.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/shoot.js
new file mode 100644
index 0000000000000000000000000000000000000000..c0f862a0c272047ca73436d2b4b0abe388e2a6c9
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/shoot.js
@@ -0,0 +1,34 @@
+// shoot 1 pig with a bow: shoot(bot, "bow", "pig");
+async function shoot(bot, weapon, target) {
+ const validWeapons = [
+ "bow",
+ "crossbow",
+ "snowball",
+ "ender_pearl",
+ "egg",
+ "splash_potion",
+ "trident",
+ ];
+ if (!validWeapons.includes(weapon)) {
+ bot.chat(`${weapon} is not a valid weapon for shooting`);
+ return;
+ }
+
+ const weaponItem = mcData.itemsByName[weapon];
+ if (!bot.inventory.findInventoryItem(weaponItem.id, null)) {
+ bot.chat(`No ${weapon} in inventory for shooting`);
+ return;
+ }
+
+ const targetEntity = bot.nearestEntity(
+ (entity) =>
+ entity.name === target
+ );
+ if (!targetEntity) {
+ bot.chat(`No ${target} nearby`);
+ return;
+ }
+ bot.hawkEye.autoAttack(targetEntity, "bow");
+ bot.on('auto_shot_stopped', (target) => {
+ })
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/smeltItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/smeltItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..4f06817ad8fbb6f4057ffe828677e31ba11911f3
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/smeltItem.js
@@ -0,0 +1,68 @@
+async function smeltItem(bot, itemName, fuelName, count = 1) {
+ // return if itemName or fuelName is not string
+ if (typeof itemName !== "string" || typeof fuelName !== "string") {
+ throw new Error("itemName or fuelName for smeltItem must be a string");
+ }
+ // return if count is not a number
+ if (typeof count !== "number") {
+ throw new Error("count for smeltItem must be a number");
+ }
+ const item = mcData.itemsByName[itemName];
+ const fuel = mcData.itemsByName[fuelName];
+ if (!item) {
+ throw new Error(`No item named ${itemName}`);
+ }
+ if (!fuel) {
+ throw new Error(`No item named ${fuelName}`);
+ }
+ const furnaceBlock = bot.findBlock({
+ matching: mcData.blocksByName.furnace.id,
+ maxDistance: 32,
+ });
+ if (!furnaceBlock) {
+ throw new Error("No furnace nearby");
+ } else {
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(furnaceBlock.position, bot.world)
+ );
+ }
+ const furnace = await bot.openFurnace(furnaceBlock);
+ let success_count = 0;
+ for (let i = 0; i < count; i++) {
+ if (!bot.inventory.findInventoryItem(item.id, null)) {
+ bot.chat(`No ${itemName} to smelt in inventory`);
+ break;
+ }
+ if (furnace.fuelSeconds < 15 && furnace.fuelItem()?.name !== fuelName) {
+ if (!bot.inventory.findInventoryItem(fuel.id, null)) {
+ bot.chat(`No ${fuelName} as fuel in inventory`);
+ break;
+ }
+ await furnace.putFuel(fuel.id, null, 1);
+ await bot.waitForTicks(20);
+ if (!furnace.fuel && furnace.fuelItem()?.name !== fuelName) {
+ throw new Error(`${fuelName} is not a valid fuel`);
+ }
+ }
+ await furnace.putInput(item.id, null, 1);
+ await bot.waitForTicks(12 * 20);
+ if (!furnace.outputItem()) {
+ throw new Error(`${itemName} is not a valid input`);
+ }
+ await furnace.takeOutput();
+ success_count++;
+ }
+ furnace.close();
+ if (success_count > 0) bot.chat(`Smelted ${success_count} ${itemName}.`);
+ else {
+ bot.chat(
+ `Failed to smelt ${itemName}, please check the fuel and input.`
+ );
+ _smeltItemFailCount++;
+ if (_smeltItemFailCount > 10) {
+ throw new Error(
+ `smeltItem failed too many times, please check the fuel and input.`
+ );
+ }
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/useChest.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/useChest.js
new file mode 100644
index 0000000000000000000000000000000000000000..64f02da8037a30f6bdfee7aabb4811acc93c6537
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/useChest.js
@@ -0,0 +1,133 @@
+async function getItemFromChest(bot, chestPosition, itemsToGet) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ bot.chat("chestPosition for getItemFromChest must be a Vec3");
+ return;
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToGet) {
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ bot.chat(`No item named ${name}`);
+ continue;
+ }
+
+ const item = chest.findContainerItem(itemByName.id);
+ if (!item) {
+ bot.chat(`I don't see ${name} in this chest`);
+ continue;
+ }
+ try {
+ await chest.withdraw(item.type, null, itemsToGet[name]);
+ } catch (err) {
+ bot.chat(`Not enough ${name} in chest.`);
+ }
+ }
+ await closeChest(bot, chestBlock);
+}
+
+async function depositItemIntoChest(bot, chestPosition, itemsToDeposit) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToDeposit) {
+ const itemByName = mcData.itemsByName[name];
+ if (!itemByName) {
+ bot.chat(`No item named ${name}`);
+ continue;
+ }
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ if (!item) {
+ bot.chat(`No ${name} in inventory`);
+ continue;
+ }
+ try {
+ await chest.deposit(item.type, null, itemsToDeposit[name]);
+ } catch (err) {
+ bot.chat(`Not enough ${name} in inventory.`);
+ }
+ }
+ await closeChest(bot, chestBlock);
+}
+
+async function checkItemInsideChest(bot, chestPosition) {
+ // return if chestPosition is not Vec3
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ await bot.openContainer(chestBlock);
+ await closeChest(bot, chestBlock);
+}
+
+async function moveToChest(bot, chestPosition) {
+ if (!(chestPosition instanceof Vec3)) {
+ throw new Error(
+ "chestPosition for depositItemIntoChest must be a Vec3"
+ );
+ }
+ if (chestPosition.distanceTo(bot.entity.position) > 32) {
+ bot.chat(
+ `/tp ${chestPosition.x} ${chestPosition.y} ${chestPosition.z}`
+ );
+ await bot.waitForTicks(20);
+ }
+ const chestBlock = bot.blockAt(chestPosition);
+ if (chestBlock.name !== "chest") {
+ bot.emit("removeChest", chestPosition);
+ throw new Error(
+ `No chest at ${chestPosition}, it is ${chestBlock.name}`
+ );
+ }
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(chestBlock.position, bot.world, {})
+ );
+ return chestBlock;
+}
+
+async function listItemsInChest(bot, chestBlock) {
+ const chest = await bot.openContainer(chestBlock);
+ const items = chest.containerItems();
+ if (items.length > 0) {
+ const itemNames = items.reduce((acc, obj) => {
+ if (acc[obj.name]) {
+ acc[obj.name] += obj.count;
+ } else {
+ acc[obj.name] = obj.count;
+ }
+ return acc;
+ }, {});
+ bot.emit("closeChest", itemNames, chestBlock.position);
+ } else {
+ bot.emit("closeChest", {}, chestBlock.position);
+ }
+ return chest;
+}
+
+async function closeChest(bot, chestBlock) {
+ try {
+ const chest = await listItemsInChest(bot, chestBlock);
+ await chest.close();
+ } catch (err) {
+ await bot.closeWindow(chestBlock);
+ }
+}
+
+function itemByName(items, name) {
+ for (let i = 0; i < items.length; ++i) {
+ const item = items[i];
+ if (item && item.name === name) return item;
+ }
+ return null;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives/waitForMobRemoved.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives/waitForMobRemoved.js
new file mode 100644
index 0000000000000000000000000000000000000000..fa83d4310bbe4776e153780dab8db8cca4097e30
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives/waitForMobRemoved.js
@@ -0,0 +1,84 @@
+function waitForMobRemoved(bot, entity, timeout = 300) {
+ return new Promise((resolve, reject) => {
+ let success = false;
+ let droppedItem = null;
+ // Set up timeout
+ const timeoutId = setTimeout(() => {
+ success = false;
+ bot.pvp.stop();
+ }, timeout * 1000);
+
+ // Function to handle entityRemoved event
+ function onEntityGone(e) {
+ if (e === entity) {
+ success = true;
+ clearTimeout(timeoutId);
+ bot.chat(`Killed ${entity.name}!`);
+ bot.pvp.stop();
+ }
+ }
+
+ function onItemDrop(item) {
+ if (entity.position.distanceTo(item.position) <= 1) {
+ droppedItem = item;
+ }
+ }
+
+ function onStoppedAttacking() {
+ clearTimeout(timeoutId);
+ bot.removeListener("entityGone", onEntityGone);
+ bot.removeListener("stoppedAttacking", onStoppedAttacking);
+ bot.removeListener("itemDrop", onItemDrop);
+ if (!success) reject(new Error(`Failed to kill ${entity.name}.`));
+ else resolve(droppedItem);
+ }
+
+ // Listen for entityRemoved event
+ bot.on("entityGone", onEntityGone);
+ bot.on("stoppedAttacking", onStoppedAttacking);
+ bot.on("itemDrop", onItemDrop);
+ });
+}
+
+
+function waitForMobShot(bot, entity, timeout = 300) {
+ return new Promise((resolve, reject) => {
+ let success = false;
+ let droppedItem = null;
+ // Set up timeout
+ const timeoutId = setTimeout(() => {
+ success = false;
+ bot.hawkEye.stop();
+ }, timeout * 1000);
+
+ // Function to handle entityRemoved event
+ function onEntityGone(e) {
+ if (e === entity) {
+ success = true;
+ clearTimeout(timeoutId);
+ bot.chat(`Shot ${entity.name}!`);
+ bot.hawkEye.stop();
+ }
+ }
+
+ function onItemDrop(item) {
+ if (entity.position.distanceTo(item.position) <= 1) {
+ droppedItem = item;
+ }
+ }
+
+ function onAutoShotStopped() {
+ clearTimeout(timeoutId);
+ bot.removeListener("entityGone", onEntityGone);
+ bot.removeListener("auto_shot_stopped", onAutoShotStopped);
+ bot.removeListener("itemDrop", onItemDrop);
+ if (!success) reject(new Error(`Failed to shoot ${entity.name}.`));
+ else resolve(droppedItem);
+ }
+
+ // Listen for entityRemoved event
+ bot.on("entityGone", onEntityGone);
+ bot.on("auto_shot_stopped", onAutoShotStopped);
+ bot.on("itemDrop", onItemDrop);
+ });
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/.prettierrc.json b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a02bcefdab2e1654666e9d5effedc14501e98db
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "tabWidth": 4
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/__init__.py b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..c5cea4a64c5d872119929317a9d589b19548d082
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/__init__.py
@@ -0,0 +1,18 @@
+import pkg_resources
+import os
+import odyssey.utils as U
+
+
+def load_control_primitives_context(primitive_names=None):
+ package_path = pkg_resources.resource_filename("odyssey", "")
+ if primitive_names is None:
+ primitive_names = [
+ primitive[:-3]
+ for primitive in os.listdir(f"{package_path}/control_primitives_context")
+ if primitive.endswith(".js")
+ ]
+ primitives = [
+ U.load_text(f"{package_path}/control_primitives_context/{primitive_name}.js")
+ for primitive_name in primitive_names
+ ]
+ return primitives
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/craftItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/craftItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..c7ad52f3a64a5054f0e7cbc0e2822a2f4b08ff87
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/craftItem.js
@@ -0,0 +1,27 @@
+// count is recipe executions, not desired output quantity.
+// Craft 8 oak_planks from 2 oak_log: craftItem(bot, "oak_planks", 2);
+// You must place a crafting table before calling this function
+async function craftItem(bot, name, count = 1) {
+ const item = mcData.itemsByName[name];
+ const craftingTable = bot.findBlock({
+ matching: mcData.blocksByName.crafting_table.id,
+ maxDistance: 32,
+ });
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(craftingTable.position, bot.world)
+ );
+ const available = new Map();
+ for (const inventoryItem of bot.inventory.items()) {
+ available.set(inventoryItem.type, (available.get(inventoryItem.type) || 0) + inventoryItem.count);
+ }
+ const recipes = bot.recipesFor(item.id, null, 1, craftingTable);
+ const recipe = recipes.find((candidate) => {
+ const required = new Map();
+ for (const delta of candidate.delta || []) {
+ if (delta.count < 0) required.set(delta.id, (required.get(delta.id) || 0) + (-delta.count * count));
+ }
+ return [...required.entries()].every(([id, needed]) => (available.get(id) || 0) >= needed);
+ });
+ if (!recipe) throw new Error(`No craftable recipe for ${name} with current inventory`);
+ await bot.craft(recipe, count, craftingTable);
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/exploreUntil.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/exploreUntil.js
new file mode 100644
index 0000000000000000000000000000000000000000..55c62a4539ac1f5ea8c391ff1d2119919d802894
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/exploreUntil.js
@@ -0,0 +1,31 @@
+/*
+Explore until find an iron_ore, use Vec3(0, -1, 0) because iron ores are usually underground
+await exploreUntil(bot, new Vec3(0, -1, 0), 60, () => {
+ const iron_ore = bot.findBlock({
+ matching: mcData.blocksByName["iron_ore"].id,
+ maxDistance: 32,
+ });
+ return iron_ore;
+});
+
+Explore until find a pig, use Vec3(1, 0, 1) because pigs are usually on the surface
+let pig = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const pig = bot.nearestEntity((entity) => {
+ return (
+ entity.name === "pig" &&
+ entity.position.distanceTo(bot.entity.position) < 32
+ );
+ });
+ return pig;
+});
+*/
+async function exploreUntil(bot, direction, maxTime = 60, callback) {
+ /*
+ Implementation of this function is omitted.
+ direction: Vec3, can only contain value of -1, 0 or 1
+ maxTime: number, the max time for exploration
+ callback: function, early stop condition, will be called each second, exploration will stop if return value is not null
+
+ Return: null if explore timeout, otherwise return the return value of callback
+ */
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/killMob.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/killMob.js
new file mode 100644
index 0000000000000000000000000000000000000000..670ca9753cb2c1348b5d3b597f7d8d7779c8f500
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/killMob.js
@@ -0,0 +1,12 @@
+// Kill a pig and collect the dropped item: killMob(bot, "pig", 300);
+async function killMob(bot, mobName, timeout = 300) {
+ const entity = bot.nearestEntity(
+ (entity) =>
+ entity.name === mobName &&
+ entity.position.distanceTo(bot.entity.position) < 32
+ );
+ await bot.pvp.attack(entity);
+ await bot.pathfinder.goto(
+ new GoalBlock(entity.position.x, entity.position.y, entity.position.z)
+ );
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineBlock.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineBlock.js
new file mode 100644
index 0000000000000000000000000000000000000000..c6a7559e600eeaf146c78c2c06e5f457e9e31e1f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineBlock.js
@@ -0,0 +1,15 @@
+// Mine 3 cobblestone: mineBlock(bot, "stone", 3);
+async function mineBlock(bot, name, count = 1) {
+ const blocks = bot.findBlocks({
+ matching: (block) => {
+ return block.name === name;
+ },
+ maxDistance: 32,
+ count: count,
+ });
+ const targets = [];
+ for (let i = 0; i < Math.min(blocks.length, count); i++) {
+ targets.push(bot.blockAt(blocks[i]));
+ }
+ await bot.collectBlock.collect(targets, { ignoreNoPath: true });
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineflayer.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineflayer.js
new file mode 100644
index 0000000000000000000000000000000000000000..43217885ca2fe281da3f7b4a9c42d7defbcedc12
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/mineflayer.js
@@ -0,0 +1,22 @@
+await bot.pathfinder.goto(goal); // A very useful function. This function may change your main-hand equipment.
+// Following are some Goals you can use:
+new GoalNear(x, y, z, range); // Move the bot to a block within the specified range of the specified block. `x`, `y`, `z`, and `range` are `number`
+new GoalXZ(x, z); // Useful for long-range goals that don't have a specific Y level. `x` and `z` are `number`
+new GoalGetToBlock(x, y, z); // Not get into the block, but get directly adjacent to it. Useful for fishing, farming, filling bucket, and beds. `x`, `y`, and `z` are `number`
+new GoalFollow(entity, range); // Follow the specified entity within the specified range. `entity` is `Entity`, `range` is `number`
+new GoalPlaceBlock(position, bot.world, {}); // Position the bot in order to place a block. `position` is `Vec3`
+new GoalLookAtBlock(position, bot.world, {}); // Path into a position where a blockface of the block at position is visible. `position` is `Vec3`
+
+// These are other Mineflayer functions you can use:
+bot.isABed(bedBlock); // Return true if `bedBlock` is a bed
+bot.blockAt(position); // Return the block at `position`. `position` is `Vec3`
+
+// These are other Mineflayer async functions you can use:
+await bot.equip(item, destination); // Equip the item in the specified destination. `item` is `Item`, `destination` can only be "hand", "head", "torso", "legs", "feet", "off-hand"
+await bot.consume(); // Consume the item in the bot's hand. You must equip the item to consume first. Useful for eating food, drinking potions, etc.
+await bot.fish(); // Let bot fish. Before calling this function, you must first get to a water block and then equip a fishing rod. The bot will automatically stop fishing when it catches a fish
+await bot.sleep(bedBlock); // Sleep until sunrise. You must get to a bed block first
+await bot.activateBlock(block); // This is the same as right-clicking a block in the game. Useful for buttons, doors, etc. You must get to the block first
+await bot.lookAt(position); // Look at the specified position. You must go near the position before you look at it. To fill bucket with water, you must lookAt first. `position` is `Vec3`
+await bot.activateItem(); // This is the same as right-clicking to use the item in the bot's hand. Useful for using buckets, etc. You must equip the item to activate first
+await bot.useOn(entity); // This is the same as right-clicking an entity in the game. Useful for shearing sheep, equipping harnesses, etc. You must get to the entity first
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/placeItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/placeItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..99e06089c004e25a8ed87665a52aa7d8954ca9c7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/placeItem.js
@@ -0,0 +1,28 @@
+// Place a crafting_table near the player, Vec3(1, 0, 0) is just an example, you shouldn't always use that: placeItem(bot, "crafting_table", bot.entity.position.offset(1, 0, 0));
+async function placeItem(bot, name, position) {
+ const item = bot.inventory.findInventoryItem(mcData.itemsByName[name].id);
+ // find a reference block
+ const faceVectors = [
+ new Vec3(0, 1, 0),
+ new Vec3(0, -1, 0),
+ new Vec3(1, 0, 0),
+ new Vec3(-1, 0, 0),
+ new Vec3(0, 0, 1),
+ new Vec3(0, 0, -1),
+ ];
+ let referenceBlock = null;
+ let faceVector = null;
+ for (const vector of faceVectors) {
+ const block = bot.blockAt(position.minus(vector));
+ if (block?.name !== "air") {
+ referenceBlock = block;
+ faceVector = vector;
+ break;
+ }
+ }
+ // You must first go to the block position you want to place
+ await bot.pathfinder.goto(new GoalPlaceBlock(position, bot.world, {}));
+ // You must equip the item right before calling placeBlock
+ await bot.equip(item, "hand");
+ await bot.placeBlock(referenceBlock, faceVector);
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/smeltItem.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/smeltItem.js
new file mode 100644
index 0000000000000000000000000000000000000000..0a3c76257d275b7b11a2ae8dd69eacb7f7bcdf90
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/smeltItem.js
@@ -0,0 +1,22 @@
+// Smelt 1 raw_iron into 1 iron_ingot using 1 oak_planks as fuel: smeltItem(bot, "raw_iron", "oak_planks");
+// You must place a furnace before calling this function
+async function smeltItem(bot, itemName, fuelName, count = 1) {
+ const item = mcData.itemsByName[itemName];
+ const fuel = mcData.itemsByName[fuelName];
+ const furnaceBlock = bot.findBlock({
+ matching: mcData.blocksByName.furnace.id,
+ maxDistance: 32,
+ });
+ await bot.pathfinder.goto(
+ new GoalLookAtBlock(furnaceBlock.position, bot.world)
+ );
+ const furnace = await bot.openFurnace(furnaceBlock);
+ for (let i = 0; i < count; i++) {
+ await furnace.putFuel(fuel.id, null, 1);
+ await furnace.putInput(item.id, null, 1);
+ // Wait 12 seconds for the furnace to smelt the item
+ await bot.waitForTicks(12 * 20);
+ await furnace.takeOutput();
+ }
+ await furnace.close();
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/useChest.js b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/useChest.js
new file mode 100644
index 0000000000000000000000000000000000000000..e80af3fd94a7b281729011ad53f93696ac6d7947
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/control_primitives_context/useChest.js
@@ -0,0 +1,35 @@
+// Get a torch from chest at (30, 65, 100): getItemFromChest(bot, new Vec3(30, 65, 100), {"torch": 1});
+// This function will work no matter how far the bot is from the chest.
+async function getItemFromChest(bot, chestPosition, itemsToGet) {
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToGet) {
+ const itemByName = mcData.itemsByName[name];
+ const item = chest.findContainerItem(itemByName.id);
+ await chest.withdraw(item.type, null, itemsToGet[name]);
+ }
+ await closeChest(bot, chestBlock);
+}
+// Deposit a torch into chest at (30, 65, 100): depositItemIntoChest(bot, new Vec3(30, 65, 100), {"torch": 1});
+// This function will work no matter how far the bot is from the chest.
+async function depositItemIntoChest(bot, chestPosition, itemsToDeposit) {
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ const chest = await bot.openContainer(chestBlock);
+ for (const name in itemsToDeposit) {
+ const itemByName = mcData.itemsByName[name];
+ const item = bot.inventory.findInventoryItem(itemByName.id);
+ await chest.deposit(item.type, null, itemsToDeposit[name]);
+ }
+ await closeChest(bot, chestBlock);
+}
+// Check the items inside the chest at (30, 65, 100): checkItemInsideChest(bot, new Vec3(30, 65, 100));
+// You only need to call this function once without any action to finish task of checking items inside the chest.
+async function checkItemInsideChest(bot, chestPosition) {
+ await moveToChest(bot, chestPosition);
+ const chestBlock = bot.blockAt(chestPosition);
+ await bot.openContainer(chestBlock);
+ // You must close the chest after opening it if you are asked to open a chest
+ await closeChest(bot, chestBlock);
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/embeddings.py b/experiments/Odyssey/Odyssey/odyssey/embeddings.py
new file mode 100644
index 0000000000000000000000000000000000000000..c933bcc52cc3adfcdf4be3fd463b966dee28fab2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/embeddings.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import os
+from typing import Any
+
+import requests
+
+
+class OpenAICompatibleEmbeddings:
+ """Minimal LangChain-compatible client for an OpenAI embeddings endpoint."""
+
+ def __init__(
+ self,
+ *,
+ base_url: str,
+ api_key: str,
+ model: str,
+ timeout: float = 60,
+ batch_size: int = 32,
+ query_instruction: str = "",
+ ):
+ self.endpoint = base_url.rstrip("/") + "/embeddings"
+ self.api_key = api_key
+ self.model = model
+ self.timeout = timeout
+ self.batch_size = max(1, int(batch_size))
+ self.query_instruction = query_instruction.strip()
+
+ def embed_documents(self, texts: list[str]) -> list[list[float]]:
+ return self._embed(list(texts))
+
+ def embed_query(self, text: str) -> list[float]:
+ query = str(text)
+ if self.query_instruction:
+ query = f"Instruct: {self.query_instruction}\nQuery: {query}"
+ return self._embed([query])[0]
+
+ def _embed(self, texts: list[str]) -> list[list[float]]:
+ if not texts:
+ return []
+ vectors: list[list[float]] = []
+ headers = {"Content-Type": "application/json"}
+ if self.api_key:
+ headers["Authorization"] = f"Bearer {self.api_key}"
+
+ for start in range(0, len(texts), self.batch_size):
+ batch = texts[start : start + self.batch_size]
+ response = requests.post(
+ self.endpoint,
+ headers=headers,
+ json={"model": self.model, "input": batch},
+ timeout=self.timeout,
+ )
+ try:
+ response.raise_for_status()
+ payload = response.json()
+ entries = sorted(payload["data"], key=lambda item: int(item["index"]))
+ batch_vectors = [entry["embedding"] for entry in entries]
+ except Exception as exc: # noqa: BLE001
+ detail = response.text[:500] if response is not None else ""
+ raise RuntimeError(
+ f"Embedding request failed for {self.model} at {self.endpoint}: {detail}"
+ ) from exc
+ if len(batch_vectors) != len(batch):
+ raise RuntimeError(
+ f"Embedding endpoint returned {len(batch_vectors)} vectors for {len(batch)} texts"
+ )
+ vectors.extend(batch_vectors)
+ return vectors
+
+
+def build_embeddings(config: str | dict[str, Any]):
+ """Build either the legacy local HuggingFace or an API embedding client."""
+ if isinstance(config, dict):
+ provider = str(config.get("provider", "")).strip().lower()
+ if provider == "openai_compatible":
+ api_key = str(config.get("api_key", ""))
+ api_key_env = str(config.get("api_key_env", "")).strip()
+ if api_key_env:
+ api_key = os.getenv(api_key_env, api_key)
+ return OpenAICompatibleEmbeddings(
+ base_url=str(config["base_url"]),
+ api_key=api_key,
+ model=str(config["model"]),
+ timeout=float(config.get("timeout", 60)),
+ batch_size=int(config.get("batch_size", 32)),
+ query_instruction=str(config.get("query_instruction", "")),
+ )
+ if provider not in {"", "huggingface"}:
+ raise ValueError(f"Unsupported embedding provider: {provider}")
+ model_name = str(config.get("model") or config.get("model_name") or "")
+ else:
+ model_name = str(config or "")
+
+ from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings
+
+ return HuggingFaceEmbeddings(model_name=model_name)
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/.gitignore b/experiments/Odyssey/Odyssey/odyssey/env/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..7d06e79c2d2351b640338ec34bc4dec8bb6b4595
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/.gitignore
@@ -0,0 +1,294 @@
+# MCP-Reborn
+MCP-Reborn/
+run/
+*.jar
+config.json
+
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+share/python-wheels/
+*.egg-info/
+.installed.cfg
+*.egg
+MANIFEST
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.nox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*.cover
+*.py,cover
+.hypothesis/
+.pytest_cache/
+cover/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+local_settings.py
+db.sqlite3
+db.sqlite3-journal
+
+# Flask stuff:
+instance/
+.webassets-cache
+
+# Scrapy stuff:
+.scrapy
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+.pybuilder/
+target/
+
+# Jupyter Notebook
+.ipynb_checkpoints
+
+# IPython
+profile_default/
+ipython_config.py
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+# .python-version
+
+# pipenv
+# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
+# However, in case of collaboration, if having platform-specific dependencies or dependencies
+# having no cross-platform support, pipenv may install dependencies that don't work, or not
+# install all needed dependencies.
+#Pipfile.lock
+
+# poetry
+# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
+# This is especially recommended for binary packages to ensure reproducibility, and is more
+# commonly ignored for libraries.
+# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
+#poetry.lock
+
+# pdm
+# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
+#pdm.lock
+# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
+# in version control.
+# https://pdm.fming.dev/#use-with-ide
+.pdm.toml
+
+# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
+__pypackages__/
+
+# Celery stuff
+celerybeat-schedule
+celerybeat.pid
+
+# SageMath parsed files
+*.sage.py
+
+# Environments
+.env
+.venv
+env/
+venv/
+ENV/
+env.bak/
+venv.bak/
+
+# Spyder project settings
+.spyderproject
+.spyproject
+
+# Rope project settings
+.ropeproject
+
+# mkdocs documentation
+/site
+
+# mypy
+.mypy_cache/
+.dmypy.json
+dmypy.json
+
+# Pyre type checker
+.pyre/
+
+# pytype static type analyzer
+.pytype/
+
+# Cython debug symbols
+cython_debug/
+
+# PyCharm
+# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
+# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
+# and can be added to the global gitignore or merged into this file. For a more nuclear
+# option (not recommended) you can uncomment the following to ignore the entire idea folder.
+.idea/
+
+# Logs
+logs
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+package-lock.json
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/__init__.py b/experiments/Odyssey/Odyssey/odyssey/env/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..24db3aefe1bb291b3ea7cbafe0779bc62474146d
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/__init__.py
@@ -0,0 +1 @@
+from .bridge import VoyagerEnv
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/bridge.py b/experiments/Odyssey/Odyssey/odyssey/env/bridge.py
new file mode 100644
index 0000000000000000000000000000000000000000..eb6783d2ce4e8093be9af6f61e6db8f8c70346e8
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/bridge.py
@@ -0,0 +1,270 @@
+import os.path
+import time
+import warnings
+from typing import SupportsFloat, Any, Tuple, Dict
+from odyssey.utils.run_utils import retry
+
+import requests
+import json
+
+import gymnasium as gym
+from gymnasium.core import ObsType
+
+import odyssey.utils as U
+
+from .minecraft_launcher import MinecraftInstance
+from .process_monitor import SubprocessMonitor
+from odyssey.utils.logger import get_logger, Timer
+
+class VoyagerEnv(gym.Env):
+ def __init__(
+ self,
+ mc_host='localhost',
+ mc_port=None,
+ azure_login=None,
+ server_host="http://127.0.0.1",
+ server_port=3000,
+ request_timeout=600000,
+ log_path="./logs",
+ ):
+ if not mc_port and not azure_login:
+ raise ValueError("Either mc_port or azure_login must be specified")
+ if mc_port and azure_login:
+ warnings.warn(
+ "Both mc_port and mc_login are specified, mc_port will be ignored"
+ )
+ self.logger = get_logger('VoyagerEnv')
+ self.mc_host = mc_host
+ self.mc_port = mc_port
+ self.azure_login = azure_login
+ self.server = f"{server_host}:{server_port}"
+ self.server_port = server_port
+ self.request_timeout = request_timeout
+ self.log_path = log_path
+ self.mineflayer = self.get_mineflayer_process(server_port)
+ if azure_login:
+ self.mc_instance = self.get_mc_instance()
+ else:
+ self.mc_instance = None
+ self.has_reset = False
+ self.reset_options = None
+ self.connected = False
+ self.server_paused = False
+
+ def get_mineflayer_process(self, server_port):
+ U.f_mkdir(self.log_path, "mineflayer")
+ file_path = os.path.abspath(os.path.dirname(__file__))
+ return SubprocessMonitor(
+ commands=[
+ "node",
+ U.f_join(file_path, "mineflayer/index.js"),
+ str(server_port),
+ ],
+ name="mineflayer",
+ ready_match=r"Server started on port (\d+)",
+ log_path=U.f_join(self.log_path, "mineflayer"),
+ )
+
+ def get_mc_instance(self):
+ self.logger.info('create minecraft server')
+ U.f_mkdir(self.log_path, "minecraft")
+ return MinecraftInstance(
+ **self.azure_login,
+ mineflayer=self.mineflayer,
+ log_path=U.f_join(self.log_path, "minecraft"),
+ )
+
+ def check_process(self):
+ if self.mc_instance and not self.mc_instance.is_running:
+ # if self.mc_instance:
+ # self.mc_instance.check_process()
+ # if not self.mc_instance.is_running:
+ self.logger.info('Start Minecraft server')
+ self.mc_instance.run()
+ self.mc_port = self.mc_instance.port
+ self.reset_options["port"] = self.mc_instance.port
+ self.logger.info(f"Server started on port {self.reset_options['port']}")
+
+ if not self.mineflayer.is_running:
+ retry = 3
+ while retry > 0:
+ self.logger.info('Start Mineflayer process')
+ with Timer('check process run mineflayer'):
+ self.mineflayer.run()
+ if not self.mineflayer.is_running:
+ if retry == 0:
+ raise RuntimeError("Mineflayer process failed to start")
+ else:
+ self.logger.warning('Try to restart mineflayer again, after sleep 1 second')
+ time.sleep(1)
+ retry -= 1
+ else:
+ self.logger.info(f'mineflayer ready line: {self.mineflayer.ready_line}')
+ if self.mineflayer.ready_line is None:
+ self.logger.critical('mineflayer read line is None.')
+ break
+
+ retry = 3
+ while retry > 0:
+ try:
+ res = requests.post(
+ f"{self.server}/start",
+ json=self.reset_options,
+ timeout=self.request_timeout,
+ )
+ if res.status_code == 200:
+ # self.logger.debug(f'start response:{res.json()}')
+ return res.json()
+ else:
+ if retry == 0:
+ self.mineflayer.stop()
+ raise RuntimeError("Reset Minecraft server failed!")
+ else:
+ self.logger.warning(f'Reset Minecraft server {res.status_code}')
+ time.sleep(3)
+ retry -= 1
+ except requests.exceptions.Timeout:
+ if retry == 0:
+ self.mineflayer.stop()
+ raise RuntimeError("Reset Minecraft server timeout!")
+ else:
+ self.logger.warning(f"Reset Minecraft server timeout, retrying")
+ time.sleep(1)
+ retry -= 1
+ else:
+ self.logger.info('Mineflayer process is running')
+ return None
+
+
+ def step(
+ self,
+ code: str,
+ programs: str = "",
+ retry: int =3
+ ) -> Tuple[ObsType, SupportsFloat, bool, bool, Dict[str, Any]]:
+ if not self.has_reset:
+ raise RuntimeError("Environment has not been reset yet")
+ self.check_process()
+ self.unpause()
+ data = {
+ "code": code,
+ "programs": programs,
+ }
+ while retry > 0:
+ try:
+ with Timer('post step'):
+ res = requests.post(
+ f"{self.server}/step", json=data, timeout=self.request_timeout
+ )
+ if res.status_code == 200:
+ response_payload = res.json()
+ response_size = (
+ len(response_payload.encode("utf-8"))
+ if isinstance(response_payload, str)
+ else len(json.dumps(response_payload, ensure_ascii=False).encode("utf-8"))
+ )
+ self.logger.debug("Step response received: %d bytes", response_size)
+ break
+ else:
+ retry -= 1
+ self.logger.warning(f"Step Minecraft server failed, retrying")
+ if retry == 0:
+ raise RuntimeError("Step Minecraft server failed!")
+ except requests.exceptions.Timeout:
+ retry -= 1
+ self.logger.warning(f"Step Minecraft server timeout, retrying")
+ if retry == 0:
+ raise RuntimeError("Step Minecraft server timeout!")
+ # A timed-out /step may still own Mineflayer listeners,
+ # pathfinder jobs and timers. Recreating only the bot inside
+ # the same Node process cannot cancel that work, so restart
+ # the process before retrying.
+ self.mineflayer.stop()
+ time.sleep(1)
+ self.check_process()
+
+
+ # if res.status_code != 200:
+ # raise RuntimeError("Failed to step Minecraft server")
+ returned_data = res.json()
+ self.pause()
+ return json.loads(returned_data)
+
+ def render(self):
+ raise NotImplementedError("render is not implemented")
+
+ def reset(
+ self,
+ *,
+ seed=None,
+ options=None,
+ ) -> Tuple[ObsType, Dict[str, Any]]:
+ self.logger.info('reset node server')
+ if options is None:
+ options = {}
+
+ if options.get("inventory", {}) and options.get("mode", "hard") != "hard":
+ raise RuntimeError("inventory can only be set when options is hard")
+
+ self.reset_options = {
+ "host": self.mc_host,
+ "port": self.mc_port,
+ "reset": options.get("mode", "hard"),
+ "inventory": options.get("inventory", {}),
+ "equipment": options.get("equipment", []),
+ "spread": options.get("spread", False),
+ "waitTicks": options.get("wait_ticks", 5),
+ "position": options.get("position", None),
+ "username": options.get('username', 'bot')
+ }
+ with Timer('reset unpause mc server'):
+ self.unpause()
+ with Timer('reset stop mc_server'):
+ self.mineflayer.stop()
+ time.sleep(1) # wait for mineflayer to exit
+ with Timer('reset check_process'):
+ returned_data = self.check_process()
+ self.has_reset = True
+ self.connected = True
+ # All the reset in step will be soft
+ self.reset_options["reset"] = "soft"
+ self.pause()
+
+ if returned_data is None:
+ self.logger.warning('reset return None')
+ return None
+ return json.loads(returned_data)
+
+ def close(self):
+ self.unpause()
+ if self.connected:
+ res = requests.post(f"{self.server}/stop")
+ if res.status_code == 200:
+ self.connected = False
+ if self.mc_instance:
+ self.mc_instance.stop()
+ self.mineflayer.close()
+ return not self.connected
+
+ @retry(retry_count=3)
+ def pause(self):
+ if self.mineflayer.is_running and not self.server_paused:
+ res = requests.post(f"{self.server}/pause")
+ if res.status_code == 200:
+ self.server_paused = True
+ else:
+ self.logger.warning(f"Failed to pause Minecraft server {res.status_code}")
+ raise RuntimeError("Failed to pause Minecraft server")
+ return self.server_paused
+
+ @retry(retry_count=3)
+ def unpause(self):
+ if self.mineflayer.is_running and self.server_paused:
+ res = requests.post(f"{self.server}/pause")
+ if res.status_code == 200:
+ self.server_paused = False
+ else:
+ self.logger.warning(f"Failed to unpause Minecraft server {res.status_code}")
+ raise RuntimeError("Failed to unpause Minecraft server")
+
+ return self.server_paused
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/minecraft_launcher.py b/experiments/Odyssey/Odyssey/odyssey/env/minecraft_launcher.py
new file mode 100644
index 0000000000000000000000000000000000000000..663cb2212fd6032a2db35f7c2960d86bbea1f3c0
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/minecraft_launcher.py
@@ -0,0 +1,127 @@
+import os
+import re
+
+import minecraft_launcher_lib
+import sys
+import odyssey.utils as U
+
+from .process_monitor import SubprocessMonitor
+
+
+class MinecraftInstance:
+ def __init__(
+ self,
+ client_id,
+ redirect_url,
+ secret_value,
+ version,
+ mineflayer,
+ log_path="logs",
+ ):
+ self.client_id = client_id
+ self.redirect_url = redirect_url
+ self.secret_value = secret_value
+ self.version = version
+ self.log_path = log_path
+ self.mc_dir = minecraft_launcher_lib.utils.get_minecraft_directory()
+ self.port = None
+
+ def stop_mineflayer():
+ print("Stopping mineflayer")
+ try:
+ mineflayer.stop()
+ except Exception as e:
+ print(e)
+
+ self.mc_command = self.get_mc_command()
+ self.mc_process = SubprocessMonitor(
+ commands=self.mc_command,
+ name="minecraft",
+ ready_match=r"Started serving on (\d+)",
+ log_path=self.log_path,
+ callback=stop_mineflayer,
+ callback_match=r"\[Server thread/INFO\]: bot left the game",
+ finished_callback=stop_mineflayer,
+ )
+
+ def get_mineflayer_process(self, server_port):
+ U.f_mkdir(self.log_path, "../mineflayer")
+ file_path = os.path.abspath(os.path.dirname(__file__))
+ return SubprocessMonitor(
+ commands=[
+ "node",
+ U.f_join(file_path, "mineflayer/index.js"),
+ str(server_port),
+ ],
+ name="mineflayer",
+ ready_match=r"Server started on port (\d+)",
+ log_path=U.f_join(self.log_path, "mineflayer"),
+ )
+
+ def get_mc_command(self):
+ file_path = os.path.abspath(os.path.dirname(__file__))
+ if not U.f_exists(file_path, "config.json"):
+ (
+ login_url,
+ state,
+ code_verifier,
+ ) = minecraft_launcher_lib.microsoft_account.get_secure_login_data(
+ self.client_id, self.redirect_url
+ )
+ print(
+ f"Please open {login_url} in your browser and copy the url you are redirected into the prompt below."
+ )
+ code_url = input()
+
+ try:
+ auth_code = (
+ minecraft_launcher_lib.microsoft_account.parse_auth_code_url(
+ code_url, state
+ )
+ )
+ except AssertionError:
+ print("States do not match!")
+ sys.exit(1)
+ except KeyError:
+ print("Url not valid")
+ sys.exit(1)
+
+ login_data = minecraft_launcher_lib.microsoft_account.complete_login(
+ self.client_id,
+ self.secret_value,
+ self.redirect_url,
+ auth_code,
+ code_verifier,
+ )
+
+ options = {
+ "username": login_data["name"],
+ "uuid": login_data["id"],
+ "token": login_data["access_token"],
+ }
+ U.json_dump(options, file_path, "config.json")
+ print(f"Login success, save to {U.f_join(file_path, 'config.json')}")
+
+ options = U.json_load(file_path, "config.json")
+ mc_command = minecraft_launcher_lib.command.get_minecraft_command(
+ self.version, self.mc_dir, options
+ )
+
+ return mc_command
+
+ def run(self):
+ self.mc_process.run()
+ pattern = r"Started serving on (\d+)"
+ match = re.search(pattern, self.mc_process.ready_line)
+ if match:
+ self.port = int(match.group(1))
+ print("The mc server is listening on port", self.port)
+ else:
+ raise RuntimeError("Port not found")
+
+ def stop(self):
+ self.mc_process.stop()
+
+ @property
+ def is_running(self):
+ return self.mc_process.is_running
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierignore b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierignore
new file mode 100644
index 0000000000000000000000000000000000000000..1b07c39e9b4cf3756f6e3ea23f7ab6ea22a87f15
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierignore
@@ -0,0 +1,3 @@
+# Ignore artifacts:
+build
+coverage
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierrc.json b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierrc.json
new file mode 100644
index 0000000000000000000000000000000000000000..0a02bcefdab2e1654666e9d5effedc14501e98db
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/.prettierrc.json
@@ -0,0 +1,3 @@
+{
+ "tabWidth": 4
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/index.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..bf472394f03f513e005fbe44c3305ed8655716ef
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/index.js
@@ -0,0 +1,447 @@
+const fs = require("fs");
+const express = require("express");
+const bodyParser = require("body-parser");
+const mineflayer = require("mineflayer");
+
+const skills = require("./lib/skillLoader");
+const { initCounter, getNextTime } = require("./lib/utils");
+const obs = require("./lib/observation/base");
+const OnChat = require("./lib/observation/onChat");
+const OnError = require("./lib/observation/onError");
+const { Voxels, BlockRecords } = require("./lib/observation/voxels");
+const Status = require("./lib/observation/status");
+const Inventory = require("./lib/observation/inventory");
+const OnSave = require("./lib/observation/onSave");
+const Chests = require("./lib/observation/chests");
+const { plugin: tool } = require("mineflayer-tool");
+
+let bot = null;
+
+const app = express();
+
+app.use(bodyParser.json({ limit: "50mb" }));
+app.use(bodyParser.urlencoded({ limit: "50mb", extended: false }));
+
+app.post("/start", (req, res) => {
+ if (bot) onDisconnect("Restarting bot");
+ bot = null;
+ console.log(req.body);
+ bot = mineflayer.createBot({
+ host: req.body.host, // minecraft server ip
+ port: req.body.port, // minecraft server port
+ username: req.body.username,
+ disableChatSigning: true,
+ checkTimeoutInterval: 60 * 60 * 1000,
+ });
+ bot.once("error", onConnectionFailed);
+
+ // Event subscriptions
+ bot.waitTicks = req.body.waitTicks;
+ bot.globalTickCounter = 0;
+ bot.stuckTickCounter = 0;
+ bot.stuckPosList = [];
+ bot.iron_pickaxe = false;
+
+ bot.on("kicked", onDisconnect);
+
+ // mounting will cause physicsTick to stop
+ bot.on("mount", () => {
+ bot.dismount();
+ });
+
+ bot.once("spawn", async () => {
+ bot.removeListener("error", onConnectionFailed);
+ let itemTicks = 1;
+ if (req.body.reset === "hard") {
+ bot.chat("/clear @s");
+ bot.chat("/kill @s");
+ const inventory = req.body.inventory ? req.body.inventory : {};
+ const equipment = req.body.equipment
+ ? req.body.equipment
+ : [null, null, null, null, null, null];
+ for (let key in inventory) {
+ bot.chat(`/give @s minecraft:${key} ${inventory[key]}`);
+ itemTicks += 1;
+ }
+ const equipmentNames = [
+ "armor.head",
+ "armor.chest",
+ "armor.legs",
+ "armor.feet",
+ "weapon.mainhand",
+ "weapon.offhand",
+ ];
+ for (let i = 0; i < 6; i++) {
+ if (i === 4) continue;
+ if (equipment[i]) {
+ bot.chat(
+ `/item replace entity @s ${equipmentNames[i]} with minecraft:${equipment[i]}`
+ );
+ itemTicks += 1;
+ }
+ }
+ }
+
+ if (req.body.position) {
+ bot.chat(
+ `/tp @s ${req.body.position.x} ${req.body.position.y} ${req.body.position.z}`
+ );
+ }
+
+ // if iron_pickaxe is in bot's inventory
+ if (
+ bot.inventory.items().find((item) => item.name === "iron_pickaxe")
+ ) {
+ bot.iron_pickaxe = true;
+ }
+
+ const { pathfinder } = require("mineflayer-pathfinder");
+ const tool = require("mineflayer-tool").plugin;
+ const collectBlock = require("mineflayer-collectblock").plugin;
+ const pvp = require("mineflayer-pvp").plugin;
+ const minecraftHawkEye = require("minecrafthawkeye");
+ bot.loadPlugin(pathfinder);
+ bot.loadPlugin(tool);
+ bot.loadPlugin(collectBlock);
+ bot.loadPlugin(pvp);
+ bot.loadPlugin(minecraftHawkEye);
+
+ // bot.collectBlock.movements.digCost = 0;
+ // bot.collectBlock.movements.placeCost = 0;
+
+ obs.inject(bot, [
+ OnChat,
+ OnError,
+ Voxels,
+ Status,
+ Inventory,
+ OnSave,
+ Chests,
+ BlockRecords,
+ ]);
+ skills.inject(bot);
+
+ if (req.body.spread) {
+ bot.chat(`/spreadplayers ~ ~ 0 300 under 80 false @s`);
+ await bot.waitForTicks(bot.waitTicks);
+ }
+
+ await bot.waitForTicks(bot.waitTicks * itemTicks);
+ res.json(bot.observe());
+
+ initCounter(bot);
+ bot.chat("/gamerule keepInventory true");
+ bot.chat("/gamerule doDaylightCycle false");
+ });
+
+ function onConnectionFailed(e) {
+ console.log(e);
+ bot = null;
+ res.status(400).json({ error: e });
+ }
+ function onDisconnect(message) {
+ if (bot.viewer) {
+ bot.viewer.close();
+ }
+ bot.end();
+ console.log(message);
+ bot = null;
+ }
+});
+
+app.post("/step", async (req, res) => {
+ const stepBot = bot;
+ if (!stepBot) {
+ res.status(409).json({ error: "Bot is not connected" });
+ return;
+ }
+ // Capture the bot for this request. A concurrent /start must not make an
+ // old request continue against the newly created bot.
+ return (async (bot) => {
+ // import useful package
+ let response_sent = false;
+ let tickListenerAttached = false;
+ function otherError(err) {
+ console.log("Uncaught Error");
+ bot.emit("error", handleError(err));
+ bot.waitForTicks(bot.waitTicks).then(() => {
+ if (!response_sent) {
+ response_sent = true;
+ res.json(bot.observe());
+ }
+ });
+ }
+
+ process.on("uncaughtException", otherError);
+ try {
+
+ const mcData = require("minecraft-data")(bot.version);
+ mcData.itemsByName["leather_cap"] = mcData.itemsByName["leather_helmet"];
+ mcData.itemsByName["leather_tunic"] =
+ mcData.itemsByName["leather_chestplate"];
+ mcData.itemsByName["leather_pants"] =
+ mcData.itemsByName["leather_leggings"];
+ mcData.itemsByName["leather_boots"] = mcData.itemsByName["leather_boots"];
+ mcData.itemsByName["lapis_lazuli_ore"] = mcData.itemsByName["lapis_ore"];
+ mcData.blocksByName["lapis_lazuli_ore"] = mcData.blocksByName["lapis_ore"];
+ const {
+ Movements,
+ goals: {
+ Goal,
+ GoalBlock,
+ GoalNear,
+ GoalXZ,
+ GoalNearXZ,
+ GoalY,
+ GoalGetToBlock,
+ GoalLookAtBlock,
+ GoalBreakBlock,
+ GoalCompositeAny,
+ GoalCompositeAll,
+ GoalInvert,
+ GoalFollow,
+ GoalPlaceBlock,
+ },
+ pathfinder,
+ Move,
+ ComputedPath,
+ PartiallyComputedPath,
+ XZCoordinates,
+ XYZCoordinates,
+ SafeBlock,
+ GoalPlaceBlockOptions,
+ } = require("mineflayer-pathfinder");
+ const { Vec3 } = require("vec3");
+
+ // Set up pathfinder
+ const movements = new Movements(bot, mcData);
+ bot.pathfinder.setMovements(movements);
+
+ bot.globalTickCounter = 0;
+ bot.stuckTickCounter = 0;
+ bot.stuckPosList = [];
+
+ function onTick() {
+ bot.globalTickCounter++;
+ if (bot.pathfinder.isMoving()) {
+ bot.stuckTickCounter++;
+ if (bot.stuckTickCounter >= 100) {
+ onStuck(1.5);
+ bot.stuckTickCounter = 0;
+ }
+ }
+ }
+
+ bot.on("physicTick", onTick);
+ tickListenerAttached = true;
+
+ // initialize fail count
+ let _craftItemFailCount = 0;
+ let _killMobFailCount = 0;
+ let _mineBlockFailCount = 0;
+ let _placeItemFailCount = 0;
+ let _smeltItemFailCount = 0;
+
+ // Retrieve array form post bod
+ const code = req.body.code;
+ const programs = req.body.programs;
+ bot.cumulativeObs = [];
+ await bot.waitForTicks(bot.waitTicks);
+ const r = await evaluateCode(code, programs);
+ if (r !== "success") {
+ bot.emit("error", handleError(r));
+ }
+ // World changes are part of the agent's persistent state. In particular,
+ // placement tasks must be able to observe a placed block and the matching
+ // inventory decrement on later steps. The legacy cleanup is therefore
+ // opt-in for callers that explicitly request an isolated rollback.
+ if (req.body.returnItems === true) {
+ await returnItems();
+ }
+ // wait for last message
+ await bot.waitForTicks(bot.waitTicks);
+ if (!response_sent) {
+ response_sent = true;
+ res.json(bot.observe());
+ }
+
+ async function evaluateCode(code, programs) {
+ // Echo the code produced for players to see it. Don't echo when the bot code is already producing dialog or it will double echo
+ try {
+ await eval("(async () => {" + programs + "\n" + code + "})()");
+ return "success";
+ } catch (err) {
+ return err;
+ }
+ }
+
+ function onStuck(posThreshold) {
+ const currentPos = bot.entity.position;
+ bot.stuckPosList.push(currentPos);
+
+ // Check if the list is full
+ if (bot.stuckPosList.length === 5) {
+ const oldestPos = bot.stuckPosList[0];
+ const posDifference = currentPos.distanceTo(oldestPos);
+
+ if (posDifference < posThreshold) {
+ teleportBot(); // execute the function
+ }
+
+ // Remove the oldest time from the list
+ bot.stuckPosList.shift();
+ }
+ }
+
+ function teleportBot() {
+ const blocks = bot.findBlocks({
+ matching: (block) => {
+ return block.type === 0;
+ },
+ maxDistance: 1,
+ count: 27,
+ });
+
+ if (blocks) {
+ // console.log(blocks.length);
+ const randomIndex = Math.floor(Math.random() * blocks.length);
+ const block = blocks[randomIndex];
+ bot.chat(`/tp @s ${block.x} ${block.y} ${block.z}`);
+ } else {
+ bot.chat("/tp @s ~ ~1.25 ~");
+ }
+ }
+
+ function returnItems() {
+ bot.chat("/gamerule doTileDrops false");
+ const crafting_table = bot.findBlock({
+ matching: mcData.blocksByName.crafting_table.id,
+ maxDistance: 128,
+ });
+ if (crafting_table) {
+ bot.chat(
+ `/setblock ${crafting_table.position.x} ${crafting_table.position.y} ${crafting_table.position.z} air destroy`
+ );
+ bot.chat("/give @s crafting_table");
+ }
+ const furnace = bot.findBlock({
+ matching: mcData.blocksByName.furnace.id,
+ maxDistance: 128,
+ });
+ if (furnace) {
+ bot.chat(
+ `/setblock ${furnace.position.x} ${furnace.position.y} ${furnace.position.z} air destroy`
+ );
+ bot.chat("/give @s furnace");
+ }
+ if (bot.inventoryUsed() >= 32) {
+ // if chest is not in bot's inventory
+ if (!bot.inventory.items().find((item) => item.name === "chest")) {
+ bot.chat("/give @s chest");
+ }
+ }
+ // if iron_pickaxe not in bot's inventory and bot.iron_pickaxe
+ if (
+ bot.iron_pickaxe &&
+ !bot.inventory.items().find((item) => item.name === "iron_pickaxe")
+ ) {
+ bot.chat("/give @s iron_pickaxe");
+ }
+ bot.chat("/gamerule doTileDrops true");
+ }
+
+ function handleError(err) {
+ let stack = err.stack;
+ if (!stack) {
+ return err;
+ }
+ console.log(stack);
+ const final_line = stack.split("\n")[1];
+ const regex = /:(\d+):\d+\)/;
+
+ const programs_length = programs.split("\n").length;
+ let match_line = null;
+ for (const line of stack.split("\n")) {
+ const match = regex.exec(line);
+ if (match) {
+ const line_num = parseInt(match[1]);
+ if (line_num >= programs_length) {
+ match_line = line_num - programs_length;
+ break;
+ }
+ }
+ }
+ if (!match_line) {
+ return err.message;
+ }
+ let f_line = final_line.match(
+ /\((?.*):(?\d+):(?\d+)\)/
+ );
+ if (f_line && f_line.groups && fs.existsSync(f_line.groups.file)) {
+ const { file, line, pos } = f_line.groups;
+ const f = fs.readFileSync(file, "utf8").split("\n");
+ // let filename = file.match(/(?<=node_modules\\)(.*)/)[1];
+ let source = file + `:${line}\n${f[line - 1].trim()}\n `;
+
+ const code_source =
+ "at " +
+ code.split("\n")[match_line - 1].trim() +
+ " in your code";
+ return source + err.message + "\n" + code_source;
+ } else if (
+ f_line &&
+ f_line.groups &&
+ f_line.groups.file.includes("")
+ ) {
+ const { file, line, pos } = f_line.groups;
+ let source =
+ "Your code" +
+ `:${match_line}\n${code.split("\n")[match_line - 1].trim()}\n `;
+ let code_source = "";
+ if (line < programs_length) {
+ source =
+ "In your program code: " +
+ programs.split("\n")[line - 1].trim() +
+ "\n";
+ code_source = `at line ${match_line}:${code
+ .split("\n")
+ [match_line - 1].trim()} in your code`;
+ }
+ return source + err.message + "\n" + code_source;
+ }
+ return err.message;
+ }
+ } finally {
+ process.off("uncaughtException", otherError);
+ if (tickListenerAttached) {
+ bot.removeListener("physicTick", onTick);
+ }
+ }
+ })(stepBot);
+});
+
+app.post("/stop", (req, res) => {
+ bot.end();
+ res.json({
+ message: "Bot stopped",
+ });
+});
+
+app.post("/pause", (req, res) => {
+ if (!bot) {
+ res.status(400).json({ error: "Bot not spawned" });
+ return;
+ }
+ bot.chat("/pause");
+ bot.waitForTicks(bot.waitTicks).then(() => {
+ res.json({ message: "Success" });
+ });
+});
+
+// Server listening to PORT 3000
+
+const DEFAULT_PORT = 3000;
+const PORT = process.argv[2] || DEFAULT_PORT;
+app.listen(PORT, () => {
+ console.log(`Server started on port ${PORT}`);
+});
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/base.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/base.js
new file mode 100644
index 0000000000000000000000000000000000000000..b661a24b57c1a61b9ff09b9254ce72002212f5d3
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/base.js
@@ -0,0 +1,45 @@
+class Observation {
+ constructor(bot) {
+ if (new.target === Observation) {
+ throw new TypeError(
+ "Cannot instantiate abstract class Observation"
+ );
+ }
+
+ this.bot = bot;
+ this.name = "Observation";
+ }
+
+ observe() {
+ throw new TypeError("Method 'observe()' must be implemented.");
+ }
+
+ reset() {}
+}
+
+function inject(bot, obs_list) {
+ bot.obsList = [];
+ bot.cumulativeObs = [];
+ bot.eventMemory = {};
+ obs_list.forEach((obs) => {
+ bot.obsList.push(new obs(bot));
+ });
+ bot.event = function (event_name) {
+ let result = {};
+ bot.obsList.forEach((obs) => {
+ if (obs.name.startsWith("on") && obs.name !== event_name) {
+ return;
+ }
+ result[obs.name] = obs.observe();
+ });
+ bot.cumulativeObs.push([event_name, result]);
+ };
+ bot.observe = function () {
+ bot.event("observe");
+ const result = bot.cumulativeObs;
+ bot.cumulativeObs = [];
+ return JSON.stringify(result);
+ };
+}
+
+module.exports = { Observation, inject };
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/chests.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/chests.js
new file mode 100644
index 0000000000000000000000000000000000000000..842bd171d579d77a328615787e0309d0b40eb1fe
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/chests.js
@@ -0,0 +1,31 @@
+const { Observation } = require("./base");
+
+class Chests extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "nearbyChests";
+ this.chestsItems = {};
+ bot.on("closeChest", (chestItems, position) => {
+ this.chestsItems[position] = chestItems;
+ });
+ bot.on("removeChest", (chestPosition) => {
+ this.chestsItems[chestPosition] = "Invalid";
+ });
+ }
+
+ observe() {
+ const chests = this.bot.findBlocks({
+ matching: this.bot.registry.blocksByName.chest.id,
+ maxDistance: 16,
+ count: 999,
+ });
+ chests.forEach((chest) => {
+ if (!this.chestsItems.hasOwnProperty(chest)) {
+ this.chestsItems[chest] = "Unknown";
+ }
+ });
+ return this.chestsItems;
+ }
+}
+
+module.exports = Chests;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/inventory.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/inventory.js
new file mode 100644
index 0000000000000000000000000000000000000000..0645d1bfa0803e155e3987d3d526f2b43d8f5936
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/inventory.js
@@ -0,0 +1,39 @@
+const { Observation } = require("./base");
+
+class Inventory extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "inventory";
+ }
+
+ observe() {
+ return listItems(this.bot);
+ }
+}
+
+function listItems(bot) {
+ const items = getInventoryItems(bot);
+ return items.reduce(itemToDict, {});
+}
+
+function getInventoryItems(bot) {
+ const inventory = bot.currentWindow || bot.inventory;
+ return inventory.items();
+}
+
+function itemToDict(acc, cur) {
+ if (cur.name && cur.count) {
+ //if both name and count property are defined
+ if (acc[cur.name]) {
+ //if the item is already in the dict
+ acc[cur.name] += cur.count;
+ } else {
+ //if the item is not in the dict
+ acc[cur.name] = cur.count;
+ }
+ }
+ return acc;
+}
+
+//export modules
+module.exports = Inventory;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onChat.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onChat.js
new file mode 100644
index 0000000000000000000000000000000000000000..54b411e2ad903ca54e4cdbf2b9d8732df82a55f8
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onChat.js
@@ -0,0 +1,26 @@
+const Observation = require("./base.js").Observation;
+
+class onChat extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "onChat";
+ this.obs = "";
+ bot.on("chatEvent", (username, message) => {
+ // Save entity status to local variable
+ if (message.startsWith("/")) {
+ return;
+ }
+
+ this.obs += message;
+ this.bot.event(this.name);
+ });
+ }
+
+ observe() {
+ const result = this.obs;
+ this.obs = "";
+ return result;
+ }
+}
+
+module.exports = onChat;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onError.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onError.js
new file mode 100644
index 0000000000000000000000000000000000000000..ac8fed9e51937c33105068e2c45800fe1c022c89
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onError.js
@@ -0,0 +1,22 @@
+const Observation = require("./base.js").Observation;
+
+class onError extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "onError";
+ this.obs = null;
+ bot.on("error", (err) => {
+ // Save entity status to local variable
+ this.obs = err;
+ this.bot.event(this.name);
+ });
+ }
+
+ observe() {
+ const result = this.obs;
+ this.obs = null;
+ return result;
+ }
+}
+
+module.exports = onError;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onSave.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onSave.js
new file mode 100644
index 0000000000000000000000000000000000000000..e5983590ff7b5829b7a9679fee7a11f04f3cc5a7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/onSave.js
@@ -0,0 +1,22 @@
+const Observation = require("./base.js").Observation;
+
+class onSave extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "onSave";
+ this.obs = null;
+ bot.on("save", (eventName) => {
+ // Save entity status to local variable
+ this.obs = eventName;
+ this.bot.event(this.name);
+ });
+ }
+
+ observe() {
+ const result = this.obs;
+ this.obs = null;
+ return result;
+ }
+}
+
+module.exports = onSave;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/status.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/status.js
new file mode 100644
index 0000000000000000000000000000000000000000..b031fbcf20d307bdd7895de1b29e589b10d33b40
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/status.js
@@ -0,0 +1,103 @@
+const Observation = require("./base.js").Observation;
+
+class Status extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "status";
+ }
+
+ observe() {
+ return {
+ health: this.bot.health,
+ food: this.bot.food,
+ saturation: this.bot.foodSaturation,
+ oxygen: this.bot.oxygenLevel,
+ position: this.bot.entity.position,
+ velocity: this.bot.entity.velocity,
+ yaw: this.bot.entity.yaw,
+ pitch: this.bot.entity.pitch,
+ onGround: this.bot.entity.onGround,
+ equipment: this.getEquipment(),
+ name: this.bot.entity.username,
+ timeSinceOnGround: this.bot.entity.timeSinceOnGround,
+ isInWater: this.bot.entity.isInWater,
+ isInLava: this.bot.entity.isInLava,
+ isInWeb: this.bot.entity.isInWeb,
+ isCollidedHorizontally: this.bot.entity.isCollidedHorizontally,
+ isCollidedVertically: this.bot.entity.isCollidedVertically,
+ biome: this.bot.blockAt(this.bot.entity.position)
+ ? this.bot.blockAt(this.bot.entity.position).biome.name
+ : "None",
+ entities: this.getEntities(),
+ timeOfDay: this.getTime(),
+ inventoryUsed: this.bot.inventoryUsed(),
+ elapsedTime: this.bot.globalTickCounter,
+ };
+ }
+
+ itemToObs(item) {
+ if (!item) return null;
+ return item.name;
+ }
+
+ getTime() {
+ const timeOfDay = this.bot.time.timeOfDay;
+ let time = "";
+ if (timeOfDay < 1000) {
+ time = "sunrise";
+ } else if (timeOfDay < 6000) {
+ time = "day";
+ } else if (timeOfDay < 12000) {
+ time = "noon";
+ } else if (timeOfDay < 13000) {
+ time = "sunset";
+ } else if (timeOfDay < 18000) {
+ time = "night";
+ } else if (timeOfDay < 22000) {
+ time = "midnight";
+ } else {
+ time = "sunrise";
+ }
+ return time;
+ }
+
+ // For each item in equipment, if it exists, return the name of the item
+ // otherwise return null
+ getEquipment() {
+ const slots = this.bot.inventory.slots;
+ const mainHand = this.bot.heldItem;
+ return slots
+ .slice(5, 9)
+ .concat(mainHand, slots[45])
+ .map(this.itemToObs);
+ }
+
+ getEntities() {
+ const entities = this.bot.entities;
+ if (!entities) return {};
+ // keep all monsters in one list, keep other mobs in another list
+ const mobs = {};
+ for (const id in entities) {
+ const entity = entities[id];
+ if (!entity.displayName) continue;
+ if (entity.name === "player" || entity.name === "item") continue;
+ if (entity.position.distanceTo(this.bot.entity.position) < 32) {
+ if (!mobs[entity.name]) {
+ mobs[entity.name] = entity.position.distanceTo(
+ this.bot.entity.position
+ );
+ } else if (
+ mobs[entity.name] >
+ entity.position.distanceTo(this.bot.entity.position)
+ ) {
+ mobs[entity.name] = entity.position.distanceTo(
+ this.bot.entity.position
+ );
+ }
+ }
+ }
+ return mobs;
+ }
+}
+
+module.exports = Status;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/voxels.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/voxels.js
new file mode 100644
index 0000000000000000000000000000000000000000..ecb0c14b70d4b48034fd4af452bb7572073db878
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/observation/voxels.js
@@ -0,0 +1,67 @@
+// Blocks = require("./blocks")
+const { Observation } = require("./base");
+
+class Voxels extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "voxels";
+ }
+
+ observe() {
+ return Array.from(getSurroundingBlocks(this.bot, 8, 2, 8));
+ }
+}
+
+class BlockRecords extends Observation {
+ constructor(bot) {
+ super(bot);
+ this.name = "blockRecords";
+ this.records = new Set();
+ this.tick = 0;
+ bot.on("physicsTick", () => {
+ this.tick++;
+ if (this.tick >= 100) {
+ const items = getInventoryItems(this.bot);
+ getSurroundingBlocks(this.bot, 8, 2, 8).forEach((block) => {
+ if (!items.has(block)) this.records.add(block);
+ });
+ this.tick = 0;
+ }
+ });
+ }
+
+ observe() {
+ return Array.from(this.records);
+ }
+
+ reset() {
+ this.records = new Set();
+ }
+}
+
+function getSurroundingBlocks(bot, x_distance, y_distance, z_distance) {
+ const surroundingBlocks = new Set();
+
+ for (let x = -x_distance; x <= x_distance; x++) {
+ for (let y = -y_distance; y <= y_distance; y++) {
+ for (let z = -z_distance; z <= z_distance; z++) {
+ const block = bot.blockAt(bot.entity.position.offset(x, y, z));
+ if (block && block.type !== 0) {
+ surroundingBlocks.add(block.name);
+ }
+ }
+ }
+ }
+ // console.log(surroundingBlocks);
+ return surroundingBlocks;
+}
+
+function getInventoryItems(bot) {
+ const items = new Set();
+ bot.inventory.items().forEach((item) => {
+ if (item) items.add(item.name);
+ });
+ return items;
+}
+
+module.exports = { Voxels, BlockRecords };
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/skillLoader.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/skillLoader.js
new file mode 100644
index 0000000000000000000000000000000000000000..d78cf782093b213b35d3d4c719490e3a86a7878b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/skillLoader.js
@@ -0,0 +1,79 @@
+function inject(bot) {
+ bot._sleep = bot.sleep;
+ bot.sleep = async (bedBlock) => {
+ await bot.waitForTicks(20);
+ await bot._sleep(bedBlock);
+ await bot.waitForTicks(135);
+ };
+
+ bot._fish = bot.fish;
+ bot.fish = async () => {
+ if (bot.heldItem?.name !== "fishing_rod") {
+ bot.chat("I'm not holding a fishing rod!");
+ return;
+ }
+ let timeout = null;
+ await Promise.race([
+ bot._fish(),
+ new Promise(
+ (resolve, reject) =>
+ (timeout = setTimeout(() => {
+ bot.activateItem();
+ reject(
+ new Error(
+ "Finishing timeout, make sure you get to and look at a water block!"
+ )
+ );
+ }, 60000))
+ ),
+ ]);
+ clearTimeout(timeout);
+ await bot.waitForTicks(20);
+ };
+
+ bot._consume = bot.consume;
+ bot.consume = async () => {
+ // action_count.activateItem++;
+ await bot._consume();
+ await bot.waitForTicks(20);
+ };
+
+ bot._useOn = bot.useOn;
+ bot.useOn = async (entity) => {
+ if (entity.position.distanceTo(bot.entity.position) > 6) {
+ bot.chat("Please goto a place near the entity first!");
+ return;
+ }
+ await bot._useOn(entity);
+ await bot.waitForTicks(20);
+ };
+
+ bot._activateBlock = bot.activateBlock;
+ bot.activateBlock = async (block) => {
+ if (block.position.distanceTo(bot.entity.position) > 6) {
+ bot.chat("Please goto a place near the block first!");
+ return;
+ }
+ // action_count.activateBlock++;
+ await bot._activateBlock(block);
+ };
+
+ bot._chat = bot.chat;
+ bot.chat = (message) => {
+ // action_count.chat++;
+ bot.emit("chatEvent", "bot", message);
+ bot._chat(message);
+ };
+
+ bot.inventoryUsed = () => {
+ return bot.inventory.slots.slice(9, 45).filter((item) => item !== null)
+ .length;
+ };
+
+ bot.save = function (eventName) {
+ bot.emit("save", eventName);
+ };
+}
+
+// export all control_primitives
+module.exports = { inject };
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/utils.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/utils.js
new file mode 100644
index 0000000000000000000000000000000000000000..68af3079602ab8d88059a9c8c4055140dda32f1d
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/lib/utils.js
@@ -0,0 +1,31 @@
+let gameTimeCounter = 0;
+let gameTimeList = [];
+const initCounter = (bot) => {
+ gameTimeList = [];
+ for (let i = 0; i < 13000; i += 1000) {
+ gameTimeList.push(i);
+ }
+ for (let i = 13000; i < 24000; i += 2000) {
+ gameTimeList.push(i);
+ }
+ const timeOfDay = bot.time.timeOfDay;
+ for (let i = 0; i < gameTimeList.length; i++) {
+ if (gameTimeList[i] > timeOfDay) {
+ gameTimeCounter = i - 1;
+ break;
+ }
+ }
+};
+
+const getNextTime = () => {
+ gameTimeCounter++;
+ if (gameTimeCounter >= gameTimeList.length) {
+ gameTimeCounter = 0;
+ }
+ return gameTimeList[gameTimeCounter];
+};
+
+module.exports = {
+ initCounter,
+ getNextTime,
+};
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/.gitignore b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..a91d5c31987e7da26ece6154728a87dd28e1a83d
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/.gitignore
@@ -0,0 +1,107 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Keep lib/ in the public source bundle: package.json uses lib/index.js at runtime.
+package-lock.json
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/LICENSE b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/LICENSE
new file mode 100644
index 0000000000000000000000000000000000000000..f2896b56e45adc3d54cd6f98764d4b155b571217
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020 TheDudeFromCI
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/_config.yml b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/_config.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c4192631f25b34d77a7f159aa0da0e3ae99c4ef4
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/_config.yml
@@ -0,0 +1 @@
+theme: jekyll-theme-cayman
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/collector.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/collector.js
new file mode 100644
index 0000000000000000000000000000000000000000..b9bb8faf9e73762856eed9d41f0da027728e82b3
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/collector.js
@@ -0,0 +1,70 @@
+/**
+ * This bot example show how to direct a bot to collect a specific block type
+ * or a group of nearby blocks of that type.
+ */
+
+const mineflayer = require('mineflayer')
+const collectBlock = require('mineflayer-collectblock').plugin
+
+if (process.argv.length < 4 || process.argv.length > 6) {
+ console.log('Usage : node collector.js [] []')
+ process.exit(1)
+}
+
+const bot = mineflayer.createBot({
+ host: process.argv[2],
+ port: process.argv[3],
+ username: process.argv[4] || 'collector',
+ password: process.argv[5]
+})
+
+bot.loadPlugin(collectBlock)
+
+let mcData
+bot.once('spawn', () => {
+ mcData = require('minecraft-data')(bot.version)
+})
+
+bot.on('chat', async (username, message) => {
+ const args = message.split(' ')
+ if (args[0] !== 'collect') return
+
+ let count = 1
+ if (args.length === 3) count = parseInt(args[1])
+
+ let type = args[1]
+ if (args.length === 3) type = args[2]
+
+ const blockType = mcData.blocksByName[type]
+ if (!blockType) {
+ return
+ }
+
+ const blocks = bot.findBlocks({
+ matching: blockType.id,
+ maxDistance: 64,
+ count: count
+ })
+
+ if (blocks.length === 0) {
+ bot.chat("I don't see that block nearby.")
+ return
+ }
+
+ const targets = []
+ for (let i = 0; i < Math.min(blocks.length, count); i++) {
+ targets.push(bot.blockAt(blocks[i]))
+ }
+
+ bot.chat(`Found ${targets.length} ${type}(s)`)
+
+ try {
+ await bot.collectBlock.collect(targets)
+ // All blocks have been collected.
+ bot.chat('Done')
+ } catch (err) {
+ // An error occurred, report it.
+ bot.chat(err.message)
+ console.log(err)
+ }
+})
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/oreMiner.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/oreMiner.js
new file mode 100644
index 0000000000000000000000000000000000000000..6accac88fd3c3e29ac431c497d618d2f27f23c67
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/oreMiner.js
@@ -0,0 +1,59 @@
+/**
+ * This bot example shows how to collect a vein of ores quickly after only finding a single block.
+ * This makes it easy to collect a vein of ores or mine a tree without looking for every block in the
+ * area.
+ */
+
+const mineflayer = require('mineflayer')
+const collectBlock = require('mineflayer-collectblock').plugin
+
+if (process.argv.length < 4 || process.argv.length > 6) {
+ console.log('Usage : node oreMiner.js [] []')
+ process.exit(1)
+}
+
+const bot = mineflayer.createBot({
+ host: process.argv[2],
+ port: process.argv[3],
+ username: process.argv[4] || 'oreMiner',
+ password: process.argv[5]
+})
+
+bot.loadPlugin(collectBlock)
+
+let mcData
+bot.once('spawn', () => {
+ mcData = require('minecraft-data')(bot.version)
+})
+
+bot.on('chat', async (username, message) => {
+ const args = message.split(' ')
+ if (args[0] !== 'collect') return
+
+ const blockType = mcData.blocksByName[args[1]]
+ if (!blockType) {
+ bot.chat(`I don't know any blocks named ${args[1]}.`)
+ return
+ }
+
+ const block = bot.findBlock({
+ matching: blockType.id,
+ maxDistance: 64
+ })
+
+ if (!block) {
+ bot.chat("I don't see that block nearby.")
+ return
+ }
+
+ const targets = bot.collectBlock.findFromVein(block)
+ try {
+ await bot.collectBlock.collect(targets)
+ // All blocks have been collected.
+ bot.chat('Done')
+ } catch (err) {
+ // An error occurred, report it.
+ bot.chat(err.message)
+ console.log(err)
+ }
+})
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/storageBot.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/storageBot.js
new file mode 100644
index 0000000000000000000000000000000000000000..b6f9971f25103612d6dd529fc4f4b42a710f1b1f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/examples/storageBot.js
@@ -0,0 +1,107 @@
+/**
+ * This bot example shows how to use the chest filling mechanic of the plugin.
+ * Simply provide a given storage chest, and the bot will automatically try and
+ * store it's inventory in that chest when the bot's inventory becomes full.
+ */
+
+if (process.argv.length < 4 || process.argv.length > 6) {
+ console.log('Usage : node storageBot.js [] []')
+ process.exit(1)
+}
+
+// Load your libraries
+const mineflayer = require('mineflayer')
+const collectBlock = require('mineflayer-collectblock').plugin
+
+// Create your bot
+const bot = mineflayer.createBot({
+ host: process.argv[2],
+ port: parseInt(process.argv[3]),
+ username: process.argv[4] ? process.argv[4] : 'storageBot',
+ password: process.argv[5]
+})
+
+// Load the collect block plugin
+bot.loadPlugin(collectBlock)
+
+// Load mcData on login
+let mcData
+bot.once('login', () => {
+ mcData = require('minecraft-data')(bot.version)
+})
+
+// On spawn, try to find any nearby chests and save those as storage locations.
+// When the bot's inventory becomes too full, it will empty it's inventory into
+// these chests before collecting more resources. If a chest gets full, it moves
+// to the next one in order until it's inventory is empty or it runs out of chests.
+bot.once('spawn', () => {
+ bot.collectBlock.chestLocations = bot.findBlocks({
+ matching: mcData.blocksByName.chest.id,
+ maxDistance: 16,
+ count: 999999 // Get as many chests as we can
+ })
+
+ if (bot.collectBlock.chestLocations.length === 0) {
+ bot.chat("I don't see any chests nearby.")
+ } else {
+ for (const chestPos of bot.collectBlock.chestLocations) {
+ bot.chat(`I found a chest at ${chestPos}`)
+ }
+ }
+})
+
+// Wait for someone to say something
+bot.on('chat', async (username, message) => {
+ // If the player says something start starts with "collect"
+ // Otherwise, do nothing
+ const args = message.split(' ')
+ if (args[0] !== 'collect') return
+
+ // If the player specifies a number, collect that many. Otherwise, default to 1.
+ let count = 1
+ if (args.length === 3) count = parseInt(args[1])
+
+ // If a number was given the item number is the 3rd arg, not the 2nd.
+ let type = args[1]
+ if (args.length === 3) type = args[2]
+
+ // Get the id of that block type for this version of Minecraft.
+ const blockType = mcData.blocksByName[type]
+ if (!blockType) {
+ bot.chat(`I don't know any blocks named ${type}.`)
+ return
+ }
+
+ // Find all nearby blocks of that type, up to the given count, within 64 blocks.
+ const blocks = bot.findBlocks({
+ matching: blockType.id,
+ maxDistance: 64,
+ count: count
+ })
+
+ // Complain if we can't find any nearby blocks of that type.
+ if (blocks.length === 0) {
+ bot.chat("I don't see that block nearby.")
+ return
+ }
+
+ // Convert the block position array into a block array to pass to collect block.
+ const targets = []
+ for (let i = 0; i < Math.min(blocks.length, count); i++) {
+ targets.push(bot.blockAt(blocks[i]))
+ }
+
+ // Announce what we found.
+ bot.chat(`Found ${targets.length} ${type}(s)`)
+
+ // Tell the bot to collect all of the given blocks in the block list.
+ try {
+ await bot.collectBlock.collect(targets)
+ // All blocks have been collected.
+ bot.chat('Done')
+ } catch (err) {
+ // An error occurred, report it.
+ bot.chat(err.message)
+ console.log(err)
+ }
+})
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..28792ed5deeed87899e4226f4f3ff7544232b986
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.d.ts
@@ -0,0 +1,3 @@
+import { Bot } from 'mineflayer';
+import { Block } from 'prismarine-block';
+export declare function findFromVein(bot: Bot, block: Block, maxBlocks: number, maxDistance: number, floodRadius: number): Block[];
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.js
new file mode 100644
index 0000000000000000000000000000000000000000..e23056de61e98cfe08ed99a142ac42c0f91fd265
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/BlockVeins.js
@@ -0,0 +1,34 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.findFromVein = void 0;
+function findFromVein(bot, block, maxBlocks, maxDistance, floodRadius) {
+ const targets = [];
+ const open = [block];
+ const type = block.type;
+ const center = block.position;
+ for (let i = 0; i < maxBlocks; i++) {
+ const next = open.pop();
+ if (next == null)
+ break;
+ targets.push(next);
+ for (let x = -floodRadius; x <= floodRadius; x++) {
+ for (let y = -floodRadius; y <= floodRadius; y++) {
+ for (let z = -floodRadius; z <= floodRadius; z++) {
+ const neighborPos = next.position.offset(x, y, z);
+ if (neighborPos.manhattanDistanceTo(center) > maxDistance)
+ continue;
+ const neighbor = bot.blockAt(neighborPos);
+ if (neighbor == null || neighbor.type !== type)
+ continue;
+ if (targets.includes(neighbor))
+ continue;
+ if (open.includes(neighbor))
+ continue;
+ open.push(neighbor);
+ }
+ }
+ }
+ }
+ return targets;
+}
+exports.findFromVein = findFromVein;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..38acd3d533d19f7b18d7a8b9ba83f2e8c4eece5c
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.d.ts
@@ -0,0 +1,109 @@
+import { Bot } from "mineflayer";
+import { Block } from "prismarine-block";
+import { Movements } from "mineflayer-pathfinder";
+import { Vec3 } from "vec3";
+import { ItemFilter } from "./Inventory";
+import { Collectable } from "./Targets";
+export type Callback = (err?: Error) => void;
+/**
+ * A set of options to apply when collecting the given targets.
+ */
+export interface CollectOptions {
+ /**
+ * If true, the target(s) will be appended to the existing target list instead of
+ * starting a new task. Defaults to false.
+ */
+ append?: boolean;
+ /**
+ * If true, errors will not be thrown when a path to the target block cannot
+ * be found. The bot will attempt to choose the best available position it
+ * can find, instead. Errors are still thrown if the bot cannot interact with
+ * the block from it's final location. Defaults to false.
+ */
+ ignoreNoPath?: boolean;
+ /**
+ * Gets the list of chest locations to use when storing items after the bot's
+ * inventory becomes full. If undefined, it defaults to the chest location
+ * list on the bot.collectBlock plugin.
+ */
+ chestLocations?: Vec3[];
+ /**
+ * When transferring items to a chest, this filter is used to determine what
+ * items are allowed to be moved, and what items aren't allowed to be moved.
+ * Defaults to the item filter specified on the bot.collectBlock plugin.
+ */
+ itemFilter?: ItemFilter;
+ /**
+ * The total number of items to collect
+ */
+ count?: number;
+}
+/**
+ * The collect block plugin.
+ */
+export declare class CollectBlock {
+ /**
+ * The bot.
+ */
+ private readonly bot;
+ /**
+ * The list of active targets being collected.
+ */
+ private readonly targets;
+ /**
+ * The movements configuration to be sent to the pathfinder plugin.
+ */
+ movements?: Movements;
+ /**
+ * A list of chest locations which the bot is allowed to empty their inventory into
+ * if it becomes full while the bot is collecting resources.
+ */
+ chestLocations: Vec3[];
+ /**
+ * When collecting items, this filter is used to determine what items should be placed
+ * into a chest if the bot's inventory becomes full. By default, returns true for all
+ * items except for tools, weapons, and armor.
+ *
+ * @param item - The item stack in the bot's inventory to check.
+ *
+ * @returns True if the item should be moved into the chest. False otherwise.
+ */
+ itemFilter: ItemFilter;
+ /**
+ * Creates a new instance of the create block plugin.
+ *
+ * @param bot - The bot this plugin is acting on.
+ */
+ constructor(bot: Bot);
+ /**
+ * If target is a block:
+ * Causes the bot to break and collect the target block.
+ *
+ * If target is an item drop:
+ * Causes the bot to collect the item drop.
+ *
+ * If target is an array containing items or blocks, preforms the correct action for
+ * all targets in that array sorting dynamically by distance.
+ *
+ * @param target - The block(s) or item(s) to collect.
+ * @param options - The set of options to use when handling these targets
+ * @param cb - The callback that is called finished.
+ */
+ collect(target: Collectable | Collectable[], options?: CollectOptions | Callback, cb?: Callback): Promise;
+ /**
+ * Loads all touching blocks of the same type to the given block and returns them as an array.
+ * This effectively acts as a flood fill algorithm to retrieve blocks in the same ore vein and similar.
+ *
+ * @param block - The starting block.
+ * @param maxBlocks - The maximum number of blocks to look for before stopping.
+ * @param maxDistance - The max distance from the starting block to look.
+ * @param floodRadius - The max distance distance from block A to block B to be considered "touching"
+ */
+ findFromVein(block: Block, maxBlocks?: number, maxDistance?: number, floodRadius?: number): Block[];
+ /**
+ * Cancels the current collection task, if still active.
+ *
+ * @param cb - The callback to use when the task is stopped.
+ */
+ cancelTask(cb?: Callback): Promise;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.js
new file mode 100644
index 0000000000000000000000000000000000000000..b9da3c778ccbebe438390e9ce16173ddb2e5859f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/CollectBlock.js
@@ -0,0 +1,355 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+var __importDefault = (this && this.__importDefault) || function (mod) {
+ return (mod && mod.__esModule) ? mod : { "default": mod };
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.CollectBlock = void 0;
+const mineflayer_pathfinder_1 = require("mineflayer-pathfinder");
+const TemporarySubscriber_1 = require("./TemporarySubscriber");
+const Util_1 = require("./Util");
+const Inventory_1 = require("./Inventory");
+const BlockVeins_1 = require("./BlockVeins");
+const Targets_1 = require("./Targets");
+const minecraft_data_1 = __importDefault(require("minecraft-data"));
+const events_1 = require("events");
+const util_1 = require("util");
+function collectAll(bot, options) {
+ var _a, _b;
+ return __awaiter(this, void 0, void 0, function* () {
+ let success_count = 0;
+ while (!options.targets.empty) {
+ yield (0, Inventory_1.emptyInventoryIfFull)(bot, options.chestLocations, options.itemFilter);
+ const closest = options.targets.getClosest();
+ if (closest == null)
+ break;
+ switch (closest.constructor.name) {
+ case "Block": {
+ try {
+ if (success_count >= options.count) {
+ break;
+ }
+ yield bot.tool.equipForBlock(closest, equipToolOptions);
+ const goal = new mineflayer_pathfinder_1.goals.GoalLookAtBlock(closest.position, bot.world);
+ yield bot.pathfinder.goto(goal);
+ yield mineBlock(bot, closest, options);
+ success_count++;
+ // TODO: options.ignoreNoPath
+ }
+ catch (err) {
+ // @ts-ignore
+ // console.log(err.stack)
+ // bot.pathfinder.stop()
+ // bot.waitForTicks(10)
+ try {
+ bot.pathfinder.setGoal(null);
+ }
+ catch (err) { }
+ if (options.ignoreNoPath) {
+ // @ts-ignore
+ if (err.name === "Invalid block") {
+ console.log(`Block ${closest.name} at ${closest.position} is not valid! Skip it!`);
+ } // @ts-ignore
+ else if (err.name === "Unsafe block") {
+ console.log(`${closest.name} at ${closest.position} is not safe to break! Skip it!`);
+ // @ts-ignore
+ }
+ else if (err.name === "NoItem") {
+ const properties = bot.registry.blocksByName[closest.name];
+ const harvestTools = Object.keys((_a = properties === null || properties === void 0 ? void 0 : properties.harvestTools) !== null && _a !== void 0 ? _a : {});
+ const leastTool = harvestTools[0];
+ if (leastTool === undefined) {
+ bot.chat(`I do not have a suitable tool to mine ${closest.name}! Skip it!`);
+ return;
+ }
+ const item = bot.registry.itemsByName[leastTool];
+ const toolName = (_b = item === null || item === void 0 ? void 0 : item.name) !== null && _b !== void 0 ? _b : leastTool;
+ bot.chat(`I need at least a ${toolName} to mine ${closest.name}! Skip it!`);
+ return;
+ }
+ else if (
+ // @ts-ignore
+ err.name === "NoPath" ||
+ // @ts-ignore
+ err.name === "Timeout") {
+ if (bot.entity.position.distanceTo(closest.position) < 0.5) {
+ yield mineBlock(bot, closest, options);
+ break;
+ }
+ console.log(`No path to ${closest.name} at ${closest.position}! Skip it!`);
+ // @ts-ignore
+ }
+ else if (err.message === "Digging aborted") {
+ console.log(`Digging aborted! Skip it!`);
+ }
+ else {
+ // @ts-ignore
+ bot.chat(`Error: ${err.message}`);
+ }
+ break;
+ }
+ throw err;
+ }
+ break;
+ }
+ case "Entity": {
+ // Don't collect any entities that are marked as 'invalid'
+ if (!closest.isValid)
+ break;
+ try {
+ const tempEvents = new TemporarySubscriber_1.TemporarySubscriber(bot);
+ const waitForPickup = new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ // After 10 seconds, reject the promise
+ clearTimeout(timeout);
+ tempEvents.cleanup();
+ reject(new Error("Failed to pickup item"));
+ }, 10000);
+ tempEvents.subscribeTo("entityGone", (entity) => {
+ if (entity === closest) {
+ clearTimeout(timeout);
+ tempEvents.cleanup();
+ resolve();
+ }
+ });
+ });
+ bot.pathfinder.setGoal(new mineflayer_pathfinder_1.goals.GoalFollow(closest, 0));
+ // await bot.pathfinder.goto(new goals.GoalBlock(closest.position.x, closest.position.y, closest.position.z))
+ yield waitForPickup;
+ }
+ catch (err) {
+ // @ts-ignore
+ console.log(err.stack);
+ try {
+ bot.pathfinder.setGoal(null);
+ }
+ catch (err) { }
+ if (options.ignoreNoPath) {
+ // @ts-ignore
+ if (err.message === "Failed to pickup item") {
+ bot.chat(`Failed to pickup item! Skip it!`);
+ }
+ break;
+ }
+ throw err;
+ }
+ break;
+ }
+ default: {
+ throw (0, Util_1.error)("UnknownType", `Target ${closest.constructor.name} is not a Block or Entity!`);
+ }
+ }
+ options.targets.removeTarget(closest);
+ }
+ bot.chat(`Collect finish!`);
+ });
+}
+const equipToolOptions = {
+ requireHarvest: true,
+ getFromChest: false,
+ maxTools: 2,
+};
+function mineBlock(bot, block, options) {
+ var _a, _b;
+ return __awaiter(this, void 0, void 0, function* () {
+ if (((_a = bot.blockAt(block.position)) === null || _a === void 0 ? void 0 : _a.type) !== block.type ||
+ ((_b = bot.blockAt(block.position)) === null || _b === void 0 ? void 0 : _b.type) === 0) {
+ options.targets.removeTarget(block);
+ throw (0, Util_1.error)("Invalid block", "Block is not valid!");
+ // @ts-expect-error
+ }
+ else if (!bot.pathfinder.movements.safeToBreak(block)) {
+ options.targets.removeTarget(block);
+ throw (0, Util_1.error)("Unsafe block", "Block is not safe to break!");
+ }
+ yield bot.tool.equipForBlock(block, equipToolOptions);
+ if (!block.canHarvest(bot.heldItem ? bot.heldItem.type : bot.heldItem)) {
+ options.targets.removeTarget(block);
+ throw (0, Util_1.error)("NoItem", "Bot does not have a harvestable tool!");
+ }
+ const tempEvents = new TemporarySubscriber_1.TemporarySubscriber(bot);
+ tempEvents.subscribeTo("itemDrop", (entity) => {
+ if (entity.position.distanceTo(block.position.offset(0.5, 0.5, 0.5)) <=
+ 0.5) {
+ options.targets.appendTarget(entity);
+ }
+ });
+ try {
+ yield bot.dig(block);
+ // Waiting for items to drop
+ yield new Promise((resolve) => {
+ let remainingTicks = 10;
+ tempEvents.subscribeTo("physicTick", () => {
+ remainingTicks--;
+ if (remainingTicks <= 0) {
+ tempEvents.cleanup();
+ resolve();
+ }
+ });
+ });
+ }
+ finally {
+ tempEvents.cleanup();
+ }
+ });
+}
+/**
+ * The collect block plugin.
+ */
+class CollectBlock {
+ /**
+ * Creates a new instance of the create block plugin.
+ *
+ * @param bot - The bot this plugin is acting on.
+ */
+ constructor(bot) {
+ /**
+ * A list of chest locations which the bot is allowed to empty their inventory into
+ * if it becomes full while the bot is collecting resources.
+ */
+ this.chestLocations = [];
+ /**
+ * When collecting items, this filter is used to determine what items should be placed
+ * into a chest if the bot's inventory becomes full. By default, returns true for all
+ * items except for tools, weapons, and armor.
+ *
+ * @param item - The item stack in the bot's inventory to check.
+ *
+ * @returns True if the item should be moved into the chest. False otherwise.
+ */
+ this.itemFilter = (item) => {
+ if (item.name.includes("helmet"))
+ return false;
+ if (item.name.includes("chestplate"))
+ return false;
+ if (item.name.includes("leggings"))
+ return false;
+ if (item.name.includes("boots"))
+ return false;
+ if (item.name.includes("shield"))
+ return false;
+ if (item.name.includes("sword"))
+ return false;
+ if (item.name.includes("pickaxe"))
+ return false;
+ if (item.name.includes("axe"))
+ return false;
+ if (item.name.includes("shovel"))
+ return false;
+ if (item.name.includes("hoe"))
+ return false;
+ return true;
+ };
+ this.bot = bot;
+ this.targets = new Targets_1.Targets(bot);
+ // @ts-ignore
+ this.movements = new mineflayer_pathfinder_1.Movements(bot, (0, minecraft_data_1.default)(bot.version));
+ }
+ /**
+ * If target is a block:
+ * Causes the bot to break and collect the target block.
+ *
+ * If target is an item drop:
+ * Causes the bot to collect the item drop.
+ *
+ * If target is an array containing items or blocks, preforms the correct action for
+ * all targets in that array sorting dynamically by distance.
+ *
+ * @param target - The block(s) or item(s) to collect.
+ * @param options - The set of options to use when handling these targets
+ * @param cb - The callback that is called finished.
+ */
+ collect(target, options = {}, cb) {
+ var _a, _b, _c, _d, _e;
+ return __awaiter(this, void 0, void 0, function* () {
+ if (typeof options === "function") {
+ cb = options;
+ options = {};
+ }
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(this.collect)(target, options, cb);
+ const optionsFull = {
+ append: (_a = options.append) !== null && _a !== void 0 ? _a : false,
+ ignoreNoPath: (_b = options.ignoreNoPath) !== null && _b !== void 0 ? _b : false,
+ chestLocations: (_c = options.chestLocations) !== null && _c !== void 0 ? _c : this.chestLocations,
+ itemFilter: (_d = options.itemFilter) !== null && _d !== void 0 ? _d : this.itemFilter,
+ targets: this.targets,
+ count: (_e = options.count) !== null && _e !== void 0 ? _e : Infinity,
+ };
+ if (this.bot.pathfinder == null) {
+ throw (0, Util_1.error)("UnresolvedDependency", "The mineflayer-collectblock plugin relies on the mineflayer-pathfinder plugin to run!");
+ }
+ if (this.bot.tool == null) {
+ throw (0, Util_1.error)("UnresolvedDependency", "The mineflayer-collectblock plugin relies on the mineflayer-tool plugin to run!");
+ }
+ if (this.movements != null) {
+ this.bot.pathfinder.setMovements(this.movements);
+ }
+ if (!optionsFull.append)
+ yield this.cancelTask();
+ if (Array.isArray(target)) {
+ this.targets.appendTargets(target);
+ }
+ else {
+ this.targets.appendTarget(target);
+ }
+ try {
+ yield collectAll(this.bot, optionsFull);
+ this.targets.clear();
+ }
+ catch (err) {
+ this.targets.clear();
+ // Ignore path stopped error for cancelTask to work properly (imo we shouldn't throw any pathing errors)
+ // @ts-expect-error
+ if (err.name !== "PathStopped")
+ throw err;
+ }
+ finally {
+ // @ts-expect-error
+ this.bot.emit("collectBlock_finished");
+ }
+ });
+ }
+ /**
+ * Loads all touching blocks of the same type to the given block and returns them as an array.
+ * This effectively acts as a flood fill algorithm to retrieve blocks in the same ore vein and similar.
+ *
+ * @param block - The starting block.
+ * @param maxBlocks - The maximum number of blocks to look for before stopping.
+ * @param maxDistance - The max distance from the starting block to look.
+ * @param floodRadius - The max distance distance from block A to block B to be considered "touching"
+ */
+ findFromVein(block, maxBlocks = 100, maxDistance = 16, floodRadius = 1) {
+ return (0, BlockVeins_1.findFromVein)(this.bot, block, maxBlocks, maxDistance, floodRadius);
+ }
+ /**
+ * Cancels the current collection task, if still active.
+ *
+ * @param cb - The callback to use when the task is stopped.
+ */
+ cancelTask(cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ if (this.targets.empty) {
+ if (cb != null)
+ cb();
+ return yield Promise.resolve();
+ }
+ this.bot.pathfinder.stop();
+ if (cb != null) {
+ // @ts-expect-error
+ this.bot.once("collectBlock_finished", cb);
+ }
+ yield (0, events_1.once)(this.bot, "collectBlock_finished");
+ });
+ }
+}
+exports.CollectBlock = CollectBlock;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4899e4e8ab63d75e4037a11260f5be0c162a8d15
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.d.ts
@@ -0,0 +1,7 @@
+import { Bot } from 'mineflayer';
+import { Callback } from './CollectBlock';
+import { Vec3 } from 'vec3';
+import { Item } from 'prismarine-item';
+export type ItemFilter = (item: Item) => boolean;
+export declare function emptyInventoryIfFull(bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise;
+export declare function emptyInventory(bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.js
new file mode 100644
index 0000000000000000000000000000000000000000..c539073aed25fd87db91e4e85a9f2f1f5e32cd48
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Inventory.js
@@ -0,0 +1,102 @@
+"use strict";
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.emptyInventory = exports.emptyInventoryIfFull = void 0;
+const Util_1 = require("./Util");
+const mineflayer_pathfinder_1 = require("mineflayer-pathfinder");
+const util_1 = require("util");
+function getClosestChest(bot, chestLocations) {
+ let chest = null;
+ let distance = 0;
+ for (const c of chestLocations) {
+ const dist = c.distanceTo(bot.entity.position);
+ if (chest == null || dist < distance) {
+ chest = c;
+ distance = dist;
+ }
+ }
+ if (chest != null) {
+ chestLocations.splice(chestLocations.indexOf(chest), 1);
+ }
+ return chest;
+}
+function emptyInventoryIfFull(bot, chestLocations, itemFilter, cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(emptyInventoryIfFull)(bot, chestLocations, cb);
+ if (bot.inventory.emptySlotCount() > 0)
+ return;
+ return yield emptyInventory(bot, chestLocations, itemFilter);
+ });
+}
+exports.emptyInventoryIfFull = emptyInventoryIfFull;
+function emptyInventory(bot, chestLocations, itemFilter, cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(emptyInventory)(bot, chestLocations, cb);
+ if (chestLocations.length === 0) {
+ throw (0, Util_1.error)('NoChests', 'There are no defined chest locations!');
+ }
+ // Shallow clone so we can safely remove chests from the list that are full.
+ chestLocations = [...chestLocations];
+ while (true) {
+ const chest = getClosestChest(bot, chestLocations);
+ if (chest == null) {
+ throw (0, Util_1.error)('NoChests', 'All chests are full.');
+ }
+ const hasRemaining = yield tryEmptyInventory(bot, chest, itemFilter);
+ if (!hasRemaining)
+ return;
+ }
+ });
+}
+exports.emptyInventory = emptyInventory;
+function tryEmptyInventory(bot, chestLocation, itemFilter, cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(tryEmptyInventory)(bot, chestLocation, itemFilter, cb);
+ yield gotoChest(bot, chestLocation);
+ return yield placeItems(bot, chestLocation, itemFilter);
+ });
+}
+function gotoChest(bot, location, cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(gotoChest)(bot, location);
+ yield bot.pathfinder.goto(new mineflayer_pathfinder_1.goals.GoalGetToBlock(location.x, location.y, location.z));
+ });
+}
+function placeItems(bot, chestPos, itemFilter, cb) {
+ return __awaiter(this, void 0, void 0, function* () {
+ // @ts-expect-error
+ if (cb != null)
+ return (0, util_1.callbackify)(placeItems)(bot, chestPos, itemFilter, cb);
+ const chestBlock = bot.blockAt(chestPos);
+ if (chestBlock == null) {
+ throw (0, Util_1.error)('UnloadedChunk', 'Chest is in an unloaded chunk!');
+ }
+ const chest = yield bot.openChest(chestBlock);
+ for (const item of bot.inventory.items()) {
+ if (!itemFilter(item))
+ continue;
+ if (chest.firstEmptyContainerSlot() === null) {
+ // We have items that didn't fit.
+ return true;
+ }
+ yield chest.deposit(item.type, item.metadata, item.count);
+ }
+ return false;
+ });
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..45fd401685928e3a263f85ff9616c0ffc764f8dd
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.d.ts
@@ -0,0 +1,20 @@
+import { Bot } from 'mineflayer';
+import { Block } from 'prismarine-block';
+import { Entity } from 'prismarine-entity';
+export type Collectable = Block | Entity;
+export declare class Targets {
+ private readonly bot;
+ private targets;
+ constructor(bot: Bot);
+ appendTargets(targets: Collectable[]): void;
+ appendTarget(target: Collectable): void;
+ /**
+ * Gets the closest target to the bot in this list.
+ *
+ * @returns The closest target, or null if there are no targets.
+ */
+ getClosest(): Collectable | null;
+ get empty(): boolean;
+ clear(): void;
+ removeTarget(target: Collectable): void;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.js
new file mode 100644
index 0000000000000000000000000000000000000000..5ff587c638a76ed7aad0fe419ba0899ca8d05dc2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Targets.js
@@ -0,0 +1,49 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.Targets = void 0;
+class Targets {
+ constructor(bot) {
+ this.targets = [];
+ this.bot = bot;
+ }
+ appendTargets(targets) {
+ for (const target of targets) {
+ this.appendTarget(target);
+ }
+ }
+ appendTarget(target) {
+ if (this.targets.includes(target))
+ return;
+ this.targets.push(target);
+ }
+ /**
+ * Gets the closest target to the bot in this list.
+ *
+ * @returns The closest target, or null if there are no targets.
+ */
+ getClosest() {
+ let closest = null;
+ let distance = 0;
+ for (const target of this.targets) {
+ const dist = target.position.distanceTo(this.bot.entity.position);
+ if (closest == null || dist < distance) {
+ closest = target;
+ distance = dist;
+ }
+ }
+ return closest;
+ }
+ get empty() {
+ return this.targets.length === 0;
+ }
+ clear() {
+ this.targets.length = 0;
+ }
+ removeTarget(target) {
+ const index = this.targets.indexOf(target);
+ if (index < 0)
+ return;
+ this.targets.splice(index, 1);
+ }
+}
+exports.Targets = Targets;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bfca2a815959f6f6fa9fcf32e53725253fe5f201
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.d.ts
@@ -0,0 +1,33 @@
+import type { Callback } from './index';
+export type Task = (cb: Callback) => void;
+export type SyncTask = () => void;
+/**
+ * A simple utility class for queuing up a series of async tasks to execute.
+ */
+export declare class TaskQueue {
+ private tasks;
+ /**
+ * If true, the task list will stop executing if one of the tasks throws an error.
+ */
+ readonly stopOnError: boolean;
+ /**
+ * Adds a new async task to this queue. The provided callback should be executed when
+ * the async task is complete.
+ *
+ * @param task - The async task to add.
+ */
+ add(task: Task): void;
+ /**
+ * Adds a synchronous task toi this queue.
+ *
+ * @param task - The sync task to add.
+ */
+ addSync(task: SyncTask): void;
+ /**
+ * Runs all tasks currently in this queue and empties the queue.
+ *
+ * @param cb - The optional callback to be executed when all tasks in this queue have
+ * finished executing.
+ */
+ runAll(cb?: Callback): void;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.js
new file mode 100644
index 0000000000000000000000000000000000000000..329e18bb0111f689f9aedbcef9b9196f22fbcd44
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TaskQueue.js
@@ -0,0 +1,76 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.TaskQueue = void 0;
+/**
+ * A simple utility class for queuing up a series of async tasks to execute.
+ */
+class TaskQueue {
+ constructor() {
+ this.tasks = [];
+ /**
+ * If true, the task list will stop executing if one of the tasks throws an error.
+ */
+ this.stopOnError = true;
+ }
+ /**
+ * Adds a new async task to this queue. The provided callback should be executed when
+ * the async task is complete.
+ *
+ * @param task - The async task to add.
+ */
+ add(task) {
+ this.tasks.push(task);
+ }
+ /**
+ * Adds a synchronous task toi this queue.
+ *
+ * @param task - The sync task to add.
+ */
+ addSync(task) {
+ this.add((cb) => {
+ try {
+ task();
+ cb();
+ }
+ catch (err) {
+ cb(err);
+ }
+ });
+ }
+ /**
+ * Runs all tasks currently in this queue and empties the queue.
+ *
+ * @param cb - The optional callback to be executed when all tasks in this queue have
+ * finished executing.
+ */
+ runAll(cb) {
+ const taskList = this.tasks;
+ this.tasks = [];
+ let index = -1;
+ const runNext = () => {
+ index++;
+ if (index >= taskList.length) {
+ if (cb !== undefined)
+ cb();
+ return;
+ }
+ try {
+ taskList[index]((err) => {
+ if (err !== undefined) {
+ if (cb !== undefined)
+ cb(err);
+ if (this.stopOnError)
+ return;
+ }
+ runNext();
+ });
+ }
+ catch (err) {
+ if (cb !== undefined)
+ cb(err);
+ }
+ };
+ runNext();
+ }
+}
+exports.TaskQueue = TaskQueue;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..81058e3ff4756c64e9ffcf4028c28d29b389d231
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.d.ts
@@ -0,0 +1,17 @@
+import { Bot } from 'mineflayer';
+export declare class TemporarySubscriber {
+ readonly bot: Bot;
+ private readonly subscriptions;
+ constructor(bot: Bot);
+ /**
+ * Adds a new temporary event listener to the bot.
+ *
+ * @param event - The event to subscribe to.
+ * @param callback - The function to execute.
+ */
+ subscribeTo(event: string, callback: Function): void;
+ /**
+ * Removes all attached event listeners from the bot.
+ */
+ cleanup(): void;
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.js
new file mode 100644
index 0000000000000000000000000000000000000000..cd78fc4892221562782849248fd3cc6d399c64cf
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/TemporarySubscriber.js
@@ -0,0 +1,36 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.TemporarySubscriber = void 0;
+class Subscription {
+ constructor(eventName, callback) {
+ this.eventName = eventName;
+ this.callback = callback;
+ }
+}
+class TemporarySubscriber {
+ constructor(bot) {
+ this.bot = bot;
+ this.subscriptions = [];
+ }
+ /**
+ * Adds a new temporary event listener to the bot.
+ *
+ * @param event - The event to subscribe to.
+ * @param callback - The function to execute.
+ */
+ subscribeTo(event, callback) {
+ this.subscriptions.push(new Subscription(event, callback));
+ // @ts-expect-error
+ this.bot.on(event, callback);
+ }
+ /**
+ * Removes all attached event listeners from the bot.
+ */
+ cleanup() {
+ for (const sub of this.subscriptions) {
+ // @ts-expect-error
+ this.bot.removeListener(sub.eventName, sub.callback);
+ }
+ }
+}
+exports.TemporarySubscriber = TemporarySubscriber;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..7393ab11d70b67922200d561cef2913be80c366a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.d.ts
@@ -0,0 +1,9 @@
+/**
+ * Creates a new error object with the given type and message.
+ *
+ * @param type - The error type.
+ * @param message - The error message.
+ *
+ * @returns The error object.
+ */
+export declare function error(type: string, message: string): Error;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.js
new file mode 100644
index 0000000000000000000000000000000000000000..1ca312ce6cf76b9fdb63649d3e8994138f83d5f2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/Util.js
@@ -0,0 +1,17 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.error = void 0;
+/**
+ * Creates a new error object with the given type and message.
+ *
+ * @param type - The error type.
+ * @param message - The error message.
+ *
+ * @returns The error object.
+ */
+function error(type, message) {
+ const e = new Error(message);
+ e.name = type;
+ return e;
+}
+exports.error = error;
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.d.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.d.ts
new file mode 100644
index 0000000000000000000000000000000000000000..beb8ead144afe9d2f831ec7dcc0d14ab091ebe48
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.d.ts
@@ -0,0 +1,3 @@
+import { Bot } from 'mineflayer';
+export declare function plugin(bot: Bot): void;
+export { CollectBlock, Callback, CollectOptions } from './CollectBlock';
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.js b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..b933fa8cf092acd2fd98f5155ffdda12b6dd0d4a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/lib/index.js
@@ -0,0 +1,26 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
+exports.CollectBlock = exports.plugin = void 0;
+const CollectBlock_1 = require("./CollectBlock");
+const mineflayer_pathfinder_1 = require("mineflayer-pathfinder");
+const mineflayer_tool_1 = require("mineflayer-tool");
+function plugin(bot) {
+ // @ts-expect-error
+ bot.collectBlock = new CollectBlock_1.CollectBlock(bot);
+ // Load plugins if not loaded manually.
+ setTimeout(() => loadPathfinderPlugin(bot), 0);
+ setTimeout(() => loadToolPlugin(bot), 0);
+}
+exports.plugin = plugin;
+function loadPathfinderPlugin(bot) {
+ if (bot.pathfinder != null)
+ return;
+ bot.loadPlugin(mineflayer_pathfinder_1.pathfinder);
+}
+function loadToolPlugin(bot) {
+ if (bot.tool != null)
+ return;
+ bot.loadPlugin(mineflayer_tool_1.plugin);
+}
+var CollectBlock_2 = require("./CollectBlock");
+Object.defineProperty(exports, "CollectBlock", { enumerable: true, get: function () { return CollectBlock_2.CollectBlock; } });
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/package.json b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..0f59e7aa6a1d38ed4c43923f910846d6c7998ec8
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/package.json
@@ -0,0 +1,44 @@
+{
+ "name": "mineflayer-collectblock",
+ "version": "1.4.1",
+ "description": "A simple utility plugin for Mineflayer that add a higher level API for collecting blocks.",
+ "main": "lib/index.js",
+ "types": "lib/index.d.ts",
+ "scripts": {
+ "build": "ts-standard && tsc && require-self",
+ "clean": "rm -rf lib",
+ "test": "test"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TheDudeFromCI/mineflayer-collectblock.git"
+ },
+ "keywords": [
+ "mineflayer",
+ "plugin",
+ "api",
+ "utility",
+ "helper",
+ "collect"
+ ],
+ "author": "TheDudeFromCI",
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/TheDudeFromCI/mineflayer-collectblock/issues"
+ },
+ "homepage": "https://github.com/TheDudeFromCI/mineflayer-collectblock#readme",
+ "dependencies": {
+ "mineflayer": "^4.0.0",
+ "mineflayer-pathfinder": "^2.1.1",
+ "mineflayer-tool": "^1.1.0"
+ },
+ "devDependencies": {
+ "@types/node": "^18.6.4",
+ "require-self": "^0.2.3",
+ "ts-standard": "^11.0.0",
+ "typescript": "^4.1.3"
+ },
+ "files": [
+ "lib/**/*"
+ ]
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae5542ce3a693d75262bea010b72766e3042fd0b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts
@@ -0,0 +1,35 @@
+import { Bot } from 'mineflayer'
+import { Block } from 'prismarine-block'
+
+export function findFromVein (bot: Bot, block: Block, maxBlocks: number, maxDistance: number, floodRadius: number): Block[] {
+ const targets: Block[] = []
+ const open: Block[] = [block]
+ const type = block.type
+ const center = block.position
+
+ for (let i = 0; i < maxBlocks; i++) {
+ const next = open.pop()
+ if (next == null) break
+
+ targets.push(next)
+
+ for (let x = -floodRadius; x <= floodRadius; x++) {
+ for (let y = -floodRadius; y <= floodRadius; y++) {
+ for (let z = -floodRadius; z <= floodRadius; z++) {
+ const neighborPos = next.position.offset(x, y, z)
+ if (neighborPos.manhattanDistanceTo(center) > maxDistance) continue
+
+ const neighbor = bot.blockAt(neighborPos)
+ if (neighbor == null || neighbor.type !== type) continue
+
+ if (targets.includes(neighbor)) continue
+ if (open.includes(neighbor)) continue
+
+ open.push(neighbor)
+ }
+ }
+ }
+ }
+
+ return targets
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts
new file mode 100644
index 0000000000000000000000000000000000000000..bbb94bd43c6db38530e4810d8b6bbd96d5732b41
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts
@@ -0,0 +1,457 @@
+import { Bot } from "mineflayer";
+import { Block } from "prismarine-block";
+import { Movements, goals } from "mineflayer-pathfinder";
+import { TemporarySubscriber } from "./TemporarySubscriber";
+import { Entity } from "prismarine-entity";
+import { error } from "./Util";
+import { Vec3 } from "vec3";
+import { emptyInventoryIfFull, ItemFilter } from "./Inventory";
+import { findFromVein } from "./BlockVeins";
+import { Collectable, Targets } from "./Targets";
+import { Item } from "prismarine-item";
+import mcDataLoader from "minecraft-data";
+import { once } from "events";
+import { callbackify } from "util";
+
+export type Callback = (err?: Error) => void;
+
+async function collectAll(
+ bot: Bot,
+ options: CollectOptionsFull
+): Promise {
+ let success_count = 0;
+ while (!options.targets.empty) {
+ await emptyInventoryIfFull(
+ bot,
+ options.chestLocations,
+ options.itemFilter
+ );
+ const closest = options.targets.getClosest();
+ if (closest == null) break;
+ switch (closest.constructor.name) {
+ case "Block": {
+ try {
+ if (success_count >= options.count) {
+ break;
+ }
+ await bot.tool.equipForBlock(
+ closest as Block,
+ equipToolOptions
+ );
+ const goal = new goals.GoalLookAtBlock(
+ closest.position,
+ bot.world
+ );
+ await bot.pathfinder.goto(goal);
+ await mineBlock(bot, closest as Block, options);
+ success_count++;
+ // TODO: options.ignoreNoPath
+ } catch (err) {
+ // @ts-ignore
+ // console.log(err.stack)
+ // bot.pathfinder.stop()
+ // bot.waitForTicks(10)
+ try {
+ bot.pathfinder.setGoal(null);
+ } catch (err) {}
+ if (options.ignoreNoPath) {
+ // @ts-ignore
+ if (err.name === "Invalid block") {
+ console.log(
+ `Block ${closest.name} at ${closest.position} is not valid! Skip it!`
+ );
+ } // @ts-ignore
+ else if (err.name === "Unsafe block") {
+ console.log(
+ `${closest.name} at ${closest.position} is not safe to break! Skip it!`
+ );
+ // @ts-ignore
+ } else if (err.name === "NoItem") {
+ const properties =
+ bot.registry.blocksByName[(closest.name as string)] as any;
+ const harvestTools = Object.keys(properties?.harvestTools ?? {});
+ const leastTool = harvestTools[0];
+ if (leastTool === undefined) {
+ bot.chat(
+ `I do not have a suitable tool to mine ${closest.name}! Skip it!`
+ );
+ return;
+ }
+ const item = bot.registry.itemsByName[(leastTool as string)] as any;
+ const toolName = item?.name ?? leastTool;
+ bot.chat(
+ `I need at least a ${toolName} to mine ${closest.name}! Skip it!`
+ );
+ return;
+ } else if (
+ // @ts-ignore
+ err.name === "NoPath" ||
+ // @ts-ignore
+ err.name === "Timeout"
+ ) {
+ if (
+ bot.entity.position.distanceTo(
+ closest.position
+ ) < 0.5
+ ) {
+ await mineBlock(bot, closest as Block, options);
+ break;
+ }
+ console.log(
+ `No path to ${closest.name} at ${closest.position}! Skip it!`
+ );
+ // @ts-ignore
+ } else if (err.message === "Digging aborted") {
+ console.log(`Digging aborted! Skip it!`);
+ } else {
+ // @ts-ignore
+ bot.chat(`Error: ${err.message}`);
+ }
+ break;
+ }
+ throw err;
+ }
+ break;
+ }
+ case "Entity": {
+ // Don't collect any entities that are marked as 'invalid'
+ if (!(closest as Entity).isValid) break;
+ try {
+ const tempEvents = new TemporarySubscriber(bot);
+ const waitForPickup = new Promise(
+ (resolve, reject) => {
+ const timeout = setTimeout(() => {
+ // After 10 seconds, reject the promise
+ clearTimeout(timeout);
+ tempEvents.cleanup();
+ reject(new Error("Failed to pickup item"));
+ }, 10000);
+ tempEvents.subscribeTo(
+ "entityGone",
+ (entity: Entity) => {
+ if (entity === closest) {
+ clearTimeout(timeout);
+ tempEvents.cleanup();
+ resolve();
+ }
+ }
+ );
+ }
+ );
+ bot.pathfinder.setGoal(
+ new goals.GoalFollow(closest as Entity, 0)
+ );
+ // await bot.pathfinder.goto(new goals.GoalBlock(closest.position.x, closest.position.y, closest.position.z))
+ await waitForPickup;
+ } catch (err) {
+ // @ts-ignore
+ console.log(err.stack);
+ try {
+ bot.pathfinder.setGoal(null);
+ } catch (err) {}
+ if (options.ignoreNoPath) {
+ // @ts-ignore
+ if (err.message === "Failed to pickup item") {
+ bot.chat(`Failed to pickup item! Skip it!`);
+ }
+ break;
+ }
+ throw err;
+ }
+ break;
+ }
+ default: {
+ throw error(
+ "UnknownType",
+ `Target ${closest.constructor.name} is not a Block or Entity!`
+ );
+ }
+ }
+ options.targets.removeTarget(closest);
+ }
+ bot.chat(`Collect finish!`);
+}
+
+const equipToolOptions = {
+ requireHarvest: true,
+ getFromChest: false,
+ maxTools: 2,
+};
+
+async function mineBlock(
+ bot: Bot,
+ block: Block,
+ options: CollectOptionsFull
+): Promise {
+ if (
+ bot.blockAt(block.position)?.type !== block.type ||
+ bot.blockAt(block.position)?.type === 0
+ ) {
+ options.targets.removeTarget(block);
+ throw error("Invalid block", "Block is not valid!");
+ // @ts-expect-error
+ } else if (!bot.pathfinder.movements.safeToBreak(block)) {
+ options.targets.removeTarget(block);
+ throw error("Unsafe block", "Block is not safe to break!");
+ }
+
+ await bot.tool.equipForBlock(block, equipToolOptions);
+
+ if (!block.canHarvest(bot.heldItem ? bot.heldItem.type : bot.heldItem)) {
+ options.targets.removeTarget(block);
+ throw error("NoItem", "Bot does not have a harvestable tool!");
+ }
+
+ const tempEvents = new TemporarySubscriber(bot);
+ tempEvents.subscribeTo("itemDrop", (entity: Entity) => {
+ if (
+ entity.position.distanceTo(block.position.offset(0.5, 0.5, 0.5)) <=
+ 0.5
+ ) {
+ options.targets.appendTarget(entity);
+ }
+ });
+ try {
+ await bot.dig(block as any);
+ // Waiting for items to drop
+ await new Promise((resolve) => {
+ let remainingTicks = 10;
+ tempEvents.subscribeTo("physicTick", () => {
+ remainingTicks--;
+ if (remainingTicks <= 0) {
+ tempEvents.cleanup();
+ resolve();
+ }
+ });
+ });
+ } finally {
+ tempEvents.cleanup();
+ }
+}
+
+/**
+ * A set of options to apply when collecting the given targets.
+ */
+export interface CollectOptions {
+ /**
+ * If true, the target(s) will be appended to the existing target list instead of
+ * starting a new task. Defaults to false.
+ */
+ append?: boolean;
+
+ /**
+ * If true, errors will not be thrown when a path to the target block cannot
+ * be found. The bot will attempt to choose the best available position it
+ * can find, instead. Errors are still thrown if the bot cannot interact with
+ * the block from it's final location. Defaults to false.
+ */
+ ignoreNoPath?: boolean;
+
+ /**
+ * Gets the list of chest locations to use when storing items after the bot's
+ * inventory becomes full. If undefined, it defaults to the chest location
+ * list on the bot.collectBlock plugin.
+ */
+ chestLocations?: Vec3[];
+
+ /**
+ * When transferring items to a chest, this filter is used to determine what
+ * items are allowed to be moved, and what items aren't allowed to be moved.
+ * Defaults to the item filter specified on the bot.collectBlock plugin.
+ */
+ itemFilter?: ItemFilter;
+
+ /**
+ * The total number of items to collect
+ */
+ count?: number;
+}
+
+/**
+ * A version of collect options where all values are assigned.
+ */
+interface CollectOptionsFull {
+ append: boolean;
+ ignoreNoPath: boolean;
+ chestLocations: Vec3[];
+ itemFilter: ItemFilter;
+ targets: Targets;
+ count: number;
+}
+
+/**
+ * The collect block plugin.
+ */
+export class CollectBlock {
+ /**
+ * The bot.
+ */
+ private readonly bot: Bot;
+
+ /**
+ * The list of active targets being collected.
+ */
+ private readonly targets: Targets;
+
+ /**
+ * The movements configuration to be sent to the pathfinder plugin.
+ */
+ movements?: Movements;
+
+ /**
+ * A list of chest locations which the bot is allowed to empty their inventory into
+ * if it becomes full while the bot is collecting resources.
+ */
+ chestLocations: Vec3[] = [];
+
+ /**
+ * When collecting items, this filter is used to determine what items should be placed
+ * into a chest if the bot's inventory becomes full. By default, returns true for all
+ * items except for tools, weapons, and armor.
+ *
+ * @param item - The item stack in the bot's inventory to check.
+ *
+ * @returns True if the item should be moved into the chest. False otherwise.
+ */
+ itemFilter: ItemFilter = (item: Item) => {
+ if (item.name.includes("helmet")) return false;
+ if (item.name.includes("chestplate")) return false;
+ if (item.name.includes("leggings")) return false;
+ if (item.name.includes("boots")) return false;
+ if (item.name.includes("shield")) return false;
+ if (item.name.includes("sword")) return false;
+ if (item.name.includes("pickaxe")) return false;
+ if (item.name.includes("axe")) return false;
+ if (item.name.includes("shovel")) return false;
+ if (item.name.includes("hoe")) return false;
+ return true;
+ };
+
+ /**
+ * Creates a new instance of the create block plugin.
+ *
+ * @param bot - The bot this plugin is acting on.
+ */
+ constructor(bot: Bot) {
+ this.bot = bot;
+ this.targets = new Targets(bot);
+ // @ts-ignore
+ this.movements = new Movements(bot, mcDataLoader(bot.version));
+ }
+
+ /**
+ * If target is a block:
+ * Causes the bot to break and collect the target block.
+ *
+ * If target is an item drop:
+ * Causes the bot to collect the item drop.
+ *
+ * If target is an array containing items or blocks, preforms the correct action for
+ * all targets in that array sorting dynamically by distance.
+ *
+ * @param target - The block(s) or item(s) to collect.
+ * @param options - The set of options to use when handling these targets
+ * @param cb - The callback that is called finished.
+ */
+ async collect(
+ target: Collectable | Collectable[],
+ options: CollectOptions | Callback = {},
+ cb?: Callback
+ ): Promise {
+ if (typeof options === "function") {
+ cb = options;
+ options = {};
+ }
+ // @ts-expect-error
+ if (cb != null) return callbackify(this.collect)(target, options, cb);
+
+ const optionsFull: CollectOptionsFull = {
+ append: options.append ?? false,
+ ignoreNoPath: options.ignoreNoPath ?? false,
+ chestLocations: options.chestLocations ?? this.chestLocations,
+ itemFilter: options.itemFilter ?? this.itemFilter,
+ targets: this.targets,
+ count: options.count ?? Infinity,
+ };
+
+ if (this.bot.pathfinder == null) {
+ throw error(
+ "UnresolvedDependency",
+ "The mineflayer-collectblock plugin relies on the mineflayer-pathfinder plugin to run!"
+ );
+ }
+
+ if (this.bot.tool == null) {
+ throw error(
+ "UnresolvedDependency",
+ "The mineflayer-collectblock plugin relies on the mineflayer-tool plugin to run!"
+ );
+ }
+
+ if (this.movements != null) {
+ this.bot.pathfinder.setMovements(this.movements);
+ }
+
+ if (!optionsFull.append) await this.cancelTask();
+ if (Array.isArray(target)) {
+ this.targets.appendTargets(target);
+ } else {
+ this.targets.appendTarget(target);
+ }
+
+ try {
+ await collectAll(this.bot, optionsFull);
+ this.targets.clear();
+ } catch (err) {
+ this.targets.clear();
+ // Ignore path stopped error for cancelTask to work properly (imo we shouldn't throw any pathing errors)
+ // @ts-expect-error
+ if (err.name !== "PathStopped") throw err;
+ } finally {
+ // @ts-expect-error
+ this.bot.emit("collectBlock_finished");
+ }
+ }
+
+ /**
+ * Loads all touching blocks of the same type to the given block and returns them as an array.
+ * This effectively acts as a flood fill algorithm to retrieve blocks in the same ore vein and similar.
+ *
+ * @param block - The starting block.
+ * @param maxBlocks - The maximum number of blocks to look for before stopping.
+ * @param maxDistance - The max distance from the starting block to look.
+ * @param floodRadius - The max distance distance from block A to block B to be considered "touching"
+ */
+ findFromVein(
+ block: Block,
+ maxBlocks = 100,
+ maxDistance = 16,
+ floodRadius = 1
+ ): Block[] {
+ return findFromVein(
+ this.bot,
+ block,
+ maxBlocks,
+ maxDistance,
+ floodRadius
+ );
+ }
+
+ /**
+ * Cancels the current collection task, if still active.
+ *
+ * @param cb - The callback to use when the task is stopped.
+ */
+ async cancelTask(cb?: Callback): Promise {
+ if (this.targets.empty) {
+ if (cb != null) cb();
+ return await Promise.resolve();
+ }
+ this.bot.pathfinder.stop();
+ if (cb != null) {
+ // @ts-expect-error
+ this.bot.once("collectBlock_finished", cb);
+ }
+ await once(this.bot as any, "collectBlock_finished");
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Inventory.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Inventory.ts
new file mode 100644
index 0000000000000000000000000000000000000000..6a17d0cc525966d26e948d627febd567abf3dbc6
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Inventory.ts
@@ -0,0 +1,87 @@
+import { Bot } from 'mineflayer'
+import { Callback } from './CollectBlock'
+import { Vec3 } from 'vec3'
+import { error } from './Util'
+import { Item } from 'prismarine-item'
+import { goals } from 'mineflayer-pathfinder'
+import { callbackify } from 'util'
+
+export type ItemFilter = (item: Item) => boolean
+
+function getClosestChest (bot: Bot, chestLocations: Vec3[]): Vec3 | null {
+ let chest = null
+ let distance = 0
+
+ for (const c of chestLocations) {
+ const dist = c.distanceTo(bot.entity.position)
+ if (chest == null || dist < distance) {
+ chest = c
+ distance = dist
+ }
+ }
+
+ if (chest != null) {
+ chestLocations.splice(chestLocations.indexOf(chest), 1)
+ }
+
+ return chest
+}
+
+export async function emptyInventoryIfFull (bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise {
+ // @ts-expect-error
+ if (cb != null) return callbackify(emptyInventoryIfFull)(bot, chestLocations, cb)
+ if (bot.inventory.emptySlotCount() > 0) return
+ return await emptyInventory(bot, chestLocations, itemFilter)
+}
+
+export async function emptyInventory (bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise {
+ // @ts-expect-error
+ if (cb != null) return callbackify(emptyInventory)(bot, chestLocations, cb)
+ if (chestLocations.length === 0) {
+ throw error('NoChests', 'There are no defined chest locations!')
+ }
+
+ // Shallow clone so we can safely remove chests from the list that are full.
+ chestLocations = [...chestLocations]
+
+ while (true) {
+ const chest = getClosestChest(bot, chestLocations)
+ if (chest == null) {
+ throw error('NoChests', 'All chests are full.')
+ }
+ const hasRemaining = await tryEmptyInventory(bot, chest, itemFilter)
+ if (!hasRemaining) return
+ }
+}
+
+async function tryEmptyInventory (bot: Bot, chestLocation: Vec3, itemFilter: ItemFilter, cb?: (err: Error | undefined, hasRemaining: boolean) => void): Promise {
+ // @ts-expect-error
+ if (cb != null) return callbackify(tryEmptyInventory)(bot, chestLocation, itemFilter, cb)
+ await gotoChest(bot, chestLocation)
+ return await placeItems(bot, chestLocation, itemFilter)
+}
+
+async function gotoChest (bot: Bot, location: Vec3, cb?: Callback): Promise {
+ // @ts-expect-error
+ if (cb != null) return callbackify(gotoChest)(bot, location)
+ await bot.pathfinder.goto(new goals.GoalGetToBlock(location.x, location.y, location.z))
+}
+
+async function placeItems (bot: Bot, chestPos: Vec3, itemFilter: ItemFilter, cb?: (err: Error | undefined, hasRemaining: boolean) => void): Promise {
+ // @ts-expect-error
+ if (cb != null) return callbackify(placeItems)(bot, chestPos, itemFilter, cb)
+ const chestBlock = bot.blockAt(chestPos)
+ if (chestBlock == null) {
+ throw error('UnloadedChunk', 'Chest is in an unloaded chunk!')
+ }
+ const chest = await bot.openChest(chestBlock)
+ for (const item of bot.inventory.items()) {
+ if (!itemFilter(item)) continue
+ if (chest.firstEmptyContainerSlot() === null) {
+ // We have items that didn't fit.
+ return true
+ }
+ await chest.deposit(item.type, item.metadata, item.count)
+ }
+ return false
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Targets.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Targets.ts
new file mode 100644
index 0000000000000000000000000000000000000000..568d07ad98ac8b4140344ed50515ad9e6a246899
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Targets.ts
@@ -0,0 +1,60 @@
+import { Bot } from 'mineflayer'
+import { Block } from 'prismarine-block'
+import { Entity } from 'prismarine-entity'
+
+export type Collectable = Block | Entity
+
+export class Targets {
+ private readonly bot: Bot
+ private targets: Collectable[] = []
+
+ constructor (bot: Bot) {
+ this.bot = bot
+ }
+
+ appendTargets (targets: Collectable[]): void {
+ for (const target of targets) {
+ this.appendTarget(target)
+ }
+ }
+
+ appendTarget (target: Collectable): void {
+ if (this.targets.includes(target)) return
+ this.targets.push(target)
+ }
+
+ /**
+ * Gets the closest target to the bot in this list.
+ *
+ * @returns The closest target, or null if there are no targets.
+ */
+ getClosest (): Collectable | null {
+ let closest: Collectable | null = null
+ let distance: number = 0
+
+ for (const target of this.targets) {
+ const dist = target.position.distanceTo(this.bot.entity.position)
+
+ if (closest == null || dist < distance) {
+ closest = target
+ distance = dist
+ }
+ }
+
+ return closest
+ }
+
+ get empty (): boolean {
+ return this.targets.length === 0
+ }
+
+ clear (): void {
+ this.targets.length = 0
+ }
+
+ removeTarget (target: Collectable): void {
+ const index = this.targets.indexOf(target)
+ if (index < 0) return
+ this.targets.splice(index, 1)
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts
new file mode 100644
index 0000000000000000000000000000000000000000..81fe3bc5ae05d9f15eedbc4e9f307176ed819040
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts
@@ -0,0 +1,77 @@
+import type { Callback } from './index'
+export type Task = (cb: Callback) => void
+export type SyncTask = () => void
+
+/**
+ * A simple utility class for queuing up a series of async tasks to execute.
+ */
+export class TaskQueue {
+ private tasks: Task[] = []
+
+ /**
+ * If true, the task list will stop executing if one of the tasks throws an error.
+ */
+ readonly stopOnError: boolean = true
+
+ /**
+ * Adds a new async task to this queue. The provided callback should be executed when
+ * the async task is complete.
+ *
+ * @param task - The async task to add.
+ */
+ add (task: Task): void {
+ this.tasks.push(task)
+ }
+
+ /**
+ * Adds a synchronous task toi this queue.
+ *
+ * @param task - The sync task to add.
+ */
+ addSync (task: SyncTask): void {
+ this.add((cb) => {
+ try {
+ task()
+ cb()
+ } catch (err: any) {
+ cb(err)
+ }
+ })
+ }
+
+ /**
+ * Runs all tasks currently in this queue and empties the queue.
+ *
+ * @param cb - The optional callback to be executed when all tasks in this queue have
+ * finished executing.
+ */
+ runAll (cb?: Callback): void {
+ const taskList = this.tasks
+ this.tasks = []
+
+ let index = -1
+ const runNext: () => void = () => {
+ index++
+ if (index >= taskList.length) {
+ if (cb !== undefined) cb()
+ return
+ }
+
+ try {
+ taskList[index]((err) => {
+ if (err !== undefined) {
+ if (cb !== undefined) cb(err)
+
+ if (this.stopOnError) return
+ }
+
+ runNext()
+ })
+ } catch (err: any) {
+ if (cb !== undefined) cb(err)
+ }
+ }
+
+ runNext()
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3f14a607da52bc42332ee1f6ba0999c2db76a679
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts
@@ -0,0 +1,34 @@
+import { Bot } from 'mineflayer'
+
+class Subscription {
+ constructor (readonly eventName: string, readonly callback: Function) {}
+}
+
+export class TemporarySubscriber {
+ private readonly subscriptions: Subscription[] = []
+
+ constructor (readonly bot: Bot) {}
+
+ /**
+ * Adds a new temporary event listener to the bot.
+ *
+ * @param event - The event to subscribe to.
+ * @param callback - The function to execute.
+ */
+ subscribeTo (event: string, callback: Function): void {
+ this.subscriptions.push(new Subscription(event, callback))
+
+ // @ts-expect-error
+ this.bot.on(event, callback)
+ }
+
+ /**
+ * Removes all attached event listeners from the bot.
+ */
+ cleanup (): void {
+ for (const sub of this.subscriptions) {
+ // @ts-expect-error
+ this.bot.removeListener(sub.eventName, sub.callback)
+ }
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Util.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Util.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ee0f29e0cb1034e1dd96593b73119382050b722b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/Util.ts
@@ -0,0 +1,13 @@
+/**
+ * Creates a new error object with the given type and message.
+ *
+ * @param type - The error type.
+ * @param message - The error message.
+ *
+ * @returns The error object.
+ */
+export function error (type: string, message: string): Error {
+ const e = new Error(message)
+ e.name = type
+ return e
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/index.ts b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/index.ts
new file mode 100644
index 0000000000000000000000000000000000000000..45c9a85087f56b5bd771477a6fe5b1a02d986b9f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/src/index.ts
@@ -0,0 +1,25 @@
+import { Bot } from 'mineflayer'
+import { CollectBlock } from './CollectBlock'
+import { pathfinder as pathfinderPlugin } from 'mineflayer-pathfinder'
+import { plugin as toolPlugin } from 'mineflayer-tool'
+
+export function plugin (bot: Bot): void {
+ // @ts-expect-error
+ bot.collectBlock = new CollectBlock(bot)
+
+ // Load plugins if not loaded manually.
+ setTimeout(() => loadPathfinderPlugin(bot), 0)
+ setTimeout(() => loadToolPlugin(bot), 0)
+}
+
+function loadPathfinderPlugin (bot: Bot): void {
+ if (bot.pathfinder != null) return
+ bot.loadPlugin(pathfinderPlugin)
+}
+
+function loadToolPlugin (bot: Bot): void {
+ if (bot.tool != null) return
+ bot.loadPlugin(toolPlugin)
+}
+
+export { CollectBlock, Callback, CollectOptions } from './CollectBlock'
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/tsconfig.json b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..a6076bc0c72a5ed65fd375450a97b2feefb28045
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/mineflayer-collectblock/tsconfig.json
@@ -0,0 +1,69 @@
+{
+ "compilerOptions": {
+ /* Visit https://aka.ms/tsconfig.json to read more about this file */
+ /* Basic Options */
+ // "incremental": true, /* Enable incremental compilation */
+ "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
+ "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
+ // "lib": [], /* Specify library files to be included in the compilation. */
+ "allowJs": true, /* Allow javascript files to be compiled. */
+ "checkJs": true, /* Report errors in .js files. */
+ // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+ "declaration": true,
+ // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
+ // "sourceMap": true, /* Generates corresponding '.map' file. */
+ // "outFile": "./", /* Concatenate and emit output to single file. */
+ "outDir": "./lib",
+ // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+ // "composite": true, /* Enable project compilation */
+ // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
+ // "removeComments": true, /* Do not emit comments to output. */
+ // "noEmit": true, /* Do not emit outputs. */
+ // "importHelpers": true, /* Import emit helpers from 'tslib'. */
+ // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+ // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+ /* Strict Type-Checking Options */
+ "strict": true, /* Enable all strict type-checking options. */
+ // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
+ "strictNullChecks": true, /* Enable strict null checks. */
+ // "strictFunctionTypes": true, /* Enable strict checking of function types. */
+ // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+ // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
+ // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
+ "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
+ /* Additional Checks */
+ "noUnusedLocals": true, /* Report errors on unused locals. */
+ // "noUnusedParameters": true, /* Report errors on unused parameters. */
+ "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
+ // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
+ /* Module Resolution Options */
+ // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+ // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
+ // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+ // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
+ // "typeRoots": [], /* List of folders to include type definitions from. */
+ // "types": [], /* Type declaration files to be included in compilation. */
+ // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+ "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+ // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
+ // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
+ /* Source Map Options */
+ // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+ // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
+ // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
+ // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+ /* Experimental Options */
+ // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
+ // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
+ /* Advanced Options */
+ "skipLibCheck": true, /* Skip type checking of declaration files. */
+ "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
+ },
+ "include": [
+ "src"
+ ],
+ "exclude": [
+ "node_modules",
+ "**/__tests__/*"
+ ]
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/package.json b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..fe0a86ef2568cafb9e2bc361fcdaabe542feb9f2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/package.json
@@ -0,0 +1,38 @@
+{
+ "name": "voyager",
+ "version": "1.0.0",
+ "description": "",
+ "main": "index.js",
+ "scripts": {
+ "test": "echo \"Error: no test specified\" && exit 1"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "body-parser": "^1.20.2",
+ "express": "^4.18.2",
+ "magic-string": "^0.30.0",
+ "minecraft-data": "^3.31.0",
+ "minecrafthawkeye": "=1.3.6",
+ "mineflayer": "^4.8.1",
+ "mineflayer-collectblock": "file:mineflayer-collectblock",
+ "mineflayer-pathfinder": "^2.4.2",
+ "mineflayer-pvp": "^1.3.2",
+ "mineflayer-tool": "^1.2.0",
+ "mocha": "^10.2.0",
+ "prismarine-biome": "^1.3.0",
+ "prismarine-block": "=1.17.1",
+ "prismarine-entity": "^2.2.0",
+ "prismarine-item": "^1.12.1",
+ "prismarine-nbt": "^2.2.1",
+ "prismarine-recipe": "^1.3.1",
+ "prismarine-viewer": "^1.24.0",
+ "typescript": "^4.9.5",
+ "vec3": "^0.1.8",
+ "graceful-fs": "^4.2.11"
+ },
+ "devDependencies": {
+ "prettier": "2.8.5"
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/yarn.lock b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/yarn.lock
new file mode 100644
index 0000000000000000000000000000000000000000..16ae1954fd47c038c621ecc116ea9c688172eae2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/mineflayer/yarn.lock
@@ -0,0 +1,1952 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@azure/msal-common@14.16.1":
+ version "14.16.1"
+ resolved "https://registry.yarnpkg.com/@azure/msal-common/-/msal-common-14.16.1.tgz#d23ecce40823a4d03ad74160dc819d62e0c0a787"
+ integrity sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w==
+
+"@azure/msal-node@^2.0.2":
+ version "2.16.3"
+ resolved "https://registry.yarnpkg.com/@azure/msal-node/-/msal-node-2.16.3.tgz#8b805152ad780b048d6c2b33b1d4e26d7b463368"
+ integrity sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw==
+ dependencies:
+ "@azure/msal-common" "14.16.1"
+ jsonwebtoken "^9.0.0"
+ uuid "^8.3.0"
+
+"@jridgewell/sourcemap-codec@^1.5.5":
+ version "1.5.5"
+ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba"
+ integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==
+
+"@socket.io/component-emitter@~3.1.0":
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
+ integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
+
+"@tweenjs/tween.js@^23.1.1":
+ version "23.1.3"
+ resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d"
+ integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==
+
+"@types/cors@^2.8.12":
+ version "2.8.19"
+ resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342"
+ integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==
+ dependencies:
+ "@types/node" "*"
+
+"@types/node-rsa@^1.1.4":
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/@types/node-rsa/-/node-rsa-1.1.4.tgz#6d2e06f8ca3619550be4fd5c12db5f2267048e20"
+ integrity sha512-dB0ECel6JpMnq5ULvpUTunx3yNm8e/dIkv8Zu9p2c8me70xIRUUG3q+qXRwcSf9rN3oqamv4116iHy90dJGRpA==
+ dependencies:
+ "@types/node" "*"
+
+"@types/node@*", "@types/node@>=10.0.0":
+ version "25.9.3"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9"
+ integrity sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==
+ dependencies:
+ undici-types ">=7.24.0 <7.24.7"
+
+"@types/node@^14.0.27":
+ version "14.18.63"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-14.18.63.tgz#1788fa8da838dbb5f9ea994b834278205db6ca2b"
+ integrity sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==
+
+"@types/rbush@^3.0.0":
+ version "3.0.4"
+ resolved "https://registry.yarnpkg.com/@types/rbush/-/rbush-3.0.4.tgz#265d84ec76dc48e464e95ae4f58e085f28f0a227"
+ integrity sha512-knSt9cCW8jj1ZSFcFeBZaX++OucmfPxxHiRwTahZfJlnQsek7O0bazTJHWD2RVj9LEoejUYF2de3/stf+QXcXw==
+
+"@types/readable-stream@^4.0.0":
+ version "4.0.23"
+ resolved "https://registry.yarnpkg.com/@types/readable-stream/-/readable-stream-4.0.23.tgz#fcd0f7472f45ceb43154f08f0083ccd1c76e53ce"
+ integrity sha512-wwXrtQvbMHxCbBgjHaMGEmImFTQxxpfMOR/ZoQnXxB1woqkUbdLGFDgauo00Py9IudiaqSeiBiulSV9i6XIPig==
+ dependencies:
+ "@types/node" "*"
+
+"@types/sat@^0.0.31":
+ version "0.0.31"
+ resolved "https://registry.yarnpkg.com/@types/sat/-/sat-0.0.31.tgz#6f0bb95f333649fb8d77ae8909e5ed104fe477bb"
+ integrity sha512-P4SVw79XheP1p92useDVpLYYOUQ6lpw2L7IdQz4dD23DZ8DiC1STgPOh72hjR5IZJBPQbzlICAbmjCKbwyYuxg==
+
+"@types/ws@^8.5.12":
+ version "8.18.1"
+ resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9"
+ integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==
+ dependencies:
+ "@types/node" "*"
+
+"@xboxreplay/xboxlive-auth@^5.1.0":
+ version "5.1.0"
+ resolved "https://registry.yarnpkg.com/@xboxreplay/xboxlive-auth/-/xboxlive-auth-5.1.0.tgz#18af63fcff899a7b7627fd32dcf0bd19ed32eb9a"
+ integrity sha512-UngHHsehZbiTjyyNmo8HvdoUDKMID1U9uVfrpFWUK/2UxPuVTKy5n+CzZQ3S488sW5vOhgh0lHqqynT8ouwgvw==
+
+abort-controller@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
+ integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+ dependencies:
+ event-target-shim "^5.0.0"
+
+accepts@~1.3.4, accepts@~1.3.8:
+ version "1.3.8"
+ resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+ integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+ dependencies:
+ mime-types "~2.1.34"
+ negotiator "0.6.3"
+
+aes-js@^3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/aes-js/-/aes-js-3.1.2.tgz#db9aabde85d5caabbfc0d4f2a4446960f627146a"
+ integrity sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==
+
+ajv@^6.5.4:
+ version "6.15.0"
+ resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.15.0.tgz#07e982c74626167aa7a2495c53817892d7139492"
+ integrity sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==
+ dependencies:
+ fast-deep-equal "^3.1.1"
+ fast-json-stable-stringify "^2.0.0"
+ json-schema-traverse "^0.4.1"
+ uri-js "^4.2.2"
+
+ansi-colors@^4.1.3:
+ version "4.1.3"
+ resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b"
+ integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==
+
+ansi-regex@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+ integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+ integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+ dependencies:
+ color-convert "^2.0.1"
+
+anymatch@~3.1.2:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+ integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+ dependencies:
+ normalize-path "^3.0.0"
+ picomatch "^2.0.4"
+
+argparse@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+ integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
+array-flatten@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2"
+ integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==
+
+asn1@0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.3.tgz#dac8787713c9966849fc8180777ebe9c1ddf3b86"
+ integrity sha512-6i37w/+EhlWlGUJff3T/Q8u1RGmP5wgbiwYnOnbOqvtrPxT63/sYFyP9RcpxtxGymtfA075IvmOnL7ycNOWl3w==
+
+balanced-match@^1.0.0:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+ integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64-js@^1.3.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
+base64id@2.0.0, base64id@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+ integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
+binary-extensions@^2.0.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522"
+ integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==
+
+body-parser@^1.20.2, body-parser@~1.20.5:
+ version "1.20.5"
+ resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.5.tgz#303c8c34423d1d6fa799bc764e93c1e4dc6ebf64"
+ integrity sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==
+ dependencies:
+ bytes "~3.1.2"
+ content-type "~1.0.5"
+ debug "2.6.9"
+ depd "2.0.0"
+ destroy "~1.2.0"
+ http-errors "~2.0.1"
+ iconv-lite "~0.4.24"
+ on-finished "~2.4.1"
+ qs "~6.15.1"
+ raw-body "~2.5.3"
+ type-is "~1.6.18"
+ unpipe "~1.0.0"
+
+brace-expansion@^2.0.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.1.tgz#c68b1c4111c76aae3a6fba55d496cee10c39dad8"
+ integrity sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==
+ dependencies:
+ balanced-match "^1.0.0"
+
+braces@~3.0.2:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789"
+ integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==
+ dependencies:
+ fill-range "^7.1.1"
+
+browser-stdout@^1.3.1:
+ version "1.3.1"
+ resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
+ integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
+
+buffer-equal-constant-time@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
+ integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==
+
+buffer-equal@^1.0.0:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/buffer-equal/-/buffer-equal-1.0.1.tgz#2f7651be5b1b3f057fcd6e7ee16cf34767077d90"
+ integrity sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==
+
+buffer@^6.0.3:
+ version "6.0.3"
+ resolved "https://registry.yarnpkg.com/buffer/-/buffer-6.0.3.tgz#2ace578459cc8fbe2a70aaa8f52ee63b6a74c6c6"
+ integrity sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==
+ dependencies:
+ base64-js "^1.3.1"
+ ieee754 "^1.2.1"
+
+bytes@3.1.2, bytes@~3.1.2:
+ version "3.1.2"
+ resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+ integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
+ integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
+ dependencies:
+ es-errors "^1.3.0"
+ function-bind "^1.1.2"
+
+call-bound@^1.0.2:
+ version "1.0.4"
+ resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a"
+ integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ get-intrinsic "^1.3.0"
+
+camelcase@^6.0.0:
+ version "6.3.0"
+ resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+ integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
+chalk@^4.1.0:
+ version "4.1.2"
+ resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+ integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+ dependencies:
+ ansi-styles "^4.1.0"
+ supports-color "^7.1.0"
+
+chokidar@^3.5.3:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
+ integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
+ dependencies:
+ anymatch "~3.1.2"
+ braces "~3.0.2"
+ glob-parent "~5.1.2"
+ is-binary-path "~2.1.0"
+ is-glob "~4.0.1"
+ normalize-path "~3.0.0"
+ readdirp "~3.6.0"
+ optionalDependencies:
+ fsevents "~2.3.2"
+
+cliui@^7.0.2:
+ version "7.0.4"
+ resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
+ integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
+ dependencies:
+ string-width "^4.2.0"
+ strip-ansi "^6.0.0"
+ wrap-ansi "^7.0.0"
+
+color-convert@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+ integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+ dependencies:
+ color-name "~1.1.4"
+
+color-name@~1.1.4:
+ version "1.1.4"
+ resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+ integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+commander@^2.19.0:
+ version "2.20.3"
+ resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+ integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+compressible@~2.0.18:
+ version "2.0.18"
+ resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba"
+ integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==
+ dependencies:
+ mime-db ">= 1.43.0 < 2"
+
+compression@^1.7.4:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/compression/-/compression-1.8.1.tgz#4a45d909ac16509195a9a28bd91094889c180d79"
+ integrity sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==
+ dependencies:
+ bytes "3.1.2"
+ compressible "~2.0.18"
+ debug "2.6.9"
+ negotiator "~0.6.4"
+ on-headers "~1.1.0"
+ safe-buffer "5.2.1"
+ vary "~1.1.2"
+
+content-disposition@~0.5.4:
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
+ integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==
+ dependencies:
+ safe-buffer "5.2.1"
+
+content-type@~1.0.4, content-type@~1.0.5:
+ version "1.0.5"
+ resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+ integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
+
+cookie-signature@~1.0.6:
+ version "1.0.7"
+ resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454"
+ integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==
+
+cookie@~0.7.1, cookie@~0.7.2:
+ version "0.7.2"
+ resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7"
+ integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==
+
+cors@~2.8.5:
+ version "2.8.6"
+ resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96"
+ integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==
+ dependencies:
+ object-assign "^4"
+ vary "^1"
+
+debug@2.6.9:
+ version "2.6.9"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+ integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+ dependencies:
+ ms "2.0.0"
+
+debug@^4.3.2, debug@^4.3.3, debug@^4.3.5, debug@~4.4.1:
+ version "4.4.3"
+ resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
+ integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
+ dependencies:
+ ms "^2.1.3"
+
+decamelize@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
+ integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==
+
+depd@2.0.0, depd@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+ integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+destroy@1.2.0, destroy@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+ integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+detect-collisions@^7.0.5:
+ version "7.0.5"
+ resolved "https://registry.yarnpkg.com/detect-collisions/-/detect-collisions-7.0.5.tgz#96f3a4b5d63c82a24a9a112eb8c020b5573d1830"
+ integrity sha512-RiebSYJ9Vok1uVm57bxpI69EO2ySK1rdcHpRv28IDBh/wgaF8wxjoZJV7mmqz8MOKioLwMHvcMyP1R0CLZ+RbA==
+ dependencies:
+ "@types/rbush" "^3.0.0"
+ "@types/sat" "^0.0.31"
+ poly-decomp "^0.3.0"
+ rbush "^3.0.1"
+ sat "^0.9.0"
+
+diff@^5.2.0:
+ version "5.2.2"
+ resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.2.tgz#0a4742797281d09cfa699b79ea32d27723623bad"
+ integrity sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==
+
+discontinuous-range@1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/discontinuous-range/-/discontinuous-range-1.0.0.tgz#e38331f0844bba49b9a9cb71c771585aab1bc65a"
+ integrity sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==
+
+dunder-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
+ integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
+ dependencies:
+ call-bind-apply-helpers "^1.0.1"
+ es-errors "^1.3.0"
+ gopd "^1.2.0"
+
+ecdsa-sig-formatter@1.0.11:
+ version "1.0.11"
+ resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
+ integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
+ dependencies:
+ safe-buffer "^5.0.1"
+
+ee-first@1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+ integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+emoji-regex@^8.0.0:
+ version "8.0.0"
+ resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+ integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+encodeurl@~2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
+ integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
+
+endian-toggle@^0.0.0:
+ version "0.0.0"
+ resolved "https://registry.yarnpkg.com/endian-toggle/-/endian-toggle-0.0.0.tgz#e5cc7578b1032d6ee01eafcd73765db0db4dc0a6"
+ integrity sha512-ShfqhXeHRE4TmggSlHXG8CMGIcsOsqDw/GcoPcosToE59Rm9e4aXaMhEQf2kPBsBRrKem1bbOAv5gOKnkliMFQ==
+
+engine.io-client@~6.6.1:
+ version "6.6.6"
+ resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.6.6.tgz#8a8f1e451b1f6d4acf413305445e42133d1cbe9a"
+ integrity sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.4.1"
+ engine.io-parser "~5.2.1"
+ ws "~8.21.0"
+ xmlhttprequest-ssl "~2.1.1"
+
+engine.io-parser@~5.2.1:
+ version "5.2.3"
+ resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f"
+ integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==
+
+engine.io@~6.6.0:
+ version "6.6.9"
+ resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.9.tgz#fd17e9f4e3a256423592574b60ac262e91af4b82"
+ integrity sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==
+ dependencies:
+ "@types/cors" "^2.8.12"
+ "@types/node" ">=10.0.0"
+ "@types/ws" "^8.5.12"
+ accepts "~1.3.4"
+ base64id "2.0.0"
+ cookie "~0.7.2"
+ cors "~2.8.5"
+ debug "~4.4.1"
+ engine.io-parser "~5.2.1"
+ ws "~8.21.0"
+
+es-define-property@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
+ integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
+
+es-errors@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+ integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
+es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
+ integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
+ dependencies:
+ es-errors "^1.3.0"
+
+escalade@^3.1.1:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5"
+ integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==
+
+escape-html@~1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+ integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+escape-string-regexp@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+ integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+etag@~1.8.1:
+ version "1.8.1"
+ resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
+ integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
+
+event-target-shim@^5.0.0:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
+ integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
+events@^3.3.0:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
+ integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
+
+express@^4.17.1, express@^4.18.2:
+ version "4.22.2"
+ resolved "https://registry.yarnpkg.com/express/-/express-4.22.2.tgz#c17ae0981e5efc24b22272f0e041c4662503b700"
+ integrity sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==
+ dependencies:
+ accepts "~1.3.8"
+ array-flatten "1.1.1"
+ body-parser "~1.20.5"
+ content-disposition "~0.5.4"
+ content-type "~1.0.4"
+ cookie "~0.7.1"
+ cookie-signature "~1.0.6"
+ debug "2.6.9"
+ depd "2.0.0"
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ finalhandler "~1.3.1"
+ fresh "~0.5.2"
+ http-errors "~2.0.0"
+ merge-descriptors "1.0.3"
+ methods "~1.1.2"
+ on-finished "~2.4.1"
+ parseurl "~1.3.3"
+ path-to-regexp "~0.1.12"
+ proxy-addr "~2.0.7"
+ qs "~6.15.1"
+ range-parser "~1.2.1"
+ safe-buffer "5.2.1"
+ send "~0.19.0"
+ serve-static "~1.16.2"
+ setprototypeof "1.2.0"
+ statuses "~2.0.1"
+ type-is "~1.6.18"
+ utils-merge "1.0.1"
+ vary "~1.1.2"
+
+fast-deep-equal@^3.1.1:
+ version "3.1.3"
+ resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+ integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-json-stable-stringify@^2.0.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+ integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fill-range@^7.1.1:
+ version "7.1.1"
+ resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
+ integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
+ dependencies:
+ to-regex-range "^5.0.1"
+
+finalhandler@~1.3.1:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88"
+ integrity sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==
+ dependencies:
+ debug "2.6.9"
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ on-finished "~2.4.1"
+ parseurl "~1.3.3"
+ statuses "~2.0.2"
+ unpipe "~1.0.0"
+
+find-up@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+ integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
+ dependencies:
+ locate-path "^6.0.0"
+ path-exists "^4.0.0"
+
+flat@^5.0.2:
+ version "5.0.2"
+ resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
+ integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
+
+forwarded@0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811"
+ integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==
+
+fresh@~0.5.2:
+ version "0.5.2"
+ resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7"
+ integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==
+
+fs.realpath@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+ integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
+fsevents@~2.3.2:
+ version "2.3.3"
+ resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+ integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+ integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+get-caller-file@^2.0.5:
+ version "2.0.5"
+ resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+ integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
+get-intrinsic@^1.2.5, get-intrinsic@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
+ integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
+ dependencies:
+ call-bind-apply-helpers "^1.0.2"
+ es-define-property "^1.0.1"
+ es-errors "^1.3.0"
+ es-object-atoms "^1.1.1"
+ function-bind "^1.1.2"
+ get-proto "^1.0.1"
+ gopd "^1.2.0"
+ has-symbols "^1.1.0"
+ hasown "^2.0.2"
+ math-intrinsics "^1.1.0"
+
+get-proto@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
+ integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
+ dependencies:
+ dunder-proto "^1.0.1"
+ es-object-atoms "^1.0.0"
+
+glob-parent@~5.1.2:
+ version "5.1.2"
+ resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+ integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+ dependencies:
+ is-glob "^4.0.1"
+
+glob@^8.1.0:
+ version "8.1.0"
+ resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e"
+ integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==
+ dependencies:
+ fs.realpath "^1.0.0"
+ inflight "^1.0.4"
+ inherits "2"
+ minimatch "^5.0.1"
+ once "^1.3.0"
+
+gopd@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
+ integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
+
+graceful-fs@^4.2.11:
+ version "4.2.11"
+ resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+ integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
+has-flag@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+ integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-symbols@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
+ integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
+
+hasown@^2.0.2:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
+ integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
+ dependencies:
+ function-bind "^1.1.2"
+
+he@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+ integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+http-errors@~2.0.0, http-errors@~2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b"
+ integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==
+ dependencies:
+ depd "~2.0.0"
+ inherits "~2.0.4"
+ setprototypeof "~1.2.0"
+ statuses "~2.0.2"
+ toidentifier "~1.0.1"
+
+iconv-lite@~0.4.24:
+ version "0.4.24"
+ resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+ integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+ dependencies:
+ safer-buffer ">= 2.1.2 < 3"
+
+ieee754@^1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
+ integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==
+
+inflight@^1.0.4:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+ integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+ dependencies:
+ once "^1.3.0"
+ wrappy "1"
+
+inherits@2, inherits@~2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+ integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+ipaddr.js@1.9.1:
+ version "1.9.1"
+ resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
+ integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==
+
+is-binary-path@~2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+ integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+ dependencies:
+ binary-extensions "^2.0.0"
+
+is-extglob@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+ integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-fullwidth-code-point@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+ integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-glob@^4.0.1, is-glob@~4.0.1:
+ version "4.0.3"
+ resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+ integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+ dependencies:
+ is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+ integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-plain-obj@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+ integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
+is-unicode-supported@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
+ integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
+
+js-yaml@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524"
+ integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==
+ dependencies:
+ argparse "^2.0.1"
+
+json-schema-traverse@^0.4.1:
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+ integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+jsonwebtoken@^9.0.0:
+ version "9.0.3"
+ resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2"
+ integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==
+ dependencies:
+ jws "^4.0.1"
+ lodash.includes "^4.3.0"
+ lodash.isboolean "^3.0.3"
+ lodash.isinteger "^4.0.4"
+ lodash.isnumber "^3.0.3"
+ lodash.isplainobject "^4.0.6"
+ lodash.isstring "^4.0.1"
+ lodash.once "^4.0.0"
+ ms "^2.1.1"
+ semver "^7.5.4"
+
+jwa@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804"
+ integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==
+ dependencies:
+ buffer-equal-constant-time "^1.0.1"
+ ecdsa-sig-formatter "1.0.11"
+ safe-buffer "^5.0.1"
+
+jws@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690"
+ integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==
+ dependencies:
+ jwa "^2.0.1"
+ safe-buffer "^5.0.1"
+
+locate-path@^6.0.0:
+ version "6.0.0"
+ resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
+ integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+ dependencies:
+ p-locate "^5.0.0"
+
+lodash.includes@^4.3.0:
+ version "4.3.0"
+ resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
+ integrity sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==
+
+lodash.isboolean@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
+ integrity sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==
+
+lodash.isinteger@^4.0.4:
+ version "4.0.4"
+ resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
+ integrity sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==
+
+lodash.isnumber@^3.0.3:
+ version "3.0.3"
+ resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
+ integrity sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==
+
+lodash.isplainobject@^4.0.6:
+ version "4.0.6"
+ resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
+ integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==
+
+lodash.isstring@^4.0.1:
+ version "4.0.1"
+ resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
+ integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==
+
+lodash.merge@^4.3.0:
+ version "4.6.2"
+ resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
+ integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
+lodash.once@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+ integrity sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==
+
+lodash.reduce@^4.6.0:
+ version "4.6.0"
+ resolved "https://registry.yarnpkg.com/lodash.reduce/-/lodash.reduce-4.6.0.tgz#f1ab6b839299ad48f784abbf476596f03b914d3b"
+ integrity sha512-6raRe2vxCYBhpBu+B+TtNGUzah+hQjVdu3E17wfusjyrXBka2nBS8OH/gjVZ5PvHOhWmIZTYri09Z6n/QfnNMw==
+
+log-symbols@^4.1.0:
+ version "4.1.0"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"
+ integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
+ dependencies:
+ chalk "^4.1.0"
+ is-unicode-supported "^0.1.0"
+
+macaddress@^0.5.1:
+ version "0.5.4"
+ resolved "https://registry.yarnpkg.com/macaddress/-/macaddress-0.5.4.tgz#996d87267fca101fa0f49c4ae657c79cf19b43c8"
+ integrity sha512-i8xVWoUjj2woYU8kbpQby86Kq7uF7xl2brtKREXUBWpfgqx1fKXEeYzDiVMVxA/IufC1d3xxwJRHtFCX+9IspA==
+
+magic-string@^0.30.0:
+ version "0.30.21"
+ resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.21.tgz#56763ec09a0fa8091df27879fd94d19078c00d91"
+ integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==
+ dependencies:
+ "@jridgewell/sourcemap-codec" "^1.5.5"
+
+math-intrinsics@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
+ integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
+
+media-typer@0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+ integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge-descriptors@1.0.3:
+ version "1.0.3"
+ resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5"
+ integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==
+
+methods@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
+ integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==
+
+mime-db@1.52.0:
+ version "1.52.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+ integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+"mime-db@>= 1.43.0 < 2":
+ version "1.54.0"
+ resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5"
+ integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==
+
+mime-types@~2.1.24, mime-types@~2.1.34:
+ version "2.1.35"
+ resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+ integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+ dependencies:
+ mime-db "1.52.0"
+
+mime@1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1"
+ integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==
+
+minecraft-data@^2.70.0:
+ version "2.221.0"
+ resolved "https://registry.yarnpkg.com/minecraft-data/-/minecraft-data-2.221.0.tgz#b6a6629c9c33bc640e540de3ebe94a96d0e8b0bc"
+ integrity sha512-0AhqzbIKb6WqPSF6qBevaPryeWOz545hLxt6q+gfJF8YIQX/YfkyX/nXWhl+pSIS2rTBcQ0RJkRCtTeRzQwHDA==
+
+minecraft-data@^3.0.0, minecraft-data@^3.108.0, minecraft-data@^3.31.0, minecraft-data@^3.38.0, minecraft-data@^3.5.1, minecraft-data@^3.70.0, minecraft-data@^3.78.0:
+ version "3.110.2"
+ resolved "https://registry.yarnpkg.com/minecraft-data/-/minecraft-data-3.110.2.tgz#5a22e89db1fcbe3fbfa5478cb55c035c55ade9f4"
+ integrity sha512-u0aCCSpQWVreGnZGU/Lu0jmZmc0Y37M0Fvw6eQVQY0BdS/BGRDDU+ug6/qP3QDuZRJCSzi8wNW8ODnOhwpnkpA==
+
+minecraft-folder-path@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/minecraft-folder-path/-/minecraft-folder-path-1.2.0.tgz#08ea450bfb20dbb02cf6e1470864e2f0d652e477"
+ integrity sha512-qaUSbKWoOsH9brn0JQuBhxNAzTDMwrOXorwuRxdJKKKDYvZhtml+6GVCUrY5HRiEsieBEjCUnhVpDuQiKsiFaw==
+
+minecraft-protocol@^1.17.0, minecraft-protocol@^1.66.0:
+ version "1.66.2"
+ resolved "https://registry.yarnpkg.com/minecraft-protocol/-/minecraft-protocol-1.66.2.tgz#f855d8a4ad6f71cda84fde820a3a433e941cda07"
+ integrity sha512-keY1IY1E2AeurcekCfcXrg0TDbykGVFiMe1E4wR8QkNtQRieNwfr2xaF3g3vT9ChkwzvENqp3jxgmtFCKSUKPg==
+ dependencies:
+ "@types/node-rsa" "^1.1.4"
+ "@types/readable-stream" "^4.0.0"
+ aes-js "^3.1.2"
+ buffer-equal "^1.0.0"
+ debug "^4.3.2"
+ endian-toggle "^0.0.0"
+ lodash.merge "^4.3.0"
+ minecraft-data "^3.78.0"
+ minecraft-folder-path "^1.2.0"
+ node-fetch "^2.6.1"
+ node-rsa "^0.4.2"
+ prismarine-auth "^3.1.1"
+ prismarine-chat "^1.10.0"
+ prismarine-nbt "^2.5.0"
+ prismarine-realms "^1.2.0"
+ protodef "^1.17.0"
+ readable-stream "^4.1.0"
+ uuid-1345 "^1.0.1"
+ yggdrasil "^1.4.0"
+
+minecrafthawkeye@=1.3.6:
+ version "1.3.6"
+ resolved "https://registry.yarnpkg.com/minecrafthawkeye/-/minecrafthawkeye-1.3.6.tgz#1ba2e3944a93072118089c6a345e4a12ff559be2"
+ integrity sha512-SlRlorxQs6nNzMiiIQ5z47wzbAI27UaCdbRB82CE8jqj4C8m3Gqk5TlgN+PSThxx8EDPXySzd8Vk+/wNigAd5A==
+ dependencies:
+ detect-collisions "^7.0.5"
+ vec3 "^0.1.8"
+
+"mineflayer-collectblock@file:mineflayer-collectblock":
+ version "1.4.1"
+ dependencies:
+ mineflayer "^4.0.0"
+ mineflayer-pathfinder "^2.1.1"
+ mineflayer-tool "^1.1.0"
+
+mineflayer-pathfinder@^2.0.0, mineflayer-pathfinder@^2.1.1, mineflayer-pathfinder@^2.4.2:
+ version "2.4.5"
+ resolved "https://registry.yarnpkg.com/mineflayer-pathfinder/-/mineflayer-pathfinder-2.4.5.tgz#3eb73ef5c2ef94e4d59f5241993329ba8d44118b"
+ integrity sha512-Jh3JnUgRLwhMh2Dugo4SPza68C41y+NPP5sdsgxRu35ydndo70i1JJGxauVWbXrpNwIxYNztUw78aFyb7icw8g==
+ dependencies:
+ minecraft-data "^3.5.1"
+ prismarine-block "^1.16.3"
+ prismarine-entity "^2.1.1"
+ prismarine-item "^1.11.5"
+ prismarine-nbt "^2.2.1"
+ prismarine-physics "^1.5.2"
+ vec3 "^0.1.7"
+
+mineflayer-pvp@^1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/mineflayer-pvp/-/mineflayer-pvp-1.3.2.tgz#16091fad1b24f77276b0254a308725d7ea29f042"
+ integrity sha512-CI2T5w4ceiQdQRCFdrCs3OYmvO9G0cOx2qovUt6f27gknpVI90Ihe1qYhf+zBZJCg9uP1oJ1Pemlzw46Mm3wcA==
+ dependencies:
+ mineflayer "^4.0.0"
+ mineflayer-pathfinder "^2.0.0"
+ mineflayer-utils "^0.1.4"
+
+mineflayer-tool@^1.1.0, mineflayer-tool@^1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/mineflayer-tool/-/mineflayer-tool-1.2.0.tgz#a4c27594f7797a4254ee94bbc9ee4620c4253b63"
+ integrity sha512-TRd7JOX4obJnzqzVi+4MLKmbeeCDjp9taerLKM8+AGpWfgtdbAguGmsr3BHu+TdXxOIT05veYecCv77S3KX6PA==
+ dependencies:
+ mineflayer "^4.0.0"
+ mineflayer-pathfinder "^2.1.1"
+ prismarine-nbt "^2.0.0"
+
+mineflayer-utils@^0.1.4:
+ version "0.1.4"
+ resolved "https://registry.yarnpkg.com/mineflayer-utils/-/mineflayer-utils-0.1.4.tgz#3f9cb4dd049bd8ee0c4266123e23472a69d32a2b"
+ integrity sha512-8+0dbGAjA6FO62/W80v5k44AuSAcwJUMdpMAAGhjI9AdCDz+UuyTnNkPBncF4EcLY7yUUdtA7m/OXQZKFdWOlg==
+ dependencies:
+ "@types/node" "^14.0.27"
+ mineflayer "^2.27.0"
+ prismarine-entity "^1.0.0"
+ require-self "^0.2.3"
+ typescript "^3.9.7"
+
+mineflayer@^2.27.0:
+ version "2.41.0"
+ resolved "https://registry.yarnpkg.com/mineflayer/-/mineflayer-2.41.0.tgz#1ab8e813077daa7cf6a535ca542e159de14a489e"
+ integrity sha512-IFFy4NgF24FU2PkAwazJphl2F+3gpbpN578ex0sq1XfcBBRge3kCz1UC2KDMjKI+V/8vffOL+OEnug9jt3f7Vw==
+ dependencies:
+ minecraft-data "^2.70.0"
+ minecraft-protocol "^1.17.0"
+ prismarine-biome "^1.1.0"
+ prismarine-block "^1.6.0"
+ prismarine-chat "^1.0.0"
+ prismarine-chunk "^1.20.3"
+ prismarine-entity "^1.0.0"
+ prismarine-item "^1.5.0"
+ prismarine-physics "^1.0.4"
+ prismarine-recipe "^1.1.0"
+ prismarine-windows "^1.5.0"
+ prismarine-world "^3.2.0"
+ protodef "^1.8.0"
+ typed-emitter "^1.2.0"
+ vec3 "^0.1.6"
+
+mineflayer@^4.0.0, mineflayer@^4.8.1:
+ version "4.37.1"
+ resolved "https://registry.yarnpkg.com/mineflayer/-/mineflayer-4.37.1.tgz#d4b32b8aecbc08dbfe24e1551fa24a57be601e14"
+ integrity sha512-kchZCJb1znzz8ZhE0+gLQ3e2t/9xUsqUy/IM/sGfceINxi3h6KXKY9luaUEa59vnD/x0OKwYdERY4sscm0ErNQ==
+ dependencies:
+ minecraft-data "^3.108.0"
+ minecraft-protocol "^1.66.0"
+ mojangson "^2.0.4"
+ prismarine-biome "^1.1.1"
+ prismarine-block "^1.22.0"
+ prismarine-chat "^1.7.1"
+ prismarine-chunk "^1.39.0"
+ prismarine-entity "^2.5.0"
+ prismarine-item "^1.17.0"
+ prismarine-nbt "^2.0.0"
+ prismarine-physics "^1.9.0"
+ prismarine-recipe "^1.5.0"
+ prismarine-registry "^1.10.0"
+ prismarine-windows "^2.9.0"
+ prismarine-world "^3.6.0"
+ protodef "^1.18.0"
+ typed-emitter "^1.0.0"
+ uuid-1345 "^1.0.2"
+ vec3 "^0.1.7"
+
+minimatch@^5.0.1, minimatch@^5.1.6:
+ version "5.1.9"
+ resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.9.tgz#1293ef15db0098b394540e8f9f744f9fda8dee4b"
+ integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==
+ dependencies:
+ brace-expansion "^2.0.1"
+
+mocha@^10.2.0:
+ version "10.8.2"
+ resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.8.2.tgz#8d8342d016ed411b12a429eb731b825f961afb96"
+ integrity sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg==
+ dependencies:
+ ansi-colors "^4.1.3"
+ browser-stdout "^1.3.1"
+ chokidar "^3.5.3"
+ debug "^4.3.5"
+ diff "^5.2.0"
+ escape-string-regexp "^4.0.0"
+ find-up "^5.0.0"
+ glob "^8.1.0"
+ he "^1.2.0"
+ js-yaml "^4.1.0"
+ log-symbols "^4.1.0"
+ minimatch "^5.1.6"
+ ms "^2.1.3"
+ serialize-javascript "^6.0.2"
+ strip-json-comments "^3.1.1"
+ supports-color "^8.1.1"
+ workerpool "^6.5.1"
+ yargs "^16.2.0"
+ yargs-parser "^20.2.9"
+ yargs-unparser "^2.0.0"
+
+mojangson@^2.0.1, mojangson@^2.0.4:
+ version "2.0.4"
+ resolved "https://registry.yarnpkg.com/mojangson/-/mojangson-2.0.4.tgz#ddf2f2ea17580222b52ce00bf7323713d72c25b6"
+ integrity sha512-HYmhgDjr1gzF7trGgvcC/huIg2L8FsVbi/KacRe6r1AswbboGVZDS47SOZlomPuMWvZLas8m9vuHHucdZMwTmQ==
+ dependencies:
+ nearley "^2.19.5"
+
+moo@^0.5.0:
+ version "0.5.3"
+ resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.3.tgz#dfcdb40ff6f1a03c34af5df7f9717cecfa88c38c"
+ integrity sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==
+
+ms@2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+ integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
+ms@2.1.3, ms@^2.1.1, ms@^2.1.3:
+ version "2.1.3"
+ resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+ integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nearley@^2.19.5:
+ version "2.20.1"
+ resolved "https://registry.yarnpkg.com/nearley/-/nearley-2.20.1.tgz#246cd33eff0d012faf197ff6774d7ac78acdd474"
+ integrity sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==
+ dependencies:
+ commander "^2.19.0"
+ moo "^0.5.0"
+ railroad-diagrams "^1.0.0"
+ randexp "0.4.6"
+
+negotiator@0.6.3:
+ version "0.6.3"
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+ integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+negotiator@~0.6.4:
+ version "0.6.4"
+ resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.4.tgz#777948e2452651c570b712dd01c23e262713fff7"
+ integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==
+
+node-fetch@^2.6.1:
+ version "2.7.0"
+ resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d"
+ integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==
+ dependencies:
+ whatwg-url "^5.0.0"
+
+node-rsa@^0.4.2:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/node-rsa/-/node-rsa-0.4.2.tgz#d6391729ec16a830ed5a38042b3157d2d5d72530"
+ integrity sha512-Bvso6Zi9LY4otIZefYrscsUpo2mUpiAVIEmSZV2q41sP8tHZoert3Yu6zv4f/RXJqMNZQKCtnhDugIuCma23YA==
+ dependencies:
+ asn1 "0.2.3"
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+ integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-assign@^4:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+ integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-inspect@^1.13.3, object-inspect@^1.13.4:
+ version "1.13.4"
+ resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213"
+ integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==
+
+on-finished@~2.4.1:
+ version "2.4.1"
+ resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+ integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+ dependencies:
+ ee-first "1.1.1"
+
+on-headers@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.1.0.tgz#59da4f91c45f5f989c6e4bcedc5a3b0aed70ff65"
+ integrity sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==
+
+once@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+ integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+ dependencies:
+ wrappy "1"
+
+p-limit@^3.0.2:
+ version "3.1.0"
+ resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+ integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+ dependencies:
+ yocto-queue "^0.1.0"
+
+p-locate@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+ integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+ dependencies:
+ p-limit "^3.0.2"
+
+parseurl@~1.3.3:
+ version "1.3.3"
+ resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+ integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-exists@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+ integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-to-regexp@~0.1.12:
+ version "0.1.13"
+ resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d"
+ integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==
+
+picomatch@^2.0.4, picomatch@^2.2.1:
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
+ integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
+
+poly-decomp@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/poly-decomp/-/poly-decomp-0.3.0.tgz#aa499289bbc1a4ca2213e966587fa5bffc1ca5f5"
+ integrity sha512-hWeBxGzPYiybmI4548Fca7Up/0k1qS5+79cVHI9+H33dKya5YNb9hxl0ZnDaDgvrZSuYFBhkCK/HOnqN7gefkQ==
+
+prettier@2.8.5:
+ version "2.8.5"
+ resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.5.tgz#3dd8ae1ebddc4f6aa419c9b64d8c8319a7e0d982"
+ integrity sha512-3gzuxrHbKUePRBB4ZeU08VNkUcqEHaUaouNt0m7LGP4Hti/NuB07C7PPTM/LkWqXoJYJn2McEo5+kxPNrtQkLQ==
+
+prismarine-auth@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/prismarine-auth/-/prismarine-auth-3.1.1.tgz#70b904f92f911984a08a3b631d0fe4c053ccbb69"
+ integrity sha512-NuNrMGZdoigFKsvi1ZZgAEvNYNuE5qe6lo/tw+bqeNbkhpjHC0u1JNxLEujnfqduXI18e19PvUtWNMDl/gH7yw==
+ dependencies:
+ "@azure/msal-node" "^2.0.2"
+ "@xboxreplay/xboxlive-auth" "^5.1.0"
+ debug "^4.3.3"
+ smart-buffer "^4.1.0"
+ uuid-1345 "^1.0.2"
+
+prismarine-biome@^1.1.0, prismarine-biome@^1.1.1, prismarine-biome@^1.2.0, prismarine-biome@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/prismarine-biome/-/prismarine-biome-1.4.0.tgz#b82f05216991e4927040090ce9ebc3732c960e40"
+ integrity sha512-fD2WmjN8Zr/xA/jeMInReLgaDlznwA5xlaK529PzWuGzgjpc5ijVu1Lp1oqHyZn3WxOG/bRVtW1bU+tmgCurWA==
+
+prismarine-block@=1.17.1:
+ version "1.17.1"
+ resolved "https://registry.yarnpkg.com/prismarine-block/-/prismarine-block-1.17.1.tgz#0c8b338f8a7bc84c7605b77956110e2a2c157de2"
+ integrity sha512-r1TIn/b5v77BX4a+qd+Yv+4/vZpsC/Jp5ElYxd6++2wpCnqiuxVG7BlS2Eo14vez1M2gt3qoNEl54Hr8qox/rQ==
+ dependencies:
+ minecraft-data "^3.38.0"
+ prismarine-biome "^1.1.0"
+ prismarine-chat "^1.5.0"
+ prismarine-item "^1.10.1"
+ prismarine-nbt "^2.0.0"
+ prismarine-registry "^1.1.0"
+
+prismarine-block@^1.14.1, prismarine-block@^1.16.3, prismarine-block@^1.17.1, prismarine-block@^1.22.0, prismarine-block@^1.6.0, prismarine-block@^1.7.3:
+ version "1.23.0"
+ resolved "https://registry.yarnpkg.com/prismarine-block/-/prismarine-block-1.23.0.tgz#8aaf8b3759a59853068e781488a4f12efb4f34cd"
+ integrity sha512-j2UoU4KbXMvNlBw+aLkMOnEuMayYefznUfbrfv1VIbckG3RA9LpNWltOMHXuOR5YkHp8uIZPOclj95XC88jgGw==
+ dependencies:
+ minecraft-data "^3.38.0"
+ prismarine-biome "^1.1.0"
+ prismarine-chat "^1.5.0"
+ prismarine-item "^1.10.1"
+ prismarine-nbt "^2.0.0"
+ prismarine-registry "^1.1.0"
+
+prismarine-chat@^1.0.0, prismarine-chat@^1.10.0, prismarine-chat@^1.4.1, prismarine-chat@^1.5.0, prismarine-chat@^1.7.1:
+ version "1.13.0"
+ resolved "https://registry.yarnpkg.com/prismarine-chat/-/prismarine-chat-1.13.0.tgz#e669d810b57ced86dc0252b0ec8f5358532b9f61"
+ integrity sha512-tvDbrQmJEoy09yLE5nnedGhQYxnRDaPRePMv7W39dFaHr2LGcA2JfCmH0vG5193+BsEFz3a5+0EpQSK8OW7YmA==
+ dependencies:
+ debug "^4.3.2"
+ mojangson "^2.0.1"
+ prismarine-nbt "^2.0.0"
+ prismarine-registry "^1.4.0"
+
+prismarine-chunk@^1.20.3, prismarine-chunk@^1.22.0, prismarine-chunk@^1.39.0:
+ version "1.40.0"
+ resolved "https://registry.yarnpkg.com/prismarine-chunk/-/prismarine-chunk-1.40.0.tgz#dbfe1e151825131c49d61480e25c1dc8cc954ab6"
+ integrity sha512-TtT84Bys7+aGA94HwcK0QDp+jkWcLOLErKYtaWWl+EJya28NqPoBASr5L/lPZ8ZWLQUugg/aFIefZI/rEhEQWw==
+ dependencies:
+ prismarine-biome "^1.2.0"
+ prismarine-block "^1.14.1"
+ prismarine-nbt "^2.2.1"
+ prismarine-registry "^1.1.0"
+ smart-buffer "^4.1.0"
+ uint4 "^0.1.2"
+ vec3 "^0.1.3"
+ xxhash-wasm "^0.4.2"
+
+prismarine-entity@^1.0.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/prismarine-entity/-/prismarine-entity-1.2.0.tgz#1c87a4186b1a2ea33465d1b627944b5575ab0038"
+ integrity sha512-4dQ9LYl6HDJQrwZHjSKU4D5VNyHRnfrjcw7eVLlbRPkuR50utW5mmfPi4ys9U7tHNmGWHC/cwjH9xzT75LUovQ==
+ dependencies:
+ vec3 "^0.1.4"
+
+prismarine-entity@^2.1.1, prismarine-entity@^2.2.0, prismarine-entity@^2.5.0:
+ version "2.6.0"
+ resolved "https://registry.yarnpkg.com/prismarine-entity/-/prismarine-entity-2.6.0.tgz#c9f19de0599ad7d980e607d63a2056f765aa5eee"
+ integrity sha512-/LlZRLOpACiXk+GqoaKi0XPBFnNMjb1d4OIzuSCSEgNMK6FUo3Wnin5yeSZ7ff3Ztt7yagN9lX2jSOafn6IIzg==
+ dependencies:
+ prismarine-chat "^1.4.1"
+ prismarine-item "^1.11.2"
+ prismarine-registry "^1.4.0"
+ vec3 "^0.1.4"
+
+prismarine-item@^1.10.1, prismarine-item@^1.11.2, prismarine-item@^1.11.5, prismarine-item@^1.12.1, prismarine-item@^1.12.2, prismarine-item@^1.17.0, prismarine-item@^1.4.0, prismarine-item@^1.5.0:
+ version "1.18.0"
+ resolved "https://registry.yarnpkg.com/prismarine-item/-/prismarine-item-1.18.0.tgz#e2335f5a4b607f4fa3198483ad29e618bbed198e"
+ integrity sha512-8pEq6YfcneVvarvUFnex09a3+MR8/4NCQVyawIKAa3kh/g9dHLexoEcpQEgM3cmpg4gbLmspSiARGwed5uGhlg==
+ dependencies:
+ prismarine-nbt "^2.0.0"
+ prismarine-registry "^1.4.0"
+
+prismarine-nbt@^2.0.0, prismarine-nbt@^2.2.1, prismarine-nbt@^2.5.0:
+ version "2.8.0"
+ resolved "https://registry.yarnpkg.com/prismarine-nbt/-/prismarine-nbt-2.8.0.tgz#ac4efda170b356107b0aa178f21eb100ff78a526"
+ integrity sha512-5D6FUZq0PNtf3v/41ImDlwThVesOv5adyqCRMZLzmkUGEmRJNNh5C6AsnvrClBftXs+IF0yqPnZoj8kcNPiMGg==
+ dependencies:
+ protodef "^1.18.0"
+
+prismarine-physics@^1.0.4, prismarine-physics@^1.5.2, prismarine-physics@^1.9.0:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/prismarine-physics/-/prismarine-physics-1.11.0.tgz#e17c1445e2446315006049f5b7a7119448a81375"
+ integrity sha512-P25VSDi3kJHQAb/AJBiJCQuxyRCVXRSdEiDjx56ywocgt65N/exatVTiJjOK5HgEKHJSfw0sXSAohQhvutnGAA==
+ dependencies:
+ minecraft-data "^3.0.0"
+ prismarine-nbt "^2.0.0"
+ vec3 "^0.1.7"
+
+prismarine-realms@^1.2.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/prismarine-realms/-/prismarine-realms-1.6.0.tgz#9b9d42dd5a27fe16111b95cd159c5fd1d3c889d7"
+ integrity sha512-AwemW0vwxG9hQaFtg1twSV7eymB6QtYxGK0jjpxfdA2sdK15kU8jh8uD1o5XF0oxSMU+BbpzZMCmXtXq4QE6bw==
+ dependencies:
+ debug "^4.3.3"
+ node-fetch "^2.6.1"
+
+prismarine-recipe@^1.1.0, prismarine-recipe@^1.3.1, prismarine-recipe@^1.5.0:
+ version "1.5.0"
+ resolved "https://registry.yarnpkg.com/prismarine-recipe/-/prismarine-recipe-1.5.0.tgz#425d4217d146e99b9702c9a374ef018116f7720a"
+ integrity sha512-GRZHbsyBIUgVNF10vFRv2YWZj86vokCT5EWX6iK6gfx6h4FapgZT29V2DNkjv5+hmdzBCLZvfx1/RYr8VPeoGQ==
+
+prismarine-registry@^1.1.0, prismarine-registry@^1.10.0, prismarine-registry@^1.4.0, prismarine-registry@^1.7.0:
+ version "1.12.0"
+ resolved "https://registry.yarnpkg.com/prismarine-registry/-/prismarine-registry-1.12.0.tgz#b6cfede995fe7bd088ef6ec1e30620cf1998ccfa"
+ integrity sha512-OC5U6YrflY6OcAWRZEqe2HGZuNp0bIuP7H+oKEHD6rLfKNDxo8Ymx5eh2VvrZWnMVugpwID1Qj/UjA4MoCzNDw==
+ dependencies:
+ minecraft-data "^3.70.0"
+ prismarine-block "^1.17.1"
+ prismarine-nbt "^2.0.0"
+
+prismarine-viewer@^1.24.0:
+ version "1.33.0"
+ resolved "https://registry.yarnpkg.com/prismarine-viewer/-/prismarine-viewer-1.33.0.tgz#2166c2253b0339f22943751a30d1ab5a64e3cf1a"
+ integrity sha512-Kb+3nJkzV5a6kfR1Mqs269bxXHJA5/ZjA+vdwTwDsyZtyib9NC1ELfyTOo/HK77JB+1bS6pfrZB+wcd0+mXKXA==
+ dependencies:
+ "@tweenjs/tween.js" "^23.1.1"
+ compression "^1.7.4"
+ express "^4.17.1"
+ minecraft-data "^3.0.0"
+ prismarine-block "^1.7.3"
+ prismarine-chunk "^1.22.0"
+ prismarine-world "^3.3.1"
+ socket.io "^4.0.0"
+ socket.io-client "^4.0.0"
+ three "0.128.0"
+ three.meshline "^1.3.0"
+ vec3 "^0.1.7"
+
+prismarine-windows@^1.5.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/prismarine-windows/-/prismarine-windows-1.6.0.tgz#7e19759d343bb484472447e90d8f688a523a46ab"
+ integrity sha512-026LG1yR76Xb62kM+W83IWT7Wy2yKplllbXNFBF2m0Lr4k4YpYKnpLb8tRft8MLOLRbYAt/KnxE/YKvRZul7kw==
+ dependencies:
+ prismarine-item "^1.4.0"
+
+prismarine-windows@^2.9.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/prismarine-windows/-/prismarine-windows-2.10.0.tgz#e657ee44e45666de5e92a3c211730bf22e5ff632"
+ integrity sha512-ssXLGAr7W9JLvvLjYMoo1j4j6AdJaoIb0/HlqkWMWlQqvZJeiS4zyBjJY6+GtR4OzpjkEf6IvF5cNXhHFpbcZQ==
+ dependencies:
+ prismarine-item "^1.12.2"
+ prismarine-registry "^1.7.0"
+ typed-emitter "^2.1.0"
+
+prismarine-world@^3.2.0, prismarine-world@^3.3.1, prismarine-world@^3.6.0:
+ version "3.7.0"
+ resolved "https://registry.yarnpkg.com/prismarine-world/-/prismarine-world-3.7.0.tgz#4a673ad171ce6b7776c6e6ce68f951c0c23d2190"
+ integrity sha512-M5euvNjQ3vIk689BSa0YC6PBwpVY35Oc6q6KyZ0IqyFtI+cQ9em+8l5OTAK/uu9/gzDDhR7cmm9L2WXgTXBQCw==
+ dependencies:
+ vec3 "^0.1.7"
+
+process@^0.11.10:
+ version "0.11.10"
+ resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+ integrity sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==
+
+protodef-validator@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/protodef-validator/-/protodef-validator-1.4.0.tgz#dfe74086b9a7a2f378ca52505afe207c62f65a97"
+ integrity sha512-2y2coBolqCEuk5Kc3QwO7ThR+/7TZiOit4FrpAgl+vFMvq8w76nDhh09z08e2NQOdrgPLsN2yzXsvRvtADgUZQ==
+ dependencies:
+ ajv "^6.5.4"
+
+protodef@^1.17.0, protodef@^1.18.0, protodef@^1.8.0:
+ version "1.19.0"
+ resolved "https://registry.yarnpkg.com/protodef/-/protodef-1.19.0.tgz#39482efe31ad5f8e58f627f85b347942d9a85f9e"
+ integrity sha512-94f3GR7pk4Qi5YVLaLvWBfTGUIzzO8hyo7vFVICQuu5f5nwKtgGDaeC1uXIu49s5to/49QQhEYeL0aigu1jEGA==
+ dependencies:
+ lodash.reduce "^4.6.0"
+ protodef-validator "^1.3.0"
+ readable-stream "^4.4.0"
+
+proxy-addr@~2.0.7:
+ version "2.0.7"
+ resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025"
+ integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==
+ dependencies:
+ forwarded "0.2.0"
+ ipaddr.js "1.9.1"
+
+punycode@^2.1.0:
+ version "2.3.1"
+ resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+ integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+
+qs@~6.15.1:
+ version "6.15.2"
+ resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9"
+ integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==
+ dependencies:
+ side-channel "^1.1.0"
+
+quickselect@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018"
+ integrity sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==
+
+railroad-diagrams@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz#eb7e6267548ddedfb899c1b90e57374559cddb7e"
+ integrity sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==
+
+randexp@0.4.6:
+ version "0.4.6"
+ resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.4.6.tgz#e986ad5e5e31dae13ddd6f7b3019aa7c87f60ca3"
+ integrity sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==
+ dependencies:
+ discontinuous-range "1.0.0"
+ ret "~0.1.10"
+
+randombytes@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
+ integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
+ dependencies:
+ safe-buffer "^5.1.0"
+
+range-parser@~1.2.1:
+ version "1.2.1"
+ resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+ integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@~2.5.3:
+ version "2.5.3"
+ resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2"
+ integrity sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==
+ dependencies:
+ bytes "~3.1.2"
+ http-errors "~2.0.1"
+ iconv-lite "~0.4.24"
+ unpipe "~1.0.0"
+
+rbush@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf"
+ integrity sha512-XRaVO0YecOpEuIvbhbpTrZgoiI6xBlz6hnlr6EHhd+0x9ase6EmeN+hdwwUaJvLcsFFQ8iWVF1GAK1yB0BWi0w==
+ dependencies:
+ quickselect "^2.0.0"
+
+readable-stream@^4.1.0, readable-stream@^4.4.0:
+ version "4.7.0"
+ resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-4.7.0.tgz#cedbd8a1146c13dfff8dab14068028d58c15ac91"
+ integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==
+ dependencies:
+ abort-controller "^3.0.0"
+ buffer "^6.0.3"
+ events "^3.3.0"
+ process "^0.11.10"
+ string_decoder "^1.3.0"
+
+readdirp@~3.6.0:
+ version "3.6.0"
+ resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+ integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+ dependencies:
+ picomatch "^2.2.1"
+
+require-directory@^2.1.1:
+ version "2.1.1"
+ resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+ integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
+
+require-self@^0.2.3:
+ version "0.2.3"
+ resolved "https://registry.yarnpkg.com/require-self/-/require-self-0.2.3.tgz#20782194699887e07ce5a452738090ad71d524d6"
+ integrity sha512-keGBWkK0PWJGFAd6IznpjM5zZzySbsrvzq0ElXpZ4G8hzymV9I+/OnC916MF5mRxHRWSZKGIyCFJ73BZFz3xaA==
+
+ret@~0.1.10:
+ version "0.1.15"
+ resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc"
+ integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==
+
+rxjs@^7.5.2:
+ version "7.8.2"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b"
+ integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==
+ dependencies:
+ tslib "^2.1.0"
+
+safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0:
+ version "5.2.1"
+ resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+ integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+"safer-buffer@>= 2.1.2 < 3":
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+ integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+sat@^0.9.0:
+ version "0.9.0"
+ resolved "https://registry.yarnpkg.com/sat/-/sat-0.9.0.tgz#02091260513c8f2482da7db9377ddfeca80f5982"
+ integrity sha512-mxdv5RZJO4tdMnUURGU3gAMcnDUEwcNJwE+lPO0/V+rBeDvFLH3wEZEOR0fH7cTN0zQaNxBEbHnyQL9DzupwQQ==
+
+semver@^7.5.4:
+ version "7.8.4"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.4.tgz#c73eceebae0616934be8dff28a7fd70757c8e696"
+ integrity sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==
+
+send@~0.19.0, send@~0.19.1:
+ version "0.19.2"
+ resolved "https://registry.yarnpkg.com/send/-/send-0.19.2.tgz#59bc0da1b4ea7ad42736fd642b1c4294e114ff29"
+ integrity sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==
+ dependencies:
+ debug "2.6.9"
+ depd "2.0.0"
+ destroy "1.2.0"
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ etag "~1.8.1"
+ fresh "~0.5.2"
+ http-errors "~2.0.1"
+ mime "1.6.0"
+ ms "2.1.3"
+ on-finished "~2.4.1"
+ range-parser "~1.2.1"
+ statuses "~2.0.2"
+
+serialize-javascript@^6.0.2:
+ version "6.0.2"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
+ integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
+ dependencies:
+ randombytes "^2.1.0"
+
+serve-static@~1.16.2:
+ version "1.16.3"
+ resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9"
+ integrity sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==
+ dependencies:
+ encodeurl "~2.0.0"
+ escape-html "~1.0.3"
+ parseurl "~1.3.3"
+ send "~0.19.1"
+
+setprototypeof@1.2.0, setprototypeof@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+ integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+side-channel-list@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127"
+ integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.4"
+
+side-channel-map@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42"
+ integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+
+side-channel-weakmap@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea"
+ integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==
+ dependencies:
+ call-bound "^1.0.2"
+ es-errors "^1.3.0"
+ get-intrinsic "^1.2.5"
+ object-inspect "^1.13.3"
+ side-channel-map "^1.0.1"
+
+side-channel@^1.1.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab"
+ integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==
+ dependencies:
+ es-errors "^1.3.0"
+ object-inspect "^1.13.4"
+ side-channel-list "^1.0.1"
+ side-channel-map "^1.0.1"
+ side-channel-weakmap "^1.0.2"
+
+smart-buffer@^4.1.0:
+ version "4.2.0"
+ resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae"
+ integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==
+
+socket.io-adapter@~2.5.2:
+ version "2.5.8"
+ resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz#c9fcabf602dbd5b09258ca98e5ec6a7ae4360698"
+ integrity sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==
+ dependencies:
+ debug "~4.4.1"
+ ws "~8.21.0"
+
+socket.io-client@^4.0.0:
+ version "4.8.3"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.8.3.tgz#62717edd46a318c918125b57e92dc7f8bb71c34c"
+ integrity sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.4.1"
+ engine.io-client "~6.6.1"
+ socket.io-parser "~4.2.4"
+
+socket.io-parser@~4.2.4:
+ version "4.2.6"
+ resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.6.tgz#19156bf179af3931abd05260cfb1491822578a6f"
+ integrity sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.4.1"
+
+socket.io@^4.0.0:
+ version "4.8.3"
+ resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.3.tgz#ca6ba1431c69532e1e0a6f496deebeb601dbc4df"
+ integrity sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==
+ dependencies:
+ accepts "~1.3.4"
+ base64id "~2.0.0"
+ cors "~2.8.5"
+ debug "~4.4.1"
+ engine.io "~6.6.0"
+ socket.io-adapter "~2.5.2"
+ socket.io-parser "~4.2.4"
+
+statuses@~2.0.1, statuses@~2.0.2:
+ version "2.0.2"
+ resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
+ integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
+
+string-width@^4.1.0, string-width@^4.2.0:
+ version "4.2.3"
+ resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+ integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+ dependencies:
+ emoji-regex "^8.0.0"
+ is-fullwidth-code-point "^3.0.0"
+ strip-ansi "^6.0.1"
+
+string_decoder@^1.3.0:
+ version "1.3.0"
+ resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e"
+ integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
+ dependencies:
+ safe-buffer "~5.2.0"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+ integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+ dependencies:
+ ansi-regex "^5.0.1"
+
+strip-json-comments@^3.1.1:
+ version "3.1.1"
+ resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+ integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
+
+supports-color@^7.1.0:
+ version "7.2.0"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+ integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+ dependencies:
+ has-flag "^4.0.0"
+
+supports-color@^8.1.1:
+ version "8.1.1"
+ resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
+ integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
+ dependencies:
+ has-flag "^4.0.0"
+
+three.meshline@^1.3.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/three.meshline/-/three.meshline-1.4.0.tgz#2a483cbd99cd5f74cf6ee5c253a9a4b4a8169178"
+ integrity sha512-A8IsiMrWP8zmHisGDAJ76ZD7t/dOF/oCe/FUKNE6Bu01ZYEx8N6IlU/1Plb2aOZtAuWM2A8s8qS3hvY0OFuvOw==
+
+three@0.128.0:
+ version "0.128.0"
+ resolved "https://registry.yarnpkg.com/three/-/three-0.128.0.tgz#884dacca6a330e45600a09ec5439283f50b76aa6"
+ integrity sha512-i0ap/E+OaSfzw7bD1TtYnPo3VEplkl70WX5fZqZnfZsE3k3aSFudqrrC9ldFZfYFkn1zwDmBcdGfiIm/hnbyZA==
+
+to-regex-range@^5.0.1:
+ version "5.0.1"
+ resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+ integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+ dependencies:
+ is-number "^7.0.0"
+
+toidentifier@~1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+ integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tr46@~0.0.3:
+ version "0.0.3"
+ resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+ integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
+tslib@^2.1.0:
+ version "2.8.1"
+ resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f"
+ integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==
+
+type-is@~1.6.18:
+ version "1.6.18"
+ resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+ integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+ dependencies:
+ media-typer "0.3.0"
+ mime-types "~2.1.24"
+
+typed-emitter@^1.0.0, typed-emitter@^1.2.0:
+ version "1.4.0"
+ resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-1.4.0.tgz#38c6bf1224e764906bb20cb0b458fa914100607c"
+ integrity sha512-weBmoo3HhpKGgLBOYwe8EB31CzDFuaK7CCL+axXhUYhn4jo6DSkHnbefboCF5i4DQ2aMFe0C/FdTWcPdObgHyg==
+
+typed-emitter@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/typed-emitter/-/typed-emitter-2.1.0.tgz#ca78e3d8ef1476f228f548d62e04e3d4d3fd77fb"
+ integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==
+ optionalDependencies:
+ rxjs "^7.5.2"
+
+typescript@^3.9.7:
+ version "3.9.10"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.9.10.tgz#70f3910ac7a51ed6bef79da7800690b19bf778b8"
+ integrity sha512-w6fIxVE/H1PkLKcCPsFqKE7Kv7QUwhU8qQY2MueZXWx5cPZdwFupLgKK3vntcK98BtNHZtAF4LA/yl2a7k8R6Q==
+
+typescript@^4.9.5:
+ version "4.9.5"
+ resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.9.5.tgz#095979f9bcc0d09da324d58d03ce8f8374cbe65a"
+ integrity sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==
+
+uint4@^0.1.2:
+ version "0.1.2"
+ resolved "https://registry.yarnpkg.com/uint4/-/uint4-0.1.2.tgz#50c5ae04b85d28128f2df3cc0ecc4d41abc2f681"
+ integrity sha512-lhEx78gdTwFWG+mt6cWAZD/R6qrIj0TTBeH5xwyuDJyswLNlGe+KVlUPQ6+mx5Ld332pS0AMUTo9hIly7YsWxQ==
+
+"undici-types@>=7.24.0 <7.24.7":
+ version "7.24.6"
+ resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91"
+ integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==
+
+unpipe@~1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+ integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+uri-js@^4.2.2:
+ version "4.4.1"
+ resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+ integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
+ dependencies:
+ punycode "^2.1.0"
+
+utils-merge@1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+ integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
+
+uuid-1345@^1.0.1, uuid-1345@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/uuid-1345/-/uuid-1345-1.0.2.tgz#1c43d074507c40494278d5236199bffac81e19b8"
+ integrity sha512-bA5zYZui+3nwAc0s3VdGQGBfbVsJLVX7Np7ch2aqcEWFi5lsAEcmO3+lx3djM1npgpZI8KY2FITZ2uYTnYUYyw==
+ dependencies:
+ macaddress "^0.5.1"
+
+uuid@^10.0.0:
+ version "10.0.0"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294"
+ integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==
+
+uuid@^8.3.0:
+ version "8.3.2"
+ resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2"
+ integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
+
+vary@^1, vary@~1.1.2:
+ version "1.1.2"
+ resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+ integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+vec3@^0.1.3, vec3@^0.1.4, vec3@^0.1.6, vec3@^0.1.7, vec3@^0.1.8:
+ version "0.1.10"
+ resolved "https://registry.yarnpkg.com/vec3/-/vec3-0.1.10.tgz#939aa046ad75c08556abde6be57170cf5e691bc6"
+ integrity sha512-Sr1U3mYtMqCOonGd3LAN9iqy0qF6C+Gjil92awyK/i2OwiUo9bm7PnLgFpafymun50mOjnDcg4ToTgRssrlTcw==
+
+webidl-conversions@^3.0.0:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+ integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+whatwg-url@^5.0.0:
+ version "5.0.0"
+ resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+ integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+ dependencies:
+ tr46 "~0.0.3"
+ webidl-conversions "^3.0.0"
+
+workerpool@^6.5.1:
+ version "6.5.1"
+ resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544"
+ integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA==
+
+wrap-ansi@^7.0.0:
+ version "7.0.0"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+ integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+ dependencies:
+ ansi-styles "^4.0.0"
+ string-width "^4.1.0"
+ strip-ansi "^6.0.0"
+
+wrappy@1:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+ integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@~8.21.0:
+ version "8.21.0"
+ resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951"
+ integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==
+
+xmlhttprequest-ssl@~2.1.1:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz#e9e8023b3f29ef34b97a859f584c5e6c61418e23"
+ integrity sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==
+
+xxhash-wasm@^0.4.2:
+ version "0.4.2"
+ resolved "https://registry.yarnpkg.com/xxhash-wasm/-/xxhash-wasm-0.4.2.tgz#752398c131a4dd407b5132ba62ad372029be6f79"
+ integrity sha512-/eyHVRJQCirEkSZ1agRSCwriMhwlyUcFkXD5TPVSLP+IPzjsqMVzZwdoczLp1SoQU0R3dxz1RpIK+4YNQbCVOA==
+
+y18n@^5.0.5:
+ version "5.0.8"
+ resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
+ integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
+
+yargs-parser@^20.2.2, yargs-parser@^20.2.9:
+ version "20.2.9"
+ resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
+ integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
+
+yargs-unparser@^2.0.0:
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb"
+ integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==
+ dependencies:
+ camelcase "^6.0.0"
+ decamelize "^4.0.0"
+ flat "^5.0.2"
+ is-plain-obj "^2.1.0"
+
+yargs@^16.2.0:
+ version "16.2.0"
+ resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
+ integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+ dependencies:
+ cliui "^7.0.2"
+ escalade "^3.1.1"
+ get-caller-file "^2.0.5"
+ require-directory "^2.1.1"
+ string-width "^4.2.0"
+ y18n "^5.0.5"
+ yargs-parser "^20.2.2"
+
+yggdrasil@^1.4.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/yggdrasil/-/yggdrasil-1.8.0.tgz#4539cbd4da03dca4566fc2ca356128b27397ce05"
+ integrity sha512-r5bKOhkZ52DJ6q034uSkdsdZLoFVhOmfDOagRs6h/JX5W7+XIPOMb+peCbElhLEoIckwt43NCUoNQbydOzuPcQ==
+ dependencies:
+ node-fetch "^2.6.1"
+ uuid "^10.0.0"
+
+yocto-queue@^0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+ integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
diff --git a/experiments/Odyssey/Odyssey/odyssey/env/process_monitor.py b/experiments/Odyssey/Odyssey/odyssey/env/process_monitor.py
new file mode 100644
index 0000000000000000000000000000000000000000..727c8ebfde31e40a62f3b405766d044772adf2d5
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/env/process_monitor.py
@@ -0,0 +1,130 @@
+import time
+import re
+import warnings
+from typing import List
+
+import psutil
+import subprocess
+import logging
+import threading
+
+import odyssey.utils as U
+
+
+class SubprocessMonitor:
+ def __init__(
+ self,
+ commands: List[str],
+ name: str,
+ ready_match: str = r".*",
+ log_path: str = "logs",
+ callback_match: str = r"^(?!x)x$", # regex that will never match
+ callback: callable = None,
+ finished_callback: callable = None,
+ ):
+ self.commands = commands
+ start_time = time.strftime("%Y%m%d_%H%M%S")
+ self.name = name
+ self.logger = logging.getLogger(name)
+ self.handler = logging.FileHandler(U.f_join(log_path, f"{start_time}.log"))
+ formatter = logging.Formatter(
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+ )
+ self.handler.setFormatter(formatter)
+ self.logger.addHandler(self.handler)
+ self.logger.setLevel(logging.INFO)
+ self.process = None
+ self.ready_match = ready_match
+ self.ready_event = None
+ self.ready_line = None
+ self.callback_match = callback_match
+ self.callback = callback
+ self.finished_callback = finished_callback
+ self.thread = None
+
+ def _start(self):
+ self.logger.info(f"Starting subprocess with commands: {self.commands}")
+ if self.process is not None:
+ self._stop_process(self.process)
+ process = psutil.Popen(
+ self.commands,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ )
+ self.process = process
+ print(f"Subprocess {self.name} started with PID {process.pid}.")
+ try:
+ for line in iter(process.stdout.readline, ""):
+ self.logger.info(line.strip())
+ if re.search(self.ready_match, line):
+ self.ready_line = line
+ self.logger.info("Subprocess is ready.")
+ self.ready_event.set()
+ if re.search(self.callback_match, line):
+ self.callback()
+ finally:
+ if process.stdout:
+ process.stdout.close()
+ if not self.ready_event.is_set():
+ self.ready_event.set()
+ warnings.warn(f"Subprocess {self.name} failed to start.")
+ if self.finished_callback:
+ self.finished_callback()
+
+ def run(self):
+ self.ready_event = threading.Event()
+ self.ready_line = None
+ self.thread = threading.Thread(target=self._start, name=f"{self.name}-stdout", daemon=True)
+ self.thread.start()
+ # block until read_event is set
+ print('wait ready_event to set')
+ # is it possible that the ready_event is never set?
+ # self.ready_event.wait(timeout=60)
+ self.ready_event.wait()
+
+ def stop(self):
+ self.logger.info("Stopping subprocess.")
+ process = self.process
+ if process is not None:
+ self._stop_process(process)
+ if self.process is process:
+ self.process = None
+ thread = self.thread
+ if thread and thread.is_alive() and thread is not threading.current_thread():
+ thread.join(timeout=10)
+ if thread is not None and thread is self.thread and not thread.is_alive():
+ self.thread = None
+
+ def close(self):
+ self.stop()
+ if self.handler:
+ self.logger.removeHandler(self.handler)
+ self.handler.close()
+ self.handler = None
+
+ @staticmethod
+ def _stop_process(process):
+ if process.is_running():
+ process.terminate()
+ try:
+ process.wait(timeout=10)
+ except psutil.TimeoutExpired:
+ process.kill()
+ process.wait(timeout=10)
+ if process.stdout:
+ process.stdout.close()
+
+ # def __del__(self):
+ # if self.process.is_running():
+ # self.stop()
+
+ @property
+ def is_running(self):
+ if self.process is None:
+ return False
+ # TODO:
+ # if self.process.is_running() and self.ready_line is None:
+ # self.stop()
+ # raise RuntimeError('Subprocess is running but ready_line is None. It may mean that the process has not started yet.')
+ return self.process.is_running()
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/__init__.py b/experiments/Odyssey/Odyssey/odyssey/harness/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..193e303415c36dc391c98fd4569c7b01471baf47
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/__init__.py
@@ -0,0 +1,3 @@
+from .orchestrator import HarnessOrchestrator
+
+__all__ = ["HarnessOrchestrator"]
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/base_manager.py b/experiments/Odyssey/Odyssey/odyssey/harness/base_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..81eca9b0c4fc0e8838931926aaa66a701786d670
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/base_manager.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import now_iso
+
+
+_ASSET_NAME = re.compile(r"^[a-z0-9_]+$")
+_READY_STATUSES = {"present", "placed"}
+
+
+class BaseManager:
+ """Persistent registry of model-designated world assets.
+
+ This layer deliberately has no knowledge of task wording or Minecraft
+ facility categories. The model designates an asset in its route; selected
+ capabilities declare abstract world dependencies. The manager binds and
+ verifies concrete coordinates, and may rebind a stale coordinate only to
+ an actually observed same-type block near the durable base anchor.
+ """
+
+ def __init__(self, ckpt_dir: str = "ckpt", resume: bool = True):
+ self.root = U.f_mkdir(ckpt_dir, "harness")
+ self.path = U.f_join(self.root, "base_state.json")
+ self.state = self._load() if resume else self._empty_state()
+
+ def maintain(self, block_name: str, events: list[tuple[str, dict[str, Any]]]) -> dict[str, Any] | None:
+ """Create or repair one model-designated persistent asset."""
+ if not self._valid_name(block_name):
+ return None
+ self.reconcile(events)
+ entry = self.state["assets"].get(block_name, {})
+ position = self._position(entry.get("position")) if entry.get("registered") else None
+ return {
+ "code": self._maintain_code(
+ self.state.get("anchor"),
+ block_name,
+ position,
+ self._rejected_positions(),
+ ),
+ "asset": block_name,
+ }
+
+ def assets_for_requirements(self, requirements: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[str]]:
+ selected: list[dict[str, Any]] = []
+ missing: list[str] = []
+ for requirement in requirements:
+ role = str(requirement.get("role", "world_asset"))
+ accepted = [name for name in requirement.get("accepted_blocks", []) if self._valid_name(name)]
+ match = next(
+ (
+ {"role": role, "block_name": name, "position": self._position(entry.get("position"))}
+ for name in accepted
+ for entry in [self.state["assets"].get(name, {})]
+ if entry.get("status") in _READY_STATUSES and self._position(entry.get("position"))
+ ),
+ None,
+ )
+ if match:
+ selected.append(match)
+ else:
+ missing.append(role)
+ return selected, missing
+
+ def reach_code(self, assets: list[dict[str, Any]]) -> str:
+ calls = []
+ for asset in assets:
+ calls.append(
+ " await useRegisteredAsset(bot, "
+ + json.dumps(asset["block_name"])
+ + ", "
+ + json.dumps(asset["position"])
+ + ");"
+ )
+ return "async function reachRegisteredAssets(bot) {\n" + "\n".join(calls) + "\n}"
+
+ def protection_call(self) -> str:
+ """Return a Harness prologue that protects registered coordinates.
+
+ Failed assets remain protected at their durable coordinate: an empty
+ stale coordinate is harmless, while silently allowing a temporarily
+ unloaded/reappearing asset to be mined would violate the registry.
+ """
+ assets = []
+ for block_name, entry in self.state.get("assets", {}).items():
+ if not isinstance(entry, dict) or not entry.get("registered"):
+ continue
+ position = self._position(entry.get("position"))
+ if self._valid_name(block_name) and position:
+ assets.append({"block_name": block_name, "position": position})
+ assets.sort(
+ key=lambda asset: (
+ asset["block_name"],
+ asset["position"]["x"],
+ asset["position"]["y"],
+ asset["position"]["z"],
+ )
+ )
+ return f"await protectRegisteredAssets(bot, {json.dumps(assets)});"
+
+ def reconcile(self, events: list[tuple[str, dict[str, Any]]] | None) -> None:
+ report = self._latest_report(events)
+ if not report:
+ return
+ anchor = self._position(report.get("anchor"))
+ if anchor:
+ self.state["anchor"] = anchor
+ name = report.get("asset")
+ position = self._position(report.get("position"))
+ if self._valid_name(name) and position:
+ status = str(report.get("status", "unknown"))
+ existing = self.state["assets"].get(name, {})
+ existing_position = self._position(existing.get("position"))
+ if status in _READY_STATUSES:
+ self.state["assets"][name] = {
+ "position": position,
+ "status": status,
+ "registered": True,
+ "verified_at": now_iso(),
+ }
+ self.state["rejected_positions"].pop(self._position_key(position), None)
+ elif existing.get("registered") and existing_position == position:
+ # A failed repair at the registered coordinate is observable.
+ # Relocation is accepted only through the READY branch above,
+ # which requires a concrete same-type world observation from
+ # maintainRegisteredAsset rather than a guessed coordinate.
+ existing["status"] = f"repair_failed:{status}"
+ existing["verified_at"] = now_iso()
+ self.state["assets"][name] = existing
+ elif self._rejectable_status(status):
+ # A candidate is not part of the durable registry until it has
+ # actually been observed. Remember failed candidates so the
+ # next attempt can make progress instead of looping forever.
+ self.state["rejected_positions"][self._position_key(position)] = {
+ "position": position,
+ "asset": name,
+ "status": status,
+ "detail": str(report.get("detail", ""))[:500],
+ "rejected_at": now_iso(),
+ }
+ self._save()
+
+ def asset_ready(self, name: str) -> bool:
+ entry = self.state["assets"].get(name, {})
+ return entry.get("status") in _READY_STATUSES and bool(self._position(entry.get("position")))
+
+ def public_state(self) -> dict[str, Any]:
+ return {
+ "anchor": self.state.get("anchor"),
+ "assets": self.state.get("assets", {}),
+ "policy": "Registered assets use verified world coordinates; stale coordinates may rebind only to an observed same-type block near the base anchor. The model chooses persistent assets; capabilities declare dependencies.",
+ }
+
+ def _maintain_code(
+ self,
+ anchor: dict[str, int] | None,
+ name: str,
+ position: dict[str, int] | None,
+ rejected_positions: list[dict[str, int]],
+ ) -> str:
+ return (
+ "async function maintainRegisteredWorldAsset(bot) {\n"
+ " await maintainRegisteredAsset(bot, "
+ f"{json.dumps(anchor)}, {json.dumps(name)}, {json.dumps(position)}, "
+ f"{json.dumps(rejected_positions)});\n"
+ "}"
+ )
+
+ def _latest_report(self, events: list[tuple[str, dict[str, Any]]] | None) -> dict[str, Any] | None:
+ for event_type, event in reversed(events or []):
+ if event_type != "onSave" or not isinstance(event, dict):
+ continue
+ raw = event.get("onSave")
+ if not isinstance(raw, str) or not raw.startswith("harness_asset:"):
+ continue
+ try:
+ report = json.loads(raw[len("harness_asset:"):])
+ except json.JSONDecodeError:
+ continue
+ if isinstance(report, dict):
+ return report
+ return None
+
+ def _load(self) -> dict[str, Any]:
+ try:
+ with open(self.path, "r", encoding="utf-8") as fp:
+ value = json.load(fp)
+ except (OSError, json.JSONDecodeError):
+ return self._empty_state()
+ if not isinstance(value, dict):
+ return self._empty_state()
+ if isinstance(value.get("assets"), dict):
+ value.setdefault("rejected_positions", {})
+ for entry in value["assets"].values():
+ if isinstance(entry, dict) and entry.get("status") in _READY_STATUSES:
+ entry.setdefault("registered", True)
+ return value
+ # One-way migration from the earlier fixed-facility state format.
+ facilities = value.get("facilities")
+ if isinstance(facilities, dict):
+ for entry in facilities.values():
+ if isinstance(entry, dict) and entry.get("status") in _READY_STATUSES:
+ entry.setdefault("registered", True)
+ return {
+ "version": 3,
+ "anchor": value.get("anchor"),
+ "assets": facilities,
+ "rejected_positions": {},
+ "updated_at": now_iso(),
+ }
+ return self._empty_state()
+
+ def _save(self) -> None:
+ self.state["version"] = 3
+ self.state["updated_at"] = now_iso()
+ with open(self.path, "w", encoding="utf-8") as fp:
+ json.dump(self.state, fp, ensure_ascii=False, indent=2)
+
+ @staticmethod
+ def _empty_state() -> dict[str, Any]:
+ return {
+ "version": 3,
+ "anchor": None,
+ "assets": {},
+ "rejected_positions": {},
+ "updated_at": now_iso(),
+ }
+
+ def _rejected_positions(self) -> list[dict[str, int]]:
+ records = self.state.get("rejected_positions", {})
+ if not isinstance(records, dict):
+ return []
+ positions = []
+ for record in records.values():
+ position = self._position(record.get("position")) if isinstance(record, dict) else None
+ if position and position not in positions:
+ positions.append(position)
+ return positions
+
+ @staticmethod
+ def _position_key(position: dict[str, int]) -> str:
+ return f"{position['x']},{position['y']},{position['z']}"
+
+ @staticmethod
+ def _rejectable_status(status: str) -> bool:
+ return status in {"placement_failed", "placement_unverified"} or status.startswith("conflict:")
+
+ @staticmethod
+ def _valid_name(value: Any) -> bool:
+ return isinstance(value, str) and bool(_ASSET_NAME.fullmatch(value))
+
+ @staticmethod
+ def _position(value: Any) -> dict[str, int] | None:
+ if not isinstance(value, dict):
+ return None
+ try:
+ return {axis: int(value[axis]) for axis in ("x", "y", "z")}
+ except (KeyError, TypeError, ValueError):
+ return None
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/capability.py b/experiments/Odyssey/Odyssey/odyssey/harness/capability.py
new file mode 100644
index 0000000000000000000000000000000000000000..31e35980f4c9ed1bbcec59a0d6795eec27a9a5b2
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/capability.py
@@ -0,0 +1,424 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import now_iso, slugify_name
+from .diagnostics import DIAGNOSTIC_SKILLS
+
+
+PRIMITIVE_REFERENCE = [
+ {
+ "name": "placeItem",
+ "type": "primitive",
+ "signature": "await placeItem(bot, name, position)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "name", "type": "minecraft_item_name"},
+ {"name": "position", "type": "Vec3"},
+ ],
+ "preconditions": ["item_in_inventory", "position_has_reference_block"],
+ "effects": ["block_placed", "inventory_item_decremented"],
+ "description": "Place an inventory item at a target Vec3 position.",
+ "constraints": [
+ "name must be a Minecraft item name string, for example \"crafting_table\".",
+ "position must be a Vec3.",
+ "Use findSuitablePosition(bot) for simple nearby placement.",
+ "The item must already be in inventory; otherwise placeItem only chats a failure.",
+ ],
+ "example": (
+ "const position = await findSuitablePosition(bot);\n"
+ "await placeItem(bot, \"crafting_table\", position);"
+ ),
+ },
+ {
+ "name": "craftItem",
+ "type": "primitive",
+ "signature": "await craftItem(bot, name, count = 1)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "name", "type": "minecraft_item_name"},
+ {"name": "count", "type": "integer", "default": 1, "meaning": "number_of_recipe_executions"},
+ ],
+ "preconditions": ["recipe_exists", "ingredients_in_inventory", "crafting_table_nearby_for_3x3_recipes"],
+ "effects": ["crafted_item_added", "ingredients_consumed"],
+ "world_asset_requirements": [
+ {"role": "crafting_surface", "accepted_blocks": ["crafting_table"]}
+ ],
+ "description": (
+ "Craft an item using available inventory. count is the number of recipe executions, "
+ "not the desired output-item quantity; e.g. 6 oak logs become 24 planks with "
+ "craftItem(bot, 'oak_planks', 6)."
+ ),
+ "constraints": [
+ "name must be a Minecraft item name string.",
+ "count must be a number.",
+ "count must equal recipe executions: divide desired output by that recipe's output per execution.",
+ "For 3x3 recipes, place or find a crafting table before calling craftItem.",
+ ],
+ "example": "await craftItem(bot, \"stone_pickaxe\", 1); // one recipe execution",
+ },
+ {
+ "name": "mineBlock",
+ "type": "primitive",
+ "signature": "await mineBlock(bot, name, count = 1)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "name", "type": "minecraft_block_name"},
+ {"name": "count", "type": "number", "default": 1},
+ ],
+ "preconditions": ["matching_block_nearby", "block_reachable", "tool_sufficient_when_required"],
+ "effects": ["block_collected", "inventory_item_added"],
+ "description": "Mine and collect nearby blocks by block name.",
+ "constraints": [
+ "name must be a Minecraft block name string.",
+ "count must be a number.",
+ "If no matching block is nearby, explore first with exploreUntil.",
+ ],
+ "example": "await mineBlock(bot, \"stone\", 1);",
+ },
+ {
+ "name": "equipItem",
+ "type": "primitive",
+ "signature": "await equipItem(bot, itemName)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "itemName", "type": "minecraft_item_name"},
+ ],
+ "preconditions": ["item_in_inventory"],
+ "effects": ["main_hand_equipped"],
+ "description": (
+ "Equip an existing inventory item in the main hand. This primitive never "
+ "crafts, places, mines, drops, or otherwise changes the world."
+ ),
+ "constraints": [
+ "itemName must be an exact Minecraft item name string.",
+ "The item must already be in inventory; a missing item returns a structured failed precondition.",
+ "Use the observed main_hand slot or the primitive chat message as evidence of success.",
+ ],
+ "example": "await equipItem(bot, \"stone_axe\");",
+ },
+ {
+ "name": "exploreUntil",
+ "type": "primitive",
+ "signature": "await exploreUntil(bot, direction, maxTime, callback)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "direction", "type": "Vec3"},
+ {"name": "maxTime", "type": "number"},
+ {"name": "callback", "type": "function"},
+ ],
+ "preconditions": ["bot_can_move"],
+ "effects": ["target_discovered_or_timeout"],
+ "description": "Move while repeatedly checking a callback until a target is found.",
+ "constraints": [
+ "direction should be a Vec3.",
+ "callback should return a block/entity/object when found.",
+ "Common search radius inside callbacks is 32 blocks.",
+ ],
+ "example": (
+ "const block = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {\n"
+ " return bot.findBlock({ matching: mcData.blocksByName.oak_log.id, maxDistance: 32 });\n"
+ "});"
+ ),
+ },
+ {
+ "name": "smeltItem",
+ "type": "primitive",
+ "signature": "await smeltItem(bot, itemName, fuelName, count)",
+ "args": [
+ {"name": "bot", "type": "Bot"},
+ {"name": "itemName", "type": "minecraft_item_name"},
+ {"name": "fuelName", "type": "minecraft_item_name"},
+ {"name": "count", "type": "number"},
+ ],
+ "preconditions": ["furnace_available", "input_item_in_inventory", "fuel_in_inventory"],
+ "effects": ["smelted_item_added", "fuel_consumed"],
+ "world_asset_requirements": [
+ {"role": "heat_source", "accepted_blocks": ["furnace"]}
+ ],
+ "contract_fallback": {
+ "kind": "raw_to_ingot",
+ "input_prefix": "raw_",
+ "output_suffix": "_ingot",
+ "fuel": "coal",
+ },
+ "description": "Smelt input items in a furnace with a fuel item.",
+ "constraints": [
+ "Requires a furnace placed nearby or available through existing furnace workflows.",
+ "Item and fuel names must be Minecraft item name strings.",
+ ],
+ "example": "await smeltItem(bot, \"raw_iron\", \"coal\", 1);",
+ },
+ {
+ "name": "findSuitablePosition",
+ "type": "helper",
+ "signature": "const position = await findSuitablePosition(bot)",
+ "args": [{"name": "bot", "type": "Bot"}],
+ "preconditions": ["nearby_space_available"],
+ "effects": ["returns_placeable_Vec3"],
+ "description": "Find a nearby Vec3 suitable for placing a block.",
+ "constraints": [
+ "Use before placeItem for ordinary block placement.",
+ "The returned position is intended to have a valid neighboring reference block.",
+ ],
+ "example": "const position = await findSuitablePosition(bot);",
+ },
+]
+
+
+EXAMPLE_SKILL_PRIORITY = [
+ "placeChest",
+ "placeRail",
+ "craftWoodenPickaxe",
+ "craftStonePickaxe",
+ "craftCraftingTable",
+ "mineWoodLog",
+ "mineCobblestone",
+ "collectCobblestone",
+]
+
+
+class CapabilityMap:
+ """Skill and tool inventory exposed to the Harness router."""
+
+ def __init__(
+ self,
+ skill_manager,
+ ckpt_dir: str = "ckpt",
+ include_harness_skills: bool = True,
+ ):
+ self.skill_manager = skill_manager
+ self.root = U.f_mkdir(ckpt_dir, "harness", "skills")
+ self.compositional_dir = U.f_mkdir(self.root, "compositional")
+ self.description_dir = U.f_mkdir(self.root, "description")
+ self.skills_path = U.f_join(self.root, "skills.json")
+ self.versions_path = U.f_join(self.root, "versions.jsonl")
+ self.harness_skills = self._load_harness_skills() if include_harness_skills else {}
+ self._register_diagnostic_skills()
+ if include_harness_skills:
+ self._merge_harness_skills()
+
+ def summarize(self, query: str, top_k: int = 24) -> dict[str, Any]:
+ skills = self.skill_manager.skills
+ names = sorted(skills.keys())
+ retrieved_names: list[str] = []
+ retrieved = []
+ try:
+ k_backup = self.skill_manager.retrieval_top_k
+ self.skill_manager.retrieval_top_k = top_k
+ codes, descriptions = self.skill_manager.retrieve_skills(query)
+ self.skill_manager.retrieval_top_k = k_backup
+ for code, description in zip(codes, descriptions):
+ name = self._name_for_code(code)
+ if name:
+ retrieved_names.append(name)
+ retrieved.append({"name": name, "description": description})
+ except Exception: # noqa: BLE001
+ retrieved = []
+ if not retrieved:
+ retrieved = [
+ {"name": name, "description": skills[name].get("description", "")}
+ for name in names[:top_k]
+ ]
+ retrieved_names = [item["name"] for item in retrieved if item["name"]]
+ for name, entry in self._rank_harness_skills(query):
+ if name not in retrieved_names:
+ retrieved.insert(0, {"name": name, "description": entry.get("description", "")})
+ retrieved_names.insert(0, name)
+ retrieved = retrieved[:top_k]
+ retrieved_names = retrieved_names[:top_k]
+ return {
+ "skill_count": len(names),
+ "all_skill_names": names,
+ "capability_layers": self._capability_layers(names),
+ "candidate_skills": retrieved,
+ "candidate_skill_names": retrieved_names,
+ "primitive_reference": PRIMITIVE_REFERENCE,
+ "skill_code_examples": self._select_skill_code_examples(query, retrieved_names),
+ "external_tools": [
+ "Mineflayer bot API",
+ "Odyssey control primitives",
+ "Minecraft inventory, crafting, mining, placing, and combat primitives",
+ ],
+ }
+
+ def learned_skill_names(self) -> list[str]:
+ return sorted(self.harness_skills.keys())
+
+ def world_asset_requirements(self, route: dict[str, Any]) -> list[dict[str, Any]]:
+ """Infer dependencies from the selected executable capability only.
+
+ This intentionally does not inspect the natural-language task. Newly
+ synthesized skills carry a primitive plan, so their dependencies are
+ derived from the same primitive metadata as direct executions.
+ """
+ operations: list[str] = []
+ if route.get("decision") == "use_primitive":
+ operations.append(str(route.get("primitive_name", "")))
+ elif route.get("decision") == "use_skill":
+ skill = self.skill_manager.skills.get(route.get("skill_name", ""), {})
+ plan = (skill.get("metadata", {}) or {}).get("primitive_plan", [])
+ if isinstance(plan, list):
+ operations.extend(str(step.get("op", "")) for step in plan if isinstance(step, dict))
+ requested_roles = set(route.get("world_asset_dependencies", []))
+ requirements: list[dict[str, Any]] = []
+ for operation in operations:
+ primitive = next((item for item in PRIMITIVE_REFERENCE if item["name"] == operation), None)
+ for requirement in (primitive or {}).get("world_asset_requirements", []):
+ if requirement.get("role") in requested_roles and requirement not in requirements:
+ requirements.append(dict(requirement))
+ return requirements
+
+ def diagnostic_skill_names(self) -> list[str]:
+ return sorted(DIAGNOSTIC_SKILLS.keys())
+
+ def get_skill_code(self, name: str) -> str:
+ if name not in self.skill_manager.skills:
+ raise KeyError(f"Unknown skill: {name}")
+ return self.skill_manager.skills[name]["code"]
+
+ def apply_skill_update(self, update: dict[str, Any]) -> dict[str, Any]:
+ action = update.get("action")
+ if action not in {"add_skill", "update_skill"}:
+ return {"applied": False, "reason": f"unsupported action {action}"}
+
+ raw_name = update.get("name") or update.get("skill_name")
+ code = update.get("code")
+ description = update.get("description") or ""
+ if not raw_name or not code:
+ return {"applied": False, "reason": "skill update requires name and code"}
+ name = slugify_name(raw_name)
+ if "async function" not in code or "(bot" not in code:
+ return {"applied": False, "reason": "skill code must define an async function that accepts bot"}
+
+ if action == "update_skill" and name not in self.skill_manager.skills:
+ action = "add_skill"
+
+ previous = self.harness_skills.get(name, {})
+ metadata = previous.get("metadata", {}) if isinstance(previous, dict) else {}
+ version = int(metadata.get("version", 0)) + 1
+ metadata = {
+ **metadata,
+ "version": version,
+ "updated_at": now_iso(),
+ "generation_mode": update.get("generation_mode", "llm_code"),
+ "reason": update.get("reason", ""),
+ "test_status": update.get("test_status", "unknown"),
+ "preconditions": update.get("preconditions", []),
+ "success_checks": update.get("success_checks", []),
+ "primitive_plan": update.get("primitive_plan", []),
+ }
+ self.harness_skills[name] = {"code": code, "description": description, "metadata": metadata}
+ self.skill_manager.skills[name] = self.harness_skills[name]
+
+ with open(U.f_join(self.compositional_dir, f"{name}.js"), "w", encoding="utf-8") as fp:
+ fp.write(code.rstrip() + "\n")
+ with open(U.f_join(self.description_dir, f"{name}.txt"), "w", encoding="utf-8") as fp:
+ fp.write(description.rstrip() + "\n")
+ with open(self.skills_path, "w", encoding="utf-8") as fp:
+ json.dump(self.harness_skills, fp, ensure_ascii=False, indent=2)
+ with open(self.versions_path, "a", encoding="utf-8") as fp:
+ fp.write(
+ json.dumps(
+ {
+ "time": now_iso(),
+ "name": name,
+ "version": version,
+ "action": action,
+ "description": description,
+ "generation_mode": metadata["generation_mode"],
+ "test_status": metadata["test_status"],
+ },
+ ensure_ascii=False,
+ )
+ + "\n"
+ )
+ return {
+ "applied": True,
+ "action": action,
+ "name": name,
+ "version": version,
+ "path": self.skills_path,
+ }
+
+ def _name_for_code(self, code: str) -> str | None:
+ for name, entry in self.skill_manager.skills.items():
+ if entry.get("code") == code:
+ return name
+ return None
+
+ def _load_harness_skills(self) -> dict[str, Any]:
+ if U.f_exists(self.skills_path):
+ return U.load_json(self.skills_path)
+ with open(self.skills_path, "w", encoding="utf-8") as fp:
+ json.dump({}, fp, ensure_ascii=False, indent=2)
+ return {}
+
+ def _merge_harness_skills(self) -> None:
+ for name, entry in self.harness_skills.items():
+ if not isinstance(entry, dict):
+ continue
+ if "code" not in entry or "description" not in entry:
+ continue
+ self.skill_manager.skills[name] = entry
+
+ def _rank_harness_skills(self, query: str) -> list[tuple[str, dict[str, Any]]]:
+ terms = {term.lower() for term in query.replace("_", " ").split() if len(term) > 2}
+ ranked = []
+ for name, entry in self.harness_skills.items():
+ text = f"{name} {entry.get('description', '')}".lower()
+ score = sum(1 for term in terms if term in text)
+ ranked.append((score, name, entry))
+ ranked.sort(key=lambda item: item[0], reverse=True)
+ return [(name, entry) for _, name, entry in ranked]
+
+ def _capability_layers(self, all_names: list[str]) -> dict[str, Any]:
+ learned = set(self.learned_skill_names())
+ diagnostics = set(self.diagnostic_skill_names())
+ return {
+ "primitives": PRIMITIVE_REFERENCE,
+ "diagnostic_skills": self.diagnostic_skill_names(),
+ "base_skills": [name for name in all_names if name not in learned and name not in diagnostics],
+ "learned_skills": sorted(learned),
+ }
+
+ def _register_diagnostic_skills(self) -> None:
+ for name, entry in DIAGNOSTIC_SKILLS.items():
+ self.skill_manager.skills[name] = entry
+
+ def _select_skill_code_examples(self, query: str, retrieved_names: list[str]) -> list[dict[str, str]]:
+ selected: list[str] = []
+ query_terms = {term.lower() for term in query.replace("_", " ").split() if len(term) > 2}
+
+ def add_if_available(name: str) -> None:
+ if name in self.skill_manager.skills and name not in selected:
+ selected.append(name)
+
+ for name in EXAMPLE_SKILL_PRIORITY:
+ text = name.lower()
+ description = self.skill_manager.skills.get(name, {}).get("description", "").lower()
+ if not query_terms or any(term in text or term in description for term in query_terms):
+ add_if_available(name)
+
+ for name in retrieved_names:
+ if len(selected) >= 6:
+ break
+ add_if_available(name)
+
+ examples = []
+ for name in selected[:6]:
+ entry = self.skill_manager.skills[name]
+ code = entry.get("code", "").strip()
+ if len(code) > 1800:
+ code = code[:1800].rstrip() + "\n// ... truncated ..."
+ examples.append(
+ {
+ "name": name,
+ "description": entry.get("description", ""),
+ "code": code,
+ }
+ )
+ return examples
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/common.py b/experiments/Odyssey/Odyssey/odyssey/harness/common.py
new file mode 100644
index 0000000000000000000000000000000000000000..406d15cbd9467104e9022dd8de2eec58d27d9d46
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/common.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+import json
+import re
+from datetime import datetime
+from typing import Any
+
+from langchain.schema import HumanMessage, SystemMessage
+
+from odyssey.agents.llama import call_with_messages
+from odyssey.utils.json_utils import fix_and_parse_json
+
+
+EQUIPMENT_SLOT_NAMES = [
+ "head",
+ "chest",
+ "legs",
+ "feet",
+ "main_hand",
+ "off_hand",
+]
+
+EQUIPMENT_SLOT_SCHEMA = {
+ "array_order": EQUIPMENT_SLOT_NAMES,
+ "description": (
+ "status.equipment is ordered as [head, chest, legs, feet, main_hand, off_hand]. "
+ "These slots are equipment/held-item views, not extra item-count containers."
+ ),
+}
+
+INVENTORY_SCHEMA = {
+ "description": (
+ "inventory is the item-count view reported by Mineflayer inventory.items(); "
+ "a held hotbar item may also appear in status.equipment_slots.main_hand."
+ ),
+}
+
+
+def now_iso() -> str:
+ return datetime.now().isoformat(timespec="seconds")
+
+
+def slugify_name(name: str) -> str:
+ cleaned = re.sub(r"[^A-Za-z0-9_]", "", name.replace("-", "_").replace(" ", "_"))
+ if not cleaned:
+ raise ValueError("empty name")
+ if cleaned[0].isdigit():
+ cleaned = f"skill_{cleaned}"
+ return cleaned
+
+
+def event_digest(events: list[tuple[str, dict[str, Any]]] | None) -> dict[str, Any]:
+ if not events:
+ return {
+ "events": [],
+ "latest_observation": None,
+ "chat": [],
+ "errors": [],
+ "inventory_schema": INVENTORY_SCHEMA,
+ "equipment_slot_schema": EQUIPMENT_SLOT_SCHEMA,
+ }
+ chat = []
+ errors = []
+ latest = None
+ compact_events = []
+ for event_type, event in events:
+ if event_type == "onChat":
+ chat.append(event.get("onChat"))
+ elif event_type == "onError":
+ errors.append(event.get("onError"))
+ elif event_type == "observe":
+ latest = _annotate_observation(event)
+ compact_events.append({"type": event_type, "value": _annotate_observation(event)})
+ return {
+ "events": compact_events[-12:],
+ "latest_observation": latest,
+ "chat": [item for item in chat if item],
+ "errors": [item for item in errors if item],
+ "inventory_schema": INVENTORY_SCHEMA,
+ "equipment_slot_schema": EQUIPMENT_SLOT_SCHEMA,
+ }
+
+
+def _annotate_observation(event: dict[str, Any]) -> dict[str, Any]:
+ if not isinstance(event, dict):
+ return event
+ status = event.get("status")
+ if not isinstance(status, dict):
+ return event
+ equipment = status.get("equipment")
+ if not isinstance(equipment, list):
+ return event
+
+ annotated = dict(event)
+ annotated_status = dict(status)
+ slots = {}
+ for index, name in enumerate(EQUIPMENT_SLOT_NAMES):
+ slots[name] = equipment[index] if index < len(equipment) else None
+ annotated_status["equipment_slots"] = slots
+ annotated_status["equipment_slot_schema"] = EQUIPMENT_SLOT_SCHEMA
+ annotated["status"] = annotated_status
+ return annotated
+
+
+def dumps_compact(data: Any, limit: int | None = None) -> str:
+ text = json.dumps(data, ensure_ascii=False, indent=2)
+ if limit and len(text) > limit:
+ return text[:limit] + "\n..."
+ return text
+
+
+def call_json(system_prompt: str, user_payload: dict[str, Any], model_name: str, max_retries: int = 3) -> dict[str, Any]:
+ last_error: Exception | None = None
+ user_content = dumps_compact(user_payload, limit=28000)
+ for _ in range(max_retries):
+ response = call_with_messages(
+ [
+ SystemMessage(content=system_prompt),
+ HumanMessage(content=user_content),
+ ],
+ model_name=model_name,
+ ).content
+ try:
+ return fix_and_parse_json(response)
+ except Exception as exc: # noqa: BLE001
+ last_error = exc
+ user_content = (
+ user_content
+ + "\n\nThe previous response was not valid JSON. Return only one JSON object."
+ )
+ raise RuntimeError(f"Failed to parse LLM JSON response: {last_error}")
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/completion_contract.py b/experiments/Odyssey/Odyssey/odyssey/harness/completion_contract.py
new file mode 100644
index 0000000000000000000000000000000000000000..4c99c3c0002d8c4a071076d5d8374fe9b17ad775
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/completion_contract.py
@@ -0,0 +1,360 @@
+from __future__ import annotations
+
+import re
+from typing import Any
+
+from .common import call_json
+
+
+TOOL_SUFFIXES = (
+ "_axe",
+ "_pickaxe",
+ "_shovel",
+ "_hoe",
+ "_sword",
+)
+TOOL_NAMES = {"shield", "bow", "crossbow", "fishing_rod", "shears", "flint_and_steel"}
+
+CHINESE_NUMBERS = {
+ "\u4e00": 1,
+ "\u4e24": 2,
+ "\u4e8c": 2,
+ "\u4e09": 3,
+ "\u56db": 4,
+ "\u4e94": 5,
+ "\u516d": 6,
+ "\u4e03": 7,
+ "\u516b": 8,
+ "\u4e5d": 9,
+ "\u5341": 10,
+}
+
+SEMANTIC_CONTRACT_VERSION = 2
+
+SEMANTIC_CONTRACT_PROMPT = """You compile a Minecraft instruction into a small,
+observation-grounded completion contract. The instruction can be Chinese or English.
+Understand its semantic roles; never infer a resource target merely because a tool name
+contains a material word (for example, a wooden pickaxe is a tool, not a request for logs).
+
+Return only JSON:
+{
+ "kind": "inventory_delta" | "none",
+ "targets": [
+ {"item": "canonical_minecraft_item_id", "amount": positive_integer}
+ ],
+ "protected_tools": ["canonical_tool_item_id"]
+}
+
+Use `inventory_delta` only when the instruction has an observable inventory outcome.
+For a placement, navigation, inspection, combat, or building-only task whose outcome
+cannot be established from item counts, return kind `none` and empty targets.
+Use canonical lower-case Minecraft item ids with underscores. For gathering or crafting,
+the target is only the newly obtained resource or product explicitly requested by the
+instruction. For smelting, include only the output item. Never include consumed recipe
+ingredients, fuel, raw inputs, implicit preparation steps, or intermediate products as
+targets, even if they are needed to execute the task. Include only tools explicitly
+required to be retained and present in the baseline inventory. Do not invent targets,
+tools, quantities, or world observations."""
+
+ITEM_ID = re.compile(r"^[a-z0-9]+(?:_[a-z0-9]+)*$")
+
+
+def build_completion_contract(
+ instruction: str,
+ baseline_inventory: dict[str, Any] | None,
+ *,
+ model_name: str | None = None,
+) -> dict[str, Any] | None:
+ """Compile task semantics with an LLM, then validate a deterministic contract.
+
+ The LLM interprets natural language once at transaction creation. The resulting
+ contract is subsequently evaluated only against observed inventory, so the model
+ never gets to declare a task complete. If semantic compilation is unavailable or
+ invalid, return only a narrow unambiguous fallback rather than guessing from words
+ inside Chinese tool names.
+ """
+ baseline = _normalize_inventory(baseline_inventory)
+ if model_name:
+ try:
+ proposal = call_json(
+ SEMANTIC_CONTRACT_PROMPT,
+ {
+ "instruction": instruction,
+ "baseline_inventory": baseline,
+ "known_baseline_tools": [name for name in baseline if _is_tool(name)],
+ },
+ model_name,
+ )
+ contract = _validated_semantic_contract(instruction, baseline, proposal)
+ if contract is not None:
+ return contract
+ except Exception: # Semantic parsing must not make a task un-runnable.
+ pass
+ return _narrow_rule_fallback(instruction, baseline)
+
+
+def _validated_semantic_contract(
+ instruction: str,
+ baseline: dict[str, int],
+ proposal: dict[str, Any],
+) -> dict[str, Any] | None:
+ if not isinstance(proposal, dict):
+ return None
+ if proposal.get("kind") == "none":
+ return None
+ if proposal.get("kind") != "inventory_delta":
+ return None
+
+ targets = []
+ seen_items = set()
+ for target in proposal.get("targets", []):
+ if not isinstance(target, dict):
+ return None
+ item = target.get("item")
+ direction = target.get("direction")
+ amount = target.get("amount")
+ if (
+ not isinstance(item, str)
+ or not ITEM_ID.fullmatch(item)
+ or item in seen_items
+ or direction not in {None, "increase"}
+ or not isinstance(amount, int)
+ or isinstance(amount, bool)
+ or not 1 <= amount <= 64
+ ):
+ return None
+ seen_items.add(item)
+ initial = int(baseline.get(item, 0))
+ targets.append(
+ {
+ "item": item,
+ "baseline_count": initial,
+ "delta": amount,
+ "target_count": initial + amount,
+ "comparison": ">=",
+ }
+ )
+ if not targets:
+ return None
+
+ protected_tools = {}
+ for item in proposal.get("protected_tools", []):
+ if not isinstance(item, str) or item in protected_tools:
+ return None
+ if not _is_tool(item) or int(baseline.get(item, 0)) <= 0:
+ return None
+ protected_tools[item] = int(baseline[item])
+ return {
+ "type": "inventory_delta",
+ "instruction": instruction,
+ "baseline_inventory": baseline,
+ "targets": targets,
+ "protected_tools": protected_tools,
+ "semantic_source": "llm_validated",
+ "semantic_contract_version": SEMANTIC_CONTRACT_VERSION,
+ "notes": [
+ "Targets are fixed from the original task baseline.",
+ "Targets specify their own comparison direction against that baseline.",
+ ],
+ }
+
+
+def _narrow_rule_fallback(instruction: str, baseline: dict[str, int]) -> dict[str, Any] | None:
+ """Preserve only the unambiguous raw-iron fallback when the LLM is unavailable."""
+ targets = [
+ target
+ for target in (_parse_smelting_targets(instruction, baseline) or [])
+ if target.get("comparison") == ">="
+ ]
+ if not targets:
+ return None
+ protected_tools = {
+ name: count
+ for name, count in baseline.items()
+ if _mentions_tool_retention(instruction) and _is_tool(name)
+ }
+ return {
+ "type": "inventory_delta",
+ "instruction": instruction,
+ "baseline_inventory": baseline,
+ "targets": targets,
+ "protected_tools": protected_tools,
+ "semantic_source": "narrow_rule_fallback",
+ "semantic_contract_version": SEMANTIC_CONTRACT_VERSION,
+ "notes": [
+ "LLM semantic compilation was unavailable; only an unambiguous smelting contract was used.",
+ "Targets are fixed from the original task baseline.",
+ ],
+ }
+
+
+def evaluate_completion_contract(
+ contract: dict[str, Any] | None,
+ inventory: dict[str, Any] | None,
+) -> dict[str, Any] | None:
+ if not contract:
+ return None
+ current = _normalize_inventory(inventory)
+ evidence: list[str] = []
+ success = True
+
+ for target in contract.get("targets", []):
+ item = str(target.get("item", ""))
+ required = int(target.get("target_count", 0))
+ actual = int(current.get(item, 0))
+ comparison = target.get("comparison", ">=")
+ passed = actual <= required if comparison == "<=" else actual >= required
+ if not passed:
+ success = False
+ evidence.append(f"{item}: current {actual}, target {comparison} {required}")
+
+ for item, required_count in (contract.get("protected_tools") or {}).items():
+ actual = int(current.get(item, 0))
+ required = int(required_count)
+ if actual < required:
+ success = False
+ evidence.append(f"tool {item}: current {actual}, baseline {required}")
+
+ return {
+ "success": success,
+ "critique": _contract_critique(success),
+ "evidence": evidence,
+ "regression_risks": [],
+ "contract": contract,
+ }
+
+
+def contract_progress(
+ contract: dict[str, Any] | None,
+ inventory: dict[str, Any] | None,
+) -> dict[str, Any] | None:
+ """Return remaining observation-grounded work without redefining a goal."""
+ if not contract:
+ return None
+ current = _normalize_inventory(inventory)
+ targets = []
+ for target in contract.get("targets", []):
+ item = str(target.get("item", ""))
+ required = int(target.get("target_count", 0))
+ actual = int(current.get(item, 0))
+ comparison = target.get("comparison", ">=")
+ satisfied = actual <= required if comparison == "<=" else actual >= required
+ remaining = max(0, actual - required) if comparison == "<=" else max(0, required - actual)
+ targets.append(
+ {
+ **target,
+ "current_count": actual,
+ "remaining_count": remaining,
+ "satisfied": satisfied,
+ }
+ )
+ protected_items = []
+ for item, baseline_count in (contract.get("protected_tools") or {}).items():
+ actual = int(current.get(item, 0))
+ required = int(baseline_count)
+ protected_items.append(
+ {
+ "item": item,
+ "baseline_count": required,
+ "current_count": actual,
+ "satisfied": actual >= required,
+ }
+ )
+ return {
+ "contract_type": contract.get("type"),
+ "targets": targets,
+ "protected_items": protected_items,
+ "satisfied": all(target["satisfied"] for target in targets)
+ and all(item["satisfied"] for item in protected_items),
+ }
+
+
+def apply_contract_result(evaluation: dict[str, Any], contract_result: dict[str, Any] | None) -> dict[str, Any]:
+ if not contract_result:
+ return evaluation
+ merged = dict(evaluation)
+ merged["contract_evaluation"] = contract_result
+ merged["success"] = bool(contract_result["success"])
+ if contract_result["success"]:
+ merged["critique"] = contract_result["critique"]
+ merged["evidence"] = contract_result["evidence"]
+ merged["regression_risks"] = contract_result["regression_risks"]
+ else:
+ merged["critique"] = (
+ (merged.get("critique") or "").strip()
+ + " Contract check failed: "
+ + "; ".join(contract_result.get("evidence", []))
+ ).strip()
+ return merged
+
+
+def _parse_smelting_targets(instruction: str, baseline: dict[str, int]) -> list[dict[str, Any]] | None:
+ """Parse raw-iron smelting into directional inventory constraints.
+
+ Coal is consumable material. Its inventory count may decrease by any
+ amount (or remain unchanged when a furnace already has fuel), and it is
+ never a protected tool or a completion target.
+ """
+ text = instruction.lower()
+ if not any(token in text for token in ("smelt", "\u7194\u70bc", "\u70e7")):
+ return None
+ if not any(token in text for token in ("raw_iron", "\u7c97\u94c1")):
+ return None
+ amount = _parse_amount(instruction) or 1
+ raw_iron = baseline.get("raw_iron", 0)
+ iron_ingot = baseline.get("iron_ingot", 0)
+ return [
+ {
+ "item": "iron_ingot",
+ "baseline_count": iron_ingot,
+ "delta": amount,
+ "target_count": iron_ingot + amount,
+ "comparison": ">=",
+ },
+ {
+ "item": "raw_iron",
+ "baseline_count": raw_iron,
+ "delta": -amount,
+ "target_count": max(0, raw_iron - amount),
+ "comparison": "<=",
+ },
+ ]
+
+
+def _parse_amount(instruction: str) -> int | None:
+ match = re.search(r"(\d+)\s*(?:\u4efd|\u4e2a|\u5757|\u6839)", instruction)
+ if match:
+ return int(match.group(1))
+ for text, value in sorted(CHINESE_NUMBERS.items(), key=lambda item: len(item[0]), reverse=True):
+ if re.search(rf"{text}\s*(?:\u4efd|\u4e2a|\u5757|\u6839)", instruction):
+ return value
+ match = re.search(r"(\d+)", instruction)
+ return int(match.group(1)) if match else None
+
+def _mentions_tool_retention(instruction: str) -> bool:
+ return any(
+ token in instruction
+ for token in (
+ "\u4fdd\u7559\u6240\u6709\u5de5\u5177",
+ "\u4e0d\u8981\u4e22\u5f03\u4efb\u4f55\u5de5\u5177",
+ "\u4e0d\u4e22\u5f03\u5de5\u5177",
+ )
+ )
+
+
+def _is_tool(item_name: str) -> bool:
+ return item_name.endswith(TOOL_SUFFIXES) or item_name in TOOL_NAMES
+
+
+def _normalize_inventory(inventory: dict[str, Any] | None) -> dict[str, int]:
+ result: dict[str, int] = {}
+ for key, value in (inventory or {}).items():
+ try:
+ result[str(key)] = int(value)
+ except (TypeError, ValueError):
+ continue
+ return result
+
+
+def _contract_critique(success: bool) -> str:
+ return "Completion contract satisfied." if success else "Completion contract not yet satisfied."
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/decomposer.py b/experiments/Odyssey/Odyssey/odyssey/harness/decomposer.py
new file mode 100644
index 0000000000000000000000000000000000000000..49afc896b99ddcfb61f00598d4c1f1d9435e9eec
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/decomposer.py
@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import call_json
+
+
+DECOMPOSER_PROMPT = """You are the Subgoal Decomposer for a Minecraft continual-learning Harness.
+Split long-horizon instructions into short observable Minecraft subgoals only when useful.
+Each subgoal should be executable by one existing or easily synthesized skill.
+Do not decompose simple one-step placement, mining, dropping, or diagnostic tasks.
+If failure_feedback is present, revise the remaining plan instead of repeating a known-bad plan.
+Return only JSON with:
+{
+ "should_decompose": true | false,
+ "reason": "brief reason",
+ "subgoals": ["short imperative subgoal"]
+}
+
+When completion_contract and contract_progress are present, preserve their
+hard target counts. Plan only the remaining work and make each subgoal state
+the remaining quantity; do not replace a delta goal with an absolute inventory
+claim.
+"""
+
+
+class TaskDecomposer:
+ def __init__(self, model_name: str):
+ self.model_name = model_name
+
+ def decompose(
+ self,
+ *,
+ instruction: str,
+ events: list[tuple[str, dict[str, Any]]] | None,
+ abstract_memory: list[str],
+ capability_summary: dict[str, Any],
+ failure_feedback: dict[str, Any] | None = None,
+ completed_subgoals: list[str] | None = None,
+ completion_contract: dict[str, Any] | None = None,
+ contract_progress: dict[str, Any] | None = None,
+ ) -> list[str]:
+ result = call_json(
+ DECOMPOSER_PROMPT,
+ {
+ "instruction": instruction,
+ "current_events": events[-3:] if events else [],
+ "retrieved_abstract_memory": abstract_memory,
+ "capability_summary": capability_summary,
+ "failure_feedback": failure_feedback or {},
+ "completed_subgoals": completed_subgoals or [],
+ "completion_contract": completion_contract or {},
+ "contract_progress": contract_progress or {},
+ },
+ self.model_name,
+ )
+ subgoals = result.get("subgoals") or []
+ if not result.get("should_decompose") or not isinstance(subgoals, list):
+ return [instruction]
+ clean = [str(item).strip() for item in subgoals if str(item).strip()]
+ return clean or [instruction]
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/diagnostics.py b/experiments/Odyssey/Odyssey/odyssey/harness/diagnostics.py
new file mode 100644
index 0000000000000000000000000000000000000000..29f12dffc0a59ebe2d708f825d9d2639113c5808
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/diagnostics.py
@@ -0,0 +1,29 @@
+from __future__ import annotations
+
+
+DIAGNOSTIC_SKILLS = {
+ "diagnoseInventoryAndNearby": {
+ "description": (
+ "Name: diagnoseInventoryAndNearby; Description: Inspect inventory and nearby common blocks "
+ "without intentionally changing the world. Use when the router is uncertain about resources, "
+ "placed crafting tables, wood, stone, or basic task preconditions.\n"
+ ),
+ "code": """async function diagnoseInventoryAndNearby(bot) {
+ const interestingBlocks = ["crafting_table", "oak_log", "birch_log", "spruce_log", "jungle_log", "acacia_log", "dark_oak_log", "mangrove_log", "stone", "chest", "furnace"];
+ const nearby = [];
+ for (const name of interestingBlocks) {
+ const block = mcData.blocksByName[name] ? bot.findBlock({
+ matching: mcData.blocksByName[name].id,
+ maxDistance: 32
+ }) : null;
+ if (block) {
+ nearby.push(`${name}@${block.position}`);
+ }
+ }
+ const inventory = bot.inventory.items().map(item => `${item.name}:${item.count}`).join(", ") || "empty";
+ bot.chat(`diagnostic inventory=${inventory}`);
+ bot.chat(`diagnostic nearby=${nearby.join(", ") || "none"}`);
+}""",
+ "metadata": {"builtin": True, "type": "diagnostic"},
+ }
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/evaluator.py b/experiments/Odyssey/Odyssey/odyssey/harness/evaluator.py
new file mode 100644
index 0000000000000000000000000000000000000000..25cb3de63113ccfcb4ecf35bc3c17e8eca90bcd9
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/evaluator.py
@@ -0,0 +1,103 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import INVENTORY_SCHEMA, call_json, event_digest
+
+
+TASK_EVALUATOR_PROMPT = """You are the Continual Evaluator for a Minecraft Harness.
+Judge whether the latest execution achieved the structured task state's success criteria.
+Use only observable evidence from events, inventory, registered world assets in task_state,
+chat logs, and tool errors. When route.decision is `observation_check`, no new execution is
+expected: judge whether the requested read-only inspection is already established by the
+current observations. Do not require a state change for such a task, and never invent
+unobserved world state.
+Return only JSON with:
+{
+ "success": true | false,
+ "critique": "brief evidence-based critique",
+ "evidence": ["specific observations supporting the judgment"],
+ "regression_risks": ["risks noticed during this execution"]
+}
+"""
+
+
+UPDATE_EVALUATOR_PROMPT = """You are the safety evaluator for Harness continual updates.
+Decide whether the optimizer's candidate update is safe to apply.
+Reject updates that are unsupported by evidence, likely to break existing skills/tasks, overwrite broad prompts with narrow hacks, or add unsafe/broken JavaScript.
+For JavaScript skill updates, verify primitive usage against capability_summary.primitive_reference and nearby code examples.
+Reject code that passes undefined values into placeItem, craftItem, mineBlock, or other primitives, or that places blocks without a valid Vec3 position.
+Return only JSON with:
+{
+ "approved": true | false,
+ "reason": "brief reason",
+ "safe_parts": {
+ "abstract_memory": {},
+ "capability_updates": [],
+ "prompt_updates": {}
+ },
+ "rejected_parts": ["short descriptions"]
+}
+"""
+
+
+class ContinualEvaluator:
+ def __init__(self, model_name: str):
+ self.model_name = model_name
+ self.base_task_prompt = TASK_EVALUATOR_PROMPT
+ self.base_update_prompt = UPDATE_EVALUATOR_PROMPT
+ self.task_prompt = TASK_EVALUATOR_PROMPT
+ self.update_prompt = UPDATE_EVALUATOR_PROMPT
+
+ def judge_task(
+ self,
+ *,
+ task_state: dict[str, Any],
+ route: dict[str, Any],
+ events: list[tuple[str, dict[str, Any]]] | None,
+ inventory: dict[str, Any] | None,
+ ) -> dict[str, Any]:
+ result = call_json(
+ self.task_prompt,
+ {
+ "task_state": task_state,
+ "route": route,
+ "events": event_digest(events),
+ "final_inventory": inventory or {},
+ "inventory_schema": INVENTORY_SCHEMA,
+ },
+ self.model_name,
+ )
+ result.setdefault("success", False)
+ result.setdefault("critique", "")
+ result.setdefault("evidence", [])
+ result.setdefault("regression_risks", [])
+ result["success"] = bool(result["success"])
+ return result
+
+ def judge_update(
+ self,
+ *,
+ task_state: dict[str, Any],
+ raw_record: dict[str, Any],
+ candidate_update: dict[str, Any],
+ abstract_memory: dict[str, Any],
+ capability_summary: dict[str, Any],
+ ) -> dict[str, Any]:
+ result = call_json(
+ self.update_prompt,
+ {
+ "task_state": task_state,
+ "raw_record": raw_record,
+ "candidate_update": candidate_update,
+ "current_abstract_memory": abstract_memory,
+ "capability_summary": capability_summary,
+ },
+ self.model_name,
+ )
+ result.setdefault("approved", False)
+ result.setdefault("reason", "")
+ result.setdefault("safe_parts", {})
+ result.setdefault("rejected_parts", [])
+ result["approved"] = bool(result["approved"])
+ return result
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/health.py b/experiments/Odyssey/Odyssey/odyssey/harness/health.py
new file mode 100644
index 0000000000000000000000000000000000000000..60e7690e81bca65196d9349261719847986f1209
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/health.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import now_iso
+
+
+class SkillHealthStore:
+ def __init__(self, ckpt_dir: str = "ckpt", resume: bool = True):
+ self.root = U.f_mkdir(ckpt_dir, "harness")
+ self.path = U.f_join(self.root, "skill_health.json")
+ if not resume:
+ U.f_remove(self.path)
+ self.health = U.load_json(self.path) if U.f_exists(self.path) else {}
+
+ def summarize(self, skill_names: list[str]) -> dict[str, Any]:
+ return {name: self.health.get(name, self._empty()) for name in skill_names if name}
+
+ def repeatedly_failed_contexts(self, minimum_failures: int = 2) -> dict[str, dict[str, Any]]:
+ """Return conservative historical quarantines for never-successful skills."""
+ result = {}
+ for name, entry in self.health.items():
+ if (
+ int(entry.get("failure_count", 0)) >= minimum_failures
+ and int(entry.get("success_count", 0)) == 0
+ ):
+ contexts = entry.get("failure_contexts") or []
+ result[name] = contexts[-1] if contexts else {
+ "skill_name": name,
+ "reason": "repeated_historical_failures",
+ "critique": entry.get("last_critique", ""),
+ }
+ return result
+
+ def update(
+ self,
+ skill_name: str,
+ success: bool,
+ evaluation: dict[str, Any] | None = None,
+ failure_context: dict[str, Any] | None = None,
+ ) -> None:
+ if not skill_name:
+ return
+ entry = self.health.setdefault(skill_name, self._empty())
+ if success:
+ entry["success_count"] += 1
+ else:
+ entry["failure_count"] += 1
+ total = entry["success_count"] + entry["failure_count"]
+ entry["confidence"] = round(entry["success_count"] / total, 3) if total else 0.0
+ entry["last_success"] = bool(success)
+ entry["last_updated"] = now_iso()
+ if evaluation:
+ entry["last_critique"] = evaluation.get("critique", "")
+ risks = evaluation.get("regression_risks") or []
+ if risks:
+ entry["last_risks"] = risks
+ if failure_context and not success:
+ contexts = entry.setdefault("failure_contexts", [])
+ contexts.append(failure_context)
+ entry["failure_contexts"] = contexts[-5:]
+ self.save()
+
+ def save(self) -> None:
+ with open(self.path, "w", encoding="utf-8") as fp:
+ json.dump(self.health, fp, ensure_ascii=False, indent=2)
+
+ def _empty(self) -> dict[str, Any]:
+ return {
+ "success_count": 0,
+ "failure_count": 0,
+ "confidence": 0.0,
+ "last_success": None,
+ "last_critique": "",
+ "last_risks": [],
+ "last_updated": None,
+ "failure_contexts": [],
+ }
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/memory.py b/experiments/Odyssey/Odyssey/odyssey/harness/memory.py
new file mode 100644
index 0000000000000000000000000000000000000000..a0aef0d431f8421f5e4a91ad8f632d9bdcbf8b8b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/memory.py
@@ -0,0 +1,463 @@
+from __future__ import annotations
+
+import hashlib
+import json
+import math
+import os
+import re
+from datetime import datetime, timezone
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import now_iso
+
+
+MEMORY_TYPES = ("lessons", "task_strategies", "failure_patterns", "skill_notes")
+SCHEMA_VERSION = 2
+_WORD = re.compile(r"[a-zA-Z][a-zA-Z0-9_]*|[0-9]+|[\u4e00-\u9fff]")
+_ITEM_ID = re.compile(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b")
+_SKILL_NAME = re.compile(r"\b[a-z]+(?:[A-Z][a-zA-Z0-9]*)+\b")
+
+
+class ExperienceMemory:
+ """Persistent episodic log plus structured, hybrid-retrievable memory.
+
+ The public retrieval API intentionally still returns strings: the Harness
+ prompts should not need to understand the storage schema. On disk each
+ abstract memory is a record with provenance, confidence and lifecycle
+ metadata. Existing version-1 files containing lists of strings are
+ migrated in place on first load.
+ """
+
+ def __init__(
+ self,
+ ckpt_dir: str = "ckpt",
+ resume: bool = True,
+ embedding_model: Any | None = None,
+ backend_config: str | None = None,
+ ):
+ self.root = U.f_mkdir(ckpt_dir, "harness")
+ self.raw_path = U.f_join(self.root, "raw_memory.jsonl")
+ self.abstract_path = U.f_join(self.root, "abstract_memory.json")
+ self.embedding_cache_path = U.f_join(self.root, "abstract_memory_embeddings.json")
+ self.proposed_updates_path = U.f_join(self.root, "proposed_updates.jsonl")
+ self.embedding_model = embedding_model
+ self.backend_config_path = backend_config or os.getenv("ODYSSEY_MEMORY_CONFIG", "")
+ self.backend_config = self._load_backend_config(self.backend_config_path)
+ backend = str(self.backend_config.get("backend", "hybrid")).lower()
+ if backend not in {"hybrid", "retry"}:
+ backend = "hybrid"
+ self.backend = backend
+ self.retry_only = backend == "retry"
+ raw_updates = self.backend_config.get("updates") or {}
+ if not isinstance(raw_updates, dict):
+ raw_updates = {}
+ default_non_memory = backend == "hybrid"
+ non_memory = self._config_bool(raw_updates, "non_memory", default_non_memory)
+ self.update_policy = {
+ "memory": self._config_bool(raw_updates, "memory", not self.retry_only),
+ "non_memory": non_memory,
+ "skills": self._config_bool(raw_updates, "skills", non_memory),
+ "prompts": self._config_bool(raw_updates, "prompts", non_memory),
+ "skill_health": self._config_bool(raw_updates, "skill_health", non_memory),
+ }
+ self.memory_updates_enabled = self.update_policy["memory"]
+ self.skill_updates_enabled = self.update_policy["skills"]
+ self.prompt_updates_enabled = self.update_policy["prompts"]
+ self.skill_health_updates_enabled = self.update_policy["skill_health"]
+ raw_execution = self.backend_config.get("execution") or {}
+ if not isinstance(raw_execution, dict):
+ raw_execution = {}
+ self.retry_suppression_enabled = self._config_bool(
+ raw_execution,
+ "retry_suppression",
+ not self.retry_only,
+ )
+ self.condition_on_failure_feedback = self._config_bool(
+ raw_execution,
+ "condition_on_failure_feedback",
+ not self.retry_only,
+ )
+ self.use_harness_learned_skills = self._config_bool(
+ raw_execution,
+ "use_harness_learned_skills",
+ not self.retry_only,
+ )
+ self._embedding_failed = False
+ if not resume:
+ U.f_remove(self.raw_path)
+ U.f_remove(self.abstract_path)
+ U.f_remove(self.embedding_cache_path)
+ U.f_remove(self.proposed_updates_path)
+ self.abstract_memory = (
+ U.load_json(self.abstract_path) if U.f_exists(self.abstract_path) else self._empty_store()
+ )
+ changed = self._migrate_store()
+ self.embedding_cache = self._load_embedding_cache()
+ if changed or not U.f_exists(self.abstract_path):
+ self.save_abstract()
+
+ def _empty_store(self) -> dict[str, Any]:
+ return {
+ "schema_version": SCHEMA_VERSION,
+ **{memory_type: [] for memory_type in MEMORY_TYPES},
+ "updated_at": None,
+ }
+
+ def append_raw(self, record: dict[str, Any]) -> dict[str, Any]:
+ record = {"time": now_iso(), **record}
+ # Retry-only is a zero-learning baseline; its execution remains in the
+ # curriculum/telemetry logs but must not enter the memory pipeline.
+ if not self.retry_only:
+ with open(self.raw_path, "a", encoding="utf-8") as fp:
+ fp.write(json.dumps(record, ensure_ascii=False) + "\n")
+ return record
+
+ def append_proposed_update(self, update: dict[str, Any]) -> None:
+ update = {"time": now_iso(), **update}
+ with open(self.proposed_updates_path, "a", encoding="utf-8") as fp:
+ fp.write(json.dumps(update, ensure_ascii=False) + "\n")
+
+ def save_abstract(self) -> None:
+ U.f_mkdir_in_path(self.abstract_path)
+ with open(self.abstract_path, "w", encoding="utf-8") as fp:
+ json.dump(self.abstract_memory, fp, ensure_ascii=False, indent=2)
+
+ def merge_abstract(
+ self,
+ update: dict[str, Any],
+ *,
+ source_raw_time: str | None = None,
+ context: dict[str, Any] | None = None,
+ ) -> None:
+ """Merge approved memories, consolidating exact normalized duplicates."""
+ changed = False
+ for memory_type in MEMORY_TYPES:
+ incoming = update.get(memory_type, [])
+ if isinstance(incoming, (str, dict)):
+ incoming = [incoming]
+ if not isinstance(incoming, list):
+ continue
+ existing = self.abstract_memory.setdefault(memory_type, [])
+ by_content = {
+ self._normalize_content(record.get("content", "")): record
+ for record in existing
+ if isinstance(record, dict)
+ }
+ for item in incoming:
+ record = self._coerce_record(
+ item,
+ memory_type,
+ source_raw_time=source_raw_time,
+ context=context,
+ )
+ if record is None:
+ continue
+ key = self._normalize_content(record["content"])
+ duplicate = by_content.get(key)
+ if duplicate is not None:
+ duplicate["evidence_count"] = int(duplicate.get("evidence_count", 1)) + 1
+ duplicate["confidence"] = round(
+ min(0.99, max(float(duplicate.get("confidence", 0.6)), float(record["confidence"])) + 0.03),
+ 3,
+ )
+ duplicate["updated_at"] = now_iso()
+ self._merge_unique(duplicate, record, "entities")
+ self._merge_unique(duplicate, record, "skills")
+ self._merge_unique(duplicate, record, "source_raw_times")
+ changed = True
+ continue
+ existing.append(record)
+ by_content[key] = record
+ changed = True
+ if changed:
+ self.abstract_memory["schema_version"] = SCHEMA_VERSION
+ self.abstract_memory["updated_at"] = now_iso()
+ self.save_abstract()
+
+ def retrieve_abstract(self, query: str, limit: int = 12) -> list[str]:
+ """Retrieve memories using semantic, lexical and structured signals.
+
+ Embeddings are optional. Any model or endpoint failure disables the
+ semantic component for this process and cleanly falls back to local
+ scoring, so memory cannot make an agent task unavailable.
+ """
+ if self.retry_only:
+ return []
+ records = self._active_records()
+ if not records or limit <= 0:
+ return []
+
+ query_tokens = self._tokens(query)
+ query_entities = self._extract_entities(query)
+ query_skills = self._extract_skills(query)
+ semantic_scores = self._semantic_scores(query, records)
+ now = datetime.now(timezone.utc)
+ ranked: list[tuple[float, float, dict[str, Any]]] = []
+ for record in records:
+ content = self._normalize_equipment_memory(str(record.get("content", "")))
+ content_tokens = self._tokens(content)
+ lexical = self._jaccard(query_tokens, content_tokens)
+ entity = self._overlap(query_entities, set(record.get("entities", [])))
+ skill = self._overlap(query_skills, set(record.get("skills", [])))
+ confidence = self._clamp(float(record.get("confidence", 0.6)))
+ recency = self._recency_score(record.get("updated_at") or record.get("created_at"), now)
+ evidence = min(1.0, math.log1p(max(1, int(record.get("evidence_count", 1)))) / math.log(6))
+ semantic = semantic_scores.get(record["id"], 0.0)
+ if semantic_scores:
+ score = (
+ 0.45 * semantic + 0.20 * lexical + 0.12 * entity
+ + 0.08 * skill + 0.08 * confidence + 0.04 * recency + 0.03 * evidence
+ )
+ else:
+ # Re-normalized local-only weights; unlike the old strategy,
+ # this handles Chinese via character tokens and favors recent,
+ # repeatedly evidenced records when no lexical term matches.
+ score = (
+ 0.45 * lexical + 0.20 * entity + 0.12 * skill
+ + 0.12 * confidence + 0.06 * recency + 0.05 * evidence
+ )
+ ranked.append((score, self._timestamp(record), record))
+ ranked.sort(key=lambda item: (item[0], item[1]), reverse=True)
+ selected = [record for _, _, record in ranked[: min(limit, len(ranked))]]
+ retrieval_time = now_iso()
+ for record in selected:
+ record["last_retrieved_at"] = retrieval_time
+ record["retrieval_count"] = int(record.get("retrieval_count", 0)) + 1
+ self.save_abstract()
+ return [f"{record['type']}: {self._normalize_equipment_memory(record['content'])}" for record in selected]
+
+ def _load_backend_config(self, path: str) -> dict[str, Any]:
+ if not path:
+ return {}
+ try:
+ with open(path, encoding="utf-8") as fp:
+ value = json.load(fp)
+ return value if isinstance(value, dict) else {}
+ except (OSError, json.JSONDecodeError):
+ return {}
+
+ @staticmethod
+ def _config_bool(config: dict[str, Any], key: str, default: bool) -> bool:
+ value = config.get(key)
+ return value if isinstance(value, bool) else default
+
+ def _migrate_store(self) -> bool:
+ if not isinstance(self.abstract_memory, dict):
+ self.abstract_memory = self._empty_store()
+ return True
+ changed = self.abstract_memory.get("schema_version") != SCHEMA_VERSION
+ for memory_type in MEMORY_TYPES:
+ values = self.abstract_memory.get(memory_type, [])
+ if not isinstance(values, list):
+ values = [values] if values else []
+ changed = True
+ migrated = []
+ for item in values:
+ record = self._coerce_record(item, memory_type)
+ if record is not None:
+ migrated.append(record)
+ if not isinstance(item, dict) or item != record:
+ changed = True
+ self.abstract_memory[memory_type] = migrated
+ self.abstract_memory["schema_version"] = SCHEMA_VERSION
+ self.abstract_memory.setdefault("updated_at", now_iso() if changed else None)
+ return changed
+
+ def _coerce_record(
+ self,
+ item: Any,
+ memory_type: str,
+ *,
+ source_raw_time: str | None = None,
+ context: dict[str, Any] | None = None,
+ ) -> dict[str, Any] | None:
+ if isinstance(item, str):
+ content = item.strip()
+ supplied: dict[str, Any] = {}
+ elif isinstance(item, dict):
+ content = str(item.get("content") or item.get("text") or "").strip()
+ supplied = item
+ else:
+ return None
+ if not content:
+ return None
+ context = context or {}
+ created_at = str(supplied.get("created_at") or now_iso())
+ sources = self._string_list(supplied.get("source_raw_times", []))
+ if source_raw_time and source_raw_time not in sources:
+ sources.append(source_raw_time)
+ record_id = str(supplied.get("id") or self._memory_id(memory_type, content))
+ record = {
+ "id": record_id,
+ "type": memory_type,
+ "content": content,
+ "task_kind": str(supplied.get("task_kind") or context.get("task_kind") or "unknown"),
+ "entities": self._string_list(supplied.get("entities")) or sorted(self._extract_entities(content)),
+ "skills": self._string_list(supplied.get("skills"))
+ or self._string_list(context.get("skills"))
+ or sorted(self._extract_skills(content)),
+ "conditions": self._string_list(supplied.get("conditions"))
+ or self._string_list(context.get("conditions")),
+ "outcome": str(
+ supplied.get("outcome") or context.get("outcome") or self._infer_outcome(memory_type)
+ ),
+ "confidence": round(self._clamp(self._number(supplied.get("confidence"), 0.6)), 3),
+ "evidence_count": max(1, self._integer(supplied.get("evidence_count"), 1)),
+ "source_raw_times": sources,
+ "created_at": created_at,
+ "updated_at": str(supplied.get("updated_at") or created_at),
+ "last_retrieved_at": supplied.get("last_retrieved_at"),
+ "retrieval_count": max(0, self._integer(supplied.get("retrieval_count"), 0)),
+ "status": str(supplied.get("status", "active")),
+ }
+ return record
+
+ def _semantic_scores(self, query: str, records: list[dict[str, Any]]) -> dict[str, float]:
+ if self.embedding_model is None or self._embedding_failed:
+ return {}
+ try:
+ missing = [
+ record
+ for record in records
+ if self.embedding_cache.get(record["id"], {}).get("content_hash")
+ != self._content_hash(record["content"])
+ ]
+ if missing:
+ vectors = self.embedding_model.embed_documents([record["content"] for record in missing])
+ if len(vectors) != len(missing):
+ raise ValueError("embedding count does not match memory count")
+ for record, vector in zip(missing, vectors):
+ self.embedding_cache[record["id"]] = {
+ "content_hash": self._content_hash(record["content"]),
+ "vector": vector,
+ }
+ self._save_embedding_cache()
+ query_vector = self.embedding_model.embed_query(query)
+ scores = {}
+ for record in records:
+ cached = self.embedding_cache.get(record["id"], {})
+ if cached.get("content_hash") != self._content_hash(record["content"]):
+ continue
+ scores[record["id"]] = max(0.0, self._cosine(query_vector, cached.get("vector", [])))
+ return scores
+ except Exception: # Embedding is an optional retrieval enhancement.
+ self._embedding_failed = True
+ return {}
+
+ def _load_embedding_cache(self) -> dict[str, dict[str, Any]]:
+ if not U.f_exists(self.embedding_cache_path):
+ return {}
+ try:
+ value = U.load_json(self.embedding_cache_path)
+ return value if isinstance(value, dict) else {}
+ except Exception:
+ return {}
+
+ def _save_embedding_cache(self) -> None:
+ with open(self.embedding_cache_path, "w", encoding="utf-8") as fp:
+ json.dump(self.embedding_cache, fp, ensure_ascii=False)
+
+ def _active_records(self) -> list[dict[str, Any]]:
+ return [
+ record
+ for memory_type in MEMORY_TYPES
+ for record in self.abstract_memory.get(memory_type, [])
+ if isinstance(record, dict) and record.get("status", "active") == "active"
+ ]
+
+ def _tokens(self, text: str) -> set[str]:
+ raw = _WORD.findall(str(text).lower().replace("_", " "))
+ tokens = {token for token in raw if len(token) > 1 or "\u4e00" <= token <= "\u9fff"}
+ chinese = "".join(token for token in raw if len(token) == 1 and "\u4e00" <= token <= "\u9fff")
+ tokens.update(chinese[index : index + 2] for index in range(max(0, len(chinese) - 1)))
+ return tokens
+
+ def _extract_entities(self, text: str) -> set[str]:
+ return {match.lower() for match in _ITEM_ID.findall(str(text))}
+
+ def _extract_skills(self, text: str) -> set[str]:
+ return set(_SKILL_NAME.findall(str(text)))
+
+ def _memory_id(self, memory_type: str, content: str) -> str:
+ digest = hashlib.sha256(f"{memory_type}\0{self._normalize_content(content)}".encode()).hexdigest()[:16]
+ return f"mem_{digest}"
+
+ def _content_hash(self, content: str) -> str:
+ return hashlib.sha256(content.encode()).hexdigest()
+
+ def _normalize_content(self, content: str) -> str:
+ return " ".join(str(content).lower().split())
+
+ def _infer_outcome(self, memory_type: str) -> str:
+ return "failure" if memory_type == "failure_patterns" else "unknown"
+
+ def _string_list(self, value: Any) -> list[str]:
+ if isinstance(value, str):
+ value = [value]
+ if not isinstance(value, list):
+ return []
+ return list(dict.fromkeys(str(item) for item in value if str(item).strip()))
+
+ def _merge_unique(self, target: dict[str, Any], source: dict[str, Any], key: str) -> None:
+ target[key] = list(dict.fromkeys(self._string_list(target.get(key)) + self._string_list(source.get(key))))
+
+ def _jaccard(self, left: set[str], right: set[str]) -> float:
+ return len(left & right) / len(left | right) if left and right else 0.0
+
+ def _overlap(self, left: set[str], right: set[str]) -> float:
+ return len(left & right) / len(left) if left else 0.0
+
+ def _cosine(self, left: list[float], right: list[float]) -> float:
+ if not left or not right or len(left) != len(right):
+ return 0.0
+ denominator = math.sqrt(sum(x * x for x in left)) * math.sqrt(sum(x * x for x in right))
+ return sum(x * y for x, y in zip(left, right)) / denominator if denominator else 0.0
+
+ def _clamp(self, value: float) -> float:
+ return max(0.0, min(1.0, value))
+
+ def _number(self, value: Any, default: float) -> float:
+ try:
+ return float(value)
+ except (TypeError, ValueError):
+ return default
+
+ def _integer(self, value: Any, default: int) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+ def _recency_score(self, value: Any, now: datetime) -> float:
+ try:
+ parsed = datetime.fromisoformat(str(value))
+ if parsed.tzinfo is None:
+ parsed = parsed.replace(tzinfo=timezone.utc)
+ days = max(0.0, (now - parsed.astimezone(timezone.utc)).total_seconds() / 86400)
+ return math.exp(-days / 90)
+ except (TypeError, ValueError):
+ return 0.0
+
+ def _timestamp(self, record: dict[str, Any]) -> float:
+ try:
+ return datetime.fromisoformat(str(record.get("updated_at") or record.get("created_at"))).timestamp()
+ except (TypeError, ValueError):
+ return 0.0
+
+ def _normalize_equipment_memory(self, item: str) -> str:
+ replacements = {
+ "equipment[4] (off-hand)": "equipment[4] (main_hand)",
+ "equipment[4] - off-hand": "equipment[4] - main_hand",
+ "equipment[4], off-hand": "equipment[4], main_hand",
+ "equipment[4] is off-hand": "equipment[4] is main_hand",
+ "equipment[4] is the off-hand": "equipment[4] is the main_hand",
+ "slot 4 (off-hand)": "slot 4 (main_hand)",
+ "slot [4] and off-hand": "slot [4] and main_hand",
+ "slot [4] which is likely not the off-hand": "slot [4] which is main_hand",
+ }
+ normalized = item
+ for old, new in replacements.items():
+ normalized = normalized.replace(old, new)
+ return normalized
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/optimizer.py b/experiments/Odyssey/Odyssey/odyssey/harness/optimizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..460810c533d6a52ecb46ac4f58a4da9f48c203cb
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/optimizer.py
@@ -0,0 +1,139 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import call_json, now_iso
+
+
+OPTIMIZER_PROMPT = """You are the Continual Optimizer for a Minecraft Harness.
+Given the structured task, route, execution result, and raw trajectory, propose useful updates.
+Abstract memory should capture reusable lessons from the trajectory and result; future routing should mainly use abstract memory.
+Capability updates may add or revise a skill only when the raw evidence shows a stable reusable behavior or a missing capability.
+When writing JavaScript skill code, use capability_summary.primitive_reference for valid primitive signatures and capability_summary.skill_code_examples as style examples.
+Important runtime rule: capability_summary.primitive_reference is documentation only. It is not a JavaScript runtime object.
+Never write JavaScript that accesses capability_summary.primitive_reference, destructures primitive functions from capability_summary, or expects capability_summary.task_state at runtime.
+Generated JavaScript must follow the existing Odyssey skill-library style:
+- Define exactly async function skillName(bot) { ... }.
+- Call injected helpers directly, for example await craftItem(bot, "crafting_table", 1).
+- Call injected helpers directly, for example await mineBlock(bot, "stone", 1).
+- Call injected helpers directly, for example const position = await findSuitablePosition(bot); await placeItem(bot, "crafting_table", position).
+- Use concrete Minecraft item/block names such as "birch_planks", "oak_log", or "crafting_table"; do not use generic names like "planks", "logs", or "wooden_planks".
+- Primitive count arguments must be numbers, not objects.
+Prefer Odyssey primitives such as placeItem, craftItem, mineBlock, exploreUntil, and findSuitablePosition over reimplementing Mineflayer logic.
+When possible, include a primitive_plan, preconditions, and success_checks so the Harness can version and test the learned skill.
+For placement skills, the common safe pattern is:
+const position = await findSuitablePosition(bot);
+await placeItem(bot, "item_name", position);
+Do not invent primitive arguments or call a primitive with undefined values.
+Prompt updates should be small and general, not task-specific hacks.
+Return only JSON with:
+{
+ "abstract_memory": {
+ "lessons": [{"content": "reusable lesson", "entities": ["canonical_item_id"], "skills": ["skillName"], "conditions": ["when this applies"], "confidence": 0.0}],
+ "task_strategies": [],
+ "failure_patterns": [],
+ "skill_notes": []
+ },
+ "capability_updates": [
+ {
+ "action": "add_skill" | "update_skill",
+ "name": "camelCaseSkillName",
+ "description": "what this skill does",
+ "code": "async function ...",
+ "reason": "why this should exist",
+ "preconditions": [],
+ "success_checks": [],
+ "primitive_plan": [],
+ "test_spec": {"run_live": false}
+ }
+ ],
+ "prompt_updates": {
+ "task_interface": "",
+ "adaptive_router": "",
+ "continual_evaluator": "",
+ "continual_optimizer": ""
+ },
+ "notes": "brief summary"
+}
+
+Each abstract-memory entry should be an object following the example schema.
+Use confidence from 0 to 1. Include only entities, skills and conditions that
+are supported by this trajectory. Do not generate ids, timestamps, evidence
+counts, retrieval metadata, task_kind, outcome or status; the Harness attaches
+those fields from the approved raw execution provenance. Legacy string entries
+remain accepted but structured objects are preferred.
+"""
+
+
+class ContinualOptimizer:
+ def __init__(self, model_name: str, ckpt_dir: str = "ckpt"):
+ self.model_name = model_name
+ self.base_prompt = OPTIMIZER_PROMPT
+ self.system_prompt = OPTIMIZER_PROMPT
+ self.root = U.f_mkdir(ckpt_dir, "harness")
+ self.prompt_overrides_path = U.f_join(self.root, "prompt_overrides.json")
+ if U.f_exists(self.prompt_overrides_path):
+ self.prompt_overrides = U.load_json(self.prompt_overrides_path)
+ else:
+ self.prompt_overrides = {}
+
+ def propose_update(
+ self,
+ *,
+ task_state: dict[str, Any],
+ route: dict[str, Any],
+ raw_record: dict[str, Any],
+ evaluation: dict[str, Any],
+ abstract_memory: dict[str, Any],
+ capability_summary: dict[str, Any],
+ ) -> dict[str, Any]:
+ update = call_json(
+ self.system_prompt,
+ {
+ "task_state": task_state,
+ "route": route,
+ "raw_record": raw_record,
+ "evaluation": evaluation,
+ "current_abstract_memory": abstract_memory,
+ "capability_summary": capability_summary,
+ },
+ self.model_name,
+ )
+ if not isinstance(update, dict):
+ # LLM JSON can be syntactically valid while violating the schema.
+ # Treat it as an empty proposal rather than breaking the task that
+ # has already finished executing.
+ update = {}
+ update.setdefault("abstract_memory", {})
+ update.setdefault("capability_updates", [])
+ update.setdefault("prompt_updates", {})
+ update.setdefault("notes", "")
+ if not isinstance(update["abstract_memory"], dict):
+ update["abstract_memory"] = {}
+ if not isinstance(update["capability_updates"], list):
+ update["capability_updates"] = []
+ if not isinstance(update["prompt_updates"], dict):
+ update["prompt_updates"] = {}
+ if not isinstance(update["notes"], str):
+ update["notes"] = ""
+ return update
+
+ def apply_prompt_updates(self, updates: dict[str, Any] | Any) -> dict[str, Any]:
+ if not isinstance(updates, dict):
+ return {}
+ applied = {}
+ for key, value in updates.items():
+ if not value or not isinstance(value, str):
+ continue
+ self.prompt_overrides[key] = {
+ "updated_at": now_iso(),
+ "instruction": value,
+ }
+ applied[key] = value
+ if applied:
+ with open(self.prompt_overrides_path, "w", encoding="utf-8") as fp:
+ json.dump(self.prompt_overrides, fp, ensure_ascii=False, indent=2)
+ return applied
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/orchestrator.py b/experiments/Odyssey/Odyssey/odyssey/harness/orchestrator.py
new file mode 100644
index 0000000000000000000000000000000000000000..4fc05fae141fcadfcf182ff17da4ccd3939212ed
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/orchestrator.py
@@ -0,0 +1,1376 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from .base_manager import BaseManager
+from .capability import CapabilityMap
+from .completion_contract import (
+ build_completion_contract,
+ contract_progress,
+ evaluate_completion_contract,
+)
+from .common import event_digest
+from .decomposer import TaskDecomposer
+from .evaluator import ContinualEvaluator
+from .health import SkillHealthStore
+from .memory import ExperienceMemory
+from .optimizer import ContinualOptimizer
+from .router import AdaptiveRouter
+from .synthesizer import SkillSynthesizer
+from .task_interface import TaskInterface
+from .telemetry import ExecutionRecorder, inventory_from
+from .tester import SkillTestGate
+
+class HarnessOrchestrator:
+ def __init__(
+ self,
+ *,
+ odyssey,
+ model_name: str,
+ ckpt_dir: str = "ckpt",
+ resume: bool = True,
+ memory_config_path: str | None = None,
+ ):
+ self.odyssey = odyssey
+ self.model_name = model_name
+ self.memory = ExperienceMemory(
+ ckpt_dir=ckpt_dir,
+ resume=resume,
+ embedding_model=getattr(odyssey.skill_manager, "embedding_model", None),
+ backend_config=memory_config_path,
+ )
+ self.capabilities = CapabilityMap(
+ odyssey.skill_manager,
+ ckpt_dir=ckpt_dir,
+ include_harness_skills=self.memory.use_harness_learned_skills,
+ )
+ self.task_interface = TaskInterface(model_name=model_name)
+ self.decomposer = TaskDecomposer(model_name=model_name)
+ self.router = AdaptiveRouter(model_name=model_name)
+ self.evaluator = ContinualEvaluator(model_name=model_name)
+ self.optimizer = ContinualOptimizer(model_name=model_name, ckpt_dir=ckpt_dir)
+ self.synthesizer = SkillSynthesizer(model_name=model_name)
+ self.test_gate = SkillTestGate(ckpt_dir=ckpt_dir, resume=resume)
+ self.execution_recorder = ExecutionRecorder(ckpt_dir=ckpt_dir, resume=resume)
+ self.health = SkillHealthStore(ckpt_dir=ckpt_dir, resume=resume)
+ self.base_manager = BaseManager(ckpt_dir=ckpt_dir, resume=resume)
+ self.refresh_prompt_overrides()
+
+ def refresh_prompt_overrides(self) -> None:
+ overrides = self.optimizer.prompt_overrides if self.memory.prompt_updates_enabled else {}
+ self.task_interface.system_prompt = self.task_interface.base_prompt
+ self.router.system_prompt = self.router.base_prompt
+ self.evaluator.task_prompt = self.evaluator.base_task_prompt
+ self.evaluator.update_prompt = self.evaluator.base_update_prompt
+ self.optimizer.system_prompt = self.optimizer.base_prompt
+ if "task_interface" in overrides:
+ self.task_interface.system_prompt = (
+ self.task_interface.system_prompt
+ + "\n\nApproved continual update:\n"
+ + overrides["task_interface"].get("instruction", "")
+ )
+ if "adaptive_router" in overrides:
+ self.router.system_prompt = (
+ self.router.system_prompt
+ + "\n\nApproved continual update:\n"
+ + overrides["adaptive_router"].get("instruction", "")
+ )
+ if "continual_evaluator" in overrides:
+ self.evaluator.task_prompt = (
+ self.evaluator.task_prompt
+ + "\n\nApproved continual update:\n"
+ + overrides["continual_evaluator"].get("instruction", "")
+ )
+ self.evaluator.update_prompt = (
+ self.evaluator.update_prompt
+ + "\n\nApproved continual update:\n"
+ + overrides["continual_evaluator"].get("instruction", "")
+ )
+ if "continual_optimizer" in overrides:
+ self.optimizer.system_prompt = (
+ self.optimizer.system_prompt
+ + "\n\nApproved continual update:\n"
+ + overrides["continual_optimizer"].get("instruction", "")
+ )
+ disabled_outputs = []
+ if not self.memory.skill_updates_enabled:
+ disabled_outputs.append("capability_updates must be []")
+ if not self.memory.prompt_updates_enabled:
+ disabled_outputs.append("prompt_updates must be {}")
+ if disabled_outputs:
+ self.optimizer.system_prompt += (
+ "\n\nFixed experiment update policy: "
+ + "; ".join(disabled_outputs)
+ + ". Only propose enabled update families."
+ )
+
+ def _execute_protected_code(self, parsed: dict[str, Any], *, skill_name: str) -> dict[str, Any]:
+ """Execute one Harness action with current persistent assets guarded."""
+ guarded = dict(parsed)
+ guarded["exec_code"] = self.base_manager.protection_call() + "\n" + parsed["exec_code"]
+ return self.odyssey.execute_harness_code(guarded, skill_name=skill_name)
+
+ def _execute_protected_skill(self, skill_name: str) -> dict[str, Any]:
+ if skill_name not in self.odyssey.skill_manager.skills:
+ raise KeyError(f"Unknown harness skill: {skill_name}")
+ parsed = self.odyssey._parse_skill_code(
+ self.odyssey.skill_manager.skills[skill_name]["code"]
+ )
+ return self._execute_protected_code(parsed, skill_name=skill_name)
+
+ def _duplicate_primitive_is_blocked(
+ self,
+ primitive_key: str,
+ failed_primitive_attempts: set[str],
+ ) -> bool:
+ return (
+ self.memory.retry_suppression_enabled
+ and primitive_key in failed_primitive_attempts
+ )
+
+ def _planning_feedback(self, last_feedback: dict[str, Any] | None) -> dict[str, Any] | None:
+ if not self.memory.condition_on_failure_feedback:
+ return None
+ return last_feedback
+
+ def _planning_events(
+ self,
+ events: list[tuple[str, dict[str, Any]]] | None,
+ ) -> list[tuple[str, dict[str, Any]]]:
+ if self.memory.condition_on_failure_feedback:
+ return list(events or [])
+ # Retry-only samples may observe the changed Minecraft state, but must
+ # not receive the previous attempt's chat/error/save transcript. The
+ # latest observe event contains inventory, status, nearby blocks and
+ # other world evidence needed for a fresh generation.
+ for event in reversed(events or []):
+ if event[0] == "observe":
+ return [event]
+ return []
+
+ def run_instruction(
+ self,
+ instruction: str,
+ *,
+ events: list[tuple[str, dict[str, Any]]] | None = None,
+ reset_env: bool = False,
+ reset_mode: str = "soft",
+ max_steps: int = 3,
+ max_replans: int = 2,
+ completion_contract: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ if reset_env or events is None:
+ events = self.odyssey.env.reset(
+ options={
+ "mode": reset_mode,
+ "wait_ticks": self.odyssey.env_wait_ticks,
+ "username": self.odyssey.username,
+ }
+ )
+ self.odyssey.last_events = events
+
+ completion_contract = completion_contract or build_completion_contract(
+ instruction,
+ inventory_from(events),
+ model_name=self.model_name,
+ )
+ initial_progress = contract_progress(completion_contract, inventory_from(events))
+ initial_abstract = self.memory.retrieve_abstract(instruction)
+ initial_capabilities = self.capabilities.summarize(instruction)
+ subgoals = self.decomposer.decompose(
+ instruction=instruction,
+ events=self._planning_events(events),
+ abstract_memory=initial_abstract,
+ capability_summary=initial_capabilities,
+ completion_contract=completion_contract,
+ contract_progress=initial_progress,
+ )
+ final_result: dict[str, Any] = {"success": False, "subgoals": subgoals}
+ completed_subgoals: list[str] = []
+ trajectory: list[dict[str, Any]] = []
+ # HCL updates are task-boundary work. Keep raw records while acting,
+ # then optimize one representative trajectory after the task reaches a
+ # terminal outcome. This prevents learning calls from blocking every
+ # ordinary Minecraft action.
+ learning_records: list[dict[str, Any]] = []
+ # These blocks are scoped to one top-level instruction. They prevent a
+ # failed capability from consuming every retry, without globally
+ # deleting a skill that might be valid in another environment.
+ diagnostic_names = set(self.capabilities.diagnostic_skill_names())
+ # A diagnostic is an observation aid, not a state-changing capability.
+ # Historical runtime failures must not remove the only way to inspect a
+ # new world state; it still has the per-subgoal diagnostic-step budget.
+ blocked_skills = (
+ {
+ name: context
+ for name, context in self.health.repeatedly_failed_contexts().items()
+ if name not in diagnostic_names
+ }
+ if self.memory.skill_health_updates_enabled and self.memory.retry_suppression_enabled
+ else {}
+ )
+ subgoal_index = 0
+ replan_count = 0
+ while subgoal_index < len(subgoals):
+ active_instruction = subgoals[subgoal_index]
+ last_feedback: dict[str, Any] | None = None
+ subgoal_result: dict[str, Any] = {}
+ action_steps = 0
+ diagnostic_steps = 0
+ failed_primitive_attempts: set[str] = set()
+ # Diagnostics are observation aids, not task-progress actions. Give
+ # them a small separate budget so one failed diagnostic cannot
+ # consume the actions needed to satisfy a resource contract.
+ for step_index in range(max_steps + 2):
+ if action_steps >= max_steps:
+ break
+ contract_result = evaluate_completion_contract(completion_contract, inventory_from(events))
+ if contract_result and contract_result["success"]:
+ evaluation = {
+ "success": True,
+ "critique": contract_result["critique"],
+ "evidence": contract_result["evidence"],
+ "regression_risks": contract_result["regression_risks"],
+ "contract_evaluation": contract_result,
+ }
+ subgoal_result = {
+ "success": True,
+ "needs_user": False,
+ "task_state": {
+ "goal": active_instruction,
+ "instruction": active_instruction,
+ "completion_contract": completion_contract,
+ "contract_progress": contract_progress(completion_contract, inventory_from(events)),
+ },
+ "route": {
+ "decision": "contract_satisfied",
+ "skill_name": "",
+ "reasoning": "Fixed completion contract is already satisfied.",
+ },
+ "evaluation": evaluation,
+ "inventory": inventory_from(events),
+ }
+ trajectory.append(
+ {
+ "type": "contract_check",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "evaluation": evaluation,
+ "success": True,
+ }
+ )
+ break
+ planning_feedback = self._planning_feedback(last_feedback)
+ query = (
+ active_instruction
+ if not planning_feedback
+ else f"{active_instruction}\n{planning_feedback}"
+ )
+ abstract = self.memory.retrieve_abstract(query)
+ capability_summary = self.capabilities.summarize(query)
+ capability_summary["update_policy"] = dict(self.memory.update_policy)
+ candidate_names = capability_summary.get("candidate_skill_names", [])
+ capability_summary["skill_health"] = (
+ self.health.summarize(candidate_names)
+ if self.memory.skill_health_updates_enabled
+ else {}
+ )
+ capability_summary["blocked_skill_names"] = sorted(blocked_skills)
+ capability_summary["scenario_failures"] = list(blocked_skills.values())
+ capability_summary["base_state"] = self.base_manager.public_state()
+ task_state = self.task_interface.structure(
+ instruction=active_instruction,
+ events=self._planning_events(events),
+ tool_returns=[planning_feedback] if planning_feedback else [],
+ abstract_memory=abstract,
+ capability_summary=capability_summary,
+ completion_contract=completion_contract,
+ contract_progress=contract_progress(completion_contract, inventory_from(events)),
+ )
+ task_state["completion_contract"] = completion_contract
+ task_state["contract_progress"] = contract_progress(completion_contract, inventory_from(events))
+ task_state["base_state"] = self.base_manager.public_state()
+ if task_state.get("task_kind") == "observation":
+ observation_route = {
+ "decision": "observation_check",
+ "skill_name": "",
+ "reasoning": "The model classified this as a read-only state check.",
+ }
+ evaluation = self.evaluator.judge_task(
+ task_state=task_state,
+ route=observation_route,
+ events=events,
+ inventory=inventory_from(events),
+ )
+ trajectory.append(
+ {
+ "type": "observation_check",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "route": observation_route,
+ "evaluation": evaluation,
+ "success": evaluation["success"],
+ }
+ )
+ if evaluation["success"]:
+ subgoal_result = {
+ "success": True,
+ "needs_user": False,
+ "task_state": task_state,
+ "route": observation_route,
+ "evaluation": evaluation,
+ "inventory": inventory_from(events),
+ }
+ break
+ last_feedback = evaluation
+ route = self._primitive_contract_fallback(
+ capability_summary=capability_summary,
+ completion_contract=completion_contract,
+ inventory=inventory_from(events),
+ blocked_skills=blocked_skills,
+ )
+ if route is None:
+ route = self.router.route(
+ task_state=task_state,
+ abstract_memory=abstract,
+ capability_summary=capability_summary,
+ last_feedback=planning_feedback,
+ completion_contract=completion_contract,
+ contract_progress=task_state["contract_progress"],
+ )
+
+ asset_intent = route.get("persistent_asset", {})
+ if asset_intent.get("action") == "place_or_reuse":
+ asset_name = asset_intent.get("block_name", "")
+ asset_request = self.base_manager.maintain(asset_name, events)
+ if asset_request is None:
+ last_feedback = {
+ "success": False,
+ "critique": "Persistent asset name was invalid; choose a concrete Minecraft block name.",
+ }
+ continue
+ events_before = events
+ action_steps += 1
+ try:
+ parsed = self.odyssey._parse_skill_code(asset_request["code"])
+ exec_result = self._execute_protected_code(
+ parsed, skill_name="persistentWorldAsset"
+ )
+ except Exception as exc: # noqa: BLE001
+ exec_result = {"skill_name": "persistentWorldAsset", "events": events, "error": str(exc)}
+ events = exec_result.get("events") or events
+ self.base_manager.reconcile(events)
+ inventory = inventory_from(events)
+ asset_ready = self.base_manager.asset_ready(asset_name)
+ evaluation = {
+ "success": asset_ready and not exec_result.get("error"),
+ "critique": (
+ "Model-designated persistent asset was verified at its registered coordinate."
+ if asset_ready and not exec_result.get("error")
+ else "Persistent asset was not verified at its registered coordinate."
+ ),
+ "evidence": [self.base_manager.public_state()],
+ "regression_risks": [],
+ }
+ if exec_result.get("error"):
+ evaluation["critique"] += f" Execution error: {exec_result['error']}"
+ structured_execution = self.execution_recorder.build_record(
+ instruction=active_instruction,
+ step_index=step_index,
+ route=route,
+ events_before=events_before,
+ events_after=events,
+ execution=exec_result,
+ evaluation=evaluation,
+ )
+ self.execution_recorder.append(structured_execution)
+ trajectory.append({
+ "type": "persistent_asset", "original_instruction": instruction,
+ "subgoal": active_instruction, "subgoal_index": subgoal_index,
+ "step_index": step_index, "route": route,
+ "execution": structured_execution, "evaluation": evaluation,
+ "success": evaluation["success"],
+ })
+ subgoal_result = {
+ "success": evaluation["success"], "needs_user": False,
+ "task_state": task_state, "route": route, "evaluation": evaluation,
+ "inventory": inventory,
+ }
+ last_feedback = evaluation
+ if evaluation["success"]:
+ break
+ continue
+
+ requirements = self.capabilities.world_asset_requirements(route)
+ if requirements:
+ registered_assets, missing_roles = self.base_manager.assets_for_requirements(requirements)
+ if missing_roles:
+ last_feedback = {
+ "success": False,
+ "critique": "Selected capability requires unregistered world assets: " + ", ".join(missing_roles),
+ "base_state": self.base_manager.public_state(),
+ }
+ trajectory.append({
+ "type": "missing_world_asset", "original_instruction": instruction,
+ "subgoal": active_instruction, "subgoal_index": subgoal_index,
+ "step_index": step_index, "route": route,
+ "missing_roles": missing_roles, "success": False,
+ })
+ continue
+ try:
+ parsed = self.odyssey._parse_skill_code(self.base_manager.reach_code(registered_assets))
+ reach_result = self._execute_protected_code(parsed, skill_name="reachRegisteredAsset")
+ except Exception as exc: # noqa: BLE001
+ reach_result = {"events": events, "error": str(exc)}
+ events = reach_result.get("events") or events
+ self.base_manager.reconcile(events)
+ if reach_result.get("error"):
+ last_feedback = {
+ "success": False,
+ "critique": "Registered world asset could not be reached: " + reach_result["error"],
+ }
+ continue
+
+ if route["decision"] == "blocked_skill":
+ last_feedback = {
+ "success": False,
+ "critique": route.get("reasoning", "Blocked skill selected."),
+ "blocked_skill_names": sorted(blocked_skills),
+ }
+ trajectory.append(
+ {
+ "type": "blocked_route",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "route": route,
+ "success": False,
+ }
+ )
+ continue
+
+ if route["decision"] == "ask_user":
+ guarded_route = self._guard_unsatisfied_contract(
+ route,
+ completion_contract,
+ task_state["contract_progress"],
+ capability_summary,
+ )
+ if guarded_route is not None:
+ route = guarded_route
+ else:
+ trajectory.append(
+ {
+ "type": "attempt",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "route": route,
+ "success": False,
+ "needs_user": True,
+ }
+ )
+ subgoal_result = {
+ "success": False,
+ "needs_user": True,
+ "task_state": task_state,
+ "route": route,
+ "inventory": inventory_from(events),
+ "message": route.get("reasoning", "Harness needs more information."),
+ }
+ break
+ if route["decision"] == "request_new_skill":
+ created = self._try_synthesize_missing_skill(task_state, route, capability_summary)
+ raw_record = self.memory.append_raw(
+ {
+ "instruction": active_instruction,
+ "original_instruction": instruction,
+ "subgoal_index": subgoal_index,
+ "task_state": task_state,
+ "route": route,
+ "events_before": event_digest(events),
+ "execution": {
+ "skipped": True,
+ "reason": "missing capability",
+ "created_skill": created,
+ },
+ }
+ )
+ evaluation = {
+ "success": False,
+ "critique": "No existing skill was selected; router requested a new capability.",
+ "evidence": [],
+ "regression_risks": [],
+ }
+ trajectory.append(
+ {
+ "type": "attempt",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "route": route,
+ "evaluation": evaluation,
+ "created_skill": created,
+ "success": False,
+ }
+ )
+ learning_records.append(
+ {
+ "task_state": task_state,
+ "route": route,
+ "raw_record": raw_record,
+ "evaluation": evaluation,
+ "capability_summary": capability_summary,
+ }
+ )
+ if created.get("applied"):
+ last_feedback = {
+ "success": False,
+ "critique": f"Created learned skill {created.get('name')}; route again to execute it.",
+ }
+ continue
+ subgoal_result = {
+ "success": False,
+ "needs_user": False,
+ "task_state": task_state,
+ "route": route,
+ "evaluation": evaluation,
+ "created_skill": created,
+ }
+ break
+
+ if route["decision"] == "use_primitive":
+ primitive_name = route.get("primitive_name", "")
+ primitive_args = route.get("primitive_args", [])
+ primitive_key = json.dumps(
+ [primitive_name, primitive_args], ensure_ascii=False, sort_keys=True, default=str
+ )
+ if self._duplicate_primitive_is_blocked(
+ primitive_key,
+ failed_primitive_attempts,
+ ):
+ last_feedback = {
+ "success": False,
+ "critique": (
+ "Blocked an identical primitive retry because the previous call used the same "
+ "arguments and left inventory unchanged. Change the arguments or strategy. "
+ "For craftItem, count means recipe executions, not desired output quantity."
+ ),
+ "blocked_primitive": primitive_name,
+ "blocked_args": primitive_args,
+ }
+ continue
+ temporary = self.synthesizer.render_ephemeral_primitive(
+ primitive_name,
+ primitive_args,
+ )
+ if temporary is None:
+ last_feedback = {
+ "success": False,
+ "critique": "Primitive arguments failed the allow-listed plan validator; choose a skill or request a composition.",
+ }
+ continue
+ primitive_test = self.test_gate.validate_update(
+ temporary,
+ known_skill_names=set(capability_summary.get("all_skill_names", [])),
+ )
+ if not primitive_test.get("passed"):
+ last_feedback = {
+ "success": False,
+ "critique": "Primitive plan failed static validation; choose a skill or request a composition.",
+ }
+ continue
+ events_before = events
+ action_steps += 1
+ try:
+ parsed = self.odyssey._parse_skill_code(temporary["code"])
+ exec_result = self._execute_protected_code(
+ parsed,
+ skill_name=f"primitive:{primitive_name}",
+ )
+ except Exception as exc: # noqa: BLE001
+ exec_result = {
+ "skill_name": f"primitive:{primitive_name}",
+ "events": events,
+ "error": str(exc),
+ }
+ events = exec_result.get("events") or events
+ self.base_manager.reconcile(events)
+ inventory = inventory_from(events)
+ if (
+ self.memory.retry_suppression_enabled
+ and inventory == inventory_from(events_before)
+ ):
+ failed_primitive_attempts.add(primitive_key)
+ contract_evaluation = evaluate_completion_contract(completion_contract, inventory)
+ if contract_evaluation is not None:
+ evaluation = {
+ "success": contract_evaluation["success"],
+ "critique": contract_evaluation["critique"],
+ "evidence": contract_evaluation["evidence"],
+ "regression_risks": contract_evaluation["regression_risks"],
+ "contract_evaluation": contract_evaluation,
+ }
+ else:
+ evaluation = self.evaluator.judge_task(
+ task_state=task_state, route=route, events=events, inventory=inventory
+ )
+ if exec_result.get("error"):
+ evaluation["success"] = False
+ evaluation["critique"] = (
+ evaluation.get("critique", "") + f" Execution error: {exec_result['error']}"
+ ).strip()
+ structured_execution = self.execution_recorder.build_record(
+ instruction=active_instruction,
+ step_index=step_index,
+ route=route,
+ events_before=events_before,
+ events_after=events,
+ execution=exec_result,
+ evaluation=evaluation,
+ )
+ self.execution_recorder.append(structured_execution)
+ trajectory.append({
+ "type": "attempt", "original_instruction": instruction,
+ "subgoal": active_instruction, "subgoal_index": subgoal_index,
+ "step_index": step_index, "replan_count": replan_count,
+ "route": route, "execution": structured_execution,
+ "evaluation": evaluation, "success": evaluation["success"],
+ })
+ if self.memory.skill_health_updates_enabled:
+ self.health.update(f"primitive:{primitive_name}", evaluation["success"], evaluation)
+ raw_record = self.memory.append_raw({
+ "instruction": active_instruction, "original_instruction": instruction,
+ "subgoal_index": subgoal_index, "step_index": step_index,
+ "task_state": task_state, "route": route,
+ "execution": exec_result, "structured_execution": structured_execution,
+ "evaluation": evaluation, "completion_contract": completion_contract,
+ "events_after": event_digest(events),
+ })
+ learning_records.append({
+ "task_state": task_state, "route": route, "raw_record": raw_record,
+ "evaluation": evaluation, "capability_summary": capability_summary,
+ })
+ subgoal_result = {
+ "success": evaluation["success"], "needs_user": False,
+ "task_state": task_state, "route": route, "evaluation": evaluation,
+ "inventory": inventory, "raw_record": raw_record,
+ }
+ last_feedback = evaluation
+ if evaluation["success"]:
+ break
+ continue
+
+ events_before = events
+ is_diagnostic = route.get("skill_name") in self.capabilities.diagnostic_skill_names()
+ if is_diagnostic and self.memory.retry_suppression_enabled:
+ if diagnostic_steps >= 1:
+ last_feedback = {
+ "success": False,
+ "critique": "Diagnostic budget exhausted; choose an executable progress-making capability.",
+ }
+ continue
+ diagnostic_steps += 1
+ else:
+ action_steps += 1
+ try:
+ exec_result = self._execute_protected_skill(route["skill_name"])
+ except Exception as exc: # noqa: BLE001
+ exec_result = {
+ "skill_name": route["skill_name"],
+ "events": events,
+ "error": str(exc),
+ }
+ events = exec_result.get("events") or events
+ self.base_manager.reconcile(events)
+ inventory = inventory_from(events)
+ contract_evaluation = evaluate_completion_contract(completion_contract, inventory)
+ if contract_evaluation is not None:
+ # A fixed contract is already an observation-grounded
+ # success predicate. Calling an LLM here adds latency but
+ # cannot improve the truth value of the inventory check.
+ evaluation = {
+ "success": contract_evaluation["success"],
+ "critique": contract_evaluation["critique"],
+ "evidence": contract_evaluation["evidence"],
+ "regression_risks": contract_evaluation["regression_risks"],
+ "contract_evaluation": contract_evaluation,
+ }
+ else:
+ evaluation = self.evaluator.judge_task(
+ task_state=task_state,
+ route=route,
+ events=events,
+ inventory=inventory,
+ )
+ if exec_result.get("error"):
+ contract_result = evaluation.get("contract_evaluation")
+ if not (isinstance(contract_result, dict) and contract_result.get("success")):
+ evaluation["success"] = False
+ evaluation["critique"] = (
+ evaluation.get("critique", "") + f" Execution error: {exec_result['error']}"
+ ).strip()
+ structured_execution = self.execution_recorder.build_record(
+ instruction=active_instruction,
+ step_index=step_index,
+ route=route,
+ events_before=events_before,
+ events_after=events,
+ execution=exec_result,
+ evaluation=evaluation,
+ )
+ self.execution_recorder.append(structured_execution)
+ trajectory.append(
+ {
+ "type": "attempt",
+ "original_instruction": instruction,
+ "subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "replan_count": replan_count,
+ "route": route,
+ "execution": structured_execution,
+ "evaluation": evaluation,
+ "success": evaluation["success"],
+ }
+ )
+ failure_context = self._failure_context_if_blocked(
+ route=route,
+ evaluation=evaluation,
+ events_before=events_before,
+ events_after=events,
+ completion_contract=completion_contract,
+ )
+ if failure_context and self.memory.retry_suppression_enabled:
+ blocked_skills[route.get("skill_name", "")] = failure_context
+ risks = evaluation.get("regression_risks")
+ if not isinstance(risks, list):
+ risks = []
+ evaluation["regression_risks"] = risks
+ risks.append(
+ f"Blocked {route.get('skill_name')} for the remainder of this task after no contract progress."
+ )
+ if self.memory.skill_health_updates_enabled:
+ self.health.update(
+ route.get("skill_name", ""),
+ evaluation["success"],
+ evaluation,
+ failure_context=failure_context,
+ )
+ raw_record = self.memory.append_raw(
+ {
+ "instruction": active_instruction,
+ "original_instruction": instruction,
+ "subgoal_index": subgoal_index,
+ "step_index": step_index,
+ "task_state": task_state,
+ "route": route,
+ "execution": exec_result,
+ "structured_execution": structured_execution,
+ "evaluation": evaluation,
+ "completion_contract": completion_contract,
+ "events_after": event_digest(events),
+ }
+ )
+ learning_records.append(
+ {
+ "task_state": task_state,
+ "route": route,
+ "raw_record": raw_record,
+ "evaluation": evaluation,
+ "capability_summary": capability_summary,
+ }
+ )
+ subgoal_result = {
+ "success": evaluation["success"],
+ "needs_user": False,
+ "task_state": task_state,
+ "route": route,
+ "evaluation": evaluation,
+ "inventory": inventory,
+ "raw_record": raw_record,
+ }
+ last_feedback = evaluation
+ if evaluation["success"]:
+ break
+ final_result = {
+ **subgoal_result,
+ "subgoals": subgoals,
+ "completed_subgoals": completed_subgoals,
+ "subgoal_index": subgoal_index,
+ "replan_count": replan_count,
+ "trajectory": trajectory,
+ }
+ if final_result.get("needs_user"):
+ break
+ if final_result.get("success"):
+ completed_subgoals.append(active_instruction)
+ final_result["completed_subgoals"] = completed_subgoals
+ subgoal_index += 1
+ continue
+ if replan_count >= max_replans:
+ break
+ if self.memory.condition_on_failure_feedback:
+ replanned = self._replan_after_failure(
+ original_instruction=instruction,
+ failed_subgoal=active_instruction,
+ completed_subgoals=completed_subgoals,
+ failure_result=final_result,
+ events=events,
+ completion_contract=completion_contract,
+ )
+ else:
+ replanned = self._replan_from_world_state(
+ original_instruction=instruction,
+ events=events,
+ completion_contract=completion_contract,
+ )
+ replan_count += 1
+ trajectory.append(
+ {
+ "type": "replan",
+ "original_instruction": instruction,
+ "failed_subgoal": active_instruction,
+ "subgoal_index": subgoal_index,
+ "replan_count": replan_count,
+ "new_subgoals": replanned,
+ "failure": {
+ "route": final_result.get("route", {}),
+ "evaluation": final_result.get("evaluation", {}),
+ },
+ }
+ )
+ if replanned:
+ if self.memory.condition_on_failure_feedback:
+ subgoals = completed_subgoals + replanned
+ subgoal_index = len(completed_subgoals)
+ else:
+ # A retry-only replan is a fresh sample conditioned only
+ # on the current world. Do not carry textual plan history
+ # into the next decomposition/execution cycle.
+ subgoals = replanned
+ completed_subgoals = []
+ subgoal_index = 0
+ final_result["subgoals"] = subgoals
+ final_result["replanned_remaining_subgoals"] = replanned
+ continue
+ break
+ final_result["trajectory"] = trajectory
+ self._optimize_at_task_boundary(learning_records)
+ return final_result
+
+ def _primitive_contract_fallback(
+ self,
+ *,
+ capability_summary: dict[str, Any],
+ completion_contract: dict[str, Any] | None,
+ inventory: dict[str, Any],
+ blocked_skills: dict[str, dict[str, Any]],
+ ) -> dict[str, Any] | None:
+ """Prefer a declared primitive fallback after a no-progress skill failure.
+
+ The mapping comes from primitive metadata, not task names. A primitive
+ is eligible only when its declared transformation can satisfy every
+ remaining input/output quantity from the live inventory.
+ """
+ if not blocked_skills or not completion_contract:
+ return None
+ progress = contract_progress(completion_contract, inventory) or {}
+ targets = progress.get("targets", [])
+ for primitive in capability_summary.get("primitive_reference", []):
+ fallback = primitive.get("contract_fallback") or {}
+ if fallback.get("kind") != "raw_to_ingot":
+ continue
+ prefix = fallback.get("input_prefix", "")
+ suffix = fallback.get("output_suffix", "")
+ fuel = fallback.get("fuel", "")
+ for consumed in targets:
+ input_item = str(consumed.get("item", ""))
+ if consumed.get("comparison") != "<=" or not input_item.startswith(prefix):
+ continue
+ output_item = input_item[len(prefix):] + suffix
+ produced = next(
+ (
+ target for target in targets
+ if target.get("item") == output_item
+ and target.get("comparison", ">=") == ">="
+ and int(target.get("remaining_count", 0)) > 0
+ ),
+ None,
+ )
+ if not produced:
+ continue
+ count = int(produced["remaining_count"])
+ if int(inventory.get(input_item, 0)) < count or int(inventory.get(fuel, 0)) < 1:
+ continue
+ if int(consumed.get("remaining_count", 0)) < count:
+ continue
+ return {
+ "decision": "use_primitive",
+ "skill_name": "",
+ "primitive_name": primitive.get("name", ""),
+ "primitive_args": [input_item, fuel, count],
+ "reasoning": "A blocked skill made no contract progress; a declared primitive transformation can directly satisfy the remaining contract.",
+ "expected_effect": f"Consume {count} {input_item} and add at least {count} {output_item}.",
+ "fallback": "If this primitive fails, request a new validated composition.",
+ "world_asset_dependencies": [
+ requirement.get("role", "")
+ for requirement in primitive.get("world_asset_requirements", [])
+ if requirement.get("role")
+ ],
+ "persistent_asset": {"action": "none", "block_name": ""},
+ "new_skill_request": {},
+ }
+ return None
+
+ def _failure_context_if_blocked(
+ self,
+ *,
+ route: dict[str, Any],
+ evaluation: dict[str, Any],
+ events_before: list[tuple[str, dict[str, Any]]] | None,
+ events_after: list[tuple[str, dict[str, Any]]] | None,
+ completion_contract: dict[str, Any] | None,
+ ) -> dict[str, Any] | None:
+ skill_name = route.get("skill_name", "")
+ if not skill_name or evaluation.get("success"):
+ return None
+ if skill_name in self.capabilities.diagnostic_skill_names():
+ return None
+ before = contract_progress(completion_contract, inventory_from(events_before))
+ after = contract_progress(completion_contract, inventory_from(events_after))
+ if not before or not after:
+ return None
+ made_progress = any(
+ int(new.get("remaining_count", 0)) < int(old.get("remaining_count", 0))
+ for old, new in zip(before.get("targets", []), after.get("targets", []))
+ )
+ observable_progress = self._observable_progress(route, events_before, events_after)
+ critique = str(evaluation.get("critique", ""))
+ execution_error = "execution error" in critique.lower() or "error" in critique.lower()
+ # A capability may complete a necessary, observable state transition
+ # (for example, equipping the required tool) without changing the
+ # task's inventory contract yet. That is progress, not evidence that
+ # the selected skill should be blacklisted. State evidence is more
+ # reliable than a textual execution error, which can be emitted after
+ # a bridge action has already taken effect.
+ if made_progress or observable_progress:
+ return None
+ return {
+ "skill_name": skill_name,
+ "reason": "execution_error" if execution_error else "no_contract_progress",
+ "contract_before": before,
+ "contract_after": after,
+ "critique": critique,
+ }
+
+ def _observable_progress(
+ self,
+ route: dict[str, Any],
+ events_before: list[tuple[str, dict[str, Any]]] | None,
+ events_after: list[tuple[str, dict[str, Any]]] | None,
+ ) -> bool:
+ """Recognize durable state transitions outside an inventory contract.
+
+ Completion contracts intentionally measure consumable inventory goals.
+ Equipment and placed-world assets are separate observable state, so an
+ action that changes one of those must not be treated as a no-op solely
+ because the final task is still incomplete.
+ """
+ def observation(events: list[tuple[str, dict[str, Any]]] | None) -> dict[str, Any]:
+ if not events:
+ return {}
+ for event_type, event in reversed(events):
+ if event_type == "observe" and isinstance(event, dict):
+ return event
+ return {}
+
+ def main_hand(event: dict[str, Any]) -> Any:
+ status = event.get("status", {}) if isinstance(event, dict) else {}
+ equipment = status.get("equipment", []) if isinstance(status, dict) else []
+ return equipment[4] if isinstance(equipment, list) and len(equipment) > 4 else None
+
+ expected_hand = ""
+ if route.get("decision") == "use_primitive" and route.get("primitive_name") == "equipItem":
+ args = route.get("primitive_args", [])
+ if len(args) == 1 and isinstance(args[0], str):
+ expected_hand = args[0]
+ elif route.get("decision") == "use_skill":
+ skill = self.capabilities.skill_manager.skills.get(route.get("skill_name", ""), {})
+ plan = (skill.get("metadata", {}) or {}).get("primitive_plan", [])
+ if isinstance(plan, list):
+ for step in plan:
+ args = step.get("args", []) if isinstance(step, dict) else []
+ if (
+ isinstance(step, dict)
+ and step.get("op") == "equipItem"
+ and len(args) == 1
+ and isinstance(args[0], str)
+ ):
+ expected_hand = args[0]
+ break
+
+ before = observation(events_before)
+ after = observation(events_after)
+ if expected_hand and main_hand(before) != expected_hand and main_hand(after) == expected_hand:
+ return True
+
+ # Asset observations are intentionally tied to the route's structured
+ # asset intent. Mere exploration also changes blockRecords, and must
+ # not turn an unrelated failed skill into apparent progress.
+ asset_name = str((route.get("persistent_asset") or {}).get("block_name") or "")
+ if not asset_name:
+ return False
+
+ def asset_records(event: dict[str, Any]) -> set[str]:
+ records = event.get("blockRecords", []) if isinstance(event, dict) else []
+ result: set[str] = set()
+ if not isinstance(records, list):
+ return result
+ for record in records:
+ if isinstance(record, str):
+ result.add(record)
+ continue
+ if not isinstance(record, dict):
+ continue
+ name = record.get("name") or record.get("blockName")
+ if isinstance(name, str):
+ result.add(name)
+ return result
+
+ return asset_name not in asset_records(before) and asset_name in asset_records(after)
+
+ def _optimize_at_task_boundary(
+ self,
+ learning_records: list[dict[str, Any]],
+ ) -> None:
+ """Run the HCL learning loop once after a task terminal outcome.
+
+ Prefer the most recent failed execution when present: it is the most
+ actionable evidence for a repair. Otherwise learn from the final
+ successful execution. Persistent capability synthesis is immediate
+ only when the selected experiment policy enables skill updates.
+ """
+ if not learning_records or not any(
+ (
+ self.memory.memory_updates_enabled,
+ self.memory.skill_updates_enabled,
+ self.memory.prompt_updates_enabled,
+ )
+ ):
+ return
+ selected = learning_records[-1]
+ for record in reversed(learning_records):
+ execution = record.get("raw_record", {}).get("execution", {})
+ if not record.get("evaluation", {}).get("success") or execution.get("error"):
+ selected = record
+ break
+ try:
+ self._optimize(
+ selected["task_state"],
+ selected["route"],
+ selected["raw_record"],
+ selected["evaluation"],
+ selected["capability_summary"],
+ )
+ except Exception as exc: # noqa: BLE001
+ # Continual optimization is post-task learning. It must never
+ # invalidate an already executed task transaction.
+ self.memory.append_proposed_update(
+ {
+ "optimization_error": str(exc),
+ "source_raw_time": selected.get("raw_record", {}).get("time"),
+ }
+ )
+
+ def _replan_after_failure(
+ self,
+ *,
+ original_instruction: str,
+ failed_subgoal: str,
+ completed_subgoals: list[str],
+ failure_result: dict[str, Any],
+ events: list[tuple[str, dict[str, Any]]] | None,
+ completion_contract: dict[str, Any] | None,
+ ) -> list[str]:
+ feedback = {
+ "failed_subgoal": failed_subgoal,
+ "route": failure_result.get("route", {}),
+ "evaluation": failure_result.get("evaluation", {}),
+ "created_skill": failure_result.get("created_skill", {}),
+ }
+ query = f"{original_instruction}\nfailed_subgoal: {failed_subgoal}\nfeedback: {feedback}"
+ abstract = self.memory.retrieve_abstract(query)
+ capability_summary = self.capabilities.summarize(query)
+ capability_summary["update_policy"] = dict(self.memory.update_policy)
+ candidate_names = capability_summary.get("candidate_skill_names", [])
+ capability_summary["skill_health"] = (
+ self.health.summarize(candidate_names)
+ if self.memory.skill_health_updates_enabled
+ else {}
+ )
+ replanned = self.decomposer.decompose(
+ instruction=original_instruction,
+ events=events,
+ abstract_memory=abstract,
+ capability_summary=capability_summary,
+ failure_feedback=feedback,
+ completed_subgoals=completed_subgoals,
+ completion_contract=completion_contract,
+ contract_progress=contract_progress(completion_contract, inventory_from(events)),
+ )
+ completed = {item.strip().lower() for item in completed_subgoals}
+ filtered = [item for item in replanned if item.strip().lower() not in completed]
+ return filtered or [failed_subgoal]
+
+ def _replan_from_world_state(
+ self,
+ *,
+ original_instruction: str,
+ events: list[tuple[str, dict[str, Any]]] | None,
+ completion_contract: dict[str, Any] | None,
+ ) -> list[str]:
+ """Generate a fresh retry plan without trajectory/failure conditioning."""
+ abstract = self.memory.retrieve_abstract(original_instruction)
+ capability_summary = self.capabilities.summarize(original_instruction)
+ capability_summary["update_policy"] = dict(self.memory.update_policy)
+ capability_summary["skill_health"] = {}
+ capability_summary["blocked_skill_names"] = []
+ capability_summary["scenario_failures"] = []
+ capability_summary["base_state"] = self.base_manager.public_state()
+ return self.decomposer.decompose(
+ instruction=original_instruction,
+ events=self._planning_events(events),
+ abstract_memory=abstract,
+ capability_summary=capability_summary,
+ completion_contract=completion_contract,
+ contract_progress=contract_progress(
+ completion_contract,
+ inventory_from(events),
+ ),
+ )
+
+ def _guard_unsatisfied_contract(
+ self,
+ route: dict[str, Any],
+ completion_contract: dict[str, Any] | None,
+ progress: dict[str, Any] | None,
+ capability_summary: dict[str, Any],
+ ) -> dict[str, Any] | None:
+ """Prevent a local subgoal from terminating an unfinished hard goal."""
+ if not completion_contract or not progress or progress.get("satisfied"):
+ return None
+ remaining = [target for target in progress.get("targets", []) if target.get("remaining_count", 0) > 0]
+ candidates = capability_summary.get("candidate_skills", [])
+ diagnostic_names = set(self.capabilities.diagnostic_skill_names())
+ for candidate in candidates:
+ name = candidate.get("name")
+ text = f"{name or ''} {candidate.get('description', '')}".lower()
+ if not name or name in diagnostic_names:
+ continue
+ if any(
+ target.get("item", "").lower() in text
+ or any(term in text for term in target.get("item", "").lower().split("_") if len(term) > 2)
+ for target in remaining
+ ):
+ return {
+ "decision": "use_skill",
+ "skill_name": name,
+ "reasoning": "Contract guard selected a capability because the original inventory target remains unsatisfied.",
+ "expected_effect": f"Make progress on remaining targets: {remaining}",
+ "fallback": "Re-evaluate the observation-grounded contract after execution.",
+ "new_skill_request": {},
+ }
+ return {
+ "decision": "request_new_skill",
+ "skill_name": "",
+ "reasoning": "Completion contract remains unsatisfied and no matching executable capability was available.",
+ "expected_effect": f"Make progress on remaining targets: {remaining}",
+ "fallback": "Synthesize a bounded capability using existing primitives.",
+ "new_skill_request": {
+ "name": "fulfillRemainingContract",
+ "description": "Fulfill the remaining observation-grounded inventory targets without violating protected inventory.",
+ "requirements": [str(target.get("item", "")) for target in remaining],
+ },
+ }
+
+ def _try_synthesize_missing_skill(
+ self,
+ task_state: dict[str, Any],
+ route: dict[str, Any],
+ capability_summary: dict[str, Any],
+ ) -> dict[str, Any]:
+ if not self.memory.skill_updates_enabled:
+ return {
+ "applied": False,
+ "reason": "skill updates are disabled by the memory-backend experiment policy",
+ }
+ try:
+ proposal = self.synthesizer.propose(
+ task_state=task_state,
+ route=route,
+ capability_summary=capability_summary,
+ )
+ update = self.synthesizer.render_update(proposal)
+ if not update:
+ return {"applied": False, "reason": proposal.get("reason", "synthesizer did not produce a primitive plan")}
+ test_result = self.test_gate.validate_update(
+ update,
+ known_skill_names=set(capability_summary.get("all_skill_names", [])),
+ )
+ update["test_status"] = "passed" if test_result.get("passed") else "failed"
+ if not test_result.get("passed"):
+ return {"applied": False, "reason": "skill failed static test gate", "test": test_result}
+ live_result = None
+ if (update.get("test_spec") or {}).get("run_live"):
+ live_result = self.test_gate.live_smoke_test(self.odyssey, update)
+ if not live_result.get("passed"):
+ update["test_status"] = "failed_live"
+ return {"applied": False, "reason": "skill failed live smoke test", "test": test_result, "live_test": live_result}
+ applied = self.capabilities.apply_skill_update(update)
+ return {**applied, "test": test_result, "live_test": live_result}
+ except Exception as exc: # noqa: BLE001
+ return {"applied": False, "reason": f"synthesis failed: {exc}"}
+
+ def _optimize(
+ self,
+ task_state: dict[str, Any],
+ route: dict[str, Any],
+ raw_record: dict[str, Any],
+ evaluation: dict[str, Any],
+ capability_summary: dict[str, Any],
+ ) -> None:
+ candidate = self.optimizer.propose_update(
+ task_state=task_state,
+ route=route,
+ raw_record=raw_record,
+ evaluation=evaluation,
+ abstract_memory=self.memory.abstract_memory,
+ capability_summary=capability_summary,
+ )
+ # Enforce the experiment policy before evaluation or persistence, even
+ # if an LLM ignores the output restriction in its system prompt.
+ if not self.memory.memory_updates_enabled:
+ candidate["abstract_memory"] = {}
+ if not self.memory.skill_updates_enabled:
+ candidate["capability_updates"] = []
+ if not self.memory.prompt_updates_enabled:
+ candidate["prompt_updates"] = {}
+ decision = self.evaluator.judge_update(
+ task_state=task_state,
+ raw_record=raw_record,
+ candidate_update=candidate,
+ abstract_memory=self.memory.abstract_memory,
+ capability_summary=capability_summary,
+ )
+ decision_safe_parts = decision.get("safe_parts")
+ if isinstance(decision_safe_parts, dict):
+ if not self.memory.memory_updates_enabled:
+ decision_safe_parts["abstract_memory"] = {}
+ if not self.memory.skill_updates_enabled:
+ decision_safe_parts["capability_updates"] = []
+ if not self.memory.prompt_updates_enabled:
+ decision_safe_parts["prompt_updates"] = {}
+ self.memory.append_proposed_update(
+ {
+ "candidate": candidate,
+ "evaluation": decision,
+ "source_raw_time": raw_record.get("time"),
+ }
+ )
+ if not decision.get("approved"):
+ return
+ safe_parts = decision.get("safe_parts") or {}
+ if not isinstance(safe_parts, dict):
+ safe_parts = {}
+ abstract_update = safe_parts.get("abstract_memory") or candidate.get("abstract_memory") or {}
+ if not isinstance(abstract_update, dict):
+ abstract_update = {}
+ if self.memory.memory_updates_enabled:
+ self.memory.merge_abstract(
+ abstract_update,
+ source_raw_time=raw_record.get("time"),
+ context={
+ "task_kind": task_state.get("task_kind", "unknown"),
+ "skills": [route.get("skill_name", "")],
+ "conditions": task_state.get("constraints", []),
+ "outcome": "success" if evaluation.get("success") else "failure",
+ },
+ )
+ prompt_updates = safe_parts.get("prompt_updates") or candidate.get("prompt_updates") or {}
+ if self.memory.prompt_updates_enabled:
+ self.optimizer.apply_prompt_updates(prompt_updates)
+ self.refresh_prompt_overrides()
+ capability_updates = safe_parts.get("capability_updates") or candidate.get("capability_updates") or []
+ if not isinstance(capability_updates, list):
+ capability_updates = []
+ if not self.memory.skill_updates_enabled:
+ capability_updates = []
+ for update in capability_updates:
+ test_result = self.test_gate.validate_update(
+ update,
+ known_skill_names=set(capability_summary.get("all_skill_names", [])),
+ )
+ if not isinstance(update, dict):
+ self.memory.append_proposed_update(
+ {
+ "applied_capability_update": {
+ "applied": False,
+ "reason": "capability update must be a JSON object",
+ "test": test_result,
+ },
+ "source_raw_time": raw_record.get("time"),
+ }
+ )
+ continue
+ update["test_status"] = "passed" if test_result.get("passed") else "failed"
+ if not test_result.get("passed"):
+ self.memory.append_proposed_update(
+ {
+ "applied_capability_update": {
+ "applied": False,
+ "reason": "skill failed static test gate",
+ "test": test_result,
+ },
+ "source_raw_time": raw_record.get("time"),
+ }
+ )
+ continue
+ live_result = None
+ if (update.get("test_spec") or {}).get("run_live"):
+ live_result = self.test_gate.live_smoke_test(self.odyssey, update)
+ if not live_result.get("passed"):
+ self.memory.append_proposed_update(
+ {
+ "applied_capability_update": {
+ "applied": False,
+ "reason": "skill failed live smoke test",
+ "test": test_result,
+ "live_test": live_result,
+ },
+ "source_raw_time": raw_record.get("time"),
+ }
+ )
+ continue
+ result = self.capabilities.apply_skill_update(update)
+ self.memory.append_proposed_update(
+ {
+ "applied_capability_update": result,
+ "source_raw_time": raw_record.get("time"),
+ "test": test_result,
+ "live_test": live_result,
+ }
+ )
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/router.py b/experiments/Odyssey/Odyssey/odyssey/harness/router.py
new file mode 100644
index 0000000000000000000000000000000000000000..33162db9bccc20fab790c6a05cb1b91d008a9c02
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/router.py
@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import call_json
+
+
+ROUTER_PROMPT = """You are the Adaptive Router of a Minecraft continual-learning Harness.
+Choose the best next executable capability using the structured task state, abstract memory, and capability map.
+The final choice must be made by reasoning over the provided skills/tools, not by a fixed rule.
+Prefer existing skills. A single listed primitive with concrete arguments may be
+executed directly; use a new skill only when a reusable multi-step composition
+is needed.
+Do not select a skill when its stated effects conflict with an exact quantity
+or an existing-world-object constraint in the task; prefer an eligible
+primitive in that case.
+Use capability_map.capability_layers to distinguish primitives, base skills, learned skills, and diagnostic skills.
+If the task state is uncertain because inventory or nearby blocks are unknown, prefer an available diagnostic skill before requesting a new skill.
+Use capability_map.skill_health to avoid skills with repeated recent failures when a healthier equivalent exists.
+capability_map.blocked_skill_names contains skills that already failed without
+making contract progress in this task. Never select them again in this task.
+When the task explicitly asks to place or establish a durable world object for
+later reuse, set persistent_asset.action to "place_or_reuse" and give its
+concrete Minecraft block name. This is a model decision based on the task and
+state, not a keyword rule in the executor. Otherwise use action "none".
+For a selected capability, world_asset_dependencies lists only the abstract
+dependencies needed for this particular invocation (for example a 3x3 craft
+may need crafting_surface while a 2x2 craft does not). It must be a subset of
+that capability's declared dependency roles.
+Return only JSON with:
+{
+ "decision": "use_skill" | "use_primitive" | "request_new_skill" | "ask_user",
+ "skill_name": "exact existing skill name when decision is use_skill, otherwise empty",
+ "primitive_name": "exact primitive name when decision is use_primitive, otherwise empty",
+ "primitive_args": ["arguments after bot, in signature order, only for use_primitive"],
+ "reasoning": "brief reason",
+ "expected_effect": "what should change in the world",
+ "fallback": "what to try if this fails",
+ "persistent_asset": {
+ "action": "none" | "place_or_reuse",
+ "block_name": "concrete Minecraft block name, or empty"
+ },
+ "world_asset_dependencies": ["abstract dependency roles needed now"],
+ "new_skill_request": {
+ "name": "camelCase name",
+ "description": "needed behavior",
+ "requirements": ["primitive/tool requirements"]
+ }
+}
+
+completion_contract and contract_progress, when supplied, are hard,
+observation-grounded completion constraints. You may choose only how to make
+the remaining targets progress; you may not declare them complete. Do not use
+ask_user while any contract target has remaining_count > 0 unless an explicit
+user-only choice is genuinely required.
+"""
+
+
+class AdaptiveRouter:
+ def __init__(self, model_name: str):
+ self.model_name = model_name
+ self.base_prompt = ROUTER_PROMPT
+ self.system_prompt = ROUTER_PROMPT
+
+ def route(
+ self,
+ *,
+ task_state: dict[str, Any],
+ abstract_memory: list[str],
+ capability_summary: dict[str, Any],
+ last_feedback: dict[str, Any] | None = None,
+ completion_contract: dict[str, Any] | None = None,
+ contract_progress: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ route = call_json(
+ self.system_prompt,
+ {
+ "task_state": task_state,
+ "retrieved_abstract_memory": abstract_memory,
+ "capability_map": capability_summary,
+ "last_feedback": last_feedback or {},
+ "completion_contract": completion_contract or {},
+ "contract_progress": contract_progress or {},
+ },
+ self.model_name,
+ )
+ route.setdefault("decision", "ask_user")
+ route.setdefault("skill_name", "")
+ route.setdefault("primitive_name", "")
+ route.setdefault("primitive_args", [])
+ route.setdefault("reasoning", "")
+ route.setdefault("expected_effect", "")
+ route.setdefault("fallback", "")
+ route.setdefault("new_skill_request", {})
+ route.setdefault("world_asset_dependencies", [])
+ if not isinstance(route["world_asset_dependencies"], list):
+ route["world_asset_dependencies"] = []
+ route["world_asset_dependencies"] = [
+ value for value in route["world_asset_dependencies"] if isinstance(value, str)
+ ]
+ route.setdefault("persistent_asset", {"action": "none", "block_name": ""})
+ if not isinstance(route["persistent_asset"], dict):
+ route["persistent_asset"] = {"action": "none", "block_name": ""}
+ asset = route["persistent_asset"]
+ if asset.get("action") not in {"none", "place_or_reuse"}:
+ asset["action"] = "none"
+ if not isinstance(asset.get("block_name"), str):
+ asset["block_name"] = ""
+ primitive_names = {
+ item.get("name")
+ for item in capability_summary.get("primitive_reference", [])
+ if item.get("name")
+ }
+ blocked_skill_names = set(capability_summary.get("blocked_skill_names", []))
+ if route["decision"] == "use_skill":
+ if route.get("skill_name") in blocked_skill_names:
+ route["decision"] = "blocked_skill"
+ route["reasoning"] = "Selected skill is blocked for this task after a no-progress failure."
+ return route
+ candidates = set(capability_summary.get("all_skill_names", []))
+ if route.get("skill_name") not in candidates:
+ requested_name = route.get("skill_name") or "missingSkill"
+ # Backwards compatibility for a model that still puts a
+ # primitive in skill_name. It must provide arguments through
+ # the explicit primitive route, so synthesize a composition
+ # when those arguments are absent.
+ if requested_name in primitive_names:
+ if isinstance(route.get("primitive_args"), list) and route["primitive_args"]:
+ route["decision"] = "use_primitive"
+ route["primitive_name"] = requested_name
+ route["skill_name"] = ""
+ return route
+ requested_name = f"compose{requested_name[:1].upper()}{requested_name[1:]}"
+ route["decision"] = "request_new_skill"
+ route["new_skill_request"] = {
+ "name": requested_name,
+ "description": "Router selected a skill that does not exist; create an equivalent capability.",
+ "requirements": [],
+ }
+ route["skill_name"] = ""
+ if route["decision"] == "use_primitive":
+ if route.get("primitive_name") not in primitive_names or not isinstance(route.get("primitive_args"), list):
+ route["decision"] = "request_new_skill"
+ route["new_skill_request"] = {
+ "name": "composeMissingPrimitive",
+ "description": "Primitive route was invalid; synthesize a validated composition instead.",
+ "requirements": [],
+ }
+ route["primitive_name"] = ""
+ route["primitive_args"] = []
+ return route
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/synthesizer.py b/experiments/Odyssey/Odyssey/odyssey/harness/synthesizer.py
new file mode 100644
index 0000000000000000000000000000000000000000..4313ea1d45c444fd832de5bb83646fa24db4d755
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/synthesizer.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+from .common import call_json, slugify_name
+
+
+SYNTHESIZER_PROMPT = """You are the Skill Synthesizer for a Minecraft Harness.
+When a missing capability can be expressed as a short composition of known Odyssey primitives, output a primitive_plan.
+Use only primitives and helpers listed in capability_summary.primitive_reference.
+Important runtime rule: capability_summary.primitive_reference is documentation only. It is not a JavaScript runtime object.
+Do not write code that reads from capability_summary, primitive_reference, or destructures primitive functions from them.
+Generated JavaScript must follow the existing Odyssey skill-library style:
+- Define exactly async function skillName(bot) { ... }.
+- Call injected helpers directly, for example await craftItem(bot, "crafting_table", 1).
+- Call injected helpers directly, for example await mineBlock(bot, "stone", 1).
+- Call injected helpers directly, for example const position = await findSuitablePosition(bot); await placeItem(bot, "crafting_table", position).
+- Use concrete Minecraft item/block names such as "birch_planks", "oak_log", or "crafting_table"; do not use generic names like "planks", "logs", or "wooden_planks".
+- Primitive count arguments must be numbers, not objects.
+- To search for a block before mining it, use a structured step such as
+ {"op": "exploreUntilBlock", "block": "stone", "direction": [1, 0, 1], "max_time": 60, "save_as": "stone"}.
+ Do not invent an exploreUntil callback or omit its direction argument.
+Prefer primitive plans over handwritten JavaScript.
+Return only JSON with:
+{
+ "can_synthesize": true | false,
+ "reason": "brief reason",
+ "skill": {
+ "name": "camelCaseSkillName",
+ "description": "what this skill does",
+ "preconditions": [],
+ "success_checks": [],
+ "primitive_plan": [
+ {"op": "findSuitablePosition", "save_as": "position"},
+ {"op": "placeItem", "args": ["crafting_table", "$position"]}
+ ]
+ }
+}
+"""
+
+
+class SkillSynthesizer:
+ def __init__(self, model_name: str):
+ self.model_name = model_name
+
+ def propose(
+ self,
+ *,
+ task_state: dict[str, Any],
+ route: dict[str, Any],
+ capability_summary: dict[str, Any],
+ ) -> dict[str, Any]:
+ proposal = call_json(
+ SYNTHESIZER_PROMPT,
+ {
+ "task_state": task_state,
+ "route": route,
+ "capability_summary": capability_summary,
+ },
+ self.model_name,
+ )
+ proposal.setdefault("can_synthesize", False)
+ proposal.setdefault("reason", "")
+ proposal.setdefault("skill", {})
+ return proposal
+
+ def render_update(self, proposal: dict[str, Any]) -> dict[str, Any] | None:
+ if not proposal.get("can_synthesize"):
+ return None
+ skill = proposal.get("skill") or {}
+ raw_name = skill.get("name")
+ plan = skill.get("primitive_plan") or []
+ if not raw_name or not isinstance(plan, list):
+ return None
+ name = slugify_name(raw_name)
+ body = self._render_plan(plan)
+ if not body:
+ return None
+ code = f"async function {name}(bot) {{\n{body}\n}}"
+ return {
+ "action": "add_skill",
+ "name": name,
+ "description": skill.get("description") or proposal.get("reason") or name,
+ "code": code,
+ "reason": proposal.get("reason", ""),
+ "generation_mode": "primitive_plan_template",
+ "primitive_plan": plan,
+ "preconditions": skill.get("preconditions", []),
+ "success_checks": skill.get("success_checks", []),
+ }
+
+ def render_ephemeral_primitive(
+ self,
+ primitive_name: str,
+ primitive_args: list[Any],
+ ) -> dict[str, Any] | None:
+ """Compile one allow-listed primitive into non-persistent skill code."""
+ body = self._render_plan([{"op": primitive_name, "args": primitive_args}])
+ if not body:
+ return None
+ name = f"temporary{primitive_name[:1].upper()}{primitive_name[1:]}"
+ return {
+ "name": name,
+ "code": f"async function {name}(bot) {{\n{body}\n}}",
+ "generation_mode": "ephemeral_primitive",
+ "primitive_plan": [{"op": primitive_name, "args": primitive_args}],
+ }
+
+ def _render_plan(self, plan: list[dict[str, Any]]) -> str:
+ lines: list[str] = []
+ for index, step in enumerate(plan):
+ op = step.get("op")
+ save_as = step.get("save_as")
+ if op == "findSuitablePosition":
+ target = self._js_identifier(save_as or f"position{index}")
+ if not target:
+ return ""
+ lines.append(f" const {target} = await findSuitablePosition(bot);")
+ elif op == "placeItem":
+ args = step.get("args") or []
+ if len(args) != 2:
+ return ""
+ item, position = args
+ lines.append(f" await placeItem(bot, {self._literal(item)}, {self._value(position)});")
+ elif op == "craftItem":
+ args = step.get("args") or []
+ if not 1 <= len(args) <= 2:
+ return ""
+ count = args[1] if len(args) == 2 else 1
+ lines.append(f" await craftItem(bot, {self._literal(args[0])}, {self._number(count)});")
+ elif op == "mineBlock":
+ args = step.get("args") or []
+ if not 1 <= len(args) <= 2:
+ return ""
+ count = args[1] if len(args) == 2 else 1
+ lines.append(f" await mineBlock(bot, {self._literal(args[0])}, {self._number(count)});")
+ elif op == "equipItem":
+ args = step.get("args") or []
+ if len(args) != 1 or not self._minecraft_name(args[0]):
+ return ""
+ lines.append(f" await equipItem(bot, {self._literal(args[0])});")
+ elif op == "smeltItem":
+ args = step.get("args") or []
+ if (
+ len(args) != 3
+ or not self._minecraft_name(args[0])
+ or not self._minecraft_name(args[1])
+ or not self._is_number(args[2])
+ ):
+ return ""
+ lines.append(
+ f" await smeltItem(bot, {self._literal(args[0])}, {self._literal(args[1])}, {self._number(args[2])});"
+ )
+ elif op == "exploreUntilBlock":
+ block_name = step.get("block")
+ direction = step.get("direction", [1, 0, 1])
+ max_time = step.get("max_time", 60)
+ target = self._js_identifier(save_as or f"foundBlock{index}")
+ if (
+ not isinstance(block_name, str)
+ or not self._js_identifier(block_name)
+ or not isinstance(direction, list)
+ or len(direction) != 3
+ or not all(value in {-1, 0, 1} for value in direction)
+ or not isinstance(max_time, (int, float))
+ or not target
+ ):
+ return ""
+ block_literal = json.dumps(block_name, ensure_ascii=False)
+ lines.extend(
+ [
+ f" const {target} = await exploreUntil(bot, new Vec3({direction[0]}, {direction[1]}, {direction[2]}), {int(max_time)}, () => {{",
+ f" return bot.findBlock({{ matching: mcData.blocksByName[{block_literal}].id, maxDistance: 32 }});",
+ " });",
+ ]
+ )
+ elif op == "callSkill":
+ name = self._js_identifier(step.get("name", ""))
+ if not name:
+ return ""
+ lines.append(f" await {name}(bot);")
+ elif op == "chat":
+ lines.append(f" bot.chat({self._literal(step.get('message', 'Done.'))});")
+ else:
+ return ""
+ return "\n".join(lines)
+
+ def _literal(self, value: Any) -> str:
+ if isinstance(value, str) and value.startswith("$"):
+ return self._value(value)
+ return json.dumps(value, ensure_ascii=False)
+
+ def _value(self, value: Any) -> str:
+ if isinstance(value, str) and value.startswith("$"):
+ return self._js_identifier(value[1:])
+ if isinstance(value, str):
+ return json.dumps(value, ensure_ascii=False)
+ return str(value)
+
+ def _number(self, value: Any) -> str:
+ if isinstance(value, (int, float)):
+ return str(value)
+ if isinstance(value, str) and value.isdigit():
+ return value
+ return "1"
+
+ def _is_number(self, value: Any) -> bool:
+ return isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0
+
+ def _minecraft_name(self, value: Any) -> bool:
+ return isinstance(value, str) and bool(value) and all(char.islower() or char.isdigit() or char == "_" for char in value)
+
+ def _js_identifier(self, value: str) -> str:
+ cleaned = "".join(ch for ch in value if ch.isalnum() or ch == "_")
+ if not cleaned or cleaned[0].isdigit():
+ return ""
+ return cleaned
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/task_interface.py b/experiments/Odyssey/Odyssey/odyssey/harness/task_interface.py
new file mode 100644
index 0000000000000000000000000000000000000000..bc94dc17014d76f8b629648939b51233679ad9f6
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/task_interface.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from typing import Any
+
+from .common import call_json, event_digest
+
+
+TASK_INTERFACE_PROMPT = """You are the Task Interface of a continual-learning Minecraft agent.
+Convert the user's open-ended instruction and current evidence into a compact structured task state.
+Do not choose a skill. Do not invent unavailable observations.
+If tool_returns or abstract memory show that a previous interpretation was wrong, revise the goal, constraints, and success criteria accordingly.
+For long tasks, describe only the current actionable subgoal passed as user_instruction, while preserving relevant context from the original trajectory.
+Return only JSON with:
+{
+ "goal": "short actionable goal",
+ "instruction": "original user instruction",
+ "task_kind": "action" | "observation",
+ "context": ["important evidence from observations, tool returns, memory"],
+ "constraints": ["known constraints or empty list"],
+ "success_criteria": ["observable success checks"],
+ "missing_information": ["unknown but relevant information"],
+ "risk_notes": ["execution risks"]
+}
+
+Set task_kind to `observation` only when the task asks to inspect, diagnose,
+verify, or confirm an already-existing state and does not request any world
+change. Otherwise set it to `action`. This is a semantic classification, not
+a keyword match.
+
+If completion_contract and contract_progress are present, they are
+observation-grounded hard constraints from the original instruction. Preserve
+their target counts and describe the current actionable goal in terms of the
+remaining_count. Never treat a partial inventory as completion.
+
+When capability_summary.base_state is present, it is a persistent,
+coordinate-verified registry of world assets. Treat assets whose status is
+"present" or "placed" as reusable. Do not propose duplicate placement of a
+registered asset; a missing registered asset may only be repaired at its
+assigned position.
+"""
+
+
+class TaskInterface:
+ def __init__(self, model_name: str):
+ self.model_name = model_name
+ self.base_prompt = TASK_INTERFACE_PROMPT
+ self.system_prompt = TASK_INTERFACE_PROMPT
+
+ def structure(
+ self,
+ *,
+ instruction: str,
+ events: list[tuple[str, dict[str, Any]]] | None,
+ tool_returns: list[dict[str, Any]] | None,
+ abstract_memory: list[str],
+ capability_summary: dict[str, Any] | None = None,
+ completion_contract: dict[str, Any] | None = None,
+ contract_progress: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ payload = {
+ "user_instruction": instruction,
+ "current_events": event_digest(events),
+ "tool_returns": tool_returns or [],
+ "retrieved_abstract_memory": abstract_memory,
+ "capability_summary": capability_summary or {},
+ "completion_contract": completion_contract or {},
+ "contract_progress": contract_progress or {},
+ }
+ state = call_json(self.system_prompt, payload, self.model_name)
+ state.setdefault("goal", instruction)
+ state.setdefault("instruction", instruction)
+ state.setdefault("task_kind", "action")
+ if state["task_kind"] not in {"action", "observation"}:
+ state["task_kind"] = "action"
+ state.setdefault("context", [])
+ state.setdefault("constraints", [])
+ state.setdefault("success_criteria", [])
+ state.setdefault("missing_information", [])
+ state.setdefault("risk_notes", [])
+ return state
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/telemetry.py b/experiments/Odyssey/Odyssey/odyssey/harness/telemetry.py
new file mode 100644
index 0000000000000000000000000000000000000000..8a7dea5e466b0f0de708c7043f08e36a04dff0b7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/telemetry.py
@@ -0,0 +1,68 @@
+from __future__ import annotations
+
+import json
+from typing import Any
+
+import odyssey.utils as U
+
+from .common import event_digest, now_iso
+
+
+def latest_observation(events: list[tuple[str, dict[str, Any]]] | None) -> dict[str, Any]:
+ if not events:
+ return {}
+ for event_type, event in reversed(events):
+ if event_type == "observe":
+ return event
+ return {}
+
+
+def inventory_from(events: list[tuple[str, dict[str, Any]]] | None) -> dict[str, Any]:
+ return latest_observation(events).get("inventory", {}) or {}
+
+
+class ExecutionRecorder:
+ def __init__(self, ckpt_dir: str = "ckpt", resume: bool = True):
+ self.root = U.f_mkdir(ckpt_dir, "harness", "execution_records")
+ self.path = U.f_join(self.root, "records.jsonl")
+ if not resume:
+ U.f_remove(self.path)
+
+ def build_record(
+ self,
+ *,
+ instruction: str,
+ step_index: int,
+ route: dict[str, Any],
+ events_before: list[tuple[str, dict[str, Any]]] | None,
+ events_after: list[tuple[str, dict[str, Any]]] | None,
+ execution: dict[str, Any],
+ evaluation: dict[str, Any] | None = None,
+ ) -> dict[str, Any]:
+ before_obs = latest_observation(events_before)
+ after_obs = latest_observation(events_after)
+ digest_before = event_digest(events_before)
+ digest_after = event_digest(events_after)
+ annotated_before_obs = digest_before.get("latest_observation") or before_obs
+ annotated_after_obs = digest_after.get("latest_observation") or after_obs
+ return {
+ "time": now_iso(),
+ "instruction": instruction,
+ "step_index": step_index,
+ "selected_skill": route.get("skill_name", ""),
+ "decision": route.get("decision", ""),
+ "pre_inventory": before_obs.get("inventory", {}) or {},
+ "post_inventory": after_obs.get("inventory", {}) or {},
+ "pre_status": annotated_before_obs.get("status", {}) or {},
+ "post_status": annotated_after_obs.get("status", {}) or {},
+ "nearby_blocks_after": after_obs.get("voxels", [])[:40] if isinstance(after_obs.get("voxels", []), list) else [],
+ "chat": digest_after.get("chat", []),
+ "errors": digest_after.get("errors", []),
+ "execution_error": execution.get("error", ""),
+ "success": bool((evaluation or {}).get("success", False)),
+ "evaluation": evaluation or {},
+ }
+
+ def append(self, record: dict[str, Any]) -> None:
+ with open(self.path, "a", encoding="utf-8") as fp:
+ fp.write(json.dumps(record, ensure_ascii=False) + "\n")
diff --git a/experiments/Odyssey/Odyssey/odyssey/harness/tester.py b/experiments/Odyssey/Odyssey/odyssey/harness/tester.py
new file mode 100644
index 0000000000000000000000000000000000000000..28c1d8c25205b7b1de84d81f5a93cb91e3eaf8ac
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/harness/tester.py
@@ -0,0 +1,233 @@
+from __future__ import annotations
+
+import json
+import re
+from typing import Any
+
+import odyssey.utils as U
+
+from .capability import PRIMITIVE_REFERENCE
+from .common import now_iso
+
+
+class SkillTestGate:
+ def __init__(self, ckpt_dir: str = "ckpt", resume: bool = True):
+ self.root = U.f_mkdir(ckpt_dir, "harness", "tests")
+ self.path = U.f_join(self.root, "skill_tests.jsonl")
+ if not resume:
+ U.f_remove(self.path)
+ self.primitive_names = {item["name"] for item in PRIMITIVE_REFERENCE}
+
+ def validate_update(
+ self,
+ update: dict[str, Any],
+ known_skill_names: set[str] | None = None,
+ ) -> dict[str, Any]:
+ if not isinstance(update, dict):
+ result = {
+ "time": now_iso(),
+ "skill_name": "",
+ "passed": False,
+ "checks": [],
+ "errors": ["capability update must be a JSON object"],
+ "mode": "static",
+ }
+ self.append(result)
+ return result
+ code = update.get("code") or ""
+ result = {
+ "time": now_iso(),
+ "skill_name": update.get("name", ""),
+ "passed": True,
+ "checks": [],
+ "errors": [],
+ "mode": "static",
+ }
+ self._check(result, "defines_async_function", "async function" in code and "(bot" in code)
+ self._check(result, "no_undefined_literal", "undefined" not in code)
+ self._check(result, "no_eval_or_require", "eval(" not in code and "require(" not in code)
+ self._check(result, "no_process_or_fs", "process." not in code and "fs." not in code)
+ self._check(result, "no_runtime_capability_summary_access", self._no_runtime_capability_summary_access(code))
+ self._check(result, "no_generic_minecraft_item_names", self._no_generic_minecraft_item_names(code))
+ self._check(result, "inventory_count_uses_item_ids_not_regex", self._inventory_count_looks_valid(code))
+ self._check(result, "does_not_treat_equipment_4_as_offhand", self._does_not_treat_equipment_4_as_offhand(code))
+ self._check(result, "balanced_braces", code.count("{") == code.count("}"))
+ self._check(result, "primitive_calls_have_required_arguments", self._primitive_calls_look_valid(code))
+ self._check(result, "placement_uses_vec3_source", self._placement_looks_valid(code))
+ self._check(
+ result,
+ "no_unknown_harness_helpers",
+ self._awaited_helpers_are_known(code, known_skill_names or set()),
+ )
+ result["passed"] = not result["errors"]
+ self.append(result)
+ return result
+
+ def live_smoke_test(self, odyssey, update: dict[str, Any]) -> dict[str, Any]:
+ test_spec = update.get("test_spec") or {}
+ result = {
+ "time": now_iso(),
+ "skill_name": update.get("name", ""),
+ "passed": False,
+ "mode": "live_smoke",
+ "checks": [],
+ "errors": [],
+ }
+ if not test_spec.get("run_live"):
+ result["errors"].append("live test not requested")
+ return result
+ try:
+ if test_spec.get("reset"):
+ options = {
+ "mode": test_spec.get("reset_mode", "soft"),
+ "wait_ticks": odyssey.env_wait_ticks,
+ "username": odyssey.username,
+ }
+ if isinstance(test_spec.get("inventory"), dict):
+ options["inventory"] = test_spec["inventory"]
+ odyssey.env.reset(options=options)
+ parsed = odyssey._parse_skill_code(update.get("code", ""))
+ execution = odyssey.execute_harness_code(parsed, skill_name=update.get("name", "temporaryHarnessSkill"))
+ result["passed"] = not execution.get("error")
+ result["events_seen"] = len(execution.get("events") or [])
+ except Exception as exc: # noqa: BLE001
+ result["errors"].append(str(exc))
+ self.append(result)
+ return result
+
+ def append(self, result: dict[str, Any]) -> None:
+ with open(self.path, "a", encoding="utf-8") as fp:
+ fp.write(json.dumps(result, ensure_ascii=False) + "\n")
+
+ def _check(self, result: dict[str, Any], name: str, passed: bool) -> None:
+ result["checks"].append({"name": name, "passed": bool(passed)})
+ if not passed:
+ result["errors"].append(name)
+
+ def _primitive_calls_look_valid(self, code: str) -> bool:
+ # Do not mistake `async function mineBlock(bot)` for a call to the
+ # injected mineBlock primitive. The old regexp made primitive-name
+ # collisions fail static validation for an unrelated reason.
+ call_code = re.sub(r"(?:async\s+)?function\s+\w+\s*\([^)]*\)", "", code)
+ for name in self.primitive_names:
+ if name not in call_code:
+ continue
+ for args in re.findall(rf"(?= 3 and parts[2].startswith("{"):
+ return False
+ if name == "smeltItem" and len(parts) == 4 and parts[3].startswith("{"):
+ return False
+ return True
+
+ def _awaited_helpers_are_known(self, code: str, known_skill_names: set[str]) -> bool:
+ """Reject invented global helpers before a candidate reaches Minecraft.
+
+ Direct Mineflayer APIs are methods (for example ``bot.equip``), while
+ globally awaited calls must be an injected primitive/helper, a known
+ skill, or a function declared in the candidate itself.
+ """
+ declared = set(re.findall(r"(?:async\s+)?function\s+(\w+)\s*\(", code))
+ allowed = self.primitive_names | {"findSuitablePosition"} | known_skill_names | declared
+ awaited = re.findall(r"await\s+([A-Za-z_$][\w$]*)\s*\(", code)
+ return all(name in allowed for name in awaited)
+
+ def _placement_looks_valid(self, code: str) -> bool:
+ if "placeItem" not in code:
+ return True
+ return "findSuitablePosition(bot)" in code or "new Vec3(" in code or "Block.position" in code
+
+ def _no_runtime_capability_summary_access(self, code: str) -> bool:
+ banned = [
+ "capability_summary",
+ "primitive_reference",
+ "task_state",
+ "success_criteria",
+ ]
+ return not any(token in code for token in banned)
+
+ def _no_generic_minecraft_item_names(self, code: str) -> bool:
+ banned_names = {"log", "logs", "plank", "planks", "wooden_planks"}
+ for match in re.findall(r"""["']([a-z_]+)["']""", code):
+ if match in banned_names:
+ return False
+ return True
+
+ def _inventory_count_looks_valid(self, code: str) -> bool:
+ for args in re.findall(r"bot\.inventory\.count\s*\(([^)]*)\)", code):
+ stripped = args.strip()
+ if not stripped:
+ return False
+ if stripped.startswith("/"):
+ return False
+ if re.match(r"""^["'][a-z_]+["']$""", stripped):
+ return False
+ return True
+
+ def _does_not_treat_equipment_4_as_offhand(self, code: str) -> bool:
+ lowered = code.lower()
+ bad_patterns = [
+ "equipment[4] is off",
+ "equipment slot 4) to inventory",
+ "equipment slot 4) from off",
+ "equipment slot 4 to inventory",
+ "slot 4 is off",
+ "slot [4] is off",
+ ]
+ return not any(pattern in lowered for pattern in bad_patterns)
+
+ def _split_args(self, args: str) -> list[str]:
+ parts: list[str] = []
+ current: list[str] = []
+ quote = ""
+ depth = 0
+ escape = False
+ for char in args:
+ if escape:
+ current.append(char)
+ escape = False
+ continue
+ if char == "\\":
+ current.append(char)
+ escape = True
+ continue
+ if quote:
+ current.append(char)
+ if char == quote:
+ quote = ""
+ continue
+ if char in {"'", '"', "`"}:
+ current.append(char)
+ quote = char
+ continue
+ if char in "([{":
+ depth += 1
+ current.append(char)
+ continue
+ if char in ")]}":
+ depth = max(0, depth - 1)
+ current.append(char)
+ continue
+ if char == "," and depth == 0:
+ value = "".join(current).strip()
+ if value:
+ parts.append(value)
+ current = []
+ continue
+ current.append(char)
+ value = "".join(current).strip()
+ if value:
+ parts.append(value)
+ return parts
diff --git a/experiments/Odyssey/Odyssey/odyssey/odyssey.py b/experiments/Odyssey/Odyssey/odyssey/odyssey.py
new file mode 100644
index 0000000000000000000000000000000000000000..8ae7b83b3043c55786a3ae83fd7c8f7c049e36c0
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/odyssey.py
@@ -0,0 +1,782 @@
+from __future__ import annotations
+
+import copy
+import json
+import time
+from typing import Dict
+
+from javascript import require
+import odyssey.utils as U
+from .env import VoyagerEnv
+
+from .agents import ActionAgent
+from .agents import CommentAgent
+from .agents import CriticAgent
+from .agents import PlannerAgent
+from .agents import SkillManager
+from .harness import HarnessOrchestrator
+
+# add llama
+from .agents.llama import call_with_messages, ModelType
+from .utils.logger import get_logger, Timer
+
+# TODO: remove event memory
+class Odyssey:
+ def __init__(
+ self,
+ mc_host: str = 'localhost',
+ mc_port: int = None,
+ azure_login: Dict[str, str] = None,
+ server_port: int = 3000,
+ environment: str = None,
+ env_wait_ticks: int = 20,
+ env_request_timeout: int = 600,
+ max_iterations: int = 160,
+ reset_placed_if_failed: bool = False,
+ action_agent_model_name: str = ModelType.LLAMA3_8B_V3,
+ action_agent_temperature: float = 0,
+ action_agent_task_max_retries: int = 4,
+ action_agent_show_chat_log: bool = True,
+ action_agent_show_execution_error: bool = True,
+ planner_agent_model_name: str = ModelType.LLAMA3_70B_V1,
+ planner_agent_qa_model_name: str = ModelType.LLAMA3_8B_V3,
+ planner_agent_warm_up: Dict[str, int] = None,
+ planner_agent_core_inventory_items: str = r".*_log|.*_planks|stick|crafting_table|furnace"
+ r"|cobblestone|dirt|coal|.*_pickaxe|.*_sword|.*_axe",
+ planner_agent_mode: str = "auto",
+ critic_agent_model_name: str = ModelType.LLAMA2_70B,
+ comment_agent_model_name: str = ModelType.LLAMA3_8B_V3,
+ critic_agent_temperature: float = 0,
+ critic_agent_mode: str = "auto",
+ skill_manager_temperature: float = 0,
+ skill_manager_retrieval_top_k: int = 10,
+ openai_api_request_timeout: int = 240,
+ ckpt_dir: str = "ckpt",
+ skill_library_dir: str = "./skill_library",
+ resume: bool = False,
+ harness_resume: bool = False,
+ reload = False,
+ embedding_dir = "",
+ username = 'bot',
+ memory_config_path: str | None = None,
+ ):
+ """
+ The main class for Odyssey.
+ Action agent is the iterative prompting mechanism in paper.
+ planner agent is the automatic planner in paper.
+ Critic agent is the self-verification in paper.
+ Skill manager is the skill library in paper.
+ :param mc_port: minecraft in-game port
+ :param azure_login: minecraft login config
+ :param server_port: mineflayer port
+ :param env_wait_ticks: how many ticks at the end each step will wait, if you found some chat log missing,
+ you should increase this value
+ :param env_request_timeout: how many seconds to wait for each step, if the code execution exceeds this time,
+ python side will terminate the connection and need to be resumed
+ :param reset_placed_if_failed: whether to reset placed blocks if failed, useful for building task
+ :param action_agent_model_name: action agent model name
+ :param action_agent_temperature: action agent temperature
+ :param action_agent_task_max_retries: how many times to retry if failed
+ :param planner_agent_model_name: planner agent model name
+ :param planner_agent_temperature: planner agent temperature
+ :param planner_agent_qa_model_name: planner agent qa model name
+ :param planner_agent_qa_temperature: planner agent qa temperature
+ :param planner_agent_warm_up: info will show in planner human message
+ if completed task larger than the value in dict, available keys are:
+ {
+ "context": int,
+ "biome": int,
+ "time": int,
+ "other_blocks": int,
+ "nearby_entities": int,
+ "health": int,
+ "hunger": int,
+ "position": int,
+ "equipment": int,
+ "chests": int,
+ "optional_inventory_items": int,
+ }
+ :param planner_agent_core_inventory_items: only show these items in inventory before optional_inventory_items
+ reached in warm up
+ :param planner_agent_mode: "auto" for automatic planner, "manual" for human planner
+ :param critic_agent_model_name: critic agent model name
+ :param critic_agent_temperature: critic agent temperature
+ :param critic_agent_mode: "auto" for automatic critic ,"manual" for human critic
+ :param skill_manager_model_name: skill manager model name
+ :param skill_manager_temperature: skill manager temperature
+ :param skill_manager_retrieval_top_k: how many skills to retrieve for each task
+ :param openai_api_request_timeout: how many seconds to wait for openai api
+ :param ckpt_dir: checkpoint dir
+ :param skill_library_dir: skill library dir
+ :param resume: whether to resume the original Odyssey agent checkpoints
+ :param harness_resume: whether to preserve Harness learning artifacts
+ and task transaction state without resuming the original agents
+ """
+ # init env
+ self.username = username
+ self.logger = get_logger("Voyager")
+ self.env = VoyagerEnv(
+ mc_host=mc_host,
+ mc_port=mc_port,
+ azure_login=azure_login,
+ server_port=server_port,
+ request_timeout=env_request_timeout,
+ )
+ self.env_wait_ticks = env_wait_ticks
+ self.reset_placed_if_failed = reset_placed_if_failed
+ self.max_iterations = max_iterations
+ self.totoal_time = 0
+ self.total_iter = 0
+ self.step_time = []
+ self.action_agent_model_name = action_agent_model_name
+ self.planner_agent_model_name = planner_agent_model_name
+ self.planner_agent_qa_model_name = planner_agent_qa_model_name
+ self.critic_agent_model_name = critic_agent_model_name
+ self.comment_agent_model_name = comment_agent_model_name
+
+ # init agents
+ self.action_agent = ActionAgent(
+ # temperature=action_agent_temperature,
+ request_timout=openai_api_request_timeout,
+ ckpt_dir=ckpt_dir,
+ resume=resume,
+ chat_log=action_agent_show_chat_log,
+ execution_error=action_agent_show_execution_error,
+ )
+ self.action_agent_task_max_retries = action_agent_task_max_retries
+ self.planner_agent = PlannerAgent(
+ model_name=planner_agent_model_name,
+ # temperature=planner_agent_temperature,
+ qa_model_name=planner_agent_qa_model_name,
+ # qa_temperature=planner_agent_qa_temperature,
+ request_timout=openai_api_request_timeout,
+ ckpt_dir=ckpt_dir,
+ resume=resume,
+ mode=planner_agent_mode,
+ warm_up=planner_agent_warm_up,
+ core_inventory_items=planner_agent_core_inventory_items,
+ embedding_model=embedding_dir,
+ )
+ self.critic_agent = CriticAgent(
+ model_name=critic_agent_model_name,
+ # temperature=critic_agent_temperature,
+ request_timout=openai_api_request_timeout,
+ mode=critic_agent_mode,
+ )
+ self.comment_agent = CommentAgent(
+ environment=environment,
+ model_name=comment_agent_model_name
+ )
+ self.skill_manager = SkillManager(
+ # temperature=skill_manager_temperature,
+ retrieval_top_k=skill_manager_retrieval_top_k,
+ request_timout=openai_api_request_timeout,
+ ckpt_dir=skill_library_dir if skill_library_dir else ckpt_dir,
+ resume=True if resume or skill_library_dir else False,
+ reload=reload,
+ embedding_model=embedding_dir,
+ )
+ self.environment = environment
+ self.skills = [[], []]
+ self.recorder = U.EventRecorder(ckpt_dir=ckpt_dir, resume=resume)
+ self.resume = resume
+ self.harness_resume = harness_resume
+
+ # init variables for rollout
+ self.action_agent_rollout_num_iter = -1
+ self.task = None
+ self.context = ""
+ self.messages = None
+ self.conversations = []
+ self.last_events = None
+ self.skill_library_dir = skill_library_dir
+ self.harness = None
+ self.memory_config_path = memory_config_path
+
+ def reset(self, task, context="", reset_env=True):
+ self.action_agent_rollout_num_iter = 0
+ self.task = task
+ self.context = context
+ if reset_env:
+ self.env.reset(
+ options={
+ "mode": "soft",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ difficulty = (
+ "easy" if len(self.planner_agent.completed_tasks) > 15 else "peaceful"
+ )
+ # step to peek an observation
+ events = self.env.step(
+ "bot.chat(`/time set ${getNextTime()}`);\n"
+ + f"bot.chat('/difficulty {difficulty}');\n"
+ )
+ with Timer('retrieve skills'):
+ self.skills = self.skill_manager.retrieve_skills(query=self.context)
+ self.logger.info(f"Render Action Agent system message with {len(self.skills[0])} skills")
+ system_message = self.action_agent.render_system_message()
+ human_message = self.action_agent.render_human_message(
+ events=events, code="", task=self.task, context=context, critique="", skills=self.skills[1]
+ )
+ self.messages = [system_message, human_message]
+ self.logger.debug(f'Select Skill Human Message: {human_message.content}')
+ # self.logger.debug(f"****Action Agent human message****\n{human_message.content}")
+ assert len(self.messages) == 2
+ self.conversations = []
+ return self.messages
+
+ def close(self):
+ self.env.close()
+
+ def step(self):
+ if self.action_agent_rollout_num_iter < 0:
+ raise ValueError("Agent must be reset before stepping")
+ # ai_message = self.action_agent.llm(self.messages)
+ # modify
+ with Timer('step: Select Skill'):
+ self.logger.debug(f'human mesasges: {self.messages[1].content}')
+ ai_message = call_with_messages(self.messages, self.action_agent_model_name)
+ self.logger.debug(f"response: {ai_message.content}")
+ self.conversations.append(
+ (self.messages[0].content, self.messages[1].content, ai_message.content)
+ )
+ with Timer('Process Select Skill Response'):
+ parsed_result = self.action_agent.process_ai_message(message=ai_message, skills=self.skills[0])
+ self.logger.debug(f"parsed_result: {parsed_result}")
+
+ success = False
+ cur_inventoty = ""
+ if isinstance(parsed_result, dict):
+ code = parsed_result["program_code"] + "\n" + parsed_result["exec_code"]
+ with Timer('env step'):
+ events = self.env.step(
+ code,
+ programs=self.skill_manager.programs,
+ )
+ self.totoal_time, self.total_iter = self.recorder.record(events, self.task)
+ self.action_agent.update_chest_memory(events[-1][1]["nearbyChests"])
+ if self.environment == 'subgoal':
+ with Timer('Check Subgoal Success'):
+ success = self.critic_agent.check_subgoal_success(
+ events=events,
+ task=self.task,
+ )
+ critique = ''
+ self.logger.debug(f'success: {success}')
+ else:
+ with Timer('Critic Check Task Success'):
+ success, critique = self.critic_agent.check_task_success(
+ events=events,
+ task=self.task,
+ context=self.context,
+ chest_observation=self.action_agent.render_chest_observation(),
+ max_retries=5,
+
+ )
+ self.logger.debug(f'success: {success}')
+ self.logger.debug(f'critique: {critique}')
+
+ if self.reset_placed_if_failed and not success:
+ # revert all the placing event in the last step
+ blocks = []
+ positions = []
+ for event_type, event in events:
+ if event_type == "onSave" and event["onSave"].endswith("_placed"):
+ block = event["onSave"].split("_placed")[0]
+ position = event["status"]["position"]
+ blocks.append(block)
+ positions.append(position)
+ new_events = self.env.step(
+ f"await givePlacedItemBack(bot, {U.json_dumps(blocks)}, {U.json_dumps(positions)})",
+ programs=self.skill_manager.programs,
+ )
+ events[-1][1]["inventory"] = new_events[-1][1]["inventory"]
+ events[-1][1]["voxels"] = new_events[-1][1]["voxels"]
+ # new_skills = self.skill_manager.retrieve_skills(
+ # query=self.context
+ # + "\n\n"
+ # + self.action_agent.summarize_chatlog(events)
+ # )
+ system_message = self.action_agent.render_system_message()
+ human_message = self.action_agent.render_human_message(
+ events=events,
+ code=parsed_result["program_name"],
+ task=self.task,
+ critique=critique,
+ skills=self.skills[1]
+ )
+ self.last_events = copy.deepcopy(events)
+ self.messages = [system_message, human_message]
+ else:
+ assert isinstance(parsed_result, str)
+ self.totoal_time, self.total_iter = self.recorder.record([], self.task)
+ self.logger.warning(f"{parsed_result} Trying again!")
+ self.step_time.append(self.totoal_time)
+ self.logger.info(self.totoal_time)
+ assert len(self.messages) == 2
+ self.action_agent_rollout_num_iter += 1
+ done = (
+ self.action_agent_rollout_num_iter >= self.action_agent_task_max_retries
+ or success
+ )
+ info = {
+ "task": self.task,
+ "success": success,
+ "conversations": self.conversations,
+ }
+ if success:
+ assert (
+ "program_code" in parsed_result and "program_name" in parsed_result
+ ), "program and program_name must be returned when success"
+ info["program_code"] = parsed_result["program_code"]
+ info["program_name"] = parsed_result["program_name"]
+ # else:
+ # self.logger.debug(f"****Action Agent human message****\n{self.messages[-1].content}")
+ return self.messages, self.last_events[-1][1]["inventory"], done, info
+
+ def rollout(self, *, task, context, reset_env=True):
+ self.reset(task=task, context=context, reset_env=reset_env)
+ while True:
+ messages, reward, done, info = self.step()
+ if done:
+ break
+ return messages, reward, done, info
+
+ def init_harness(self):
+ if self.harness is None:
+ self.harness = HarnessOrchestrator(
+ odyssey=self,
+ model_name=self.action_agent_model_name,
+ ckpt_dir=self.recorder.ckpt_dir,
+ resume=self.harness_resume,
+ memory_config_path=self.memory_config_path,
+ )
+ return self.harness
+
+ def harness_run_instruction(
+ self,
+ instruction,
+ reset_env=False,
+ reset_mode="soft",
+ max_steps=3,
+ max_replans=2,
+ completion_contract=None,
+ ):
+ harness = self.init_harness()
+ return harness.run_instruction(
+ instruction,
+ events=self.last_events,
+ reset_env=reset_env,
+ reset_mode=reset_mode,
+ max_steps=max_steps,
+ max_replans=max_replans,
+ completion_contract=completion_contract,
+ )
+
+ def execute_harness_skill(self, skill_name):
+ if skill_name not in self.skill_manager.skills:
+ raise KeyError(f"Unknown harness skill: {skill_name}")
+ parsed_result = self._parse_skill_code(self.skill_manager.skills[skill_name]["code"])
+ return self.execute_harness_code(parsed_result, skill_name=skill_name)
+
+ def execute_harness_code(self, parsed_result, skill_name="temporaryHarnessSkill"):
+ code = parsed_result["program_code"] + "\n" + parsed_result["exec_code"]
+ events = self.env.step(code, programs=self.skill_manager.programs)
+ self.last_events = copy.deepcopy(events)
+ self.action_agent.update_chest_memory(events[-1][1]["nearbyChests"])
+ return {
+ "skill_name": skill_name,
+ "program_name": parsed_result["program_name"],
+ "events": events,
+ }
+
+ def _parse_skill_code(self, skill_code):
+ babel = require("@babel/core")
+ generator_module = require("@babel/generator")
+ try:
+ babel_generator = generator_module.default
+ except Exception: # CommonJS versions export the callable directly.
+ babel_generator = generator_module
+ if babel_generator is None:
+ babel_generator = generator_module
+ parsed = babel.parseSync(skill_code)
+ functions = []
+ assert len(list(parsed.program.body)) > 0, "No functions found"
+ for node in parsed.program.body:
+ if node.type != "FunctionDeclaration":
+ continue
+ node_type = (
+ "AsyncFunctionDeclaration"
+ if node["async"]
+ else "FunctionDeclaration"
+ )
+ functions.append(
+ {
+ "name": node.id.name,
+ "type": node_type,
+ "body": babel_generator(node).code,
+ "params": list(node["params"]),
+ }
+ )
+ main_function = None
+ for function in reversed(functions):
+ if function["type"] == "AsyncFunctionDeclaration":
+ main_function = function
+ break
+ assert main_function is not None, "No async function found. Your main function must be async."
+ assert (
+ len(main_function["params"]) == 1
+ and main_function["params"][0].name == "bot"
+ ), f"Main function {main_function['name']} must take a single argument named 'bot'"
+ return {
+ "program_code": "\n\n".join(function["body"] for function in functions),
+ "program_name": main_function["name"],
+ "exec_code": f"await {main_function['name']}(bot);",
+ }
+
+ def learn(self, goals=None, reset_env=True):
+ self.inventory = []
+ self.recorder.elapsed_time = 0
+ self.recorder.iteration = 0
+ self.step_time = []
+ self.critic_agent.last_inventory = "Empty"
+ self.critic_agent.last_inventory_used = 0
+ with Timer('env reset'):
+ self.logger.debug(f'resume: {self.resume}')
+ if self.resume:
+ # keep the inventory
+ self.env.reset(
+ options={
+ "mode": "soft",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ else:
+ # clear the inventory
+ self.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ self.resume = True
+ self.run_raw_skill("odyssey/test_env/respawnAndClear.js") # clear inventory without reset
+ with Timer('env step empty string'):
+ self.last_events = self.env.step("")
+ while True:
+ if self.recorder.iteration > self.max_iterations:
+ self.logger.warning("Iteration limit reached")
+ break
+ with Timer('planner Agent propose_next_task'):
+ task, context = self.planner_agent.propose_next_task(
+ events=self.last_events,
+ environment=self.environment,
+ chest_observation=self.action_agent.render_chest_observation(),
+ goals=goals,
+ max_retries=500,
+ )
+ self.logger.info(f"Starting task {task} for at most {self.action_agent_task_max_retries} times")
+ try:
+ with Timer('learn: rollout'):
+ messages, inventory, done, info = self.rollout(
+ task=task,
+ context=context,
+ reset_env=reset_env,
+ )
+ except Exception as e:
+ time.sleep(3) # wait for mineflayer to exit
+ info = {
+ "task": task,
+ "success": False,
+ }
+ # reset bot status here
+ self.last_events = self.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": self.env_wait_ticks,
+ "inventory": self.last_events[-1][1]["inventory"],
+ "equipment": self.last_events[-1][1]["status"]["equipment"],
+ "position": self.last_events[-1][1]["status"]["position"],
+ "username": self.username
+ }
+ )
+ # use red color background to print the error
+ self.logger.critical(f"Your last round rollout terminated due to error: {e}")
+ continue
+
+ # if info["success"]:
+ # self.skill_manager.add_new_skill(info)
+ new_inventory = [key for key in inventory if key not in self.inventory]
+ self.inventory += new_inventory
+ U.f_mkdir(f"./results/{self.environment}")
+ U.dump_text(f"Iteration: {self.recorder.iteration}, Inventory obtained: {new_inventory}, Total inventory: {self.inventory}, Num: {len(self.inventory)}\n", f"./results/{self.environment}/{self.action_agent_model_name.replace(' ', '_')}.txt")
+ with Timer('Update Exploration Progress'):
+ self.planner_agent.update_exploration_progress(info)
+ completed = None
+ if goals is not None:
+ with Timer('Critic Check Goal Success'):
+ completed = self.critic_agent.check_goal_success(self.last_events, self.planner_agent.completed_tasks, self.planner_agent.failed_tasks, goals, mode = "program")
+ if completed or self.step_time[-1] >= 36000:
+ break
+ self.logger.success(f"Completed tasks: {', '.join(self.planner_agent.completed_tasks)}")
+ self.logger.failed(f"Failed tasks: {', '.join(self.planner_agent.failed_tasks)}")
+
+ U.f_mkdir(f"./results/{self.environment}")
+ self.logger.info(f"\n\nTicks on each step: {self.step_time}, LLM iters: {self.total_iter}, Completed: {completed}")
+ U.dump_text(f"\n\nTicks on each step: {self.step_time}; LLM iters: {self.total_iter}; Completed: {completed}", f"./results/{self.environment}/{goals.replace(' ', '_')}{self.action_agent_model_name.replace(' ', '_')}.txt")
+ return {
+ "completed_tasks": self.planner_agent.completed_tasks,
+ "failed_tasks": self.planner_agent.failed_tasks,
+ "skills": self.skill_manager.skills,
+ }
+
+ def decompose_task(self, task, last_tasklist=None, critique=None, health=None):
+ if not self.last_events:
+ self.last_events = self.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ return self.planner_agent.decompose_task(self.environment, task, last_tasklist, critique, health)
+
+ def inference(self, task:str=None, sub_goals=[], reset_mode="hard", reset_env=True, feedback_rounds:int=1):
+ if not task and not sub_goals:
+ raise ValueError("Either task or sub_goals must be provided")
+ self.logger.debug(f"Starting inference for task: {task}")
+ with Timer('env reset'):
+ self.last_events = self.env.reset(
+ options={
+ "mode": reset_mode,
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ if not sub_goals:
+ with Timer('decompose task'):
+ sub_goals = self.decompose_task(task)
+ self.logger.debug(f'Decomposed sub_goals: {sub_goals}')
+
+ self.planner_agent.completed_tasks = []
+ self.planner_agent.failed_tasks = []
+ self.last_events = self.env.step("")
+ for i in range(feedback_rounds):
+ try:
+ self.recorder.elapsed_time = 0
+ self.recorder.iteration = 0
+ self.step_time = []
+ self.critic_agent.last_inventory = "Empty"
+ self.critic_agent.last_inventory_used = 0
+ while self.planner_agent.progress < len(sub_goals):
+ next_task = sub_goals[self.planner_agent.progress]
+ self.logger.debug(f'Next subgoal: {next_task}, All subgoals: {sub_goals}')
+ with Timer('get task context'):
+ context = self.planner_agent.get_task_context(next_task)
+ self.logger.debug(f'Got task context: {context}')
+ with Timer('rollout'):
+ messages, reward, done, info = self.rollout(
+ task=next_task,
+ context=context,
+ reset_env=reset_env,
+ )
+ # self.logger.debug(f'info: {info}')
+ with Timer('Update Exploration Progress'):
+ self.planner_agent.update_exploration_progress(info)
+ self.logger.success(f"Completed tasks: {', '.join(self.planner_agent.completed_tasks)}")
+ self.logger.failed(f"Failed tasks: {', '.join(self.planner_agent.failed_tasks)}")
+ if (self.step_time[-1] >= 24000):
+ self.logger.warning('Inference Time limit reached >=24000')
+ break
+ # str_list = task.split()
+ # TODO: hard coding
+ self.run_raw_skill("odyssey/test_env/combatEnv.js", [10, 15, 100])
+ with Timer('rerank monsters'):
+ combat_order = self.planner_agent.rerank_monster(task=task)
+ self.logger.debug(f'Combat order: {combat_order}')
+
+ for task_item in task.split(','):
+ summon_para = task_item.split()
+ summon_para.insert(1, 5) # idx =1, r=5
+ self.run_raw_skill("odyssey/test_env/summonMob.js", summon_para)
+
+ monster_origin = task.split(',')
+ try:
+ for monster in combat_order:
+ para = monster.split(' ')
+ combat_para2 = int(para[0])
+ combat_para1 = para[1].lower() # ensure no uppercase
+ except:
+ # if error happens, use the origin order to kill monster
+ combat_order = monster_origin
+ finally:
+ for monster in combat_order:
+ para = monster.split(' ')
+ combat_para2 = int(para[0])
+ combat_para1 = para[1].lower() # ensure no uppercase
+ with Timer('kill monsters'):
+ self.logger.debug(f'kill monster skill parameter: {combat_para1}, {combat_para2}')
+ kill_res = self.run_raw_skill("skill_library/skill/primitive/killMonsters.js", [combat_para1, combat_para2])
+ if 'lost' in kill_res:
+ break
+
+ with Timer('Comment Check Task Success'):
+ health, cirtiques, result, equipment = \
+ self.comment_agent.check_task_success(events=self.last_events, task=sub_goals, time=self.totoal_time, iter=self.total_iter)
+ U.f_mkdir(f"./results/{self.environment}")
+ U.dump_text(f"Route {i}; Plan list: {sub_goals}; Equipments obtained: {equipment}; Ticks on each step: {self.step_time}; LLM iters: {self.total_iter}; Health: {health:.1f}; Combat result: {result}\n\n", f"./results/{self.environment}/{task.replace(' ', '_')}{self.action_agent_model_name.replace(' ', '_')}.txt")
+
+ with Timer('decompose task again based on feedback'):
+ sub_goals = self.decompose_task(task, last_tasklist=equipment, critique=cirtiques, health=health)
+ self.logger.debug('Decomposed sub_goals based on feedback: {sub_goals}')
+
+ except Exception as e:
+ U.f_mkdir(f"./results/{self.environment}")
+ U.dump_text(f"Route {i}; Plan list: {sub_goals}; Ticks on each step: {self.step_time}; LLM iters: {self.total_iter}; failed; caused by {e}\n\n", f"./results/{self.environment}/{task.replace(' ', '_')}{self.action_agent_model_name.replace(' ', '_')}.txt")
+ finally:
+ self.run_raw_skill("odyssey/test_env/respawnAndClear.js")
+ self.env.reset(
+ options={
+ "mode": "hard",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ self.planner_agent.completed_tasks = []
+ self.planner_agent.failed_tasks = []
+
+ def inference_sub_goal(self, task:str=None, sub_goals=[], reset_mode="hard", reset_env=True):
+ if not sub_goals:
+ raise ValueError("Sub_goals must be provided")
+ self.env.reset(
+ options={
+ "mode": reset_mode,
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ self.recorder.elapsed_time = 0
+ self.recorder.iteration = 0
+ self.step_time = []
+ self.planner_agent.completed_tasks = []
+ self.planner_agent.failed_tasks = []
+ self.last_events = self.env.step("")
+ self.run_raw_skill("odyssey/test_env/respawnAndClear.js")
+
+ while self.planner_agent.progress < len(sub_goals):
+ next_task = sub_goals[self.planner_agent.progress]
+ context = self.planner_agent.get_task_context(next_task)
+ self.logger.info(f"Starting task {next_task} for at most {self.action_agent_task_max_retries} times")
+ messages, reward, done, info = self.rollout(
+ task=next_task,
+ context=context,
+ reset_env=reset_env,
+ )
+ self.planner_agent.update_exploration_progress(info)
+ self.logger.success(f"Completed tasks: {', '.join(self.planner_agent.completed_tasks)}")
+ self.logger.failed(f"Failed tasks: {', '.join(self.planner_agent.failed_tasks)}")
+ U.f_mkdir(f"./results/{self.environment}")
+ if info['success']:
+ U.dump_text(f"Subgoal: {next_task}, Ticks: {self.step_time[-1]}\n", f"./results/{self.environment}/{task.replace(' ', '_')}.txt")
+ else:
+ U.dump_text(f"Subgoal: {next_task}, Ticks: {self.step_time[-1]}, Failed.\n", f"./results/{self.environment}/{task.replace(' ', '_')}.txt")
+ if (self.step_time[-1] >= 24000):
+ break
+
+ def run_raw_skill(self, skill_path, parameters = [], skill_lib = "old", reset = False):
+ # reset here only used for skill test
+ if (reset):
+ self.env.reset(
+ options={
+ "mode": "soft",
+ "wait_ticks": self.env_wait_ticks,
+ "username": self.username
+ }
+ )
+ retry = 3
+ while retry > 0:
+ try:
+ babel = require("@babel/core")
+ generator_module = require("@babel/generator")
+ try:
+ babel_generator = generator_module.default
+ except Exception: # CommonJS versions export the callable directly.
+ babel_generator = generator_module
+ if babel_generator is None:
+ babel_generator = generator_module
+
+ with open(f"{skill_path}", 'r') as file:
+ code = file.read()
+
+ parsed = babel.parseSync(code)
+ functions = []
+ assert len(list(parsed.program.body)) > 0, "No functions found"
+ for i, node in enumerate(parsed.program.body):
+ if node.type != "FunctionDeclaration":
+ continue
+ node_type = (
+ "AsyncFunctionDeclaration"
+ if node["async"]
+ else "FunctionDeclaration"
+ )
+ functions.append(
+ {
+ "name": node.id.name,
+ "type": node_type,
+ "body": babel_generator(node).code,
+ "params": list(node["params"]),
+ }
+ )
+ # find the last async function
+ main_function = None
+ for function in reversed(functions):
+ if function["type"] == "AsyncFunctionDeclaration":
+ main_function = function
+ break
+ assert (
+ main_function is not None
+ ), "No async function found. Your main function must be async."
+ assert (
+ main_function["params"][0].name == "bot"
+ ), f"Main function {main_function['name']} must take a single argument named 'bot'"
+
+ program_code = "\n\n".join(function["body"] for function in functions)
+ para_list = "(bot"
+ for i in range(len(parameters)):
+ if isinstance(parameters[i], str):
+ para_list += ", " + "\"" + parameters[i] + "\""
+ else:
+ para_list += ", " + str(parameters[i])
+ para_list += ");"
+ exec_code = f"await {main_function['name']}{para_list}"
+ parsed_result = {
+ "program_code": program_code,
+ "program_name": main_function["name"],
+ "exec_code": exec_code,
+ }
+ break
+ except Exception as e:
+ retry -= 1
+ parsed_result = f"Error parsing action response (before program execution): {e}"
+
+ result = ''
+ if isinstance(parsed_result, dict):
+ code = parsed_result["program_code"] + "\n" + parsed_result["exec_code"]
+ self.skill_manager.programs = skill_lib # use old or new skill library
+ events = self.env.step(
+ code,
+ programs=self.skill_manager.programs,
+ )
+ for event in reversed(events):
+ if event[0] == 'onChat':
+ result = event[1]['onChat']
+ break
+ self.last_events = copy.deepcopy(events)
+ else:
+ self.logger.warning(f"{parsed_result} Code executes error!")
+
+ return result
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/__init__.py b/experiments/Odyssey/Odyssey/odyssey/prompts/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..2859d5aaf9fba412ff5d9aaa1c7126ab8f88d067
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/__init__.py
@@ -0,0 +1,7 @@
+import pkg_resources
+import odyssey.utils as U
+
+
+def load_prompt(prompt):
+ package_path = pkg_resources.resource_filename("odyssey", "")
+ return U.load_text(f"{package_path}/prompts/{prompt}.txt")
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/action_response_format.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/action_response_format.txt
new file mode 100644
index 0000000000000000000000000000000000000000..df3713a8307f28c798600b215403e3b4841dd1c7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/action_response_format.txt
@@ -0,0 +1,15 @@
+Explain: ...
+Plan:
+1) ...
+2) ...
+3) ...
+...
+Code:
+```javascript
+// helper functions (only if needed, try to avoid them)
+...
+// main function after the helper functions
+async function yourMainFunctionName(bot) {
+ // ...
+}
+```
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/action_template.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/action_template.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ab857a2fc51effad3d1df4c095df9ef427352617
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/action_template.txt
@@ -0,0 +1,15 @@
+You are a helpful assistant that decides Mineflayer javascript code to complete any Minecraft task specified by me.
+
+I will give you
+Task: The task I need to complete in this stage.
+Programs: The description of relevant programs that may use to complete the task.
+Program used in the last round: ...
+Critique: ...
+
+You will choose only one program based on the program description and critique. You should only respond in the format as described below:
+{
+ "program": "your selected program name",
+ "reason": "Reason you choose the program."
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
+Please ensure that the program name you output should be exactly the same (case-inclusive) as the information provided!
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/combat_critic_prompt.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_critic_prompt.txt
new file mode 100644
index 0000000000000000000000000000000000000000..98c369f035c183e180ef42ac0d8d75053cae6df8
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_critic_prompt.txt
@@ -0,0 +1,20 @@
+You are an assistant that assesses my combat task in Minecraft.
+I will give you the following information during this round:
+
+Used Time: The total time for equipment crafting.
+Total iteration: The number of iterations for interacting with the LLM.
+Task List: The equipment I crafted during this round.
+Result: The result of the combat.
+Health: The health of me after the combat. Full HP is 20 and more than 15 means healthy.
+
+In the next round(A new game without keeping existing inventory), I will face the same task, but I will reformulate my task planning based on my experience.
+If I won the combat, then the goal of the next round is to spend less time, you should suggest that I streamline the task plan, for example, reduce the quantity and quality of crafting equipment;
+If I lost the combat, then the goal of the next round is to win, you should suggest that I improve the task plan, for example, improve the quantity and quality of crafting equipment;
+In the next round of combat task, I will reformulate the task subgoals based on your critiques in order to complete more efficiently.
+
+You should only respond in JSON format as described below:
+{
+ "reasoning": "reasoning",
+ "critique": "critique",
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no extra brackets, no trailing commas, no single quotes, etc.
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/combat_sys_prompt.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_sys_prompt.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0c3ddffef7fa49913e3dae457068ba10374799f5
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_sys_prompt.txt
@@ -0,0 +1,29 @@
+In Minecraft, combating with monsters requires weapons and armor.
+The weapon options are limited to "sword", while the armor includes "helmet", "chestplate", "leggings", and "boots".
+The materials for swords range from low to high level: wooden swords, stone swords, iron swords, and diamond swords;
+The materials for armor range from low to high level: iron, diamond.
+The higher the material level, the greater the attack damage of the weapon and the better the protection effect of the armor.
+However, the higher the material level, the more time it costs to collect.
+
+Tips: Wooden, stone, iron and diamond are the only levels of sword; iron and diamond are the only levels of armors; helmet, chestplate, leggings and boots are the only types of armors; do not generate information that doesn't relate to them.
+
+Your goal is to generate the plan that can defeat all monsters while using the shortest time. So, more is not always better when proposing your plan list.
+After each round of combat, I will give you:
+
+Equipment obtained from last round: ...
+Health after last combat: ...
+Critique: ...
+Monster: The monsters you need to defeat.
+
+The critique (if any) will tell you the subgoal list from the previous round and whether you should trim or add to it. Remember to refer to the critique to adjust your task list.
+Next, you will start a new combat task, the last round of equipment and health is only for planning reference, not related to the current round. Plan from scratch!
+
+You must follow the following criteria:
+1) Return a Python list of subgoals that can be completed in order to complete the specified task.
+2) Each subgoal should only start with "craft"! do not propose any other type of skills!
+3) Each subgoal should follow a concise format "craft [material type] [equipment type]".
+
+You should only respond in JSON format as described below:
+["subgoal1", "subgoal2", "subgoal3", ...]
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
+The [] format will be used for RE extraction, do not use this format for anything other than your plan list and no extra brackets!
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/combat_template.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_template.txt
new file mode 100644
index 0000000000000000000000000000000000000000..00a5d93fcd03e4d01f6b3bab76e80d3a9162eb3a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/combat_template.txt
@@ -0,0 +1,11 @@
+You are a helpful assistant that generates the order of fighting monsters to defeat all monsters specified by me.
+
+I'll give you a list of monsters, and you need to rearrange the order of monsters according to how hard it is to beat them.
+You should give priority to monsters that attack the player and do more damage, while monsters that don't actively attack the player or do less damage should be left behind.
+Make sure your list includes all the monsters in your task..
+The output format must be exactly same as the input, including the underline.
+If your task is to combat single type of monsters, return a list containing only that monster as well.
+
+You should only respond in JSON format as described below:
+["quantity monster1", "quantity monster2", "quantity monster3", ...]
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/critic.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/critic.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6f69b22ae1682f622be4b8b4f34757c48583ee0b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/critic.txt
@@ -0,0 +1,26 @@
+You are required to evaluate if I have met the task requirements in Minecraft. Exceeding the task requirements is also considered a success while failing to meet them requires you to provide critique to help me improve.
+
+I will give you the following information:
+
+Task: The objective I need to accomplish.
+Nearby blocks:
+Equipment: My tools, weapons and armor could sometimes be here.
+Chests: If the task requires me to place items in a chest, you can find chest information here.
+Current inventory (xx/36): My final inventory after carry out the task.
+Last inventory (xx/36): My inventory before carry out the task.
+Chat log: The logs during carrying out the task.
+
+**Note** that you only need to check the changes of my inventory to judge whether I meet the task.
+For a `craft [item]` task, all you need to do is checking if the item I need to craft is in my current inventory or equipment. If in, you should judge it as a success and vice versa.
+For a `mine [item]` task, you only need to check whether the item is in my current inventory or has an increase over last inventory.
+For a `hoe` or `plant` task, you only need to check whether the `farmland` or `seed` is in Nearby Blocks.
+Do not judge the success of a `craft` task based on other materials I have!
+You can only judge a task failure via chat log, not as a reason to judge a task's success.
+
+You should only respond in JSON format as described below:
+{
+ "reasoning": "reasoning",
+ "success": boolean,
+ "critique": "critique",
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step1_ask_questions.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step1_ask_questions.txt
new file mode 100644
index 0000000000000000000000000000000000000000..6d93537fee089875695d80e77e7824011bea6477
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step1_ask_questions.txt
@@ -0,0 +1,94 @@
+You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world.
+
+I will give you the following information:
+Biome: ...
+Time: ...
+Nearby blocks: ...
+Other blocks that are recently seen: ...
+Nearby entities (nearest to farthest): ...
+Health: ...
+Hunger: ...
+Position: ...
+Equipment: ...
+Inventory (xx/36): ...
+Chests: ...
+Completed tasks so far: ...
+Failed tasks that are too hard: ...
+
+You must follow the following criteria:
+1) You should ask at least 5 questions (but no more than 10 questions) to help me decide the next immediate task to do. Each question should be followed by the concept that the question is about.
+2) Your question should be specific to a concept in Minecraft.
+ Bad example (the question is too general):
+ Question: What is the best way to play Minecraft?
+ Concept: unknown
+ Bad example (axe is still general, you should specify the type of axe such as wooden axe):
+ What are the benefits of using an axe to gather resources?
+ Concept: axe
+ Good example:
+ Question: How to make a wooden pickaxe?
+ Concept: wooden pickaxe
+3) Your questions should be self-contained and not require any context.
+ Bad example (the question requires the context of my current biome):
+ Question: What are the blocks that I can find in my current biome?
+ Concept: unknown
+ Bad example (the question requires the context of my current inventory):
+ Question: What are the resources you need the most currently?
+ Concept: unknown
+ Bad example (the question requires the context of my current inventory):
+ Question: Do you have any gold or emerald resources?
+ Concept: gold
+ Bad example (the question requires the context of my nearby entities):
+ Question: Can you see any animals nearby that you can kill for food?
+ Concept: food
+ Bad example (the question requires the context of my nearby blocks):
+ Question: Is there any water source nearby?
+ Concept: water
+ Good example:
+ Question: What are the blocks that I can find in the sparse jungle?
+ Concept: sparse jungle
+4) Do not ask questions about building tasks (such as building a shelter) since they are too hard for me to do.
+
+Let's say your current biome is sparse jungle. You can ask questions like:
+Question: What are the items that I can find in the sparse jungle?
+Concept: sparse jungle
+Question: What are the mobs that I can find in the sparse jungle?
+Concept: sparse jungle
+
+Let's say you see a creeper nearby, and you have not defeated a creeper before. You can ask a question like:
+Question: How to defeat the creeper?
+Concept: creeper
+
+Let's say your last completed task is "Craft a wooden pickaxe". You can ask a question like:
+Question: What are the suggested tasks that I can do after crafting a wooden pickaxe?
+Concept: wooden pickaxe
+
+Here are some more question and concept examples:
+Question: What are the ores that I can find in the sparse jungle?
+Concept: sparse jungle
+(the above concept should not be "ore" because I need to look up the page of "sparse jungle" to find out what ores I can find in the sparse jungle)
+Question: How can you obtain food in the sparse jungle?
+Concept: sparse jungle
+(the above concept should not be "food" because I need to look up the page of "sparse jungle" to find out what food I can obtain in the sparse jungle)
+Question: How can you use the furnace to upgrade your equipment and make useful items?
+Concept: furnace
+Question: How to obtain a diamond ore?
+Concept: diamond ore
+Question: What are the benefits of using a stone pickaxe over a wooden pickaxe?
+Concept: stone pickaxe
+Question: What are the tools that you can craft using wood planks and sticks?
+Concept: wood planks
+
+You should only respond in the format as described below:
+RESPONSE FORMAT:
+Reasoning: ...
+Question 1: ...
+Concept 1: ...
+Question 2: ...
+Concept 2: ...
+Question 3: ...
+Concept 3: ...
+Question 4: ...
+Concept 4: ...
+Question 5: ...
+Concept 5: ...
+...
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step2_answer_questions.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step2_answer_questions.txt
new file mode 100644
index 0000000000000000000000000000000000000000..860d986e497ee709b097431f54f5540e5dd9884a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_qa_step2_answer_questions.txt
@@ -0,0 +1,8 @@
+You are a helpful assistant that answer my question about Minecraft.
+
+I will give you the following information:
+Question: ...
+
+You will answer the question based on the context (only if available and helpful) and your own knowledge of Minecraft.
+1) Start your answer with "Answer: ".
+2) Answer "Answer: Unknown" if you don't know the answer.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_task_decomposition.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_task_decomposition.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0e3b1592daf21a7bd15c09f621e0eeefcebc6dc0
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/curriculum_task_decomposition.txt
@@ -0,0 +1,12 @@
+You are a helpful assistant that generates a curriculum of subgoals to complete any Minecraft task specified by me.
+
+I'll give you a final task and my current inventory, you need to decompose the task into a list of subgoals based on my inventory.
+
+You must follow the following criteria:
+1) Return a Python list of subgoals that can be completed in order to complete the specified task.
+2) Each subgoal should follow a concise format, such as "Mine [quantity] [block]", "Craft [quantity] [item]", "Smelt [quantity] [item]", "Kill [quantity] [mob]", "Cook [quantity] [food]", "Equip [item]".
+3) Include each level of necessary tools as a subgoal, such as wooden, stone, iron, diamond, etc.
+
+You should only respond in JSON format as described below:
+["subgoal1", "subgoal2", "subgoal3", ...]
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/explore_sys_prompt.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/explore_sys_prompt.txt
new file mode 100644
index 0000000000000000000000000000000000000000..43b64332a98ea65682b6826a3e391ce9a86bcafa
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/explore_sys_prompt.txt
@@ -0,0 +1,33 @@
+You are a helpful assistant that tells me the next immediate task to do in Minecraft. My ultimate goal is to discover as many diverse things as possible, accomplish as many diverse tasks as possible and become the best Minecraft player in the world.
+
+I will give you the following information:
+Biome: ...
+Time: ...
+Nearby blocks: ...
+Other blocks that are recently seen: ...
+Nearby entities (nearest to farthest): ...
+Health: Higher than 15 means I'm healthy.
+Hunger: Higher than 15 means I'm not hungry.
+Position: ...
+Equipment: If I have better armor in my inventory, you should ask me to equip it.
+Inventory (xx/36): ...
+Chests: ...
+Completed tasks so far: ...
+Failed tasks that are too hard: ...
+
+You must follow the following criteria:
+1) You should act as a mentor and guide me to the next task based on my current learning progress.
+2) Please be very specific about what resources I need to collect, what I need to craft, or what mobs I need to kill.
+3) The next task should follow a concise format, such as "Mine [block]", "Craft [item]", "Smelt [item]", "Kill [mob]", "Cook [food]", "Equip" etc. It should be a single phrase. Do not propose multiple tasks at the same time. Do not mention anything else.
+4) The next task should be novel and interesting. I should look for rare resources, upgrade my equipment and tools using better materials, and discover new things. I should not be doing the same thing over and over again.
+5) Don't propose tasks that have already completed once or failed more than three times!
+6) Do not ask me to build or dig shelter even if it's at night. I want to explore the world and discover new things. I don't want to stay in one place.
+7) Tasks that require information beyond the player's status to verify should be avoided. For instance, "Placing 4 torches" and "Dig a 2x1x2 hole" are not ideal since they require visual confirmation from the screen. All the placing, building and trading tasks should be avoided. Do not propose task starting with these keywords.
+8) For wood-related tasks, you don't need to emphasize the type of wood, just propose "mine log" or "craft planks".
+
+You should only respond in JSON format as described below:
+{
+ "reasoning": "Based on the information I listed above, do reasoning about what the next task should be.",
+ "task": "The next task."
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/farming_sys_prompt.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/farming_sys_prompt.txt
new file mode 100644
index 0000000000000000000000000000000000000000..7441c5dbb6091f6f9aee50f42be6e582b8362fce
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/farming_sys_prompt.txt
@@ -0,0 +1,33 @@
+You are a helpful assistant that tells me the next immediate task to do in Minecraft. My ultimate goal is to ```goals```.
+Make sure that the proposed task is related to the ultimate goal, and do not propose unrelated tasks!
+For easy tasks like collect seeds, you can directly propose "collect [type] seeds", but determine seed species based on environmental information. Samely, "obtain leather" by directly killing the corresponding animal like propose "kill [animalType]".
+But for complex tasks, you need to plan step by step towards your ultimate goal, so propose necessary prerequisite tasks first.
+For example, "craft hoe" before "hoe farmland", "kill animal" before "cook meat", "craft shears" before "shear sheep", "craft bucket" before "collect milk" or "collect water", "collect sugarcane" before "make sugar" and "smelt dye" after "collect cactus".
+Propose the current task only when you ensure that you have all the necessary dependent items in inventory.
+Don't ask for repetitive tasks. If you already have an item in your inventory, try not to collect it repeatedly.
+For example, when you already have a hoe in your inventory, propose "hoe farmland" instead of "craft hoe" again.
+
+I will give you the following information:
+Ultimate goal: ...
+Reference: ...
+Biome: ...
+Nearby blocks: ...
+Other blocks that are recently seen: ...
+Nearby entities (nearest to farthest): ...
+Health: Higher than 15 means I'm healthy.
+Hunger: Higher than 15 means I'm not hungry.
+Inventory (xx/36): ...
+Logs: The execution logs in last task, you can refer to it to propose next task.
+Completed tasks so far: ...
+Failed tasks that are too hard: ...
+
+You must follow the following criteria:
+1) Please be very specific about what resources I need to collect, what I need to farm, or what animals I need to breed/kill.
+2) The next task should follow a concise format, such as "craft [item]", "breed/kill [animal type]", "cook/eat [food type]", "plant [seed type] seed" or some specific action "shear sheep", "collect milk". Do not propose multiple tasks at the same time. Do not mention anything else.
+
+You should only respond in JSON format as described below:
+{
+ "reasoning": "Based on the information I listed above, do reasoning about what the next task should be",
+ "task": "The next task"
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/goals.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/goals.txt
new file mode 100644
index 0000000000000000000000000000000000000000..76a4c338ca0302ff1d9dbefd114eff59fe268467
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/goals.txt
@@ -0,0 +1,26 @@
+You are required to evaluate if I have met the goal requirements in Minecraft. Exceeding the goal requirements is also considered a success.
+
+I'll give you:
+
+My completed task: ...
+My failed task: ...
+My ultimate goal: ...
+
+You should only respond in JSON format as described below:
+{
+ "reasoning": "reasoning",
+ "success": boolean
+}
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
+
+Here are some examples:
+INPUT:
+My completed task: grow wheat, get cow, collect milk
+My failed task: None
+My ultimate goal: Collect milk.
+
+RESPONSE:
+{
+ "reasoning": "Your goal is to collect milk, this has in your completed task.",
+ "success": true
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/long-term_planning_prompt.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/long-term_planning_prompt.txt
new file mode 100644
index 0000000000000000000000000000000000000000..44ad331c4987be7a27e569574d4eafa099011a0c
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/long-term_planning_prompt.txt
@@ -0,0 +1,18 @@
+In minecraft, many tasks require long-term planning.
+For example, make a set of building materials (doors, glass windows, wooden beds, fences, stone walls, stairs, wooden ladders, lanterns, trap doors, sand stones, etc.)
+Collect a set of minerals (gold, copper, iron, lapis lazuli, redstone, diamonds, emeralds, etc.)
+Get a set of redstone artifacts (pistons, comparators, hoppers, redstone blocks, etc.)
+Make a set of tech tree X-level tools (sword, axe, shovel, hoe, pickaxe) where X= wood, stone, golden, iron and diamond.
+Make a utility kit (compass, clock, spyglass, lightning rod, anvil, etc.)
+
+I will give you a specific description of the task, and you need to generate a list of plans to complete the task at once, with the goal of completing it as quickly as possible.
+
+You must follow the following criteria:
+1) Return a Python list of subgoals that can be completed in order to complete the specified task.
+2) Each subgoal should follow a concise format like "mine [item_name]" or "craft [item_name]".
+3) In the planning list, a subgoal is as detailed as getting an item, and you don't have to think about breaking that item down into a number of pre-steps, which our skill library already does.
+
+You should only respond in JSON format as described below:
+["subgoal1", "subgoal2", "subgoal3", ...]
+Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc.
+The [] format will be used for RE extraction, do not use this format for anything other than your plan list and no extra brackets!
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/prompts/skill.txt b/experiments/Odyssey/Odyssey/odyssey/prompts/skill.txt
new file mode 100644
index 0000000000000000000000000000000000000000..dc846cdf697b28e966222153dc8a709b664b390b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/prompts/skill.txt
@@ -0,0 +1,51 @@
+You are a helpful assistant that writes a description of the given function written in Mineflayer javascript code.
+
+1) Do not mention the function name.
+2) Do not mention anything about `bot.chat` or helper functions.
+3) There might be some helper functions before the main function, but you only need to describe the main function.
+4) Try to summarize the function in no more than 6 sentences.
+5) Your response should be a single line of text.
+
+For example, if the function is:
+
+async function mineCobblestone(bot) {
+ // Check if the wooden pickaxe is in the inventory, if not, craft one
+ let woodenPickaxe = bot.inventory.findInventoryItem(mcData.itemsByName["wooden_pickaxe"].id);
+ if (!woodenPickaxe) {
+ bot.chat("Crafting a wooden pickaxe.");
+ await craftWoodenPickaxe(bot);
+ woodenPickaxe = bot.inventory.findInventoryItem(mcData.itemsByName["wooden_pickaxe"].id);
+ }
+
+ // Equip the wooden pickaxe if it exists
+ if (woodenPickaxe) {
+ await bot.equip(woodenPickaxe, "hand");
+
+ // Explore until we find a stone block
+ await exploreUntil(bot, new Vec3(1, -1, 1), 60, () => {
+ const stone = bot.findBlock({
+ matching: mcData.blocksByName["stone"].id,
+ maxDistance: 32
+ });
+ if (stone) {
+ return true;
+ }
+ });
+
+ // Mine 8 cobblestone blocks using the wooden pickaxe
+ bot.chat("Found a stone block. Mining 8 cobblestone blocks.");
+ await mineBlock(bot, "stone", 8);
+ bot.chat("Successfully mined 8 cobblestone blocks.");
+
+ // Save the event of mining 8 cobblestone
+ bot.save("cobblestone_mined");
+ } else {
+ bot.chat("Failed to craft a wooden pickaxe. Cannot mine cobblestone.");
+ }
+}
+
+The main function is `mineCobblestone`.
+
+Then you would write:
+
+The function is about mining 8 cobblestones using a wooden pickaxe. First check if a wooden pickaxe is in the inventory. If not, craft one. If the wooden pickaxe is available, equip the wooden pickaxe in the hand. Next, explore the environment until finding a stone block. Once a stone block is found, mine a total of 8 cobblestone blocks using the wooden pickaxe.
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/test_env/combatEnv.js b/experiments/Odyssey/Odyssey/odyssey/test_env/combatEnv.js
new file mode 100644
index 0000000000000000000000000000000000000000..c802dc56bc3684e17d948162afdce64e72732b1b
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/test_env/combatEnv.js
@@ -0,0 +1,22 @@
+async function combatEnv(bot, h = 10, r = 20, y = 150) {
+ // let h = 10; env height
+ // let r = 20; env size
+ // let y = 150; env position y
+ if (y + h >= 320) {
+ await bot.chat("upper bound exceeded.");
+ return;
+ } else if (y <= -64) {
+ await bot.chat("lower bound exceeded.");
+ return;
+ } else if ((2*r + 1) * (2*r + 1) * (h + 1) >= 32768) {
+ await bot.chat("Too many blocks."); // fill max 32768
+ return;
+ }
+ // kill potential mobs and set difficulty to easy for summoning
+ await bot.chat("/difficulty peaceful")
+ await bot.chat("/difficulty easy")
+ // env set
+ await bot.chat(`/fill ~${-r} ${y} ~${-r} ~${r} ${y+h} ~${r} minecraft:sea_lantern`);
+ await bot.chat(`/fill ~${-(r-1)} ${y+1} ~${-(r-1)} ~${r-1} ${y+h-1} ~${r-1} minecraft:air`);
+ await bot.chat(`/tp @p ~ ${y+1} ~`);
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/test_env/respawnAndClear.js b/experiments/Odyssey/Odyssey/odyssey/test_env/respawnAndClear.js
new file mode 100644
index 0000000000000000000000000000000000000000..a0f9f47658e2652c61876fb01e6b864a79dfde33
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/test_env/respawnAndClear.js
@@ -0,0 +1,27 @@
+async function respawnAndClear(bot, spawnX = null, spawnZ = null, yaw = 0, pitch = 0, spawnY = null) {
+ // A fixed X/Z makes independent same-seed worlds start from the same
+ // terrain location. If Y is omitted, resolve the motion-blocking surface
+ // at that X/Z and stand one block above it. Keeping spreadplayers as the
+ // no-argument legacy behavior avoids changing unrelated experiment entry
+ // points that explicitly want a random start.
+ if (Number.isFinite(spawnX) && Number.isFinite(spawnZ)) {
+ if (Number.isFinite(spawnY)) {
+ await bot.chat(`/tp @s ${spawnX} ${spawnY} ${spawnZ} ${yaw} ${pitch}`);
+ } else {
+ await bot.chat(
+ `/execute as @s positioned ${spawnX} 0 ${spawnZ} positioned over motion_blocking_no_leaves run tp @s ~ ~1 ~ ${yaw} ${pitch}`
+ );
+ }
+ } else {
+ await bot.chat("/spreadplayers ~ ~ 0 300 under 80 false @s");
+ await bot.waitForTicks(20);
+ await bot.chat("/execute as @s at @s positioned over motion_blocking_no_leaves run tp @s ~ ~1 ~");
+ }
+ await bot.waitForTicks(10);
+ // Keep later deaths from introducing a second source of spawn variance.
+ await bot.chat("/spawnpoint @s ~ ~ ~");
+ await bot.chat("/clear @s");
+ await bot.chat("/gamemode survival @s");
+ await bot.chat("/difficulty peaceful");
+ await bot.chat("Reset bot location and inventory.");
+}
diff --git a/experiments/Odyssey/Odyssey/odyssey/test_env/summonMob.js b/experiments/Odyssey/Odyssey/odyssey/test_env/summonMob.js
new file mode 100644
index 0000000000000000000000000000000000000000..2e27ac472002528fc62220004d84720e67c86d85
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/test_env/summonMob.js
@@ -0,0 +1,19 @@
+async function summonMob(bot, n = 1, r = 8, type = null) {
+ // let n = 1; number of monsters
+ // let r = 8; env size
+ // let type = "zombie"; type of monsters
+ // set to creative to prevent immediate monster attacks
+ bot.chat("/gamemode creative")
+ bot.chat("Summoning mob...")
+ function getRandomNumber(r) {
+ const sign = Math.random() < 0.5 ? -1 : 1;
+ const randomValue = Math.random() * r;
+ return sign * (randomValue + r);
+ }
+ for (let i = 0; i < n; i++) {
+ let x = getRandomNumber(r);
+ let z = getRandomNumber(r);
+ await bot.chat(`/summon minecraft:${type} ~${x} ~1 ~${z}`);
+ }
+ bot.chat("Mobs summoned.")
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/__init__.py b/experiments/Odyssey/Odyssey/odyssey/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..61d7a0577e8ad8d10a10c8c6513d70cff803204e
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/__init__.py
@@ -0,0 +1,5 @@
+from .file_utils import *
+from .json_utils import *
+from .record_utils import EventRecorder
+from .env_manager import ConfigManager
+config = ConfigManager()
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/env_manager.py b/experiments/Odyssey/Odyssey/odyssey/utils/env_manager.py
new file mode 100644
index 0000000000000000000000000000000000000000..aead55166823ab1ddd7c04d71882e4e1e7e2ab77
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/env_manager.py
@@ -0,0 +1,46 @@
+import json
+import os
+from pathlib import Path
+from typing import Any
+
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_CONFIG_PATH = PROJECT_ROOT / "conf" / "config.json"
+EXAMPLE_CONFIG_PATH = PROJECT_ROOT / "conf" / "config.example.json"
+
+ENV_OVERRIDES = {
+ "ODYSSEY_OPENAI_BASE_URL": "openai_base_url",
+ "ODYSSEY_OPENAI_API_KEY": "openai_api_key",
+ "ODYSSEY_OPENAI_MODEL": "openai_model",
+ "ODYSSEY_MC_SERVER_HOST": "MC_SERVER_HOST",
+ "ODYSSEY_MC_SERVER_PORT": "MC_SERVER_PORT",
+ "ODYSSEY_NODE_SERVER_PORT": "NODE_SERVER_PORT",
+}
+
+class ConfigManager:
+ def __init__(self):
+ configured_path = os.environ.get("ODYSSEY_CONFIG")
+ if configured_path:
+ path = Path(configured_path).expanduser().resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"ODYSSEY_CONFIG does not exist: {path}")
+ elif DEFAULT_CONFIG_PATH.is_file():
+ path = DEFAULT_CONFIG_PATH
+ else:
+ path = EXAMPLE_CONFIG_PATH
+
+ with path.open("r", encoding="utf-8") as handle:
+ self.config = json.load(handle)
+
+ for environment_name, config_name in ENV_OVERRIDES.items():
+ value = os.environ.get(environment_name)
+ if value is not None:
+ self.config[config_name] = value
+
+ self.path = path
+
+ def __getitem__(self, key):
+ return self.get(key)
+
+ def get(self, key: str, default: Any = "") -> Any:
+ return self.config.get(key, default)
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/file_utils.py b/experiments/Odyssey/Odyssey/odyssey/utils/file_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..13257e1f31ed473f9c3da294238ec12e09d28552
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/file_utils.py
@@ -0,0 +1,568 @@
+"""
+File system utils.
+"""
+import collections
+import os
+import pickle
+import sys
+import errno
+import shutil
+import glob
+
+# import pwd
+import codecs
+import hashlib
+import tarfile
+import fnmatch
+import tempfile
+from datetime import datetime
+from socket import gethostname
+import logging
+
+
+f_ext = os.path.splitext
+
+f_size = os.path.getsize
+
+is_file = os.path.isfile
+
+is_dir = os.path.isdir
+
+get_dir = os.path.dirname
+
+
+def host_name():
+ "Get host name, alias with ``socket.gethostname()``"
+ return gethostname()
+
+
+def host_id():
+ """
+ Returns: first part of hostname up to '.'
+ """
+ return host_name().split(".")[0]
+
+
+def utf_open(fname, mode):
+ """
+ Wrapper for codecs.open
+ """
+ return codecs.open(fname, mode=mode, encoding="utf-8")
+
+
+def is_sequence(obj):
+ """
+ Returns:
+ True if the sequence is a collections.Sequence and not a string.
+ """
+ return isinstance(obj, collections.abc.Sequence) and not isinstance(obj, str)
+
+
+def pack_varargs(args):
+ """
+ Pack *args or a single list arg as list
+
+ def f(*args):
+ arg_list = pack_varargs(args)
+ # arg_list is now packed as a list
+ """
+ assert isinstance(args, tuple), "please input the tuple `args` as in *args"
+ if len(args) == 1 and is_sequence(args[0]):
+ return args[0]
+ else:
+ return args
+
+
+def f_not_empty(*fpaths):
+ """
+ Returns:
+ True if and only if the file exists and file size > 0
+ if fpath is a dir, if and only if dir exists and has at least 1 file
+ """
+ fpath = f_join(*fpaths)
+ if not os.path.exists(fpath):
+ return False
+
+ if os.path.isdir(fpath):
+ return len(os.listdir(fpath)) > 0
+ else:
+ return os.path.getsize(fpath) > 0
+
+
+def f_expand(fpath):
+ return os.path.expandvars(os.path.expanduser(fpath))
+
+
+def f_exists(*fpaths):
+ return os.path.exists(f_join(*fpaths))
+
+
+def f_join(*fpaths):
+ """
+ join file paths and expand special symbols like `~` for home dir
+ """
+ fpaths = pack_varargs(fpaths)
+ fpath = f_expand(os.path.join(*fpaths))
+ if isinstance(fpath, str):
+ fpath = fpath.strip()
+ return fpath
+
+
+def f_listdir(
+ *fpaths,
+ filter_ext=None,
+ filter=None,
+ sort=True,
+ full_path=False,
+ nonexist_ok=True,
+ recursive=False,
+):
+ """
+ Args:
+ full_path: True to return full paths to the dir contents
+ filter: function that takes in file name and returns True to include
+ nonexist_ok: True to return [] if the dir is non-existent, False to raise
+ sort: sort the file names by alphabetical
+ recursive: True to use os.walk to recursively list files. Note that `filter`
+ will be applied to the relative path string to the root dir.
+ e.g. filter will take "a/data1.txt" and "a/b/data3.txt" as input, instead of
+ just the base file names "data1.txt" and "data3.txt".
+ if False, will simply call os.listdir()
+ """
+ assert not (filter_ext and filter), "filter_ext and filter are mutually exclusive"
+ dir_path = f_join(*fpaths)
+ if not os.path.exists(dir_path) and nonexist_ok:
+ return []
+ if recursive:
+ files = [
+ os.path.join(os.path.relpath(root, dir_path), file)
+ for root, _, files in os.walk(dir_path)
+ for file in files
+ ]
+ else:
+ files = os.listdir(dir_path)
+ if filter is not None:
+ files = [f for f in files if filter(f)]
+ elif filter_ext is not None:
+ files = [f for f in files if f.endswith(filter_ext)]
+ if sort:
+ files.sort()
+ if full_path:
+ return [os.path.join(dir_path, f) for f in files]
+ else:
+ return files
+
+
+def f_mkdir(*fpaths):
+ """
+ Recursively creates all the subdirs
+ If exist, do nothing.
+ """
+ fpath = f_join(*fpaths)
+ os.makedirs(fpath, exist_ok=True)
+ return fpath
+
+
+def f_mkdir_in_path(*fpaths):
+ """
+ fpath is a file,
+ recursively creates all the parent dirs that lead to the file
+ If exist, do nothing.
+ """
+ os.makedirs(get_dir(f_join(*fpaths)), exist_ok=True)
+
+
+def last_part_in_path(fpath):
+ """
+ https://stackoverflow.com/questions/3925096/how-to-get-only-the-last-part-of-a-path-in-python
+ """
+ return os.path.basename(os.path.normpath(f_expand(fpath)))
+
+
+def is_abs_path(*fpath):
+ return os.path.isabs(f_join(*fpath))
+
+
+def is_relative_path(*fpath):
+ return not is_abs_path(f_join(*fpath))
+
+
+def f_time(*fpath):
+ "File modification time"
+ return str(os.path.getctime(f_join(*fpath)))
+
+
+def f_append_before_ext(fpath, suffix):
+ """
+ Append a suffix to file name and retain its extension
+ """
+ name, ext = f_ext(fpath)
+ return name + suffix + ext
+
+
+def f_add_ext(fpath, ext):
+ """
+ Append an extension if not already there
+ Args:
+ ext: will add a preceding `.` if doesn't exist
+ """
+ if not ext.startswith("."):
+ ext = "." + ext
+ if fpath.endswith(ext):
+ return fpath
+ else:
+ return fpath + ext
+
+
+def f_has_ext(fpath, ext):
+ "Test if file path is a text file"
+ _, actual_ext = f_ext(fpath)
+ return actual_ext == "." + ext.lstrip(".")
+
+
+def f_glob(*fpath):
+ return glob.glob(f_join(*fpath), recursive=True)
+
+
+def f_remove(*fpath, verbose=False, dry_run=False):
+ """
+ If exist, remove. Supports both dir and file. Supports glob wildcard.
+ """
+ assert isinstance(verbose, bool)
+ fpath = f_join(fpath)
+ if dry_run:
+ print("Dry run, delete:", fpath)
+ return
+ for f in glob.glob(fpath):
+ try:
+ shutil.rmtree(f)
+ except OSError as e:
+ if e.errno == errno.ENOTDIR:
+ try:
+ os.remove(f)
+ except: # final resort safeguard
+ pass
+ if verbose:
+ print(f'Deleted "{fpath}"')
+
+
+def f_copy(fsrc, fdst, ignore=None, include=None, exists_ok=True, verbose=False):
+ """
+ Supports both dir and file. Supports glob wildcard.
+ """
+ fsrc, fdst = f_expand(fsrc), f_expand(fdst)
+ for f in glob.glob(fsrc):
+ try:
+ f_copytree(f, fdst, ignore=ignore, include=include, exist_ok=exists_ok)
+ except OSError as e:
+ if e.errno == errno.ENOTDIR:
+ shutil.copy(f, fdst)
+ else:
+ raise
+ if verbose:
+ print(f'Copied "{fsrc}" to "{fdst}"')
+
+
+def _f_copytree(
+ src,
+ dst,
+ symlinks=False,
+ ignore=None,
+ exist_ok=True,
+ copy_function=shutil.copy2,
+ ignore_dangling_symlinks=False,
+):
+ """Copied from python standard lib shutil.copytree
+ except that we allow exist_ok
+ Use f_copytree as entry
+ """
+ names = os.listdir(src)
+ if ignore is not None:
+ ignored_names = ignore(src, names)
+ else:
+ ignored_names = set()
+
+ os.makedirs(dst, exist_ok=exist_ok)
+ errors = []
+ for name in names:
+ if name in ignored_names:
+ continue
+ srcname = os.path.join(src, name)
+ dstname = os.path.join(dst, name)
+ try:
+ if os.path.islink(srcname):
+ linkto = os.readlink(srcname)
+ if symlinks:
+ # We can't just leave it to `copy_function` because legacy
+ # code with a custom `copy_function` may rely on copytree
+ # doing the right thing.
+ os.symlink(linkto, dstname)
+ shutil.copystat(srcname, dstname, follow_symlinks=not symlinks)
+ else:
+ # ignore dangling symlink if the flag is on
+ if not os.path.exists(linkto) and ignore_dangling_symlinks:
+ continue
+ # otherwise let the copy occurs. copy2 will raise an error
+ if os.path.isdir(srcname):
+ _f_copytree(
+ srcname, dstname, symlinks, ignore, exist_ok, copy_function
+ )
+ else:
+ copy_function(srcname, dstname)
+ elif os.path.isdir(srcname):
+ _f_copytree(srcname, dstname, symlinks, ignore, exist_ok, copy_function)
+ else:
+ # Will raise a SpecialFileError for unsupported file types
+ copy_function(srcname, dstname)
+ # catch the Error from the recursive copytree so that we can
+ # continue with other files
+ except shutil.Error as err:
+ errors.extend(err.args[0])
+ except OSError as why:
+ errors.append((srcname, dstname, str(why)))
+ try:
+ shutil.copystat(src, dst)
+ except OSError as why:
+ # Copying file access times may fail on Windows
+ if getattr(why, "winerror", None) is None:
+ errors.append((src, dst, str(why)))
+ if errors:
+ raise shutil.Error(errors)
+ return dst
+
+
+def _include_patterns(*patterns):
+ """Factory function that can be used with copytree() ignore parameter.
+
+ Arguments define a sequence of glob-style patterns
+ that are used to specify what files to NOT ignore.
+ Creates and returns a function that determines this for each directory
+ in the file hierarchy rooted at the source directory when used with
+ shutil.copytree().
+ """
+
+ def _ignore_patterns(path, names):
+ keep = set(
+ name for pattern in patterns for name in fnmatch.filter(names, pattern)
+ )
+ ignore = set(
+ name
+ for name in names
+ if name not in keep and not os.path.isdir(os.path.join(path, name))
+ )
+ return ignore
+
+ return _ignore_patterns
+
+
+def f_copytree(fsrc, fdst, symlinks=False, ignore=None, include=None, exist_ok=True):
+ fsrc, fdst = f_expand(fsrc), f_expand(fdst)
+ assert (ignore is None) or (
+ include is None
+ ), "ignore= and include= are mutually exclusive"
+ if ignore:
+ ignore = shutil.ignore_patterns(*ignore)
+ elif include:
+ ignore = _include_patterns(*include)
+ _f_copytree(fsrc, fdst, ignore=ignore, symlinks=symlinks, exist_ok=exist_ok)
+
+
+def f_move(fsrc, fdst):
+ fsrc, fdst = f_expand(fsrc), f_expand(fdst)
+ for f in glob.glob(fsrc):
+ shutil.move(f, fdst)
+
+
+def f_split_path(fpath, normpath=True):
+ """
+ Splits path into a list of its component folders
+
+ Args:
+ normpath: call os.path.normpath to remove redundant '/' and
+ up-level references like ".."
+ """
+ if normpath:
+ fpath = os.path.normpath(fpath)
+ allparts = []
+ while 1:
+ parts = os.path.split(fpath)
+ if parts[0] == fpath: # sentinel for absolute paths
+ allparts.insert(0, parts[0])
+ break
+ elif parts[1] == fpath: # sentinel for relative paths
+ allparts.insert(0, parts[1])
+ break
+ else:
+ fpath = parts[0]
+ allparts.insert(0, parts[1])
+ return allparts
+
+
+def get_script_dir():
+ """
+ Returns: the dir of current script
+ """
+ return os.path.dirname(os.path.realpath(sys.argv[0]))
+
+
+def get_script_file_name():
+ """
+ Returns: the dir of current script
+ """
+ return os.path.basename(sys.argv[0])
+
+
+def get_script_self_path():
+ """
+ Returns: the dir of current script
+ """
+ return os.path.realpath(sys.argv[0])
+
+
+def get_parent_dir(location, abspath=False):
+ """
+ Args:
+ location: current directory or file
+
+ Returns:
+ parent directory absolute or relative path
+ """
+ _path = os.path.abspath if abspath else os.path.relpath
+ return _path(f_join(location, os.pardir))
+
+
+def md5_checksum(*fpath):
+ """
+ File md5 signature
+ """
+ hash_md5 = hashlib.md5()
+ with open(f_join(*fpath), "rb") as f:
+ for chunk in iter(lambda: f.read(65536), b""):
+ hash_md5.update(chunk)
+ return hash_md5.hexdigest()
+
+
+def create_tar(fsrc, output_tarball, include=None, ignore=None, compress_mode="gz"):
+ """
+ Args:
+ fsrc: source file or folder
+ output_tarball: output tar file name
+ compress_mode: "gz", "bz2", "xz" or "" (empty for uncompressed write)
+ include: include pattern, will trigger copy to temp directory
+ ignore: ignore pattern, will trigger copy to temp directory
+ """
+ fsrc, output_tarball = f_expand(fsrc), f_expand(output_tarball)
+ assert compress_mode in ["gz", "bz2", "xz", ""]
+ src_base = os.path.basename(fsrc)
+
+ tempdir = None
+ if include or ignore:
+ tempdir = tempfile.mkdtemp()
+ tempdest = f_join(tempdir, src_base)
+ f_copy(fsrc, tempdest, include=include, ignore=ignore)
+ fsrc = tempdest
+
+ with tarfile.open(output_tarball, "w:" + compress_mode) as tar:
+ tar.add(fsrc, arcname=src_base)
+
+ if tempdir:
+ f_remove(tempdir)
+
+
+def extract_tar(source_tarball, output_dir=".", members=None):
+ """
+ Args:
+ source_tarball: extract members from archive
+ output_dir: default to current working dir
+ members: must be a subset of the list returned by getmembers()
+ """
+ source_tarball, output_dir = f_expand(source_tarball), f_expand(output_dir)
+ with tarfile.open(source_tarball, "r:*") as tar:
+ tar.extractall(output_dir, members=members)
+
+
+def move_with_backup(*fpath, suffix=".bak"):
+ """
+ Ensures that a path is not occupied. If there is a file, rename it by
+ adding @suffix. Resursively backs up everything.
+
+ Args:
+ fpath: file path to clear
+ suffix: Add to backed up files (default: {'.bak'})
+ """
+ fpath = str(f_join(*fpath))
+ if os.path.exists(fpath):
+ move_with_backup(fpath + suffix)
+ shutil.move(fpath, fpath + suffix)
+
+
+def insert_before_ext(name, insert):
+ """
+ log.txt -> log.ep50.txt
+ """
+ name, ext = os.path.splitext(name)
+ return name + insert + ext
+
+
+def timestamp_file_name(fname):
+ timestr = datetime.now().strftime("_%H-%M-%S_%m-%d-%y")
+ return insert_before_ext(fname, timestr)
+
+
+def get_file_lock(*fpath, timeout: int = 15, logging_level="critical"):
+ """
+ NFS-safe filesystem-backed lock. `pip install flufl.lock`
+ https://flufllock.readthedocs.io/en/stable/apiref.html
+
+ Args:
+ fpath: should be a path on NFS so that every process can see it
+ timeout: seconds
+ """
+ from flufl.lock import Lock
+
+ logging.getLogger("flufl.lock").setLevel(logging_level.upper())
+ return Lock(f_join(*fpath), lifetime=timeout)
+
+
+def load_pickle(*fpaths):
+ with open(f_join(*fpaths), "rb") as fp:
+ return pickle.load(fp)
+
+
+def dump_pickle(data, *fpaths):
+ with open(f_join(*fpaths), "wb") as fp:
+ pickle.dump(data, fp)
+
+
+def load_text(*fpaths, by_lines=False):
+ with open(f_join(*fpaths), "r") as fp:
+ if by_lines:
+ return fp.readlines()
+ else:
+ return fp.read()
+
+
+def load_text_lines(*fpaths):
+ return load_text(*fpaths, by_lines=True)
+
+
+def dump_text(s, *fpaths):
+ with open(f_join(*fpaths), "a+") as fp:
+ fp.write(s)
+
+
+def dump_text_lines(lines: list[str], *fpaths, add_newline=True):
+ with open(f_join(*fpaths), "w") as fp:
+ for line in lines:
+ print(line, file=fp, end="\n" if add_newline else "")
+
+
+# aliases to be consistent with other load_* and dump_*
+pickle_load = load_pickle
+pickle_dump = dump_pickle
+text_load = load_text
+read_text = load_text
+read_text_lines = load_text_lines
+write_text = dump_text
+write_text_lines = dump_text_lines
+text_dump = dump_text
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/json_utils.py b/experiments/Odyssey/Odyssey/odyssey/utils/json_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..68524040b134897b80d8e795b95c721b173796d7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/json_utils.py
@@ -0,0 +1,249 @@
+import json
+import re
+import ast
+from typing import Any, Dict, Union
+from .file_utils import f_join
+
+
+def json_load(*file_path, **kwargs):
+ file_path = f_join(file_path)
+ # Checkpoints and skill artifacts are written as UTF-8. Relying on the
+ # Windows process default (commonly GBK) makes a resumed run fail as soon
+ # as an LLM-produced skill contains non-ASCII text.
+ with open(file_path, "r", encoding="utf-8") as fp:
+ return json.load(fp, **kwargs)
+
+
+def json_loads(string, **kwargs):
+ return json.loads(string, **kwargs)
+
+
+def json_dump(data, *file_path, **kwargs):
+ file_path = f_join(file_path)
+ with open(file_path, "w", encoding="utf-8") as fp:
+ json.dump(data, fp, **kwargs)
+
+
+def json_dumps(data, **kwargs):
+ """
+ Returns: string
+ """
+ return json.dumps(data, **kwargs)
+
+
+# ---------------- Aliases -----------------
+# add aliases where verb goes first, json_load -> load_json
+load_json = json_load
+loads_json = json_loads
+dump_json = json_dump
+dumps_json = json_dumps
+
+
+def extract_char_position(error_message: str) -> int:
+ """Extract the character position from the JSONDecodeError message.
+ Args:
+ error_message (str): The error message from the JSONDecodeError
+ exception.
+ Returns:
+ int: The character position.
+ """
+ import re
+
+ char_pattern = re.compile(r"\(char (\d+)\)")
+ if match := char_pattern.search(error_message):
+ return int(match[1])
+ else:
+ raise ValueError("Character position not found in the error message.")
+
+
+def add_quotes_to_property_names(json_string: str) -> str:
+ """
+ Add quotes to property names in a JSON string.
+ Args:
+ json_string (str): The JSON string.
+ Returns:
+ str: The JSON string with quotes added to property names.
+ """
+
+ def replace_func(match):
+ return f'"{match.group(1)}":'
+
+ property_name_pattern = re.compile(r"(\w+):")
+ corrected_json_string = property_name_pattern.sub(replace_func, json_string)
+
+ try:
+ json.loads(corrected_json_string)
+ return corrected_json_string
+ except json.JSONDecodeError as e:
+ raise e
+
+
+def balance_braces(json_string: str) -> str:
+ """
+ Balance the braces in a JSON string.
+ Args:
+ json_string (str): The JSON string.
+ Returns:
+ str: The JSON string with braces balanced.
+ """
+
+ open_braces_count = json_string.count("{")
+ close_braces_count = json_string.count("}")
+
+ while open_braces_count > close_braces_count:
+ json_string += "}"
+ close_braces_count += 1
+
+ while close_braces_count > open_braces_count:
+ json_string = json_string.rstrip("}")
+ close_braces_count -= 1
+
+ try:
+ json.loads(json_string)
+ return json_string
+ except json.JSONDecodeError as e:
+ raise e
+
+
+def fix_invalid_escape(json_str: str, error_message: str) -> str:
+ while error_message.startswith("Invalid \\escape"):
+ bad_escape_location = extract_char_position(error_message)
+ json_str = json_str[:bad_escape_location] + json_str[bad_escape_location + 1 :]
+ try:
+ json.loads(json_str)
+ return json_str
+ except json.JSONDecodeError as e:
+ error_message = str(e)
+ return json_str
+
+
+def correct_json(json_str: str) -> str:
+ """
+ Correct common JSON errors.
+ Args:
+ json_str (str): The JSON string.
+ """
+
+ try:
+ json.loads(json_str)
+ return json_str
+ except json.JSONDecodeError as e:
+ error_message = str(e)
+ if error_message.startswith("Invalid \\escape"):
+ json_str = fix_invalid_escape(json_str, error_message)
+ if error_message.startswith(
+ "Expecting property name enclosed in double quotes"
+ ):
+ json_str = add_quotes_to_property_names(json_str)
+ try:
+ json.loads(json_str)
+ return json_str
+ except json.JSONDecodeError as e:
+ error_message = str(e)
+ if balanced_str := balance_braces(json_str):
+ return balanced_str
+ return json_str
+
+
+def fix_and_parse_list(list_str: str):
+ pattern = r'\[.*?\]'
+
+ # Using re.findall to extract the content
+ extracted_content = re.findall(pattern, list_str, re.DOTALL)
+ i = 0
+ for item in extracted_content:
+ if "INST" not in item:
+ break
+ i+=1
+ # Print the extracted content
+ # print(extracted_content)
+ subgoal_list = ast.literal_eval(extracted_content[0])
+ return subgoal_list
+
+
+def fix_and_parse_json(
+ json_str: str, try_to_fix_with_gpt: bool = True
+) -> Union[str, Dict[Any, Any]]:
+ """Fix and parse JSON string"""
+ try:
+ pattern = r'(?<=:\s)None(?=\s|}|,)'
+ json_str = json_str.replace("\t", "")
+ json_str = re.sub(pattern, '""', json_str)
+ return json.loads(json_str)
+ except json.JSONDecodeError as _: # noqa: F841
+ json_str = correct_json(json_str)
+ try:
+ return json.loads(json_str)
+ except json.JSONDecodeError as _: # noqa: F841
+ pass
+ # Let's do something manually:
+ # sometimes GPT responds with something BEFORE the braces:
+ # "I'm sorry, I don't understand. Please try again."
+ # {"text": "I'm sorry, I don't understand. Please try again.",
+ # "confidence": 0.0}
+ # So let's try to find the first brace and then parse the rest
+ # of the string
+ try:
+ brace_index = json_str.index("{")
+ json_str = json_str[brace_index:]
+ last_brace_index = json_str.rindex("}")
+ json_str = json_str[: last_brace_index + 1]
+ return json.loads(json_str)
+ except json.JSONDecodeError as e: # noqa: F841
+ # if try_to_fix_with_gpt:
+ # print(
+ # "Warning: Failed to parse AI output, attempting to fix."
+ # "\n If you see this warning frequently, it's likely that"
+ # " your prompt is confusing the AI. Try changing it up"
+ # " slightly."
+ # )
+ # # Now try to fix this up using the ai_functions
+ # ai_fixed_json = fix_json(json_str, JSON_SCHEMA)
+ #
+ # if ai_fixed_json != "failed":
+ # return json.loads(ai_fixed_json)
+ # else:
+ # # This allows the AI to react to the error message,
+ # # which usually results in it correcting its ways.
+ # print("Failed to fix ai output, telling the AI.")
+ # return json_str
+ # else:
+ raise e
+
+
+# def fix_json(json_str: str, schema: str) -> str:
+# """Fix the given JSON string to make it parseable and fully complient with the provided schema."""
+#
+# # Try to fix the JSON using gpt:
+# function_string = "def fix_json(json_str: str, schema:str=None) -> str:"
+# args = [f"'''{json_str}'''", f"'''{schema}'''"]
+# description_string = (
+# "Fixes the provided JSON string to make it parseable"
+# " and fully complient with the provided schema.\n If an object or"
+# " field specified in the schema isn't contained within the correct"
+# " JSON, it is ommited.\n This function is brilliant at guessing"
+# " when the format is incorrect."
+# )
+#
+# # If it doesn't already start with a "`", add one:
+# if not json_str.startswith("`"):
+# json_str = "```json\n" + json_str + "\n```"
+# result_string = call_ai_function(
+# function_string, args, description_string, model=cfg.fast_llm_model
+# )
+# if cfg.debug:
+# print("------------ JSON FIX ATTEMPT ---------------")
+# print(f"Original JSON: {json_str}")
+# print("-----------")
+# print(f"Fixed JSON: {result_string}")
+# print("----------- END OF FIX ATTEMPT ----------------")
+#
+# try:
+# json.loads(result_string) # just check the validity
+# return result_string
+# except: # noqa: E722
+# # Get the call stack:
+# # import traceback
+# # call_stack = traceback.format_exc()
+# # print(f"Failed to fix JSON: '{json_str}' "+call_stack)
+# return "failed"
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/logger.py b/experiments/Odyssey/Odyssey/odyssey/utils/logger.py
new file mode 100644
index 0000000000000000000000000000000000000000..a1dbfefa67810d6be8e23b4241efb95d6e61da20
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/logger.py
@@ -0,0 +1,63 @@
+import coloredlogs, logging
+import time
+# Define a new logging level
+SUCCESS_LEVEL_NUM = 35 # Between WARNING (30) and ERROR (40)
+FAILED_LEVEL_NUM = 36
+logging.addLevelName(SUCCESS_LEVEL_NUM, "SUCCESS")
+logging.addLevelName(FAILED_LEVEL_NUM, "FAILED")
+
+def success(self, message, *args, **kws):
+ if self.isEnabledFor(SUCCESS_LEVEL_NUM):
+ self._log(SUCCESS_LEVEL_NUM, message, args, **kws)
+logging.Logger.success = success
+
+def failed(self, message, *args, **kws):
+ if self.isEnabledFor(FAILED_LEVEL_NUM):
+ self._log(FAILED_LEVEL_NUM, message, args, **kws)
+logging.Logger.failed = failed
+
+def get_logger(name: str, level: int = logging.INFO):
+ # Configure coloredlogs
+ field_styles = coloredlogs.DEFAULT_FIELD_STYLES
+ field_styles['levelname'] = {'color': 11} # Set the color for the custom level
+
+ level_styles = coloredlogs.DEFAULT_LEVEL_STYLES
+ level_styles['success'] = {'color': 'green', 'bold': True}
+ level_styles['failed'] = {'color': 'red', 'bold': True}
+
+ logger = logging.getLogger(name)
+ logger.setLevel(level)
+ if not getattr(logger, "_odyssey_console_configured", False):
+ coloredlogs.install(
+ fmt='%(asctime)s %(message)s',datefmt='%Y-%m-%d %H:%M:%S',
+ level=level, logger=logger, field_styles=field_styles, level_styles=level_styles
+ )
+ logger._odyssey_console_configured = True
+ return logger
+
+
+
+class Timer:
+ def __init__(self, description):
+ self.description = description
+ self.logger = get_logger('Timer')
+
+ def __enter__(self):
+ self.start = time.time()
+ print('')
+ self.logger.info('='*15+f"{self.description} starts."+'='*15)
+ return self
+
+ def __exit__(self, type, value, traceback):
+ self.end = time.time()
+ self.logger.info('='*10+f"{self.description} ends. Cost {self.end - self.start} seconds"+'='*10)
+ print('')
+
+
+if __name__ == '__main__':
+ with Timer('logger'):
+ logger = get_logger('logger_test')
+ logger.success('success')
+ logger.failed('failed')
+ logger.critical('critical')
+ logger.debug('debug'*20)
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/record_utils.py b/experiments/Odyssey/Odyssey/odyssey/utils/record_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..28a2186c7c3747467ca3b8c0d5a5a21a3960a878
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/record_utils.py
@@ -0,0 +1,104 @@
+import time
+
+from .file_utils import *
+from .json_utils import *
+from .logger import get_logger
+
+class EventRecorder:
+ def __init__(
+ self,
+ ckpt_dir="ckpt",
+ resume=False,
+ init_position=None,
+ ):
+ self.logger = get_logger("EventRecorder")
+ self.ckpt_dir = ckpt_dir
+ self.item_history = set()
+ self.item_vs_time = {}
+ self.item_vs_iter = {}
+ self.biome_history = set()
+ self.init_position = init_position
+ self.position_history = [[0, 0]]
+ self.elapsed_time = 0
+ self.iteration = 0
+ f_mkdir(self.ckpt_dir, "events")
+ if resume:
+ self.resume()
+
+ def record(self, events, task):
+ task = re.sub(r'[\\/:"*?<>| ]', "_", task)
+ task = task.replace(" ", "_") + time.strftime(
+ "_%Y%m%d_%H%M%S", time.localtime()
+ )
+ self.iteration += 1
+ if not self.init_position:
+ self.init_position = [
+ events[0][1]["status"]["position"]["x"],
+ events[0][1]["status"]["position"]["z"],
+ ]
+ for event_type, event in events:
+ self.update_items(event)
+ if event_type == "observe":
+ self.update_elapsed_time(event)
+
+
+ self.logger.info(f"****Recorder message: {self.elapsed_time} ticks have elapsed****")
+ self.logger.info(f"****Recorder message: {self.iteration} iteration passed****")
+ dump_json(events, f_join(self.ckpt_dir, "events", task))
+ return self.elapsed_time, self.iteration
+
+ def resume(self, cutoff=None):
+ self.item_history = set()
+ self.item_vs_time = {}
+ self.item_vs_iter = {}
+ self.elapsed_time = 0
+ self.position_history = [[0, 0]]
+
+ def get_timestamp(string):
+ timestamp = "_".join(string.split("_")[-2:])
+ return time.mktime(time.strptime(timestamp, "%Y%m%d_%H%M%S"))
+
+ records = f_listdir(self.ckpt_dir, "events")
+ sorted_records = sorted(records, key=get_timestamp)
+ for record in sorted_records:
+ self.iteration += 1
+ if cutoff and self.iteration > cutoff:
+ break
+ events = load_json(f_join(self.ckpt_dir, "events", record))
+ if not self.init_position:
+ self.init_position = (
+ events[0][1]["status"]["position"]["x"],
+ events[0][1]["status"]["position"]["z"],
+ )
+ for event_type, event in events:
+ self.update_items(event)
+ self.update_position(event)
+ if event_type == "observe":
+ self.update_elapsed_time(event)
+
+ def update_items(self, event):
+ inventory = event["inventory"]
+ elapsed_time = event["status"]["elapsedTime"]
+ biome = event["status"]["biome"]
+ items = set(inventory.keys())
+ new_items = items - self.item_history
+ self.item_history.update(items)
+ self.biome_history.add(biome)
+ if new_items:
+ if self.elapsed_time + elapsed_time not in self.item_vs_time:
+ self.item_vs_time[self.elapsed_time + elapsed_time] = []
+ self.item_vs_time[self.elapsed_time + elapsed_time].extend(new_items)
+ if self.iteration not in self.item_vs_iter:
+ self.item_vs_iter[self.iteration] = []
+ self.item_vs_iter[self.iteration].extend(new_items)
+
+ def update_elapsed_time(self, event):
+ self.elapsed_time += event["status"]["elapsedTime"]
+
+ def update_position(self, event):
+ position = [
+ event["status"]["position"]["x"] - self.init_position[0],
+ event["status"]["position"]["z"] - self.init_position[1],
+ ]
+ if self.position_history[-1] != position:
+ self.position_history.append(position)
diff --git a/experiments/Odyssey/Odyssey/odyssey/utils/run_utils.py b/experiments/Odyssey/Odyssey/odyssey/utils/run_utils.py
new file mode 100644
index 0000000000000000000000000000000000000000..b07f1ecea936ab9cda42d38f6b91f2cf1de48f24
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/odyssey/utils/run_utils.py
@@ -0,0 +1,35 @@
+import functools
+import time
+
+def retry(retry_count=3):
+ def decorator_retry(func):
+ @functools.wraps(func)
+ def wrapper_retry(*args, **kwargs):
+ last_exception = None
+ for _ in range(retry_count):
+ try:
+ return func(*args, **kwargs)
+ except Exception as e:
+ last_exception = e
+ time.sleep(1)
+ raise last_exception
+ return wrapper_retry
+ return decorator_retry
+
+
+if __name__ == '__main__':
+ @retry(retry_count=3)
+ def test_func():
+ import random
+ if random.randint(0, 1):
+ print("Function succeeded")
+ return "Success"
+ else:
+ print("Function failed")
+ raise ValueError("Random error occurred")
+
+ try:
+ result = test_func()
+ print("Result:", result)
+ except Exception as error:
+ print("An error occurred:", error)
diff --git a/experiments/Odyssey/Odyssey/pyproject.toml b/experiments/Odyssey/Odyssey/pyproject.toml
new file mode 100644
index 0000000000000000000000000000000000000000..b2fcc25f0ee3a6c4697e11704dd199086dfc42a9
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/pyproject.toml
@@ -0,0 +1,3 @@
+[build-system]
+requires = ["setuptools>=64"]
+build-backend = "setuptools.build_meta"
diff --git a/experiments/Odyssey/Odyssey/requirements.txt b/experiments/Odyssey/Odyssey/requirements.txt
new file mode 100644
index 0000000000000000000000000000000000000000..aaccc58db0a051f155ce3c46bb1174bec1bddad7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/requirements.txt
@@ -0,0 +1,15 @@
+tqdm
+langchain
+langchain_community
+javascript
+openai
+chardet
+cchardet
+chromadb==0.3.29
+tiktoken
+requests
+gymnasium
+psutil
+minecraft_launcher_lib
+sentence-transformers
+coloredlogs
diff --git a/experiments/Odyssey/Odyssey/setup.py b/experiments/Odyssey/Odyssey/setup.py
new file mode 100644
index 0000000000000000000000000000000000000000..139e0a2af0622e0a5345a3cbf26b8494b0643e99
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/setup.py
@@ -0,0 +1,59 @@
+from pathlib import Path
+
+from setuptools import setup, find_packages
+
+
+PKG_NAME = "odyssey"
+VERSION = "0.1"
+EXTRAS = {}
+PROJECT_ROOT = Path(__file__).resolve().parent
+
+
+def _read_file(fname):
+ with (PROJECT_ROOT / fname).open(encoding="utf-8") as fp:
+ return fp.read()
+
+
+def _read_install_requires():
+ with (PROJECT_ROOT / "requirements.txt").open(encoding="utf-8") as fp:
+ return [
+ line.strip()
+ for line in fp
+ if line.strip() and not line.lstrip().startswith("#")
+ ]
+
+
+def _fill_extras(extras):
+ if extras:
+ extras["all"] = list(set([item for group in extras.values() for item in group]))
+ return extras
+
+
+setup(
+ name=PKG_NAME,
+ version=VERSION,
+ author=f"MineDojo Team",
+ url="https://github.com/zju-vipa/Odyssey",
+ description="research project",
+ # long_description=_read_file("README.md"),
+ long_description_content_type="text/markdown",
+ keywords=[
+ "Open-Ended Learning",
+ "Lifelong Learning",
+ "Embodied Agents",
+ "Large Language Models",
+ ],
+ license="MIT License",
+ packages=find_packages(include=[PKG_NAME, f"{PKG_NAME}.*"]),
+ include_package_data=True,
+ zip_safe=False,
+ install_requires=_read_install_requires(),
+ extras_require=_fill_extras(EXTRAS),
+ python_requires=">=3.9",
+ classifiers=[
+ "Development Status :: 5 - Production/Stable",
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
+ "Environment :: Console",
+ "Programming Language :: Python :: 3.9",
+ ],
+)
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedChicken.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedChicken.js
new file mode 100644
index 0000000000000000000000000000000000000000..2464833058237c489410032afc1b8945c1da48ff
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedChicken.js
@@ -0,0 +1,10 @@
+async function breedChicken(bot) {
+ let wheatSeedsCount = bot.inventory.count(mcData.itemsByName.wheat_seeds.id);
+ let wheatSeed = bot.inventory.findInventoryItem(mcData.itemsByName.wheat_seeds.id);
+ if (wheatSeedsCount <= 2) {
+ bot.chat("Not enough wheat seeds for breeding. Need 2 wheat seeds.");
+ return;
+ }
+ await bot.equip(wheatSeed, "hand");
+ await feedAnimals(bot, 2, "chicken");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedCow.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedCow.js
new file mode 100644
index 0000000000000000000000000000000000000000..ebb35226fe867e379cb228bd1aaac0ef931ae2b4
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedCow.js
@@ -0,0 +1,10 @@
+async function breedCow(bot) {
+ let wheatCount = bot.inventory.count(mcData.itemsByName.wheat.id);
+ let wheat = bot.inventory.findInventoryItem(mcData.itemsByName.wheat.id);
+ if (wheatCount <= 2) {
+ bot.chat("Not enough wheat for breeding. Need 2 wheat.");
+ return;
+ }
+ await bot.equip(wheat, "hand");
+ await feedAnimals(bot, 2, "cow");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedPig.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedPig.js
new file mode 100644
index 0000000000000000000000000000000000000000..b28966618ccac3e740be940e22acee4d167c45df
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedPig.js
@@ -0,0 +1,10 @@
+async function breedPig(bot) {
+ let carrotsCount = bot.inventory.count(mcData.itemsByName.carrot.id);
+ let carrot = bot.inventory.findInventoryItem(mcData.itemsByName.carrot.id);
+ if (carrotsCount <= n) {
+ bot.chat("Not enough carrots for breeding. Need 2 carrots.");
+ return;
+ }
+ await bot.equip(carrot, "hand");
+ await feedAnimals(bot, 2, "pig");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedSheep.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedSheep.js
new file mode 100644
index 0000000000000000000000000000000000000000..662ef722a9e6e1e4fa7892e030004f15b2aa264f
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/breedSheep.js
@@ -0,0 +1,10 @@
+async function breedSheep(bot) {
+ let wheatCount = bot.inventory.count(mcData.itemsByName.wheat.id);
+ let wheat = bot.inventory.findInventoryItem(mcData.itemsByName.wheat.id);
+ if (wheatCount <= 2) {
+ bot.chat("Not enough wheat for breeding. Need 2 wheat.");
+ return;
+ }
+ await bot.equip(wheat, "hand");
+ await feedAnimals(bot, 2, "sheep");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/catchFish.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/catchFish.js
new file mode 100644
index 0000000000000000000000000000000000000000..106b547b04d7c3f0030d3c7e63e977039fe9ae00
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/catchFish.js
@@ -0,0 +1,32 @@
+async function catchFish(bot) {
+ // Check if the bot has a fishing rod in its inventory
+ let fishingRod = bot.inventory.findInventoryItem(mcData.itemsByName.fishing_rod.id);
+ if (!fishingRod) {
+ await craftFishingRod(bot);
+ fishingRod = bot.inventory.findInventoryItem(mcData.itemsByName.fishing_rod.id);
+ }
+ // Find a nearby water block
+ let waterBlock;
+ while (!waterBlock) {
+ waterBlock = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const foundWaterBlock = bot.findBlock({
+ matching: mcData.blocksByName.water.id,
+ maxDistance: 32
+ });
+ return foundWaterBlock;
+ });
+ if (!waterBlock) {
+ bot.chat("No path to the water block. Trying to find another water block...");
+ }
+ }
+ // Move to a block adjacent to the water block
+ const adjacentBlock = waterBlock.position.offset(0, 1, 0);
+ await bot.pathfinder.goto(new GoalBlock(adjacentBlock.x, adjacentBlock.y, adjacentBlock.z));
+
+ // Look at the water block
+ await bot.lookAt(waterBlock.position);
+
+ // Equip the fishing rod
+ await bot.equip(fishingRod, "hand");
+ await bot.fish();
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBamboo.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBamboo.js
new file mode 100644
index 0000000000000000000000000000000000000000..c76b3c0c8c77906780a5a11eec655d56576f9187
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBamboo.js
@@ -0,0 +1,29 @@
+async function collectBamboo(bot) {
+ // Find bamboo plants using the exploreUntil function
+ const bambooPlants = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const bambooPlants = bot.findBlocks({
+ matching: block => block.name === "bamboo",
+ maxDistance: 32,
+ count: 10
+ });
+ return bambooPlants.length >= 10 ? bambooPlants : null;
+ });
+ if (!bambooPlants) {
+ bot.chat("Could not find enough bamboo plants.");
+ return;
+ }
+ // equip sword if there is one
+ await equipSword(bot);
+ // Break 10 bamboo plants
+ for (const bambooPlant of bambooPlants) {
+ const block = bot.blockAt(bambooPlant);
+ await bot.dig(block);
+ }
+ bot.chat("Broke 10 bamboo plants.");
+
+ // Collect the dropped bamboo items
+ for (const bambooPlant of bambooPlants) {
+ await bot.pathfinder.goto(new GoalBlock(bambooPlant.x, bambooPlant.y, bambooPlant.z));
+ }
+ bot.chat("Collected 10 bamboo.");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBeetroots.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBeetroots.js
new file mode 100644
index 0000000000000000000000000000000000000000..b707a5c481d22b91e058f75b20c15fea586856c7
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectBeetroots.js
@@ -0,0 +1,18 @@
+async function collectBeetroots(bot) {
+ // Use the exploreUntil function to find beetroots
+ const beetroots = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const beetroots = bot.findBlocks({
+ matching: block => block.name === "beetroots" && block.getProperties().age === 3,
+ maxDistance: 32,
+ count: 1
+ });
+ return beetroots.length >= 1 ? beetroots : null;
+ });
+ if (!beetroots) {
+ bot.chat("Could not find enough beetroots.");
+ return;
+ }
+ const block = bot.blockAt(beetroots[0]);
+ await bot.pathfinder.goto(new GoalBlock(beetroots[0].x, beetroots[0].y, beetroots[0].z));
+ await bot.dig(block);
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCactusBlocks.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCactusBlocks.js
new file mode 100644
index 0000000000000000000000000000000000000000..ef92a74adfe2ec9dfdbb6438858e9a8850889c0a
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCactusBlocks.js
@@ -0,0 +1,25 @@
+async function collectCactusBlocks(bot) {
+ // Find 5 cactus blocks using the exploreUntil function
+ const cactusBlocks = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const cactusBlocks = bot.findBlocks({
+ matching: block => block.name === "cactus",
+ maxDistance: 32,
+ count: 5
+ });
+ return cactusBlocks.length >= 5 ? cactusBlocks : null;
+ });
+ if (!cactusBlocks) {
+ bot.chat("Could not find enough cactus blocks.");
+ return;
+ }
+
+ // Mine the 5 cactus blocks using the mineBlock function
+ await mineBlock(bot, "cactus", 5);
+ bot.chat("5 cactus blocks mined.");
+
+ // Collect the dropped cactus items
+ for (const cactusBlock of cactusBlocks) {
+ await bot.pathfinder.goto(new GoalBlock(cactusBlock.x, cactusBlock.y, cactusBlock.z));
+ }
+ bot.chat("Collected 5 cactus blocks.");
+ }
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCarrots.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCarrots.js
new file mode 100644
index 0000000000000000000000000000000000000000..370b75246d122d60b4e9b30bfc2a17927a304082
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCarrots.js
@@ -0,0 +1,18 @@
+async function collectCarrots(bot) {
+ // Use the exploreUntil function to find carrots
+ const carrots = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const carrots = bot.findBlocks({
+ matching: block => block.name === "carrots" && block.getProperties().age === 7,
+ maxDistance: 32,
+ count: 1
+ });
+ return carrots.length >= 1 ? carrots : null;
+ });
+ if (!carrots) {
+ bot.chat("Could not find enough carrots.");
+ return;
+ }
+ const block = bot.blockAt(carrots[0]);
+ await bot.pathfinder.goto(new GoalBlock(carrots[0].x, carrots[0].y, carrots[0].z));
+ await bot.dig(block);
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCobblestone.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCobblestone.js
new file mode 100644
index 0000000000000000000000000000000000000000..053949b51ee00e5007785e30748ce1405c9cdb09
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCobblestone.js
@@ -0,0 +1,19 @@
+async function collectCobblestone(bot) {
+ await equipPickaxeOrCraftOne(bot);
+ // Use the exploreUntil function to find cobblestone blocks
+ const cobblestoneBlocks = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const cobblestoneBlocks = bot.findBlocks({
+ matching: block => block.name === "stone",
+ maxDistance: 32,
+ count: 10
+ });
+ return cobblestoneBlocks.length >= 10 ? cobblestoneBlocks : null;
+ });
+ if (!cobblestoneBlocks) {
+ bot.chat("Could not find enough stones.");
+ return;
+ }
+ // Mine 10 cobblestone blocks using the mineBlock function
+ await mineBlock(bot, "stone", 10);
+ bot.chat(`10 cobblestones mined.`);
+ }
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCocoaBeans.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCocoaBeans.js
new file mode 100644
index 0000000000000000000000000000000000000000..f0556e45f0f54af020ab0e30c4ad9d9b86b3cb50
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectCocoaBeans.js
@@ -0,0 +1,23 @@
+async function collectCocoaBeans(bot) {
+ // Find bamboo plants using the exploreUntil function
+ const cocoaPods = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const cocoaPods = bot.findBlocks({
+ matching: block => block.name === "cocoa",
+ maxDistance: 32,
+ count: 1
+ });
+ return cocoaPods.length >= 1 ? cocoaPods : null;
+ });
+ // equip axe if there is one
+ await equipAxe(bot);
+ if (!cocoaPods) {
+ bot.chat("Could not find cocoaPods.");
+ return;
+ }
+ for (const cocoa of cocoaPods) {
+ const block = bot.blockAt(cocoa);
+ await bot.dig(block);
+ await bot.pathfinder.goto(new GoalBlock(cocoa.x, cocoa.y, cocoa.z));
+ }
+ bot.chat("cocoa beans collected.")
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectDirt.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectDirt.js
new file mode 100644
index 0000000000000000000000000000000000000000..f2f4fbe6185367ba98360aedf66b36c821f24630
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectDirt.js
@@ -0,0 +1,19 @@
+async function collectDirt(bot) {
+ await equipShovelOrCraftOne(bot);
+ // Use the exploreUntil function to find dirt blocks
+ const dirtBlocks = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const dirtBlocks = bot.findBlocks({
+ matching: block => block.name === "dirt",
+ maxDistance: 32,
+ count: 10
+ });
+ return dirtBlocks.length >= 10 ? dirtBlocks : null;
+ });
+ if (!dirtBlocks) {
+ bot.chat("Could not find enough dirt.");
+ return;
+ }
+ // Mine 10 dirt blocks using the mineBlock function
+ await mineBlock(bot, "dirt", 10);
+ bot.chat("10 dirt mined.");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectFlowers.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectFlowers.js
new file mode 100644
index 0000000000000000000000000000000000000000..6a72548831d66dbf6dcf2a483439678b3d13b37c
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectFlowers.js
@@ -0,0 +1,24 @@
+async function collectFlowers(bot) {
+ const flowerNames = ["dandelion", "poppy", "blue_orchid", "allium", "azure_bluet",
+ "red_tulip", "orange_tulip", "white_tulip", "pink_tulip",
+ "oxeye_daisy", "cornflower", "lily_of_the_valley", "wither_rose", "torchflower",
+ "sunflower", "lilac", "rose_bush", "peony", "pitcher_plant"];
+ // Find 10 flowers
+ const flowers = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const flowers = bot.findBlocks({
+ matching: block => flowerNames.includes(block.name),
+ maxDistance: 32,
+ count: 10
+ });
+ return flowers.length >= 1 ? flowers : null;
+ });
+ if (!flowers) {
+ bot.chat("Could not find flowers.");
+ return;
+ }
+ for (const flower of flowers) {
+ const block = bot.blockAt(flower);
+ await bot.dig(block);
+ }
+ await bot.chat("Collected flowers.")
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectLavaWithBucket.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectLavaWithBucket.js
new file mode 100644
index 0000000000000000000000000000000000000000..30ff1c5ce817c756bc88c853231d030f32034ed9
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectLavaWithBucket.js
@@ -0,0 +1,27 @@
+async function collectLavaWithBucket(bot) {
+ // check bucket
+ let bucket = bot.inventory.findInventoryItem(mcData.itemsByName.bucket.id);
+ if (!bucket) {
+ await craftBucket(bot);
+ }
+ // find lava
+ const lavaBlock = bot.findBlock({
+ matching: mcData.blocksByName.lava.id,
+ maxDistance: 32
+ });
+ if (!lavaBlock) {
+ bot.chat("No lava block found nearby. Exploring...");
+ await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const foundlavaBlock = bot.findBlock({
+ matching: mcData.blocksByName.lava.id,
+ maxDistance: 32
+ });
+ return foundlavaBlock;
+ });
+ }
+
+ await bot.equip(bucket, "hand");
+ await bot.lookAt(lavaBlock.position);
+ await bot.activateItem();
+ bot.chat("lava collected with bucket.");
+}
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelon.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelon.js
new file mode 100644
index 0000000000000000000000000000000000000000..83a024231d79f16958fcf70d42b2fa2878bb4023
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelon.js
@@ -0,0 +1,19 @@
+async function collectMelon(bot) {
+ await equipAxe(bot);
+ // Use the exploreUntil function to find melon blocks
+ const melonBlocks = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {
+ const melonBlocks = bot.findBlocks({
+ matching: block => block.name === "melon",
+ maxDistance: 32,
+ count: 1
+ });
+ return melonBlocks.length >= 1 ? melonBlocks : null;
+ });
+ if (!melonBlocks) {
+ bot.chat("Could not find enough melon.");
+ return;
+ }
+ // Mine 1 melon block using the mineBlock function
+ await mineBlock(bot, "melon", 1);
+ bot.chat("1 melon collected.");
+ }
\ No newline at end of file
diff --git a/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelonSeeds.js b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelonSeeds.js
new file mode 100644
index 0000000000000000000000000000000000000000..d81bfdb18d407604d56664a5c7168ececa58a1ea
--- /dev/null
+++ b/experiments/Odyssey/Odyssey/skill_library/skill/compositional/collectMelonSeeds.js
@@ -0,0 +1,8 @@
+async function collectMelonSeeds(bot) {
+ let melonSlice = bot.inventory.findInventoryItem(mcData.itemsByName.melon_slice.id);
+ if (!melonSlice) {
+ await bot.chat("No melon slice found in inventory.");
+ return;
+ }
+ await craftItem(bot, "melon_seeds", 1);
+}
\ No newline at end of file