Skip to content
Merged
Show file tree
Hide file tree
Changes from 53 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
11d0812
fix(llm): price ordinary model calls via task-level inputPricePer1M/o…
ginccc Aug 7, 2026
89f2b15
fix(groups): sum nested-group cost per child discussion instead of ov…
ginccc Aug 7, 2026
65b00b6
fix(schema): default schemaVersion to a legacy sentinel, stamp curren…
ginccc Aug 7, 2026
b6c0eb4
feat(groups): transcript windowing for rendered member context (N2/I9)
ginccc Aug 7, 2026
6aeba13
feat(groups): shared artifacts - typed co-edited documents with CAS a…
ginccc Aug 7, 2026
7d47990
fix(review): NaN cost guard, ceiling-gated summarizer, visible-entry …
ginccc Aug 7, 2026
5966172
feat(groups): voting with structural ballot independence (I14)
ginccc Aug 7, 2026
3252624
feat(groups): I8 retro phases harvest team-owned group memory
ginccc Aug 7, 2026
6173e48
fix(test): PhaseType enum-count pin now includes VOTE
ginccc Aug 7, 2026
8025962
fix(test): PhaseType enum-count pin now includes RETRO
ginccc Aug 7, 2026
74c0aca
fix(groups): I17 review round 1 - validators, event ordering, store c…
ginccc Aug 8, 2026
51a7092
fix(groups): I14 review round 1 - budgeted tiebreak, tie epsilon, dis…
ginccc Aug 8, 2026
c2b4b7a
fix(groups): I8 CI round 1 - bind-order pin update, sanitized retro logs
ginccc Aug 8, 2026
3de3de6
test(groups): I8 - UserMemoryToolScopingTest extension per plan
ginccc Aug 8, 2026
07d566b
docs(changelog): record I8 CI round 1 + scoping-test extension
ginccc Aug 8, 2026
9c84783
feat(groups): humans as group members - AWAITING_HUMAN_INPUT turns (I6)
ginccc Aug 8, 2026
a845f3e
feat(groups): NEGOTIATION style - the trade form (I11)
ginccc Aug 8, 2026
a9f1598
fix(groups): I6 review round 1 - member read access, resume hygiene
ginccc Aug 8, 2026
8c4eb5d
feat(groups): bid-based task assignment - CNP-lite (I18)
ginccc Aug 8, 2026
41de0a5
Merge branch 'feat/group-i6-human-members' into feat/group-i12-facili…
ginccc Aug 8, 2026
87f5002
feat(groups): facilitator with bounded moves (I12)
ginccc Aug 8, 2026
5537087
fix(groups): sanitize caller-influenced values in bid-round logs
ginccc Aug 8, 2026
84d3f18
fix(groups): I11 review round 1 - stale-signature withdrawal, schema v4
ginccc Aug 8, 2026
45f8159
feat(groups): standing teams - workspace, backlog, cadences (I13)
ginccc Aug 8, 2026
86f4376
fix(groups): I12 review round 1 - TOCTOU snapshot, encapsulated exten…
ginccc Aug 8, 2026
d44370f
fix(groups): artifact announce mutex guards the handoff, not the call…
ginccc Aug 8, 2026
3953b81
docs(groups): I18 review round 2 - test count, bid-section placement
ginccc Aug 8, 2026
bca94b4
Merge branch 'feat/group-i11-negotiation' into feat/group-i10-templates
ginccc Aug 8, 2026
ba64cc8
Merge branch 'feat/group-i18-bidding' into feat/group-i10-templates
ginccc Aug 8, 2026
d96fd2a
Merge branch 'feat/group-i13-standing-teams' into feat/group-i10-temp…
ginccc Aug 8, 2026
5651894
Merge branch 'feat/group-i17-shared-artifacts' into feat/group-i10-te…
ginccc Aug 8, 2026
5b84439
Merge branch 'fix/group-pre-feature-defects' into feat/group-i10-temp…
ginccc Aug 8, 2026
63411c5
feat(groups): org/team preset templates (I10)
ginccc Aug 8, 2026
7b2b111
fix(groups): I13 review round 1 - sanitized logs, encapsulated cadences
ginccc Aug 8, 2026
2da9894
Merge branch 'feat/group-i13-standing-teams' into feat/group-i10-temp…
ginccc Aug 8, 2026
ab5c115
fix(groups): persist the repeat slice base across a mid-repeat human …
ginccc Aug 8, 2026
0eb199e
fix(groups): facilitator checkpoint consults before the decision block
ginccc Aug 8, 2026
7bbe45b
fix(groups): clear the negotiation table on continuation rounds
ginccc Aug 8, 2026
be17c4f
fix(groups): final-review fixes — conditional settle release, persist…
ginccc Aug 8, 2026
dce5068
merge: I6 final-review fix (mid-repeat human pause repeat slice)
ginccc Aug 8, 2026
66a6e30
merge: I11 final-review fix (negotiation state cleared on continuation)
ginccc Aug 8, 2026
4be0bfd
merge: I12 final-review fixes (consult-before-decision-block ordering)
ginccc Aug 8, 2026
c460190
merge: I13 final-review fixes (conditional settle, persisted ceiling,…
ginccc Aug 8, 2026
fe0c40d
fix(groups): retro cap semantics + event contract (review round 2)
ginccc Aug 8, 2026
9862478
fix(groups): schema bump for slice base, approver full-view scope, pe…
ginccc Aug 8, 2026
74c051a
merge: I8 review-round-2 fixes (retro cap semantics, event contract) …
ginccc Aug 8, 2026
0a8dae1
fix(groups): workspace write concurrency + schedule lifecycle (review…
ginccc Aug 8, 2026
492a347
merge: I6 review fixes (schema bump, approver scope, persist-time test)
ginccc Aug 8, 2026
e92518e
merge: I8 round-2 + I13 round-2 review fixes into integration
ginccc Aug 8, 2026
bb5cb05
fix(groups): retro config ceilings + creation-ordered FIFO eviction (…
ginccc Aug 8, 2026
dfdbf61
merge: I8 round-3 fixes (retro ceilings, creation-ordered FIFO) into I13
ginccc Aug 8, 2026
e5c049e
fix(groups): revision parse guard, convergence limit, order/write-pat…
ginccc Aug 8, 2026
c558df6
merge: I8 round-3 + I13 round-3 review fixes into integration
ginccc Aug 8, 2026
32009f3
fix(groups): facilitator HITL-gate precedence, CALL_VOTE guards, boun…
ginccc Aug 8, 2026
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
466 changes: 466 additions & 0 deletions docs/changelog.md

Large diffs are not rendered by default.

376 changes: 376 additions & 0 deletions docs/group-conversations.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/langchain.md
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,7 @@ Both stored shapes therefore keep working: a config that sets only `streamingTim
| `maxBudgetPerConversation` | number | Ceiling on accumulated **tool** cost per conversation, in USD. Records cost; only refuses calls when `enforceBudget` is on | (unlimited) |
| `enforceBudget` | boolean | Refuse tool calls once `maxBudgetPerConversation` is passed. **Opt-in** — a ceiling without it is report-only, and is named in a startup WARN | false (`eddi.tools.budget.enforce-by-default`) |
| `toolPricing` | map | Per-call tool prices in USD. Keyed on the built-in slug (`{"websearch": 0.005}`) or on a single dispatch name (`{"searchNews": 0.01}`), which takes precedence — so one operation can be priced apart from its siblings | (built-in defaults) |
| `inputPricePer1M` / `outputPricePer1M` | number | Token prices in USD per 1M tokens for this task's **model calls**, feeding the conversation's tracked cost (and any group cost ceiling). Applies to non-cascade calls; a [model cascade](model-cascade.md) prices its steps via its own fields of the same name. Negative values fail deployment | (unpriced — $0) |
| `enableToolCaching` | boolean | Cache tool results to reduce API calls | true |
| `toolCacheScopes` | map | Per-tool cache partition: `user`/`conversation`/`global` | (all `user`) |
| `defaultToolCacheScope` | string | Cache partition for tools without an override | `user` |
Expand Down
17 changes: 11 additions & 6 deletions planning/group-collaboration-NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
> holds the **design** for each item and does not repeat status — go there only for the one
> item you are about to build, using the section pointers below.
>
> **Last updated:** 2026-08-04 · after PR [#626](https://github.com/labsai/EDDI/pull/626) (`refactor/group-service-split`)
> **Last updated:** 2026-08-07 · after the `fix/group-pre-feature-defects` branch (N1 + N2/I9 + N3, §2 below — all three done)

---

Expand All @@ -21,7 +21,8 @@ builds on (F1–F6), and shipped six feature items.
| **Wave 0** — F1 `LiveDiscussionRegistry`, F2 speaker-level `ResumePoint`, F3 `DecisionRecord`, F4 entry types + visibility, F5 cost ledger, F6 schema versioning | ✅ Done |
| **Wave 1** — I1 cost ceiling, I2 convergence + early exit, I3 structured verdicts, I4 abstention + minority report | ✅ Done |
| **Wave 2** — I5 agent-filed tasks, I7 recruitment + delegation timeout | ✅ Done |
| **Wave 2** — I6, I8, I9, I14, I17 | ⬜ Queued (see §3) |
| **Pre-feature defects** — N1 plain-call pricing, N2/I9 transcript windowing, N3 schema sentinel (§2) | ✅ Done (2026-08-07, `fix/group-pre-feature-defects`) |
| **Wave 2** — I6, I8, I14, I17 | ⬜ Queued (see §3) |
| **Wave 3** — I10, I11, I12, I13, I18 | ⬜ Queued (see §3) |
| **Excluded — do not start** — I15 cross-team process DAGs, I16 A2A remote members | ❌ Out of scope |

Expand All @@ -32,7 +33,12 @@ tool surfaces → a `ToolSourceProvider`.

---

## 2. Do these three first
## 2. Do these three first — ✅ ALL DONE (2026-08-07, branch `fix/group-pre-feature-defects`)

> N1, N2 and N3 below all shipped on that branch, each mutation-checked; the §4
> nested-group cost gap was folded into N1 as planned. The section is kept for
> the reasoning record — do not redo any of it. Details in `docs/changelog.md`
> (entries of 2026-08-07). **Next up: §3's queue, starting with I17 / I14 / I8.**

These are not new features. They are **defects in what already shipped**, and later items depend
on them. Do them as one small PR before starting any Wave 2 feature.
Expand Down Expand Up @@ -171,9 +177,8 @@ Recorded so they are not rediscovered. Fix opportunistically, or fold into a rel
- **`decision_reached` never fires.** I3 writes `DecisionRecord`, but no producer calls the sink
event, so verdicts reach REST/MCP and never SSE or Slack.
`SlackGroupDiscussionListener.onDecisionReached` is dead code today. *(Natural fold-in: I14.)*
- **Nested-group cost is overwritten, not accumulated.** `accumulateNestedGroupCost` keys by
`agentId`, and each nested turn is a fresh child discussion starting at 0, so only the last
child's spend survives. *(Natural fold-in: N1.)*
- ~~**Nested-group cost is overwritten, not accumulated.**~~ ✅ Fixed with N1 (2026-08-07):
attribution is keyed per child discussion (`agentId:childId`), so children sum.
- **`allowAbstention` on a SYNTHESIS phase** yields a `COMPLETED` discussion with no answer.
- **Parallel-phase late entries are lost.** After the batch deadline a member finishing
milliseconds late has its real entry replaced by `SKIPPED`. **Both obvious fixes were tried
Expand Down
7 changes: 7 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,13 @@
<artifactId>jsonschema-module-jackson</artifactId>
<version>4.38.0</version>
</dependency>
<!-- JSON Schema VALIDATION (victools above only GENERATES schemas) — used
by the declarative shared-artifact validators (I17). -->
<dependency>
<groupId>com.networknt</groupId>
<artifactId>json-schema-validator</artifactId>
<version>1.5.4</version>
</dependency>
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
Expand Down
289 changes: 289 additions & 0 deletions src/main/java/ai/labs/eddi/configs/groups/ArtifactValidators.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
/*
* Copyright EDDI contributors
* SPDX-License-Identifier: Apache-2.0
*/
package ai.labs.eddi.configs.groups;

import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactConfig;
import ai.labs.eddi.configs.groups.model.AgentGroupConfiguration.ArtifactValidator;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.networknt.schema.JsonSchema;
import com.networknt.schema.JsonSchemaFactory;
import com.networknt.schema.SchemaId;
import com.networknt.schema.SchemaLocation;
import com.networknt.schema.SpecVersion;
import com.networknt.schema.ValidationMessage;
import org.jboss.logging.Logger;

import java.util.List;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
import java.util.stream.Collectors;

/**
* The declarative artifact validation chain (I17) — JSON Schema, regex and
* length checks a group config can gate artifact writes behind. Declarative
* <em>only</em>: there is deliberately no way to configure code execution here.
* <p>
* Two entry points, one per failure audience: {@link #requireValidSpecs} runs
* at config save time and throws so a typo'd spec fails the save (the author's
* problem, at the moment they can fix it); {@link #firstRejection} runs at
* write time and returns a rejection <em>sentence</em> for the model (the write
* is refused, nothing is stored).
*
* @author ginccc
*/
public final class ArtifactValidators {

/** Bound on schema violation messages quoted back to the model. */
private static final int MAX_QUOTED_VIOLATIONS = 3;

/**
* Validation-only mapper: parses candidate content and schema documents, never
* persists anything.
*/
private static final ObjectMapper MAPPER = new ObjectMapper();

private static final JsonSchemaFactory SCHEMA_FACTORY = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012);

/**
* The 2020-12 meta-schema, resolved from the library's bundled copy (no network
* I/O). {@code getSchema(spec)} alone only parses — it accepts schemas with
* invalid keyword values — so save-time validation additionally validates the
* spec <em>as an instance</em> against this. Null only if the bundled resource
* could not load, in which case save-time validation degrades to parse-only
* rather than rejecting every config.
*/
private static final JsonSchema META_SCHEMA;

static {
JsonSchema metaSchema = null;
try {
metaSchema = SCHEMA_FACTORY.getSchema(SchemaLocation.of(SchemaId.V202012));
} catch (Exception e) {
Logger.getLogger(ArtifactValidators.class)
.warnf("2020-12 meta-schema unavailable; schema specs are checked by parse only: %s", e.getMessage());
}
META_SCHEMA = metaSchema;
}

/**
* Wall-clock budget for one config-authored regex against one artifact's
* content. A catastrophically backtracking pattern must abort instead of
* pinning the member turn for its full timeout.
*/
private static final long REGEX_DEADLINE_NANOS = TimeUnit.MILLISECONDS.toNanos(500);

private ArtifactValidators() {
}

/**
* Save-time spec check: every validator must carry a kind and a spec its kind
* can actually use. Throws {@link IllegalArgumentException} (the same contract
* as {@code HitlConfigValidation}) so the config save fails with an actionable
* message rather than every future artifact write failing at runtime.
*/
public static void requireValidSpecs(ArtifactConfig config) {
if (config == null || config.validators().isEmpty()) {
return;
}
List<ArtifactValidator> validators = config.validators();
for (int i = 0; i < validators.size(); i++) {
ArtifactValidator validator = validators.get(i);
String path = "artifactConfig.validators[" + i + "]";
if (validator == null || validator.kind() == null) {
throw new IllegalArgumentException(path + " must name a kind (JSON_SCHEMA, REGEX or MAX_LENGTH)");
}
String spec = validator.spec();
if (spec == null || spec.isBlank()) {
throw new IllegalArgumentException(path + " (" + validator.kind() + ") needs a spec");
}
switch (validator.kind()) {
case JSON_SCHEMA -> {
String problem = schemaSpecProblem(spec);
if (problem != null) {
throw new IllegalArgumentException(path + " is not a valid JSON schema: " + problem);
}
}
case REGEX -> {
try {
Pattern.compile(spec);
} catch (PatternSyntaxException e) {
throw new IllegalArgumentException(path + " is not a valid regex: " + e.getMessage());
}
}
case MAX_LENGTH -> {
int max;
try {
max = Integer.parseInt(spec.trim());
} catch (NumberFormatException e) {
throw new IllegalArgumentException(path + " (MAX_LENGTH) spec must be a positive integer, not '" + spec + "'");
}
if (max <= 0) {
throw new IllegalArgumentException(path + " (MAX_LENGTH) must be > 0");
}
}
}
}
}

/**
* Write-time gate: runs the chain in config order and returns the first failure
* as a rejection sentence for the model, or {@code null} when every validator
* passes. A broken spec that slipped past save-time validation (hand-edited
* storage) rejects the write rather than admitting it — the gate fails closed.
*/
public static String firstRejection(List<ArtifactValidator> validators, String content) {
if (validators == null || validators.isEmpty()) {
return null;
}
String candidate = content != null ? content : "";
for (ArtifactValidator validator : validators) {
if (validator == null || validator.kind() == null || validator.spec() == null) {
return "This discussion's artifact validation is misconfigured; the write was refused.";
}
String rejection = switch (validator.kind()) {
case MAX_LENGTH -> checkMaxLength(validator.spec(), candidate);
case REGEX -> checkRegex(validator.spec(), candidate);
case JSON_SCHEMA -> checkJsonSchema(validator.spec(), candidate);
};
if (rejection != null) {
return rejection;
}
}
return null;
}

private static String checkMaxLength(String spec, String content) {
int max;
try {
max = Integer.parseInt(spec.trim());
} catch (NumberFormatException e) {
return "This discussion's artifact length validator is misconfigured; the write was refused.";
}
if (content.length() > max) {
return "The content is %d characters, over this discussion's %d-character limit for artifacts. Shorten it."
.formatted(content.length(), max);
}
return null;
}

private static String checkRegex(String spec, String content) {
try {
if (!Pattern.compile(spec, Pattern.DOTALL).matcher(deadlineGuarded(content)).find()) {
return "The content does not match this discussion's required pattern for artifacts. Expected to find a match of: " + spec;
}
} catch (PatternSyntaxException e) {
return "This discussion's artifact pattern validator is misconfigured; the write was refused.";
} catch (MatchDeadlineExceededException e) {
return "This discussion's artifact pattern validator did not finish in time; the write was refused.";
}
return null;
}

/**
* Save-time schema check, two layers: the spec must parse, and it must itself
* satisfy the 2020-12 meta-schema — {@code getSchema(spec)} alone accepts e.g.
* {@code {"type": "strng"}} and only misbehaves at write time. Returns the
* problem, or {@code null} for a valid spec.
*/
private static String schemaSpecProblem(String spec) {
JsonNode schemaNode;
try {
schemaNode = MAPPER.readTree(spec);
} catch (Exception e) {
return e.getMessage();
}
if (META_SCHEMA != null) {
Set<ValidationMessage> violations = META_SCHEMA.validate(schemaNode);
if (!violations.isEmpty()) {
return violations.stream()
.limit(MAX_QUOTED_VIOLATIONS)
.map(ValidationMessage::getMessage)
.collect(Collectors.joining("; "));
}
}
try {
SCHEMA_FACTORY.getSchema(spec);
} catch (Exception e) {
return e.getMessage();
}
return null;
}

/**
* Thrown by {@link #deadlineGuarded}'s wrapper when the match budget is spent.
*/
private static final class MatchDeadlineExceededException extends RuntimeException {
MatchDeadlineExceededException() {
super("regex match exceeded its time budget", null, false, false);
}
}

/**
* Wraps content so a regex match aborts once {@link #REGEX_DEADLINE_NANOS} is
* spent. Backtracking re-reads characters, so the deadline check in
* {@code charAt} (sampled, to keep the fast path cheap) is hit constantly by
* exactly the pathological patterns it exists to stop. Single-matcher use only
* — the sampling counter is not thread-safe, matching a Matcher's own contract.
*/
private static CharSequence deadlineGuarded(String content) {
long deadline = System.nanoTime() + REGEX_DEADLINE_NANOS;
return new CharSequence() {
private int accesses;

@Override
public int length() {
return content.length();
}

@Override
public char charAt(int index) {
if ((++accesses & 0x3FF) == 0 && System.nanoTime() > deadline) {
throw new MatchDeadlineExceededException();
}
return content.charAt(index);
}

@Override
public CharSequence subSequence(int start, int end) {
return content.subSequence(start, end);
}

@Override
public String toString() {
return content;
}
};
}

private static String checkJsonSchema(String spec, String content) {
JsonSchema schema;
try {
schema = SCHEMA_FACTORY.getSchema(spec);
} catch (Exception e) {
return "This discussion's artifact schema validator is misconfigured; the write was refused.";
}
JsonNode node;
try {
node = MAPPER.readTree(content);
} catch (Exception e) {
return "The content must be valid JSON to pass this discussion's artifact schema, and it is not. Fix the JSON and retry.";
}
Set<ValidationMessage> violations = schema.validate(node);
if (!violations.isEmpty()) {
String quoted = violations.stream()
.limit(MAX_QUOTED_VIOLATIONS)
.map(ValidationMessage::getMessage)
.collect(Collectors.joining("; "));
String suffix = violations.size() > MAX_QUOTED_VIOLATIONS
? " (and " + (violations.size() - MAX_QUOTED_VIOLATIONS) + " more)"
: "";
return "The content does not satisfy this discussion's artifact schema: " + quoted + suffix + ". Fix it and retry.";
}
return null;
}
}
Loading
Loading