Compare commits

...
6 Commits
Author SHA1 Message Date
Jon Saad-Falcon 7e8db280e4 Merge pull request #413 from open-jarvis/ci/desktop-stable-updater-channel
ci(desktop): stable (desktop-latest) + edge (desktop-edge) updater channels
2026-05-25 16:41:08 -07:00
krypticmouseandClaude Opus 4.7 e3f2b008d2 ci(desktop): split updater into stable (desktop-latest) + edge (desktop-edge) channels
The installed desktop app polls `desktop-latest/latest.json`. Previously
every push to `main` (autotag -> v*.devN -> desktop.yml dispatch) rebuilt
and republished `desktop-latest` as a DEV prerelease, so stable users were
auto-updated onto unvetted dev builds, and any manual stable mirror was
clobbered on the next merge.

Split the streams so the app's channel only ever serves vetted stable:

- Dev/rolling builds (v* autotag + manual workflow_dispatch) now publish to
  a new `desktop-edge` pre-release. The shipped app does not poll edge, so
  dev builds never auto-install onto stable users.
- Stable `desktop-v*` builds publish the user-facing release as before, then
  a new `refresh-stable-channel` job copies that release's signed
  `latest.json` into `desktop-latest` (mirror; URLs already point at the
  desktop-v* assets). Cut a `desktop-v*` tag to ship an update.
- `clean-release` now targets `desktop-edge`; `desktop-latest` is never
  wiped by CI.

No app/tauri.conf.json change — the updater endpoint stays `desktop-latest`.
Doc updated to describe the now-implemented stable/edge split.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:36:17 +00:00
Jon Saad-Falcon cdae426d48 docs: fix broken desktop download links → stable desktop-v1.0.2 release (#412)
Repoints all desktop download links (README, docs/index.md, downloads.md, getting-started/installation.md) from the removed desktop-latest prerelease (with wrong 0.1.0 filenames) to the stable desktop-v1.0.2 release. All 5 URLs verified live (200). macOS .dmg now included (addresses #356).
2026-05-25 16:20:33 -07:00
krypticmouseandClaude Opus 4.7 ae9727599b docs: point desktop download links at the stable desktop-v1.0.2 release
Every desktop download link in the docs was broken on two counts:
1. They pointed at the rolling `desktop-latest` prerelease, which had
   been removed (404 for all of README, docs/index.md,
   docs/downloads.md, docs/getting-started/installation.md).
2. They used the wrong version in the filenames (`OpenJarvis_0.1.0_*`)
   — the actual published assets were never `0.1.0`.

Repointed all four files at the new stable `Desktop desktop-v1.0.2`
release with the exact asset filenames it ships
(`OpenJarvis_1.0.1_*` — the Tauri bundle version is 1.0.1, distinct
from the 1.0.2 Python/CLI release). This release also includes a
macOS universal `.dmg`, which the prior desktop releases lacked
(addresses #356 "No Mac Download") — so the macOS rows now say
"Universal" (Apple Silicon + Intel) instead of "Apple Silicon".

All five download URLs verified live (HTTP 200) against the
desktop-v1.0.2 release before committing.

Note: `docs/desktop-auto-update.md` still references the
`desktop-latest` rolling channel — that's the auto-updater's endpoint,
a separate concern from the manual download links, and is left as-is.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 23:18:33 +00:00
Robby Manihani a86d022947 fix(agents): repair continuous monitor_operative agent (parrot loop, tool calling, traces) (#407) 2026-05-25 11:08:14 -07:00
Robby Manihani 8e6bc343d8 fix(connectors): validate credentials before persisting + populate Gmail URLs (#410) 2026-05-25 11:07:35 -07:00
17 changed files with 509 additions and 74 deletions
+53 -8
View File
@@ -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}"
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+5 -5
View File
@@ -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
View File
@@ -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.
+104 -11
View File
@@ -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:
+14 -11
View File
@@ -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
+5
View File
@@ -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,
+42 -2
View File
@@ -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(
+28 -11
View File
@@ -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
+12 -1
View File
@@ -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
+10 -2
View File
@@ -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,
},
)
+4
View File
@@ -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()
+85
View File
@@ -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"
+60
View File
@@ -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."
)
+57
View File
@@ -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)