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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"dependencies": {
"@a2a-js/sdk": "^0.3.12",
"@anthropic-ai/claude-agent-sdk": "0.2.75",
"@anthropic-ai/claude-agent-sdk": "^0.2.77",
"zod": "^4.0.0"
},
"devDependencies": {
Expand Down
43 changes: 42 additions & 1 deletion src/com/tjclp/scalagent/Claude.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import com.tjclp.scalagent.config.*
import com.tjclp.scalagent.errors.*
import com.tjclp.scalagent.messages.*
import com.tjclp.scalagent.session.*
import com.tjclp.scalagent.types.SessionId
import com.tjclp.scalagent.types.{MessageUuid, SessionId}

/** Simplified entry point for the Claude Agent SDK.
*
Expand Down Expand Up @@ -269,6 +269,47 @@ object Claude:
.map(_.toOption.map(dyn => SessionInfo.fromRaw(dyn.asInstanceOf[js.Dynamic])))
.mapError(AgentError.fromThrowable)

/** Fork a session into a new branch with fresh UUIDs.
*
* The forked session can be resumed with [[ClaudeSession.resume]] or via the `resume` session mode.
*
* @param sessionId
* UUID of the source session to fork
* @param upToMessageId
* Slice the transcript up to this message UUID (inclusive). If omitted, the full transcript is copied.
* @param title
* Custom title for the fork. If omitted, derives from the original title + " (fork)".
* @param dir
* Optional project directory path
* @return
* The session ID of the newly forked session
*/
def forkSession(
sessionId: SessionId,
upToMessageId: Option[MessageUuid] = None,
title: Option[String] = None,
dir: Option[String] = None
): IO[AgentError, SessionId] =
val hasOptions = upToMessageId.isDefined || title.isDefined || dir.isDefined
val jsOpts: js.UndefOr[js.Dynamic] =
if hasOptions then
val opts = js.Dynamic.literal()
upToMessageId.foreach(id => opts.upToMessageId = id.value)
title.foreach(t => opts.title = t)
dir.foreach(d => opts.dir = d)
opts
else js.undefined
ZIO
.fromPromiseJS(SdkModule.forkSession(sessionId.value, jsOpts))
.flatMap { result =>
val sid = result.sessionId.asInstanceOf[js.UndefOr[String]].toOption
ZIO.fromOption(sid).mapError(_ =>
new RuntimeException("forkSession: missing sessionId in response")
)
}
.map(SessionId(_))
.mapError(AgentError.fromThrowable)

def getSessionMessages(sessionId: SessionId, dir: String): IO[AgentError, List[SessionMessage]] =
ZIO
.fromPromiseJS(
Expand Down
1 change: 1 addition & 0 deletions src/com/tjclp/scalagent/ClaudeAgent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -238,3 +238,4 @@ private[scalagent] object SdkModule extends js.Object:
def renameSession(sessionId: String, title: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[Unit] = js.native
def tagSession(sessionId: String, tag: String | Null, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[Unit] = js.native
def getSessionInfo(sessionId: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[js.UndefOr[js.Dynamic]] = js.native
def forkSession(sessionId: String, options: js.UndefOr[js.Dynamic] = js.undefined): js.Promise[js.Dynamic] = js.native
10 changes: 7 additions & 3 deletions src/com/tjclp/scalagent/a2a/A2AServer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -295,14 +295,17 @@ private final class A2AServerLive(config: A2AServer.Config, runtime: Runtime[Any
// Log completion
SessionLogger.logEvent(taskId.value, "completed", responseText.take(500))

// Publish response
bus.publish(jsMsg)
// Publish completed status BEFORE the message event.
// The A2A SDK's event queue breaks on whichever comes first
// ("message" or final "status-update"), so the status-update
// must come first to ensure the task store transitions to "completed".
publishStatusUpdate(
ctx,
bus,
com.tjclp.scalagent.a2a.TaskStatus.completed(responseMsg),
finalUpdate = true
)
bus.publish(jsMsg)
bus.finished()
}
.catchAll { error =>
Expand All @@ -315,13 +318,14 @@ private final class A2AServerLive(config: A2AServer.Config, runtime: Runtime[Any
// Log failure
SessionLogger.logEvent(taskId.value, "failed", errorText)

bus.publish(A2AConverters.toJs(errorMsg))
// Publish failed status BEFORE the message event (same ordering rationale).
publishStatusUpdate(
ctx,
bus,
com.tjclp.scalagent.a2a.TaskStatus.failed(errorMsg),
finalUpdate = true
)
bus.publish(A2AConverters.toJs(errorMsg))
bus.finished()
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/com/tjclp/scalagent/config/SandboxSettings.scala
Original file line number Diff line number Diff line change
Expand Up @@ -189,17 +189,25 @@ object SandboxNetworkConfig:
* Paths/patterns to deny write access
* @param denyRead
* Paths/patterns to deny read access
* @param allowRead
* Paths to re-allow reading within denyRead regions (takes precedence over denyRead)
* @param allowManagedReadPathsOnly
* When true, only allowRead paths from policySettings are used
*/
final case class SandboxFilesystemConfig(
allowWrite: List[String] = Nil,
denyWrite: List[String] = Nil,
denyRead: List[String] = Nil
denyRead: List[String] = Nil,
allowRead: List[String] = Nil,
allowManagedReadPathsOnly: Boolean = false
):
def toRaw: js.Object =
val obj = js.Dynamic.literal()
if allowWrite.nonEmpty then obj.allowWrite = allowWrite.toJSArray
if denyWrite.nonEmpty then obj.denyWrite = denyWrite.toJSArray
if denyRead.nonEmpty then obj.denyRead = denyRead.toJSArray
if allowRead.nonEmpty then obj.allowRead = allowRead.toJSArray
if allowManagedReadPathsOnly then obj.allowManagedReadPathsOnly = true
obj.asInstanceOf[js.Object]

object SandboxFilesystemConfig:
Expand Down
19 changes: 18 additions & 1 deletion src/com/tjclp/scalagent/hooks/HookCallback.scala
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,10 @@ object HookCallback:
permissionMode = permissionMode,
toolUseId = firstString(raw, "tool_use_id", "toolUseId").map(ToolUseId.apply),
agentId = baseAgentId,
hookAgentType = baseAgentType
hookAgentType = baseAgentType,
title = firstString(raw, "title"),
displayName = firstString(raw, "display_name", "displayName"),
description = firstString(raw, "description")
)

case "Notification" =>
Expand Down Expand Up @@ -250,6 +253,20 @@ object HookCallback:
permissionMode = permissionMode
)

case "PostCompact" =>
HookInput.PostCompact(
sessionId = sessionId,
cwd = cwd,
transcriptPath = transcriptPath,
trigger = CompactTrigger.fromString(
firstString(raw, "trigger").getOrElse("auto")
),
compactSummary = firstString(raw, "compact_summary", "compactSummary").getOrElse(""),
hookAgentId = baseAgentId,
hookAgentType = baseAgentType,
permissionMode = permissionMode
)

case "PreCompact" =>
HookInput.PreCompact(
sessionId = sessionId,
Expand Down
6 changes: 6 additions & 0 deletions src/com/tjclp/scalagent/hooks/HookEvent.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ enum HookEvent:
/** Before context compaction */
case PreCompact

/** After context compaction completes */
case PostCompact

/** Setup hook - triggered during initialization or maintenance */
case Setup

Expand Down Expand Up @@ -85,6 +88,7 @@ enum HookEvent:
case SubagentStart => "SubagentStart"
case SubagentStop => "SubagentStop"
case PreCompact => "PreCompact"
case PostCompact => "PostCompact"
case Setup => "Setup"
case TeammateIdle => "TeammateIdle"
case TaskCompleted => "TaskCompleted"
Expand All @@ -110,6 +114,7 @@ object HookEvent:
case "SubagentStart" => Right(SubagentStart)
case "SubagentStop" => Right(SubagentStop)
case "PreCompact" => Right(PreCompact)
case "PostCompact" => Right(PostCompact)
case "Setup" => Right(Setup)
case "TeammateIdle" => Right(TeammateIdle)
case "TaskCompleted" => Right(TaskCompleted)
Expand All @@ -135,6 +140,7 @@ object HookEvent:
case "SubagentStart" => SubagentStart
case "SubagentStop" => SubagentStop
case "PreCompact" => PreCompact
case "PostCompact" => PostCompact
case "Setup" => Setup
case "TeammateIdle" => TeammateIdle
case "TaskCompleted" => TaskCompleted
Expand Down
21 changes: 19 additions & 2 deletions src/com/tjclp/scalagent/hooks/HookInput.scala
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ object HookInput:
permissionMode: Option[PermissionMode] = None,
toolUseId: Option[ToolUseId] = None,
agentId: Option[SubagentId] = None,
hookAgentType: Option[String] = None
hookAgentType: Option[String] = None,
title: Option[String] = None,
displayName: Option[String] = None,
description: Option[String] = None
) extends HookInput:
def hookAgentId: Option[SubagentId] = agentId

Expand Down Expand Up @@ -195,6 +198,18 @@ object HookInput:
@deprecated("Use agentType", "0.2.63")
def subagentType: String = agentType

/** Input for PostCompact hook - after context compaction completes */
final case class PostCompact(
sessionId: SessionId,
cwd: String,
transcriptPath: String,
trigger: CompactTrigger,
compactSummary: String,
hookAgentId: Option[SubagentId] = None,
hookAgentType: Option[String] = None,
permissionMode: Option[PermissionMode] = None
) extends HookInput

/** Input for PreCompact hook - before context compaction */
final case class PreCompact(
sessionId: SessionId,
Expand Down Expand Up @@ -506,14 +521,15 @@ object MemoryType:

/** Load reason for instructions loaded hook */
enum InstructionsLoadReason:
case SessionStart, NestedTraversal, PathGlobMatch, Include
case SessionStart, NestedTraversal, PathGlobMatch, Include, Compact
case Custom(value: String)

def toRaw: String = this match
case SessionStart => "session_start"
case NestedTraversal => "nested_traversal"
case PathGlobMatch => "path_glob_match"
case Include => "include"
case Compact => "compact"
case Custom(v) => v

object InstructionsLoadReason:
Expand All @@ -525,6 +541,7 @@ object InstructionsLoadReason:
case "nested_traversal" => NestedTraversal
case "path_glob_match" => PathGlobMatch
case "include" => Include
case "compact" => Compact
case other => Custom(other)

/** Source of a configuration change */
Expand Down
11 changes: 11 additions & 0 deletions src/com/tjclp/scalagent/messages/AgentMessage.scala
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,17 @@ enum AgentMessage:
summary: Option[String] = None
)

/** API retry notification - emitted when a retryable error occurs and the request will be retried */
case ApiRetry(
attempt: Int,
maxRetries: Int,
retryDelayMs: Long,
errorStatus: Option[Int],
error: AssistantMessageError,
uuid: MessageUuid,
sessionId: SessionId
)

/** Forward-compatible fallback for unknown top-level SDK messages */
case Unknown(
envelope: UnknownEnvelope
Expand Down
15 changes: 15 additions & 0 deletions src/com/tjclp/scalagent/streaming/MessageConverter.scala
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,10 @@ object MessageConverter:
case Some("task_started") => parseTaskStarted(obj, raw)
case Some("local_command_output") => parseLocalCommandOutput(obj, raw)
case Some("elicitation_complete") => parseElicitationComplete(obj, raw)
case Some("api_retry") =>
guardTopLevelUnknown(raw, "system", Some("api_retry"), context) {
parseApiRetryMessage(obj, raw)
}
case other =>
AgentMessage.System(
event = parseSystemEvent(obj, raw, other, context),
Expand Down Expand Up @@ -409,6 +413,17 @@ object MessageConverter:
prompt = stringField(obj, "prompt")
)

private def parseApiRetryMessage(obj: js.Dynamic, raw: Json): AgentMessage.ApiRetry =
AgentMessage.ApiRetry(
attempt = intField(obj, "attempt").getOrElse(0),
maxRetries = intField(obj, "max_retries").getOrElse(0),
retryDelayMs = longField(obj, "retry_delay_ms").getOrElse(0L),
errorStatus = intField(obj, "error_status"),
error = stringField(obj, "error").map(AssistantMessageError.fromString).getOrElse(AssistantMessageError.Unknown),
uuid = requiredUuid(obj, raw),
sessionId = requiredSessionId(obj, raw)
)

private def parseTaskProgress(obj: js.Dynamic, raw: Json): AgentMessage.TaskProgress =
AgentMessage.TaskProgress(
taskId = requiredString(obj, "task_id", raw),
Expand Down
20 changes: 18 additions & 2 deletions src/com/tjclp/scalagent/streaming/QueryStream.scala
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ trait RawQuery extends AsyncGenerator[js.Any, Unit, Unit]:
/** Get the full initialization result including commands, models, account info */
def initializationResult(): js.Promise[js.Dynamic] = js.native

/** Apply settings mid-session (only available in streaming input mode) */
def applyFlagSettings(settings: js.Dynamic): js.Promise[Unit] = js.native

/** Wrapper for SDK Query that provides ZIO/ZStream interface.
*
* This class wraps the raw JavaScript Query object and provides:
Expand Down Expand Up @@ -355,6 +358,17 @@ final class QueryStream private (rawQuery: RawQuery):
.fromPromiseJS(rawQuery.initializationResult())
.map(InitializationResult.fromRaw)

/** Apply settings mid-session, dynamically updating the active configuration.
*
* Equivalent to passing a `settings` object to `query()` but applies during an ongoing session.
* Only available in streaming input mode.
*
* @param settings
* A raw JS settings object to merge into the flag settings layer
*/
def applyFlagSettings(settings: js.Dynamic): Task[Unit] =
ZIO.fromPromiseJS(rawQuery.applyFlagSettings(settings))

object QueryStream:

/** Create a QueryStream from a raw SDK Query object.
Expand Down Expand Up @@ -486,7 +500,8 @@ final case class AccountInfo(
organization: Option[String],
subscriptionType: Option[String],
tokenSource: Option[String],
apiKeySource: Option[String]
apiKeySource: Option[String],
apiProvider: Option[String] = None
)

object AccountInfo:
Expand All @@ -496,7 +511,8 @@ object AccountInfo:
organization = obj.organization.asInstanceOf[js.UndefOr[String]].toOption,
subscriptionType = obj.subscriptionType.asInstanceOf[js.UndefOr[String]].toOption,
tokenSource = obj.tokenSource.asInstanceOf[js.UndefOr[String]].toOption,
apiKeySource = obj.apiKeySource.asInstanceOf[js.UndefOr[String]].toOption
apiKeySource = obj.apiKeySource.asInstanceOf[js.UndefOr[String]].toOption,
apiProvider = obj.apiProvider.asInstanceOf[js.UndefOr[String]].toOption
)

/** Result of a rewindFiles operation */
Expand Down
Loading
Loading