ask: rename ask_operator → ask + optional 'to' for agent-to-agent Q&A
This commit is contained in:
parent
87f8f8a123
commit
82b0877c47
21 changed files with 640 additions and 266 deletions
|
|
@ -1,7 +1,13 @@
|
|||
//! Operator question queue. Manager submits via `AskOperator`; the
|
||||
//! operator answers via the dashboard. The manager-socket handler long-polls
|
||||
//! the store until the answer lands, so claude's `ask_operator` tool call
|
||||
//! returns the answer directly as its result.
|
||||
//! Question queue. Agents submit via `Ask`; the answer comes from
|
||||
//! either the operator (via the dashboard, for `target IS NULL`) or
|
||||
//! a peer agent (via `Answer`, for agent-to-agent questions).
|
||||
//!
|
||||
//! Despite the file name (kept for git history sanity), this table
|
||||
//! now stores *all* asynchronous questions in the hive — both the
|
||||
//! operator-targeted ones and the peer-to-peer ones. `target IS
|
||||
//! NULL` is the operator path (back-compat with rows written before
|
||||
//! the column existed); `target = '<agent-name>'` is the
|
||||
//! agent-to-agent path.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
|
@ -38,6 +44,15 @@ fn ensure_columns(conn: &Connection) -> Result<()> {
|
|||
"deadline_at",
|
||||
"ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;",
|
||||
),
|
||||
// `target` = recipient of the question. NULL = operator
|
||||
// (back-compat default for rows written before agent-to-agent
|
||||
// questions existed); a non-null agent name = peer-to-peer
|
||||
// question. Dashboard's `pending()` filters on `target IS NULL`
|
||||
// so peer questions never leak into the operator's queue.
|
||||
(
|
||||
"target",
|
||||
"ALTER TABLE operator_questions ADD COLUMN target TEXT;",
|
||||
),
|
||||
] {
|
||||
let has: bool = conn
|
||||
.prepare(&format!(
|
||||
|
|
@ -67,6 +82,12 @@ pub struct OpQuestion {
|
|||
pub deadline_at: Option<i64>,
|
||||
pub answered_at: Option<i64>,
|
||||
pub answer: Option<String>,
|
||||
/// Recipient of the question. `None` = the operator (dashboard
|
||||
/// path); `Some(<agent>)` = a peer agent asked via
|
||||
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
|
||||
/// never appear in `pending()` so the operator's queue stays clean.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
pub struct OperatorQuestions {
|
||||
|
|
@ -97,57 +118,89 @@ impl OperatorQuestions {
|
|||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<&str>,
|
||||
) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
||||
conn.execute(
|
||||
"INSERT INTO operator_questions
|
||||
(asker, question, options_json, multi, deadline_at, asked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
(asker, question, options_json, multi, deadline_at, target, asked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
asker,
|
||||
question,
|
||||
options_json,
|
||||
i64::from(multi),
|
||||
deadline_at,
|
||||
target,
|
||||
now_unix(),
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// Mark the question answered. Returns the original question text so the
|
||||
/// Mark a pending question answered. Returns `(question, asker)`
|
||||
/// so the caller can both echo the question back in a helper
|
||||
/// event AND route that event to whichever agent originally
|
||||
/// asked it.
|
||||
pub fn answer(&self, id: i64, answer: &str) -> Result<(String, String)> {
|
||||
/// Mark a pending question answered. `answerer` is who's actually
|
||||
/// answering: `"operator"` for the dashboard path, or an agent's
|
||||
/// own name when responding via `Answer`. Authorisation:
|
||||
///
|
||||
/// - Operator-targeted questions (`target IS NULL`) can only be
|
||||
/// answered by `"operator"`. (Agents must not be able to spoof
|
||||
/// answers to operator questions — the dashboard is the
|
||||
/// privileged path.)
|
||||
/// - Agent-targeted questions can only be answered by the
|
||||
/// declared target agent, OR by `"operator"` (operator override
|
||||
/// for stuck threads — useful when an agent is offline/down
|
||||
/// and someone has to close the loop).
|
||||
///
|
||||
/// Returns `(question, asker, target)` so the caller can fire the
|
||||
/// `QuestionAnswered` event with the right answerer label and route
|
||||
/// it back to the original asker.
|
||||
pub fn answer(
|
||||
&self,
|
||||
id: i64,
|
||||
answer: &str,
|
||||
answerer: &str,
|
||||
) -> Result<(String, String, Option<String>)> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let row: Option<(String, String, Option<i64>)> = conn
|
||||
let row: Option<(String, String, Option<String>, Option<i64>)> = conn
|
||||
.query_row(
|
||||
"SELECT question, asker, answered_at FROM operator_questions WHERE id = ?1",
|
||||
"SELECT question, asker, target, answered_at FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
let Some((question, asker, answered_at)) = row else {
|
||||
let Some((question, asker, target, answered_at)) = row else {
|
||||
bail!("question {id} not found");
|
||||
};
|
||||
if answered_at.is_some() {
|
||||
bail!("question {id} already answered");
|
||||
}
|
||||
// Authorisation check: must match the target, or be the operator
|
||||
// (operator-targeted questions are operator-only; the operator
|
||||
// can additionally override agent-to-agent questions to close
|
||||
// stuck threads).
|
||||
let authorised = match target.as_deref() {
|
||||
None => answerer == hive_sh4re::OPERATOR_RECIPIENT,
|
||||
Some(t) => answerer == t || answerer == hive_sh4re::OPERATOR_RECIPIENT,
|
||||
};
|
||||
if !authorised {
|
||||
bail!(
|
||||
"question {id} not addressed to '{answerer}' (target = {:?})",
|
||||
target.as_deref().unwrap_or(hive_sh4re::OPERATOR_RECIPIENT)
|
||||
);
|
||||
}
|
||||
conn.execute(
|
||||
"UPDATE operator_questions SET answer = ?1, answered_at = ?2 WHERE id = ?3",
|
||||
params![answer, now_unix(), id],
|
||||
)?;
|
||||
Ok((question, asker))
|
||||
Ok((question, asker, target))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_question,
|
||||
|
|
@ -156,12 +209,15 @@ impl OperatorQuestions {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Pending operator-targeted questions only (`target IS NULL`).
|
||||
/// Drives the dashboard's pending-question pane — agent-to-agent
|
||||
/// questions never appear here so the operator's queue stays clean.
|
||||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions
|
||||
WHERE answered_at IS NULL
|
||||
WHERE answered_at IS NULL AND target IS NULL
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_question)?;
|
||||
|
|
@ -169,13 +225,15 @@ impl OperatorQuestions {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Last `limit` answered questions, newest-first.
|
||||
/// Last `limit` answered operator-targeted questions, newest-first.
|
||||
/// Same `target IS NULL` filter as `pending()` so the dashboard's
|
||||
/// history view only shows operator-relevant rows.
|
||||
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at, target
|
||||
FROM operator_questions
|
||||
WHERE answered_at IS NOT NULL
|
||||
WHERE answered_at IS NOT NULL AND target IS NULL
|
||||
ORDER BY answered_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
|
|
@ -199,6 +257,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
|||
answered_at: row.get(6)?,
|
||||
answer: row.get(7)?,
|
||||
deadline_at: row.get(8)?,
|
||||
target: row.get(9)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue