Skip to content
Merged
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 README.org
Original file line number Diff line number Diff line change
Expand Up @@ -1050,6 +1050,7 @@ always go to Evil modes if you need to with ~C-z~).
| agent-shell-openai-default-session-mode-id | Default Codex session mode ID. |
| agent-shell-opencode-acp-command | Command and parameters for the OpenCode client. |
| agent-shell-opencode-authentication | Configuration for OpenCode authentication. |
| agent-shell-opencode-default-config-options | Default OpenCode config options, applied at session start. |
| agent-shell-opencode-default-model-id | Default OpenCode model ID. |
| agent-shell-opencode-default-session-mode-id | Default OpenCode session mode ID. |
| agent-shell-opencode-environment | Environment variables for the OpenCode client. |
Expand Down
20 changes: 20 additions & 0 deletions agent-shell-config.el
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,26 @@ For example:
matches)
(car matches))))

(defun agent-shell--resolve-config-option (state option)
"Return the config option in STATE addressed by OPTION, or nil.

OPTION is matched against advertised ids first, then ACP categories, so
both what a shell lists under \"Available config options\" (\"effort\")
and the spec's category names (\"thought_level\") reach the same option.
Ids cast the wider net: an option outside the spec's categories, say
\"fast\", is only addressable by id.

For example, against an agent advertising an \"effort\" option
categorized as \"thought_level\":

(agent-shell--resolve-config-option state \"effort\")
=> \\='((:id . \"effort\") (:category . \"thought_level\") ...)

(agent-shell--resolve-config-option state \"thought_level\")
=> \\='((:id . \"effort\") (:category . \"thought_level\") ...)"
(or (agent-shell--config-option-get :state state :id option)
(agent-shell--config-option-by-category state option)))

(defun agent-shell--select-config-options (state)
"Return selectable (type = \"select\") config options from STATE."
(seq-filter (lambda (option)
Expand Down
26 changes: 26 additions & 0 deletions agent-shell-opencode.el
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,31 @@ when starting a new shell."
:type '(choice (const nil) string)
:group 'agent-shell)

(defcustom agent-shell-opencode-default-config-options
nil
"Default OpenCode config options, applied at session start.

An alist of (OPTION . VALUE). Both are the ids listed under
\"Available config options\" when starting a new shell, so whatever
OpenCode advertises can be set here without further agent-shell
changes. OPTION also accepts the ACP category names (\"model\",
\"mode\", \"thought_level\") for options carrying one.

OpenCode exposes model variants (provider-specific reasoning effort)
as its \"effort\" option:

(setq agent-shell-opencode-default-config-options
\\='((\"model\" . \"anthropic/claude-opus-4-5\")
(\"effort\" . \"high\")
(\"mode\" . \"plan\")))

Options are applied in the order listed. Order matters: OpenCode
scopes the available efforts to the active model, so \"effort\" belongs
after \"model\". An option or value OpenCode does not offer is
reported and skipped."
:type '(alist :key-type string :value-type string)
:group 'agent-shell)

(defcustom agent-shell-opencode-default-session-mode-id
nil
"Default OpenCode session mode ID.
Expand Down Expand Up @@ -129,6 +154,7 @@ Returns an agent configuration alist using `agent-shell-make-agent-config'."
(agent-shell-opencode-make-client :buffer buffer))
:default-model-id (lambda () agent-shell-opencode-default-model-id)
:default-session-mode-id (lambda () agent-shell-opencode-default-session-mode-id)
:default-config-options (lambda () agent-shell-opencode-default-config-options)
:install-instructions "See https://opencode.ai/docs for installation."))

;;;###autoload
Expand Down
194 changes: 192 additions & 2 deletions agent-shell.el
Original file line number Diff line number Diff line change
Expand Up @@ -710,6 +710,7 @@ Each element can be:
authenticate-request-maker
default-model-id
default-session-mode-id
default-config-options
session-meta
mcp-servers
notification-adapter
Expand All @@ -729,6 +730,17 @@ Keyword arguments:
- AUTHENTICATE-REQUEST-MAKER: Function to create authentication requests
- DEFAULT-MODEL-ID: Default model ID (function returning value).
- DEFAULT-SESSION-MODE-ID: Default session mode ID (function returning value).
- DEFAULT-CONFIG-OPTIONS: Default ACP session config options (function
returning an alist of (OPTION . VALUE), both strings). OPTION is
matched against the ids the agent advertises, falling back to ACP
categories (\"model\", \"mode\", \"thought_level\"). The categories
\"model\" and \"mode\" additionally reach agents advertising no config
options, via the same legacy requests DEFAULT-MODEL-ID and
DEFAULT-SESSION-MODE-ID use. Applied in the order listed, after
DEFAULT-MODEL-ID and DEFAULT-SESSION-MODE-ID, so an entry here wins
over either. Order matters: options an agent scopes to the active
model (thought level, for example) must follow the option selecting
that model.
- SESSION-META: Optional alist of agent-specific metadata sent as `_meta'
with session-creating requests (`session/new', `session/load',
`session/resume', and `session/fork').
Expand All @@ -751,6 +763,7 @@ Returns an alist with all specified values."
(:authenticate-request-maker . ,authenticate-request-maker) ;; function
(:default-model-id . ,default-model-id) ;; function
(:default-session-mode-id . ,default-session-mode-id) ;; function
(:default-config-options . ,default-config-options) ;; function
(:session-meta . ,session-meta)
(:mcp-servers . ,mcp-servers)
(:notification-adapter . ,notification-adapter) ;; function
Expand Down Expand Up @@ -1189,6 +1202,7 @@ OUTGOING-REQUEST-DECORATOR (passed through to `acp-make-client')."
(cons :authenticated nil)
(cons :set-model nil)
(cons :set-session-mode nil)
(cons :set-config-options nil)
(cons :session (list (cons :id nil)
(cons :config-options nil)
(cons :model-id nil)
Expand Down Expand Up @@ -2252,6 +2266,16 @@ Flow:
:on-mode-changed (lambda ()
(map-put! (agent-shell--state) :set-session-mode t)
(agent-shell--handle :command command :shell-buffer shell-buffer))))
;; Send ACP requests to set default config options (optional)
((and (map-nested-elt (agent-shell--state) '(:agent-config :default-config-options))
(funcall (map-nested-elt (agent-shell--state) '(:agent-config :default-config-options)))
(not (map-elt (agent-shell--state) :set-config-options)))
(agent-shell--set-default-config-options
:shell-buffer shell-buffer
:config-options (funcall (map-nested-elt (agent-shell--state) '(:agent-config :default-config-options)))
:on-options-set (lambda ()
(map-put! (agent-shell--state) :set-config-options t)
(agent-shell--handle :command command :shell-buffer shell-buffer))))
;; Initialization complete
(t
(agent-shell--emit-event :event 'init-finished)
Expand Down Expand Up @@ -4046,7 +4070,8 @@ For example, shut down ACP client."
(map-put! (agent-shell--state) :initialized nil)
(map-put! (agent-shell--state) :authenticated nil)
(map-put! (agent-shell--state) :set-model nil)
(map-put! (agent-shell--state) :set-session-mode nil))
(map-put! (agent-shell--state) :set-session-mode nil)
(map-put! (agent-shell--state) :set-config-options nil))
(agent-shell-heartbeat-stop
:heartbeat (map-elt (agent-shell--state) :heartbeat)))

Expand Down Expand Up @@ -6070,6 +6095,7 @@ Initialization events (emitted in order):
`init-session' - ACP session created
`init-model' - Default model set (optional)
`init-session-mode' - Default session mode set (optional)
`init-config-options' - Default config options applied (optional)
`session-list' - Session list fetch initiated
`session-prompt' - About to prompt user for session selection
`session-selected' - Session chosen (new or existing)
Expand Down Expand Up @@ -6615,6 +6641,162 @@ Call ON-MODE-CHANGED on success."
:on-failure (agent-shell--make-error-handler
:state (agent-shell--state) :shell-buffer shell-buffer))))

(defun agent-shell--default-config-option-values (state option)
"Return the value ids STATE advertises for OPTION.

The ACP categories \"model\" and \"mode\" read through the accessors
that unify config options with the legacy `models'/`modes' session
fields, so they cover agents advertising no config options at all.
Returns nil when OPTION is unknown to STATE, or constrains nothing.

For example:

(agent-shell--default-config-option-values state \"thought_level\")
=> \\='(\"low\" \"high\" \"max\")"
(pcase option
("model" (seq-map (lambda (model)
(map-elt model :model-id))
(agent-shell--get-available-models state)))
("mode" (seq-map (lambda (mode)
(map-elt mode :id))
(agent-shell--get-available-modes state)))
(_ (seq-map (lambda (value)
(map-elt value :value))
(map-elt (agent-shell--resolve-config-option state option) :options)))))

(defun agent-shell--default-config-option-addressable-p (state option)
"Return non-nil when STATE can be asked to set OPTION.

\"model\" and \"mode\" are always addressable: agents advertising no
config options still answer the legacy `session/set_model' and
`session/set_mode' requests. Any other OPTION has to resolve to an
advertised config option."
(or (member option '("model" "mode"))
(agent-shell--resolve-config-option state option)))

(defun agent-shell--default-config-option-settable-p (state option value)
"Return non-nil when STATE can be asked to set OPTION to VALUE.

An option enumerating no values (a free-form string option, or one an
agent only exposes over the legacy requests) accepts any VALUE."
(and (agent-shell--default-config-option-addressable-p state option)
(if-let* ((values (agent-shell--default-config-option-values state option)))
(member value values)
t)))

(defun agent-shell--default-config-option-skip-reason (state option)
"Explain why OPTION could not be set in STATE.

Names the ids the agent does offer, since agents advertise options
conditionally and scope their values to the active model.

For example:

(agent-shell--default-config-option-skip-reason state \"effort\")
=> \"agent offers low, high, max\"

(agent-shell--default-config-option-skip-reason state \"fast\")
=> \"agent advertises no fast option\""
(if-let* (((agent-shell--default-config-option-addressable-p state option))
(values (agent-shell--default-config-option-values state option)))
(format "agent offers %s" (string-join values ", "))
(format "agent advertises no %s option" option)))

(cl-defun agent-shell--set-default-config-options (&key shell-buffer config-options (first t) on-options-set)
"Apply CONFIG-OPTIONS in SHELL-BUFFER, one at a time, in order.

CONFIG-OPTIONS is an alist of (OPTION . VALUE), as described in
`agent-shell-make-agent-config'. Applying them in sequence (rather
than concurrently) lets an earlier entry determine what a later one can
choose from, since agents re-advertise their options on every change.

FIRST tracks whether the next entry opens the progress report, and is
managed by the recursion.

Call ON-OPTIONS-SET once the list is exhausted."
(if-let* ((entry (car config-options)))
(agent-shell--set-default-config-option
:shell-buffer shell-buffer
:option (car entry)
:value (cdr entry)
:first first
:on-option-set (lambda ()
(agent-shell--set-default-config-options
:shell-buffer shell-buffer
:config-options (cdr config-options)
:first nil
:on-options-set on-options-set)))
(agent-shell--emit-event :event 'init-config-options)
(when on-options-set
(funcall on-options-set))))

(cl-defun agent-shell--set-default-config-option (&key shell-buffer option value first on-option-set)
"Set config OPTION to VALUE in SHELL-BUFFER, then call ON-OPTION-SET.

Agents advertise options conditionally (thought levels only for models
supporting them, for example) and scope values to the active model, so
an unknown option or value is reported and skipped rather than
aborting initialization.

FIRST reports this as the opening line of the shared progress block,
which later entries append their own line to."
(when (map-nested-elt (agent-shell--state) '(:session :id))
(with-current-buffer (map-elt agent-shell--state :buffer)
(agent-shell--update-bootstrapping-fragment
:state (agent-shell--state)
:block-id "set-config-options"
:label-left (propertize "Setting config options" 'font-lock-face 'agent-shell-section-heading)
:body (format "%s%s: requesting %s..." (if first "" "\n") option value)
:append t))
(if (agent-shell--default-config-option-settable-p (agent-shell--state) option value)
(agent-shell--send-default-config-option
:shell-buffer shell-buffer
:option option
:value value
:on-sent (lambda ()
(agent-shell--update-bootstrapping-fragment
:state (agent-shell--state)
:block-id "set-config-options"
:body " done"
:append t)
(when on-option-set
(funcall on-option-set))))
(agent-shell--update-bootstrapping-fragment
:state (agent-shell--state)
:block-id "set-config-options"
:body (format " skipped (%s)"
(agent-shell--default-config-option-skip-reason (agent-shell--state) option))
:append t)
(when on-option-set
(funcall on-option-set)))))

(cl-defun agent-shell--send-default-config-option (&key shell-buffer option value on-sent)
"Ask the agent to set OPTION to VALUE, then call ON-SENT.

The ACP categories \"model\" and \"mode\" route through the setters
owning their legacy fallbacks, so an agent advertising no config
options is still reachable over `session/set_model' and
`session/set_mode'. Any other OPTION resolves to an advertised
config option and goes out as `session/set_config_option'.

SHELL-BUFFER is where a rejected request reports its error."
(let ((on-failure (agent-shell--make-error-handler
:state (agent-shell--state) :shell-buffer shell-buffer)))
(pcase option
("model" (agent-shell--config-option-set-model-id
:model-id value
:on-success on-sent
:on-failure on-failure))
("mode" (agent-shell--config-option-set-mode-id
:mode-id value
:on-success on-sent
:on-failure on-failure))
(_ (agent-shell--set-session-config-option
:config-id (map-elt (agent-shell--resolve-config-option (agent-shell--state) option) :id)
:value value
:on-success on-sent
:on-failure on-failure)))))

(cl-defun agent-shell--initiate-session (&key shell-buffer on-session-init)
"Initiate ACP session creation with SHELL-BUFFER.

Expand Down Expand Up @@ -6937,7 +7119,15 @@ overwrites an existing fragment with equivalent content."
:block-id "set-session-mode"
:label-left (propertize "Setting session mode"
'font-lock-face 'agent-shell-section-heading)
:body (format "Requesting %s..." mode-id))))
:body (format "Requesting %s..." mode-id)))
(when-let* ((options-fn (map-nested-elt state '(:agent-config :default-config-options)))
((funcall options-fn))
((not (map-elt state :set-config-options))))
(agent-shell--update-bootstrapping-fragment
:state state
:block-id "set-config-options"
:label-left (propertize "Setting config options"
'font-lock-face 'agent-shell-section-heading))))

(defun agent-shell--display-session-options ()
"Display available session options during bootstrapping."
Expand Down
47 changes: 47 additions & 0 deletions tests/agent-shell-opencode-tests.el
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
;;; agent-shell-opencode-tests.el --- Tests for agent-shell-opencode -*- lexical-binding: t; -*-

(require 'ert)
(require 'agent-shell)
(require 'agent-shell-opencode)

;;; Code:

(ert-deftest agent-shell-opencode-default-model-id-test ()
"Test that OpenCode config exposes default model id."
(let ((default-model-id-fn
(map-elt (agent-shell-opencode-make-agent-config) :default-model-id)))

(let ((agent-shell-opencode-default-model-id nil))
(should (null (funcall default-model-id-fn))))

(let ((agent-shell-opencode-default-model-id "anthropic/claude-opus-4-5"))
(should (string= (funcall default-model-id-fn) "anthropic/claude-opus-4-5")))))

(ert-deftest agent-shell-opencode-default-config-options-test ()
"Test that OpenCode config exposes default config options."
(let ((default-config-options-fn
(map-elt (agent-shell-opencode-make-agent-config) :default-config-options)))

(let ((agent-shell-opencode-default-config-options nil))
(should (null (funcall default-config-options-fn))))

(let ((agent-shell-opencode-default-config-options
'(("model" . "anthropic/claude-opus-4-5")
("effort" . "high"))))
(should (equal (funcall default-config-options-fn)
'(("model" . "anthropic/claude-opus-4-5")
("effort" . "high")))))))

(ert-deftest agent-shell-opencode-default-session-mode-id-test ()
"Test that OpenCode config exposes default session mode id."
(let ((default-session-mode-id-fn
(map-elt (agent-shell-opencode-make-agent-config) :default-session-mode-id)))

(let ((agent-shell-opencode-default-session-mode-id nil))
(should (null (funcall default-session-mode-id-fn))))

(let ((agent-shell-opencode-default-session-mode-id "plan"))
(should (string= (funcall default-session-mode-id-fn) "plan")))))

(provide 'agent-shell-opencode-tests)
;;; agent-shell-opencode-tests.el ends here
Loading
Loading