ask_operator: multi-select + free-text fallback
ask_operator now accepts a multi: bool. when true and options is
non-empty, the dashboard renders the choices as checkboxes — operator
picks any subset, answer comes back as a ', '-joined string. when
false (default), options are radio buttons.
independent of multi, a free-text input ('or type your own…') is
always rendered alongside options so the operator is never trapped
by an incomplete list. submit merges checked options + free text into
the single 'answer' field.
schema migration: operator_questions grows a multi INTEGER column
with a one-shot ALTER TABLE on open. backward compatible — old rows
default to 0 (not multi).
prompt + mcp tool description updated; existing dashboard css for
.qform was rewritten around the new vertical layout.
This commit is contained in:
parent
c337cc06f8
commit
8344dd9ab7
7 changed files with 130 additions and 35 deletions
|
|
@ -9,7 +9,7 @@ Tools (hyperhive surface):
|
||||||
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
|
- `mcp__hyperhive__start(name)` — start a stopped sub-agent. No approval required.
|
||||||
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
|
- `mcp__hyperhive__restart(name)` — stop + start a sub-agent. No approval required.
|
||||||
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
|
- `mcp__hyperhive__request_apply_commit(agent, commit_ref)` — submit a config change for any agent (`hm1nd` for self) for operator approval.
|
||||||
- `mcp__hyperhive__ask_operator(question, options?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Do not poll inside the same turn — finish the current work and react when the event lands.
|
- `mcp__hyperhive__ask_operator(question, options?, multi?)` — surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Do not poll inside the same turn — finish the current work and react when the event lands.
|
||||||
|
|
||||||
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.
|
Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion — no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -227,10 +227,16 @@ pub struct RestartArgs {
|
||||||
pub struct AskOperatorArgs {
|
pub struct AskOperatorArgs {
|
||||||
/// The question to surface on the dashboard.
|
/// The question to surface on the dashboard.
|
||||||
pub question: String,
|
pub question: String,
|
||||||
/// Optional fixed-choice answers. If empty, the dashboard renders a
|
/// Optional fixed-choice answers. The dashboard always renders a
|
||||||
/// free-text input. Otherwise renders a select list of these options.
|
/// free-text fallback ("Other…") so the operator is never trapped
|
||||||
|
/// by an incomplete list.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
|
/// When true, options are rendered as checkboxes — operator can pick
|
||||||
|
/// any subset. The answer comes back as a single string with
|
||||||
|
/// selections joined by ", ". Ignored when `options` is empty.
|
||||||
|
#[serde(default)]
|
||||||
|
pub multi: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
|
@ -369,7 +375,9 @@ impl ManagerServer {
|
||||||
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \
|
with event `operator_answered { id, question, answer }` lands in your inbox; handle it \
|
||||||
on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \
|
on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \
|
||||||
request, policy call, scope clarification). `options` is advisory: pass a short \
|
request, policy call, scope clarification). `options` is advisory: pass a short \
|
||||||
fixed-choice list when applicable, otherwise leave empty for free text."
|
fixed-choice list when applicable, otherwise leave empty for free text. Set \
|
||||||
|
`multi: true` to let the operator pick multiple options (checkboxes); the answer \
|
||||||
|
comes back as a comma-separated string."
|
||||||
)]
|
)]
|
||||||
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
|
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
|
||||||
let log = format!("{args:?}");
|
let log = format!("{args:?}");
|
||||||
|
|
@ -378,6 +386,7 @@ impl ManagerServer {
|
||||||
.dispatch(hive_sh4re::ManagerRequest::AskOperator {
|
.dispatch(hive_sh4re::ManagerRequest::AskOperator {
|
||||||
question: args.question,
|
question: args.question,
|
||||||
options: args.options,
|
options: args.options,
|
||||||
|
multi: args.multi,
|
||||||
})
|
})
|
||||||
.await;
|
.await;
|
||||||
match resp {
|
match resp {
|
||||||
|
|
|
||||||
|
|
@ -205,20 +205,46 @@
|
||||||
method: 'POST', action: '/answer-question/' + q.id,
|
method: 'POST', action: '/answer-question/' + q.id,
|
||||||
class: 'qform', 'data-async': '',
|
class: 'qform', 'data-async': '',
|
||||||
});
|
});
|
||||||
let input;
|
const hasOptions = q.options && q.options.length;
|
||||||
if (q.options && q.options.length) {
|
const isMulti = !!q.multi && hasOptions;
|
||||||
input = el('select', { name: 'answer', required: '' });
|
const freeText = el('input', {
|
||||||
input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…'));
|
type: 'text', name: 'answer-free',
|
||||||
for (const opt of q.options) {
|
placeholder: hasOptions ? 'or type your own…' : 'your answer',
|
||||||
input.append(el('option', { value: opt }, opt));
|
autocomplete: 'off',
|
||||||
}
|
|
||||||
} else {
|
|
||||||
input = el('input', {
|
|
||||||
name: 'answer', type: 'text', required: '',
|
|
||||||
placeholder: 'your answer', autocomplete: 'off',
|
|
||||||
});
|
});
|
||||||
|
const optionGroup = el('div', { class: 'q-options' });
|
||||||
|
if (hasOptions) {
|
||||||
|
for (const opt of q.options) {
|
||||||
|
const inputType = isMulti ? 'checkbox' : 'radio';
|
||||||
|
const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8);
|
||||||
|
const input = el('input', { type: inputType, name: 'choice', value: opt, id });
|
||||||
|
const label = el('label', { for: id }, ' ' + opt);
|
||||||
|
optionGroup.append(el('div', { class: 'q-option' }, input, label));
|
||||||
}
|
}
|
||||||
f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R'));
|
}
|
||||||
|
// On submit, build the final `answer` field from selected
|
||||||
|
// options + free-text, joined by ', '. This lets the operator
|
||||||
|
// pick options AND add free text in the same form.
|
||||||
|
f.addEventListener('submit', (ev) => {
|
||||||
|
const parts = [];
|
||||||
|
for (const cb of f.querySelectorAll('input[name="choice"]:checked')) {
|
||||||
|
parts.push(cb.value);
|
||||||
|
}
|
||||||
|
const ft = (freeText.value || '').trim();
|
||||||
|
if (ft) parts.push(ft);
|
||||||
|
const merged = parts.join(', ');
|
||||||
|
// Replace the existing hidden `answer` (if any) with the merged value.
|
||||||
|
const existing = f.querySelector('input[name="answer"]');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
f.append(el('input', { type: 'hidden', name: 'answer', value: merged }));
|
||||||
|
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||||||
|
}, true);
|
||||||
|
if (hasOptions) f.append(optionGroup);
|
||||||
|
f.append(
|
||||||
|
el('div', { class: 'q-free' }, freeText),
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-approve' },
|
||||||
|
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
|
||||||
|
);
|
||||||
li.append(f);
|
li.append(f);
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -292,18 +292,36 @@ summary:hover { color: var(--purple); }
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; }
|
.qform {
|
||||||
.qform input, .qform select {
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-top: 0.4em;
|
||||||
|
}
|
||||||
|
.qform .q-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25em;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0.4em 0.6em;
|
||||||
|
}
|
||||||
|
.qform .q-option label { cursor: pointer; user-select: none; }
|
||||||
|
.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); }
|
||||||
|
.qform .q-free { display: flex; }
|
||||||
|
.qform .q-free input {
|
||||||
|
flex: 1;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--fg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
padding: 0.4em 0.6em;
|
padding: 0.4em 0.6em;
|
||||||
flex: 1;
|
|
||||||
}
|
}
|
||||||
.qform input::placeholder { color: var(--muted); }
|
.qform .q-free input::placeholder { color: var(--muted); }
|
||||||
.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); }
|
.qform .q-free input:focus { outline: 1px solid var(--amber); }
|
||||||
|
.qform button { align-self: flex-start; }
|
||||||
.inbox {
|
.inbox {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -182,9 +182,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ManagerRequest::AskOperator { question, options } => {
|
ManagerRequest::AskOperator {
|
||||||
tracing::info!(%question, ?options, "manager: ask_operator");
|
question,
|
||||||
match coord.questions.submit(MANAGER_AGENT, question, options) {
|
options,
|
||||||
|
multi,
|
||||||
|
} => {
|
||||||
|
tracing::info!(%question, ?options, multi, "manager: ask_operator");
|
||||||
|
match coord
|
||||||
|
.questions
|
||||||
|
.submit(MANAGER_AGENT, question, options, *multi)
|
||||||
|
{
|
||||||
Ok(id) => {
|
Ok(id) => {
|
||||||
tracing::info!(%id, "operator question queued");
|
tracing::info!(%id, "operator question queued");
|
||||||
ManagerResponse::QuestionQueued { id }
|
ManagerResponse::QuestionQueued { id }
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,29 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
||||||
ON operator_questions (id) WHERE answered_at IS NULL;
|
ON operator_questions (id) WHERE answered_at IS NULL;
|
||||||
";
|
";
|
||||||
|
|
||||||
|
/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN`
|
||||||
|
/// has no `IF NOT EXISTS` form in sqlite, so we check pragma_table_info first.
|
||||||
|
fn ensure_multi_column(conn: &Connection) -> Result<()> {
|
||||||
|
let has: bool = conn
|
||||||
|
.prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")?
|
||||||
|
.exists([])?;
|
||||||
|
if !has {
|
||||||
|
conn.execute_batch(
|
||||||
|
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
|
||||||
|
)
|
||||||
|
.context("add operator_questions.multi column")?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[allow(clippy::doc_markdown)]
|
||||||
pub struct OpQuestion {
|
pub struct OpQuestion {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub asker: String,
|
pub asker: String,
|
||||||
pub question: String,
|
pub question: String,
|
||||||
pub options: Vec<String>,
|
pub options: Vec<String>,
|
||||||
|
pub multi: bool,
|
||||||
pub asked_at: i64,
|
pub asked_at: i64,
|
||||||
pub answered_at: Option<i64>,
|
pub answered_at: Option<i64>,
|
||||||
pub answer: Option<String>,
|
pub answer: Option<String>,
|
||||||
|
|
@ -51,18 +68,25 @@ impl OperatorQuestions {
|
||||||
.with_context(|| format!("open operator_questions db {}", path.display()))?;
|
.with_context(|| format!("open operator_questions db {}", path.display()))?;
|
||||||
conn.execute_batch(SCHEMA)
|
conn.execute_batch(SCHEMA)
|
||||||
.context("apply operator_questions schema")?;
|
.context("apply operator_questions schema")?;
|
||||||
|
ensure_multi_column(&conn).context("migrate operator_questions.multi")?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn: Mutex::new(conn),
|
conn: Mutex::new(conn),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn submit(&self, asker: &str, question: &str, options: &[String]) -> Result<i64> {
|
pub fn submit(
|
||||||
|
&self,
|
||||||
|
asker: &str,
|
||||||
|
question: &str,
|
||||||
|
options: &[String],
|
||||||
|
multi: bool,
|
||||||
|
) -> Result<i64> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into());
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO operator_questions (asker, question, options_json, asked_at)
|
"INSERT INTO operator_questions (asker, question, options_json, multi, asked_at)
|
||||||
VALUES (?1, ?2, ?3, ?4)",
|
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||||
params![asker, question, options_json, now_unix()],
|
params![asker, question, options_json, i64::from(multi), now_unix()],
|
||||||
)?;
|
)?;
|
||||||
Ok(conn.last_insert_rowid())
|
Ok(conn.last_insert_rowid())
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +119,7 @@ impl OperatorQuestions {
|
||||||
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
|
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
|
||||||
FROM operator_questions WHERE id = ?1",
|
FROM operator_questions WHERE id = ?1",
|
||||||
params![id],
|
params![id],
|
||||||
row_to_question,
|
row_to_question,
|
||||||
|
|
@ -107,7 +131,7 @@ impl OperatorQuestions {
|
||||||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
"SELECT id, asker, question, options_json, asked_at, answered_at, answer
|
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
|
||||||
FROM operator_questions
|
FROM operator_questions
|
||||||
WHERE answered_at IS NULL
|
WHERE answered_at IS NULL
|
||||||
ORDER BY id ASC",
|
ORDER BY id ASC",
|
||||||
|
|
@ -121,14 +145,16 @@ impl OperatorQuestions {
|
||||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
||||||
let options_json: String = row.get(3)?;
|
let options_json: String = row.get(3)?;
|
||||||
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
|
let options: Vec<String> = serde_json::from_str(&options_json).unwrap_or_default();
|
||||||
|
let multi: i64 = row.get(4)?;
|
||||||
Ok(OpQuestion {
|
Ok(OpQuestion {
|
||||||
id: row.get(0)?,
|
id: row.get(0)?,
|
||||||
asker: row.get(1)?,
|
asker: row.get(1)?,
|
||||||
question: row.get(2)?,
|
question: row.get(2)?,
|
||||||
options,
|
options,
|
||||||
asked_at: row.get(4)?,
|
multi: multi != 0,
|
||||||
answered_at: row.get(5)?,
|
asked_at: row.get(5)?,
|
||||||
answer: row.get(6)?,
|
answered_at: row.get(6)?,
|
||||||
|
answer: row.get(7)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -289,13 +289,22 @@ pub enum ManagerRequest {
|
||||||
agent: String,
|
agent: String,
|
||||||
commit_ref: String,
|
commit_ref: String,
|
||||||
},
|
},
|
||||||
/// Ask the operator a question. The host-side handler blocks until the
|
/// Ask the operator a question. Returns immediately with the queued
|
||||||
/// operator answers via the dashboard; the answer is then returned as the
|
/// question id; the operator's answer arrives later as a
|
||||||
/// response. `options` is advisory: an empty list means free-text.
|
/// `HelperEvent::OperatorAnswered` in the manager inbox.
|
||||||
|
///
|
||||||
|
/// - `options` is advisory: empty = free-text only; non-empty = the
|
||||||
|
/// dashboard renders the choices alongside a free-text fallback
|
||||||
|
/// ("Other…") so the operator is never trapped.
|
||||||
|
/// - `multi=true` lets the operator pick multiple options (rendered
|
||||||
|
/// as checkboxes). The answer is returned as a single string with
|
||||||
|
/// selections joined by ", ".
|
||||||
AskOperator {
|
AskOperator {
|
||||||
question: String,
|
question: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
options: Vec<String>,
|
options: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
multi: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue