You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@hop.apache.org by "bamaer (via GitHub)" <gi...@apache.org> on 2026/10/07 11:45:09 UTC
[PR] AI Assistant hardening #8757 (hop)
bamaer opened a new pull request, #8784:
URL: https://github.com/apache/hop/pull/8784
**Please** add a meaningful description for your change here
fixes #8757
------------------------
Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:
- [x] Run `mvn clean install apache-rat:check` to make sure basic checks pass. A more thorough check will be performed on your pull request automatically.
- [x] If you have a group of commits related to the same change, please squash your commits into one and force push your branch using `git rebase -i`.
- [x] Mention the appropriate issue in your description (for example: `addresses #123`), if applicable.
To make clear that you license your contribution under the [Apache License Version 2.0, January 2004](http://www.apache.org/licenses/LICENSE-2.0)
you have to acknowledge this by using the following check-box.
- [x] I hereby declare this contribution to be licensed under the [Apache License Version 2.0, January 2004](http://www.apache.org/licenses/LICENSE-2.0)
- [ ] In any other case, please file an [Apache Individual Contributor License Agreement](https://www.apache.org/licenses/icla.pdf).
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@hop.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] AI Assistant hardening #8757 (hop)
Posted by "bamaer (via GitHub)" <gi...@apache.org>.
bamaer merged PR #8784:
URL: https://github.com/apache/hop/pull/8784
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@hop.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] AI Assistant hardening #8757 (hop)
Posted by "mattcasters (via GitHub)" <gi...@apache.org>.
mattcasters commented on code in PR #8784:
URL: https://github.com/apache/hop/pull/8784#discussion_r4210109109
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java:
##########
@@ -177,43 +222,75 @@ private static AiProposalValidation validateAddPipelineHop(
String fromName = proposal.parameter("fromTransform");
String toName = proposal.parameter("toTransform");
if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
- return blocked(proposal, "fromTransform and toTransform are required");
+ return blocked(
+ proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopEndsRequired"));
}
if (!transformExists(pipelineMeta, fromName, reservedNames)) {
- return blocked(proposal, "From transform not found: " + fromName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(
+ PKG, "PipelineAiProposalValidator.FromTransformNotFound", fromName));
}
if (!transformExists(pipelineMeta, toName, reservedNames)) {
- return blocked(proposal, "To transform not found: " + toName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(PKG, "PipelineAiProposalValidator.ToTransformNotFound", toName));
}
TransformMeta from = pipelineMeta.findTransform(fromName);
TransformMeta to = pipelineMeta.findTransform(toName);
if (fromName.trim().equals(toName.trim())) {
- return blocked(proposal, "Hop cannot connect a transform to itself");
- }
+ return blocked(
+ proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopToItself"));
+ }
+ // A pipeline cannot loop: the reverse hop, already there or proposed earlier, would. Not when
+ // the batch deletes it.
+ if ((from != null
Review Comment:
**[suggestion]** This only rejects a direct reverse hop (`B -> A` against `A -> B`, on the graph or earlier in the batch). A longer cycle such as `A -> B`, `B -> C`, `C -> A` is accepted and applied. Hop pipelines cannot run with any loop, and `addPipelineHop` does not check.
**Suggestion:** After the batch's hop deletes and adds, reject a proposal when `PipelineMeta.hasLoop` would be true (on a copy, or with the hops reserved in this batch). The same-direction key on line 256 also does not trim names, so `"A"` and `" A "` do not match.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java:
##########
@@ -128,7 +483,10 @@ static AiAdvisorRequest toRequest(
request.setAttributes(copyAttributes(session.getAttributes()));
request.setInclusionSelections(copyInclusionSelections(session.getInclusionSelections()));
request.setFollowUp(hasSuccessfulPriorTurn(session));
- request.setAppliedChangeSummaries(session.consumePendingAppliedSummaries());
+ request.setAppliedChangeSummaries(
+ consumeApplied
+ ? session.consumePendingAppliedSummaries()
Review Comment:
**[bug]** `prepare()` consumes `pendingAppliedSummaries` before the prompt-size check and before the model call. `checkPromptFits` then throws `AiUserException` (the message tells the user to uncheck Sharing items and send again), and `AiAdvisorSessionPane.send()` records that error and returns. The queue is already cleared, so the retry no longer includes `<applied_changes>`, which is exactly the context the follow-up needs after Apply. A later failure in `execute()` loses the same list.
**Suggestion:** Keep the summaries until the answer is recorded. Pass a copy into the request and clear the queue only from the success path, or put the list back in both the `prepare()` catch and the background failure path.
##########
plugins/tech/ai/src/main/resources/org/apache/hop/ai/config/messages/messages_en_US.properties:
##########
@@ -17,15 +17,22 @@
HopAiConfig.Tab.Name=AI Assistant
HopAiConfigOptionPlugin.GitWarning.Label=Use Git (or similar version control) and create a feature branch before applying AI-driven changes.\nYou can then revert the whole project, or selected files, if things do not go as intended.
HopAiConfigOptionPlugin.GitWarning.Tooltip=Graph Undo only reverses some canvas edits. Metadata saves and pasted transforms or actions are not undone that way. A feature branch is the reliable rollback.
-HopAiConfigOptionPlugin.AiEnabled.Label=Enable AI advisory
-HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, AI Help is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.
+HopAiConfigOptionPlugin.AiEnabled.Label=Enable the AI Assistant
+HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, the AI Assistant is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.
HopAiConfigOptionPlugin.DefaultProvider.Label=Default AI provider
-HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata used by GUI advisors. Pipelines can still pick a different provider.
+HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata used by the AI Assistant. Pipelines can still pick a different provider.
HopAiConfigOptionPlugin.AllowFullXml.Label=Allow sending full XML
HopAiConfigOptionPlugin.AllowFullXml.Tooltip=Second gate for the session "Include full XML" checkbox. Off by default. Full XML is large and can contain SQL, filenames and notes. Leave this off unless you need it, and only after secrets live in environment variables or a keystore.
HopAiConfigOptionPlugin.ExtraContext.Label=Extra context
HopAiConfigOptionPlugin.ExtraContext.Tooltip=Standing notes sent with every AI Assistant question. The default reminds the model that metadata names are case-sensitive (`local` is not `Local`), that pipeline and workflow run configurations are different types, and that Hop is not Kettle. Add project conventions here.
HopAiConfigOptionPlugin.ExtraContextFiles.Label=Context files
-HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files that exist are appended to the system prompt; missing files are skipped. Default is '${PROJECT_HOME}/AGENTS.md'. Variables are resolved.
+HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files that exist are appended to the system prompt as instructions the assistant follows; missing files are skipped. Variables are resolved. Empty by default. Only list files you wrote for the AI Assistant, for example '${PROJECT_HOME}/ai-assistant.md': an AGENTS.md is usually written for coding agents, and a file from someone else's project can steer the assistant.
Review Comment:
**[nit]** `someone else's` is a single apostrophe, and `BaseMessages` runs the value through `MessageFormat`. That eats the apostrophe (the generated config partial already says "someone elses project").
**Suggestion:** Write `someone else''s` so the tooltip and the docs keep the apostrophe. The `'${PROJECT_HOME}/ai-assistant.md'` quoting on the same line is correct.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java:
##########
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.engine;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.util.ReflectionUtil;
+
+/**
+ * The settings of a transform or action, as compact JSON for a prompt.
+ *
+ * <p>Reads the {@code @HopMetadataProperty} fields: SQL, file names, connection names, field lists
+ * and so on, which is what a model needs to explain what a pipeline or workflow does. Password
+ * fields are skipped. Empty values, long texts and long lists are left out or shortened so one
+ * transform cannot take over the prompt.
+ */
+public final class AiNodeSettings {
+
+ static final int MAX_DEPTH = 3;
+ static final int MAX_LIST_ITEMS = 25;
+ static final int MAX_TEXT_CHARS = 400;
+ static final int MAX_NODE_CHARS = 3_000;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiNodeSettings() {}
+
+ /**
+ * @param node the transform or action meta
+ * @return a JSON object, {@code {"truncated":true}} when the settings are too large, or null when
+ * the plugin exposes no settings this way
+ */
+ public static String toJson(Object node) {
+ if (node == null) {
+ return null;
+ }
+ Object settings = read(node, 0);
+ if (!(settings instanceof Map<?, ?> map) || map.isEmpty()) {
+ return null;
+ }
+ try {
+ String json = MAPPER.writeValueAsString(settings);
+ if (json.length() > MAX_NODE_CHARS) {
+ return "{\"truncated\":true}";
+ }
+ return AiTextUtil.redactSecrets(json);
+ } catch (JsonProcessingException e) {
+ return null;
+ }
+ }
+
+ static Object read(Object value, int depth) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ return text.length() > MAX_TEXT_CHARS ? text.substring(0, MAX_TEXT_CHARS) + "…" : text;
+ }
+ if (value instanceof Number || value instanceof Boolean) {
+ return value;
+ }
+ if (value instanceof Enum<?> e) {
+ return e.name();
+ }
+ if (depth >= MAX_DEPTH) {
+ return null;
+ }
+ if (value instanceof Collection<?> collection) {
+ return readList(collection, depth);
+ }
+ if (value.getClass().isArray() && value instanceof Object[] array) {
+ return readList(List.of(array), depth);
+ }
+ return readObject(value, depth);
+ }
+
+ private static List<Object> readList(Collection<?> collection, int depth) {
+ List<Object> list = new ArrayList<>();
+ int count = 0;
+ for (Object item : collection) {
+ if (count == MAX_LIST_ITEMS) {
+ list.add("… " + (collection.size() - MAX_LIST_ITEMS) + " more");
+ break;
+ }
+ Object read = read(item, depth + 1);
+ if (read != null) {
+ list.add(read);
+ }
+ count++;
+ }
+ return list.isEmpty() ? null : list;
+ }
+
+ private static Map<String, Object> readObject(Object object, int depth) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (Field field : ReflectionUtil.findAllFields(object.getClass())) {
+ HopMetadataProperty property = field.getAnnotation(HopMetadataProperty.class);
+ if (property == null || property.password()) {
+ continue;
+ }
+ Object fieldValue;
+ try {
+ fieldValue =
+ ReflectionUtil.getFieldValue(object, field.getName(), isBoolean(field.getType()));
+ } catch (Exception e) {
+ continue;
+ }
+ Object read = read(fieldValue, depth + 1);
+ if (read == null || Boolean.FALSE.equals(read)) {
Review Comment:
**[suggestion]** Dropping every `false` boolean treats "off" as "not set". Settings whose default is true and that the user turned off (create parent folder, cache, append, and similar) disappear from `pipeline_structure` / `workflow_structure`, so the model explains and edits them as the default.
**Suggestion:** Include `false`. Keep skipping null and empty strings; those are the values that are actually unset.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.session;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.ai.advisor.AiAdvisorMetadataSelection;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.engine.AiMetadataBackup;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+
+/**
+ * Keeps the AI Assistant sessions of a project between Hop GUI runs, in the audit folder ({@code
+ * HOP_AUDIT_FOLDER}), where Hop GUI also remembers the open files of each project.
+ *
+ * <p>Questions, answers, proposals and the session options are kept. The context that was sent with
+ * a question is not: it is gathered again from the open file. The pipeline or workflow itself is
+ * found again by its file name when it is opened.
+ */
+public final class AiAdvisorSessionArchive {
+
+ static final String AUDIT_TYPE = "ai-assistant";
+ static final String AUDIT_NAME = "sessions";
+ static final String DEFAULT_GROUP = "hop-gui";
+
+ /** The newest sessions per project that are kept; older ones are dropped when saving. */
+ static final int MAX_SESSIONS = 50;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiAdvisorSessionArchive() {}
+
+ public static void save(String scope, List<AiAdvisorSession> sessions) throws HopException {
+ List<AiAdvisorSession> kept =
+ sessions.size() > MAX_SESSIONS
+ ? sessions.subList(sessions.size() - MAX_SESSIONS, sessions.size())
+ : sessions;
+ List<Map<String, Object>> list = new ArrayList<>();
+ for (AiAdvisorSession session : kept) {
+ list.add(toMap(session));
+ }
+ Map<String, Object> state = new LinkedHashMap<>();
+ try {
+ state.put("json", MAPPER.writeValueAsString(list));
+ } catch (Exception e) {
+ throw new HopException("Unable to write the AI Assistant sessions", e);
+ }
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new AuditState(AUDIT_NAME, state));
+ }
+
+ public static List<AiAdvisorSession> load(String scope) throws HopException {
+ List<AiAdvisorSession> sessions = new ArrayList<>();
+ AuditState state = AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, AUDIT_NAME);
+ if (state == null || state.getStateMap() == null) {
+ return sessions;
+ }
+ Object json = state.getStateMap().get("json");
+ if (!(json instanceof String text) || Utils.isEmpty(text)) {
+ return sessions;
+ }
+ try {
+ List<Map<String, Object>> list =
+ MAPPER.readValue(text, new TypeReference<List<Map<String, Object>>>() {});
+ for (Map<String, Object> map : list) {
+ AiAdvisorSession session = fromMap(map);
+ session.setScope(scope);
+ sessions.add(session);
+ }
+ } catch (Exception e) {
+ throw new HopException("Unable to read the saved AI Assistant sessions", e);
+ }
+ return sessions;
+ }
+
+ static final String LAST_PROVIDER_NAME = "last-provider";
+
+ /**
+ * Remember the provider picked last in a project, for new sessions when no default provider is
+ * configured. Kept even when conversations are not: it holds only a name.
+ */
+ public static void saveLastProvider(String scope, String providerName) throws HopException {
+ Map<String, Object> state = new LinkedHashMap<>();
+ state.put("name", providerName == null ? "" : providerName);
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new AuditState(LAST_PROVIDER_NAME, state));
+ }
+
+ /** The provider picked last in a project, or null when none was remembered. */
+ public static String loadLastProvider(String scope) throws HopException {
+ AuditState state =
+ AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, LAST_PROVIDER_NAME);
+ if (state == null
+ || state.getStateMap() == null
+ || !(state.getStateMap().get("name") instanceof String name)
+ || Utils.isEmpty(name)) {
+ return null;
+ }
+ return name;
+ }
+
+ static String group(String scope) {
+ return Utils.isEmpty(scope) ? DEFAULT_GROUP : scope;
+ }
+
+ static Map<String, Object> toMap(AiAdvisorSession session) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("title", session.getTitle());
+ map.put("advisorPluginId", session.getAdvisorPluginId());
+ map.put("scenarioId", session.getScenarioId());
+ map.put("providerName", session.getProviderName());
+ map.put("location", session.getLocation());
+ map.put("areaLabel", session.getAreaLabel());
+ map.put("artifactName", session.getArtifactName());
+ map.put("artifactKind", session.getArtifactKind());
+ map.put("artifactFilename", session.getArtifactFilename());
+ map.put("focusNodeName", session.getFocusNodeName());
+ map.put("inclusions", new LinkedHashMap<>(session.getInclusions()));
+ map.put("userChosenInclusions", new ArrayList<>(session.getUserChosenInclusions()));
+ List<Map<String, String>> selections = new ArrayList<>();
+ for (AiAdvisorMetadataSelection selection : session.getMetadataSelections()) {
+ Map<String, String> entry = new LinkedHashMap<>();
+ entry.put("typeKey", selection.getTypeKey());
+ entry.put("name", selection.getName());
+ selections.add(entry);
+ }
+ map.put("metadataSelections", selections);
+ map.put("inclusionSelections", new LinkedHashMap<>(session.getInclusionSelections()));
+ List<Map<String, Object>> turns = new ArrayList<>();
+ for (AiAdvisorTurn turn : session.getTurns()) {
+ turns.add(toMap(turn));
+ }
+ map.put("turns", turns);
+ return map;
Review Comment:
**[suggestion]** `toMap` keeps each turn's `appliedSummaries`, but not `pendingAppliedSummaries`. After Hop GUI restarts, the next question's `<applied_changes>` block is empty even though the user applied proposals and Keep conversations is on. The model only sees the old proposals in history, not that they were applied.
**Suggestion:** Persist the pending list on the session and restore it in `fromMap`, and consume it on the next real question the same way as an in-process follow-up.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java:
##########
@@ -947,58 +1501,101 @@ private void send() {
}
HopAiConfig config = HopAiConfigSingleton.getConfig();
if (!config.isAiEnabled()) {
- wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
return;
}
String prompt = wPrompt.getText();
- if (Utils.isEmpty(prompt)) {
+ if (Utils.isEmpty(prompt) || prompt.isBlank()) {
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.NoQuestion"));
+ wPrompt.setFocus();
return;
}
IAiAdvisor advisor = loadSelectedAdvisor();
if (advisor == null) {
- wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.NoAdvisor"));
+ setStatus(noAdvisorMessage());
+ return;
+ }
+ if (!providerExists(wProvider.getText())) {
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ wProvider.getItemCount() == 0
+ ? "AiAdvisor.Status.NoProviderDefined"
+ : "AiAdvisor.Status.NoProviderSelected"));
return;
}
session.setAdvisorPluginId(selectedAdvisorId());
session.setScenarioId(selectedScenarioId());
session.setProviderName(wProvider.getText());
+ includeLogAfterARun();
for (Map.Entry<String, Button> entry : inclusionButtons.entrySet()) {
- session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+ // A blocked option keeps the user's choice for when the configuration allows it again.
+ if (!isBlockedByConfig(entry.getKey())) {
+ session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+ }
}
AiAdvisorTurn turn = new AiAdvisorTurn();
turn.setUserPrompt(prompt);
+ turn.setStartedAtMillis(System.currentTimeMillis());
session.addTurn(turn);
session.setCancelled(false);
session.setWorking(true);
- session.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.Working"));
+ // The transcript shows a live waiting line; the status line is for problems.
+ session.setStatusMessage("");
wPrompt.setText("");
updateSendButton();
- wlStatus.setText(session.getStatusMessage());
+ setStatus(session.getStatusMessage());
transcript.showSession(session, this::reviewProposalsForTurn);
store.fireChanged();
AiAdvisorSession target = session;
- // Log widgets are SWT; read them on this UI thread before the background worker starts.
- final String logExcerpt =
- target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+ sentFromHere.add(target);
+ // Build the question here on the UI thread: the log widgets are SWT, and the pipeline or
+ // workflow may be edited while the model works. Only the model call runs in the background.
+ AiAdvisorEngine.Prepared prepared;
+ try {
+ String logExcerpt = target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+ prepared =
+ AiAdvisorEngine.prepare(
+ target, advisor, host.getVariables(), host.getMetadataProvider(), logExcerpt);
+ } catch (Exception e) {
+ completeTurn(target, turn, null, e);
+ return;
+ }
+ turn.setEstimatedPromptTokens(prepared.estimatedTokens());
+ turn.setProviderLabel(prepared.provider().getName());
+ startWorkingLine(target);
+ IVariables variables = host.getVariables();
+ Display display = getDisplay();
Thread worker =
BackgroundThreadFacade.start(
() -> {
+ AiAdvisorResponse response = null;
+ Throwable error = null;
try {
- AiAdvisorResponse response =
- AiAdvisorEngine.advise(
- target,
- advisor,
- host.getVariables(),
- host.getMetadataProvider(),
- logExcerpt);
- host.asyncExec(() -> completeTurn(target, turn, response, null));
+ response = AiAdvisorEngine.execute(target, advisor, variables, prepared);
} catch (Throwable t) {
if (t instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
- host.asyncExec(() -> completeTurn(target, turn, null, t));
+ error = t;
+ }
+ AiAdvisorResponse result = response;
+ Throwable failure = error;
+ Thread self = Thread.currentThread();
+ // Record the answer even when this window was closed in the meantime (unless the
+ // configuration cancels it on close): the session is shared with the other views
+ // and would otherwise stay "working" for good.
+ if (!display.isDisposed()) {
Review Comment:
**[bug]** Finish in the background does not survive closing Hop GUI, which the new option and tooltip say it does. The worker only records the answer via `display.asyncExec`. Closing the assistant window is fine (the display stays up), but exiting Hop disposes the display: a queued `asyncExec` is dropped, and this branch does nothing when `isDisposed()` is already true. `recordResult` never runs, so the session stays on the unanswered turn. `AiAdvisorSessionStore` saves on shell dispose, which is often before the HTTP call returns, so a late `recordResult` would also need an immediate `saveNow()`.
**Suggestion:** When the display is gone and this worker is still the session's worker (and the question was not cancelled), call `recordResult` directly from the worker and `saveNow()`. `recordResult` does not touch widgets.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@hop.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org
Re: [PR] AI Assistant hardening #8757 (hop)
Posted by "bamaer (via GitHub)" <gi...@apache.org>.
bamaer commented on code in PR #8784:
URL: https://github.com/apache/hop/pull/8784#discussion_r4210614772
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiAdvisorEngine.java:
##########
@@ -128,7 +483,10 @@ static AiAdvisorRequest toRequest(
request.setAttributes(copyAttributes(session.getAttributes()));
request.setInclusionSelections(copyInclusionSelections(session.getInclusionSelections()));
request.setFollowUp(hasSuccessfulPriorTurn(session));
- request.setAppliedChangeSummaries(session.consumePendingAppliedSummaries());
+ request.setAppliedChangeSummaries(
+ consumeApplied
+ ? session.consumePendingAppliedSummaries()
Review Comment:
Fixed. The request now gets a copy of the applied changes. They are taken off the session only when an answer is recorded, and only the ones that question was sent with, so changes applied while it waits stay for the next question.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/ui/AiAdvisorSessionPane.java:
##########
@@ -947,58 +1501,101 @@ private void send() {
}
HopAiConfig config = HopAiConfigSingleton.getConfig();
if (!config.isAiEnabled()) {
- wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.Disabled"));
return;
}
String prompt = wPrompt.getText();
- if (Utils.isEmpty(prompt)) {
+ if (Utils.isEmpty(prompt) || prompt.isBlank()) {
+ setStatus(BaseMessages.getString(PKG, "AiAdvisor.Status.NoQuestion"));
+ wPrompt.setFocus();
return;
}
IAiAdvisor advisor = loadSelectedAdvisor();
if (advisor == null) {
- wlStatus.setText(BaseMessages.getString(PKG, "AiAdvisor.Status.NoAdvisor"));
+ setStatus(noAdvisorMessage());
+ return;
+ }
+ if (!providerExists(wProvider.getText())) {
+ setStatus(
+ BaseMessages.getString(
+ PKG,
+ wProvider.getItemCount() == 0
+ ? "AiAdvisor.Status.NoProviderDefined"
+ : "AiAdvisor.Status.NoProviderSelected"));
return;
}
session.setAdvisorPluginId(selectedAdvisorId());
session.setScenarioId(selectedScenarioId());
session.setProviderName(wProvider.getText());
+ includeLogAfterARun();
for (Map.Entry<String, Button> entry : inclusionButtons.entrySet()) {
- session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+ // A blocked option keeps the user's choice for when the configuration allows it again.
+ if (!isBlockedByConfig(entry.getKey())) {
+ session.getInclusions().put(entry.getKey(), entry.getValue().getSelection());
+ }
}
AiAdvisorTurn turn = new AiAdvisorTurn();
turn.setUserPrompt(prompt);
+ turn.setStartedAtMillis(System.currentTimeMillis());
session.addTurn(turn);
session.setCancelled(false);
session.setWorking(true);
- session.setStatusMessage(BaseMessages.getString(PKG, "AiAdvisor.Status.Working"));
+ // The transcript shows a live waiting line; the status line is for problems.
+ session.setStatusMessage("");
wPrompt.setText("");
updateSendButton();
- wlStatus.setText(session.getStatusMessage());
+ setStatus(session.getStatusMessage());
transcript.showSession(session, this::reviewProposalsForTurn);
store.fireChanged();
AiAdvisorSession target = session;
- // Log widgets are SWT; read them on this UI thread before the background worker starts.
- final String logExcerpt =
- target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+ sentFromHere.add(target);
+ // Build the question here on the UI thread: the log widgets are SWT, and the pipeline or
+ // workflow may be edited while the model works. Only the model call runs in the background.
+ AiAdvisorEngine.Prepared prepared;
+ try {
+ String logExcerpt = target.getLogSupplier() != null ? target.getLogSupplier().get() : null;
+ prepared =
+ AiAdvisorEngine.prepare(
+ target, advisor, host.getVariables(), host.getMetadataProvider(), logExcerpt);
+ } catch (Exception e) {
+ completeTurn(target, turn, null, e);
+ return;
+ }
+ turn.setEstimatedPromptTokens(prepared.estimatedTokens());
+ turn.setProviderLabel(prepared.provider().getName());
+ startWorkingLine(target);
+ IVariables variables = host.getVariables();
+ Display display = getDisplay();
Thread worker =
BackgroundThreadFacade.start(
() -> {
+ AiAdvisorResponse response = null;
+ Throwable error = null;
try {
- AiAdvisorResponse response =
- AiAdvisorEngine.advise(
- target,
- advisor,
- host.getVariables(),
- host.getMetadataProvider(),
- logExcerpt);
- host.asyncExec(() -> completeTurn(target, turn, response, null));
+ response = AiAdvisorEngine.execute(target, advisor, variables, prepared);
} catch (Throwable t) {
if (t instanceof InterruptedException) {
Thread.currentThread().interrupt();
}
- host.asyncExec(() -> completeTurn(target, turn, null, t));
+ error = t;
+ }
+ AiAdvisorResponse result = response;
+ Throwable failure = error;
+ Thread self = Thread.currentThread();
+ // Record the answer even when this window was closed in the meantime (unless the
+ // configuration cancels it on close): the session is shared with the other views
+ // and would otherwise stay "working" for good.
+ if (!display.isDisposed()) {
Review Comment:
Agreed the option promised too much, but recording from the worker can't help here: `HopGui.main` calls `System.exit` as soon as `open()` returns, so the worker is killed. I dropped "or Hop GUI" from the tooltip and docs, which now say exiting always stops a waiting question. The session store marks that question as stopped before the final save on exit, so after a restart it says so instead of sitting unanswered.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/advisors/pipeline/PipelineAiProposalValidator.java:
##########
@@ -177,43 +222,75 @@ private static AiProposalValidation validateAddPipelineHop(
String fromName = proposal.parameter("fromTransform");
String toName = proposal.parameter("toTransform");
if (Utils.isEmpty(fromName) || Utils.isEmpty(toName)) {
- return blocked(proposal, "fromTransform and toTransform are required");
+ return blocked(
+ proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopEndsRequired"));
}
if (!transformExists(pipelineMeta, fromName, reservedNames)) {
- return blocked(proposal, "From transform not found: " + fromName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(
+ PKG, "PipelineAiProposalValidator.FromTransformNotFound", fromName));
}
if (!transformExists(pipelineMeta, toName, reservedNames)) {
- return blocked(proposal, "To transform not found: " + toName);
+ return blocked(
+ proposal,
+ BaseMessages.getString(PKG, "PipelineAiProposalValidator.ToTransformNotFound", toName));
}
TransformMeta from = pipelineMeta.findTransform(fromName);
TransformMeta to = pipelineMeta.findTransform(toName);
if (fromName.trim().equals(toName.trim())) {
- return blocked(proposal, "Hop cannot connect a transform to itself");
- }
+ return blocked(
+ proposal, BaseMessages.getString(PKG, "PipelineAiProposalValidator.HopToItself"));
+ }
+ // A pipeline cannot loop: the reverse hop, already there or proposed earlier, would. Not when
+ // the batch deletes it.
+ if ((from != null
Review Comment:
Fixed. The validator follows the hops as they will be once the batch is applied: existing hops minus the deleted ones, with the batch's renames and transform deletes, plus the hops added earlier in the batch. It blocks a loop of any length and names the path in the message, for example `C -> A -> B -> C`. Names are trimmed. Tests cover a loop through three transforms and one through a renamed transform.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/engine/AiNodeSettings.java:
##########
@@ -0,0 +1,146 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.engine;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.metadata.api.HopMetadataProperty;
+import org.apache.hop.metadata.util.ReflectionUtil;
+
+/**
+ * The settings of a transform or action, as compact JSON for a prompt.
+ *
+ * <p>Reads the {@code @HopMetadataProperty} fields: SQL, file names, connection names, field lists
+ * and so on, which is what a model needs to explain what a pipeline or workflow does. Password
+ * fields are skipped. Empty values, long texts and long lists are left out or shortened so one
+ * transform cannot take over the prompt.
+ */
+public final class AiNodeSettings {
+
+ static final int MAX_DEPTH = 3;
+ static final int MAX_LIST_ITEMS = 25;
+ static final int MAX_TEXT_CHARS = 400;
+ static final int MAX_NODE_CHARS = 3_000;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiNodeSettings() {}
+
+ /**
+ * @param node the transform or action meta
+ * @return a JSON object, {@code {"truncated":true}} when the settings are too large, or null when
+ * the plugin exposes no settings this way
+ */
+ public static String toJson(Object node) {
+ if (node == null) {
+ return null;
+ }
+ Object settings = read(node, 0);
+ if (!(settings instanceof Map<?, ?> map) || map.isEmpty()) {
+ return null;
+ }
+ try {
+ String json = MAPPER.writeValueAsString(settings);
+ if (json.length() > MAX_NODE_CHARS) {
+ return "{\"truncated\":true}";
+ }
+ return AiTextUtil.redactSecrets(json);
+ } catch (JsonProcessingException e) {
+ return null;
+ }
+ }
+
+ static Object read(Object value, int depth) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof String text) {
+ if (text.isEmpty()) {
+ return null;
+ }
+ return text.length() > MAX_TEXT_CHARS ? text.substring(0, MAX_TEXT_CHARS) + "…" : text;
+ }
+ if (value instanceof Number || value instanceof Boolean) {
+ return value;
+ }
+ if (value instanceof Enum<?> e) {
+ return e.name();
+ }
+ if (depth >= MAX_DEPTH) {
+ return null;
+ }
+ if (value instanceof Collection<?> collection) {
+ return readList(collection, depth);
+ }
+ if (value.getClass().isArray() && value instanceof Object[] array) {
+ return readList(List.of(array), depth);
+ }
+ return readObject(value, depth);
+ }
+
+ private static List<Object> readList(Collection<?> collection, int depth) {
+ List<Object> list = new ArrayList<>();
+ int count = 0;
+ for (Object item : collection) {
+ if (count == MAX_LIST_ITEMS) {
+ list.add("… " + (collection.size() - MAX_LIST_ITEMS) + " more");
+ break;
+ }
+ Object read = read(item, depth + 1);
+ if (read != null) {
+ list.add(read);
+ }
+ count++;
+ }
+ return list.isEmpty() ? null : list;
+ }
+
+ private static Map<String, Object> readObject(Object object, int depth) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ for (Field field : ReflectionUtil.findAllFields(object.getClass())) {
+ HopMetadataProperty property = field.getAnnotation(HopMetadataProperty.class);
+ if (property == null || property.password()) {
+ continue;
+ }
+ Object fieldValue;
+ try {
+ fieldValue =
+ ReflectionUtil.getFieldValue(object, field.getName(), isBoolean(field.getType()));
+ } catch (Exception e) {
+ continue;
+ }
+ Object read = read(fieldValue, depth + 1);
+ if (read == null || Boolean.FALSE.equals(read)) {
Review Comment:
Fixed. `false` is kept; only null and empty values are skipped.
##########
plugins/tech/ai/src/main/java/org/apache/hop/ai/session/AiAdvisorSessionArchive.java:
##########
@@ -0,0 +1,322 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.ai.session;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.apache.hop.ai.advisor.AiAdvisorMetadataSelection;
+import org.apache.hop.ai.advisor.AiProposal;
+import org.apache.hop.ai.engine.AiMetadataBackup;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.util.Utils;
+import org.apache.hop.history.AuditManager;
+import org.apache.hop.history.AuditState;
+
+/**
+ * Keeps the AI Assistant sessions of a project between Hop GUI runs, in the audit folder ({@code
+ * HOP_AUDIT_FOLDER}), where Hop GUI also remembers the open files of each project.
+ *
+ * <p>Questions, answers, proposals and the session options are kept. The context that was sent with
+ * a question is not: it is gathered again from the open file. The pipeline or workflow itself is
+ * found again by its file name when it is opened.
+ */
+public final class AiAdvisorSessionArchive {
+
+ static final String AUDIT_TYPE = "ai-assistant";
+ static final String AUDIT_NAME = "sessions";
+ static final String DEFAULT_GROUP = "hop-gui";
+
+ /** The newest sessions per project that are kept; older ones are dropped when saving. */
+ static final int MAX_SESSIONS = 50;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private AiAdvisorSessionArchive() {}
+
+ public static void save(String scope, List<AiAdvisorSession> sessions) throws HopException {
+ List<AiAdvisorSession> kept =
+ sessions.size() > MAX_SESSIONS
+ ? sessions.subList(sessions.size() - MAX_SESSIONS, sessions.size())
+ : sessions;
+ List<Map<String, Object>> list = new ArrayList<>();
+ for (AiAdvisorSession session : kept) {
+ list.add(toMap(session));
+ }
+ Map<String, Object> state = new LinkedHashMap<>();
+ try {
+ state.put("json", MAPPER.writeValueAsString(list));
+ } catch (Exception e) {
+ throw new HopException("Unable to write the AI Assistant sessions", e);
+ }
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new AuditState(AUDIT_NAME, state));
+ }
+
+ public static List<AiAdvisorSession> load(String scope) throws HopException {
+ List<AiAdvisorSession> sessions = new ArrayList<>();
+ AuditState state = AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, AUDIT_NAME);
+ if (state == null || state.getStateMap() == null) {
+ return sessions;
+ }
+ Object json = state.getStateMap().get("json");
+ if (!(json instanceof String text) || Utils.isEmpty(text)) {
+ return sessions;
+ }
+ try {
+ List<Map<String, Object>> list =
+ MAPPER.readValue(text, new TypeReference<List<Map<String, Object>>>() {});
+ for (Map<String, Object> map : list) {
+ AiAdvisorSession session = fromMap(map);
+ session.setScope(scope);
+ sessions.add(session);
+ }
+ } catch (Exception e) {
+ throw new HopException("Unable to read the saved AI Assistant sessions", e);
+ }
+ return sessions;
+ }
+
+ static final String LAST_PROVIDER_NAME = "last-provider";
+
+ /**
+ * Remember the provider picked last in a project, for new sessions when no default provider is
+ * configured. Kept even when conversations are not: it holds only a name.
+ */
+ public static void saveLastProvider(String scope, String providerName) throws HopException {
+ Map<String, Object> state = new LinkedHashMap<>();
+ state.put("name", providerName == null ? "" : providerName);
+ AuditManager.getActive()
+ .storeState(group(scope), AUDIT_TYPE, new AuditState(LAST_PROVIDER_NAME, state));
+ }
+
+ /** The provider picked last in a project, or null when none was remembered. */
+ public static String loadLastProvider(String scope) throws HopException {
+ AuditState state =
+ AuditManager.getActive().retrieveState(group(scope), AUDIT_TYPE, LAST_PROVIDER_NAME);
+ if (state == null
+ || state.getStateMap() == null
+ || !(state.getStateMap().get("name") instanceof String name)
+ || Utils.isEmpty(name)) {
+ return null;
+ }
+ return name;
+ }
+
+ static String group(String scope) {
+ return Utils.isEmpty(scope) ? DEFAULT_GROUP : scope;
+ }
+
+ static Map<String, Object> toMap(AiAdvisorSession session) {
+ Map<String, Object> map = new LinkedHashMap<>();
+ map.put("title", session.getTitle());
+ map.put("advisorPluginId", session.getAdvisorPluginId());
+ map.put("scenarioId", session.getScenarioId());
+ map.put("providerName", session.getProviderName());
+ map.put("location", session.getLocation());
+ map.put("areaLabel", session.getAreaLabel());
+ map.put("artifactName", session.getArtifactName());
+ map.put("artifactKind", session.getArtifactKind());
+ map.put("artifactFilename", session.getArtifactFilename());
+ map.put("focusNodeName", session.getFocusNodeName());
+ map.put("inclusions", new LinkedHashMap<>(session.getInclusions()));
+ map.put("userChosenInclusions", new ArrayList<>(session.getUserChosenInclusions()));
+ List<Map<String, String>> selections = new ArrayList<>();
+ for (AiAdvisorMetadataSelection selection : session.getMetadataSelections()) {
+ Map<String, String> entry = new LinkedHashMap<>();
+ entry.put("typeKey", selection.getTypeKey());
+ entry.put("name", selection.getName());
+ selections.add(entry);
+ }
+ map.put("metadataSelections", selections);
+ map.put("inclusionSelections", new LinkedHashMap<>(session.getInclusionSelections()));
+ List<Map<String, Object>> turns = new ArrayList<>();
+ for (AiAdvisorTurn turn : session.getTurns()) {
+ turns.add(toMap(turn));
+ }
+ map.put("turns", turns);
+ return map;
Review Comment:
Fixed. The pending list is saved with the session and restored on load, and the next question uses it in the same way.
##########
plugins/tech/ai/src/main/resources/org/apache/hop/ai/config/messages/messages_en_US.properties:
##########
@@ -17,15 +17,22 @@
HopAiConfig.Tab.Name=AI Assistant
HopAiConfigOptionPlugin.GitWarning.Label=Use Git (or similar version control) and create a feature branch before applying AI-driven changes.\nYou can then revert the whole project, or selected files, if things do not go as intended.
HopAiConfigOptionPlugin.GitWarning.Tooltip=Graph Undo only reverses some canvas edits. Metadata saves and pasted transforms or actions are not undone that way. A feature branch is the reliable rollback.
-HopAiConfigOptionPlugin.AiEnabled.Label=Enable AI advisory
-HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, AI Help is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.
+HopAiConfigOptionPlugin.AiEnabled.Label=Enable the AI Assistant
+HopAiConfigOptionPlugin.AiEnabled.Tooltip=When disabled, the AI Assistant is hidden and no prompts are sent. Default is off. Before applying AI changes, work on a Git feature branch so you can revert the project if needed.
HopAiConfigOptionPlugin.DefaultProvider.Label=Default AI provider
-HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata used by GUI advisors. Pipelines can still pick a different provider.
+HopAiConfigOptionPlugin.DefaultProvider.Tooltip=Named AI Provider metadata used by the AI Assistant. Pipelines can still pick a different provider.
HopAiConfigOptionPlugin.AllowFullXml.Label=Allow sending full XML
HopAiConfigOptionPlugin.AllowFullXml.Tooltip=Second gate for the session "Include full XML" checkbox. Off by default. Full XML is large and can contain SQL, filenames and notes. Leave this off unless you need it, and only after secrets live in environment variables or a keystore.
HopAiConfigOptionPlugin.ExtraContext.Label=Extra context
HopAiConfigOptionPlugin.ExtraContext.Tooltip=Standing notes sent with every AI Assistant question. The default reminds the model that metadata names are case-sensitive (`local` is not `Local`), that pipeline and workflow run configurations are different types, and that Hop is not Kettle. Add project conventions here.
HopAiConfigOptionPlugin.ExtraContextFiles.Label=Context files
-HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files that exist are appended to the system prompt; missing files are skipped. Default is '${PROJECT_HOME}/AGENTS.md'. Variables are resolved.
+HopAiConfigOptionPlugin.ExtraContextFiles.Tooltip=One VFS path per line. Files that exist are appended to the system prompt as instructions the assistant follows; missing files are skipped. Variables are resolved. Empty by default. Only list files you wrote for the AI Assistant, for example '${PROJECT_HOME}/ai-assistant.md': an AGENTS.md is usually written for coding agents, and a file from someone else's project can steer the assistant.
Review Comment:
Fixed, along with the generated config docs partials.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: issues-unsubscribe@hop.apache.org
For queries about this service, please contact Infrastructure at:
users@infra.apache.org