Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ tests/data/crypto/out*
*.ox
*.Plo
*.so
/.solers
# Binutils tmp linker output of the form "stXXXXXX" where "X" is alphanumeric
st[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9]

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
| **Solers AI** | Agent loop, context, tools, providers, permissions, editor UI, and MCP-compatible surface. | [`modules/solers_ai/`](modules/solers_ai/) |
| **Engine** | The Godot 4.7.1 editor and runtime maintained as the Solers fork. | [`/`](.) |
| **Architecture** | Runtime boundaries, native write contracts, and development guidance. | [`docs/SOLERS_ARCHITECTURE.md`](docs/SOLERS_ARCHITECTURE.md) |
| **AI-assisted design** | Human/AI workflow, design theory, verification, safety, and delivery roadmap. | [`docs/AI_ASSISTED_GAME_DESIGN.md`](docs/AI_ASSISTED_GAME_DESIGN.md) |
| **Upstream** | Deterministic protocol for tracking and validating new Godot releases. | [`docs/UPSTREAM.md`](docs/UPSTREAM.md) |
| **Tests** | Unit contracts and real editor behavior projects for Solers. | [`modules/solers_ai/tests/`](modules/solers_ai/tests/) |

Expand Down
399 changes: 399 additions & 0 deletions docs/AI_ASSISTED_GAME_DESIGN.md

Large diffs are not rendered by default.

164 changes: 150 additions & 14 deletions modules/solers_ai/core/solers_agent_session.cpp

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions modules/solers_ai/core/solers_agent_session.h
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ class SolersAgentSession : public Object {
int cached_request_tool_tokens = 0;
uint32_t cached_request_deferred_count = 0;
uint64_t cached_tool_catalog_revision = 0;
bool cached_request_image_input_enabled = false;
Array turn_attachments;
HashSet<String> delivered_model_attachments;
HashSet<String> pending_model_attachments;
Expand Down Expand Up @@ -217,6 +218,7 @@ class SolersAgentSession : public Object {
Array _collect_tools();
bool _refresh_active_model_limits();
int _active_model_input_support(const String &p_modality) const;
bool _image_input_enabled() const;
Dictionary _build_request(const Array &p_messages, const String &p_request_system_prompt, const Array &p_tools) const;
Dictionary _provider_dispatch_error() const;
Error _begin_provider_request(const Dictionary &p_request, const Dictionary &p_profile);
Expand Down Expand Up @@ -280,6 +282,12 @@ class SolersAgentSession : public Object {
// conversation after the current tool batch, before the next model
// dispatch. Fails with AGENT_IDLE when no turn is running.
Dictionary queue_user_message(const Dictionary &p_args);
// Preserve the original audit log and create a new conversation whose
// history ends immediately before the selected user message.
Dictionary branch_from_event(int64_t p_event_id);
// Atomically restore reversible project mutations after the selected
// message's revision, then create the same conversation branch.
Dictionary rewind_to_event(int64_t p_event_id);
void poll();
void shutdown();
void abort();
Expand Down
24 changes: 20 additions & 4 deletions modules/solers_ai/core/solers_settings_service.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
#include "modules/solers_ai/core/solers_secret_store.h"
#include "modules/solers_ai/llm/solers_models_dev.h"

static constexpr int SOLERS_PROVIDER_SETTINGS_VERSION = 6;
static constexpr int SOLERS_PROVIDER_SETTINGS_VERSION = 7;

void SolersSettingsService::_bind_methods() {
ClassDB::bind_method(D_METHOD("set_provider_registry", "provider_registry"), &SolersSettingsService::set_provider_registry);
Expand Down Expand Up @@ -108,7 +108,7 @@ void SolersSettingsService::_migrate_provider_settings() {
provider = "custom_openai_compatible";
}
if (version >= 2 && !previous_provider.is_empty() && previous_provider != provider) {
static const char *PROVIDER_KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth" };
static const char *PROVIDER_KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth", "send_session_id_header", "image_input_mode" };
for (const char *key : PROVIDER_KEYS) {
const String old_path = _provider_setting_path(previous_provider, key);
if (settings->has_setting(old_path)) {
Expand Down Expand Up @@ -152,7 +152,7 @@ void SolersSettingsService::_migrate_provider_settings() {
// Single alias authority: SolersModelsDev::canonical_provider_id.
SolersModelsDev *md = provider_registry ? provider_registry->get_models_dev() : nullptr;
if (md) {
static const char *PROVIDER_KEYS[] = { "configured", "model", "base_url", "api_key", "oauth", "reasoning_effort" };
static const char *PROVIDER_KEYS[] = { "configured", "model", "base_url", "api_key", "oauth", "reasoning_effort", "send_session_id_header", "image_input_mode" };
for (const Variant &from_v : md->list_legacy_provider_ids()) {
const String from = from_v;
const String to = md->canonical_provider_id(from);
Expand Down Expand Up @@ -251,6 +251,8 @@ Dictionary SolersSettingsService::_get_provider_config(const String &p_provider,
data["configured"] = settings->has_setting(_provider_setting_path(p_provider, "configured")) && (bool)settings->get_setting(_provider_setting_path(p_provider, "configured"));
data["model"] = settings->has_setting(_provider_setting_path(p_provider, "model")) ? String(settings->get_setting(_provider_setting_path(p_provider, "model"))) : String(profile.get("default_model", String()));
data["reasoning_effort"] = settings->has_setting(_provider_setting_path(p_provider, "reasoning_effort")) ? String(settings->get_setting(_provider_setting_path(p_provider, "reasoning_effort"))) : String();
data["send_session_id_header"] = !settings->has_setting(_provider_setting_path(p_provider, "send_session_id_header")) || (bool)settings->get_setting(_provider_setting_path(p_provider, "send_session_id_header"));
data["image_input_mode"] = settings->has_setting(_provider_setting_path(p_provider, "image_input_mode")) ? String(settings->get_setting(_provider_setting_path(p_provider, "image_input_mode"))) : String("auto");
data["base_url"] = settings->has_setting(_provider_setting_path(p_provider, "base_url")) ? String(settings->get_setting(_provider_setting_path(p_provider, "base_url"))) : String(profile.get("default_base_url", String()));

// Credential PRESENCE only: a stored blob / env var existing is the
Expand Down Expand Up @@ -362,6 +364,13 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args)
if (!provider_registry) {
return _error("PROVIDER_REGISTRY_UNAVAILABLE", "Solers provider registry is not initialized.", false);
}
if (p_args.has("image_input_mode")) {
const String mode = String(p_args["image_input_mode"]).strip_edges().to_lower();
if (mode != "auto" && mode != "enabled" && mode != "disabled") {
return _error("INVALID_IMAGE_INPUT_MODE", "Image input mode must be auto, enabled, or disabled.");
}
}
// Catalog / AuthHook / custom OpenAI-compatible — get_provider_profile always resolves.
if (p_args.has("model") && !provider_registry->is_model_allowed(provider, String(p_args["model"]))) {
return _error("MODEL_NOT_ALLOWED", "The selected model is not available through this provider connection.");
}
Expand Down Expand Up @@ -393,6 +402,13 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args)
if (p_args.has("base_url")) {
settings->set_manually(_provider_setting_path(provider, "base_url"), String(p_args["base_url"]));
}
if (p_args.has("send_session_id_header")) {
settings->set_manually(_provider_setting_path(provider, "send_session_id_header"), (bool)p_args["send_session_id_header"]);
}
if (p_args.has("image_input_mode")) {
const String mode = String(p_args["image_input_mode"]).strip_edges().to_lower();
settings->set_manually(_provider_setting_path(provider, "image_input_mode"), mode);
}
if (p_args.has("api_key") && !String(p_args["api_key"]).is_empty()) {
settings->set_manually(_provider_setting_path(provider, "api_key"), SolersSecretStore::protect(String(p_args["api_key"])));
}
Expand All @@ -411,7 +427,7 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args)
Dictionary SolersSettingsService::disconnect_provider(const String &p_provider) {
EditorSettings *settings = EditorSettings::get_singleton();
ERR_FAIL_NULL_V(settings, _error("EDITOR_SETTINGS_UNAVAILABLE", "EditorSettings is not available.", false));
static const char *KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth" };
static const char *KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth", "send_session_id_header", "image_input_mode" };
for (const char *key : KEYS) {
const String path = _provider_setting_path(p_provider, key);
if (settings->has_setting(path)) {
Expand Down
126 changes: 101 additions & 25 deletions modules/solers_ai/core/solers_tool_registry.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1109,32 +1109,28 @@ Dictionary SolersToolRegistry::_finalize_prepared_result(SolersPreparedToolCall
mutation["receipt"] = receipt;
const String session_key = r_call.context.session_id.is_empty() ? String("direct") : r_call.context.session_id;
if (r_call.mutation_policy == SolersToolMutationPolicy::EDITOR_UNDO || r_call.mutation_policy == SolersToolMutationPolicy::FILE_CHECKPOINT) {
const String *previous_id = latest_reversal_by_session.getptr(session_key);
if (previous_id) {
const Dictionary *previous = reversals.getptr(*previous_id);
if (previous) {
_discard_reversal(*previous);
}
reversals.erase(*previous_id);
}
const String reversal_id = (session_key + ":" + r_call.context.call_id + ":" + String::num_uint64(r_call.context.authored_revision + 1) + ":" + String::num_int64(reversals.size() + 1)).sha256_text();
record["id"] = reversal_id;
record["session_id"] = session_key;
record["session_revision"] = (int64_t)(r_call.context.authored_revision + 1);
reversals[reversal_id] = record;
reversal_stack_by_session[session_key].push_back(reversal_id);
latest_reversal_by_session[session_key] = reversal_id;
mutation["reversal_id"] = reversal_id;
r_call.journal_event["event_type"] = "checkpoint_created";
r_call.journal_event["checkpoint"] = record;
r_call.journal_event["note"] = "Protective checkpoint for history.revert; not a rollback of your edit.";
} else {
const String *previous_id = latest_reversal_by_session.getptr(session_key);
if (previous_id) {
const Dictionary *previous = reversals.getptr(*previous_id);
if (previous) {
_discard_reversal(*previous);
Vector<String> *stack = reversal_stack_by_session.getptr(session_key);
if (stack) {
for (const String &id : *stack) {
const Dictionary *previous = reversals.getptr(id);
if (previous) {
_discard_reversal(*previous);
}
reversals.erase(id);
}
reversals.erase(*previous_id);
reversal_stack_by_session.erase(session_key);
latest_reversal_by_session.erase(session_key);
r_call.journal_event["event_type"] = "checkpoint_cleared";
}
Expand Down Expand Up @@ -1192,10 +1188,20 @@ Dictionary SolersToolRegistry::_revert_latest(const SolersToolContext &p_context
_discard_reversal(record);

reversals.erase(reversal_id);
latest_reversal_by_session.erase(session_key);
Vector<String> *stack = reversal_stack_by_session.getptr(session_key);
if (stack && !stack->is_empty() && (*stack)[stack->size() - 1] == reversal_id) {
stack->remove_at(stack->size() - 1);
}
if (stack && !stack->is_empty()) {
latest_reversal_by_session[session_key] = (*stack)[stack->size() - 1];
} else {
reversal_stack_by_session.erase(session_key);
latest_reversal_by_session.erase(session_key);
}
Dictionary data;
data["reversal_id"] = reversal_id;
data["reverted_session_revision"] = record.get("session_revision", 0);
data["checkpoint"] = record.duplicate(true);
data["checkpoint_consumed"] = true;
data["authored_state_changed"] = true;
return _ok(data);
Expand Down Expand Up @@ -2574,21 +2580,90 @@ void SolersToolRegistry::clear_task_state(const String &p_session_id) {
}
}

void SolersToolRegistry::restore_session_reversal(const String &p_session_id, const Dictionary &p_record) {
void SolersToolRegistry::restore_session_reversals(const String &p_session_id, const Array &p_records) {
if (p_session_id.is_empty()) {
return;
}
const String *existing_id = latest_reversal_by_session.getptr(p_session_id);
if (existing_id) {
reversals.erase(*existing_id);
latest_reversal_by_session.erase(p_session_id);
Vector<String> *existing = reversal_stack_by_session.getptr(p_session_id);
if (existing) {
for (const String &id : *existing) {
reversals.erase(id);
}
}
reversal_stack_by_session.erase(p_session_id);
latest_reversal_by_session.erase(p_session_id);
for (int i = 0; i < p_records.size(); i++) {
const Dictionary record = p_records[i];
const String reversal_id = record.get("id", String());
if (reversal_id.is_empty() || String(record.get("session_id", String())) != p_session_id) {
continue;
}
reversals[reversal_id] = record.duplicate(true);
reversal_stack_by_session[p_session_id].push_back(reversal_id);
latest_reversal_by_session[p_session_id] = reversal_id;
}
const String reversal_id = p_record.get("id", String());
if (reversal_id.is_empty() || String(p_record.get("session_id", String())) != p_session_id) {
return;
}

Dictionary SolersToolRegistry::rewind_session_to_revision(const String &p_project_path, const String &p_session_id, uint64_t p_revision) {
Vector<String> *stack = reversal_stack_by_session.getptr(p_session_id);
if (!stack) {
return _ok(Dictionary());
}
reversals[reversal_id] = p_record.duplicate(true);
latest_reversal_by_session[p_session_id] = reversal_id;
// Preflight the whole suffix before changing anything. This prevents a
// half-rewind when a user edited a scene or file after an Agent action.
uint64_t expected_scene_version = UINT64_MAX;
bool newest_record = true;
for (int i = stack->size() - 1; i >= 0; i--) {
const Dictionary *record = reversals.getptr((*stack)[i]);
if (!record || (uint64_t)(int64_t)record->get("session_revision", 0) <= p_revision) {
break;
}
const String policy = record->get("policy", String());
if (policy == "editor_undo") {
const int history_id = record->get("history_id", EditorUndoRedoManager::INVALID_HISTORY);
UndoRedo *undo_redo = EditorUndoRedoManager::get_singleton() ? EditorUndoRedoManager::get_singleton()->get_history_undo_redo(history_id) : nullptr;
const uint64_t after = (int64_t)record->get("version_after", 0);
if (!undo_redo || (expected_scene_version == UINT64_MAX ? undo_redo->get_version() : expected_scene_version) != after) {
return _error("REWIND_CONFLICT", "The scene UndoRedo history changed after an Agent edit.");
}
expected_scene_version = (int64_t)record->get("version_before", 0);
} else if (policy == "file_checkpoint" && newest_record) {
const Array checkpoints = record->get("checkpoints", Array());
for (int j = 0; j < checkpoints.size(); j++) {
const Dictionary checkpoint = checkpoints[j];
const String path = checkpoint.get("path", String());
const bool exists = FileAccess::exists(path);
if (exists != (bool)checkpoint.get("exists_after", false) || (exists && FileAccess::get_sha256(path) != String(checkpoint.get("sha256_after", String())))) {
return _error("REWIND_CONFLICT", vformat("File changed after the Agent edit: %s", path));
}
}
}
newest_record = false;
}
int count = 0;
Array consumed_checkpoints;
while ((stack = reversal_stack_by_session.getptr(p_session_id)) && !stack->is_empty()) {
const Dictionary *record = reversals.getptr((*stack)[stack->size() - 1]);
if (!record || (uint64_t)(int64_t)record->get("session_revision", 0) <= p_revision) {
break;
}
SolersToolContext context;
context.project_path = p_project_path;
context.session_id = p_session_id;
context.authored_revision = (int64_t)record->get("session_revision", 1) - 1;
Dictionary args;
args["reversal_id"] = (*stack)[stack->size() - 1];
const Dictionary reverted = _revert_latest(context, args);
if (!(bool)reverted.get("ok", false)) {
return reverted;
}
consumed_checkpoints.push_back(Dictionary(reverted.get("data", Dictionary())).get("checkpoint", Dictionary()));
count++;
}
Dictionary data;
data["reverted_count"] = count;
data["consumed_checkpoints"] = consumed_checkpoints;
return _ok(data);
}

int SolersToolRegistry::get_tool_count() const {
Expand All @@ -2601,5 +2676,6 @@ SolersToolRegistry::~SolersToolRegistry() {
_clear_tools();
reversals.clear();
latest_reversal_by_session.clear();
reversal_stack_by_session.clear();
delivered_addon_contracts.clear();
}
4 changes: 3 additions & 1 deletion modules/solers_ai/core/solers_tool_registry.h
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ class SolersToolRegistry : public Object {
uint64_t tool_catalog_revision = 0;
HashMap<String, Dictionary> reversals;
HashMap<String, String> latest_reversal_by_session;
HashMap<String, Vector<String>> reversal_stack_by_session;
HashSet<String> delivered_addon_contracts;

SolersObservationService *observation_service = nullptr;
Expand Down Expand Up @@ -175,7 +176,8 @@ class SolersToolRegistry : public Object {
Dictionary call_tool(const StringName &p_name, const Dictionary &p_args);
Dictionary call_tool_with_context(const StringName &p_name, const Dictionary &p_args, const SolersToolContext &p_context);
void clear_task_state(const String &p_session_id);
void restore_session_reversal(const String &p_session_id, const Dictionary &p_record);
void restore_session_reversals(const String &p_session_id, const Array &p_records);
Dictionary rewind_session_to_revision(const String &p_project_path, const String &p_session_id, uint64_t p_revision);
int get_tool_count() const;

SolersToolRegistry();
Expand Down
Loading