diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..67a4d9b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,33 @@ +name: Test Suite + +on: + push: + branches: [ main, nightly ] + pull_request: + branches: [ main, nightly ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install system dependencies + run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[dev] + + - name: Run tests + run: python -m pytest tests/ -v diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..eb4bca4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,122 @@ +# Contributing to nanobot + +Thank you for being here. + +nanobot is built with a simple belief: good tools should feel calm, clear, and humane. +We care deeply about useful features, but we also believe in achieving more with less: +solutions should be powerful without becoming heavy, and ambitious without becoming +needlessly complicated. + +This guide is not only about how to open a PR. It is also about how we hope to build +software together: with care, clarity, and respect for the next person reading the code. + +## Maintainers + +| Maintainer | Focus | +|------------|-------| +| [@re-bin](https://github.com/re-bin) | Project lead, `main` branch | +| [@chengyongru](https://github.com/chengyongru) | `nightly` branch, experimental features | + +## Branching Strategy + +We use a two-branch model to balance stability and exploration: + +| Branch | Purpose | Stability | +|--------|---------|-----------| +| `main` | Stable releases | Production-ready | +| `nightly` | Experimental features | May have bugs or breaking changes | + +### Which Branch Should I Target? + +**Target `nightly` if your PR includes:** + +- New features or functionality +- Refactoring that may affect existing behavior +- Changes to APIs or configuration + +**Target `main` if your PR includes:** + +- Bug fixes with no behavior changes +- Documentation improvements +- Minor tweaks that don't affect functionality + +**When in doubt, target `nightly`.** It is easier to move a stable idea from `nightly` +to `main` than to undo a risky change after it lands in the stable branch. + +### How Does Nightly Get Merged to Main? + +We don't merge the entire `nightly` branch. Instead, stable features are **cherry-picked** from `nightly` into individual PRs targeting `main`: + +``` +nightly ──┬── feature A (stable) ──► PR ──► main + ├── feature B (testing) + └── feature C (stable) ──► PR ──► main +``` + +This happens approximately **once a week**, but the timing depends on when features become stable enough. + +### Quick Summary + +| Your Change | Target Branch | +|-------------|---------------| +| New feature | `nightly` | +| Bug fix | `main` | +| Documentation | `main` | +| Refactoring | `nightly` | +| Unsure | `nightly` | + +## Development Setup + +Keep setup boring and reliable. The goal is to get you into the code quickly: + +```bash +# Clone the repository +git clone https://github.com/HKUDS/nanobot.git +cd nanobot + +# Install with dev dependencies +pip install -e ".[dev]" + +# Run tests +pytest + +# Lint code +ruff check nanobot/ + +# Format code +ruff format nanobot/ +``` + +## Code Style + +We care about more than passing lint. We want nanobot to stay small, calm, and readable. + +When contributing, please aim for code that feels: + +- Simple: prefer the smallest change that solves the real problem +- Clear: optimize for the next reader, not for cleverness +- Decoupled: keep boundaries clean and avoid unnecessary new abstractions +- Honest: do not hide complexity, but do not create extra complexity either +- Durable: choose solutions that are easy to maintain, test, and extend + +In practice: + +- Line length: 100 characters (`ruff`) +- Target: Python 3.11+ +- Linting: `ruff` with rules E, F, I, N, W (E501 ignored) +- Async: uses `asyncio` throughout; pytest with `asyncio_mode = "auto"` +- Prefer readable code over magical code +- Prefer focused patches over broad rewrites +- If a new abstraction is introduced, it should clearly reduce complexity rather than move it around + +## Questions? + +If you have questions, ideas, or half-formed insights, you are warmly welcome here. + +Please feel free to open an [issue](https://github.com/HKUDS/nanobot/issues), join the community, or simply reach out: + +- [Discord](https://discord.gg/MnCvHqpUGB) +- [Feishu/WeChat](./COMMUNICATION.md) +- Email: Xubin Ren (@Re-bin) — + +Thank you for spending your time and care on nanobot. We would love for more people to participate in this community, and we genuinely welcome contributions of all sizes. diff --git a/README.md b/README.md index 1259573..c78f113 100644 --- a/README.md +++ b/README.md @@ -1444,6 +1444,15 @@ nanobot/ PRs welcome! The codebase is intentionally small and readable. 🤗 +### Branching Strategy + +| Branch | Purpose | +|--------|---------| +| `main` | Stable releases — bug fixes and minor improvements | +| `nightly` | Experimental features — new features and breaking changes | + +**Unsure which branch to target?** See [CONTRIBUTING.md](./CONTRIBUTING.md) for details. + **Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! - [ ] **Multi-modal** — See and hear (images, voice, video) diff --git a/nanobot/agent/context.py b/nanobot/agent/context.py index 1b468e6..fad6d27 100644 --- a/nanobot/agent/context.py +++ b/nanobot/agent/context.py @@ -3,8 +3,6 @@ import base64 import mimetypes import platform -import time -from datetime import datetime from pathlib import Path from typing import Any @@ -18,7 +16,7 @@ from nanobot.agent.personas import ( resolve_persona_name, ) from nanobot.agent.skills import SkillsLoader -from nanobot.utils.helpers import build_assistant_message, detect_image_mime +from nanobot.utils.helpers import build_assistant_message, current_time_str, detect_image_mime class ContextBuilder: @@ -136,9 +134,7 @@ Reply directly with text for conversations. Only use the 'message' tool to send @staticmethod def _build_runtime_context(channel: str | None, chat_id: str | None) -> str: """Build untrusted runtime metadata block for injection before the user message.""" - now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") - tz = time.strftime("%Z") or "UTC" - lines = [f"Current Time: {now} ({tz})"] + lines = [f"Current Time: {current_time_str()}"] if channel and chat_id: lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"] return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) diff --git a/nanobot/heartbeat/service.py b/nanobot/heartbeat/service.py index 831ae85..7769af8 100644 --- a/nanobot/heartbeat/service.py +++ b/nanobot/heartbeat/service.py @@ -87,10 +87,13 @@ class HeartbeatService: Returns (action, tasks) where action is 'skip' or 'run'. """ + from nanobot.utils.helpers import current_time_str + response = await self.provider.chat_with_retry( messages=[ {"role": "system", "content": "You are a heartbeat agent. Call the heartbeat tool to report your decision."}, {"role": "user", "content": ( + f"Current Time: {current_time_str()}\n\n" "Review the following HEARTBEAT.md and decide whether there are active tasks.\n\n" f"{content}" )}, diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 5ca06f4..d937b6e 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -2,6 +2,7 @@ import json import re +import time from datetime import datetime from pathlib import Path from typing import Any @@ -33,6 +34,13 @@ def timestamp() -> str: return datetime.now().isoformat() +def current_time_str() -> str: + """Human-readable current time with weekday and timezone, e.g. '2026-03-15 22:30 (Saturday) (CST)'.""" + now = datetime.now().strftime("%Y-%m-%d %H:%M (%A)") + tz = time.strftime("%Z") or "UTC" + return f"{now} ({tz})" + + _UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]') def safe_filename(name: str) -> str: diff --git a/tests/test_heartbeat_service.py b/tests/test_heartbeat_service.py index 9ce8912..524863a 100644 --- a/tests/test_heartbeat_service.py +++ b/tests/test_heartbeat_service.py @@ -158,3 +158,39 @@ async def test_decide_retries_transient_error_then_succeeds(tmp_path, monkeypatc assert tasks == "check open tasks" assert provider.calls == 2 assert delays == [1] + + +@pytest.mark.asyncio +async def test_decide_prompt_includes_current_time(tmp_path) -> None: + """Phase 1 user prompt must contain current time so the LLM can judge task urgency.""" + + captured_messages: list[dict] = [] + + class CapturingProvider(LLMProvider): + async def chat(self, *, messages=None, **kwargs) -> LLMResponse: + if messages: + captured_messages.extend(messages) + return LLMResponse( + content="", + tool_calls=[ + ToolCallRequest( + id="hb_1", name="heartbeat", + arguments={"action": "skip"}, + ) + ], + ) + + def get_default_model(self) -> str: + return "test-model" + + service = HeartbeatService( + workspace=tmp_path, + provider=CapturingProvider(), + model="test-model", + ) + + await service._decide("- [ ] check servers at 10:00 UTC") + + user_msg = captured_messages[1] + assert user_msg["role"] == "user" + assert "Current Time:" in user_msg["content"]