From 9d4307d34373e47244a8e5b0fde3e2a2d1c9d3eb Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Sun, 5 Apr 2026 11:50:19 +0200 Subject: [PATCH 1/4] ifcchat: Throttling of messages based on estimated token counts --- src/ifcchat/app.js | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index 203d25798a..bcb28ecf57 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -499,6 +499,9 @@ let messages = []; // running conversation state (Chat Completions style) const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; +const ESTIMATED_CHARS_PER_TOKEN = 4; +const MAX_ESTIMATED_TOKENS_PER_MINUTE = 20000; +const minuteTokenMap = new Map(); function truncateToolResult(text) { if (MAX_TOOL_RESULT_CHARS == 0 || text.length <= MAX_TOOL_RESULT_CHARS) return text; @@ -518,6 +521,16 @@ function trimHistory() { } } +function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) { + return Array.from(minuteTokenMap.entries()) + .filter(([minuteBucket]) => minuteBucket >= firstIterationMinuteBucket) + .sort(([leftMinuteBucket], [rightMinuteBucket]) => leftMinuteBucket - rightMinuteBucket) + .map(([minuteBucket, estimatedTokens]) => ({ + timestamp: new Date(minuteBucket * 60000).toISOString(), + estimated_tokens: estimatedTokens, + })); +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -525,16 +538,33 @@ async function runAgentTurn(userText) { const provider = PROVIDERS[getProviderValue()]; const { chat } = provider.api; const baseURL = provider.baseUrlDefault ? baseUrlEl.value.trim() : undefined; + let firstIterationMinuteBucket = null; messages.push({ role: "user", content: userText }); trimHistory(); for (let i = 0; i < 64; i++) { + const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]; + const estimatedTokens = Math.max(1, Math.ceil(JSON.stringify(messages_with_system).length / ESTIMATED_CHARS_PER_TOKEN)); + const now = Date.now(); + let currentMinuteBucket = Math.floor(now / 60000); + if (firstIterationMinuteBucket === null) { + firstIterationMinuteBucket = currentMinuteBucket; + } + const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; + + if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { + currentMinuteBucket += 1; + await new Promise((resolve) => setTimeout(() => resolve(), 60000)); + } + + minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); + const response = await chat({ apiKey, baseURL, model: modelEl.value, - messages: [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages], + messages: messages_with_system, tools, }); @@ -546,7 +576,10 @@ async function runAgentTurn(userText) { if (message.content) addMessage("assistant", message.content); const calls = message.tool_calls ?? []; - if (calls.length === 0) return; + if (calls.length === 0) { + console.log("Estimated token usage by minute", getEstimatedTokenMinuteLog(firstIterationMinuteBucket)); + return; + } for (const call of calls) { let args = {}; From a751c1cce3d3b497abc0b1e756c28f230a9a2aae Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Tue, 7 Apr 2026 09:46:45 +0200 Subject: [PATCH 2/4] ifcchat: compaction --- src/ifcchat/app.js | 122 +++++++++++++++++++++++++++++++++++------ src/ifcchat/index.html | 4 ++ 2 files changed, 108 insertions(+), 18 deletions(-) diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js index bcb28ecf57..cf8723f46f 100644 --- a/src/ifcchat/app.js +++ b/src/ifcchat/app.js @@ -139,6 +139,7 @@ const baseUrlRowEl = $("baseUrlRow"); const baseUrlLabelEl = $("baseUrlLabel"); const baseUrlEl = $("baseUrl"); const thinkingIndicatorEl = $("thinkingIndicator"); +const compactingIndicatorEl = $("compactingIndicator"); const modelEl = $("model"); const providerEls = document.querySelectorAll('input[name="provider"]'); const ifcFileEl = $("ifcFile"); @@ -395,6 +396,7 @@ function addMessage(role, text) { function setStatus(text) { statusEl.textContent = text; thinkingIndicatorEl.hidden = text !== "Thinking…"; + compactingIndicatorEl.hidden = text !== "Compacting…"; msgsEl.scrollTop = msgsEl.scrollHeight; } @@ -491,6 +493,7 @@ Rules: - If the user asks about model contents (counts, lists, properties, hierarchy), use tools like ifc_summary/ifc_select/ifc_info/ifc_tree. - If the user asks to change the model, prefer: (1) ifc_list to find candidate API modules, (2) ifc_docs for the exact function signature, then (3) ifc_edit. - If there is no model and the user wants to create one, call ifc_new. +- In case of type errors on api functions, retry providing values as strings (for example in the case of the matrix in geometry.edit_object_placement). - After edits, explain what changed and suggest downloading the IFC. Be concise. Avoid dumping huge trees unless asked. `; @@ -500,7 +503,9 @@ let messages = []; // running conversation state (Chat Completions style) const MAX_TOOL_RESULT_CHARS = 0; const MAX_HISTORY_MESSAGES = 40; const ESTIMATED_CHARS_PER_TOKEN = 4; -const MAX_ESTIMATED_TOKENS_PER_MINUTE = 20000; +const MAX_ESTIMATED_TOKENS_PER_MINUTE = 24000; +const COMPACT_WHEN_ESTIMATED_TOKENS = 18000; +const KEEP_RAW_TURN_GROUPS = 1; const minuteTokenMap = new Map(); function truncateToolResult(text) { @@ -531,6 +536,93 @@ function getEstimatedTokenMinuteLog(firstIterationMinuteBucket) { })); } +async function chatWithMinuteDelay({ chat, apiKey, baseURL, model, messages, tools }) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify({ model, messages, ...(tools ? { tools } : {}) }).length / ESTIMATED_CHARS_PER_TOKEN) + ); + let currentMinuteBucket = Math.floor(Date.now() / 60000); + const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; + + if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { + currentMinuteBucket += 1; + await new Promise((resolve) => setTimeout(() => resolve(), 60000)); + } + + minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); + + return { + minuteBucket: currentMinuteBucket, + response: await chat({ apiKey, baseURL, model, messages, tools }), + }; +} + +async function compactHistoryWithLLM(chat, apiKey, baseURL, model) { + const estimatedTokens = Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ); + if (messages.length <= MAX_HISTORY_MESSAGES && estimatedTokens <= COMPACT_WHEN_ESTIMATED_TOKENS) return null; + + const { prefix, groups } = messages.reduce((acc, message) => { + if (message.role === "user") { + acc.groups.push([message]); + } else if (acc.groups.length) { + acc.groups[acc.groups.length - 1].push(message); + } else { + acc.prefix.push(message); + } + return acc; + }, { prefix: [], groups: [] }); + + if (groups.length <= KEEP_RAW_TURN_GROUPS) return null; + + const compacted = [...prefix, ...groups.slice(0, -KEEP_RAW_TURN_GROUPS).flat()]; + if (!compacted.length) return null; + + setStatus("Compacting…"); + try { + const before = { + message_count: messages.length, + turn_group_count: groups.length, + estimated_tokens: estimatedTokens, + }; + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, + apiKey, + baseURL, + model, + messages: [ + { + role: "system", + content: "Summarize older IFC chat context for continuation. Preserve user goals, model state and schema, edits already applied, important ids, names, selectors, and unresolved questions. Be concise, factual, and use short markdown bullets. Do not mention that this is a summary." + }, + { role: "user", content: JSON.stringify(compacted) }, + ], + }); + const summary = response.choices?.[0]?.message?.content?.trim(); + + if (!summary) return minuteBucket; + + messages = [ + { role: "assistant", content: `[Context summary]\n${summary}` }, + ...groups.slice(-KEEP_RAW_TURN_GROUPS).flat(), + ]; + console.log("History compaction before", before); + console.log("History compaction after", { + message_count: messages.length, + turn_group_count: messages.filter((message) => message.role === "user").length, + estimated_tokens: Math.max( + 1, + Math.ceil(JSON.stringify([{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]).length / ESTIMATED_CHARS_PER_TOKEN) + ), + }); + return minuteBucket; + } finally { + setStatus("Thinking…"); + } +} + async function runAgentTurn(userText) { const apiKey = apiKeyEl.value.trim(); if (!apiKey) throw new Error("Missing API key"); @@ -541,32 +633,26 @@ async function runAgentTurn(userText) { let firstIterationMinuteBucket = null; messages.push({ role: "user", content: userText }); - trimHistory(); for (let i = 0; i < 64; i++) { + const compactedMinuteBucket = await compactHistoryWithLLM(chat, apiKey, baseURL, modelEl.value); + if (firstIterationMinuteBucket === null && compactedMinuteBucket !== null) { + firstIterationMinuteBucket = compactedMinuteBucket; + } + if (messages.length > MAX_HISTORY_MESSAGES * 2) trimHistory(); + const messages_with_system = [{ role: "system", content: SYSTEM_INSTRUCTIONS }, ...messages]; - const estimatedTokens = Math.max(1, Math.ceil(JSON.stringify(messages_with_system).length / ESTIMATED_CHARS_PER_TOKEN)); - const now = Date.now(); - let currentMinuteBucket = Math.floor(now / 60000); - if (firstIterationMinuteBucket === null) { - firstIterationMinuteBucket = currentMinuteBucket; - } - const estimateTokenUsage = (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens; - - if (estimateTokenUsage > MAX_ESTIMATED_TOKENS_PER_MINUTE) { - currentMinuteBucket += 1; - await new Promise((resolve) => setTimeout(() => resolve(), 60000)); - } - - minuteTokenMap.set(currentMinuteBucket, (minuteTokenMap.get(currentMinuteBucket) ?? 0) + estimatedTokens); - - const response = await chat({ + const { minuteBucket, response } = await chatWithMinuteDelay({ + chat, apiKey, baseURL, model: modelEl.value, messages: messages_with_system, tools, }); + if (firstIterationMinuteBucket === null) { + firstIterationMinuteBucket = minuteBucket; + } const message = response.choices?.[0]?.message; if (!message) throw new Error("No message in response"); diff --git a/src/ifcchat/index.html b/src/ifcchat/index.html index daf6146126..7ccb678591 100644 --- a/src/ifcchat/index.html +++ b/src/ifcchat/index.html @@ -111,6 +111,10 @@ thinking... +
From 27d9cae8ff534922dc367b78970711bdbba6bd37 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 7 Apr 2026 17:59:33 +0100 Subject: [PATCH 3/4] Bonsai, bump ifcmerge.exe to working version with deps Don't leave a broken repo if ifcmerge is misinstalled. Fix bug where only local branches could be merged. Fix gitch where merge commits were not considered relevant. --- src/bonsai/Makefile | 2 +- src/bonsai/bonsai/core/ifcgit.py | 7 +++++-- src/bonsai/bonsai/tool/ifcgit.py | 24 +++++++++++++++++------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/bonsai/Makefile b/src/bonsai/Makefile index b517bc572b..bd7bbbf597 100644 --- a/src/bonsai/Makefile +++ b/src/bonsai/Makefile @@ -64,7 +64,7 @@ PYNUMBER:=3$(PYMINOR) PYPI_VERSION:=3.$(PYMINOR) endif # def PYVERSION -IFCMERGE_VERSION:=2026-04-02 +IFCMERGE_VERSION:=2026-04-07 ifdef PLATFORM SUPPORTED_PLATFORMS := linux macos macosm1 win diff --git a/src/bonsai/bonsai/core/ifcgit.py b/src/bonsai/bonsai/core/ifcgit.py index 289337908a..875a37f8c0 100644 --- a/src/bonsai/bonsai/core/ifcgit.py +++ b/src/bonsai/bonsai/core/ifcgit.py @@ -151,8 +151,11 @@ def merge_branch(ifcgit: type[tool.IfcGit], ifc: type[tool.Ifc], operator: bpy.t conflicts = ifcgit.git_mergetool(mergetool, path_ifc) if conflicts is not None: ifcgit.git_merge_abort() - ifcgit.store_merge_conflicts(conflicts) - operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") + if conflicts: + ifcgit.store_merge_conflicts(conflicts) + operator.report({"WARNING"}, "Merge failed — see the conflict report in the panel below") + else: + operator.report({"ERROR"}, "Merge tool failed — check that ifcmerge is installed correctly") return False ifcgit.commit_merge(path_ifc) diff --git a/src/bonsai/bonsai/tool/ifcgit.py b/src/bonsai/bonsai/tool/ifcgit.py index ae7176eb6f..9c7199e839 100644 --- a/src/bonsai/bonsai/tool/ifcgit.py +++ b/src/bonsai/bonsai/tool/ifcgit.py @@ -217,7 +217,7 @@ class IfcGit: rev=[props.display_branch], ) ) - commits_relevant = list( + commits_relevant = set( git.objects.commit.Commit.iter_items( repo=repo, rev=[props.display_branch], @@ -225,11 +225,17 @@ class IfcGit: ) ) + def is_relevant(commit): + if commit in commits_relevant: + return True + # Merge commits are relevant too + return len(commit.parents) > 1 and any(p in commits_relevant for p in commit.parents) + for commit in commits: if props.ifcgit_filter == "tagged" and commit.hexsha not in lookup: continue - elif props.ifcgit_filter == "relevant" and commit not in commits_relevant: + elif props.ifcgit_filter == "relevant" and not is_relevant(commit): continue props.ifcgit_commits.add() @@ -239,7 +245,7 @@ class IfcGit: list_item.author_name = commit.author.name list_item.author_email = commit.author.email list_item.committed_date = int(commit.committed_date) - if commit in commits_relevant: + if is_relevant(commit): list_item.relevant = True if commit.hexsha in lookup: for tag in lookup[commit.hexsha]: @@ -589,7 +595,7 @@ class IfcGit: """Attempt a git merge. Returns None on clean merge, 'conflict' on expected GitCommandError, or 'error' on an unknown GitError.""" repo = IfcGitRepo.repo - branch = repo.branches[branch_name] + branch = repo.refs[branch_name] try: repo.git.merge(branch) return None @@ -603,7 +609,7 @@ class IfcGit: """Attempt a git merge without committing (always leaves a merge state to abort). Returns None on clean merge, 'conflict' on conflict, or 'error' on unknown failure.""" repo = IfcGitRepo.repo - branch = repo.branches[branch_name] + branch = repo.refs[branch_name] try: repo.git.merge(branch, no_commit=True, no_ff=True) return None @@ -619,8 +625,8 @@ class IfcGit: report_path = path_ifc + ".ifcmerge" try: repo.git.mergetool(tool=mergetool) - except git.exc.GitCommandError: - pass + except git.exc.GitCommandError as e: + print(f"ifcgit: mergetool failed: {e}") conflicts = None if os.path.exists(report_path): @@ -636,6 +642,10 @@ class IfcGit: os.remove(report_path) except OSError: pass + + if conflicts is None and repo.index.unmerged_blobs(): + conflicts = [] + return conflicts @classmethod From 26a1955cbab622eceda9f1bab33a1ef0e8098cb5 Mon Sep 17 00:00:00 2001 From: Bruno Postle Date: Tue, 7 Apr 2026 23:34:30 +0100 Subject: [PATCH 4/4] Fix compilation failure introduced in 24acfea --- src/svgfill/src/arrange_polygons.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/svgfill/src/arrange_polygons.cpp b/src/svgfill/src/arrange_polygons.cpp index 3ce49011f7..0d5cfd96e3 100644 --- a/src/svgfill/src/arrange_polygons.cpp +++ b/src/svgfill/src/arrange_polygons.cpp @@ -618,7 +618,7 @@ void eliminate_overlaps(DebugWriter& debug_writer, double OVERLAP_RESOLUTION_DIS std::cerr << "processing: " << edge.first << " " << edge.second << std::endl; std::cerr << "area before: " << poly1->area() << " " << poly2->area() << std::endl; - bool is_ = edge == std::make_pair(25, 27); + bool is_ = edge == std::make_pair(25, 27); bool success = false; if ((mp1 = maybe_take_first_if_single_item(create_and_convert_offset_polygon(OVERLAP_RESOLUTION_DISTANCE, *poly2)))) { @@ -1550,7 +1550,7 @@ std::vector arrangement_cell_iou(Arrangement_2& left, Arrangement_2& righ } auto res = walk_pl.locate(best_point); - if (auto* v = boost::get(&res)) { + if (auto* v = variant_get(&res)) { if (visited_faces_on_right.count(*v) > 0) { return_values.push_back(0); } else {