mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-15 17:31:58 +00:00
Compare commits
6
Commits
v1.0.2
...
desktop-edge
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8db280e4 | ||
|
|
e3f2b008d2 | ||
|
|
cdae426d48 | ||
|
|
ae9727599b | ||
|
|
a86d022947 | ||
|
|
8e6bc343d8 |
@@ -73,19 +73,20 @@ jobs:
|
||||
working-directory: frontend/src-tauri
|
||||
run: cargo test
|
||||
|
||||
# Remove stale artifacts from the desktop-latest pre-release so that
|
||||
# only the current build's files are available for download.
|
||||
# Remove stale artifacts from the desktop-edge rolling pre-release so that
|
||||
# only the current build's files are available for download. (The stable
|
||||
# `desktop-latest` channel the installed app polls is never cleaned here.)
|
||||
clean-release:
|
||||
needs: [validate]
|
||||
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Delete old assets from desktop-latest
|
||||
- name: Delete old assets from desktop-edge
|
||||
if: "!startsWith(github.ref, 'refs/tags/')"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="desktop-latest"
|
||||
TAG="desktop-edge"
|
||||
# List all asset IDs on the release and delete them
|
||||
ASSET_IDS=$(gh api "repos/${{ github.repository }}/releases/tags/${TAG}" \
|
||||
--jq '.assets[].id' 2>/dev/null || true)
|
||||
@@ -170,10 +171,13 @@ jobs:
|
||||
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
|
||||
# Auto-tagged rolling build from autotag.yml — use the same
|
||||
# version as the CLI/PyPI release so all surfaces stay in sync.
|
||||
# Rolling/dev builds go to the `desktop-edge` channel, NOT the
|
||||
# `desktop-latest` channel the installed app polls — so users on
|
||||
# stable are never auto-updated onto an unvetted dev build.
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${VERSION#v}"
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
# workflow_dispatch fallback (manual UI dispatch without --ref).
|
||||
@@ -186,8 +190,9 @@ jobs:
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
BUILD=$(git rev-list --count HEAD)
|
||||
VERSION="${MAJOR}.${MINOR}.${NEXT_PATCH}.dev${BUILD}"
|
||||
echo "tag=desktop-latest" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Latest Build)" >> "$GITHUB_OUTPUT"
|
||||
# Manual dispatches are also dev builds -> the edge channel.
|
||||
echo "tag=desktop-edge" >> "$GITHUB_OUTPUT"
|
||||
echo "name=Desktop (Edge Build)" >> "$GITHUB_OUTPUT"
|
||||
echo "prerelease=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
@@ -243,3 +248,43 @@ jobs:
|
||||
prerelease: ${{ steps.release-info.outputs.prerelease }}
|
||||
includeUpdaterJson: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
# When a stable `desktop-v*` release is published, repoint the
|
||||
# `desktop-latest` auto-update channel (the endpoint the installed app
|
||||
# polls) at it. The stable release's own `latest.json` already references
|
||||
# this release's signed assets, so we copy it verbatim — installed apps are
|
||||
# only ever offered vetted stable builds, never `desktop-edge` dev builds.
|
||||
refresh-stable-channel:
|
||||
needs: [build-and-release]
|
||||
if: startsWith(github.ref, 'refs/tags/desktop-v')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mirror stable latest.json into desktop-latest
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
STABLE_TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The stable release's updater manifest may take a moment to become
|
||||
# downloadable after tauri-action publishes it; retry briefly.
|
||||
URL="https://github.com/${REPO}/releases/download/${STABLE_TAG}/latest.json"
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if curl -fsSL -o latest.json "$URL"; then
|
||||
echo "Fetched ${STABLE_TAG}/latest.json on attempt ${attempt}"
|
||||
break
|
||||
fi
|
||||
echo "latest.json not ready yet (attempt ${attempt}); sleeping 15s"
|
||||
sleep 15
|
||||
done
|
||||
test -s latest.json || { echo "::error::Could not fetch ${URL}"; exit 1; }
|
||||
# Ensure the channel release exists (prerelease so it never usurps
|
||||
# the stable "Latest" badge), then replace its manifest in place.
|
||||
if ! gh release view desktop-latest --repo "$REPO" >/dev/null 2>&1; then
|
||||
gh release create desktop-latest --repo "$REPO" \
|
||||
--prerelease \
|
||||
--title "Desktop Auto-Update Channel" \
|
||||
--notes "Auto-update channel pointer for the desktop app. Mirrors the latest stable \`desktop-v*\` release; the in-app updater polls this \`latest.json\`. Download the app from the latest stable release, not here."
|
||||
fi
|
||||
gh release upload desktop-latest latest.json --repo "$REPO" --clobber
|
||||
echo "desktop-latest now mirrors ${STABLE_TAG}"
|
||||
|
||||
@@ -46,7 +46,7 @@ The installer handles everything for you — including [uv](https://docs.astral.
|
||||
wsl --install -d Ubuntu-24.04
|
||||
```
|
||||
Open the Ubuntu shell that gets installed, then follow [WSL2 install instructions](https://open-jarvis.github.io/OpenJarvis/getting-started/wsl2/).
|
||||
- **Desktop app** — download the `.exe` from the [Releases page](https://github.com/open-jarvis/OpenJarvis/releases) for the GUI experience, no terminal required. **Prerequisite:** the desktop app expects [uv](https://docs.astral.sh/uv/) to be installed already — if it isn't, install it first in PowerShell, then launch the app:
|
||||
- **Desktop app** — download the [Windows installer (`.exe`)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) from the latest [desktop release](https://github.com/open-jarvis/OpenJarvis/releases/tag/desktop-v1.0.2) (macOS `.dmg` and Linux `.deb`/`.rpm`/`.AppImage` are there too) for the GUI experience, no terminal required. **Prerequisite:** the desktop app expects [uv](https://docs.astral.sh/uv/) to be installed already — if it isn't, install it first in PowerShell, then launch the app:
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -c "irm https://astral.sh/uv/install.ps1 | iex"
|
||||
```
|
||||
|
||||
+22
-15
@@ -33,25 +33,32 @@ under `plugins.updater`.
|
||||
|
||||
The `Desktop Build & Release` GitHub Action
|
||||
([`.github/workflows/desktop.yml`](../.github/workflows/desktop.yml))
|
||||
publishes signed binaries plus a `latest.json` manifest to the
|
||||
`desktop-latest` GitHub release on every push to `main`. The
|
||||
`tauri-action` step with `includeUpdaterJson: true` generates the
|
||||
manifest automatically.
|
||||
builds signed binaries plus a `latest.json` manifest with the
|
||||
`tauri-action` step (`includeUpdaterJson: true` generates the manifest
|
||||
automatically). Where it publishes depends on the trigger.
|
||||
|
||||
Two release streams exist:
|
||||
Three release streams exist:
|
||||
|
||||
- **`desktop-latest`** (rolling pre-release): updated on every push to
|
||||
`main`. This is the channel the desktop app currently polls. Users
|
||||
on this channel get the most recent build the CI produced.
|
||||
- **`desktop-latest`** (stable auto-update channel): **this is the
|
||||
channel the installed app polls.** It is *not* built directly —
|
||||
instead, when a stable `desktop-vX.Y.Z` release is published, the
|
||||
`refresh-stable-channel` job copies that release's `latest.json`
|
||||
into `desktop-latest`. So the app is only ever offered vetted stable
|
||||
builds, and `latest.json` here points at the current `desktop-v*`
|
||||
assets.
|
||||
- **`desktop-vX.Y.Z`** (tagged stable): created when someone pushes a
|
||||
`desktop-v*` git tag. Has the same artifacts but is marked as a
|
||||
proper release rather than a pre-release.
|
||||
`desktop-v*` git tag. The user-facing stable release with full
|
||||
installers; also the source of truth the stable channel mirrors.
|
||||
- **`desktop-edge`** (rolling pre-release): rebuilt on every push to
|
||||
`main` (via the `autotag` → `desktop.yml` dispatch) and on manual
|
||||
`workflow_dispatch`. Carries the most recent CI build for testers.
|
||||
The shipped app does **not** poll this stream, so dev builds never
|
||||
auto-install onto stable users.
|
||||
|
||||
The current updater endpoint points at the rolling `desktop-latest`
|
||||
stream so that bug fixes (especially security and telemetry-policy
|
||||
changes) reach users without waiting for a manual stable tag. A future
|
||||
release may introduce a stable channel that points at
|
||||
`desktop-v*` tags only.
|
||||
This split means security and telemetry-policy fixes reach users on
|
||||
the next **stable** `desktop-v*` tag — cut one to ship an update.
|
||||
Edge builds are available for anyone who wants to test `main` ahead of
|
||||
a stable tag, without risking the stable population.
|
||||
|
||||
## Signing
|
||||
|
||||
|
||||
+5
-5
@@ -25,11 +25,11 @@ processing happens on your local machine — the app connects to the backend you
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) | M1/M2/M3/M4 Macs |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) | Any distro |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) | Apple Silicon + Intel |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) | Windows 10+ |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) | Ubuntu, Debian |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) | Fedora, RHEL |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) | Any distro |
|
||||
|
||||
!!! tip "All releases"
|
||||
Browse all versions on the [GitHub Releases](https://github.com/open-jarvis/OpenJarvis/releases) page.
|
||||
|
||||
@@ -94,11 +94,11 @@ cd OpenJarvis
|
||||
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| macOS (Apple Silicon) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_aarch64.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.AppImage) |
|
||||
| macOS (Universal) | [:material-download: **OpenJarvis.dmg**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg) |
|
||||
| Windows (64-bit) | [:material-download: **OpenJarvis-setup.exe**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe) |
|
||||
| Linux (DEB) | [:material-download: **OpenJarvis.deb**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb) |
|
||||
| Linux (RPM) | [:material-download: **OpenJarvis.rpm**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm) |
|
||||
| Linux (AppImage) | [:material-download: **OpenJarvis.AppImage**](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.AppImage) |
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
|
||||
+2
-2
@@ -54,9 +54,9 @@ OpenJarvis is that stack. It is a framework for local-first personal AI, built a
|
||||
|
||||
**Step 2.** Download and open the desktop app:
|
||||
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_universal.dmg){ .md-button .md-button--primary }
|
||||
[Download for macOS](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_universal.dmg){ .md-button .md-button--primary }
|
||||
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis_0.1.0_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-latest/OpenJarvis-0.1.0-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
Also available for [Windows](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_x64-setup.exe), [Linux (DEB)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis_1.0.1_amd64.deb), and [Linux (RPM)](https://github.com/open-jarvis/OpenJarvis/releases/download/desktop-v1.0.2/OpenJarvis-1.0.1-1.x86_64.rpm). See the [Downloads](downloads.md) page for details.
|
||||
|
||||
The app connects to `http://localhost:8000` automatically.
|
||||
|
||||
|
||||
@@ -23,6 +23,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_RETRIES = 3
|
||||
|
||||
# Default model for monitor_operative / long-horizon agent ticks. qwen3:8b
|
||||
# emits tool_calls but, when given the full MonitorOperative system prompt
|
||||
# alongside a `think` no-op tool, reliably picks `think` instead of the real
|
||||
# action tools — producing tickless prose from training-data memory.
|
||||
# gemma4:31b follows the function-calling protocol with the same prompt and
|
||||
# actually invokes web_search / memory_retrieve. Explicit ``config["model"]``
|
||||
# on an agent still wins.
|
||||
_AGENT_TICK_DEFAULT_MODEL = "gemma4:31b"
|
||||
|
||||
|
||||
class AgentExecutor:
|
||||
"""Executes a single tick for a managed agent.
|
||||
@@ -257,7 +266,11 @@ class AgentExecutor:
|
||||
engine = self._system.engine if self._system else None
|
||||
if engine is None:
|
||||
raise FatalError("No engine available in JarvisSystem")
|
||||
model = config.get("model") or (self._system.model if self._system else "")
|
||||
model = (
|
||||
config.get("model")
|
||||
or _AGENT_TICK_DEFAULT_MODEL
|
||||
or (self._system.model if self._system else "")
|
||||
)
|
||||
if not model:
|
||||
raise FatalError("No model configured for agent")
|
||||
|
||||
@@ -350,29 +363,109 @@ class AgentExecutor:
|
||||
agent_kwargs["system_prompt"] = sys_prompt
|
||||
if getattr(agent_cls, "accepts_tools", False) and tool_instances:
|
||||
agent_kwargs["tools"] = tool_instances
|
||||
# Hand the agent our EventBus so its ToolExecutor can publish
|
||||
# TOOL_CALL_START/END — without this, ToolExecutor's ``self._bus``
|
||||
# is None and every tool call executes silently, which is why
|
||||
# traces previously reported "0 steps" even when the model was
|
||||
# actively invoking web_search/memory_*/etc.
|
||||
if self._bus is not None:
|
||||
agent_kwargs["bus"] = self._bus
|
||||
# Propagate confirmation policy from the AgentExecutor down to the
|
||||
# agent's own ToolExecutor. Set by CLI paths like `jarvis agents ask`
|
||||
# so non-interactive runs can auto-approve tool execution.
|
||||
if getattr(self, "_confirm_callback", None) is not None:
|
||||
agent_kwargs["interactive"] = True
|
||||
agent_kwargs["confirm_callback"] = self._confirm_callback
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
except TypeError:
|
||||
agent_instance = agent_cls(engine, model)
|
||||
|
||||
# Build input from instruction + summary_memory + pending messages
|
||||
# Wire cross-tick state plumbing into agent classes that accept it.
|
||||
# Without this, MonitorOperative/Operative agents have no working
|
||||
# session_store or memory_backend and silently no-op their state
|
||||
# recall / persistence paths.
|
||||
import inspect
|
||||
|
||||
init_sig = inspect.signature(agent_cls.__init__)
|
||||
accepts_var_kw = any(
|
||||
p.kind == inspect.Parameter.VAR_KEYWORD
|
||||
for p in init_sig.parameters.values()
|
||||
)
|
||||
|
||||
def _accepts(name: str) -> bool:
|
||||
return accepts_var_kw or name in init_sig.parameters
|
||||
|
||||
state_kwargs: dict[str, Any] = {}
|
||||
if _accepts("operator_id"):
|
||||
state_kwargs["operator_id"] = agent["id"]
|
||||
if self._system is not None:
|
||||
if _accepts("session_store"):
|
||||
state_kwargs["session_store"] = getattr(
|
||||
self._system, "session_store", None
|
||||
)
|
||||
if _accepts("memory_backend"):
|
||||
state_kwargs["memory_backend"] = getattr(
|
||||
self._system, "memory_backend", None
|
||||
)
|
||||
|
||||
try:
|
||||
agent_instance = agent_cls(
|
||||
engine, model, **agent_kwargs, **state_kwargs
|
||||
)
|
||||
except TypeError:
|
||||
try:
|
||||
agent_instance = agent_cls(engine, model, **agent_kwargs)
|
||||
except TypeError:
|
||||
agent_instance = agent_cls(engine, model)
|
||||
|
||||
# Inject the managed-agent UUID into the agent's ToolExecutor so
|
||||
# emitted TOOL_CALL_START/END events carry it; the trace subscriber
|
||||
# below filters by ``event.data["agent"] == agent_id`` and would
|
||||
# otherwise drop every tool call (the class-level agent_id like
|
||||
# "monitor_operative" doesn't match the runtime UUID).
|
||||
inner_executor = getattr(agent_instance, "_executor", None)
|
||||
if inner_executor is not None and hasattr(inner_executor, "_agent_id"):
|
||||
inner_executor._agent_id = agent["id"]
|
||||
|
||||
logger.info(
|
||||
"Agent %s: tool wiring — %d tools resolved (%s), agent class %s",
|
||||
agent["name"],
|
||||
len(tool_instances),
|
||||
", ".join(t.spec.name for t in tool_instances) or "none",
|
||||
agent_cls.__name__,
|
||||
)
|
||||
|
||||
# Build input from instruction + summary_memory + pending messages.
|
||||
# NB: we deliberately do NOT inject the full previous response back
|
||||
# in as "Previous context" — that caused the model to parrot its
|
||||
# own prior output verbatim. Cross-tick continuity now lives in the
|
||||
# agent's session_store / memory_backend; here we only surface a
|
||||
# short tick-boundary marker so the model knows time has passed.
|
||||
import datetime
|
||||
import re
|
||||
|
||||
today = datetime.date.today().strftime("%A, %B %d, %Y")
|
||||
instruction = config.get("instruction", "")
|
||||
memory = agent.get("summary_memory", "")
|
||||
memory = (agent.get("summary_memory") or "").strip()
|
||||
last_run_at = agent.get("last_run_at")
|
||||
|
||||
tick_note = ""
|
||||
if memory:
|
||||
first_sentence = re.split(r"(?<=[.!?])\s+", memory, maxsplit=1)[0]
|
||||
first_sentence = first_sentence.strip()[:200]
|
||||
if last_run_at:
|
||||
ts = datetime.datetime.fromtimestamp(last_run_at).strftime(
|
||||
"%Y-%m-%d %H:%M"
|
||||
)
|
||||
tick_note = f"Last tick at {ts}: {first_sentence}"
|
||||
else:
|
||||
tick_note = f"Previous tick: {first_sentence}"
|
||||
|
||||
if instruction:
|
||||
input_text = f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
if memory:
|
||||
input_text += f"\n\nPrevious context: {memory}"
|
||||
input_text = (
|
||||
f"Current date: {today}\n\nStanding instruction: {instruction}"
|
||||
)
|
||||
if tick_note:
|
||||
input_text += f"\n\n{tick_note}"
|
||||
else:
|
||||
base = memory or "Continue your assigned task."
|
||||
base = tick_note or "Continue your assigned task."
|
||||
input_text = f"Current date: {today}\n\n{base}"
|
||||
pending = self._manager.get_pending_messages(agent["id"])
|
||||
if pending:
|
||||
|
||||
@@ -49,18 +49,18 @@ MONITOR_OPERATIVE_SYSTEM_PROMPT = """\
|
||||
You are a Monitor Operative Agent designed for long-horizon tasks.
|
||||
|
||||
## Capabilities
|
||||
1. TOOLS: Call any available tool via function calling
|
||||
2. STATE: Your previous findings and state are automatically restored
|
||||
3. MEMORY: Store important findings for future recall
|
||||
1. TOOLS: You have access to tools via native function calling. The list
|
||||
below shows what is available — invoke them through the function-calling
|
||||
API, not by writing tool names into your text response.
|
||||
2. STATE: Your previous findings and state are automatically restored from memory.
|
||||
3. MEMORY: Store important findings via memory_store; recall via memory_retrieve.
|
||||
|
||||
## How to use tools
|
||||
|
||||
To call a tool, write on its own lines:
|
||||
|
||||
Action: <tool_name>
|
||||
Action Input: <json_arguments>
|
||||
|
||||
You will receive the result, then continue your response.
|
||||
## Critical Operating Rule
|
||||
Your training data is frozen and out of date. For ANY question about recent,
|
||||
current, or evolving information, you MUST call a substantive retrieval tool
|
||||
(web_search, memory_retrieve, or an equivalent) BEFORE composing a response.
|
||||
Writing fact claims about recent events from memory alone produces
|
||||
hallucinations and is a failure mode.
|
||||
|
||||
## Strategy
|
||||
- Memory extraction: {memory_extraction}
|
||||
@@ -70,6 +70,9 @@ You will receive the result, then continue your response.
|
||||
|
||||
## Protocol
|
||||
- Break complex tasks into phases and track progress
|
||||
- Prefer substantive tools (web_search, memory_retrieve) over reasoning-only
|
||||
tools (think) — `think` does not gather new information, only reorganises
|
||||
what you already have
|
||||
- Store causal relationships and key findings in memory
|
||||
- Compress long tool outputs before adding to context
|
||||
- Self-evaluate retrieved context for relevance
|
||||
|
||||
@@ -497,6 +497,11 @@ class GmailConnector(BaseConnector):
|
||||
timestamp=timestamp,
|
||||
thread_id=thread_id,
|
||||
channel=channel,
|
||||
# Deep-link straight to the message. ``msg_id`` is Gmail's
|
||||
# internal hex id, which the ``#all/<id>`` permalink
|
||||
# resolves directly — so citations have a working URL
|
||||
# without relying on _hit_url reconstruction at query time.
|
||||
url=f"https://mail.google.com/mail/u/0/#all/{msg_id}",
|
||||
metadata={
|
||||
"message_id": msg_id,
|
||||
"rfc_message_id": rfc_message_id,
|
||||
|
||||
@@ -76,6 +76,41 @@ def _granola_api_list_notes(
|
||||
return resp.json()
|
||||
|
||||
|
||||
class GranolaKeyError(ValueError):
|
||||
"""Raised when a Granola API key is missing or rejected by the API.
|
||||
|
||||
Surfaced through the ``/connect`` endpoint as an HTTP 400 so the user
|
||||
sees why the key was refused instead of a silent failed sync later.
|
||||
"""
|
||||
|
||||
|
||||
def _granola_api_validate_key(api_key: str) -> None:
|
||||
"""Verify an API key with a minimal ``GET /v1/notes?limit=1`` probe.
|
||||
|
||||
Raises :class:`GranolaKeyError` when the key is empty or the API
|
||||
responds 401/403, so an invalid key never overwrites a working
|
||||
credential on disk. Other HTTP errors propagate via ``raise_for_status``.
|
||||
"""
|
||||
if not api_key:
|
||||
raise GranolaKeyError("Granola API key is empty.")
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{_GRANOLA_API_BASE}/v1/notes",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
params={"limit": 1},
|
||||
timeout=30.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise GranolaKeyError(
|
||||
f"Could not reach Granola to verify the key: {exc}"
|
||||
) from exc
|
||||
if resp.status_code in (401, 403):
|
||||
raise GranolaKeyError(
|
||||
"Invalid API key. Check your key in Granola Settings → API."
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def _granola_api_get_note(api_key: str, note_id: str) -> Dict[str, Any]:
|
||||
"""Fetch a single Granola note by ID (includes transcript).
|
||||
|
||||
@@ -242,10 +277,15 @@ class GranolaConnector(BaseConnector):
|
||||
)
|
||||
|
||||
def handle_callback(self, code: str) -> None:
|
||||
"""Persist the API key to the credentials file.
|
||||
"""Validate and persist the API key.
|
||||
|
||||
The *code* parameter holds the raw API key string provided by the user.
|
||||
The *code* parameter holds the raw API key string provided by the
|
||||
user. The key is verified with a live ``GET /v1/notes?limit=1``
|
||||
probe *before* it is written, so an invalid key can never overwrite
|
||||
a working credential on disk (raises :class:`GranolaKeyError` on a
|
||||
401/403).
|
||||
"""
|
||||
_granola_api_validate_key(code)
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
|
||||
def sync(
|
||||
|
||||
@@ -205,17 +205,13 @@ def _validate_user_token(token: str) -> None:
|
||||
raise SlackTokenError("Slack token is empty.")
|
||||
if token.startswith(_BOT_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
"Slack bot tokens (xoxb-) can't see user-to-user DMs. "
|
||||
"Use a User OAuth Token (xoxp-) from "
|
||||
"api.slack.com/apps → OAuth & Permissions, "
|
||||
"with User Token Scopes channels:history, channels:read, "
|
||||
"groups:history, groups:read, im:history, im:read, "
|
||||
"mpim:history, mpim:read, users:read."
|
||||
"Bot tokens (xoxb-) can't read DMs. "
|
||||
"Use a User OAuth Token (xoxp-) instead."
|
||||
)
|
||||
if not token.startswith(_USER_TOKEN_PREFIX):
|
||||
raise SlackTokenError(
|
||||
"Slack token must be a User OAuth Token (starts with 'xoxp-'). "
|
||||
"Got a token with an unexpected prefix."
|
||||
"Invalid token format. Expected a Slack User OAuth Token "
|
||||
"starting with xoxp-"
|
||||
)
|
||||
|
||||
|
||||
@@ -337,14 +333,35 @@ class SlackConnector(BaseConnector):
|
||||
return f"{_SLACK_AUTH_ENDPOINT}?{urlencode(params)}"
|
||||
|
||||
def handle_callback(self, code: str) -> None:
|
||||
"""Persist the supplied User OAuth Token after validating its shape.
|
||||
"""Validate and persist a supplied User OAuth Token.
|
||||
|
||||
The connector ``/connect`` endpoint funnels manually-pasted tokens
|
||||
through this method (the parameter is named ``code`` for OAuth-flow
|
||||
compatibility). Bot tokens (``xoxb-``) are rejected here so the
|
||||
invalid credential never lands on disk.
|
||||
compatibility). The token is checked two ways before it is allowed
|
||||
to touch disk, so an invalid credential never overwrites a working
|
||||
one:
|
||||
|
||||
1. **Shape** — must start with ``xoxp-`` (``xoxb-`` bot tokens and
|
||||
any other prefix are rejected via :func:`_validate_user_token`).
|
||||
2. **Liveness** — a live ``auth.test`` call must return ``ok`` so an
|
||||
expired or revoked token is caught at connect time.
|
||||
"""
|
||||
_validate_user_token(code)
|
||||
|
||||
# Verify the token actually works against Slack before persisting.
|
||||
try:
|
||||
auth_resp = _slack_api_auth_test(code)
|
||||
except Exception as exc: # noqa: BLE001 — surface as a token error
|
||||
raise SlackTokenError(
|
||||
f"Could not verify the Slack token (auth.test failed: {exc})."
|
||||
) from exc
|
||||
if not auth_resp.get("ok", False):
|
||||
err = str(auth_resp.get("error", "auth_failed"))
|
||||
raise SlackTokenError(
|
||||
f"Slack rejected the token (auth.test: {err}). "
|
||||
"Check that it is a current User OAuth Token."
|
||||
)
|
||||
|
||||
save_tokens(self._credentials_path, {"token": code})
|
||||
self._last_error = None
|
||||
|
||||
|
||||
@@ -320,9 +320,20 @@ def create_connectors_router():
|
||||
if req.code:
|
||||
instance.handle_callback(req.code)
|
||||
elif req.token:
|
||||
# Some OAuth connectors accept a pre-existing token.
|
||||
# A credential pasted into the ``token`` field. Connectors
|
||||
# that accept a pre-existing access token expose ``_token``
|
||||
# and set it directly (their real OAuth code-exchange runs
|
||||
# via /oauth/start → /oauth/callback). Connectors that
|
||||
# persist a manually-supplied credential — the Slack user
|
||||
# token and the Granola API key — validate inside
|
||||
# handle_callback, so route through it to guarantee the
|
||||
# credential is verified before anything touches disk. A
|
||||
# failed validation raises and is turned into HTTP 400
|
||||
# below, leaving any existing credential intact.
|
||||
if hasattr(instance, "_token"):
|
||||
instance._token = req.token
|
||||
else:
|
||||
instance.handle_callback(req.token)
|
||||
|
||||
else:
|
||||
# Generic: try to store token or credentials if the instance
|
||||
|
||||
@@ -225,11 +225,18 @@ class ToolExecutor:
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Emit start event
|
||||
# Emit start event. ``agent`` carries the managed-agent UUID so the
|
||||
# AgentExecutor's trace subscriber (which filters by agent_id) can
|
||||
# actually match this event — without it, every tool call is silently
|
||||
# dropped from traces.
|
||||
if self._bus:
|
||||
self._bus.publish(
|
||||
EventType.TOOL_CALL_START,
|
||||
{"tool": tool_call.name, "arguments": params},
|
||||
{
|
||||
"tool": tool_call.name,
|
||||
"arguments": params,
|
||||
"agent": self._agent_id,
|
||||
},
|
||||
)
|
||||
|
||||
# Execute with timeout
|
||||
@@ -289,6 +296,7 @@ class ToolExecutor:
|
||||
"latency": latency,
|
||||
"result": result_text,
|
||||
"metadata": event_metadata,
|
||||
"agent": self._agent_id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -150,6 +150,9 @@ def test_sync_yields_documents(
|
||||
assert doc1.content == "Hello world"
|
||||
assert doc1.thread_id == "thread1"
|
||||
assert "alice@example.com" in doc1.participants
|
||||
# Deep-link permalink to the message must be populated at ingest time
|
||||
# (GH #408) — not left empty for _hit_url to reconstruct later.
|
||||
assert doc1.url == "https://mail.google.com/mail/u/0/#all/msg1"
|
||||
|
||||
# --- Message 2 ---
|
||||
doc2 = next(d for d in docs if d.doc_id == "gmail:msg2")
|
||||
@@ -157,6 +160,7 @@ def test_sync_yields_documents(
|
||||
assert doc2.author == "bob@example.com"
|
||||
assert doc2.content == "Budget reply"
|
||||
assert doc2.thread_id == "thread2"
|
||||
assert doc2.url == "https://mail.google.com/mail/u/0/#all/msg2"
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_list.assert_called_once()
|
||||
|
||||
@@ -375,3 +375,88 @@ def test_end_to_end_ingest_and_search(
|
||||
# And the client-facing sources list does end up with the stored URL.
|
||||
client_sources = build_sources_for_client([target])
|
||||
assert client_sources[0]["url"] == _NOTE_1_WEB_URL
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test — API-key validation happens BEFORE the key is written to disk, so an
|
||||
# invalid key is rejected at connect time and can never overwrite a working
|
||||
# credential (the data-loss bug this guards against, GH #409).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
"""Minimal httpx.Response stand-in for the validation probe."""
|
||||
|
||||
def __init__(self, status_code: int) -> None:
|
||||
self.status_code = status_code
|
||||
|
||||
def raise_for_status(self) -> None:
|
||||
if self.status_code >= 400:
|
||||
import httpx # noqa: PLC0415
|
||||
|
||||
raise httpx.HTTPStatusError(
|
||||
"error", request=None, response=None # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_validate_key_empty_raises() -> None:
|
||||
"""An empty key is rejected without any network call."""
|
||||
from openjarvis.connectors.granola import ( # noqa: PLC0415
|
||||
GranolaKeyError,
|
||||
_granola_api_validate_key,
|
||||
)
|
||||
|
||||
with pytest.raises(GranolaKeyError):
|
||||
_granola_api_validate_key("")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
def test_validate_key_rejects_unauthorized(status: int) -> None:
|
||||
"""A 401/403 from GET /v1/notes raises GranolaKeyError with guidance."""
|
||||
from openjarvis.connectors.granola import ( # noqa: PLC0415
|
||||
GranolaKeyError,
|
||||
_granola_api_validate_key,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"openjarvis.connectors.granola.httpx.get",
|
||||
return_value=_FakeResponse(status),
|
||||
) as mock_get:
|
||||
with pytest.raises(GranolaKeyError) as excinfo:
|
||||
_granola_api_validate_key("grl_bad_key")
|
||||
|
||||
assert str(excinfo.value) == (
|
||||
"Invalid API key. Check your key in Granola Settings → API."
|
||||
)
|
||||
# The probe must hit GET /v1/notes with limit=1 (cheap validation call).
|
||||
_, kwargs = mock_get.call_args
|
||||
assert kwargs["params"] == {"limit": 1}
|
||||
|
||||
|
||||
@patch("openjarvis.connectors.granola._granola_api_validate_key")
|
||||
def test_handle_callback_persists_after_validation(mock_validate, connector) -> None:
|
||||
"""A valid key is written only after the validation probe succeeds."""
|
||||
connector.handle_callback("grl_good_key")
|
||||
|
||||
mock_validate.assert_called_once_with("grl_good_key")
|
||||
stored = json.loads(Path(connector._credentials_path).read_text())
|
||||
assert stored["token"] == "grl_good_key"
|
||||
|
||||
|
||||
def test_handle_callback_invalid_key_does_not_overwrite_existing(connector) -> None:
|
||||
"""A bad key must not clobber an existing, working credential on disk."""
|
||||
from openjarvis.connectors.granola import GranolaKeyError # noqa: PLC0415
|
||||
|
||||
creds_path = Path(connector._credentials_path)
|
||||
creds_path.write_text(json.dumps({"token": "grl_real_existing_key"}))
|
||||
|
||||
with patch(
|
||||
"openjarvis.connectors.granola.httpx.get",
|
||||
return_value=_FakeResponse(401),
|
||||
):
|
||||
with pytest.raises(GranolaKeyError):
|
||||
connector.handle_callback("fake-key-12345")
|
||||
|
||||
# The pre-existing credential must be untouched.
|
||||
stored = json.loads(creds_path.read_text())
|
||||
assert stored["token"] == "grl_real_existing_key"
|
||||
|
||||
@@ -560,3 +560,63 @@ def test_sync_logs_per_type_channel_counts(
|
||||
assert "1 private channels" in summary
|
||||
assert "2 DMs" in summary
|
||||
assert "1 group DMs" in summary
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test — handle_callback verifies the token with a live auth.test BEFORE it
|
||||
# persists anything, so a syntactically-valid-but-dead token is rejected at
|
||||
# connect time instead of overwriting a working credential on disk.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch("openjarvis.connectors.slack_connector._slack_api_auth_test")
|
||||
def test_handle_callback_persists_after_auth_test_succeeds(
|
||||
mock_auth, connector
|
||||
) -> None:
|
||||
"""A valid xoxp- token is persisted only after auth.test returns ok."""
|
||||
mock_auth.return_value = _AUTH_TEST_RESPONSE
|
||||
|
||||
connector.handle_callback("xoxp-valid-user-token")
|
||||
|
||||
mock_auth.assert_called_once_with("xoxp-valid-user-token")
|
||||
stored = json.loads(Path(connector._credentials_path).read_text())
|
||||
assert stored["token"] == "xoxp-valid-user-token"
|
||||
|
||||
|
||||
@patch("openjarvis.connectors.slack_connector._slack_api_auth_test")
|
||||
def test_handle_callback_rejects_when_auth_test_fails(mock_auth, connector) -> None:
|
||||
"""A well-formed token Slack rejects (auth.test not ok) is never written."""
|
||||
from openjarvis.connectors.slack_connector import SlackTokenError # noqa: PLC0415
|
||||
|
||||
mock_auth.return_value = {"ok": False, "error": "invalid_auth"}
|
||||
|
||||
with pytest.raises(SlackTokenError) as excinfo:
|
||||
connector.handle_callback("xoxp-revoked-token")
|
||||
|
||||
assert "invalid_auth" in str(excinfo.value)
|
||||
assert not Path(connector._credentials_path).exists()
|
||||
|
||||
|
||||
@patch("openjarvis.connectors.slack_connector._slack_api_auth_test")
|
||||
def test_handle_callback_skips_auth_test_for_bad_shape(mock_auth, connector) -> None:
|
||||
"""Shape validation short-circuits before any network call is made."""
|
||||
from openjarvis.connectors.slack_connector import SlackTokenError # noqa: PLC0415
|
||||
|
||||
with pytest.raises(SlackTokenError):
|
||||
connector.handle_callback("xoxb-bot-token")
|
||||
|
||||
mock_auth.assert_not_called()
|
||||
assert not Path(connector._credentials_path).exists()
|
||||
|
||||
|
||||
def test_handle_callback_xoxb_message_wording(connector) -> None:
|
||||
"""The xoxb- rejection carries the user-facing 'can't read DMs' guidance."""
|
||||
from openjarvis.connectors.slack_connector import SlackTokenError # noqa: PLC0415
|
||||
|
||||
with pytest.raises(SlackTokenError) as excinfo:
|
||||
connector.handle_callback("xoxb-bot-token")
|
||||
|
||||
assert str(excinfo.value) == (
|
||||
"Bot tokens (xoxb-) can't read DMs. "
|
||||
"Use a User OAuth Token (xoxp-) instead."
|
||||
)
|
||||
|
||||
@@ -102,3 +102,60 @@ def test_trigger_sync(app, tmp_path: Path) -> None:
|
||||
data = resp.json()
|
||||
assert data["connector_id"] == "obsidian"
|
||||
assert data["status"] in {"started", "already_syncing"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connect-time credential validation (GH #409): the /connect endpoint must
|
||||
# reject invalid credentials with HTTP 400 and never persist (or overwrite)
|
||||
# anything on disk when validation fails.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_connect_slack_bot_token_returns_400(app, tmp_path: Path) -> None:
|
||||
"""POST connect with an xoxb- token is rejected 400 and writes nothing."""
|
||||
from openjarvis.connectors.slack_connector import SlackConnector
|
||||
from openjarvis.server.connectors_router import _instances
|
||||
|
||||
creds = tmp_path / "slack.json"
|
||||
_instances["slack"] = SlackConnector(credentials_path=str(creds))
|
||||
try:
|
||||
resp = app.post(
|
||||
"/v1/connectors/slack/connect", json={"token": "xoxb-fake-token"}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "xoxb" in resp.json()["detail"].lower()
|
||||
assert not creds.exists()
|
||||
finally:
|
||||
_instances.pop("slack", None)
|
||||
|
||||
|
||||
def test_connect_granola_invalid_key_returns_400_keeps_existing(
|
||||
app, tmp_path: Path
|
||||
) -> None:
|
||||
"""A bad Granola key is rejected 400 and the existing credential survives."""
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from openjarvis.connectors.granola import GranolaConnector, GranolaKeyError
|
||||
from openjarvis.server.connectors_router import _instances
|
||||
|
||||
creds = tmp_path / "granola.json"
|
||||
creds.write_text(json.dumps({"token": "grl_real_existing_key"}))
|
||||
_instances["granola"] = GranolaConnector(credentials_path=str(creds))
|
||||
try:
|
||||
with patch(
|
||||
"openjarvis.connectors.granola._granola_api_validate_key",
|
||||
side_effect=GranolaKeyError(
|
||||
"Invalid API key. Check your key in Granola Settings → API."
|
||||
),
|
||||
):
|
||||
resp = app.post(
|
||||
"/v1/connectors/granola/connect",
|
||||
json={"code": "fake-key-12345"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "Invalid API key" in resp.json()["detail"]
|
||||
# The previously-working credential must be untouched.
|
||||
assert json.loads(creds.read_text())["token"] == "grl_real_existing_key"
|
||||
finally:
|
||||
_instances.pop("granola", None)
|
||||
|
||||
Reference in New Issue
Block a user