Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ target/*
!target/release/
target/release/*
!target/release/openplotva-app
!target/release/openplotva-sandbox
*.log
*.tmp
.env
Expand Down
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,13 @@
# PLOTVA_UPLOADER_SECRET=
# PLOTVA_UPLOADER_TIMEOUT_SECONDS=120

# -----------------------------------------------------------------------------
# Python sandbox (run_python dialog tool; see sandbox/)
# -----------------------------------------------------------------------------
# Supervisor base URL; the tool stays hidden from the model while it is empty.
# SANDBOX_URL=http://sandbox:8090
# SANDBOX_TIMEOUT_SECONDS=75

# -----------------------------------------------------------------------------
# Vision
# -----------------------------------------------------------------------------
Expand Down
63 changes: 42 additions & 21 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ jobs:
image_name="ghcr.io/${GITHUB_REPOSITORY}"
image_name="$(printf '%s' "$image_name" | tr '[:upper:]' '[:lower:]')"
echo "OPENPLOTVA_IMAGE=${image_name}:${GITHUB_SHA}" >> "$GITHUB_ENV"
echo "OPENPLOTVA_SANDBOX_IMAGE=${image_name}-sandbox:${GITHUB_SHA}" >> "$GITHUB_ENV"

- name: Install Rust
run: |
Expand All @@ -55,8 +56,8 @@ jobs:
with:
shared-key: rust-${{ runner.os }}-ubuntu-22.04-${{ env.RUST_VERSION }}-deploy

- name: Build release binary
run: cargo build --locked --release -p openplotva-app
- name: Build release binaries
run: cargo build --locked --release -p openplotva-app -p openplotva-sandbox

- name: Log in to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
Expand Down Expand Up @@ -84,6 +85,23 @@ jobs:
cache-from: type=gha,scope=openplotva-runtime-prebuilt
cache-to: type=gha,mode=min,scope=openplotva-runtime-prebuilt

- name: Build and push sandbox image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7
env:
DOCKER_BUILD_RECORD_UPLOAD: "false"
with:
context: .
file: sandbox/Dockerfile
target: runtime-prebuilt
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
tags: ${{ env.OPENPLOTVA_SANDBOX_IMAGE }}
push: true
# mode=max keeps the nsjail and package layers between deploys.
cache-from: type=gha,scope=openplotva-sandbox
cache-to: type=gha,mode=max,scope=openplotva-sandbox

- name: Wait for green CI on this commit
shell: bash
env:
Expand Down Expand Up @@ -173,14 +191,15 @@ jobs:
set -euo pipefail
test -n "$GHCR_PULL_TOKEN"
printf -v image_q '%q' "$OPENPLOTVA_IMAGE"
printf -v sandbox_image_q '%q' "$OPENPLOTVA_SANDBOX_IMAGE"
printf -v token_q '%q' "$GHCR_PULL_TOKEN"
printf -v actor_q '%q' "$GITHUB_ACTOR"
printf -v env_q '%q' "${OPENPLOTVA_PRODUCTION_ENV_B64:-}"
ssh \
-o ServerAliveInterval=30 \
-o ServerAliveCountMax=20 \
"$GETA_SSH_TARGET" \
"OPENPLOTVA_DEPLOY_IMAGE=$image_q GHCR_USERNAME=$actor_q GHCR_PULL_TOKEN=$token_q OPENPLOTVA_PRODUCTION_ENV_B64=$env_q '$GETA_DEPLOY_ROOT/deploy-production.sh'"
"OPENPLOTVA_DEPLOY_IMAGE=$image_q OPENPLOTVA_SANDBOX_IMAGE=$sandbox_image_q GHCR_USERNAME=$actor_q GHCR_PULL_TOKEN=$token_q OPENPLOTVA_PRODUCTION_ENV_B64=$env_q '$GETA_DEPLOY_ROOT/deploy-production.sh'"

- name: Remove non-current GHCR images
shell: bash
Expand All @@ -194,27 +213,29 @@ jobs:
}

owner="${GITHUB_REPOSITORY_OWNER,,}"
package="${GITHUB_REPOSITORY#*/}"
package="${package,,}"
repository="${GITHUB_REPOSITORY#*/}"
repository="${repository,,}"
current_tag="${GITHUB_SHA}"
versions_file="$(mktemp)"

echo "Cleaning GHCR package ${owner}/${package}; keeping only current tag ${current_tag}"
gh api --paginate --slurp "/users/${owner}/packages/container/${package}/versions?per_page=100" > "$versions_file"
for package in "$repository" "${repository}-sandbox"; do
versions_file="$(mktemp)"
echo "Cleaning GHCR package ${owner}/${package}; keeping only current tag ${current_tag}"
gh api --paginate --slurp "/users/${owner}/packages/container/${package}/versions?per_page=100" > "$versions_file"

deleted=0
while IFS= read -r version; do
id="$(jq -r '.id' <<<"$version")"
created_at="$(jq -r '.created_at' <<<"$version")"
tags="$(jq -r '[.metadata.container.tags[]?] | join(",")' <<<"$version")"
deleted=0
while IFS= read -r version; do
id="$(jq -r '.id' <<<"$version")"
created_at="$(jq -r '.created_at' <<<"$version")"
tags="$(jq -r '[.metadata.container.tags[]?] | join(",")' <<<"$version")"

if jq -e --arg tag "$current_tag" '.metadata.container.tags // [] | index($tag)' <<<"$version" >/dev/null; then
continue
fi
if jq -e --arg tag "$current_tag" '.metadata.container.tags // [] | index($tag)' <<<"$version" >/dev/null; then
continue
fi

echo "Deleting GHCR version id=${id} created_at=${created_at} tags=${tags:-<none>}"
gh api -X DELETE "/users/${owner}/packages/container/${package}/versions/${id}"
deleted=$((deleted + 1))
done < <(jq -c '.[][]' "$versions_file")
echo "Deleting GHCR version id=${id} created_at=${created_at} tags=${tags:-<none>}"
gh api -X DELETE "/users/${owner}/packages/container/${package}/versions/${id}"
deleted=$((deleted + 1))
done < <(jq -c '.[][]' "$versions_file")

echo "Deleted ${deleted} old GHCR package version(s)"
echo "Deleted ${deleted} old version(s) of ${package}"
done
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Codebase Overview

OpenPlotva is a Telegram bot + web service runtime in Rust (18-crate workspace, edition 2024), migrated from a Go original (`go-plotva`): agentic AI dialog with tool-calling turns, media generation (image/music/vision/youtube), bitemporal memory, content-safety shield, Telegram Stars payments, a token-driven admin Web UI, and an operator GraphQL runtime API. Single process backed by Postgres 17 + pgvector and Dragonfly (Redis).
OpenPlotva is a Telegram bot + web service runtime in Rust (19-crate workspace, edition 2024), migrated from a Go original (`go-plotva`): agentic AI dialog with tool-calling turns, media generation (image/music/vision/youtube), bitemporal memory, content-safety shield, Telegram Stars payments, a token-driven admin Web UI, and an operator GraphQL runtime API. Single process backed by Postgres 17 + pgvector and Dragonfly (Redis).

**Stack**: Rust 1.99.0 · tokio · axum · async-graphql · sqlx 0.9 (Postgres-only) · carapax (Telegram) · pgvector · redis · rustls · handlebars (prompts).

Expand Down
18 changes: 18 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ members = [
"crates/openplotva-memory",
"crates/openplotva-shield",
"crates/openplotva-media",
"crates/openplotva-sandbox",
"crates/openplotva-web",
]
default-members = ["crates/openplotva-app"]
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,13 @@ OPENPLOTVA_PRODUCE_UPDATES=false OPENPLOTVA_CONSUME_UPDATES=true cargo run -p op
- `crates/openplotva-memory`: memory extraction and retrieval.
- `crates/openplotva-observability`: tracing and log buffering.
- `crates/openplotva-prompts`: prompt loading and rendering.
- `crates/openplotva-sandbox`: nsjail Python sandbox supervisor and its client.
- `crates/openplotva-server`: HTTP, readiness, and runtime API types.
- `crates/openplotva-shield`: protective retrieval and safety checks.
- `crates/openplotva-storage`: Postgres, Redis, migrations, and persistence stores.
- `crates/openplotva-taskman`: background job data structures and queues.
- `crates/openplotva-telegram`: Telegram Bot API transport and outbound payloads.
- `crates/openplotva-updates`: update queue codec and helpers.
- `crates/openplotva-web`: admin/settings WebApp helpers and embedded assets.
- `sandbox`: Python sandbox image, package set, and nsjail policy.
- `tools`: local smoke checks and auxiliary service binaries.
1 change: 1 addition & 0 deletions crates/openplotva-app/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ openplotva-memory = { path = "../openplotva-memory" }
openplotva-history = { path = "../openplotva-history" }
openplotva-observability = { path = "../openplotva-observability" }
openplotva-prompts = { path = "../openplotva-prompts" }
openplotva-sandbox = { path = "../openplotva-sandbox" }
openplotva-server = { path = "../openplotva-server" }
openplotva-shield = { path = "../openplotva-shield" }
openplotva-storage = { path = "../openplotva-storage" }
Expand Down
5 changes: 3 additions & 2 deletions crates/openplotva-app/src/dialog_messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,7 +666,7 @@ impl<Generator, Sender> DirectDrawApiRuntimeEffects<Generator, Sender> {

async fn record_sent_history(&self, message: carapax::types::Message) -> Option<String> {
let history = self.history.as_ref()?;
let entry = direct_draw_api_sent_history_entry(&message, self.bot_id)?;
let entry = sent_message_history_entry(&message, self.bot_id)?;
history.record_direct_draw_api_history(entry).await.err()
}
}
Expand Down Expand Up @@ -2087,7 +2087,8 @@ fn direct_draw_api_photo_request(
})
}

fn direct_draw_api_sent_history_entry(
/// History entry for a message the bot sent itself, outside the dispatcher.
pub(crate) fn sent_message_history_entry(
message: &carapax::types::Message,
bot_id: i64,
) -> Option<HistoryTextEntry> {
Expand Down
38 changes: 33 additions & 5 deletions crates/openplotva-app/src/dialog_tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ use openplotva_core::{
};
use openplotva_dialog::{
DialogToolbox, DrawRequest, HistorySearchRequest, HistorySummaryRequest,
IMAGE_GENERATION_NOT_SCHEDULED_MESSAGE, RatesRequest, SONG_GENERATION_NOT_SCHEDULED_MESSAGE,
SongRequest, TOOL_RESULT_STATUS_EXECUTED, TOOL_RESULT_STATUS_FAILED, TOOL_RESULT_STATUS_NOOP,
TOOL_RESULT_STATUS_OK, TOOL_RESULT_STATUS_QUEUED, ToolError, ToolResult, ToolSideEffect,
ToolboxError, ToolboxFuture, VisionRequest, is_internal_not_scheduled_instruction,
sanitize_tool_text, user_facing_not_scheduled_message,
IMAGE_GENERATION_NOT_SCHEDULED_MESSAGE, PythonRunRequest, RatesRequest,
SONG_GENERATION_NOT_SCHEDULED_MESSAGE, SongRequest, TOOL_RESULT_STATUS_EXECUTED,
TOOL_RESULT_STATUS_FAILED, TOOL_RESULT_STATUS_NOOP, TOOL_RESULT_STATUS_OK,
TOOL_RESULT_STATUS_QUEUED, ToolError, ToolResult, ToolSideEffect, ToolboxError, ToolboxFuture,
VisionRequest, is_internal_not_scheduled_instruction, sanitize_tool_text,
user_facing_not_scheduled_message,
};
use openplotva_history::{StoredSummary, SummaryContent};
use openplotva_taskman::{
Expand Down Expand Up @@ -2272,6 +2273,7 @@ pub struct AppDialogToolbox<RatesFetcherT, RatesDispatcherT, TranslatorT> {
history_summarizer: Option<Arc<dyn ChatHistorySummarizer>>,
history_searcher: Option<Arc<dyn HistorySearcher>>,
agent_tools: Option<Arc<dyn DialogToolbox>>,
python_sandbox: Option<Arc<crate::python_sandbox::PythonSandboxTool>>,
}

impl<RatesFetcherT, RatesDispatcherT, TranslatorT>
Expand All @@ -2298,6 +2300,7 @@ impl<RatesFetcherT, RatesDispatcherT, TranslatorT>
history_summarizer: None,
history_searcher: None,
agent_tools: None,
python_sandbox: None,
}
}

Expand Down Expand Up @@ -2378,6 +2381,15 @@ impl<RatesFetcherT, RatesDispatcherT, TranslatorT>
self.history_searcher = Some(history_searcher);
self
}

#[must_use]
pub fn with_python_sandbox(
mut self,
python_sandbox: Arc<crate::python_sandbox::PythonSandboxTool>,
) -> Self {
self.python_sandbox = Some(python_sandbox);
self
}
}

impl<RatesFetcherT, RatesDispatcherT, TranslatorT> DialogToolbox
Expand All @@ -2387,6 +2399,22 @@ where
RatesDispatcherT: RatesSideEffectDispatcher + Send + Sync + 'static,
TranslatorT: TextTranslator + Send + Sync + 'static,
{
fn offers_tool(&self, name: &str) -> bool {
name != openplotva_dialog::STEP_RUN_PYTHON || self.python_sandbox.is_some()
}

fn run_python<'a>(&'a self, req: PythonRunRequest) -> ToolboxFuture<'a> {
Box::pin(async move {
Ok(match self.python_sandbox.as_deref() {
Some(sandbox) => sandbox.run(req).await,
None => ToolResult::failed(
"run_python_unavailable",
"The Python sandbox is not configured",
),
})
})
}

fn currency_rates<'a>(&'a self, req: RatesRequest) -> ToolboxFuture<'a> {
Box::pin(async move {
let fetcher = self.rates_fetcher.as_deref();
Expand Down
58 changes: 46 additions & 12 deletions crates/openplotva-app/src/dialog_turn/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,18 @@ fn inline_link_targets(answer: &str) -> Vec<String> {
/// serves plus the engine-intercepted tools. `send_message` and
/// `react_to_message` deliberately never enter the shared catalog constant —
/// the legacy provider loop must not advertise tools it cannot execute.
fn session_native_tools(allow_finish: bool) -> Result<Vec<Value>, String> {
fn session_native_tools(
toolbox: &dyn DialogToolbox,
allow_finish: bool,
) -> Result<Vec<Value>, String> {
let names = openplotva_dialog::alternative_dialog_tool_names();
let mut specs = openplotva_dialog::alternative_dialog_tools()
.into_iter()
.filter(|spec| names.contains(&spec.name) && (allow_finish || spec.name != "finish_turn"))
.filter(|spec| {
names.contains(&spec.name)
&& toolbox.offers_tool(spec.name)
&& (allow_finish || spec.name != "finish_turn")
})
.collect::<Vec<_>>();
specs.push(SESSION_SEND_MESSAGE_SPEC);
specs.push(SESSION_REACT_TO_MESSAGE_SPEC);
Expand Down Expand Up @@ -487,6 +494,7 @@ where
let active_params = crate::dialog_jobs::dialog_job_params_from_input(ctx.params, &base_input);
let meta = dialog_tool_context(&base_input);
let native_tools = match session_native_tools(
cfg.toolbox,
active_params
.meta
.get("dialog_trigger")
Expand Down Expand Up @@ -2949,20 +2957,46 @@ mod tests {
}
}

struct NoPythonToolbox;

impl DialogToolbox for NoPythonToolbox {
fn offers_tool(&self, name: &str) -> bool {
name != openplotva_dialog::STEP_RUN_PYTHON
}
}

#[test]
fn session_native_tools_exclude_agent_only_tools() {
let tools = session_native_tools(true).expect("native tool schemas");
let names = tools
.iter()
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
.collect::<Vec<_>>();
let tool_names = |toolbox: &dyn DialogToolbox| {
session_native_tools(toolbox, true)
.expect("native tool schemas")
.iter()
.filter_map(|tool| tool.pointer("/function/name").and_then(Value::as_str))
.map(str::to_owned)
.collect::<Vec<_>>()
};
let names = tool_names(&BudgetProbe::default());

assert!(names.contains(&openplotva_dialog::STEP_MEMORY_SEARCH));
assert!(names.contains(&STEP_SEND_MESSAGE));
assert!(names.contains(&STEP_REACT_TO_MESSAGE));
assert!(names.contains(&"finish_turn"));
assert!(
session_native_tools(false)
names
.iter()
.any(|name| name == openplotva_dialog::STEP_MEMORY_SEARCH)
);
assert!(names.iter().any(|name| name == STEP_SEND_MESSAGE));
assert!(names.iter().any(|name| name == STEP_REACT_TO_MESSAGE));
assert!(names.iter().any(|name| name == "finish_turn"));
assert!(
names
.iter()
.any(|name| name == openplotva_dialog::STEP_RUN_PYTHON)
);
assert!(
!tool_names(&NoPythonToolbox)
.iter()
.any(|name| name == openplotva_dialog::STEP_RUN_PYTHON)
);
assert!(
session_native_tools(&BudgetProbe::default(), false)
.expect("addressed schemas")
.iter()
.all(|tool| {
Expand Down
Loading
Loading