feat(#1167): hive-wide knowledge repo — forge, local clone, bind-mount, webhook
This commit is contained in:
parent
4bb0d7f310
commit
41befe3839
6 changed files with 276 additions and 0 deletions
|
|
@ -127,6 +127,8 @@ Claude session (OAuth credentials) lives at `/root/.claude/` and persists across
|
||||||
<!-- role:agent -->
|
<!-- role:agent -->
|
||||||
|
|
||||||
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
|
**Shared space**: `/shared` is accessible to all agents (read/write). Only put things here you're willing to lose — other agents may delete them. Use for explicit cross-agent communication or shared artifacts when appropriate.
|
||||||
|
|
||||||
|
**Hive knowledge**: `/knowledge` is a read-only bind-mount of the `internal/knowledge` repo on the forge. It contains hive-wide reference documents (conventions, service endpoints, shared runbooks). Read files there for context; to contribute, fork `internal/knowledge` on the forge and open a PR — do not try to write to `/knowledge` directly.
|
||||||
<!-- /role:agent -->
|
<!-- /role:agent -->
|
||||||
|
|
||||||
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
|
**Code forge**: a private Forgejo at `http://localhost:3000` is available when `/agents/{label}/state/forge-token` exists. You have your own user account (named `{label}`). Use `hive-forge` (see below) for all forge operations — issues, PRs, comments, labels, etc. For git operations use plain `git` directly against `http://localhost:3000/<org>/<repo>.git` (credentials are pre-configured).
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
)
|
)
|
||||||
.route("/dashboard/stream", get(dashboard_stream))
|
.route("/dashboard/stream", get(dashboard_stream))
|
||||||
.route("/dashboard/history", get(dashboard_history))
|
.route("/dashboard/history", get(dashboard_history))
|
||||||
|
.route("/webhook/knowledge", post(post_webhook_knowledge))
|
||||||
// Anything not matched by the dynamic routes above falls
|
// Anything not matched by the dynamic routes above falls
|
||||||
// through to the bundled dashboard dist (GET / →
|
// through to the bundled dashboard dist (GET / →
|
||||||
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
// dist/index.html, /favicon.svg → dist/favicon.svg,
|
||||||
|
|
@ -2969,3 +2970,57 @@ async fn get_approval_diff(
|
||||||
fn plain_text(body: String) -> Response {
|
fn plain_text(body: String) -> Response {
|
||||||
(StatusCode::OK, body).into_response()
|
(StatusCode::OK, body).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Minimal Forgejo push-webhook payload — only the fields we care about.
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PushWebhookPayload {
|
||||||
|
#[serde(rename = "ref")]
|
||||||
|
git_ref: Option<String>,
|
||||||
|
repository: Option<PushWebhookRepo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PushWebhookRepo {
|
||||||
|
full_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST `/webhook/knowledge` — Forgejo push webhook for
|
||||||
|
/// `internal/hive-knowledge`. Runs `git pull` on the local clone so
|
||||||
|
/// agents see up-to-date documents on their next turn.
|
||||||
|
///
|
||||||
|
/// Expected Forgejo webhook configuration:
|
||||||
|
/// - URL: `http://127.0.0.1:<dashboard_port>/webhook/knowledge`
|
||||||
|
/// - Event: "Push" (fires on merge commits to main as well)
|
||||||
|
///
|
||||||
|
/// No signature verification for now; the endpoint is loopback-only
|
||||||
|
/// and only triggers a read-only `git pull` on an operator-curated repo.
|
||||||
|
async fn post_webhook_knowledge(
|
||||||
|
axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>,
|
||||||
|
) -> Response {
|
||||||
|
let expected_repo = format!(
|
||||||
|
"{}/{}",
|
||||||
|
crate::knowledge::ORG,
|
||||||
|
crate::knowledge::REPO
|
||||||
|
);
|
||||||
|
let full_name = payload
|
||||||
|
.repository
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|r| r.full_name.as_deref())
|
||||||
|
.unwrap_or("");
|
||||||
|
if full_name != expected_repo {
|
||||||
|
tracing::debug!(full_name, "webhook/knowledge: ignoring push from unexpected repo");
|
||||||
|
return (StatusCode::OK, "ignored").into_response();
|
||||||
|
}
|
||||||
|
let git_ref = payload.git_ref.as_deref().unwrap_or("");
|
||||||
|
if git_ref != "refs/heads/main" {
|
||||||
|
tracing::debug!(git_ref, "webhook/knowledge: ignoring non-main push");
|
||||||
|
return (StatusCode::OK, "ignored").into_response();
|
||||||
|
}
|
||||||
|
tracing::info!("webhook/knowledge: pull triggered by push to {expected_repo}");
|
||||||
|
tokio::spawn(async {
|
||||||
|
if let Err(e) = crate::knowledge::pull().await {
|
||||||
|
tracing::warn!(error = ?e, "webhook/knowledge: pull failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
(StatusCode::OK, "ok").into_response()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,11 @@ const SHARED_ORG: &str = "internal";
|
||||||
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
|
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
|
||||||
/// at `{FORGE_HTTP}/internal/docs.git`.
|
/// at `{FORGE_HTTP}/internal/docs.git`.
|
||||||
const SHARED_DOCS_REPO: &str = "docs";
|
const SHARED_DOCS_REPO: &str = "docs";
|
||||||
|
/// The hive-wide knowledge repo inside `SHARED_ORG`. Bind-mounted
|
||||||
|
/// read-only into every container at `/hive-knowledge`. Agents
|
||||||
|
/// contribute by forking + opening PRs; direct writes to main are
|
||||||
|
/// not granted. See `hive-c0re/src/knowledge.rs`.
|
||||||
|
const KNOWLEDGE_REPO: &str = crate::knowledge::REPO;
|
||||||
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
|
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
|
||||||
/// `core/meta` (the `core` user's own namespace — no org needed).
|
/// `core/meta` (the `core` user's own namespace — no org needed).
|
||||||
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG];
|
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG];
|
||||||
|
|
@ -606,6 +611,34 @@ pub async fn shared_docs_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ensure the `internal/hive-knowledge` repo exists. Called once at
|
||||||
|
/// startup after `ensure_org(SHARED_ORG)`. Idempotent — `ensure_org_repo`
|
||||||
|
/// treats 409 as success.
|
||||||
|
pub async fn ensure_knowledge_repo(core_token: &str) -> Result<()> {
|
||||||
|
ensure_org_repo(SHARED_ORG, KNOWLEDGE_REPO, core_token).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant agent `name` read-only collaborator access to
|
||||||
|
/// `internal/hive-knowledge`. Agents read documents from the bind-mounted
|
||||||
|
/// clone and contribute by forking + opening PRs — no write to main.
|
||||||
|
/// Idempotent: HTTP 204 (already a collaborator) is treated as success.
|
||||||
|
pub async fn knowledge_access(name: &str, core_token: &str) -> Result<()> {
|
||||||
|
let url = format!(
|
||||||
|
"{FORGE_HTTP}/api/v1/repos/{SHARED_ORG}/{KNOWLEDGE_REPO}/collaborators/{name}"
|
||||||
|
);
|
||||||
|
let body = r#"{"permission":"read"}"#;
|
||||||
|
let status = forge_http(reqwest::Method::PUT, &url, core_token, body).await?;
|
||||||
|
match status.as_u16() {
|
||||||
|
204 => {
|
||||||
|
tracing::info!(%name, "forge: granted hive-knowledge read access");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
other => anyhow::bail!(
|
||||||
|
"PUT {SHARED_ORG}/{KNOWLEDGE_REPO}/collaborators/{name} returned HTTP {other}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
/// Grant agent `name` read-only collaborator access to `core/meta` on
|
||||||
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
/// the forge so the agent can clone/fetch the meta flake. Idempotent:
|
||||||
/// HTTP 204 (already a collaborator) is treated as success.
|
/// HTTP 204 (already a collaborator) is treated as success.
|
||||||
|
|
@ -785,6 +818,13 @@ pub async fn sync_agent(name: &str, core_token: Option<&str>) {
|
||||||
{
|
{
|
||||||
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
|
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
|
||||||
}
|
}
|
||||||
|
// Grant read-only access to internal/hive-knowledge so the agent can
|
||||||
|
// read the bind-mounted knowledge repo and fork it to open PRs.
|
||||||
|
if let Some(token) = core_token
|
||||||
|
&& let Err(e) = knowledge_access(name, token).await
|
||||||
|
{
|
||||||
|
tracing::warn!(%name, error = ?e, "forge: knowledge_access failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sweep every existing container (manager + sub-agents) and ensure
|
/// Sweep every existing container (manager + sub-agents) and ensure
|
||||||
|
|
@ -823,6 +863,14 @@ pub async fn ensure_all() {
|
||||||
if let Err(e) = ensure_shared_docs_repo(token).await {
|
if let Err(e) = ensure_shared_docs_repo(token).await {
|
||||||
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
|
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
|
||||||
}
|
}
|
||||||
|
// Seed the hive-wide knowledge repo.
|
||||||
|
if let Err(e) = ensure_knowledge_repo(token).await {
|
||||||
|
tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed");
|
||||||
|
}
|
||||||
|
// Clone knowledge repo locally so it can be bind-mounted into agents.
|
||||||
|
if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
|
||||||
|
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
|
||||||
|
}
|
||||||
if let Err(e) = ensure_core_avatar(token).await {
|
if let Err(e) = ensure_core_avatar(token).await {
|
||||||
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
161
hive-c0re/src/knowledge.rs
Normal file
161
hive-c0re/src/knowledge.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
//! Hive-wide knowledge repository management.
|
||||||
|
//!
|
||||||
|
//! `internal/hive-knowledge` on the forge is cloned to
|
||||||
|
//! [`LOCAL_DIR`] and bind-mounted read-only into every agent container
|
||||||
|
//! at `/hive-knowledge`. Agents read documents from it directly and
|
||||||
|
//! contribute by forking the repo and opening PRs — they never write
|
||||||
|
//! to the bind-mounted path inside the container.
|
||||||
|
//!
|
||||||
|
//! hive-c0re maintains the local clone. A Forgejo webhook notifies it
|
||||||
|
//! on push to main so agents always see an up-to-date snapshot.
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
|
||||||
|
pub const ORG: &str = "internal";
|
||||||
|
pub const REPO: &str = "knowledge";
|
||||||
|
|
||||||
|
/// Host-side path for the local clone. Also referenced from
|
||||||
|
/// `lifecycle.rs` (bind-mount source) and the dashboard webhook handler.
|
||||||
|
pub const LOCAL_DIR: &str = "/var/lib/hyperhive/knowledge";
|
||||||
|
|
||||||
|
/// In-container mount point for the knowledge repo. Consistent,
|
||||||
|
/// predictable path documented in the agent system prompt.
|
||||||
|
pub const CONTAINER_MOUNT: &str = "/knowledge";
|
||||||
|
|
||||||
|
/// Default README pushed to a freshly created `internal/knowledge` repo.
|
||||||
|
/// Short explanation + empty ToC with an HTML comment instructing contributors
|
||||||
|
/// to add entries when they create new files.
|
||||||
|
const README_CONTENT: &str = "\
|
||||||
|
# knowledge
|
||||||
|
|
||||||
|
Hive-wide reference documents: conventions, service endpoints, runbooks, and \
|
||||||
|
anything that every agent should know but does not belong in an individual \
|
||||||
|
agent's system prompt.
|
||||||
|
|
||||||
|
## How to contribute
|
||||||
|
|
||||||
|
1. Fork this repo into your own namespace on the forge.
|
||||||
|
2. Create a branch, add or update a document.
|
||||||
|
3. Open a pull request — the operator reviews and merges.
|
||||||
|
4. Every agent container updates automatically on merge.
|
||||||
|
|
||||||
|
Do **not** push directly to `main` — agents have read-only access.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
<!-- Add an entry here each time you create a new document:
|
||||||
|
- [Title](path/to/file.md) - one-line description
|
||||||
|
-->
|
||||||
|
";
|
||||||
|
|
||||||
|
/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
|
||||||
|
/// repository. `core_token` authenticates the HTTPS clone so private repos
|
||||||
|
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
|
||||||
|
///
|
||||||
|
/// When the upstream repo is empty (freshly created), seeds it with a
|
||||||
|
/// README.md before returning so the local clone is always non-empty and
|
||||||
|
/// agents see a useful starting document.
|
||||||
|
///
|
||||||
|
/// Called once at hive-c0re startup after `forge::ensure_all`.
|
||||||
|
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
||||||
|
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
|
||||||
|
if git_dir.exists() {
|
||||||
|
tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?;
|
||||||
|
// Embed credentials in the URL — safe for localhost-only forge.
|
||||||
|
let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git");
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.args(["clone", &url, LOCAL_DIR])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("git clone knowledge repo")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||||
|
anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}");
|
||||||
|
}
|
||||||
|
tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}");
|
||||||
|
// If the repo is brand new (no commits), seed it with a README.
|
||||||
|
let head_out = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", LOCAL_DIR, "rev-parse", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("git rev-parse HEAD")?;
|
||||||
|
if !head_out.status.success() {
|
||||||
|
seed_readme(core_token).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the initial README.md, commit, and push to `internal/knowledge`.
|
||||||
|
/// Called only when the upstream repo is empty.
|
||||||
|
async fn seed_readme(core_token: &str) -> Result<()> {
|
||||||
|
let readme = std::path::Path::new(LOCAL_DIR).join("README.md");
|
||||||
|
std::fs::write(&readme, README_CONTENT).context("write README.md")?;
|
||||||
|
// Set a minimal git identity for the seed commit.
|
||||||
|
for (k, v) in [("user.email", "core@hive"), ("user.name", "hive-c0re")] {
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", LOCAL_DIR, "config", k, v])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git config {k}"))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||||
|
anyhow::bail!("git config {k} failed: {stderr}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for args in [
|
||||||
|
vec!["add", "README.md"],
|
||||||
|
vec!["commit", "-m", "init: seed README"],
|
||||||
|
] {
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", LOCAL_DIR].iter().chain(args.iter()))
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("git {:?}", args))?;
|
||||||
|
if !out.status.success() {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||||
|
anyhow::bail!("git {:?} failed: {stderr}", args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let url = format!("http://core:{core_token}@localhost:3000/{ORG}/{REPO}.git");
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", LOCAL_DIR, "push", &url, "HEAD:main"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("git push knowledge README")?;
|
||||||
|
if out.status.success() {
|
||||||
|
tracing::info!("knowledge: seeded README.md and pushed to {ORG}/{REPO}");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||||
|
anyhow::bail!("git push {ORG}/{REPO} failed: {stderr}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull the latest changes in the local clone. Called from the webhook
|
||||||
|
/// handler on every push to `internal/hive-knowledge` main. Uses
|
||||||
|
/// `--ff-only` so a force-push to the knowledge repo never wedges the
|
||||||
|
/// local copy silently; an error triggers a re-clone fallback.
|
||||||
|
pub async fn pull() -> Result<()> {
|
||||||
|
// Sanity: if the clone is missing (e.g. storage was wiped), refuse
|
||||||
|
// to pull and let the caller decide whether to re-clone.
|
||||||
|
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
|
||||||
|
if !git_dir.exists() {
|
||||||
|
anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first");
|
||||||
|
}
|
||||||
|
let out = tokio::process::Command::new("git")
|
||||||
|
.args(["-C", LOCAL_DIR, "pull", "--ff-only"])
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.context("git pull hive-knowledge")?;
|
||||||
|
if out.status.success() {
|
||||||
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
||||||
|
tracing::info!(result = %stdout, "knowledge: pull succeeded");
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
||||||
|
anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,7 @@ pub mod events_vacuum;
|
||||||
pub mod flake_check;
|
pub mod flake_check;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
pub mod gateway_nginx;
|
pub mod gateway_nginx;
|
||||||
|
pub mod knowledge;
|
||||||
pub mod lifecycle;
|
pub mod lifecycle;
|
||||||
pub mod limits;
|
pub mod limits;
|
||||||
pub mod loose_ends;
|
pub mod loose_ends;
|
||||||
|
|
|
||||||
|
|
@ -1128,6 +1128,10 @@ async fn set_nspawn_flags(
|
||||||
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
|
// Ensure /shared directory exists before binding. systemd-nspawn requires the bind source to exist.
|
||||||
std::fs::create_dir_all(HOST_SHARED_ROOT)
|
std::fs::create_dir_all(HOST_SHARED_ROOT)
|
||||||
.with_context(|| format!("create {HOST_SHARED_ROOT}"))?;
|
.with_context(|| format!("create {HOST_SHARED_ROOT}"))?;
|
||||||
|
// Ensure /knowledge dir exists. It may be empty until forge seeds it;
|
||||||
|
// nspawn refuses to start if the bind source is missing entirely.
|
||||||
|
std::fs::create_dir_all(crate::knowledge::LOCAL_DIR)
|
||||||
|
.with_context(|| format!("create {}", crate::knowledge::LOCAL_DIR))?;
|
||||||
|
|
||||||
// Logical agent name — strip the `h-` prefix.
|
// Logical agent name — strip the `h-` prefix.
|
||||||
// For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`.
|
// For the manager: `h-ruth` → `ruth`. For sub-agents: `h-iris` → `iris`.
|
||||||
|
|
@ -1156,6 +1160,11 @@ async fn set_nspawn_flags(
|
||||||
container_path: CONTAINER_SHARED_MOUNT.to_owned(),
|
container_path: CONTAINER_SHARED_MOUNT.to_owned(),
|
||||||
read_only: false,
|
read_only: false,
|
||||||
},
|
},
|
||||||
|
BindMount {
|
||||||
|
host_path: crate::knowledge::LOCAL_DIR.to_owned(),
|
||||||
|
container_path: crate::knowledge::CONTAINER_MOUNT.to_owned(),
|
||||||
|
read_only: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Own state, harness, and config dirs — same for every agent including
|
// Own state, harness, and config dirs — same for every agent including
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue