Skip to content

Commit d47d39d

Browse files
authored
Preserve queued collaboration across limits and offer bounded continuation (#12)
1 parent 7c090ee commit d47d39d

7 files changed

Lines changed: 324 additions & 47 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,3 +247,5 @@ Automatic Slack app portraits from compatible agents: [agent images](docs/agent-
247247
Verified, installation-local updates are available for Linux systemd installations.
248248
See [Updating one Slack connector](docs/updates.md) for the CLI, management API,
249249
and optional owner-DM agent controls.
250+
251+
Bounded agent collaboration and durable delivery waits: [collaboration waits](docs/collaboration-waits.md).

‎docs/collaboration-waits.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Collaboration waits
2+
3+
The connector preserves completed replies in its durable outbox while waiting for
4+
reply spacing or hourly capacity. It retries delivery after the limit clears;
5+
it does not rerun the completed ACP prompt or repeat its tool actions. Model
6+
capacity similarly defers the pending job with a durable availability time rather
7+
than permanently dropping it. Recovery still treats interrupted model turns and
8+
uncertain sends as requiring reconciliation, not permission to replay work.
9+
10+
Settings includes **Agent collaboration**. Owners may allow agents to continue
11+
conversing after a short pause without waiting for another human message. This
12+
sets `attention.bot_exchange_cooldown_seconds` to 60. Zero preserves the existing
13+
wait-for-a-person policy; allowed nonzero values are 30–3600 seconds. Hourly reply
14+
and model budgets still apply. This is an optional conversation policy, not an
15+
execution grant or an unlimited background-work authorization.
16+
17+
New bot messages do not discard a completed contribution solely by advancing the
18+
conversation version. New human input and edits/deletions of existing messages
19+
still cause reconsideration. Editing the root of a thread invalidates its snapshot.
20+
The completed work is not rolled back or blindly repeated. The conversation's
21+
read-only response-delivery tool reports pending state, the reason for a delivery
22+
wait, and a retry time when one is scheduled.
23+
24+
Every setting remains available through the configuration API/CLI. The browser's
25+
`collaboration.toggle` semantic action changes the same durable draft as the
26+
visible control; Save preferences applies it. Existing installations keep the
27+
human gate until the owner opts in. No agent identity, ACP endpoint, permission,
28+
channel scope, or secret is changed by this preference.

‎src/config.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ pub struct Attention {
138138
pub max_replies_per_agent_per_hour: usize,
139139
pub max_decisions_per_agent_per_hour: usize,
140140
pub max_bot_replies_without_human: usize,
141+
/// Zero waits for a human; otherwise resume a bounded exchange after this pause.
142+
pub bot_exchange_cooldown_seconds: u64,
141143
}
142144
impl Default for Attention {
143145
fn default() -> Self {
@@ -149,6 +151,7 @@ impl Default for Attention {
149151
max_replies_per_agent_per_hour: 120,
150152
max_decisions_per_agent_per_hour: 240,
151153
max_bot_replies_without_human: 3,
154+
bot_exchange_cooldown_seconds: 0,
152155
}
153156
}
154157
}
@@ -261,6 +264,11 @@ impl Config {
261264
&& (1..=10000).contains(&self.attention.max_replies_per_context_per_hour),
262265
"Invalid attention budget"
263266
);
267+
ensure!(
268+
self.attention.bot_exchange_cooldown_seconds == 0
269+
|| (30..=3600).contains(&self.attention.bot_exchange_cooldown_seconds),
270+
"Agent collaboration pause must be zero or 30–3600 seconds"
271+
);
264272
ensure!(
265273
(1..=20).contains(&self.attention.max_bot_replies_without_human),
266274
"Bot exchange limit must be 1–20"

‎src/store.rs‎

Lines changed: 129 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,31 @@ impl Store {
5757
[],
5858
)?;
5959
}
60+
for (table, column, definition) in [
61+
("jobs", "available", "INTEGER NOT NULL DEFAULT 0"),
62+
("outbox", "attempted", "INTEGER NOT NULL DEFAULT 0"),
63+
("contexts", "human_version", "INTEGER NOT NULL DEFAULT 0"),
64+
] {
65+
let present = store
66+
.db
67+
.prepare(&format!("PRAGMA table_info({table})"))?
68+
.query_map([], |r| r.get::<_, String>(1))?
69+
.any(|v| v.is_ok_and(|v| v == column));
70+
if !present {
71+
store.db.execute(
72+
&format!("ALTER TABLE {table} ADD COLUMN {column} {definition}"),
73+
[],
74+
)?;
75+
if table == "contexts" {
76+
store
77+
.db
78+
.execute("UPDATE contexts SET human_version=version", [])?;
79+
}
80+
if table == "outbox" {
81+
store.db.execute("UPDATE outbox SET attempted=created WHERE status IN ('sending','sent','uncertain')",[])?;
82+
}
83+
}
84+
}
6085
crate::permissions::initialize(&store)?;
6186
Ok(store)
6287
}
@@ -207,14 +232,20 @@ impl Store {
207232
let ctx = m.context();
208233
tx.execute("INSERT INTO messages VALUES(?1,?2,?3,?4,?5) ON CONFLICT(channel,ts) DO UPDATE SET context=excluded.context,revision=excluded.revision,body=excluded.body",params![m.channel,m.ts,ctx,m.revision,serde_json::to_string(m)?])?;
209234
tx.execute("INSERT INTO contexts(id,version,bot_replies,last_human) VALUES(?1,1,0,CASE WHEN ?2 THEN '' ELSE ?3 END) ON CONFLICT(id) DO UPDATE SET version=version+1,bot_replies=CASE WHEN NOT ?2 AND ?3>last_human THEN 0 ELSE bot_replies END,last_human=CASE WHEN NOT ?2 AND ?3>last_human THEN ?3 ELSE last_human END",params![ctx,m.bot||m.deleted,m.ts])?;
235+
if !m.bot || old.is_some() {
236+
tx.execute(
237+
"UPDATE contexts SET human_version=version WHERE id=?1",
238+
[&ctx],
239+
)?;
240+
}
210241
if old.is_some() {
211242
tx.execute("UPDATE outbox SET status='cancelled',note='Source edited or deleted before delivery' WHERE status='ready' AND job_id IN (SELECT id FROM jobs WHERE json_extract(trigger,'$.channel')=?1 AND json_extract(trigger,'$.ts')=?2)",params![m.channel,m.ts])?;
212243
}
213244
// A root edit also changes the source snapshot of its child thread.
214245
if old.is_some() && m.thread.is_empty() {
215246
let thread_ctx = crate::model::context_id(&m.team, &m.channel, &m.ts);
216247
tx.execute(
217-
"UPDATE contexts SET version=version+1 WHERE id=?1",
248+
"UPDATE contexts SET version=version+1,human_version=version+1 WHERE id=?1",
218249
[thread_ctx],
219250
)?;
220251
}
@@ -268,7 +299,7 @@ impl Store {
268299
/// neither a private draft nor an attempted send establishes publication.
269300
pub fn response_delivery(&self, context: &str, current_job: &str) -> Result<Value> {
270301
let mut stmt = self.db.prepare(
271-
"SELECT j.id,json_extract(j.trigger,'$.ts'),j.status,o.status,o.slack_ts
302+
"SELECT j.id,json_extract(j.trigger,'$.ts'),j.status,o.status,o.slack_ts,o.note,o.available
272303
FROM jobs j LEFT JOIN outbox o ON o.job_id=j.id
273304
WHERE j.context=?1 AND j.id!=?2 AND j.status NOT IN ('pending','running')
274305
ORDER BY j.updated DESC,j.rowid DESC LIMIT 12",
@@ -294,7 +325,7 @@ impl Store {
294325
};
295326
Ok(json!({"job_id":row.get::<_,String>(0)?,
296327
"trigger_message_id":row.get::<_,String>(1)?,"status":status,
297-
"slack_message_id":row.get::<_,Option<String>>(4)?}))
328+
"slack_message_id":row.get::<_,Option<String>>(4)?,"delivery_note":row.get::<_,Option<String>>(5)?,"retry_at":row.get::<_,Option<i64>>(6)?.filter(|t|*t>now_ms())}))
298329
})?
299330
.collect::<rusqlite::Result<Vec<_>>>()?;
300331
for item in &mut items {
@@ -347,13 +378,14 @@ impl Store {
347378
pub fn claim(&mut self, a: &Attention) -> Result<Option<Job>> {
348379
let now = now_ms();
349380
let tx = self.db.transaction()?;
350-
let candidate:Option<(String,String,String,String)>=tx.query_row("SELECT id,context,trigger,note FROM jobs j WHERE status='pending' AND updated<=?1 AND NOT EXISTS(SELECT 1 FROM jobs x WHERE x.context=j.context AND x.status='running') ORDER BY created LIMIT 1",[now-a.settle_ms as i64],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
381+
let candidate:Option<(String,String,String,String)>=tx.query_row("SELECT id,context,trigger,note FROM jobs j WHERE status='pending' AND available<=?2 AND updated<=?1 AND NOT EXISTS(SELECT 1 FROM jobs x WHERE x.context=j.context AND x.status='running') ORDER BY created LIMIT 1",params![now-a.settle_ms as i64,now],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
351382
let Some((id, context, trigger, note)) = candidate else {
352383
return Ok(None);
353384
};
354385
let decisions:i64=tx.query_row("SELECT COUNT(*) FROM jobs WHERE status NOT IN ('pending','superseded','budget') AND updated>?1",[now-3600000],|r|r.get(0))?;
355386
if decisions >= a.max_decisions_per_agent_per_hour as i64 {
356-
tx.execute("UPDATE jobs SET status='budget',note='Hourly model decision budget reached' WHERE id=?1",[id])?;
387+
let oldest:i64=tx.query_row("SELECT MIN(updated) FROM jobs WHERE status NOT IN ('pending','superseded','budget') AND updated>?1",[now-3600000],|r|r.get(0))?;
388+
tx.execute("UPDATE jobs SET available=?2,note='Waiting for hourly model capacity; resumes automatically' WHERE id=?1",params![id,oldest+3600001])?;
357389
tx.commit()?;
358390
return Ok(None);
359391
}
@@ -375,7 +407,7 @@ impl Store {
375407
reconsidered: note.starts_with("Reconsider"),
376408
}))
377409
}
378-
pub fn finish(&mut self, job: &Job, result: Result<Decision>, a: &Attention) -> Result<()> {
410+
pub fn finish(&mut self, job: &Job, result: Result<Decision>, _a: &Attention) -> Result<()> {
379411
let d = match result {
380412
Ok(d) => d,
381413
Err(error) => {
@@ -401,11 +433,11 @@ impl Store {
401433
}
402434
};
403435
let version: i64 = self.db.query_row(
404-
"SELECT version FROM contexts WHERE id=?1",
436+
"SELECT human_version FROM contexts WHERE id=?1",
405437
[&job.context],
406438
|r| r.get(0),
407439
)?;
408-
if version != job.version {
440+
if version > job.version {
409441
self.db.execute("UPDATE jobs SET status='superseded',updated=?1,note='Conversation changed during this turn; contribution withheld' WHERE id=?2",params![now_ms(),job.id])?;
410442
self.audit("stale_contribution_withheld", json!({"job":job.id}))?;
411443
if let Some(current) = self.message(&job.trigger.channel, &job.trigger.ts)?
@@ -415,22 +447,12 @@ impl Store {
415447
}
416448
return Ok(());
417449
}
418-
let mut state = d.decision.clone();
419-
let mut note = d.reason.clone();
420-
if d.decision == "speak"
421-
&& let Some(why) = self.reply_limit(&job.context, &job.trigger, a)?
422-
{
423-
state = "limited".into();
424-
note = why;
425-
}
450+
let state = d.decision.clone();
451+
let note = d.reason.clone();
426452
let tx = self.db.transaction()?;
427453
if state == "speak" {
428454
let id = Uuid::new_v4().to_string();
429455
tx.execute("INSERT INTO outbox(id,job_id,context,channel,thread,text,status,created,available,media) VALUES(?1,?2,?3,?4,?5,?6,'ready',?7,?7,?8)",params![id,job.id,job.context,job.trigger.channel,job.trigger.thread,d.text,now_ms(),serde_json::to_string(&d.media)?])?;
430-
tx.execute(
431-
"UPDATE contexts SET bot_replies=bot_replies+1 WHERE id=?1",
432-
[&job.context],
433-
)?;
434456
}
435457
tx.execute(
436458
"UPDATE jobs SET status=?1,updated=?2,note=?3 WHERE id=?4",
@@ -447,29 +469,9 @@ impl Store {
447469
tx.commit()?;
448470
Ok(())
449471
}
450-
fn reply_limit(&self, ctx: &str, m: &Message, a: &Attention) -> Result<Option<String>> {
451-
let (total,room,last):(i64,i64,i64)=self.db.query_row("SELECT COUNT(*),COALESCE(SUM(CASE WHEN context=?1 THEN 1 ELSE 0 END),0),COALESCE(MAX(CASE WHEN context=?1 THEN created ELSE 0 END),0) FROM outbox WHERE created>?2 AND status!='failed'",params![ctx,now_ms()-3600000],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?;
452-
let burst: i64 =
453-
self.db
454-
.query_row("SELECT bot_replies FROM contexts WHERE id=?1", [ctx], |r| {
455-
r.get(0)
456-
})?;
457-
let why = if total >= a.max_replies_per_agent_per_hour as i64 {
458-
"Agent hourly contribution limit"
459-
} else if room >= a.max_replies_per_context_per_hour as i64 {
460-
"Conversation hourly contribution limit"
461-
} else if m.bot && burst >= a.max_bot_replies_without_human as i64 {
462-
"Bot exchange limit; waiting for a human"
463-
} else if now_ms() - last < (a.minimum_reply_interval_seconds * 1000) as i64 {
464-
"Contribution cooldown"
465-
} else {
466-
return Ok(None);
467-
};
468-
Ok(Some(why.into()))
469-
}
470472
pub fn claim_outgoing(&mut self, c: &Config) -> Result<Option<Outgoing>> {
471473
let tx = self.db.transaction()?;
472-
let candidate:Option<(Outgoing,String,i64,i64,i64)>=tx.query_row("SELECT o.id,o.channel,o.thread,o.text,j.trigger,j.version,c.version,o.media,(SELECT COUNT(*) FROM outbox_parts p WHERE p.outbox_id=o.id AND p.status='sent') FROM outbox o JOIN jobs j ON o.job_id=j.id JOIN contexts c ON c.id=o.context WHERE o.status='ready' AND o.available<=?1 ORDER BY o.created LIMIT 1",[now_ms()],|r| {
474+
let candidate:Option<(Outgoing,String,i64,i64,i64)>=tx.query_row("SELECT o.id,o.channel,o.thread,o.text,j.trigger,j.version,c.human_version,o.media,(SELECT COUNT(*) FROM outbox_parts p WHERE p.outbox_id=o.id AND p.status='sent') FROM outbox o JOIN jobs j ON o.job_id=j.id JOIN contexts c ON c.id=o.context WHERE o.status='ready' AND o.available<=?1 ORDER BY o.created LIMIT 1",[now_ms()],|r| {
473475
let raw:String=r.get(7)?;
474476
let media=serde_json::from_str(&raw).map_err(|e|rusqlite::Error::FromSqlConversionFailure(7,rusqlite::types::Type::Text,Box::new(e)))?;
475477
Ok((Outgoing{id:r.get(0)?,channel:r.get(1)?,thread:r.get(2)?,text:r.get(3)?,media},r.get(4)?,r.get(5)?,r.get(6)?,r.get(8)?))
@@ -494,7 +496,7 @@ impl Store {
494496
&& source
495497
.as_ref()
496498
.is_some_and(|s| !s.deleted && c.should_wake(&s.channel, &s.text));
497-
let unchanged = (original_version == current_version || delivered_parts > 0)
499+
let unchanged = (original_version >= current_version || delivered_parts > 0)
498500
&& source
499501
.as_ref()
500502
.is_some_and(|s| s.revision == trigger.revision);
@@ -507,9 +509,42 @@ impl Store {
507509
tx.commit()?;
508510
return Ok(None);
509511
}
512+
let now = now_ms();
513+
let attempted: i64 =
514+
tx.query_row("SELECT attempted FROM outbox WHERE id=?1", [&out.id], |r| {
515+
r.get(0)
516+
})?;
517+
if attempted == 0 {
518+
if let Some((why, until)) = delivery_wait(&tx, &trigger, &c.attention, now)? {
519+
let available = until.unwrap_or(now + 30_000);
520+
let changed = tx.execute(
521+
"UPDATE outbox SET available=?2,note=?3 WHERE id=?1 AND note!=?3",
522+
params![out.id, available, why],
523+
)?;
524+
tx.execute(
525+
"UPDATE outbox SET available=?2 WHERE id=?1",
526+
params![out.id, available],
527+
)?;
528+
if changed > 0 {
529+
tx.execute(
530+
"INSERT INTO audit(time,kind,data) VALUES(?1,'delivery_wait',?2)",
531+
params![
532+
now,
533+
json!({"id":out.id,"reason":why,"retry_at":until}).to_string()
534+
],
535+
)?;
536+
}
537+
tx.commit()?;
538+
return Ok(None);
539+
}
540+
let last:i64=tx.query_row("SELECT COALESCE(MAX(attempted),0) FROM outbox WHERE context=?1 AND status IN ('sent','sending','uncertain')",[trigger.context()],|r|r.get(0))?;
541+
let reset = c.attention.bot_exchange_cooldown_seconds > 0
542+
&& now - last >= c.attention.bot_exchange_cooldown_seconds as i64 * 1000;
543+
tx.execute("UPDATE contexts SET bot_replies=CASE WHEN ?2 THEN 1 ELSE bot_replies+1 END WHERE id=?1",params![trigger.context(),reset])?;
544+
}
510545
tx.execute(
511-
"UPDATE outbox SET status='sending',note='' WHERE id=?1 AND status='ready'",
512-
[&out.id],
546+
"UPDATE outbox SET status='sending',note='',attempted=CASE WHEN attempted=0 THEN ?2 ELSE attempted END WHERE id=?1 AND status='ready'",
547+
params![out.id,now],
513548
)?;
514549
tx.commit()?;
515550
Ok(Some(out))
@@ -592,3 +627,53 @@ fn schedule_reconsider(db: &Connection, m: &Message) -> Result<()> {
592627
db.execute("INSERT INTO jobs(id,context,trigger,status,created,updated,note) SELECT ?1,?2,?3,'pending',?4,?4,'Reconsider the latest conversation; an earlier turn may already have performed work. Do not repeat actions without checking the existing result.' WHERE NOT EXISTS(SELECT 1 FROM jobs WHERE context=?2 AND status IN ('pending','running'))",params![Uuid::new_v4().to_string(),m.context(),serde_json::to_string(m)?,now_ms()])?;
593628
Ok(())
594629
}
630+
631+
// Budgets are checked when reserving delivery, rather than throwing away a
632+
// completed model turn. Retries never replay its tools or count as a new reply.
633+
fn delivery_wait(
634+
db: &Connection,
635+
m: &Message,
636+
a: &Attention,
637+
now: i64,
638+
) -> Result<Option<(String, Option<i64>)>> {
639+
let ctx = m.context();
640+
let (total,room,last,oldest,room_oldest):(i64,i64,i64,i64,i64)=db.query_row("SELECT COUNT(*),COALESCE(SUM(CASE WHEN context=?1 THEN 1 ELSE 0 END),0),COALESCE(MAX(CASE WHEN context=?1 THEN attempted ELSE 0 END),0),COALESCE(MIN(attempted),0),COALESCE(MIN(CASE WHEN context=?1 THEN attempted END),0) FROM outbox WHERE attempted>?2 AND status IN ('sent','sending','uncertain')",params![ctx,now-3600000],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?)))?;
641+
if total >= a.max_replies_per_agent_per_hour as i64 {
642+
return Ok(Some((
643+
"Waiting for agent reply capacity".into(),
644+
Some(oldest + 3600001),
645+
)));
646+
}
647+
if room >= a.max_replies_per_context_per_hour as i64 {
648+
return Ok(Some((
649+
"Waiting for conversation reply capacity".into(),
650+
Some(room_oldest + 3600001),
651+
)));
652+
}
653+
let burst: i64 = db.query_row("SELECT bot_replies FROM contexts WHERE id=?1", [ctx], |r| {
654+
r.get(0)
655+
})?;
656+
if m.bot && burst >= a.max_bot_replies_without_human as i64 {
657+
if a.bot_exchange_cooldown_seconds == 0 {
658+
return Ok(Some((
659+
"Agent exchange paused until a person responds".into(),
660+
None,
661+
)));
662+
}
663+
let until = last + a.bot_exchange_cooldown_seconds as i64 * 1000;
664+
if now < until {
665+
return Ok(Some((
666+
"Agent collaboration pause; resumes automatically".into(),
667+
Some(until),
668+
)));
669+
}
670+
}
671+
let until = last + a.minimum_reply_interval_seconds as i64 * 1000;
672+
if now < until {
673+
return Ok(Some((
674+
"Reply spacing; resumes automatically".into(),
675+
Some(until),
676+
)));
677+
}
678+
Ok(None)
679+
}

0 commit comments

Comments
 (0)